@bli-cockpit/mcp 0.1.69 → 0.1.71
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 +5 -1
- package/dist/brief-tools.js +20 -0
- package/dist/cal-sync-status-tool.d.ts +11 -0
- package/dist/cal-sync-status-tool.js +38 -0
- package/dist/cal-tools.js +2 -0
- package/dist/docs-notion-tools.js +12 -1
- package/dist/mail-tools.js +24 -0
- package/dist/notes-account-tools.js +24 -0
- package/dist/notes-granola-status-tool.d.ts +15 -0
- package/dist/notes-granola-status-tool.js +40 -0
- package/dist/ops-tools.js +8 -0
- package/dist/server.js +4 -0
- package/dist/tool-catalog.d.ts +1 -1
- package/dist/tool-catalog.js +5 -0
- package/dist/verb-census.js +5 -0
- package/dist/work-flag-census.d.ts +22 -0
- package/dist/work-flag-census.js +109 -0
- package/dist/work-issue-rows.d.ts +30 -0
- package/dist/work-issue-rows.js +41 -1
- package/dist/work-tools.js +56 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ about what an agent can reach.
|
|
|
103
103
|
|
|
104
104
|
<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
|
|
105
105
|
|
|
106
|
-
**
|
|
106
|
+
**115 of 120 Tower verbs have an MCP twin.**
|
|
107
107
|
Each tool goes through the SAME door its CLI verb calls, with the same
|
|
108
108
|
collector device token — never a second route and never a service-role
|
|
109
109
|
reader. `src/verb-census.test.ts` fails when a verb is in none of the
|
|
@@ -125,6 +125,7 @@ three tables below.
|
|
|
125
125
|
| `cockpit cal next` | `cal_next` | `GET /api/cal/next` |
|
|
126
126
|
| `cockpit cal share` | `cal_share` | `PATCH /api/cal/calendars` |
|
|
127
127
|
| `cockpit cal sync` | `cal_sync` | `POST /api/cal/calendars/[id]/sync` |
|
|
128
|
+
| `cockpit cal sync-status` | `cal_sync_status` | `GET /api/cal/sync-status` |
|
|
128
129
|
| `cockpit cal today` | `cal_today` | `GET /api/cal/today` |
|
|
129
130
|
| `cockpit cal week` | `cal_week` | `GET /api/cal/week` |
|
|
130
131
|
| `cockpit careers decide` | `careers_decide` | `POST /api/careers/applications/[id]/decide` |
|
|
@@ -164,6 +165,7 @@ three tables below.
|
|
|
164
165
|
| `cockpit mail search` | `mail_search` | `GET /api/mail/search` |
|
|
165
166
|
| `cockpit mail send` | `mail_send` | `POST /api/mail/send` |
|
|
166
167
|
| `cockpit mail sync` | `mail_sync` | `POST /api/mail/accounts/[id]/sync` |
|
|
168
|
+
| `cockpit mail sync-status` | `mail_sync_status` | `GET /api/mail/sync-status` |
|
|
167
169
|
| `cockpit memory consolidation` | `memory_consolidation` | `GET /api/memory/consolidation` |
|
|
168
170
|
| `cockpit memory forget` | `forget_memory` (`bli-memory`) | `POST /api/memory/forget` |
|
|
169
171
|
| `cockpit memory log` | `memory_experience` | `POST /api/memory/experience` |
|
|
@@ -186,6 +188,7 @@ three tables below.
|
|
|
186
188
|
| `cockpit notes accounts` | `notes_accounts` | `GET /api/notes/accounts` |
|
|
187
189
|
| `cockpit notes detach` | `notes_detach` | `DELETE /api/notes/accounts` |
|
|
188
190
|
| `cockpit notes folders` | `notes_folders` | `GET /api/notes/folders` |
|
|
191
|
+
| `cockpit notes granola-status` | `notes_granola_status` | `GET /api/notes/granola-status` |
|
|
189
192
|
| `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
|
|
190
193
|
| `cockpit notes mkdir` | `notes_mkdir` | `POST /api/notes/folders` |
|
|
191
194
|
| `cockpit notes move` | `notes_move` | `POST /api/notes/move` |
|
|
@@ -198,6 +201,7 @@ three tables below.
|
|
|
198
201
|
| `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
|
|
199
202
|
| `cockpit notes show` | `notes_show` | `GET /api/notes/library/[id]` |
|
|
200
203
|
| `cockpit notes sync` | `notes_sync` | `POST /api/notes/accounts/[id]/sync` |
|
|
204
|
+
| `cockpit notes sync-status` | `notes_sync_status` | `GET /api/notes/sync-status` |
|
|
201
205
|
| `cockpit notes unshare` | `notes_unshare` | `POST /api/notes/share (share=false)` |
|
|
202
206
|
| `cockpit notes upload` | `notes_upload` | `POST /api/notes/upload` |
|
|
203
207
|
| `cockpit ops recompile` | `ops_recompile` | `POST /api/ops/recompile` |
|
package/dist/brief-tools.js
CHANGED
|
@@ -157,4 +157,24 @@ export function registerBriefTools(server, deps) {
|
|
|
157
157
|
return textResult(`${status.displayName ?? "This person"} — ${reason}${detail}${slack}${readError}`
|
|
158
158
|
+ `\n${status.owningCadence ?? "?"} owns this morning${status.timeZone ? ` (${status.timeZone})` : ""}${rendered}`, { status, ...(body.render ? { render: body.render } : {}) });
|
|
159
159
|
}));
|
|
160
|
+
register("brief_missed", {
|
|
161
|
+
title: "Who is missing their Tower daily brief right now",
|
|
162
|
+
description: "Every roster person owed a brief who has no delivered receipt for their own most recently passed window, "
|
|
163
|
+
+ "and why — `compile_failed`, `page_refused`, `dm_failed`, or `no_page_shape` for a person with no page "
|
|
164
|
+
+ "shape at all (named, never dropped). Same reader `cockpit ops status`'s `jarvis-ic-dm` row folds in. "
|
|
165
|
+
+ "Reads across the team, same gate as `brief_status`'s `subject`.",
|
|
166
|
+
inputSchema: {},
|
|
167
|
+
}, async () => withSession(deps, async (session) => {
|
|
168
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/ops/brief-status?missed=1");
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
return errorResult(doorFailureText("brief_missed", response));
|
|
171
|
+
const body = response.body;
|
|
172
|
+
const coverage = body.coverage ?? {};
|
|
173
|
+
const missed = coverage.missed ?? [];
|
|
174
|
+
const lines = missed
|
|
175
|
+
.map((person) => `${person.displayName ?? "?"} ${person.missReason ?? "?"}${person.detail ? ` — ${person.detail}` : ""}`)
|
|
176
|
+
.join("\n");
|
|
177
|
+
return textResult(`${coverage.verdict ?? "unknown"}: ${missed.length} missing of those owed a brief${coverage.detail ? `\n${coverage.detail}` : ""}`
|
|
178
|
+
+ `${lines ? `\n${lines}` : ""}`, { coverage });
|
|
179
|
+
}));
|
|
160
180
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cal_sync_status` (ticket 10730) — split out of `cal-tools.ts` when adding
|
|
3
|
+
* it there would have pushed that file over the 500-line readability band.
|
|
4
|
+
* Same door as `cockpit cal sync-status`: every calendar's sync health, or
|
|
5
|
+
* one's, read from `cal_sync_runs` on the caller's own RLS client. Built
|
|
6
|
+
* because the integrations audit had no Tower door for this and went to the
|
|
7
|
+
* ledger table directly; `mail_sync_status` and `notes_sync_status` are its
|
|
8
|
+
* siblings, same shape, same reason.
|
|
9
|
+
*/
|
|
10
|
+
import { type RegisterTool, type ToolDeps } from "./tool-result.js";
|
|
11
|
+
export declare function registerCalSyncStatusTool(register: RegisterTool, deps: ToolDeps): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cal_sync_status` (ticket 10730) — split out of `cal-tools.ts` when adding
|
|
3
|
+
* it there would have pushed that file over the 500-line readability band.
|
|
4
|
+
* Same door as `cockpit cal sync-status`: every calendar's sync health, or
|
|
5
|
+
* one's, read from `cal_sync_runs` on the caller's own RLS client. Built
|
|
6
|
+
* because the integrations audit had no Tower door for this and went to the
|
|
7
|
+
* ledger table directly; `mail_sync_status` and `notes_sync_status` are its
|
|
8
|
+
* siblings, same shape, same reason.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
12
|
+
import { doorFailureText, errorResult, textResult, withSession } from "./tool-result.js";
|
|
13
|
+
export function registerCalSyncStatusTool(register, deps) {
|
|
14
|
+
register("cal_sync_status", {
|
|
15
|
+
title: "Read calendar sync health",
|
|
16
|
+
description: "Every calendar's sync health, or one's: the last successful pass, the last pass's own result and reason, "
|
|
17
|
+
+ "how many events it carried, failures in the last 24 hours, and the LAG between an event's own provider "
|
|
18
|
+
+ "clock and when Tower actually stored it. Metadata only — the same read `cockpit cal sync-status` makes "
|
|
19
|
+
+ "(ticket 10730, built because the integrations audit had no Tower door for this and went to the table directly).",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
calendar_id: z.string().uuid().optional().describe("One calendar id (cal_calendars). Omit for every calendar you may read."),
|
|
22
|
+
},
|
|
23
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
24
|
+
const path = args.calendar_id
|
|
25
|
+
? `/api/cal/sync-status?calendar_id=${encodeURIComponent(String(args.calendar_id))}`
|
|
26
|
+
: "/api/cal/sync-status";
|
|
27
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
|
|
28
|
+
if (!response.ok)
|
|
29
|
+
return errorResult(doorFailureText("cal_sync_status", response));
|
|
30
|
+
const calendars = (Array.isArray(response.body.calendars) ? response.body.calendars : []);
|
|
31
|
+
const lines = calendars
|
|
32
|
+
.map((calendar) => `${calendar.name} last_success=${calendar.last_success_at ?? "never"} last_run=${calendar.last_run ? `${calendar.last_run.reason}@${calendar.last_run.started_at}` : "never"} failures_24h=${calendar.failures_24h}${calendar.data_incomplete ? " (partial read)" : ""}`)
|
|
33
|
+
.join("\n");
|
|
34
|
+
return textResult(calendars.length === 0 ? "No calendars are attached." : `${calendars.length} calendar(s).\n${lines}`, {
|
|
35
|
+
calendars,
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
package/dist/cal-tools.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { z } from "zod";
|
|
35
35
|
import { callAgentDoor } from "./agent-door.js";
|
|
36
|
+
import { registerCalSyncStatusTool } from "./cal-sync-status-tool.js";
|
|
36
37
|
import { registerCalDeleteTool } from "./cal-tools-delete.js";
|
|
37
38
|
import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
38
39
|
/** This machine's zone: what a person means by "today" unless they say otherwise. */
|
|
@@ -278,6 +279,7 @@ export function registerCalTools(server, deps) {
|
|
|
278
279
|
return textResult(`Detached ${String(args.calendar_id)}. Its stored events went with it; the calendar itself is untouched at Google.`, response.body);
|
|
279
280
|
}));
|
|
280
281
|
registerCalDeleteTool(register, deps);
|
|
282
|
+
registerCalSyncStatusTool(register, deps);
|
|
281
283
|
}
|
|
282
284
|
/** BLI-10560: the new event's Tower id, or why there is none yet. */
|
|
283
285
|
function towerIdLine(event) {
|
|
@@ -81,9 +81,20 @@ export function registerDocsNotionTools(server, deps) {
|
|
|
81
81
|
const summary = Object.entries(counts?.by_state ?? {})
|
|
82
82
|
.map(([state, count]) => `${count} ${state}`)
|
|
83
83
|
.join(", ");
|
|
84
|
-
|
|
84
|
+
const lastRun = response.body.last_run;
|
|
85
|
+
const runLine = ["push", "pull"]
|
|
86
|
+
.map((direction) => `${direction} ${runSentence(lastRun?.[direction] ?? null)}`)
|
|
87
|
+
.join(" · ");
|
|
88
|
+
return textResult(`${counts?.linked ?? 0} linked: ${summary}. Last run — ${runLine}.`, {
|
|
85
89
|
counts,
|
|
86
90
|
documents: response.body.documents ?? [],
|
|
91
|
+
last_run: lastRun ?? { push: null, pull: null },
|
|
87
92
|
});
|
|
88
93
|
}));
|
|
89
94
|
}
|
|
95
|
+
function runSentence(run) {
|
|
96
|
+
if (!run)
|
|
97
|
+
return "never run";
|
|
98
|
+
const moved = run.pushed || run.pulled;
|
|
99
|
+
return `${run.reason} (${moved} moved, ${run.failed} failed) at ${run.started_at}`;
|
|
100
|
+
}
|
package/dist/mail-tools.js
CHANGED
|
@@ -51,6 +51,30 @@ export function registerMailTools(server, deps) {
|
|
|
51
51
|
? "No mailboxes are attached. A person attaches one with `cockpit mail add-imap` (personal) or the browser (work)."
|
|
52
52
|
: `${accounts.length} mailbox(es).\n${lines}`, { accounts });
|
|
53
53
|
}));
|
|
54
|
+
register("mail_sync_status", {
|
|
55
|
+
title: "Read mail sync health",
|
|
56
|
+
description: "Every mailbox's sync health, or one's: the last successful pass, the last pass's own result and reason, "
|
|
57
|
+
+ "how many messages it carried, failures in the last 24 hours, and the LAG between a message's own clock "
|
|
58
|
+
+ "and when Tower actually stored it. Metadata only — the same read `cockpit mail sync-status` makes "
|
|
59
|
+
+ "(ticket 10730, built because the integrations audit had no Tower door for this and went to the table directly).",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
account_id: z.string().uuid().optional().describe("One mailbox id (mail_accounts). Omit for every mailbox you may read."),
|
|
62
|
+
},
|
|
63
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
64
|
+
const path = args.account_id
|
|
65
|
+
? `/api/mail/sync-status?account_id=${encodeURIComponent(String(args.account_id))}`
|
|
66
|
+
: "/api/mail/sync-status";
|
|
67
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
return errorResult(doorFailureText("mail_sync_status", response));
|
|
70
|
+
const accounts = (Array.isArray(response.body.accounts) ? response.body.accounts : []);
|
|
71
|
+
const lines = accounts
|
|
72
|
+
.map((account) => `${account.address} last_success=${account.last_success_at ?? "never"} last_run=${account.last_run ? `${account.last_run.reason}@${account.last_run.started_at}` : "never"} failures_24h=${account.failures_24h}${account.data_incomplete ? " (partial read)" : ""}`)
|
|
73
|
+
.join("\n");
|
|
74
|
+
return textResult(accounts.length === 0 ? "No mailboxes are attached." : `${accounts.length} mailbox(es).\n${lines}`, {
|
|
75
|
+
accounts,
|
|
76
|
+
});
|
|
77
|
+
}));
|
|
54
78
|
register("mail_inbox", {
|
|
55
79
|
title: "Read the unified inbox",
|
|
56
80
|
description: "Messages across every mailbox you may read, newest first. Never a body — call mail_read on a thread for that. "
|
|
@@ -45,6 +45,30 @@ export function registerNotesAccountTools(server, deps) {
|
|
|
45
45
|
? "No notetaker is connected. A person connects one with `cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin`, or in Tower's Settings."
|
|
46
46
|
: `${accounts.length} notetaker(s).\n${accounts.map(accountLine).join("\n")}`, { accounts });
|
|
47
47
|
}));
|
|
48
|
+
register("notes_sync_status", {
|
|
49
|
+
title: "Read notetaker sync health",
|
|
50
|
+
description: "Every notetaker's sync health, or one's: the last successful pass, the last pass's own result and reason, "
|
|
51
|
+
+ "how many meetings it carried, failures in the last 24 hours, and the LAG between the newest meeting it has "
|
|
52
|
+
+ "already read and when it last ran. Metadata only — the same read `cockpit notes sync-status` makes "
|
|
53
|
+
+ "(ticket 10730, built because the integrations audit had no Tower door for this and went to the table directly).",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
account_id: z.string().uuid().optional().describe("One notetaker account id (notes_accounts). Omit for every account you may read."),
|
|
56
|
+
},
|
|
57
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
58
|
+
const path = args.account_id
|
|
59
|
+
? `/api/notes/sync-status?account_id=${encodeURIComponent(String(args.account_id))}`
|
|
60
|
+
: "/api/notes/sync-status";
|
|
61
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
return errorResult(doorFailureText("notes_sync_status", response));
|
|
64
|
+
const accounts = (Array.isArray(response.body.accounts) ? response.body.accounts : []);
|
|
65
|
+
const lines = accounts
|
|
66
|
+
.map((account) => `${account.label ?? account.provider} last_success=${account.last_success_at ?? "never"} last_run=${account.last_run ? `${account.last_run.reason}@${account.last_run.started_at}` : "never"} failures_24h=${account.failures_24h}${account.data_incomplete ? " (partial read)" : ""}`)
|
|
67
|
+
.join("\n");
|
|
68
|
+
return textResult(accounts.length === 0 ? "No notetaker is connected." : `${accounts.length} notetaker(s).\n${lines}`, {
|
|
69
|
+
accounts,
|
|
70
|
+
});
|
|
71
|
+
}));
|
|
48
72
|
register("notes_sync", {
|
|
49
73
|
title: "Read a notetaker now",
|
|
50
74
|
description: "Reads the meetings this notetaker has recorded since the last pass and files them as meeting notes. "
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_granola_status` MCP tool (ticket 10718) — the Granola cron's own
|
|
3
|
+
* last several runs, on the `bli-tower` server, over the same
|
|
4
|
+
* `/api/notes/granola-status` door `cockpit notes granola-status` calls,
|
|
5
|
+
* authenticated with this machine's collector device token.
|
|
6
|
+
*
|
|
7
|
+
* Granola runs on one shared server-side key rather than a `notes_accounts`
|
|
8
|
+
* row a person connects, so this is its own tool beside
|
|
9
|
+
* `notes-account-tools.ts` rather than a filter on `notes_accounts`.
|
|
10
|
+
*/
|
|
11
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
12
|
+
export type NotesGranolaStatusDeps = ToolDeps;
|
|
13
|
+
export declare function registerNotesGranolaStatusTool(server: {
|
|
14
|
+
registerTool: (...args: never[]) => unknown;
|
|
15
|
+
}, deps: NotesGranolaStatusDeps): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_granola_status` MCP tool (ticket 10718) — the Granola cron's own
|
|
3
|
+
* last several runs, on the `bli-tower` server, over the same
|
|
4
|
+
* `/api/notes/granola-status` door `cockpit notes granola-status` calls,
|
|
5
|
+
* authenticated with this machine's collector device token.
|
|
6
|
+
*
|
|
7
|
+
* Granola runs on one shared server-side key rather than a `notes_accounts`
|
|
8
|
+
* row a person connects, so this is its own tool beside
|
|
9
|
+
* `notes-account-tools.ts` rather than a filter on `notes_accounts`.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
13
|
+
import { doorFailureText, errorResult, queryString, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
14
|
+
function runLine(run) {
|
|
15
|
+
const spend = run.spend_usd === null ? "n/a" : `$${run.spend_usd.toFixed(4)}`;
|
|
16
|
+
return `${run.started_at} ${run.reason} seen=${run.notes_seen} stored=${run.notes_stored} `
|
|
17
|
+
+ `failed=${run.notes_failed} model=${run.model ?? "n/a"} spend=${spend}`;
|
|
18
|
+
}
|
|
19
|
+
export function registerNotesGranolaStatusTool(server, deps) {
|
|
20
|
+
const register = registrarFor(server);
|
|
21
|
+
register("notes_granola_status", {
|
|
22
|
+
title: "The Granola cron's own health",
|
|
23
|
+
description: "The newest granola:pull runs (ticket 10718): each one's reason (ok, partial, or a named setup failure), "
|
|
24
|
+
+ "how many notes it saw/stored/failed, the model it used, real spend, and the lookback window it computed. "
|
|
25
|
+
+ "Granola runs on one shared server-side key, not a connected account, so this answers for the CRON itself "
|
|
26
|
+
+ "rather than `notes_accounts`.",
|
|
27
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("Newest N runs. Default 10, ceiling 50.") },
|
|
28
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
if (args.limit !== undefined)
|
|
31
|
+
query.set("limit", String(args.limit));
|
|
32
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/granola-status${queryString(query)}`);
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
return errorResult(doorFailureText("notes_granola_status", response));
|
|
35
|
+
const runs = (Array.isArray(response.body.runs) ? response.body.runs : []);
|
|
36
|
+
return textResult(runs.length === 0
|
|
37
|
+
? "No granola:pull run has ever landed a row."
|
|
38
|
+
: `${runs.length} run(s).\n${runs.map(runLine).join("\n")}`, { runs });
|
|
39
|
+
}));
|
|
40
|
+
}
|
package/dist/ops-tools.js
CHANGED
|
@@ -161,6 +161,12 @@ export function registerOpsTools(server, deps) {
|
|
|
161
161
|
? `TOOL ROUTER ${toolRouterBoard.turnsRouted} routed`
|
|
162
162
|
: "TOOL ROUTER not read (tool_router_section_absent)"
|
|
163
163
|
: "";
|
|
164
|
+
// BLI-10728: the daily-brief delivery streak, the server's own
|
|
165
|
+
// sentence relayed verbatim, same rule as `ladderLine` above.
|
|
166
|
+
const briefStreak = asRecord(body.briefStreak);
|
|
167
|
+
const briefStreakLine = typeof briefStreak.streakDays === "number"
|
|
168
|
+
? `BRIEF STREAK ${briefStreak.streakDays} day(s)${briefStreak.detail ? ` — ${briefStreak.detail}` : ""}`
|
|
169
|
+
: "";
|
|
164
170
|
// Both branches say something. A board that is entirely green and
|
|
165
171
|
// reports nothing cannot answer "did anybody look today?".
|
|
166
172
|
return textResult(`${rows.length} pipeline(s), ${unhealthy.length} unhealthy`
|
|
@@ -169,6 +175,7 @@ export function registerOpsTools(server, deps) {
|
|
|
169
175
|
+ `${fleetSummary ? `\nFLEET ${fleetSummary}` : ""}`
|
|
170
176
|
+ `${coverageSummary ? `\nCOVERAGE ${coverageSummary}` : ""}`
|
|
171
177
|
+ `${ladderLine ? `\n${ladderLine}` : ""}`
|
|
178
|
+
+ `${briefStreakLine ? `\n${briefStreakLine}` : ""}`
|
|
172
179
|
+ `${memoryLines ? `\n${memoryLines}` : ""}`
|
|
173
180
|
+ `${experienceLines ? `\n${experienceLines}` : ""}`
|
|
174
181
|
+ `${turnLine ? `\n${turnLine}` : ""}`
|
|
@@ -181,6 +188,7 @@ export function registerOpsTools(server, deps) {
|
|
|
181
188
|
...(body.coverage ? { coverage: summaryOnly(body.coverage, args.people === true) } : {}),
|
|
182
189
|
...(body.skips ? { skips: body.skips } : {}),
|
|
183
190
|
...(body.ladder ? { ladder: body.ladder } : {}),
|
|
191
|
+
...(body.briefStreak ? { briefStreak: body.briefStreak } : {}),
|
|
184
192
|
...(memory?.ok ? { memory: memory.body.memory, hooks: memory.body.hooks, experience: memory.body.experience } : {}),
|
|
185
193
|
...(args.turns ? { turns: body.turns ?? null } : {}),
|
|
186
194
|
...(toolRouter?.ok ? { toolRouter: toolRouter.body.board ?? null } : {}),
|
package/dist/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerCalTools } from "./cal-tools.js";
|
|
|
16
16
|
import { registerMailTools } from "./mail-tools.js";
|
|
17
17
|
import { registerNotesAccountTools } from "./notes-account-tools.js";
|
|
18
18
|
import { registerNotesTools } from "./notes-tools.js";
|
|
19
|
+
import { registerNotesGranolaStatusTool } from "./notes-granola-status-tool.js";
|
|
19
20
|
import { registerNotesWriteTools } from "./notes-write-tools.js";
|
|
20
21
|
import { registerMemoryConsolidationTools } from "./memory-consolidation-tools.js";
|
|
21
22
|
import { registerMemoryExperienceTools } from "./memory-experience-tools.js";
|
|
@@ -450,6 +451,9 @@ export function createServer(deps) {
|
|
|
450
451
|
// complete.
|
|
451
452
|
registerBriefTools(server, { fetchImpl: deps.fetchImpl });
|
|
452
453
|
registerNotesTools(server, { fetchImpl: deps.fetchImpl });
|
|
454
|
+
// Ticket 10718: the Granola cron's own health, read-only, over the shared
|
|
455
|
+
// server-side key rather than a `notes_accounts` row.
|
|
456
|
+
registerNotesGranolaStatusTool(server, { fetchImpl: deps.fetchImpl });
|
|
453
457
|
registerOpsTools(server, { fetchImpl: deps.fetchImpl });
|
|
454
458
|
registerMemoryExperienceTools(server, { fetchImpl: deps.fetchImpl });
|
|
455
459
|
// BLI-4533: the compiled one-page profile, the twin of `cockpit memory profile`.
|
package/dist/tool-catalog.d.ts
CHANGED
|
@@ -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_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
|
|
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_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "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", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_sync_status", "notes_granola_status", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
|
|
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).
|
package/dist/tool-catalog.js
CHANGED
|
@@ -36,11 +36,13 @@ export const READ_TOOLS = [
|
|
|
36
36
|
"mail_read",
|
|
37
37
|
"mail_search",
|
|
38
38
|
"mail_attachment",
|
|
39
|
+
"mail_sync_status",
|
|
39
40
|
"cal_today",
|
|
40
41
|
"cal_week",
|
|
41
42
|
"cal_next",
|
|
42
43
|
"cal_find",
|
|
43
44
|
"cal_calendars",
|
|
45
|
+
"cal_sync_status",
|
|
44
46
|
"jarvis_ask",
|
|
45
47
|
"jarvis_trace",
|
|
46
48
|
"jarvis_check",
|
|
@@ -50,6 +52,7 @@ export const READ_TOOLS = [
|
|
|
50
52
|
"brief_read",
|
|
51
53
|
"brief_history",
|
|
52
54
|
"brief_status",
|
|
55
|
+
"brief_missed",
|
|
53
56
|
"notes_folders",
|
|
54
57
|
"notes_list",
|
|
55
58
|
"notes_show",
|
|
@@ -57,6 +60,8 @@ export const READ_TOOLS = [
|
|
|
57
60
|
"notes_shelves",
|
|
58
61
|
"notes_place",
|
|
59
62
|
"notes_accounts",
|
|
63
|
+
"notes_sync_status",
|
|
64
|
+
"notes_granola_status",
|
|
60
65
|
"ops_status",
|
|
61
66
|
"slack_coverage",
|
|
62
67
|
"slack_read",
|
package/dist/verb-census.js
CHANGED
|
@@ -258,6 +258,7 @@ export const MCP_TWINS = {
|
|
|
258
258
|
"mail send": { tool: "mail_send", door: "POST /api/mail/send" },
|
|
259
259
|
"mail attachment": { tool: "mail_attachment", door: "GET /api/mail/attachments/[id]?meta=1" },
|
|
260
260
|
"mail sync": { tool: "mail_sync", door: "POST /api/mail/accounts/[id]/sync" },
|
|
261
|
+
"mail sync-status": { tool: "mail_sync_status", door: "GET /api/mail/sync-status" },
|
|
261
262
|
"mail detach": { tool: "mail_detach", door: "DELETE /api/mail/accounts" },
|
|
262
263
|
"cal today": { tool: "cal_today", door: "GET /api/cal/today" },
|
|
263
264
|
"cal week": { tool: "cal_week", door: "GET /api/cal/week" },
|
|
@@ -267,6 +268,7 @@ export const MCP_TWINS = {
|
|
|
267
268
|
"cal create": { tool: "cal_create", door: "POST /api/cal/events" },
|
|
268
269
|
"cal share": { tool: "cal_share", door: "PATCH /api/cal/calendars" },
|
|
269
270
|
"cal sync": { tool: "cal_sync", door: "POST /api/cal/calendars/[id]/sync" },
|
|
271
|
+
"cal sync-status": { tool: "cal_sync_status", door: "GET /api/cal/sync-status" },
|
|
270
272
|
"cal detach": { tool: "cal_detach", door: "DELETE /api/cal/calendars" },
|
|
271
273
|
"cal delete": { tool: "cal_delete", door: "DELETE /api/cal/events/[eventId]" },
|
|
272
274
|
"brief read": { tool: "brief_read", door: "GET /api/jarvis/brief" },
|
|
@@ -297,6 +299,9 @@ export const MCP_TWINS = {
|
|
|
297
299
|
"notes accounts": { tool: "notes_accounts", door: "GET /api/notes/accounts" },
|
|
298
300
|
"notes detach": { tool: "notes_detach", door: "DELETE /api/notes/accounts" },
|
|
299
301
|
"notes sync": { tool: "notes_sync", door: "POST /api/notes/accounts/[id]/sync" },
|
|
302
|
+
"notes sync-status": { tool: "notes_sync_status", door: "GET /api/notes/sync-status" },
|
|
303
|
+
// Ticket 10718: Granola's own cron health, over its shared server-side key.
|
|
304
|
+
"notes granola-status": { tool: "notes_granola_status", door: "GET /api/notes/granola-status" },
|
|
300
305
|
"memory log": { tool: "memory_experience", door: "POST /api/memory/experience" },
|
|
301
306
|
// BLI-4047: the memory STORE at a terminal. The twins live on the second
|
|
302
307
|
// server, `bli-memory`, over the same four doors these CLI verbs call.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Flags `parseIssueArgs` accepts a VALUE for (`--flag value`), read from its own `valueFlags` array. */
|
|
2
|
+
export declare function issueValueFlags(): string[];
|
|
3
|
+
/** The bare field names (`--parent` → `parent`) `cockpit issue create`/`update` actually write. */
|
|
4
|
+
export declare function issueWriteFieldNames(): string[];
|
|
5
|
+
export declare const WORK_WRITE_VERBS: readonly ["issue create", "issue update"];
|
|
6
|
+
export type WorkWriteVerb = (typeof WORK_WRITE_VERBS)[number];
|
|
7
|
+
export declare const MCP_TOOL_FOR_WRITE_VERB: Record<WorkWriteVerb, string>;
|
|
8
|
+
/** The field names an MCP tool's REGISTERED zod shape actually carries. */
|
|
9
|
+
export declare function mcpToolFieldNames(toolName: string): string[];
|
|
10
|
+
/**
|
|
11
|
+
* A field on exactly one side ON PURPOSE, with why — a claim about shape,
|
|
12
|
+
* never a backlog (the same discipline `TERMINAL_ONLY` keeps in
|
|
13
|
+
* `verb-census.ts`). Anything asymmetric that is NOT listed here fails the
|
|
14
|
+
* test in `work-flag-census.test.ts`.
|
|
15
|
+
*/
|
|
16
|
+
export interface OneSidedField {
|
|
17
|
+
verb: WorkWriteVerb;
|
|
18
|
+
side: "cli" | "mcp";
|
|
19
|
+
field: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const EXPECTED_ONE_SIDED_FIELDS: OneSidedField[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag-level parity for `cockpit issue create|update` against their MCP
|
|
3
|
+
* twins, `work_create_issue`/`work_update_issue` (BLI-10575).
|
|
4
|
+
*
|
|
5
|
+
* `verb-census.ts` proves every VERB has a twin — it never looks at a verb's
|
|
6
|
+
* FLAGS, by its own header ("What it deliberately does NOT see"): the
|
|
7
|
+
* extraction there only ever reads a noun's `action` union, never a shape's
|
|
8
|
+
* own fields. That is exactly how `work_create_issue` shipped with no
|
|
9
|
+
* `parent` while `cockpit issue create --parent` already had one (BLI-3716)
|
|
10
|
+
* — the VERB existed on both sides, its FLAGS did not, and nothing checked
|
|
11
|
+
* because nothing looked. `--size`, added in this same ticket, would have
|
|
12
|
+
* been the next one to drift silently were this test not written alongside
|
|
13
|
+
* it.
|
|
14
|
+
*
|
|
15
|
+
* This reads both real sources rather than trusting a hand-written list on
|
|
16
|
+
* either side:
|
|
17
|
+
*
|
|
18
|
+
* - the collector's own `valueFlags` array for `parseIssueArgs`, a text
|
|
19
|
+
* read the same way `verb-census.ts` reads the collector (this package
|
|
20
|
+
* declares no dependency on it);
|
|
21
|
+
* - the MCP tool's REGISTERED zod shape — `createServer(...)`'s
|
|
22
|
+
* `_registeredTools[name].inputSchema.shape`, the same private field
|
|
23
|
+
* `server.test.ts` already introspects, not a text scan of the source.
|
|
24
|
+
*
|
|
25
|
+
* `cockpit issue` has ONE shared flag vocabulary across every verb (`list`,
|
|
26
|
+
* `create`, `update`, `move`, ... all parse from the same `valueFlags`
|
|
27
|
+
* array — `local-args-tower-work.ts` gates a flag's MEANING per action, not
|
|
28
|
+
* its presence), so the CLI side of this comparison is the same set for
|
|
29
|
+
* `create` and `update` alike; only the filter list below narrows it to the
|
|
30
|
+
* fields that name something an issue's ROW carries, as opposed to a filter
|
|
31
|
+
* (`--state`, `--limit`) or a global (`--home`, `--dashboard-url`).
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { dirname, resolve } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { createServer } from "./server.js";
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const WORK_ARGS_FILE = resolve(HERE, "../../cockpit-local-collector/src/commands/local-args-tower-work.ts");
|
|
39
|
+
/** Flags `parseIssueArgs` accepts a VALUE for (`--flag value`), read from its own `valueFlags` array. */
|
|
40
|
+
export function issueValueFlags() {
|
|
41
|
+
const source = readFileSync(WORK_ARGS_FILE, "utf8");
|
|
42
|
+
const start = source.indexOf("export function parseIssueArgs");
|
|
43
|
+
const end = source.indexOf("export type ProjectAction", start);
|
|
44
|
+
if (start < 0 || end < 0) {
|
|
45
|
+
throw new Error("parseIssueArgs (or the point where it ends, ProjectAction) moved in local-args-tower-work.ts — "
|
|
46
|
+
+ "work-flag-census.ts's extraction needs updating alongside it.");
|
|
47
|
+
}
|
|
48
|
+
const slice = source.slice(start, end);
|
|
49
|
+
const valueFlagsStart = slice.indexOf("valueFlags: [");
|
|
50
|
+
if (valueFlagsStart < 0)
|
|
51
|
+
throw new Error("parseIssueArgs's own valueFlags array not found.");
|
|
52
|
+
const valueFlagsEnd = slice.indexOf("]", valueFlagsStart);
|
|
53
|
+
return [...slice.slice(valueFlagsStart, valueFlagsEnd).matchAll(/"(--[a-z-]+)"/g)].map((match) => match[1]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Flags that name a filter, a global, or a transport knob rather than a
|
|
57
|
+
* FIELD on the issue row itself — never counted as a "write field" either
|
|
58
|
+
* side owes the other. `--file` carries the same DESCRIPTION the MCP tools
|
|
59
|
+
* take as a plain `description` argument; see `EXPECTED_ONE_SIDED` below for
|
|
60
|
+
* why that one stays a documented asymmetry rather than a shared name.
|
|
61
|
+
*/
|
|
62
|
+
const NON_FIELD_FLAGS = new Set(["--home", "--dashboard-url", "--state", "--limit", "--file"]);
|
|
63
|
+
/** The bare field names (`--parent` → `parent`) `cockpit issue create`/`update` actually write. */
|
|
64
|
+
export function issueWriteFieldNames() {
|
|
65
|
+
return issueValueFlags()
|
|
66
|
+
.filter((flag) => !NON_FIELD_FLAGS.has(flag))
|
|
67
|
+
.map((flag) => flag.replace(/^--/, ""));
|
|
68
|
+
}
|
|
69
|
+
export const WORK_WRITE_VERBS = ["issue create", "issue update"];
|
|
70
|
+
export const MCP_TOOL_FOR_WRITE_VERB = {
|
|
71
|
+
"issue create": "work_create_issue",
|
|
72
|
+
"issue update": "work_update_issue",
|
|
73
|
+
};
|
|
74
|
+
/** The field names an MCP tool's REGISTERED zod shape actually carries. */
|
|
75
|
+
export function mcpToolFieldNames(toolName) {
|
|
76
|
+
const deps = {
|
|
77
|
+
resolveToken: async () => "token-not-used-by-registration",
|
|
78
|
+
baseUrl: "https://dashboard.example.test",
|
|
79
|
+
fetchImpl: (async () => new Response("{}")),
|
|
80
|
+
};
|
|
81
|
+
const server = createServer(deps);
|
|
82
|
+
const shape = server._registeredTools[toolName]?.inputSchema?.shape;
|
|
83
|
+
if (!shape) {
|
|
84
|
+
throw new Error(`${toolName} has no inputSchema shape — is it still registered under this name?`);
|
|
85
|
+
}
|
|
86
|
+
return Object.keys(shape);
|
|
87
|
+
}
|
|
88
|
+
export const EXPECTED_ONE_SIDED_FIELDS = [
|
|
89
|
+
{
|
|
90
|
+
verb: "issue update",
|
|
91
|
+
side: "mcp",
|
|
92
|
+
field: "id",
|
|
93
|
+
reason: "the issue being updated is a positional argument on the CLI (`cockpit issue update <id>`), never a flag — "
|
|
94
|
+
+ "there is nothing for it to be symmetric WITH.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
verb: "issue create",
|
|
98
|
+
side: "mcp",
|
|
99
|
+
field: "description",
|
|
100
|
+
reason: "the CLI never takes a description on argv, by the sourcing-secrets discipline every body follows here — "
|
|
101
|
+
+ "stdin or --file only. An MCP call has no stdin, so its tool takes description as a plain argument instead.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
verb: "issue update",
|
|
105
|
+
side: "mcp",
|
|
106
|
+
field: "description",
|
|
107
|
+
reason: "the same reason as issue create's: stdin/--file on the CLI, a plain argument on MCP.",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -19,11 +19,40 @@ export interface IssueRow {
|
|
|
19
19
|
title: string;
|
|
20
20
|
state: string;
|
|
21
21
|
priority?: number | null;
|
|
22
|
+
estimate?: number | null;
|
|
22
23
|
assignee_id?: string | null;
|
|
23
24
|
project_id?: string | null;
|
|
24
25
|
parent_id?: string | null;
|
|
25
26
|
updated_at?: string | null;
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* `size` on `work_create_issue`/`work_update_issue` (BLI-10575) maps onto the
|
|
30
|
+
* existing `work_issues.estimate` numeric column — the CLI twin of this,
|
|
31
|
+
* `issue-contracts.ts` in the collector, carries the identical table and
|
|
32
|
+
* comment; kept as a second copy rather than a shared import for the same
|
|
33
|
+
* reason `work-directory.ts` duplicates `issue-directory.ts` instead of
|
|
34
|
+
* depending on the collector package.
|
|
35
|
+
*/
|
|
36
|
+
export declare const SIZE_ESTIMATES: {
|
|
37
|
+
readonly S: 1;
|
|
38
|
+
readonly M: 3;
|
|
39
|
+
readonly L: 5;
|
|
40
|
+
};
|
|
41
|
+
export type IssueSize = keyof typeof SIZE_ESTIMATES;
|
|
42
|
+
export declare function estimateForSize(size: string): number;
|
|
43
|
+
export declare function sizeForEstimate(estimate: number | null | undefined): IssueSize | null;
|
|
44
|
+
/**
|
|
45
|
+
* `T extends object` rather than `T extends { estimate?: ... }` on purpose:
|
|
46
|
+
* a caller here often has `result.issue ?? {}` in hand, and TypeScript's
|
|
47
|
+
* excess-property check on an all-optional ("weak") object type refuses a
|
|
48
|
+
* `{}` that shares NO property with `{ estimate?: ... }` at all (TS2559) —
|
|
49
|
+
* exactly the shape `withAssigneeIdentity(... ?? {}, directory)` hands back
|
|
50
|
+
* when the issue itself is absent. Reading `estimate` off an `object` cast
|
|
51
|
+
* keeps the same runtime behaviour without that false refusal.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withSizeLabel<T extends object>(row: T): T & {
|
|
54
|
+
size: IssueSize | null;
|
|
55
|
+
};
|
|
27
56
|
/**
|
|
28
57
|
* The fields `work_list_issues` promises. The door's row carries the whole
|
|
29
58
|
* description and a dozen bookkeeping columns; with no limit that was 1,000
|
|
@@ -36,6 +65,7 @@ export interface IssueListRow {
|
|
|
36
65
|
title: string;
|
|
37
66
|
state: string;
|
|
38
67
|
priority: number | null;
|
|
68
|
+
estimate: number | null;
|
|
39
69
|
assignee_id: string | null;
|
|
40
70
|
project_id: string | null;
|
|
41
71
|
parent_id: string | null;
|
package/dist/work-issue-rows.js
CHANGED
|
@@ -13,6 +13,44 @@
|
|
|
13
13
|
* Every string moved verbatim.
|
|
14
14
|
*/
|
|
15
15
|
import { assigneeLabel } from "./work-directory.js";
|
|
16
|
+
/**
|
|
17
|
+
* `size` on `work_create_issue`/`work_update_issue` (BLI-10575) maps onto the
|
|
18
|
+
* existing `work_issues.estimate` numeric column — the CLI twin of this,
|
|
19
|
+
* `issue-contracts.ts` in the collector, carries the identical table and
|
|
20
|
+
* comment; kept as a second copy rather than a shared import for the same
|
|
21
|
+
* reason `work-directory.ts` duplicates `issue-directory.ts` instead of
|
|
22
|
+
* depending on the collector package.
|
|
23
|
+
*/
|
|
24
|
+
export const SIZE_ESTIMATES = { S: 1, M: 3, L: 5 };
|
|
25
|
+
export function estimateForSize(size) {
|
|
26
|
+
const key = size.trim().toUpperCase();
|
|
27
|
+
if (key !== "S" && key !== "M" && key !== "L") {
|
|
28
|
+
throw new Error("size must be S, M, or L.");
|
|
29
|
+
}
|
|
30
|
+
return SIZE_ESTIMATES[key];
|
|
31
|
+
}
|
|
32
|
+
export function sizeForEstimate(estimate) {
|
|
33
|
+
if (estimate === null || estimate === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
for (const key of Object.keys(SIZE_ESTIMATES)) {
|
|
36
|
+
if (SIZE_ESTIMATES[key] === estimate)
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `T extends object` rather than `T extends { estimate?: ... }` on purpose:
|
|
43
|
+
* a caller here often has `result.issue ?? {}` in hand, and TypeScript's
|
|
44
|
+
* excess-property check on an all-optional ("weak") object type refuses a
|
|
45
|
+
* `{}` that shares NO property with `{ estimate?: ... }` at all (TS2559) —
|
|
46
|
+
* exactly the shape `withAssigneeIdentity(... ?? {}, directory)` hands back
|
|
47
|
+
* when the issue itself is absent. Reading `estimate` off an `object` cast
|
|
48
|
+
* keeps the same runtime behaviour without that false refusal.
|
|
49
|
+
*/
|
|
50
|
+
export function withSizeLabel(row) {
|
|
51
|
+
const estimate = row.estimate;
|
|
52
|
+
return { ...row, size: sizeForEstimate(estimate ?? null) };
|
|
53
|
+
}
|
|
16
54
|
export function issueListRow(row) {
|
|
17
55
|
return {
|
|
18
56
|
id: row.id,
|
|
@@ -20,6 +58,7 @@ export function issueListRow(row) {
|
|
|
20
58
|
title: row.title,
|
|
21
59
|
state: row.state,
|
|
22
60
|
priority: row.priority ?? null,
|
|
61
|
+
estimate: row.estimate ?? null,
|
|
23
62
|
assignee_id: row.assignee_id ?? null,
|
|
24
63
|
project_id: row.project_id ?? null,
|
|
25
64
|
parent_id: row.parent_id ?? null,
|
|
@@ -55,8 +94,9 @@ export function outsideNote(outside) {
|
|
|
55
94
|
}
|
|
56
95
|
export function renderOneIssue(result, directory) {
|
|
57
96
|
const { issue, comments } = result;
|
|
97
|
+
const size = sizeForEstimate(issue?.estimate ?? null);
|
|
58
98
|
const commentText = comments.map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`).join("\n\n");
|
|
59
|
-
return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"}) · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
|
|
99
|
+
return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"})${size ? ` · size ${size}` : ""} · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
|
|
60
100
|
+ `\n\n${issue?.description ?? "(no description)"}`
|
|
61
101
|
+ (commentText ? `\n\n--- comments ---\n${commentText}` : "")
|
|
62
102
|
+ (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
|
package/dist/work-tools.js
CHANGED
|
@@ -18,7 +18,7 @@ import { z } from "zod";
|
|
|
18
18
|
import { callAgentDoor } from "./agent-door.js";
|
|
19
19
|
import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
20
20
|
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./work-directory.js";
|
|
21
|
-
import { historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, } from "./work-issue-rows.js";
|
|
21
|
+
import { estimateForSize, historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, sizeForEstimate, withSizeLabel, } from "./work-issue-rows.js";
|
|
22
22
|
/** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
|
|
23
23
|
export const WORK_ISSUE_STATES = [
|
|
24
24
|
"backlog",
|
|
@@ -63,6 +63,24 @@ const ISSUE_REF = z
|
|
|
63
63
|
.min(1)
|
|
64
64
|
.max(200)
|
|
65
65
|
.describe("A BLI-#### identifier (e.g. BLI-3654) or an issue's uuid. Both work.");
|
|
66
|
+
/**
|
|
67
|
+
* A parent issue's uuid from any issue reference — the `create_id`/`update_id`
|
|
68
|
+
* bodies below need a uuid, and `[issueId]`'s own ref resolution
|
|
69
|
+
* (`lib/work/issue-ref.ts`) only runs on the PATH segment of `GET
|
|
70
|
+
* /api/work/issues/[issueId]`, never on a `parent_id` in a POST/PATCH body.
|
|
71
|
+
* `issue-contracts.ts#resolveIssueUuid` in the collector is the CLI's own
|
|
72
|
+
* copy of the identical resolve-then-write two-step.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveParentId(deps, session, ref) {
|
|
75
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
return { status: "failed", text: doorFailureText("work_get_issue", response) };
|
|
78
|
+
const parent = response.body.issue;
|
|
79
|
+
if (!parent?.id) {
|
|
80
|
+
return { status: "failed", text: `No issue matches "${ref}" — it may not exist, or you cannot read it.` };
|
|
81
|
+
}
|
|
82
|
+
return { status: "ok", id: parent.id };
|
|
83
|
+
}
|
|
66
84
|
export function registerWorkTools(server, deps) {
|
|
67
85
|
const register = registrarFor(server);
|
|
68
86
|
register("work_list_issues", {
|
|
@@ -111,12 +129,15 @@ export function registerWorkTools(server, deps) {
|
|
|
111
129
|
// BLI-10450: one directory call for the whole page, never one per row.
|
|
112
130
|
const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
|
|
113
131
|
const lines = issues
|
|
114
|
-
.map((issue) =>
|
|
115
|
-
|
|
132
|
+
.map((issue) => {
|
|
133
|
+
const size = sizeForEstimate(issue.estimate ?? null);
|
|
134
|
+
return `${issue.identifier} ${issue.state} ${issue.title}`
|
|
135
|
+
+ ` [p${issue.priority ?? 0}${size ? `, size ${size}` : ""}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`;
|
|
136
|
+
})
|
|
116
137
|
.join("\n");
|
|
117
138
|
const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
|
|
118
139
|
return textResult(`${issues.length} issue(s).${totalNote}${moreNote(more, ISSUE_LIST_MAX)}${outsideNote(outside)}${lines ? `\n${lines}` : ""}`, {
|
|
119
|
-
issues: issues.map((issue) => withAssigneeIdentity(issueListRow(issue), directory)),
|
|
140
|
+
issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issueListRow(issue), directory))),
|
|
120
141
|
more,
|
|
121
142
|
...(completeness ? { total: completeness.total } : {}),
|
|
122
143
|
...(outside ? { outside_project: outside } : {}),
|
|
@@ -146,7 +167,7 @@ export function registerWorkTools(server, deps) {
|
|
|
146
167
|
if (!result?.ok)
|
|
147
168
|
return errorResult(result?.text ?? "work_get_issue could not read that issue.");
|
|
148
169
|
return textResult(renderOneIssue(result, directory), {
|
|
149
|
-
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
170
|
+
issue: withSizeLabel(withAssigneeIdentity(result.issue ?? {}, directory)),
|
|
150
171
|
comments: result.comments,
|
|
151
172
|
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
152
173
|
});
|
|
@@ -160,7 +181,7 @@ export function registerWorkTools(server, deps) {
|
|
|
160
181
|
? {
|
|
161
182
|
ref: result.ref,
|
|
162
183
|
ok: true,
|
|
163
|
-
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
184
|
+
issue: withSizeLabel(withAssigneeIdentity(result.issue ?? {}, directory)),
|
|
164
185
|
comments: result.comments,
|
|
165
186
|
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
166
187
|
}
|
|
@@ -175,6 +196,11 @@ export function registerWorkTools(server, deps) {
|
|
|
175
196
|
description: z.string().max(200_000).optional().describe("The issue body, markdown."),
|
|
176
197
|
project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
|
|
177
198
|
priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
|
|
199
|
+
// BLI-10575: the CLI twin of this tool, `cockpit issue create`, has
|
|
200
|
+
// had --parent since BLI-3716; this field was missing, and nothing
|
|
201
|
+
// checked for the gap — see work-flag-census.test.ts.
|
|
202
|
+
parent: ISSUE_REF.optional().describe("Another issue's BLI-#### identifier or uuid — this issue becomes its sub-issue."),
|
|
203
|
+
size: z.enum(["S", "M", "L"]).optional().describe("Tower's own size scale, stored in the existing estimate column: S=1, M=3, L=5 (BLI-10575)."),
|
|
178
204
|
assignee: z.string().min(1).max(100).optional().describe('"me" or a person\'s uuid.'),
|
|
179
205
|
},
|
|
180
206
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -188,17 +214,26 @@ export function registerWorkTools(server, deps) {
|
|
|
188
214
|
}
|
|
189
215
|
projectId = resolved.id;
|
|
190
216
|
}
|
|
217
|
+
let parentId;
|
|
218
|
+
if (args.parent) {
|
|
219
|
+
const resolved = await resolveParentId(deps, session, String(args.parent));
|
|
220
|
+
if (resolved.status === "failed")
|
|
221
|
+
return errorResult(resolved.text);
|
|
222
|
+
parentId = resolved.id;
|
|
223
|
+
}
|
|
191
224
|
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/work/issues", {
|
|
192
225
|
title: args.title,
|
|
193
226
|
...(args.description === undefined ? {} : { description: args.description }),
|
|
194
227
|
...(projectId ? { project_id: projectId } : {}),
|
|
195
228
|
...(args.priority === undefined ? {} : { priority: args.priority }),
|
|
229
|
+
...(args.size === undefined ? {} : { estimate: estimateForSize(String(args.size)) }),
|
|
196
230
|
...(args.assignee ? { assignee_id: args.assignee } : {}),
|
|
231
|
+
...(parentId ? { parent_id: parentId } : {}),
|
|
197
232
|
});
|
|
198
233
|
if (!response.ok)
|
|
199
234
|
return errorResult(doorFailureText("work_create_issue", response));
|
|
200
235
|
const issue = response.body.issue;
|
|
201
|
-
return textResult(`Filed ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`, { issue });
|
|
236
|
+
return textResult(`Filed ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`, { issue: issue ? withSizeLabel(issue) : issue });
|
|
202
237
|
}));
|
|
203
238
|
register("work_update_issue", {
|
|
204
239
|
title: "Update a Tower issue",
|
|
@@ -209,6 +244,10 @@ export function registerWorkTools(server, deps) {
|
|
|
209
244
|
title: z.string().min(1).max(300).optional().describe("A new title."),
|
|
210
245
|
description: z.string().max(200_000).nullable().optional().describe("The WHOLE new body, markdown; null clears it."),
|
|
211
246
|
priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
|
|
247
|
+
// BLI-10575: had no MCP twin at all — `cockpit issue update --parent`
|
|
248
|
+
// could re-parent a ticket and nothing on this server could.
|
|
249
|
+
parent: ISSUE_REF.optional().describe("Another issue's BLI-#### identifier or uuid — this issue becomes its sub-issue."),
|
|
250
|
+
size: z.enum(["S", "M", "L"]).optional().describe("Tower's own size scale, stored in the existing estimate column: S=1, M=3, L=5 (BLI-10575)."),
|
|
212
251
|
assignee: z.string().min(1).max(100).nullable().optional().describe('"me", a uuid, or null to unassign.'),
|
|
213
252
|
project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
|
|
214
253
|
},
|
|
@@ -224,17 +263,26 @@ export function registerWorkTools(server, deps) {
|
|
|
224
263
|
}
|
|
225
264
|
projectId = resolved.id;
|
|
226
265
|
}
|
|
266
|
+
let parentId;
|
|
267
|
+
if (args.parent) {
|
|
268
|
+
const resolved = await resolveParentId(deps, session, String(args.parent));
|
|
269
|
+
if (resolved.status === "failed")
|
|
270
|
+
return errorResult(resolved.text);
|
|
271
|
+
parentId = resolved.id;
|
|
272
|
+
}
|
|
227
273
|
const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/work/issues/${encodeURIComponent(ref)}`, {
|
|
228
274
|
...(args.title !== undefined ? { title: args.title } : {}),
|
|
229
275
|
...(args.description !== undefined ? { description: args.description } : {}),
|
|
230
276
|
...(args.priority !== undefined ? { priority: args.priority } : {}),
|
|
277
|
+
...(args.size === undefined ? {} : { estimate: estimateForSize(String(args.size)) }),
|
|
231
278
|
...(args.assignee !== undefined ? { assignee_id: args.assignee } : {}),
|
|
232
279
|
...(projectId ? { project_id: projectId } : {}),
|
|
280
|
+
...(parentId ? { parent_id: parentId } : {}),
|
|
233
281
|
});
|
|
234
282
|
if (!response.ok)
|
|
235
283
|
return errorResult(doorFailureText("work_update_issue", response));
|
|
236
284
|
const issue = response.body.issue;
|
|
237
|
-
return textResult(`Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`, { issue });
|
|
285
|
+
return textResult(`Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`, { issue: issue ? withSizeLabel(issue) : issue });
|
|
238
286
|
}));
|
|
239
287
|
register("work_move_issue", {
|
|
240
288
|
title: "Move a Tower issue",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"readme": "npm run build && node scripts/write-readme-census.mjs"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
33
|
+
"@bli-cockpit/telemetry-core": "0.1.49",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
35
|
"zod": "^4.3.6"
|
|
36
36
|
},
|