@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
@@ -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)
@@ -9,7 +9,7 @@
9
9
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
10
10
  export function parseSourcesArgs(args) {
11
11
  const values = parseNamedArgs(args, {
12
- allowedFlags: ["--older-than", "--home", "--dashboard-url", "--json"],
12
+ allowedFlags: ["--older-than", "--home", "--dashboard-url", "--json", "--reconcile"],
13
13
  valueFlags: ["--older-than", "--home", "--dashboard-url"],
14
14
  });
15
15
  const action = values.positionals[0] ?? "check";
@@ -17,6 +17,7 @@ export function parseSourcesArgs(args) {
17
17
  throw new Error("sources has one verb: `cockpit sources check [--older-than <days>] [--json]`.");
18
18
  }
19
19
  return {
20
+ reconcile: values.booleans.has("--reconcile"),
20
21
  kind: "sources",
21
22
  action: "check",
22
23
  olderThanDays: optionalPositiveInteger(values.flags.get("--older-than"), "--older-than") ?? null,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `cockpit milestone` argument parsing (BLI-22360).
3
+ *
4
+ * list [--project <ref>] [--archived]
5
+ * show <ref> [--project <ref>] [--limit <n>] [--cursor <c>]
6
+ * create --project <ref> --name "<n>" [--description <text|->] [--target YYYY-MM-DD]
7
+ * update <ref> [--project <ref>] [--name <n>] [--description <text|-|none>] [--target <day|none>] [--expected-updated-at <ts>]
8
+ * archive|restore <ref> [--project <ref>] [--expected-updated-at <ts>]
9
+ * add|remove <ref> <ticket> [<ticket> ...] [--project <ref>]
10
+ *
11
+ * A milestone ref is an id or an exact, case-insensitive name; two milestones with one name are refused by the door
12
+ * (`milestone_ref_ambiguous`) with their ids, never guessed. `--request-id` belongs to every write.
13
+ */
14
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
15
+ const ACTIONS = ["list", "show", "create", "update", "archive", "restore", "add", "remove"];
16
+ const WRITES = ["create", "update", "archive", "restore", "add", "remove"];
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
19
+ export const MILESTONE_TICKETS_MAX = 50;
20
+ function target(value, action) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ if (value.trim().toLowerCase() === "none") {
24
+ if (action !== "update")
25
+ throw new Error("--target none clears a date on `milestone update`; leave --target off instead.");
26
+ return null;
27
+ }
28
+ if (!DAY.test(value) || new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) !== value) {
29
+ throw new Error("--target must be a calendar date as YYYY-MM-DD, or none.");
30
+ }
31
+ return value;
32
+ }
33
+ export function parseMilestoneArgs(args) {
34
+ const values = parseNamedArgs(args, {
35
+ allowedFlags: [
36
+ "--home", "--dashboard-url", "--json", "--archived", "--project", "--name", "--description", "--target",
37
+ "--limit", "--cursor", "--request-id", "--expected-updated-at",
38
+ ],
39
+ valueFlags: [
40
+ "--home", "--dashboard-url", "--project", "--name", "--description", "--target",
41
+ "--limit", "--cursor", "--request-id", "--expected-updated-at",
42
+ ],
43
+ });
44
+ const first = values.positionals[0];
45
+ const action = (first === undefined ? "list" : first);
46
+ if (!ACTIONS.includes(action)) {
47
+ throw new Error(`Unknown milestone command: ${first}. Try list, show, create, update, archive, restore, add or remove.`);
48
+ }
49
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
50
+ const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
51
+ let ref;
52
+ let tickets = [];
53
+ if (action === "list" || action === "create") {
54
+ if (rest.length > 0)
55
+ throw new Error(`milestone ${action} does not take "${rest[0]}".`);
56
+ }
57
+ else if (action === "add" || action === "remove") {
58
+ if (rest.length < 2)
59
+ throw new Error(`milestone ${action} takes a milestone and at least one ticket: milestone ${action} <milestone> BLI-123 [BLI-124 ...].`);
60
+ ref = rest[0];
61
+ tickets = [...new Set(rest.slice(1).flatMap((t) => t.split(",")).map((t) => t.trim()).filter(Boolean))];
62
+ if (tickets.length > MILESTONE_TICKETS_MAX)
63
+ throw new Error(`At most ${MILESTONE_TICKETS_MAX} tickets per call.`);
64
+ }
65
+ else {
66
+ if (rest.length !== 1)
67
+ throw new Error(`milestone ${action} takes exactly one milestone id or name.`);
68
+ ref = rest[0];
69
+ }
70
+ if (action !== "create" && action !== "update" && (given("--name") || given("--description") || given("--target"))) {
71
+ throw new Error("--name, --description and --target belong to milestone create and update.");
72
+ }
73
+ if (action !== "list" && given("--archived"))
74
+ throw new Error("--archived belongs to milestone list.");
75
+ if (action !== "show" && (given("--limit") || given("--cursor")))
76
+ throw new Error("--limit and --cursor belong to milestone show.");
77
+ if (!WRITES.includes(action) && given("--request-id"))
78
+ throw new Error("--request-id belongs to the milestone writes.");
79
+ if (!["update", "archive", "restore"].includes(action) && given("--expected-updated-at")) {
80
+ throw new Error("--expected-updated-at belongs to milestone update, archive and restore.");
81
+ }
82
+ const project = optionalNonEmpty(values.flags.get("--project"));
83
+ const name = optionalNonEmpty(values.flags.get("--name"));
84
+ if (action === "create" && (!project || !name))
85
+ throw new Error('milestone create needs --project <project> and --name "<name>".');
86
+ const rawDescription = values.flags.get("--description");
87
+ const description = rawDescription === undefined ? undefined
88
+ : action === "update" && rawDescription.trim().toLowerCase() === "none" ? null : rawDescription;
89
+ const targetDate = target(values.flags.get("--target"), action);
90
+ if (action === "update" && name === undefined && description === undefined && targetDate === undefined) {
91
+ throw new Error("milestone update needs --name, --description (or none) or --target (or none). Archive with `milestone archive`.");
92
+ }
93
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
94
+ if (requestId && !UUID.test(requestId))
95
+ throw new Error("--request-id must be a UUID.");
96
+ const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
97
+ if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
98
+ throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
99
+ }
100
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
101
+ const cursor = optionalNonEmpty(values.flags.get("--cursor"));
102
+ return {
103
+ kind: "milestone",
104
+ action,
105
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
106
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
107
+ ...(ref === undefined ? {} : { ref }),
108
+ ...(project ? { project } : {}),
109
+ includeArchived: values.booleans.has("--archived"),
110
+ ...(name ? { name } : {}),
111
+ ...(description === undefined ? {} : { description }),
112
+ ...(targetDate === undefined ? {} : { targetDate }),
113
+ tickets,
114
+ ...(limit === undefined ? {} : { limit }),
115
+ ...(cursor ? { cursor } : {}),
116
+ ...(requestId ? { requestId } : {}),
117
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
118
+ json: values.booleans.has("--json"),
119
+ };
120
+ }
@@ -23,6 +23,7 @@ import { NOTIFY_ACTIONS, NOTIFY_VALUE_FLAGS, isNotifyAction, parseNotifyArgs } f
23
23
  import { VIEW_BOOLEAN_FLAGS, VIEW_VALUE_FLAGS, parseViewArgs } from "./view-args.js";
24
24
  import { parseTemplateArgs } from "./template-args.js";
25
25
  import { CYCLE_BOOLEAN_FLAGS, CYCLE_VALUE_FLAGS, parseCycleArgs, readCycleField } from "./cycle-args.js";
26
+ import { readArchivedFlag, readIssuePage } from "./issue-page-args.js";
26
27
  const ISSUE_ACTIONS = new Set([
27
28
  "mutation-status",
28
29
  "board",
@@ -37,6 +38,9 @@ const ISSUE_ACTIONS = new Set([
37
38
  "restore",
38
39
  "label",
39
40
  "batch",
41
+ // BLI-22367: reversible archive. `restore` stays the description-version verb.
42
+ "archive",
43
+ "unarchive",
40
44
  "relations",
41
45
  "relate",
42
46
  "unrelate",
@@ -56,6 +60,8 @@ const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
56
60
  "versions",
57
61
  "restore",
58
62
  "label",
63
+ "archive",
64
+ "unarchive",
59
65
  "relations",
60
66
  "relate",
61
67
  "unrelate",
@@ -99,6 +105,7 @@ const ISSUE_ALLOWED_FLAGS = [
99
105
  "--sort",
100
106
  "--dir",
101
107
  "--all",
108
+ "--archived",
102
109
  "--body-stdin",
103
110
  "--json",
104
111
  ...VIEW_BOOLEAN_FLAGS,
@@ -174,9 +181,10 @@ export function parseIssueArgs(args) {
174
181
  throw new Error("--request-id must be a UUID.");
175
182
  if (action === "mutation-status" && !UUID_PATTERN.test(issueRef ?? ""))
176
183
  throw new Error("mutation-status needs a request UUID.");
177
- if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore", "label", "batch", "relate", "unrelate"].includes(action)) {
178
- throw new Error("Write-safety flags belong to create, update, move, comment, restore, label, relate, unrelate or batch.");
184
+ if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore", "label", "batch", "relate", "unrelate", "archive", "unarchive"].includes(action)) {
185
+ throw new Error("Write-safety flags belong to create, update, move, comment, restore, label, relate, unrelate, archive, unarchive or batch.");
179
186
  }
187
+ const archived = readArchivedFlag(action, values);
180
188
  validateIssueState(moveState, "issue move state must be one of: ");
181
189
  const stateFilter = readIssueStateFilter(action, values);
182
190
  const fields = readIssueFields(action, values);
@@ -192,61 +200,18 @@ export function parseIssueArgs(args) {
192
200
  if (action === "board" && boardProject && !UUID_PATTERN.test(boardProject))
193
201
  throw new Error("board --project requires a uuid.");
194
202
  const priority = readIssuePriority(values);
195
- const estimate = readIssueSize(values);
203
+ const estimate = readIssueSize(action, values);
204
+ checkNoneClears(action, values);
196
205
  const assignee = readIssueAssignee(action, values);
197
206
  const limit = readIssueLimit(action, values);
198
207
  const versionNumber = readIssueVersion(action, values);
199
- return { ...buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page, fields), ...readRelationFlags(action, values) };
200
- }
201
- const INSTANT = /^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d:\d\d))?$/;
202
- /** BLI-21315: the cursor contract's flags. Filters and sort belong to `list`; `--cursor` also to `history`. */
203
- function readIssuePage(action, values) {
204
- const named = ["--q", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"];
205
- for (const flag of named) {
206
- if (values.flags.has(flag) && action !== "list")
207
- throw new Error(`${flag} belongs to \`cockpit ticket list\`.`);
208
- }
209
- if (values.booleans.has("--all") && action !== "list")
210
- throw new Error("--all belongs to `cockpit ticket list`.");
211
- if (values.flags.has("--cursor") && action !== "list" && action !== "history") {
212
- throw new Error("--cursor belongs to `cockpit ticket list` and `cockpit ticket history`.");
213
- }
214
- const instants = {};
215
- for (const flag of ["--created-after", "--created-before", "--updated-after", "--updated-before"]) {
216
- const value = optionalNonEmpty(values.flags.get(flag));
217
- if (value !== undefined && (!INSTANT.test(value) || !Number.isFinite(Date.parse(value)))) {
218
- throw new Error(`${flag} must be a UTC instant like 2026-10-01T00:00:00Z or a date like 2026-10-01.`);
219
- }
220
- instants[flag] = value;
221
- }
222
- const sort = optionalNonEmpty(values.flags.get("--sort"));
223
- if (sort !== undefined && sort !== "updated_at" && sort !== "created_at")
224
- throw new Error("--sort must be updated_at or created_at.");
225
- const dir = optionalNonEmpty(values.flags.get("--dir"));
226
- if (dir !== undefined && dir !== "asc" && dir !== "desc")
227
- throw new Error("--dir must be asc or desc.");
228
- const all = values.booleans.has("--all");
229
- const cursor = optionalNonEmpty(values.flags.get("--cursor"));
230
- if (all && cursor)
231
- throw new Error("--all walks from the start; drop --cursor, or pass --cursor without --all to read one page.");
232
- const q = optionalNonEmpty(values.flags.get("--q"));
233
- return {
234
- ...(cursor ? { cursor } : {}),
235
- ...(q ? { q } : {}),
236
- ...(instants["--created-after"] ? { createdAfter: instants["--created-after"] } : {}),
237
- ...(instants["--created-before"] ? { createdBefore: instants["--created-before"] } : {}),
238
- ...(instants["--updated-after"] ? { updatedAfter: instants["--updated-after"] } : {}),
239
- ...(instants["--updated-before"] ? { updatedBefore: instants["--updated-before"] } : {}),
240
- ...(sort ? { sort } : {}),
241
- ...(dir ? { dir } : {}),
242
- ...(all ? { all } : {}),
243
- };
208
+ return { ...buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page, fields), ...readRelationFlags(action, values), ...(archived ? { archived } : {}) };
244
209
  }
245
210
  function readIssueAction(values) {
246
211
  const first = values.positionals[0];
247
212
  const action = (first === undefined ? "list" : first);
248
213
  if (!ISSUE_ACTIONS.has(action)) {
249
- throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, label, relations, relate, unrelate, views, templates, cycles, inbox, read, unread, subscribe, unsubscribe, notifications, history, versions, restore, batch, or mutation-status.`);
214
+ throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, label, archive, unarchive, relations, relate, unrelate, views, templates, cycles, inbox, read, unread, subscribe, unsubscribe, notifications, history, versions, restore, batch, or mutation-status.`);
250
215
  }
251
216
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
252
217
  }
@@ -318,9 +283,37 @@ function readIssuePriority(values) {
318
283
  }
319
284
  return priority;
320
285
  }
321
- function readIssueSize(values) {
286
+ /**
287
+ * `--size S|M|L`, or `none` on update to clear the size (BLI-22367). Only ever what the person typed: no surface
288
+ * picks a size for them.
289
+ */
290
+ function readIssueSize(action, values) {
322
291
  const raw = optionalNonEmpty(values.flags.get("--size"));
323
- return raw === undefined ? undefined : estimateForSize(raw);
292
+ if (raw === undefined)
293
+ return undefined;
294
+ if (raw.toLowerCase() === "none") {
295
+ if (action !== "update")
296
+ throw new Error("--size none clears a size on `cockpit ticket update`; leave --size off to create without one.");
297
+ return null;
298
+ }
299
+ return estimateForSize(raw);
300
+ }
301
+ /**
302
+ * ONE spelling clears a field on update: `none` (BLI-22367). `--parent none` and `--project none` join the existing
303
+ * `--assignee none`, `--due none`, `--cycle none` and `--size none`. On create there is nothing to clear, so `none`
304
+ * is refused rather than looked up as a ticket or project called "none".
305
+ */
306
+ function checkNoneClears(action, values) {
307
+ for (const flag of ["--parent", "--project"]) {
308
+ const value = optionalNonEmpty(values.flags.get(flag));
309
+ if (value?.toLowerCase() !== "none")
310
+ continue;
311
+ if (action === "update")
312
+ continue;
313
+ throw new Error(action === "create"
314
+ ? `${flag} none clears it on \`cockpit ticket update\`; leave ${flag} off to create without one.`
315
+ : `${flag} none clears it on \`cockpit ticket update\` only.`);
316
+ }
324
317
  }
325
318
  function readIssueAssignee(action, values) {
326
319
  // An assignee is `me`, `unassigned`, or a person's uuid. A name or an
@@ -33,6 +33,7 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
33
33
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
34
34
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
35
35
  export { parseGithubArgs } from "./local-args-tower-github.js";
36
+ export { parseMilestoneArgs } from "./local-args-tower-milestone.js";
36
37
  export { parseSearchArgs } from "./local-args-tower-search.js";
37
38
  export { parseModelsArgs } from "./local-args-tower-models.js";
38
39
  export { parseMailArgs } from "./local-args-tower-mail.js";
@@ -19,6 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
19
19
  import { parseSpendArgs } from "./spend.js";
20
20
  import { parsePushArgs } from "./push.js";
21
21
  import { parseRootsArgs } from "./roots.js";
22
+ import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
22
23
  import { SURFACE_ALIASES } from "./local-help.js";
23
24
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
24
25
  import { parseCiArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
@@ -90,6 +91,7 @@ const COMMAND_PARSERS = new Map([
90
91
  ["mail", (_command, rest) => parseMailArgs(rest)],
91
92
  ["cal", (_command, rest) => parseCalArgs(rest)],
92
93
  ["project", (_command, rest) => parseProjectArgs(rest)],
94
+ ["milestone", (_command, rest) => parseMilestoneArgs(rest)],
93
95
  ["search", (_command, rest) => parseSearchArgs(rest)],
94
96
  ["github", (_command, rest) => parseGithubArgs(rest)],
95
97
  ["release", (_command, rest) => parseReleaseArgs(rest)],
@@ -0,0 +1 @@
1
+ export {};
@@ -226,10 +226,10 @@ export const TOWER_OPS_COMMAND_HELP = [
226
226
  " \"YYYY-MM-DD: \" dates a bullet, without it the bullet is undated. none clears --next or --blocker. blocked needs a blocker (given, or already on the card),",
227
227
  " and a blocker goes only on a Blocked card: sent with any other health it is refused (summary_blocker_needs_blocked); sending only a new health drops the stored one.",
228
228
  " --version is summary.updatedAt from `home <client> --json`, or none when the client has no card yet (then --health, --headline and at least one --bullet are all needed).",
229
- " Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is marked as yours and stands until the client's",
230
- " data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes the card again (newest wins).",
229
+ " Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is stored as written by your agent",
230
+ " (source agent, \"Written by <you>'s agent\"): Tower's check never rewrites it, whatever changes; your agent's next edit, or a person's on the page, replaces it.",
231
231
  " A saved card that says On track while an open action is more than 14 days overdue is saved anyway and prints a \"Warning: ...\" line naming the action overdue longest.",
232
- " Write the card after your item writes: your own later item writes count as data changes, and the next check rewrites the card.",
232
+ " Write the card after your item writes: the check will not rewrite it to take them in.",
233
233
  " Health rule: an open action more than 14 days overdue, as `cockpit manager home` shows it (Tower's today, America/Vancouver), means the card is at_risk (or blocked, with a named blocker).",
234
234
  " Do not set on_track over one; tell the person instead. The headline or a bullet must name the action overdue longest, with the day it was due.",
235
235
  " cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name>|--no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]",
@@ -261,7 +261,7 @@ export const TOWER_OPS_COMMAND_HELP = [
261
261
  " --version and --timeline stay flags. A field given as a flag and in the file is refused, so is an unknown key. The file gets the flags' own checks. Example: cat fields.json | cockpit manager summary Acme --version none --fields-file -",
262
262
  "Free text (the flags above and every value in a fields file) that holds a provider key (an sk-, rk_, sb_secret_, ghp_, github_pat_, AKIA/ASIA, xoxb- key, any JWT, a PEM private key) or a password given a value (\"password: x\") is refused, naming the flag or key; ordinary words, a commit sha, a URL and the words \"service-role key\" are plain text.",
263
263
  "On a flag, free text (--headline, --bullet, --title, --detail, --next, --blocker, --owners, --source-note) goes in SINGLE quotes so the shell leaves $ alone: \"$12k\" in double quotes loses the $12k (zsh and bash read it as a variable). In bash and zsh write an apostrophe inside single quotes as '\\'' (end the quote, a backslash-escaped apostrophe, reopen); in PowerShell double the apostrophe ('Rowan''s deck'). The command prints what it saved: check it is your words.",
264
- "<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are reviewed already.",
264
+ "<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add here are stored as added by your agent, not yet checked (they count in needs review until a person approves or dismisses them), never as you having checked them; text tagged [HR] is refused (item_hr_tagged, summary_hr_tagged).",
265
265
  "--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
266
266
  "since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
267
267
  "--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",