@bli-cockpit/cli 0.2.242 → 0.2.244

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 (135) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +6 -50
  4. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  5. package/dist/agent-rules.js +8 -0
  6. package/dist/autostart-contract.js +12 -0
  7. package/dist/autostart-darwin-plist.js +2 -2
  8. package/dist/autostart-darwin.js +1 -1
  9. package/dist/autostart-remind.js +245 -0
  10. package/dist/autostart-self-heal.js +17 -1
  11. package/dist/autostart-windows-scripts.js +7 -7
  12. package/dist/autostart.js +16 -15
  13. package/dist/commands/approvals-check-where.js +14 -0
  14. package/dist/commands/approvals-render.js +8 -1
  15. package/dist/commands/approvals.js +3 -2
  16. package/dist/commands/autostart-command.js +10 -0
  17. package/dist/commands/autostart-heal.js +36 -1
  18. package/dist/commands/brain.js +1 -1
  19. package/dist/commands/cal-agenda.js +44 -0
  20. package/dist/commands/cal-availability-share.js +97 -0
  21. package/dist/commands/cal-booking.js +147 -0
  22. package/dist/commands/cal-join.js +62 -0
  23. package/dist/commands/cal-links.js +68 -0
  24. package/dist/commands/cal-people.js +136 -0
  25. package/dist/commands/cal-remind.js +176 -0
  26. package/dist/commands/cal-reminders.js +33 -0
  27. package/dist/commands/cal-suggest.js +89 -0
  28. package/dist/commands/cal.js +41 -1
  29. package/dist/commands/cycle-args.js +127 -0
  30. package/dist/commands/cycles.js +117 -0
  31. package/dist/commands/docs-comments-args.js +85 -0
  32. package/dist/commands/docs-comments.js +128 -0
  33. package/dist/commands/docs-files-args.js +49 -0
  34. package/dist/commands/docs-files.js +179 -0
  35. package/dist/commands/docs-formatting.js +45 -0
  36. package/dist/commands/docs-inbox-args.js +93 -0
  37. package/dist/commands/docs-inbox.js +126 -0
  38. package/dist/commands/docs-notion.js +5 -0
  39. package/dist/commands/docs-read.js +2 -1
  40. package/dist/commands/docs-recovery.js +173 -0
  41. package/dist/commands/docs-refs-args.js +104 -0
  42. package/dist/commands/docs-refs.js +153 -0
  43. package/dist/commands/docs-search-args.js +79 -0
  44. package/dist/commands/docs-search.js +64 -0
  45. package/dist/commands/docs-sync-args.js +82 -0
  46. package/dist/commands/docs-sync.js +197 -0
  47. package/dist/commands/docs-tree.js +136 -0
  48. package/dist/commands/docs.js +85 -51
  49. package/dist/commands/doctor-update.js +4 -1
  50. package/dist/commands/inbox-args.js +81 -0
  51. package/dist/commands/inbox.js +88 -0
  52. package/dist/commands/issue-batch.js +61 -0
  53. package/dist/commands/issue-relations.js +151 -0
  54. package/dist/commands/issue-write.js +38 -5
  55. package/dist/commands/issue.js +32 -1
  56. package/dist/commands/local-args-collector-sources.js +2 -1
  57. package/dist/commands/local-args-tower-approvals.js +16 -4
  58. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  59. package/dist/commands/local-args-tower-cal-links.js +42 -0
  60. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  61. package/dist/commands/local-args-tower-cal-share.js +41 -0
  62. package/dist/commands/local-args-tower-cal.js +81 -8
  63. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  64. package/dist/commands/local-args-tower-msg.js +222 -10
  65. package/dist/commands/local-args-tower-search.js +32 -5
  66. package/dist/commands/local-args-tower-work.js +110 -34
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +6 -4
  69. package/dist/commands/local-help-commands-tower.js +47 -31
  70. package/dist/commands/local-help-commands.js +3 -1
  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 +3 -3
  84. package/dist/commands/manager-hints.js +4 -2
  85. package/dist/commands/manager-limits.json +3 -1
  86. package/dist/commands/manager-read-reasons.js +1 -0
  87. package/dist/commands/manager-render-home.js +13 -5
  88. package/dist/commands/manager-render.js +3 -0
  89. package/dist/commands/manager.js +3 -1
  90. package/dist/commands/mcp-stdio-probe.js +1 -0
  91. package/dist/commands/msg-activity.js +134 -0
  92. package/dist/commands/msg-channel-args.js +92 -0
  93. package/dist/commands/msg-channel.js +125 -0
  94. package/dist/commands/msg-context-args.js +67 -0
  95. package/dist/commands/msg-context.js +65 -0
  96. package/dist/commands/msg-dm.js +28 -0
  97. package/dist/commands/msg-file.js +185 -0
  98. package/dist/commands/msg-mark-args.js +78 -0
  99. package/dist/commands/msg-mark.js +148 -0
  100. package/dist/commands/msg-notify-args.js +100 -0
  101. package/dist/commands/msg-notify.js +122 -0
  102. package/dist/commands/msg-schedule-args.js +114 -0
  103. package/dist/commands/msg-schedule.js +226 -0
  104. package/dist/commands/msg-search-args.js +71 -0
  105. package/dist/commands/msg-search.js +48 -0
  106. package/dist/commands/msg-write.js +15 -1
  107. package/dist/commands/msg.js +62 -39
  108. package/dist/commands/project-args.js +82 -0
  109. package/dist/commands/project.js +71 -7
  110. package/dist/commands/public-root.js +1 -1
  111. package/dist/commands/search.js +23 -0
  112. package/dist/commands/session-sync-failures.js +17 -4
  113. package/dist/commands/session-sync-health.js +7 -2
  114. package/dist/commands/session-sync-hold.js +42 -1
  115. package/dist/commands/session-sync-outside-roots.js +72 -14
  116. package/dist/commands/session-sync-upload.js +2 -0
  117. package/dist/commands/session-sync.js +1 -0
  118. package/dist/commands/sources-check.js +63 -2
  119. package/dist/commands/sources-discovery.js +1 -1
  120. package/dist/commands/sources-reconciliation.js +87 -0
  121. package/dist/commands/sync-doctor.js +14 -1
  122. package/dist/commands/sync-report.js +1 -1
  123. package/dist/commands/template-args.js +122 -0
  124. package/dist/commands/templates.js +162 -0
  125. package/dist/commands/view-args.js +131 -0
  126. package/dist/commands/views.js +138 -0
  127. package/dist/commands/work-notify-args.js +67 -0
  128. package/dist/commands/work-notify.js +63 -0
  129. package/dist/delivery-hold-notice.js +34 -0
  130. package/dist/evidence-transcripts-client.js +1 -0
  131. package/dist/log-rotation.js +2 -2
  132. package/dist/notifier.js +83 -0
  133. package/dist/process-runner.js +4 -0
  134. package/dist/repo-identity-git.js +1 -0
  135. package/package.json +4 -4
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `cockpit ticket views [list|show|save|update|favorite|unfavorite|settings]` argument parsing
3
+ * (BLI-21686, P8). `views` is an `issue` action; its flags are read here so `local-args-tower-work.ts`
4
+ * only lists their names, the way `issue-relations.ts` does for relations.
5
+ */
6
+ import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
7
+ export const VIEW_VALUE_FLAGS = ["--name", "--layout", "--hide", "--show", "--expected-updated-at", "--description"];
8
+ export const VIEW_BOOLEAN_FLAGS = ["--shared", "--private", "--archive", "--restore", "--include-archived"];
9
+ export const VIEW_FIELDS = ["priority", "labels", "project", "assignee", "updated"];
10
+ const VIEW_VERBS = ["list", "show", "save", "update", "favorite", "unfavorite", "settings"];
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const STATES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
13
+ function listOf(values, flag) {
14
+ return (values.flagValues.get(flag) ?? []).map((v) => v.trim()).filter(Boolean);
15
+ }
16
+ export function parseViewArgs(values, rest) {
17
+ const first = rest[0];
18
+ const verb = (first === undefined ? "list" : first);
19
+ if (!VIEW_VERBS.includes(verb)) {
20
+ throw new Error(`Unknown views command: ${first}. Try list, show, save, update, favorite, unfavorite or settings.`);
21
+ }
22
+ const positional = rest.slice(first === undefined ? 0 : 1);
23
+ const needsRef = verb === "show" || verb === "update" || verb === "favorite" || verb === "unfavorite";
24
+ if (needsRef && positional.length !== 1)
25
+ throw new Error(`ticket views ${verb} takes exactly one view id or name.`);
26
+ if (!needsRef && positional.length > 0)
27
+ throw new Error(`ticket views ${verb} does not take "${positional[0]}".`);
28
+ const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
29
+ const definitionFlags = ["--name", "--description", "--state", "--assignee", "--project", "--label", "--q", "--layout", "--sort", "--dir", "--shared", "--private"];
30
+ const writes = verb === "save" || verb === "update";
31
+ for (const flag of definitionFlags) {
32
+ if (given(flag) && !writes)
33
+ throw new Error(`${flag} belongs to \`cockpit ticket views save\` and \`update\`.`);
34
+ }
35
+ if (!writes && (given("--archive") || given("--restore") || given("--expected-updated-at"))) {
36
+ throw new Error("--archive, --restore and --expected-updated-at belong to `cockpit ticket views update`.");
37
+ }
38
+ if (verb === "save" && (given("--archive") || given("--restore") || given("--expected-updated-at"))) {
39
+ throw new Error("save takes --shared (or nothing for private); --archive, --restore and --expected-updated-at belong to `update`.");
40
+ }
41
+ if (given("--include-archived"))
42
+ throw new Error("--include-archived belongs to `cockpit ticket templates list`.");
43
+ if (verb !== "save" && given("--request-id"))
44
+ throw new Error("--request-id belongs to `cockpit ticket views save`.");
45
+ if (given("--private") && given("--shared"))
46
+ throw new Error("Give --shared or --private, not both.");
47
+ if (given("--archive") && given("--restore"))
48
+ throw new Error("Give --archive or --restore, not both.");
49
+ if (verb !== "settings" && (given("--hide") || given("--show")))
50
+ throw new Error("--hide and --show belong to `cockpit ticket views settings`.");
51
+ if (verb === "list" || verb === "show" || verb === "favorite" || verb === "unfavorite" || verb === "settings") {
52
+ if (given("--expected-revision") || given("--limit"))
53
+ throw new Error("That flag does not belong to ticket views.");
54
+ }
55
+ const name = optionalNonEmpty(values.flags.get("--name"));
56
+ if (verb === "save" && !name)
57
+ throw new Error('ticket views save needs --name "<name>".');
58
+ const filters = {};
59
+ const state = optionalNonEmpty(values.flags.get("--state"));
60
+ if (state) {
61
+ if (!STATES.includes(state))
62
+ throw new Error(`--state must be one of: ${STATES.join(", ")}.`);
63
+ filters["state"] = state;
64
+ }
65
+ const assignee = optionalNonEmpty(values.flags.get("--assignee"));
66
+ if (assignee) {
67
+ if (assignee !== "me" && assignee !== "unassigned" && !UUID.test(assignee)) {
68
+ throw new Error('--assignee must be "me", "unassigned" or a person\'s uuid. "me" stays "me" and means whoever opens the view.');
69
+ }
70
+ filters["assignee"] = assignee;
71
+ }
72
+ for (const [flag, key] of [["--project", "project"], ["--label", "label"], ["--q", "q"]]) {
73
+ const value = optionalNonEmpty(values.flags.get(flag));
74
+ if (value)
75
+ filters[key] = value;
76
+ }
77
+ const layout = optionalNonEmpty(values.flags.get("--layout"));
78
+ if (layout && layout !== "list" && layout !== "board")
79
+ throw new Error("--layout must be list or board.");
80
+ const sort = optionalNonEmpty(values.flags.get("--sort"));
81
+ if (sort && sort !== "updated_at" && sort !== "created_at")
82
+ throw new Error("--sort must be updated_at or created_at.");
83
+ const dir = optionalNonEmpty(values.flags.get("--dir"));
84
+ if (dir && dir !== "asc" && dir !== "desc")
85
+ throw new Error("--dir must be asc or desc.");
86
+ const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
87
+ if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
88
+ throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
89
+ }
90
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
91
+ if (requestId && !UUID.test(requestId))
92
+ throw new Error("--request-id must be a UUID.");
93
+ const rawDescription = values.flags.get("--description");
94
+ const hide = listOf(values, "--hide");
95
+ const show = listOf(values, "--show");
96
+ for (const field of [...hide, ...show]) {
97
+ if (!VIEW_FIELDS.includes(field))
98
+ throw new Error(`"${field}" is not a field. Use ${VIEW_FIELDS.join(", ")}.`);
99
+ }
100
+ if (verb === "settings" && (hide.length === 0 && show.length === 0) === false && hide.some((f) => show.includes(f))) {
101
+ throw new Error("A field cannot be in both --hide and --show.");
102
+ }
103
+ if (verb === "update" && !name && rawDescription === undefined && Object.keys(filters).length === 0 && !layout && !sort && !dir
104
+ && !given("--shared") && !given("--private") && !given("--archive") && !given("--restore")) {
105
+ throw new Error("ticket views update needs a field to change: --name, a filter, --layout, --sort, --dir, --shared, --private, --archive or --restore.");
106
+ }
107
+ const views = {
108
+ verb,
109
+ ...(needsRef ? { ref: positional[0] } : {}),
110
+ ...(name ? { name } : {}),
111
+ ...(rawDescription === undefined ? {} : { description: rawDescription.trim().toLowerCase() === "none" ? null : rawDescription }),
112
+ ...(Object.keys(filters).length > 0 ? { filters } : {}),
113
+ ...(layout ? { layout } : {}),
114
+ ...(sort ? { sort } : {}),
115
+ ...(dir ? { dir } : {}),
116
+ ...(given("--shared") ? { visibility: "shared" } : given("--private") ? { visibility: "private" } : {}),
117
+ ...(given("--archive") ? { archived: true } : given("--restore") ? { archived: false } : {}),
118
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
119
+ ...(hide.length > 0 ? { hide } : {}),
120
+ ...(show.length > 0 ? { show } : {}),
121
+ };
122
+ return {
123
+ kind: "issue",
124
+ action: "views",
125
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
126
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
127
+ ...(requestId ? { requestId } : {}),
128
+ json: values.booleans.has("--json"),
129
+ views,
130
+ };
131
+ }
@@ -0,0 +1,138 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ import { pageFieldsOf, pageFooter } from "./issue-list-page.js";
4
+ import { READ_DEADLINE_MS, WRITE_DEADLINE_MS } from "./issue-contracts.js";
5
+ const TAG = "[ticket views cli]";
6
+ const filterText = (filters) => {
7
+ const parts = Object.entries(filters).map(([key, value]) => `${key}=${value}`);
8
+ return parts.length === 0 ? "no filters" : parts.join(" ");
9
+ };
10
+ const viewLine = (view) => `${view.id} ${view.name} [${view.preset ? "built in" : view.visibility}${view.archived_at ? ", archived" : ""}${view.favorite ? ", favorite" : ""}] ${filterText(view.filters)}`;
11
+ export async function runViews(command, io, door) {
12
+ void io;
13
+ const views = command.views;
14
+ const ref = views.ref ? encodeURIComponent(views.ref) : "";
15
+ switch (views.verb) {
16
+ case "list":
17
+ return listViews(door, command);
18
+ case "show":
19
+ return showView(door, ref, views.ref ?? "");
20
+ case "favorite":
21
+ case "unfavorite": {
22
+ const answer = await askAgentDoor(door, {
23
+ path: `/api/work/views/${ref}/favorite`,
24
+ method: views.verb === "favorite" ? "POST" : "DELETE",
25
+ label: `ticket views ${views.verb}`,
26
+ timeoutMs: WRITE_DEADLINE_MS,
27
+ });
28
+ if (!answer.ok)
29
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
30
+ const body = answer.body;
31
+ console.error(`${TAG} ${views.verb} done`, JSON.stringify({ view_id: body.view?.id ?? views.ref, count: body.favorite_view_ids?.length ?? 0 }));
32
+ if (door.json)
33
+ return emitAgentDoor(door, { ok: true, ...body });
34
+ writeLine(door.io.stdout, `${views.verb === "favorite" ? "Favorited" : "Removed from favorites"}: ${body.view?.name ?? views.ref}. ${body.favorite_view_ids?.length ?? 0} favorite(s).`);
35
+ return 0;
36
+ }
37
+ case "settings":
38
+ return settings(door, command);
39
+ case "save":
40
+ case "update":
41
+ return writeView(door, command, ref);
42
+ }
43
+ }
44
+ async function listViews(door, command) {
45
+ void command;
46
+ const answer = await askAgentDoor(door, { path: "/api/work/views", method: "GET", label: "ticket views list", timeoutMs: READ_DEADLINE_MS });
47
+ if (!answer.ok)
48
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
49
+ const body = answer.body;
50
+ if (door.json)
51
+ return emitAgentDoor(door, { ok: true, ...body });
52
+ const out = door.io.stdout;
53
+ if (body.my_work)
54
+ writeLine(out, viewLine(body.my_work));
55
+ for (const [title, rows] of [["Favorites", body.favorites], ["Mine", body.mine], ["Shared", body.shared]]) {
56
+ if (!rows || rows.length === 0)
57
+ continue;
58
+ writeLine(out, "");
59
+ writeLine(out, `${title}:`);
60
+ for (const view of rows)
61
+ writeLine(out, viewLine(view));
62
+ }
63
+ if (body.favorites_hidden)
64
+ writeLine(out, `\n${body.favorites_hidden} favorite(s) are not shown: archived, private to someone else or gone.`);
65
+ return 0;
66
+ }
67
+ async function showView(door, ref, label) {
68
+ const answer = await askAgentDoor(door, { path: `/api/work/views/${ref}`, method: "GET", label: "ticket views show", timeoutMs: READ_DEADLINE_MS });
69
+ if (!answer.ok)
70
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
71
+ const body = answer.body;
72
+ if (door.json)
73
+ return emitAgentDoor(door, { ok: true, ...body, ...pageFieldsOf(body) });
74
+ const out = door.io.stdout;
75
+ const view = body.view;
76
+ writeLine(out, `${view?.name ?? label} (${view?.preset ? "built in" : view?.visibility}, ${view?.layout}, ${view?.sort} ${view?.dir})`);
77
+ writeLine(out, `Filters: ${filterText(view?.filters ?? {})}${view?.filters?.["assignee"] === "me" ? " (me is you)" : ""}`);
78
+ for (const issue of body.issues ?? [])
79
+ writeLine(out, `${(issue.identifier ?? "").padEnd(9)} ${(issue.state ?? "").padEnd(11)} ${issue.title ?? ""}`);
80
+ const footer = pageFooter(body, "tickets");
81
+ if (footer)
82
+ writeLine(out, footer);
83
+ if (body.href)
84
+ writeLine(out, `Open on the web: ${body.href}`);
85
+ return 0;
86
+ }
87
+ async function settings(door, command) {
88
+ const { hide, show } = command.views;
89
+ const changing = Boolean(hide?.length || show?.length);
90
+ const answer = await askAgentDoor(door, {
91
+ path: "/api/work/view-settings",
92
+ method: changing ? "PATCH" : "GET",
93
+ label: "ticket views settings",
94
+ timeoutMs: changing ? WRITE_DEADLINE_MS : READ_DEADLINE_MS,
95
+ ...(changing ? { body: { ...(hide?.length ? { hide } : {}), ...(show?.length ? { show } : {}) } } : {}),
96
+ });
97
+ if (!answer.ok)
98
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
99
+ const body = answer.body;
100
+ if (door.json)
101
+ return emitAgentDoor(door, { ok: true, ...body });
102
+ writeLine(door.io.stdout, `Hidden fields on your ticket rows: ${body.hidden_fields?.length ? body.hidden_fields.join(", ") : "none"}. Fields: ${(body.available_fields ?? []).join(", ")}.`);
103
+ return 0;
104
+ }
105
+ async function writeView(door, command, ref) {
106
+ const v = command.views;
107
+ const payload = {
108
+ ...(v.verb === "save" && command.requestId ? { request_id: command.requestId } : {}),
109
+ ...(v.verb === "update" && v.expectedUpdatedAt ? { expected_updated_at: v.expectedUpdatedAt } : {}),
110
+ ...(v.name === undefined ? {} : { name: v.name }),
111
+ ...(v.description === undefined ? {} : { description: v.description }),
112
+ ...(v.filters ? { filters: v.filters } : {}),
113
+ ...(v.layout ? { layout: v.layout } : {}),
114
+ ...(v.sort ? { sort: v.sort } : {}),
115
+ ...(v.dir ? { dir: v.dir } : {}),
116
+ ...(v.visibility ? { visibility: v.visibility } : {}),
117
+ ...(v.archived === undefined ? {} : { archived: v.archived }),
118
+ };
119
+ const answer = await askAgentDoor(door, {
120
+ path: v.verb === "save" ? "/api/work/views" : `/api/work/views/${ref}`,
121
+ method: v.verb === "save" ? "POST" : "PATCH",
122
+ label: `ticket views ${v.verb}`,
123
+ timeoutMs: WRITE_DEADLINE_MS,
124
+ body: payload,
125
+ });
126
+ if (!answer.ok) {
127
+ const mutation = answer.body?.mutation;
128
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
129
+ }
130
+ const body = answer.body;
131
+ console.error(`${TAG} ${v.verb} done`, JSON.stringify({ view_id: body.view?.id, replayed: Boolean(body.replayed) }));
132
+ if (door.json)
133
+ return emitAgentDoor(door, { ok: true, ...body });
134
+ writeLine(door.io.stdout, `${v.verb === "save" ? (body.replayed ? "Already saved" : "Saved") : "Updated"}: ${body.view?.name ?? ""} (${body.view?.id ?? ""}), ${body.view?.visibility ?? ""}${body.view?.archived_at ? ", archived" : ""}.`);
135
+ if (body.view?.href)
136
+ writeLine(door.io.stdout, `Open on the web: ${body.view.href}`);
137
+ return 0;
138
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `cockpit ticket notifications [set|receipts]` argument parsing (BLI-21736, P10). An `issue` action; its flags are
3
+ * 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 NOTIFY_ACTIONS = ["notifications"];
7
+ export const NOTIFY_VALUE_FLAGS = ["--slack", "--notification"];
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export function isNotifyAction(action) {
10
+ return NOTIFY_ACTIONS.includes(action);
11
+ }
12
+ export function parseNotifyArgs(values, rest) {
13
+ const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
14
+ const word = rest[0];
15
+ if (word !== undefined && word !== "set" && word !== "receipts") {
16
+ throw new Error(`ticket notifications does not take "${word.slice(0, 40)}". Try: notifications, notifications set --slack on|off, notifications receipts.`);
17
+ }
18
+ if (rest.length > 1)
19
+ throw new Error(`ticket notifications ${word} takes no further arguments.`);
20
+ for (const flag of ["--request-id", "--expected-revision", "--all", "--kind", "--unread"]) {
21
+ if (given(flag))
22
+ throw new Error(`${flag} does not apply to \`cockpit ticket notifications\`.`);
23
+ }
24
+ const notify = { sub: word === "set" ? "set" : word === "receipts" ? "receipts" : "show" };
25
+ const onlyFor = (flags, sub) => {
26
+ for (const flag of flags)
27
+ if (given(flag))
28
+ throw new Error(`${flag} belongs to \`cockpit ticket notifications ${sub}\`.`);
29
+ };
30
+ if (notify.sub === "set") {
31
+ onlyFor(["--notification", "--before", "--limit"], "receipts");
32
+ const slack = optionalNonEmpty(values.flags.get("--slack"));
33
+ if (slack !== "on" && slack !== "off")
34
+ throw new Error("ticket notifications set needs --slack on or --slack off.");
35
+ notify.slackEnabled = slack === "on";
36
+ }
37
+ else if (notify.sub === "receipts") {
38
+ onlyFor(["--slack"], "set");
39
+ const notification = optionalNonEmpty(values.flags.get("--notification"));
40
+ if (notification) {
41
+ if (!UUID.test(notification))
42
+ throw new Error("--notification must be a notification id. Copy it from `cockpit ticket inbox --json`.");
43
+ notify.notification = notification;
44
+ }
45
+ const before = optionalNonEmpty(values.flags.get("--before"));
46
+ if (before)
47
+ notify.before = before;
48
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
49
+ if (limit !== undefined) {
50
+ if (limit > 100)
51
+ throw new Error("--limit is at most 100.");
52
+ notify.limit = limit;
53
+ }
54
+ }
55
+ else {
56
+ onlyFor(["--slack"], "set");
57
+ onlyFor(["--notification", "--before", "--limit"], "receipts");
58
+ }
59
+ return {
60
+ kind: "issue",
61
+ action: "notifications",
62
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
63
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
64
+ json: values.booleans.has("--json"),
65
+ notify,
66
+ };
67
+ }
@@ -0,0 +1,63 @@
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 notifications cli]";
5
+ function writeSettings(door, settings) {
6
+ const out = door.io.stdout;
7
+ writeLine(out, `Slack delivery: ${settings.slack_enabled ? "on" : "off"}${settings.slack_enabled_at && settings.slack_enabled ? ` (since ${settings.slack_enabled_at})` : ""}`);
8
+ if (settings.slack_linked === null)
9
+ writeLine(out, `Slack account: could not check (${settings.slack_linked_reason ?? "unknown"}).`);
10
+ else
11
+ writeLine(out, settings.slack_linked ? "Slack account: linked." : "Slack account: none linked, so delivery would fail with slack_identity_missing.");
12
+ }
13
+ export async function runWorkNotify(command, io, door) {
14
+ void io;
15
+ const notify = command.notify;
16
+ if (notify.sub === "receipts") {
17
+ const query = new URLSearchParams();
18
+ if (notify.notification)
19
+ query.set("notification_id", notify.notification);
20
+ if (notify.before)
21
+ query.set("before", notify.before);
22
+ if (notify.limit !== undefined)
23
+ query.set("limit", String(notify.limit));
24
+ const suffix = query.toString();
25
+ const answer = await askAgentDoor(door, {
26
+ path: `/api/work/notifications/receipts${suffix ? `?${suffix}` : ""}`,
27
+ method: "GET",
28
+ label: "ticket notifications receipts",
29
+ timeoutMs: READ_DEADLINE_MS,
30
+ });
31
+ if (!answer.ok)
32
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
33
+ const body = answer.body;
34
+ if (door.json)
35
+ return emitAgentDoor(door, { ok: true, ...body });
36
+ const receipts = body.receipts ?? [];
37
+ if (receipts.length === 0)
38
+ writeLine(door.io.stdout, "No delivery receipts yet.");
39
+ for (const receipt of receipts) {
40
+ writeLine(door.io.stdout, `${receipt.id} ${receipt.outcome.padEnd(10)} ${(receipt.reason ?? "-").padEnd(22)} ${(receipt.identifier ?? "(ticket not readable)").padEnd(9)} ${receipt.created_at}`);
41
+ }
42
+ if (body.has_more && body.next_cursor)
43
+ writeLine(door.io.stdout, `More receipts available. Continue with --before ${body.next_cursor}`);
44
+ return 0;
45
+ }
46
+ const setting = notify.sub === "set";
47
+ const answer = await askAgentDoor(door, {
48
+ path: "/api/work/notifications/settings",
49
+ method: setting ? "PUT" : "GET",
50
+ label: setting ? "ticket notifications set" : "ticket notifications",
51
+ timeoutMs: setting ? WRITE_DEADLINE_MS : READ_DEADLINE_MS,
52
+ ...(setting ? { body: { slack_enabled: notify.slackEnabled } } : {}),
53
+ });
54
+ if (!answer.ok)
55
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
56
+ const body = answer.body;
57
+ console.error(`${TAG} ${setting ? "set" : "read"} done`, JSON.stringify({ slack_enabled: body.settings?.slack_enabled }));
58
+ if (door.json)
59
+ return emitAgentDoor(door, { ok: true, ...body });
60
+ if (body.settings)
61
+ writeSettings(door, body.settings);
62
+ return 0;
63
+ }
@@ -163,4 +163,38 @@ function reasonWord(reason, budget) {
163
163
  if (safe.length === 0)
164
164
  return ABSENT;
165
165
  return safe.slice(0, budget);
166
+ }
167
+ /**
168
+ * Ticket 21952: the per-tick SIZE BUDGET's hold, the second token this file owns.
169
+ *
170
+ * A tick that sent some evidence and held the rest for the next tick reports
171
+ * `ok` with this token instead of failing, because overflow defers and converges
172
+ * by design. The collector writes it and the fleet board reads it
173
+ * (`apps/dashboard/src/lib/ops/fleet-budget-hold.ts`), which turns a hold that
174
+ * never drains into a RED row, so a growing backlog cannot stay green.
175
+ *
176
+ * held_budget:<held objects>:<held MiB>:<sent MiB>
177
+ * held_budget:3:901:2043
178
+ */
179
+ export const BUDGET_HOLD_NOTICE_PREFIX = "held_budget";
180
+ const MIB = 1024 * 1024;
181
+ /** Render the size-budget hold, or null for a hold of nothing. Held MiB rounds up so a held byte never reads as 0. */
182
+ export function formatBudgetHoldNotice(use) {
183
+ if (use.deferredObjects <= 0)
184
+ return null;
185
+ const heldMib = Math.ceil(Math.max(0, use.deferredBytes) / MIB);
186
+ const sentMib = Math.round(Math.max(0, use.admittedBytes) / MIB);
187
+ return `${BUDGET_HOLD_NOTICE_PREFIX}:${Math.trunc(use.deferredObjects)}:${heldMib}:${sentMib}`;
188
+ }
189
+ /** Find a size-budget hold token anywhere in a receipt's detail. A search, for the same reason as `parseDeliveryHoldNotice`. */
190
+ export function parseBudgetHoldNotice(text) {
191
+ if (!text)
192
+ return null;
193
+ const match = new RegExp(String.raw `${BUDGET_HOLD_NOTICE_PREFIX}:(\d+):(\d+):(\d+)`, "u").exec(text);
194
+ if (!match)
195
+ return null;
196
+ const heldObjects = Number.parseInt(match[1], 10);
197
+ if (!Number.isFinite(heldObjects) || heldObjects <= 0)
198
+ return null;
199
+ return { heldObjects, heldMib: Number.parseInt(match[2], 10), sentMib: Number.parseInt(match[3], 10) };
166
200
  }
@@ -78,6 +78,7 @@ function addTranscriptObject(object, files, state) {
78
78
  const key = transcriptFileKey(identity);
79
79
  const list = files.get(key) ?? [];
80
80
  list.push({
81
+ ref_id: object.ref_id,
81
82
  object_key: object.object_key,
82
83
  byte_size: object.byte_size,
83
84
  content_hash_sha256: object.content_hash_sha256,
@@ -47,8 +47,8 @@ import path from "node:path";
47
47
  */
48
48
  export const DEFAULT_LOG_ROTATION_MAX_BYTES = 10 * 1024 * 1024;
49
49
  export const DEFAULT_LOG_ROTATION_KEEP = 2;
50
- /** The two files launchd writes for the scheduled tick. */
51
- export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
50
+ /** The files launchd writes for the scheduled ticks (sync, and BLI-21564 the reminder job). */
51
+ export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log", "remind.log", "remind.err.log"];
52
52
  /**
53
53
  * Caps the scheduled tick's own logs. Never throws: a rotation failure is data
54
54
  * for the caller to log, never a reason a sync does not run.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The one place the collector paints an OS banner (BLI-21564, calendar slice 11).
3
+ *
4
+ * macOS goes through `osascript -e 'display notification ...'`, Windows through
5
+ * a PowerShell toast on PowerShell's own app id. Both are spawned through
6
+ * `process-runner.ts` with an ARRAY of arguments (never a shell line), and every
7
+ * string a person or a calendar can influence is escaped (AppleScript) or passed
8
+ * by environment variable (PowerShell), so a meeting title cannot become code.
9
+ *
10
+ * The result is always one of three named outcomes. A banner the OS refused is
11
+ * `permission_denied`; a helper that failed any other way is `notifier_failed`
12
+ * with its exit code; nothing here is silent. `osascript` exits 0 when the user
13
+ * switched notifications off for it, so a silent denial is not detectable: a
14
+ * desktop receipt, not this module, is the proof a banner appeared.
15
+ */
16
+ import { createCapturedExecRunner } from "./process-runner.js";
17
+ const NOTIFIER_TIMEOUT_MS = 15_000;
18
+ const MAX_TEXT = 200;
19
+ const DENIAL = /not allowed|not authorized|not permitted|access.*denied|notifications? (?:are |is )?(?:disabled|blocked|turned off)|\(-1743\)|\(-1744\)|blocked by/i;
20
+ /** Controls become spaces and the length is bounded; the text is a banner, not a document. */
21
+ function plain(value) {
22
+ // eslint-disable-next-line no-control-regex
23
+ return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, MAX_TEXT);
24
+ }
25
+ /** An AppleScript string literal body: backslash and double quote are the only escapes it needs. */
26
+ export function appleScriptEscape(value) {
27
+ return plain(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
28
+ }
29
+ export function macNotifyArgs(request) {
30
+ const sound = request.sound ? ' sound name "Glass"' : "";
31
+ return ["-e", `display notification "${appleScriptEscape(request.message)}" with title "${appleScriptEscape(request.title)}"${sound}`];
32
+ }
33
+ /** The toast script reads its text from the environment, so no title can reach the script body. */
34
+ export const WINDOWS_TOAST_SCRIPT = [
35
+ "$ErrorActionPreference = 'Stop'",
36
+ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null",
37
+ "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null",
38
+ "$title = [System.Security.SecurityElement]::Escape($env:TOWER_TOAST_TITLE)",
39
+ "$message = [System.Security.SecurityElement]::Escape($env:TOWER_TOAST_MESSAGE)",
40
+ "$audio = if ($env:TOWER_TOAST_SOUND -eq '1') { '' } else { '<audio silent=\"true\"/>' }",
41
+ "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
42
+ "$xml.LoadXml('<toast><visual><binding template=\"ToastText02\"><text id=\"1\">' + $title + '</text><text id=\"2\">' + $message + '</text></binding></visual>' + $audio + '</toast>')",
43
+ "$toast = New-Object Windows.UI.Notifications.ToastNotification $xml",
44
+ "$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
45
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)",
46
+ ].join("\n");
47
+ export function windowsNotifyArgs() {
48
+ const encoded = Buffer.from(WINDOWS_TOAST_SCRIPT, "utf16le").toString("base64");
49
+ return ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded];
50
+ }
51
+ export async function showNotification(request, options = {}) {
52
+ const platform = options.platform ?? process.platform;
53
+ const exec = options.exec ?? createCapturedExecRunner({ platform });
54
+ let command;
55
+ let args;
56
+ let env;
57
+ if (platform === "darwin") {
58
+ command = "osascript";
59
+ args = macNotifyArgs(request);
60
+ }
61
+ else if (platform === "win32") {
62
+ command = "powershell.exe";
63
+ args = windowsNotifyArgs();
64
+ env = { ...(options.env ?? process.env), TOWER_TOAST_TITLE: plain(request.title), TOWER_TOAST_MESSAGE: plain(request.message), TOWER_TOAST_SOUND: request.sound ? "1" : "0" };
65
+ }
66
+ else {
67
+ const reason = `no_notifier_for_${platform}`;
68
+ console.error("[notifier] not shown", JSON.stringify({ outcome: "notifier_failed", reason }));
69
+ return { outcome: "notifier_failed", reason };
70
+ }
71
+ const result = await exec(command, args, { timeoutMs: NOTIFIER_TIMEOUT_MS, ...(env ? { env } : {}) });
72
+ if (result.code === 0) {
73
+ console.error("[notifier] shown", JSON.stringify({ outcome: "shown", platform }));
74
+ return { outcome: "shown", reason: null };
75
+ }
76
+ if (DENIAL.test(`${result.stderr}\n${result.stdout}`)) {
77
+ console.error("[notifier] not shown", JSON.stringify({ outcome: "permission_denied", platform, exit: result.code }));
78
+ return { outcome: "permission_denied", reason: `os_refused_exit_${result.code}` };
79
+ }
80
+ const reason = result.code === 124 ? "timed_out" : `exit_${result.code}`;
81
+ console.error("[notifier] not shown", JSON.stringify({ outcome: "notifier_failed", platform, exit: result.code }));
82
+ return { outcome: "notifier_failed", reason };
83
+ }
@@ -35,6 +35,7 @@ export function createCapturedExecRunner(options = {}) {
35
35
  encoding: "utf8",
36
36
  env,
37
37
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
38
+ windowsHide: true,
38
39
  ...(runOptions?.timeoutMs ? { timeout: runOptions.timeoutMs } : {}),
39
40
  }, (error, stdout, stderr) => {
40
41
  const code = capturedExitCode(error, runOptions?.timeoutMs);
@@ -86,6 +87,9 @@ export function createInteractiveExecRunner(options = {}) {
86
87
  stdio,
87
88
  env,
88
89
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
90
+ // Ticket 21952: a scheduled sync has no console, so a child without this opens a visible window on Windows.
91
+ // With a console (a person typing `cockpit doctor`) the child shares it and the flag changes nothing.
92
+ windowsHide: true,
89
93
  });
90
94
  let settled = false;
91
95
  child.on("error", (error) => {
@@ -14,6 +14,7 @@ export async function runGit(args, cwd) {
14
14
  const { stdout } = await execFileAsync("git", args, {
15
15
  cwd,
16
16
  timeout: 2_000,
17
+ windowsHide: true,
17
18
  maxBuffer: 1024 * 1024,
18
19
  });
19
20
  return stdout;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.242",
3
+ "version": "0.2.244",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.70",
31
- "@bli-cockpit/mcp": "0.1.147",
32
- "@bli-cockpit/telemetry-core": "0.1.77"
30
+ "@bli-cockpit/memory-mcp": "0.1.72",
31
+ "@bli-cockpit/mcp": "0.1.149",
32
+ "@bli-cockpit/telemetry-core": "0.1.79"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",