@bli-cockpit/mcp 0.1.120 → 0.1.122

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.
package/README.md CHANGED
@@ -109,7 +109,7 @@ about what an agent can reach.
109
109
 
110
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
111
111
 
112
- **171 of 176 Tower verbs have an MCP twin.**
112
+ **187 of 192 Tower verbs have an MCP twin.**
113
113
  Each tool goes through the SAME door its CLI verb calls, with the same
114
114
  collector device token — never a second route and never a service-role
115
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -206,6 +206,22 @@ three tables below.
206
206
  | `cockpit mail send` | `mail_send` | `POST /api/mail/send` |
207
207
  | `cockpit mail sync` | `mail_sync` | `POST /api/mail/accounts/[id]/sync` |
208
208
  | `cockpit mail sync-status` | `mail_sync_status` | `GET /api/mail/sync-status` |
209
+ | `cockpit manager add` | `manager_add` | `POST /api/manager/clients/[clientId]/actions (or /timeline)` |
210
+ | `cockpit manager approve` | `manager_approve` | `POST /api/manager/actions/[id]/review (or /timeline/[id]/review)` |
211
+ | `cockpit manager archive` | `manager_archive` | `POST /api/manager/actions/[id]/archive (or /timeline/[id]/archive)` |
212
+ | `cockpit manager client` | `manager_client` | `GET /api/manager/clients/[clientId]` |
213
+ | `cockpit manager clients` | `manager_clients` | `GET /api/manager/clients` |
214
+ | `cockpit manager dismiss` | `manager_dismiss` | `POST /api/manager/actions/[id]/review (or /timeline/[id]/review)` |
215
+ | `cockpit manager done` | `manager_done` | `POST /api/manager/actions/[id]/status` |
216
+ | `cockpit manager edit` | `manager_edit` | `PATCH /api/manager/actions/[id] (or /timeline/[id])` |
217
+ | `cockpit manager link` | `manager_link` | `POST /api/manager/actions/[id]/link` |
218
+ | `cockpit manager list` | `manager_list` | `GET /api/manager/clients/[clientId]/actions (or /timeline)` |
219
+ | `cockpit manager proposal` | `manager_proposal` | `POST /api/manager/actions/[id]/proposal (or /timeline/[id]/proposal)` |
220
+ | `cockpit manager queue` | `manager_queue` | `GET /api/manager/clients/[clientId]/review-queue` |
221
+ | `cockpit manager reopen` | `manager_reopen` | `POST /api/manager/actions/[id]/status` |
222
+ | `cockpit manager run` | `manager_run` | `GET /api/manager/runs/[runId]` |
223
+ | `cockpit manager show` | `manager_show` | `GET /api/manager/actions/[id] (or /timeline/[id])` |
224
+ | `cockpit manager unarchive` | `manager_unarchive` | `POST /api/manager/actions/[id]/unarchive (or /timeline/[id]/unarchive)` |
209
225
  | `cockpit memory chain` | `memory_chain` | `GET /api/memory/chain` |
210
226
  | `cockpit memory consolidation` | `memory_consolidation` | `GET /api/memory/consolidation` |
211
227
  | `cockpit memory forget` | `forget_memory` (`bli-memory`) | `POST /api/memory/forget` |
@@ -0,0 +1,7 @@
1
+ import { type AgentDoorResponse } from "./agent-door.js";
2
+ import { type ToolDeps } from "./tool-result.js";
3
+ /** The Manager's refusal in the person's words: label, finer label when named, then the sentence. */
4
+ export declare function managerFailureText(tool: string, response: AgentDoorResponse): string;
5
+ export declare function registerManagerTools(server: {
6
+ registerTool: (...args: never[]) => unknown;
7
+ }, deps: ToolDeps): void;
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Tower Manager over MCP (BLI-5006): the sixteen twins of `cockpit manager`.
3
+ *
4
+ * Plain words: the Manager keeps, per client, a timeline (meetings, documents,
5
+ * milestones, internal events) and a list of actions (to-dos with owners and a
6
+ * due date). The AI proposes items and changes; a person reviews them. These tools
7
+ * let an agent read both and do what a person does on the page: add, edit, tick off,
8
+ * link, approve or dismiss the AI's suggestion, archive. They ride the SAME
9
+ * `/api/manager/**` door the CLI calls, on this machine's device token; the door
10
+ * hands the Manager's own answer back unchanged.
11
+ *
12
+ * Every write on an existing item carries `version`, the item's `updatedAt` as last
13
+ * read, sent as `expectedUpdatedAt`: a stale one is refused (409 invalid_state,
14
+ * `refusal item_changed`) rather than written over someone else's change.
15
+ *
16
+ * Deciding the AI's suggestion (approve, dismiss, proposal) is not an outward act, so
17
+ * none of these needs `confirm`: it records the person whose key this is as the
18
+ * reviewer and takes the item out of the review queue unless a suggested change still waits on it. Nothing here reaches anyone.
19
+ *
20
+ * Refusals come in two shapes and are relayed in the person's own words
21
+ * (`managerFailureText`): the Manager's `{ error: <sentence>, reason, refusal? }` and
22
+ * the door's own `{ code, reason, message }`. Only these tools read the first shape;
23
+ * every other family's refusal text is untouched.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { z } from "zod";
27
+ import { callAgentDoor } from "./agent-door.js";
28
+ import { dataResult, doorFailureText, doorReason, errorResult, queryString, registrarFor, withSession, } from "./tool-result.js";
29
+ const KINDS = ["meeting", "document", "milestone", "internal"];
30
+ const ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
31
+ const DEFAULT_LIMIT = 50;
32
+ const MAX_LIMIT = 200;
33
+ const CLIENT_ID = z.string().uuid().describe("The client's id, from manager_clients.");
34
+ const ITEM_ID = z.string().uuid().describe("The item's id, from manager_list, manager_queue or manager_show.");
35
+ const TIMELINE = z.boolean().optional()
36
+ .describe("true when the item is a timeline item (a meeting, document, milestone or internal event). Omit or false for an action (a to-do).");
37
+ const VERSION = z.string().min(1)
38
+ .describe("The item's updatedAt exactly as manager_list or manager_show returned it. If anyone changed the item since, the write is refused (item_changed): read it again and retry.");
39
+ const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date written YYYY-MM-DD");
40
+ const OWNERS = z.array(z.string().min(1).max(120)).max(20).optional()
41
+ .describe("Actions only: the owners, one person per entry. Leaving this out leaves the owners alone; an empty list clears them; a list replaces them.");
42
+ const OWNER_NAME = z.string().max(120).nullable().optional()
43
+ .describe("Timeline items only: the one owner's name. Leaving this out leaves the owner alone; null (or an empty string) clears it.");
44
+ /** The fields only one table has; naming one on the other table is refused here, never dropped silently. */
45
+ const ACTION_ONLY = ["owners", "due", "meeting_id", "status", "owner", "no_owner", "overdue"];
46
+ const TIMELINE_ONLY = ["kind", "date", "owner_name"];
47
+ /** The Manager's refusal in the person's words: label, finer label when named, then the sentence. */
48
+ export function managerFailureText(tool, response) {
49
+ if (response.transportError)
50
+ return doorFailureText(tool, response);
51
+ const body = response.body;
52
+ const str = (key) => (typeof body[key] === "string" && body[key] !== "" ? body[key] : null);
53
+ const sentence = str("message") ?? (typeof body["reason"] === "string" ? str("error") : null) ?? `Tower answered ${response.status}.`;
54
+ const refusal = str("refusal");
55
+ const reason = doorReason(body);
56
+ // A change with no answer may or may not have landed (an add says to retry with its request_id instead).
57
+ const unknown = (reason === "manager_timeout" || reason === "manager_unreachable" || response.status >= 500) && tool !== "manager_add" && !READ_TOOLS_HERE.has(tool);
58
+ const next = NEXT_STEP[refusal ?? ""] ?? (reason === "not_found" ? NEXT_STEP["not_found"] : unknown ? NEXT_STEP["no_answer"] : undefined);
59
+ return `Tower refused ${tool} (${doorReason(body)}${refusal ? `, refusal ${refusal}` : ""}): ${sentence}${next ? ` ${next}` : ""}`;
60
+ }
61
+ /** What to do next after a refusal, in tool terms, so every refusal ends with a way forward (the CLI says the same). */
62
+ const READ_TOOLS_HERE = new Set(["manager_clients", "manager_client", "manager_run", "manager_list", "manager_queue", "manager_show"]);
63
+ const NEXT_STEP = {
64
+ no_answer: "It may or may not have been saved: read the item with manager_show, and use the version it gives if you try again.",
65
+ not_found: "Check the id with manager_list; a timeline item needs timeline true, and done, reopen and link are for actions only.",
66
+ item_changed: "Read the item again with manager_show for its current version, then retry.",
67
+ item_archived: "Bring it back first with manager_unarchive (manager_list with archived true gives its version).",
68
+ item_dismissed: "Restore it first with manager_approve and restore true (manager_list with review dismissed gives its version).",
69
+ person_item_not_dismissable: "To delete an item a person added, use manager_archive.",
70
+ proposal_replaced: "Read the item with manager_show to see the suggested changes waiting and their ids.",
71
+ proposal_decided: "Read the item with manager_show for what is still waiting.",
72
+ timeline_not_live: "Link to a live timeline item of the same client (manager_list with timeline true).",
73
+ item_not_archived: "It is already in the lists; nothing to bring back.",
74
+ request_reused: "Add again without request_id to make a new item.",
75
+ };
76
+ /** Fields named for the wrong table: a refusal that says which, so the caller fixes the call. */
77
+ function wrongTable(tool, args, timeline) {
78
+ const misplaced = (timeline ? ACTION_ONLY : TIMELINE_ONLY).filter((key) => args[key] !== undefined);
79
+ if (misplaced.length === 0)
80
+ return null;
81
+ return errorResult(`Refused (wrong_table_field): ${tool} ${timeline ? "with timeline true" : "on an action"} does not take ${misplaced.join(", ")}`
82
+ + `${timeline ? " (those are for actions)" : " (those are for timeline items; pass timeline true)"}. Nothing was changed.`);
83
+ }
84
+ /** The item fields a caller named, in the door's own spelling. A field left out stays out: the door keeps its value. */
85
+ function itemFields(args) {
86
+ const wire = {
87
+ title: "title", detail: "detail", kind: "kind", due: "dueAt", date: "occurredAt",
88
+ owners: "owners", owner_name: "ownerName", meeting_id: "timelineItemId", source_url: "sourceUrl", source_note: "sourceNote",
89
+ };
90
+ const body = {};
91
+ for (const [key, name] of Object.entries(wire))
92
+ if (args[key] !== undefined)
93
+ body[name] = args[key];
94
+ return body;
95
+ }
96
+ /** The first `limit` rows of a list, with the cut named (a client with hundreds of items is not shipped whole). */
97
+ function page(rows, limit) {
98
+ const all = Array.isArray(rows) ? rows : [];
99
+ return { rows: all.slice(0, limit), more: all.length > limit };
100
+ }
101
+ const LIST_CAP = 500;
102
+ const QUEUE_CAP = 200;
103
+ /** What follows "Showing N of TOTAL" when rows are left out: the server's own cap when it cut, then the next step. */
104
+ function nextStep(serverCap, limit, narrow) {
105
+ const steps = [...(limit < MAX_LIMIT ? [serverCap ? `raise limit to ${MAX_LIMIT}` : "raise limit"] : []), ...(narrow ? ["narrow with filters"] : [])];
106
+ return `${serverCap ? ` (server cap ${serverCap})` : ""}${steps.length > 0 ? `; ${steps.join(" or ")}` : ""}.`;
107
+ }
108
+ /** The Manager writes nothing when an action already has the status asked for, so its version comes back as sent. */
109
+ const sameVersion = (answer, version) => answer.item?.updatedAt === version;
110
+ const titleOf = (answer) => {
111
+ const item = answer.item;
112
+ return typeof item?.title === "string" ? item.title : "that item";
113
+ };
114
+ export function registerManagerTools(server, deps) {
115
+ const register = registrarFor(server);
116
+ /** One door call, relayed: the data on success, the Manager's reason and sentence on a refusal. `suffix` is added when the answer was lost or the server failed (the write may or may not have happened). */
117
+ async function ask(tool, method, path, body, headline, extra = {}) {
118
+ return withSession(deps, async (session) => {
119
+ const response = await callAgentDoor(session, deps.fetchImpl, method, path, body);
120
+ if (!response.ok)
121
+ return errorResult(`${managerFailureText(tool, response)}${response.transportError || response.status >= 500 ? (extra.suffix ?? "") : ""}`);
122
+ const shown = extra.shape ? extra.shape(response.body) : response.body;
123
+ return dataResult(headline(shown), shown);
124
+ });
125
+ }
126
+ const door = (rest) => `/api/manager/${rest}`;
127
+ const table = (args) => (args.timeline === true ? "timeline" : "actions");
128
+ const itemPath = (args, tail = "") => door(`${table(args)}/${encodeURIComponent(String(args.item_id))}${tail}`);
129
+ const clientPath = (args, tail) => door(`clients/${encodeURIComponent(String(args.client_id))}/${tail}`);
130
+ const limitOf = (args) => (typeof args.limit === "number" ? args.limit : DEFAULT_LIMIT);
131
+ const LIMIT = z.number().int().min(1).max(MAX_LIMIT).optional().describe(`How many rows to return, 1 to ${MAX_LIMIT}. Default ${DEFAULT_LIMIT}; the count of every match is always given.`);
132
+ register("manager_clients", {
133
+ title: "List Tower Manager clients",
134
+ description: "The clients Tower Manager follows, each with its id (use it as client_id in the other manager tools), name, onboarding state and counts. Read-only. `cockpit manager clients`, same door.",
135
+ inputSchema: {},
136
+ }, async () => ask("manager_clients", "GET", door("clients"), undefined, (answer) => `${Array.isArray(answer.clients) ? answer.clients.length : 0} client(s).`));
137
+ register("manager_list", {
138
+ title: "List a client's actions or timeline",
139
+ description: "One client's actions (to-dos, default) or, with timeline true, its timeline items. Each row carries its id, title, owners, date, review state, any change the AI proposed and `updatedAt` (the `version` a write needs). "
140
+ + "Filters: needs_review (only items waiting for a person), search, archived (only archived items), review (dismissed or all: dismissed items are hidden by default), plus for actions status (open default, done, dropped, closed, all), owner (one person), no_owner, overdue; for timeline items kind. "
141
+ + `Returns the first ${DEFAULT_LIMIT} rows by default (limit up to ${MAX_LIMIT}); \`total\` is every match. Read-only. \`cockpit manager list\`, same door.`,
142
+ inputSchema: {
143
+ client_id: CLIENT_ID,
144
+ timeline: TIMELINE,
145
+ needs_review: z.boolean().optional().describe("true: only items waiting for a person (never reviewed, or carrying a proposed change)."),
146
+ status: z.enum(ACTION_STATUSES).optional().describe("Actions only: open (default), done, dropped, closed (done and dropped) or all."),
147
+ owner: z.string().min(1).max(120).optional().describe("Actions only: one owner's name; a shared action is under each of its owners."),
148
+ no_owner: z.boolean().optional().describe("Actions only: true for actions nobody owns. Not with owner."),
149
+ overdue: z.boolean().optional().describe("Actions only: true for open actions due before today."),
150
+ kind: z.enum(KINDS).optional().describe("Timeline items only: meeting, document, milestone or internal."),
151
+ archived: z.boolean().optional().describe("true: only archived items."),
152
+ review: z.enum(["dismissed", "all"]).optional().describe("Dismissed items are hidden by default: dismissed lists only those the person dismissed, all lists every item whatever its review state."),
153
+ search: z.string().min(1).max(200).optional().describe("Words to find in the title or detail."),
154
+ limit: LIMIT,
155
+ },
156
+ }, async (args) => {
157
+ const timeline = args.timeline === true;
158
+ const refused = wrongTable("manager_list", args, timeline);
159
+ if (refused)
160
+ return refused;
161
+ const query = new URLSearchParams();
162
+ if (typeof args.kind === "string")
163
+ query.set("kind", args.kind);
164
+ if (typeof args.status === "string")
165
+ query.set("status", args.status);
166
+ if (typeof args.owner === "string")
167
+ query.set("owner", args.owner);
168
+ if (args.no_owner === true)
169
+ query.set("noOwner", "true");
170
+ if (args.overdue === true)
171
+ query.set("overdue", "true");
172
+ if (args.needs_review === true)
173
+ query.set("needsReview", "true");
174
+ if (args.archived === true)
175
+ query.set("archived", "true");
176
+ if (args.review === "dismissed")
177
+ query.set("review", "rejected"); // Dismiss writes review status "rejected"
178
+ if (args.review === "all")
179
+ query.set("review", "all");
180
+ if (typeof args.search === "string")
181
+ query.set("q", args.search);
182
+ const limit = limitOf(args);
183
+ return ask("manager_list", "GET", clientPath(args, `${table(args)}${queryString(query)}`), undefined, (answer) => answer.shown === answer.total || typeof answer.total !== "number"
184
+ ? `${String(answer.shown)} of ${String(answer.total ?? answer.shown)} ${timeline ? "timeline item(s)" : "action(s)"}.`
185
+ : `Showing ${String(answer.shown)} of ${String(answer.total)}${nextStep(answer.truncated === true ? LIST_CAP : null, limit, true)}`, { shape: (answer) => {
186
+ const { rows, more } = page(answer.items, limit);
187
+ return { ...answer, items: rows, shown: rows.length, more_exist: more };
188
+ } });
189
+ });
190
+ register("manager_client", {
191
+ title: "Read one Manager client",
192
+ description: "One client in full: its name, Notion root, onboarding state, when it was last synced and whether a run holds it now, its latest read summed over its receipts, pages waiting and incomplete, and its newest brief rebuild. "
193
+ + "Read-only. `cockpit manager client`, same door.",
194
+ inputSchema: { client_id: CLIENT_ID },
195
+ }, async (args) => ask("manager_client", "GET", door(`clients/${encodeURIComponent(String(args.client_id))}`), undefined, (answer) => `${typeof answer.name === "string" ? answer.name : "That client"}.`));
196
+ register("manager_run", {
197
+ title: "Read one Manager run's receipt",
198
+ description: "One run's receipt: its kind (sync, extract, onboard, brief_rebuild), when it started and finished, why it ended (reason), and its counts (pages seen, changed, fetched, archived; operations applied, proposed, rejected; model and tokens). "
199
+ + "Read-only. `cockpit manager run`, same door.",
200
+ inputSchema: { run_id: z.string().uuid().describe("The run's id: `changedByRunId` on a manager_queue row, `runId` on a proposal in manager_show, or `lastBriefRebuild.runId` in manager_client.") },
201
+ }, async (args) => ask("manager_run", "GET", door(`runs/${encodeURIComponent(String(args.run_id))}`), undefined, (answer) => `Run ${typeof answer.kind === "string" ? answer.kind : ""} (${typeof answer.reason === "string" ? answer.reason : "no reason named"}), ${answer.finishedAt ? "finished" : "not finished"}.`));
202
+ register("manager_queue", {
203
+ title: "A client's review queue",
204
+ description: "Changes from syncs after onboarding that nobody has reviewed yet, for one client: what a later sync added, changed or proposed, timeline items and actions, each with why it is queued (added, changed, proposal). "
205
+ + "The onboarding's own items are not in it, so an empty queue does not mean everything is reviewed: for that, use manager_list with needs_review true and status all, and manager_list with timeline true and needs_review true. "
206
+ + "Work it with manager_approve, manager_dismiss and manager_proposal. Read-only. `cockpit manager queue`, same door.",
207
+ inputSchema: { client_id: CLIENT_ID, limit: LIMIT },
208
+ }, async (args) => {
209
+ const limit = limitOf(args);
210
+ return ask("manager_queue", "GET", clientPath(args, "review-queue"), undefined, (answer) => {
211
+ const total = (answer.total ?? {});
212
+ const shown = (answer.shown ?? {});
213
+ const [totalTl, totalAc] = [total.timeline ?? 0, total.actions ?? 0];
214
+ if ((shown.timeline ?? 0) >= totalTl && (shown.actions ?? 0) >= totalAc)
215
+ return `${totalTl} timeline item(s) and ${totalAc} action(s) in the queue.`;
216
+ return `Showing ${String(shown.timeline)} of ${totalTl} timeline item(s) and ${String(shown.actions)} of ${totalAc} action(s)${nextStep(answer.truncated === true ? QUEUE_CAP : null, limit, false)}`;
217
+ }, { shape: (answer) => {
218
+ const timeline = page(answer.timeline, limit);
219
+ const actions = page(answer.actions, limit);
220
+ return { ...answer, timeline: timeline.rows, actions: actions.rows, shown: { timeline: timeline.rows.length, actions: actions.rows.length }, more_exist: timeline.more || actions.more };
221
+ } });
222
+ });
223
+ register("manager_show", {
224
+ title: "Read one Manager item",
225
+ description: "One action (default) or, with timeline true, one timeline item in full: its fields, the sources it cites, every change the AI proposed (each with the `id` manager_proposal needs), "
226
+ + "and what it links to. `updatedAt` is the `version` the next write on it needs. Read-only. `cockpit manager show`, same door.",
227
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE },
228
+ }, async (args) => ask("manager_show", "GET", itemPath(args), undefined, (answer) => `${titleOf(answer)}.`));
229
+ register("manager_add", {
230
+ title: "Add an action or timeline item",
231
+ description: "Adds one action (default: title, optional detail, due date, owners, and meeting_id to link it to a timeline item) or, with timeline true, one timeline item (kind and title are required; date is optional: "
232
+ + "without one it lands in Needs a date). Dates are YYYY-MM-DD. The item is the person's own and needs no review; source_url and source_note say where it came from. "
233
+ + "The answer (and a lost-answer or server-error refusal) carries `request_id`: add again with the same request_id and no duplicate is made; pass your own request_id (a uuid) up front to be able to retry. "
234
+ + "A request_id already used by another person or client is refused (request_reused). `cockpit manager add`, same door.",
235
+ inputSchema: {
236
+ client_id: CLIENT_ID,
237
+ timeline: TIMELINE,
238
+ kind: z.enum(KINDS).optional().describe("Timeline items only, required there: meeting, document, milestone or internal."),
239
+ title: z.string().min(1).max(300).describe("The item's title."),
240
+ detail: z.string().max(4000).optional().describe("A short paragraph about the item."),
241
+ due: DATE.optional().describe("Actions only: the due date, YYYY-MM-DD."),
242
+ date: DATE.optional().describe("Timeline items only: when it happened or will, YYYY-MM-DD. Leave out for undated."),
243
+ owners: OWNERS,
244
+ owner_name: OWNER_NAME,
245
+ meeting_id: z.string().uuid().optional().describe("Actions only: the timeline item (id from manager_list with timeline true) to link the action to."),
246
+ source_url: z.string().url().max(2000).regex(/^https?:\/\//i).optional().describe("Where it came from: an http(s) link, as the web form's Source. manager_show lists it."),
247
+ source_note: z.string().min(1).max(1000).optional().describe("Where it came from, in words (at most 1,000 characters). manager_show lists it."),
248
+ request_id: z.string().uuid().optional().describe("A uuid naming this add, so a retry returns the same item. Default: a fresh one."),
249
+ },
250
+ }, async (args) => {
251
+ const timeline = args.timeline === true;
252
+ const refused = wrongTable("manager_add", args, timeline);
253
+ if (refused)
254
+ return refused;
255
+ const requestId = typeof args.request_id === "string" ? args.request_id : randomUUID();
256
+ return ask("manager_add", "POST", clientPath(args, table(args)), { ...itemFields(args), requestId }, (answer) => `Added ${titleOf(answer)}. request_id ${String(answer.request_id)}: retry with it if unsure it was saved.`, { shape: (answer) => ({ ...answer, request_id: requestId }), suffix: ` If unsure whether it was saved, call manager_add again with request_id ${requestId}: no duplicate is made.` });
257
+ });
258
+ register("manager_edit", {
259
+ title: "Edit an action or timeline item",
260
+ description: "Changes the fields you name on an action (default) or, with timeline true, a timeline item; a field you leave out keeps its value. `version` is required. Dates are YYYY-MM-DD; null clears a date. "
261
+ + "Actions: owners (leave out to leave the owners alone, [] to clear, a list to replace). Timeline items: kind, date, owner_name. `cockpit manager edit`, same door.",
262
+ inputSchema: {
263
+ item_id: ITEM_ID,
264
+ timeline: TIMELINE,
265
+ version: VERSION,
266
+ title: z.string().min(1).max(300).optional().describe("The new title."),
267
+ detail: z.string().max(4000).optional().describe("The new detail; an empty string clears it."),
268
+ kind: z.enum(KINDS).optional().describe("Timeline items only: the new kind."),
269
+ due: DATE.nullable().optional().describe("Actions only: the new due date, YYYY-MM-DD, or null for none."),
270
+ date: DATE.nullable().optional().describe("Timeline items only: the new date, YYYY-MM-DD, or null for undated."),
271
+ owners: OWNERS,
272
+ owner_name: OWNER_NAME,
273
+ },
274
+ }, async (args) => {
275
+ const refused = wrongTable("manager_edit", args, args.timeline === true);
276
+ if (refused)
277
+ return refused;
278
+ return ask("manager_edit", "PATCH", itemPath(args), { ...itemFields(args), expectedUpdatedAt: args.version }, (answer) => `Saved ${titleOf(answer)}.`);
279
+ });
280
+ const ACTION_VERSION = { item_id: z.string().uuid().describe("The action's id, from manager_list."), version: VERSION };
281
+ register("manager_done", {
282
+ title: "Mark an action done",
283
+ description: "Ticks an action off (status done). Actions only. `version` is required. manager_reopen undoes it. `cockpit manager done`, same door.",
284
+ inputSchema: ACTION_VERSION,
285
+ }, async (args) => ask("manager_done", "POST", itemPath({ ...args, timeline: false }, "/status"), { status: "done", expectedUpdatedAt: args.version }, (answer) => (sameVersion(answer, args.version) ? `Already done; nothing changed: ${titleOf(answer)}.` : `Marked done: ${titleOf(answer)}.`)));
286
+ register("manager_reopen", {
287
+ title: "Reopen an action",
288
+ description: "Puts a done action back to open. Actions only. `version` is required. `cockpit manager reopen`, same door.",
289
+ inputSchema: ACTION_VERSION,
290
+ }, async (args) => ask("manager_reopen", "POST", itemPath({ ...args, timeline: false }, "/status"), { status: "open", expectedUpdatedAt: args.version }, (answer) => (sameVersion(answer, args.version) ? `Already open; nothing changed: ${titleOf(answer)}.` : `Reopened: ${titleOf(answer)}.`)));
291
+ register("manager_link", {
292
+ title: "Link an action to a timeline item",
293
+ description: "Links an action to a timeline item (a meeting, say) by meeting_id, or unlinks it when meeting_id is null. Actions only. `version` is the action's. `cockpit manager link`, same door.",
294
+ inputSchema: {
295
+ ...ACTION_VERSION,
296
+ meeting_id: z.string().uuid().nullable().describe("The timeline item's id (manager_list with timeline true), or null to unlink."),
297
+ },
298
+ }, async (args) => ask("manager_link", "POST", itemPath({ ...args, timeline: false }, "/link"), { timelineItemId: args.meeting_id, expectedUpdatedAt: args.version }, (answer) => `${args.meeting_id === null ? "Unlinked" : "Linked"}: ${titleOf(answer)}.`));
299
+ const AS_YOU = "This decides the AI's suggestion as the person whose key this is: their name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it. It reaches nobody, so no confirm is needed.";
300
+ register("manager_approve", {
301
+ title: "Approve an AI-added item",
302
+ description: `Approves an item the AI added or changed: it stays, marked reviewed. ${AS_YOU} For an item carrying a proposed change use manager_proposal. `
303
+ + "Approving a DISMISSED item brings it back into the lists (the web's Restore button), so the item is read first: a dismissed one is refused unless restore is true, and restore on an item that is not dismissed is refused too. Nothing is written on a refusal. "
304
+ + "`version` is required. `cockpit manager approve` (with `--restore`), same door.",
305
+ inputSchema: {
306
+ item_id: ITEM_ID,
307
+ timeline: TIMELINE,
308
+ version: VERSION,
309
+ restore: z.boolean().optional().describe("true to bring a DISMISSED item back into the lists, marked reviewed by the person whose key this is. Only for a dismissed item; leave it out to approve an item that is not dismissed."),
310
+ },
311
+ }, async (args) => withSession(deps, async (session) => {
312
+ const read = await callAgentDoor(session, deps.fetchImpl, "GET", itemPath(args), undefined);
313
+ if (!read.ok)
314
+ return errorResult(managerFailureText("manager_show", read));
315
+ const dismissed = read.body.item?.reviewStatus === "rejected";
316
+ const restore = args.restore === true;
317
+ if (dismissed && !restore) {
318
+ return errorResult("Refused (restore_needed): This item is dismissed. Approving it brings it back into the lists, marked reviewed by you. To do that, call manager_approve again with restore true. Nothing was changed.");
319
+ }
320
+ if (!dismissed && restore) {
321
+ return errorResult("Refused (not_dismissed): restore is only for a dismissed item; this one is not dismissed. Call manager_approve without restore. Nothing was changed.");
322
+ }
323
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", itemPath(args, "/review"), { decision: "accept", expectedUpdatedAt: args.version });
324
+ if (!response.ok)
325
+ return errorResult(managerFailureText("manager_approve", response));
326
+ return dataResult(restore ? `Restored: ${titleOf(response.body)}, back in the lists and marked reviewed by you.` : `Approved ${titleOf(response.body)}.`, response.body);
327
+ }));
328
+ register("manager_dismiss", {
329
+ title: "Dismiss an AI-added item",
330
+ description: `Dismisses an item the AI added that does not belong: it stops showing in the lists. ${AS_YOU} For a proposed change use manager_proposal with decision dismiss. `
331
+ + "`version` is required. `cockpit manager dismiss`, same door.",
332
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
333
+ }, async (args) => ask("manager_dismiss", "POST", itemPath(args, "/review"), { decision: "dismiss", expectedUpdatedAt: args.version }, (answer) => `Dismissed ${titleOf(answer)}.`));
334
+ register("manager_proposal", {
335
+ title: "Decide one AI-proposed change",
336
+ description: `Decides one change the AI proposed to an item a person owns: accept applies it, dismiss keeps the item as it is and remembers the refusal, add_new makes the proposal an item of its own (update and duplicate proposals only; a close proposal is refused). ${AS_YOU} `
337
+ + "`proposal_id` is the `id` of an entry in the item's proposedChanges (manager_show); a newer run may have replaced it, then the decision is refused. `version` is required. `cockpit manager proposal`, same door.",
338
+ inputSchema: {
339
+ item_id: ITEM_ID,
340
+ timeline: TIMELINE,
341
+ version: VERSION,
342
+ proposal_id: z.string().min(1).max(64).describe("The proposal's id, from the item's proposedChanges in manager_show."),
343
+ decision: z.enum(["accept", "dismiss", "add_new"]).describe("accept, dismiss, or add_new (make it a new item)."),
344
+ },
345
+ }, async (args) => ask("manager_proposal", "POST", itemPath(args, "/proposal"), {
346
+ decision: args.decision === "add_new" ? "add_as_new" : args.decision,
347
+ proposalId: args.proposal_id,
348
+ expectedUpdatedAt: args.version,
349
+ }, (answer) => {
350
+ const added = answer.added;
351
+ return `Decided (${String(args.decision)}) on ${titleOf(answer)}.${typeof added?.title === "string" ? ` Added as a new item: ${added.title}.` : ""}`;
352
+ }));
353
+ register("manager_archive", {
354
+ title: "Archive an item",
355
+ description: "Archives an action (default) or timeline item (timeline true): it leaves the lists but is kept, and manager_unarchive puts it back as it was (list archived ones with manager_list archived true). `version` is required. `cockpit manager archive`, same door.",
356
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
357
+ }, async (args) => ask("manager_archive", "POST", itemPath(args, "/archive"), { expectedUpdatedAt: args.version }, (answer) => `Archived ${titleOf(answer)}.`));
358
+ register("manager_unarchive", {
359
+ title: "Unarchive an item",
360
+ description: "Puts an archived action (default) or timeline item (timeline true) back as it was. `version` is the archived item's updatedAt. `cockpit manager unarchive`, same door.",
361
+ inputSchema: { item_id: ITEM_ID, timeline: TIMELINE, version: VERSION },
362
+ }, async (args) => ask("manager_unarchive", "POST", itemPath(args, "/unarchive"), { expectedUpdatedAt: args.version }, (answer) => `Unarchived ${titleOf(answer)}.`));
363
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Tower Manager's twins in the verb census (BLI-5006).
3
+ *
4
+ * `cockpit manager <verb>` and the `manager_*` tools both ride `/api/manager/**`,
5
+ * which hands the Manager's own answer back unchanged. A verb that works on both
6
+ * tables (an action, or a timeline item with `--timeline`) names both doors.
7
+ * Split out of `verb-census.ts`, which sits at the file-size ratchet.
8
+ */
9
+ import type { McpTwin } from "./verb-census.js";
10
+ export declare const MANAGER_TWINS: Record<string, McpTwin>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The Tower Manager's twins in the verb census (BLI-5006).
3
+ *
4
+ * `cockpit manager <verb>` and the `manager_*` tools both ride `/api/manager/**`,
5
+ * which hands the Manager's own answer back unchanged. A verb that works on both
6
+ * tables (an action, or a timeline item with `--timeline`) names both doors.
7
+ * Split out of `verb-census.ts`, which sits at the file-size ratchet.
8
+ */
9
+ export const MANAGER_TWINS = {
10
+ "manager clients": { tool: "manager_clients", door: "GET /api/manager/clients" },
11
+ "manager client": { tool: "manager_client", door: "GET /api/manager/clients/[clientId]" },
12
+ "manager run": { tool: "manager_run", door: "GET /api/manager/runs/[runId]" },
13
+ "manager list": { tool: "manager_list", door: "GET /api/manager/clients/[clientId]/actions (or /timeline)" },
14
+ "manager queue": { tool: "manager_queue", door: "GET /api/manager/clients/[clientId]/review-queue" },
15
+ "manager show": { tool: "manager_show", door: "GET /api/manager/actions/[id] (or /timeline/[id])" },
16
+ "manager add": { tool: "manager_add", door: "POST /api/manager/clients/[clientId]/actions (or /timeline)" },
17
+ "manager edit": { tool: "manager_edit", door: "PATCH /api/manager/actions/[id] (or /timeline/[id])" },
18
+ "manager done": { tool: "manager_done", door: "POST /api/manager/actions/[id]/status" },
19
+ "manager reopen": { tool: "manager_reopen", door: "POST /api/manager/actions/[id]/status" },
20
+ "manager link": { tool: "manager_link", door: "POST /api/manager/actions/[id]/link" },
21
+ "manager approve": { tool: "manager_approve", door: "POST /api/manager/actions/[id]/review (or /timeline/[id]/review)" },
22
+ "manager dismiss": { tool: "manager_dismiss", door: "POST /api/manager/actions/[id]/review (or /timeline/[id]/review)" },
23
+ "manager proposal": { tool: "manager_proposal", door: "POST /api/manager/actions/[id]/proposal (or /timeline/[id]/proposal)" },
24
+ "manager archive": { tool: "manager_archive", door: "POST /api/manager/actions/[id]/archive (or /timeline/[id]/archive)" },
25
+ "manager unarchive": { tool: "manager_unarchive", door: "POST /api/manager/actions/[id]/unarchive (or /timeline/[id]/unarchive)" },
26
+ };
package/dist/server.js CHANGED
@@ -43,6 +43,7 @@ import { registerUsageTools } from "./usage-tools.js";
43
43
  import { registerLogsTools } from "./logs-tools.js";
44
44
  import { registerBrainTools } from "./brain-tools.js";
45
45
  import { registerApprovalsTools } from "./approvals-tools.js";
46
+ import { registerManagerTools } from "./manager-tools.js";
46
47
  import { registerImageTools } from "./image-tools.js";
47
48
  import { registerSourcesTools } from "./sources-tools.js";
48
49
  import { TOWER_SERVER_ICONS } from "./server-icons.js";
@@ -526,6 +527,8 @@ export function createServer(deps, options = {}) {
526
527
  registerBrainTools(server, { fetchImpl: deps.fetchImpl });
527
528
  // BLI-16945: the approvals inbox, over the same /api/approvals doors the page and `cockpit approvals` call.
528
529
  registerApprovalsTools(server, { fetchImpl: deps.fetchImpl });
530
+ // BLI-5006: Tower Manager's clients, actions and timeline, over the same /api/manager door `cockpit manager` calls.
531
+ registerManagerTools(server, { fetchImpl: deps.fetchImpl });
529
532
  // BLI-16977: image descriptions, over the same /api/ambient/images door `cockpit image` calls.
530
533
  registerImageTools(server, { fetchImpl: deps.fetchImpl });
531
534
  return server;
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
12
  /** Tools that change nothing. The smoke calls every one. */
13
- export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_board", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules", "image_show"];
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_board", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules", "manager_clients", "manager_client", "manager_run", "manager_list", "manager_queue", "manager_show", "image_show"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["image_describe", "approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_grant", "team_revoke", "team_device_revoke", "team_device_doctor"];
24
+ export declare const WRITE_TOOLS: readonly ["image_describe", "approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "manager_add", "manager_edit", "manager_done", "manager_reopen", "manager_link", "manager_approve", "manager_dismiss", "manager_proposal", "manager_archive", "manager_unarchive", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_grant", "team_revoke", "team_device_revoke", "team_device_doctor"];
25
25
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
26
  export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
27
  /**
@@ -105,6 +105,13 @@ export const READ_TOOLS = [
105
105
  "approvals_list",
106
106
  "approvals_show",
107
107
  "approvals_rules",
108
+ // BLI-5006: Tower Manager's clients, lists, review queue and one item. Reads only.
109
+ "manager_clients",
110
+ "manager_client",
111
+ "manager_run",
112
+ "manager_list",
113
+ "manager_queue",
114
+ "manager_show",
108
115
  // BLI-16977: a stored image description, by sha256. Reads only.
109
116
  "image_show",
110
117
  ];
@@ -128,6 +135,17 @@ export const WRITE_TOOLS = [
128
135
  "approvals_reject",
129
136
  "approvals_rules_create",
130
137
  "approvals_rules_revoke",
138
+ // BLI-5006: Tower Manager's writes. Archive is undone by unarchive, so none is destructive.
139
+ "manager_add",
140
+ "manager_edit",
141
+ "manager_done",
142
+ "manager_reopen",
143
+ "manager_link",
144
+ "manager_approve",
145
+ "manager_dismiss",
146
+ "manager_proposal",
147
+ "manager_archive",
148
+ "manager_unarchive",
131
149
  "logs_key_mint",
132
150
  "brain_retry",
133
151
  "emit_event",
@@ -29,6 +29,7 @@
29
29
  import { readFileSync, readdirSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
+ import { MANAGER_TWINS } from "./manager-twins.js";
32
33
  import { IMAGE_TWINS, MEMORY_STORE_TWINS } from "./memory-twins.js";
33
34
  const HERE = dirname(fileURLToPath(import.meta.url));
34
35
  export const COLLECTOR_COMMANDS_DIR = resolve(HERE, "../../cockpit-local-collector/src/commands");
@@ -349,9 +350,8 @@ export const MCP_TWINS = {
349
350
  "notes bot-upcoming": { tool: "notes_bot_upcoming", door: "GET /api/notetaker/upcoming" },
350
351
  "notes granola-status": { tool: "notes_granola_status", door: "GET /api/notes/granola-status" },
351
352
  "memory log": { tool: "memory_experience", door: "POST /api/memory/experience" },
352
- // BLI-4047, BLI-12927: the memory STORE at a terminal. Its twins live on the
353
- // second server, `bli-memory` (see `memory-twins.ts`, which also holds the image twins).
354
- ...MEMORY_STORE_TWINS, ...IMAGE_TWINS,
353
+ // BLI-4047, BLI-12927: the memory STORE (twins on the `bli-memory` server) and images: `memory-twins.ts`; BLI-5006: the Manager: `manager-twins.ts`.
354
+ ...MEMORY_STORE_TWINS, ...IMAGE_TWINS, ...MANAGER_TWINS,
355
355
  // BLI-4533: the compiled one-page profile. Its twin is on `bli-tower`, not
356
356
  // `bli-memory`: that server's four tools are the vendor-compatible set the
357
357
  // Claude Code plugin expects, and this page is ours.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.120",
3
+ "version": "0.1.122",
4
4
  "private": false,
5
5
  "description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "readme": "npm run build && node scripts/write-readme-census.mjs"
38
38
  },
39
39
  "dependencies": {
40
- "@bli-cockpit/telemetry-core": "0.1.74",
40
+ "@bli-cockpit/telemetry-core": "0.1.75",
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
42
  "zod": "^4.3.6"
43
43
  },