@bli-cockpit/mcp 0.1.61 → 0.1.65

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
@@ -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
- **107 of 112 Tower verbs have an MCP twin.**
106
+ **109 of 114 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
@@ -139,8 +139,10 @@ three tables below.
139
139
  | `cockpit careers takehome show` | `careers_takehome_show` | `GET /api/careers/takehomes/[role]` |
140
140
  | `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
141
141
  | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
142
+ | `cockpit docs link` | `docs_link` | `PUT /api/docs/documents/[id]/notion-link` |
142
143
  | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
143
144
  | `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
145
+ | `cockpit docs sync-status` | `docs_sync_status` | `GET /api/docs/notion-sync` |
144
146
  | `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
145
147
  | `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
146
148
  | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
@@ -382,6 +384,8 @@ narrowing who can read something.
382
384
  | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
383
385
  | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
384
386
  | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). Emptying a document that holds text needs `allow_empty: true` (BLI-3757's guard). |
387
+ | `docs_link` | `PUT`/`DELETE /api/docs/documents/[id]/notion-link` | Ties a document to a Notion page for the two-way sync (`notion_page`: a URL or page id), or unties it with `clear: true`. The page becomes the truth: the next sync pulls it over the copy and keeps the copy as a version. |
388
+ | `docs_sync_status` | `GET /api/docs/notion-sync` | Every linked document's sync state (`synced` / `tower_changed` / `never_synced`), or one's by `id`. Tower's half only; metadata, never a body. |
385
389
  | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
386
390
  | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
387
391
  | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Helpers behind the `docs_*` and `msg_*` tools (split out of
3
+ * `docs-msg-tools.ts` to keep it under the file-size ratchet): id resolution,
4
+ * list shaping and one-line renderers. No tool is registered here.
5
+ */
6
+ import { type ToolDeps } from "./tool-result.js";
7
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
8
+ export declare const UUID_PATTERN: RegExp;
9
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
10
+ export declare const DOCS_LIST_MAX = 1000;
11
+ export type DocsMsgDeps = ToolDeps;
12
+ /** One node of `GET /api/docs/tree`. The door nests; nothing here re-nests. */
13
+ export interface DocTreeNode {
14
+ id: string;
15
+ title: string;
16
+ slug: string | null;
17
+ children: DocTreeNode[];
18
+ }
19
+ /**
20
+ * The tree as the text half prints it: id, title, slug, children. The door
21
+ * also sends visibility, source and a timestamp per node, which nothing here
22
+ * promised and which made the structured half 48 KB for 226 documents
23
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
24
+ */
25
+ export declare function slimTree(nodes: DocTreeNode[]): DocTreeNode[];
26
+ /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
27
+ export declare function renderDocTree(nodes: DocTreeNode[], depth: number): string;
28
+ export declare function countTree(nodes: DocTreeNode[]): number;
29
+ export interface DocRow {
30
+ id: string;
31
+ title: string;
32
+ slug: string | null;
33
+ visibility: string;
34
+ source?: string | null;
35
+ parent_id?: string | null;
36
+ updated_at: string;
37
+ /** BLI-3737: the body's length, the one fact about a body the LIST still carries. */
38
+ body_chars?: number;
39
+ }
40
+ /**
41
+ * The fields `docs_list` promises, and only those. The door's row also carries
42
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
43
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
44
+ */
45
+ export declare function docListRow(row: DocRow): Record<string, unknown>;
46
+ export declare function docListLine(row: DocRow): string;
47
+ /**
48
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
49
+ * The list itself is metadata only — asking for a whole library used to cost
50
+ * every document's prose, so an agent that wants one folder or one phrase
51
+ * says so here and the database does the filtering.
52
+ */
53
+ export declare function docsListPath(args: {
54
+ parent_id?: unknown;
55
+ query?: unknown;
56
+ word?: unknown;
57
+ limit?: unknown;
58
+ }): string;
59
+ export declare function resolveDocumentId(deps: DocsMsgDeps, session: {
60
+ dashboardUrl: string;
61
+ deviceToken: string;
62
+ }, ref: string): Promise<{
63
+ status: "ok";
64
+ id: string;
65
+ } | {
66
+ status: "not_found";
67
+ } | {
68
+ status: "list_failed";
69
+ text: string;
70
+ }>;
71
+ export interface ChannelRow {
72
+ id: string;
73
+ name: string | null;
74
+ is_private: boolean;
75
+ is_dm: boolean;
76
+ }
77
+ export declare function resolveChannelId(deps: DocsMsgDeps, session: {
78
+ dashboardUrl: string;
79
+ deviceToken: string;
80
+ }, ref: string): Promise<{
81
+ status: "ok";
82
+ id: string;
83
+ } | {
84
+ status: "not_found";
85
+ } | {
86
+ status: "list_failed";
87
+ text: string;
88
+ }>;
89
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
90
+ export declare function messageLine(message: Record<string, unknown>): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Helpers behind the `docs_*` and `msg_*` tools (split out of
3
+ * `docs-msg-tools.ts` to keep it under the file-size ratchet): id resolution,
4
+ * list shaping and one-line renderers. No tool is registered here.
5
+ */
6
+ import { callAgentDoor } from "./agent-door.js";
7
+ import { DEFAULT_LIST_LIMIT, doorFailureText } from "./tool-result.js";
8
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
9
+ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
11
+ export const DOCS_LIST_MAX = 1000;
12
+ /**
13
+ * The tree as the text half prints it: id, title, slug, children. The door
14
+ * also sends visibility, source and a timestamp per node, which nothing here
15
+ * promised and which made the structured half 48 KB for 226 documents
16
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
17
+ */
18
+ export function slimTree(nodes) {
19
+ return nodes.map((node) => ({
20
+ id: node.id,
21
+ title: node.title,
22
+ slug: node.slug ?? null,
23
+ children: slimTree(Array.isArray(node.children) ? node.children : []),
24
+ }));
25
+ }
26
+ /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
27
+ export function renderDocTree(nodes, depth) {
28
+ return nodes
29
+ .map((node) => {
30
+ const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
31
+ const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
32
+ return `${line}${children}`;
33
+ })
34
+ .join("\n");
35
+ }
36
+ export function countTree(nodes) {
37
+ return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
38
+ }
39
+ /**
40
+ * The fields `docs_list` promises, and only those. The door's row also carries
41
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
42
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
43
+ */
44
+ export function docListRow(row) {
45
+ return {
46
+ id: row.id,
47
+ slug: row.slug ?? null,
48
+ title: row.title,
49
+ visibility: row.visibility,
50
+ source: row.source ?? null,
51
+ parent_id: row.parent_id ?? null,
52
+ updated_at: row.updated_at,
53
+ body_chars: row.body_chars ?? null,
54
+ };
55
+ }
56
+ export function docListLine(row) {
57
+ return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
58
+ + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
59
+ }
60
+ /**
61
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
62
+ * The list itself is metadata only — asking for a whole library used to cost
63
+ * every document's prose, so an agent that wants one folder or one phrase
64
+ * says so here and the database does the filtering.
65
+ */
66
+ export function docsListPath(args) {
67
+ const params = new URLSearchParams();
68
+ if (typeof args.parent_id === "string" && args.parent_id.length > 0)
69
+ params.set("parent_id", args.parent_id);
70
+ const hasQuery = typeof args.query === "string" && args.query.trim().length > 0;
71
+ if (hasQuery)
72
+ params.set("query", args.query);
73
+ // BLI-10451: whole words, the same `word=1` the CLI's `--word` sends.
74
+ if (hasQuery && args.word === true)
75
+ params.set("word", "1");
76
+ if (typeof args.limit === "number" && Number.isInteger(args.limit))
77
+ params.set("limit", String(args.limit));
78
+ // No limit named: one page plus one row, so "more exist" is read off the
79
+ // door instead of guessed from a full page (BLI-10432).
80
+ else if (args.limit === undefined)
81
+ params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
82
+ const search = params.toString();
83
+ return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
84
+ }
85
+ export async function resolveDocumentId(deps, session, ref) {
86
+ // An id needs no lookup: the read itself answers 404 by name when it is
87
+ // wrong. Only a slug costs the whole-library list read (BLI-10432).
88
+ if (UUID_PATTERN.test(ref))
89
+ return { status: "ok", id: ref };
90
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
91
+ if (!response.ok)
92
+ return { status: "list_failed", text: doorFailureText("docs_list", response) };
93
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
94
+ const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
95
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
96
+ }
97
+ export async function resolveChannelId(deps, session, ref) {
98
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
99
+ if (!response.ok)
100
+ return { status: "list_failed", text: doorFailureText("msg_channels", response) };
101
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
102
+ const bare = ref.startsWith("#") ? ref.slice(1) : ref;
103
+ const match = channels.find((channel) => channel.id === ref || channel.name === bare);
104
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
105
+ }
106
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
107
+ export function messageLine(message) {
108
+ return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
109
+ + `${String(message.agent_label ?? message.user_id ?? message.slack_author_label ?? "?")} ${String(message.content ?? "")}`;
110
+ }
@@ -9,19 +9,9 @@
9
9
  * on a machine with no collector pairing at all, so a missing session must
10
10
  * fail the ONE call that needed it, never the whole process.
11
11
  */
12
- import { type ToolDeps } from "./tool-result.js";
13
- export type DocsMsgDeps = ToolDeps;
14
- /**
15
- * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
16
- * The list itself is metadata only — asking for a whole library used to cost
17
- * every document's prose, so an agent that wants one folder or one phrase
18
- * says so here and the database does the filtering.
19
- */
20
- export declare function docsListPath(args: {
21
- parent_id?: unknown;
22
- query?: unknown;
23
- limit?: unknown;
24
- }): string;
12
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
13
+ export { docsListPath } from "./docs-msg-helpers.js";
14
+ export type { DocsMsgDeps } from "./docs-msg-helpers.js";
25
15
  export declare function registerDocsMsgTools(server: {
26
16
  registerTool: (...args: never[]) => unknown;
27
17
  }, deps: DocsMsgDeps): void;
@@ -12,116 +12,22 @@
12
12
  import { z } from "zod";
13
13
  import { callAgentDoor } from "./agent-door.js";
14
14
  import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
15
- /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
16
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
17
- /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
18
- const DOCS_LIST_MAX = 1000;
19
- /**
20
- * The tree as the text half prints it: id, title, slug, children. The door
21
- * also sends visibility, source and a timestamp per node, which nothing here
22
- * promised and which made the structured half 48 KB for 226 documents
23
- * (BLI-10432). `docs_list` carries those fields for a flat read.
24
- */
25
- function slimTree(nodes) {
26
- return nodes.map((node) => ({
27
- id: node.id,
28
- title: node.title,
29
- slug: node.slug ?? null,
30
- children: slimTree(Array.isArray(node.children) ? node.children : []),
31
- }));
32
- }
33
- /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
34
- function renderDocTree(nodes, depth) {
35
- return nodes
36
- .map((node) => {
37
- const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
38
- const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
39
- return `${line}${children}`;
40
- })
41
- .join("\n");
42
- }
43
- function countTree(nodes) {
44
- return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
45
- }
46
- /**
47
- * The fields `docs_list` promises, and only those. The door's row also carries
48
- * `created_by`, `updated_by`, `created_at`, `import_truncated` and
49
- * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
50
- */
51
- function docListRow(row) {
52
- return {
53
- id: row.id,
54
- slug: row.slug ?? null,
55
- title: row.title,
56
- visibility: row.visibility,
57
- source: row.source ?? null,
58
- parent_id: row.parent_id ?? null,
59
- updated_at: row.updated_at,
60
- body_chars: row.body_chars ?? null,
61
- };
62
- }
63
- function docListLine(row) {
64
- return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
65
- + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
66
- }
67
- /**
68
- * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
69
- * The list itself is metadata only — asking for a whole library used to cost
70
- * every document's prose, so an agent that wants one folder or one phrase
71
- * says so here and the database does the filtering.
72
- */
73
- export function docsListPath(args) {
74
- const params = new URLSearchParams();
75
- if (typeof args.parent_id === "string" && args.parent_id.length > 0)
76
- params.set("parent_id", args.parent_id);
77
- if (typeof args.query === "string" && args.query.trim().length > 0)
78
- params.set("query", args.query);
79
- if (typeof args.limit === "number" && Number.isInteger(args.limit))
80
- params.set("limit", String(args.limit));
81
- // No limit named: one page plus one row, so "more exist" is read off the
82
- // door instead of guessed from a full page (BLI-10432).
83
- else if (args.limit === undefined)
84
- params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
85
- const search = params.toString();
86
- return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
87
- }
88
- async function resolveDocumentId(deps, session, ref) {
89
- // An id needs no lookup: the read itself answers 404 by name when it is
90
- // wrong. Only a slug costs the whole-library list read (BLI-10432).
91
- if (UUID_PATTERN.test(ref))
92
- return { status: "ok", id: ref };
93
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
94
- if (!response.ok)
95
- return { status: "list_failed", text: doorFailureText("docs_list", response) };
96
- const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
97
- const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
98
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
99
- }
100
- async function resolveChannelId(deps, session, ref) {
101
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
102
- if (!response.ok)
103
- return { status: "list_failed", text: doorFailureText("msg_channels", response) };
104
- const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
105
- const bare = ref.startsWith("#") ? ref.slice(1) : ref;
106
- const match = channels.find((channel) => channel.id === ref || channel.name === bare);
107
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
108
- }
109
- /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
110
- function messageLine(message) {
111
- return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
112
- + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
113
- }
15
+ import { DOCS_LIST_MAX, slimTree, renderDocTree, countTree, docListRow, docListLine, resolveDocumentId, resolveChannelId, messageLine, docsListPath } from "./docs-msg-helpers.js";
16
+ import { runDocsReadTool } from "./docs-read-tool.js";
17
+ export { docsListPath } from "./docs-msg-helpers.js";
114
18
  export function registerDocsMsgTools(server, deps) {
115
19
  const register = registrarFor(server);
116
20
  register("docs_list", {
117
21
  title: "List Tower documents",
118
22
  description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
119
23
  + "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
120
- + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), "
24
+ + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy; "
25
+ + "word: true matches it as whole words, so \"ott\" stops matching \"bottom\"), "
121
26
  + `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
122
27
  inputSchema: {
123
28
  parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
124
29
  query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
30
+ word: z.boolean().optional().describe("Match query as whole words rather than a substring. Needs query."),
125
31
  limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
126
32
  },
127
33
  }, async (args) => withSession(deps, async (session) => {
@@ -149,21 +55,25 @@ export function registerDocsMsgTools(server, deps) {
149
55
  }));
150
56
  register("docs_read", {
151
57
  title: "Read a Tower document",
152
- description: "One document's title and full body, by its id or slug (docs_list returns both).",
153
- inputSchema: { id: z.string().min(1).max(200).describe("A document id, or its slug.") },
154
- }, async (args) => withSession(deps, async (session) => {
155
- const ref = String(args.id ?? "");
156
- const resolved = await resolveDocumentId(deps, session, ref);
157
- if (resolved.status === "list_failed")
158
- return errorResult(resolved.text);
159
- if (resolved.status === "not_found")
160
- return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
161
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents/${encodeURIComponent(resolved.id)}`);
162
- if (!response.ok)
163
- return errorResult(doorFailureText("docs_read", response));
164
- const document = response.body.document;
165
- return textResult(`${document?.title ?? ""}\n\n${document?.body_markdown ?? ""}`, { document });
166
- }));
58
+ description: "A document's title and body, by id or slug (docs_list returns both). Three shapes (BLI-10450): "
59
+ + "`id` — one document, answers { document }. `ids` — several in one call (one shared list resolve, then "
60
+ + "one GET per document — no bulk body door exists), answers { documents: [{ ref, ok, document|error }] }, "
61
+ + "one bad ref never losing the rest. `parent_id` + `bodies: true` — every document filed directly under "
62
+ + "that parent, bodies included, in ONE call, answers { documents: [...] }. "
63
+ + "`section`/`offset`/`limit` narrow whatever body was fetched, in every shape: `section` slices from a "
64
+ + "matching Markdown heading (any level, case-insensitive) to the next heading at the same or shallower "
65
+ + "level, mirroring `cockpit workbook`'s own --section; `offset`/`limit` slice by character range — for a "
66
+ + "100-225 KB transcript, read it in parts instead of whole.",
67
+ inputSchema: {
68
+ id: z.string().min(1).max(200).optional().describe("A document id, or its slug. Omit when passing ids or parent_id."),
69
+ ids: z.array(z.string().min(1).max(200)).min(1).max(50).optional().describe("Several documents in one call."),
70
+ parent_id: z.string().min(1).max(64).optional().describe("With bodies: true, read every document filed directly under this one (or \"root\")."),
71
+ bodies: z.boolean().optional().describe("Only with parent_id: include each document's body_markdown."),
72
+ section: z.string().min(1).max(200).optional().describe("One section's heading text — slices from there to the next heading at the same or shallower level."),
73
+ offset: z.number().int().min(0).optional().describe("The body's character offset to start from; paired with limit."),
74
+ limit: z.number().int().min(1).optional().describe("With an explicit ref: at most this many characters. With parent_id + bodies: at most this many documents (1-50)."),
75
+ },
76
+ }, async (args) => runDocsReadTool(deps, args));
167
77
  register("docs_create", {
168
78
  title: "Create a Tower document",
169
79
  description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
@@ -276,7 +186,15 @@ export function registerDocsMsgTools(server, deps) {
276
186
  // which is what msg_thread takes. It used to say only "2 message(s)",
277
187
  // so a Claude-family client never saw one (BLI-10432).
278
188
  const lines = messages.map(messageLine).join("\n");
279
- return textResult(`${messages.length} message(s) in ${ref}, newest first; each line is id, time, author, text.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, messages });
189
+ // BLI-10449: Tower's own channel, or a Slack snapshot and how fresh —
190
+ // the door's sentence, word for word, the same line the CLI prints.
191
+ const provenance = (response.body.provenance ?? null);
192
+ const limitApplied = typeof response.body.limitApplied === "number" ? response.body.limitApplied : null;
193
+ const header = [
194
+ provenance?.sentence ?? null,
195
+ limitApplied !== null ? `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.` : null,
196
+ ].filter(Boolean).join("\n");
197
+ return textResult(`${header ? `${header}\n` : ""}${messages.length} message(s) in ${ref}, newest first; each line is id, time, author, text.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, provenance, limitApplied, messages });
280
198
  }));
281
199
  register("msg_thread", {
282
200
  title: "Read a Tower message thread",
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
14
+ export declare function registerDocsNotionTools(server: {
15
+ registerTool: (...args: never[]) => unknown;
16
+ }, deps: DocsMsgDeps): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { z } from "zod";
14
+ import { callAgentDoor } from "./agent-door.js";
15
+ import { resolveDocumentId } from "./docs-msg-helpers.js";
16
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
17
+ export function registerDocsNotionTools(server, deps) {
18
+ const register = registrarFor(server);
19
+ register("docs_link", {
20
+ title: "Link a Tower document to a Notion page",
21
+ description: "Ties a document to a Notion page so the two-way sync moves it both ways, or unties it with clear: true. "
22
+ + "The page becomes the truth: the next sync pulls it over this copy and keeps the copy as a version. "
23
+ + "Refusals keep Tower's reason: invalid_notion_page, notion_page_already_linked, document_not_writable.",
24
+ inputSchema: {
25
+ id: z.string().min(1).max(200).describe("A document id, or its slug."),
26
+ notion_page: z
27
+ .string()
28
+ .min(1)
29
+ .max(500)
30
+ .optional()
31
+ .describe("The Notion page URL (app.notion.com/p/… or notion.so/…) or its page id."),
32
+ clear: z.boolean().optional().describe("true to untie the document from Notion instead."),
33
+ },
34
+ }, async (args) => withSession(deps, async (session) => {
35
+ const clear = args.clear === true;
36
+ const notionPage = typeof args.notion_page === "string" ? args.notion_page : undefined;
37
+ if (clear === Boolean(notionPage)) {
38
+ return errorResult("docs_link needs exactly one of notion_page (to link) or clear: true (to unlink).");
39
+ }
40
+ const ref = String(args.id ?? "");
41
+ const resolved = await resolveDocumentId(deps, session, ref);
42
+ if (resolved.status === "list_failed")
43
+ return errorResult(resolved.text);
44
+ if (resolved.status === "not_found")
45
+ return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
46
+ const path = `/api/docs/documents/${encodeURIComponent(resolved.id)}/notion-link`;
47
+ const response = clear
48
+ ? await callAgentDoor(session, deps.fetchImpl, "DELETE", path)
49
+ : await callAgentDoor(session, deps.fetchImpl, "PUT", path, { notion_page: notionPage });
50
+ if (!response.ok)
51
+ return errorResult(doorFailureText("docs_link", response));
52
+ const outcome = String(response.body.outcome ?? "");
53
+ const document = response.body.document;
54
+ return textResult(`${outcome || "done"}: "${document?.title ?? ""}" (${document?.id ?? resolved.id}).`, {
55
+ outcome,
56
+ document,
57
+ });
58
+ }));
59
+ register("docs_sync_status", {
60
+ title: "Read the Tower-Notion sync status",
61
+ description: "Every document linked to a Notion page (or one, by id or slug) with its sync state: synced (the body is what "
62
+ + "both sides last agreed on), tower_changed (edited in Tower since; the next write-back pushes it), never_synced "
63
+ + "(linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull. Never a body.",
64
+ inputSchema: {
65
+ id: z.string().min(1).max(200).optional().describe("A document id or slug, for one document's status."),
66
+ },
67
+ }, async (args) => withSession(deps, async (session) => {
68
+ let path = "/api/docs/notion-sync";
69
+ if (typeof args.id === "string" && args.id.length > 0) {
70
+ const resolved = await resolveDocumentId(deps, session, args.id);
71
+ if (resolved.status === "list_failed")
72
+ return errorResult(resolved.text);
73
+ if (resolved.status === "not_found")
74
+ return errorResult(`No document matches "${args.id}" — check docs_list for the id or slug.`);
75
+ path = `${path}?document_id=${encodeURIComponent(resolved.id)}`;
76
+ }
77
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
78
+ if (!response.ok)
79
+ return errorResult(doorFailureText("docs_sync_status", response));
80
+ const counts = response.body.counts;
81
+ const summary = Object.entries(counts?.by_state ?? {})
82
+ .map(([state, count]) => `${count} ${state}`)
83
+ .join(", ");
84
+ return textResult(`${counts?.linked ?? 0} linked: ${summary}.`, {
85
+ counts,
86
+ documents: response.body.documents ?? [],
87
+ });
88
+ }));
89
+ }
@@ -0,0 +1,23 @@
1
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
2
+ import { type ToolResult } from "./tool-result.js";
3
+ /** Mirrors `commands/docs-read.ts` `sliceDocSection` exactly. */
4
+ export declare function sliceDocSection(markdown: string, wanted: string): {
5
+ text: string;
6
+ headings: string[];
7
+ } | null;
8
+ /** Mirrors `commands/docs-read.ts` `sliceDocRange` exactly. */
9
+ export declare function sliceDocRange(body: string, offset: number | undefined, limit: number | undefined): {
10
+ text: string;
11
+ truncated: boolean;
12
+ totalChars: number;
13
+ };
14
+ export interface DocsReadArgs {
15
+ id?: unknown;
16
+ ids?: unknown;
17
+ parent_id?: unknown;
18
+ bodies?: unknown;
19
+ section?: unknown;
20
+ offset?: unknown;
21
+ limit?: unknown;
22
+ }
23
+ export declare function runDocsReadTool(deps: DocsMsgDeps, args: DocsReadArgs): Promise<ToolResult>;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `docs_read` (BLI-10450) — the MCP twin of `cockpit docs read`'s three
3
+ * shapes, split out of `docs-msg-tools.ts` to keep it under the file-size
4
+ * ratchet:
5
+ *
6
+ * 1. `id` — unchanged: one document, `{ document }`.
7
+ * 2. `ids` — several references in one call. One shared list resolves every
8
+ * ref (a uuid ref skips the list read entirely — `resolveDocumentIds`),
9
+ * then one GET per document. `{ documents: [{ ref, ok, document|error }] }`.
10
+ * 3. `parent_id` + `bodies: true` — every document filed directly under that
11
+ * parent, bodies included, in ONE call
12
+ * (`GET /api/docs/documents?parent_id=...&bodies=true`).
13
+ *
14
+ * `section`/`offset`/`limit` narrow whatever body was fetched, in every
15
+ * shape above — the same slicing `commands/docs-read.ts` runs on the CLI.
16
+ */
17
+ import { callAgentDoor } from "./agent-door.js";
18
+ import { resolveDocumentId } from "./docs-msg-helpers.js";
19
+ import { doorFailureText, errorResult, textResult, withSession } from "./tool-result.js";
20
+ function headingsOf(markdown) {
21
+ return markdown
22
+ .split("\n")
23
+ .map((line, lineIndex) => ({ lineIndex, line }))
24
+ .map(({ lineIndex, line }) => {
25
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
26
+ return match?.[1] && match[2] ? { lineIndex, level: match[1].length, title: match[2] } : null;
27
+ })
28
+ .filter((heading) => heading !== null);
29
+ }
30
+ /** Mirrors `commands/docs-read.ts` `sliceDocSection` exactly. */
31
+ export function sliceDocSection(markdown, wanted) {
32
+ const headings = headingsOf(markdown);
33
+ const target = headings.find((heading) => heading.title.toLowerCase() === wanted.trim().toLowerCase());
34
+ if (!target)
35
+ return null;
36
+ const lines = markdown.split("\n");
37
+ const next = headings.find((heading) => heading.lineIndex > target.lineIndex && heading.level <= target.level);
38
+ const endLine = next ? next.lineIndex : lines.length;
39
+ return { text: lines.slice(target.lineIndex, endLine).join("\n").trim(), headings: headings.map((h) => h.title) };
40
+ }
41
+ /** Mirrors `commands/docs-read.ts` `sliceDocRange` exactly. */
42
+ export function sliceDocRange(body, offset, limit) {
43
+ const totalChars = body.length;
44
+ if (offset === undefined && limit === undefined)
45
+ return { text: body, truncated: false, totalChars };
46
+ const start = Math.min(offset ?? 0, totalChars);
47
+ const end = limit === undefined ? totalChars : Math.min(start + limit, totalChars);
48
+ return { text: body.slice(start, end), truncated: end < totalChars, totalChars };
49
+ }
50
+ /**
51
+ * `applyCharRange` is false in `parent_id` + `bodies: true` mode: there,
52
+ * `limit` already picked the DOCUMENT COUNT (mirrors `commands/docs-read.ts`
53
+ * — a live-fire probe against production caught this exact overload
54
+ * silently truncating every body to a handful of characters when `limit`
55
+ * was passed meaning "N documents").
56
+ */
57
+ function narrowBody(document, section, offset, limit, applyCharRange) {
58
+ let body = typeof document.body_markdown === "string" ? document.body_markdown : "";
59
+ if (section) {
60
+ const sliced = sliceDocSection(body, section);
61
+ if (!sliced) {
62
+ const known = headingsOf(body).map((heading) => heading.title);
63
+ return { ok: false, text: `No section "${section}" in ${document.id} (${known.join(", ") || "none"}).` };
64
+ }
65
+ body = sliced.text;
66
+ }
67
+ if (applyCharRange && (offset !== undefined || limit !== undefined))
68
+ body = sliceDocRange(body, offset, limit).text;
69
+ document.body_markdown = body;
70
+ return { ok: true };
71
+ }
72
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
73
+ /** One shared list read for every non-uuid ref, never one per ref. */
74
+ async function resolveDocumentIds(deps, session, refs) {
75
+ const ids = new Map();
76
+ const needsList = refs.filter((ref) => !UUID_PATTERN.test(ref));
77
+ for (const ref of refs)
78
+ if (UUID_PATTERN.test(ref))
79
+ ids.set(ref, ref);
80
+ if (needsList.length === 0)
81
+ return { status: "ok", ids };
82
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
83
+ if (!response.ok)
84
+ return { status: "list_failed", text: doorFailureText("docs_list", response) };
85
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
86
+ for (const ref of needsList) {
87
+ const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
88
+ if (match)
89
+ ids.set(ref, match.id);
90
+ }
91
+ return { status: "ok", ids };
92
+ }
93
+ async function fetchOneDocument(deps, session, ref, id) {
94
+ if (!id)
95
+ return { ref, ok: false, text: `No document matches "${ref}" — check docs_list for the id or slug.` };
96
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents/${encodeURIComponent(id)}`);
97
+ if (!response.ok)
98
+ return { ref, ok: false, text: doorFailureText("docs_read", response) };
99
+ const document = (response.body.document ?? { id });
100
+ return { ref, ok: true, document };
101
+ }
102
+ export async function runDocsReadTool(deps, args) {
103
+ return withSession(deps, async (session) => {
104
+ const section = typeof args.section === "string" ? args.section : undefined;
105
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
106
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
107
+ // Shape 3: --parent + --bodies. Built by hand rather than through
108
+ // `docsListPath`: that helper defaults a missing limit to
109
+ // DEFAULT_LIST_LIMIT + 1 for the METADATA list's "more exist" check,
110
+ // which would overshoot the door's own DOCUMENT_BODIES_MAX_LIMIT (50)
111
+ // here and turn an unset limit into a refusal.
112
+ if (typeof args.parent_id === "string" && args.parent_id && args.bodies === true) {
113
+ const params = new URLSearchParams({ parent_id: args.parent_id, bodies: "true" });
114
+ if (limit !== undefined)
115
+ params.set("limit", String(limit));
116
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents?${params.toString()}`);
117
+ if (!response.ok)
118
+ return errorResult(doorFailureText("docs_read", response));
119
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
120
+ const misses = [];
121
+ for (const document of documents) {
122
+ // `limit` already picked the DOCUMENT COUNT above; `false` keeps it
123
+ // from being reapplied as a character count.
124
+ const narrowed = narrowBody(document, section, offset, limit, false);
125
+ if (!narrowed.ok)
126
+ misses.push(narrowed.text);
127
+ }
128
+ return textResult(`${documents.length} document(s) under ${String(args.parent_id)}.${misses.length ? `\n${misses.join("\n")}` : ""}`, { documents });
129
+ }
130
+ // Shapes 1/2: id, or ids (several).
131
+ const refs = Array.isArray(args.ids) && args.ids.length > 0
132
+ ? args.ids.map(String)
133
+ : [String(args.id ?? "")];
134
+ if (refs.length === 1 && refs[0] === "") {
135
+ return errorResult("docs_read needs id, ids, or parent_id + bodies: true.");
136
+ }
137
+ const resolved = refs.length === 1
138
+ ? await (async () => {
139
+ const one = await resolveDocumentId(deps, session, refs[0]);
140
+ if (one.status === "list_failed")
141
+ return { status: "list_failed", text: one.text };
142
+ const ids = new Map();
143
+ if (one.status === "ok")
144
+ ids.set(refs[0], one.id);
145
+ return { status: "ok", ids };
146
+ })()
147
+ : await resolveDocumentIds(deps, session, refs);
148
+ if (resolved.status === "list_failed")
149
+ return errorResult(resolved.text);
150
+ const results = await Promise.all(refs.map((ref) => fetchOneDocument(deps, session, ref, resolved.ids.get(ref))));
151
+ for (const result of results) {
152
+ if (result.ok) {
153
+ // Not `--parent --bodies` mode: `limit` here is a CHARACTER count.
154
+ const narrowed = narrowBody(result.document, section, offset, limit, true);
155
+ if (!narrowed.ok)
156
+ Object.assign(result, { ok: false, text: narrowed.text });
157
+ }
158
+ }
159
+ if (refs.length === 1) {
160
+ const [result] = results;
161
+ if (!result?.ok)
162
+ return errorResult(result?.text ?? "docs_read could not read that document.");
163
+ return textResult(`${result.document.title ?? ""}\n\n${result.document.body_markdown ?? ""}`, { document: result.document });
164
+ }
165
+ const failed = results.filter((result) => !result.ok);
166
+ const lines = results
167
+ .map((result) => (result.ok ? `${result.document.title ?? result.ref}\n\n${result.document.body_markdown ?? ""}` : `${result.ref}: ${result.text}`))
168
+ .join("\n\n---\n\n");
169
+ return textResult(`${results.length - failed.length}/${results.length} document(s) read.\n\n${lines}`, {
170
+ documents: results.map((result) => result.ok
171
+ ? { ref: result.ref, ok: true, document: result.document }
172
+ : { ref: result.ref, ok: false, error: result.text }),
173
+ });
174
+ });
175
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * BLI-10449: the notes library door's pointer at meeting records that live in
3
+ * Docs, not Notes (`apps/dashboard/src/lib/notes/meeting-docs.ts` decides what
4
+ * counts and which copy is canonical). `notes_list` prints these lines under
5
+ * the notes; the CLI's twin is `meetingDocumentLines` in the collector's
6
+ * `commands/notes-reads.ts`, and the two print the same words.
7
+ */
8
+ export interface MeetingDocuments {
9
+ count: number;
10
+ copies: number;
11
+ truncated: boolean;
12
+ items: Array<{
13
+ id: string;
14
+ slug: string | null;
15
+ title: string;
16
+ meetingDate: string | null;
17
+ visibility: string;
18
+ source: string;
19
+ bodyChars: number;
20
+ parts: string[];
21
+ canonical: boolean;
22
+ duplicateOf: string | null;
23
+ canonicalReason: string | null;
24
+ }>;
25
+ }
26
+ export declare function meetingDocumentLines(meeting: MeetingDocuments | null | undefined, readVerb: string): string[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * BLI-10449: the notes library door's pointer at meeting records that live in
3
+ * Docs, not Notes (`apps/dashboard/src/lib/notes/meeting-docs.ts` decides what
4
+ * counts and which copy is canonical). `notes_list` prints these lines under
5
+ * the notes; the CLI's twin is `meetingDocumentLines` in the collector's
6
+ * `commands/notes-reads.ts`, and the two print the same words.
7
+ */
8
+ export function meetingDocumentLines(meeting, readVerb) {
9
+ if (!meeting || meeting.count === 0)
10
+ return [];
11
+ const refOf = new Map(meeting.items.map((item) => [item.id, item.slug ?? item.id]));
12
+ const copies = meeting.copies > 0 ? `, ${meeting.copies} of them ${meeting.copies === 1 ? "a copy" : "copies"}` : "";
13
+ const lines = [
14
+ `Meeting records in Docs, not in Notes (${meeting.count}${copies}). Read one with \`${readVerb} <ref>\`:`,
15
+ ];
16
+ for (const item of meeting.items) {
17
+ const size = `${Math.max(1, Math.round(item.bodyChars / 1000))} KB${item.parts.length > 0 ? ` in ${item.parts.length + 1} parts` : ""}`;
18
+ const mark = item.canonical
19
+ ? item.canonicalReason && item.canonicalReason !== "only_copy" ? ` · canonical (${item.canonicalReason})` : ""
20
+ : ` · copy of ${refOf.get(item.duplicateOf ?? "") ?? item.duplicateOf}`;
21
+ lines.push(` ${item.meetingDate ?? "undated "} ${refOf.get(item.id)} ${item.title} ${item.visibility} · ${item.source} · ${size}${mark}`);
22
+ }
23
+ if (meeting.truncated)
24
+ lines.push(" (the read hit its cap, so there may be more)");
25
+ return lines;
26
+ }
@@ -18,6 +18,7 @@ import { registerNoteFolderTools } from "./note-folder-tools.js";
18
18
  */
19
19
  import { z } from "zod";
20
20
  import { callAgentDoor } from "./agent-door.js";
21
+ import { meetingDocumentLines } from "./notes-meeting-docs.js";
21
22
  import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
22
23
  /**
23
24
  * The door's own words for "this answer is narrower than the browser's", or
@@ -82,8 +83,16 @@ export function registerNotesTools(server, deps) {
82
83
  return `${shelf.heading} (${shelf.notes.length})\n${notes}`;
83
84
  })
84
85
  .join("\n\n");
86
+ const inDocs = meetingDocumentLines(body.meetingDocuments, "docs_read").join("\n");
85
87
  return textResult(`${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`
86
- + `${lines ? `\n\n${lines}` : ""}${narrowingNote(body)}`, { scope: body.scope ?? null, count: body.count ?? 0, more: body.more ?? false, series });
88
+ + `${lines ? `\n\n${lines}` : ""}${inDocs ? `\n\n${inDocs}` : ""}${narrowingNote(body)}`, {
89
+ scope: body.scope ?? null,
90
+ count: body.count ?? 0,
91
+ more: body.more ?? false,
92
+ series,
93
+ meetingDocuments: body.meetingDocuments ?? null,
94
+ meetingDocumentsSkipped: body.meetingDocumentsSkipped ?? null,
95
+ });
87
96
  }));
88
97
  register("notes_show", {
89
98
  title: "Read one Tower meeting note",
package/dist/ops-tools.js CHANGED
@@ -193,11 +193,15 @@ export function registerOpsTools(server, deps) {
193
193
  inputSchema: {
194
194
  workspace: z.string().min(1).max(100).optional().describe("One workspace key instead of all of them."),
195
195
  stale_only: z.boolean().optional().describe("Report only the stale channels, not the whole breakdown."),
196
+ // BLI-10450: counts alone could not answer "is there a Kaiben channel?".
197
+ list: z.boolean().optional().describe("Name every channel and its readable state, not only counts."),
196
198
  },
197
199
  }, async (args) => withSession(deps, async (session) => {
198
200
  const params = new URLSearchParams();
199
201
  if (args.workspace)
200
202
  params.set("workspace", String(args.workspace));
203
+ if (args.list)
204
+ params.set("list", "1");
201
205
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/slack/coverage${queryString(params)}`);
202
206
  if (!response.ok)
203
207
  return errorResult(doorFailureText("slack_coverage", response));
@@ -214,7 +218,13 @@ export function registerOpsTools(server, deps) {
214
218
  ? ` no channel is over ${staleAfter}h since its last sync`
215
219
  : ` ${stale} channel(s) over ${staleAfter}h since last sync`;
216
220
  const cursor = args.stale_only ? "" : `\n newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`;
217
- return `${head}\n${staleLine}${cursor}`;
221
+ const channels = Array.isArray(workspace["channels"])
222
+ ? workspace["channels"]
223
+ : [];
224
+ const channelLines = channels.length > 0
225
+ ? `\n${channels.map((channel) => ` #${String(channel["name"] ?? "?")} ${String(channel["meaning"] ?? "?")}`).join("\n")}`
226
+ : "";
227
+ return `${head}\n${staleLine}${cursor}${channelLines}`;
218
228
  })
219
229
  .join("\n\n");
220
230
  return textResult(`${workspaces.length} workspace(s), ${staleTotal} stale channel(s).${lines ? `\n\n${lines}` : ""}`
@@ -227,7 +237,8 @@ export function registerOpsTools(server, deps) {
227
237
  inputSchema: {
228
238
  person: z.string().min(1).max(200).optional().describe("A person — name, email or Slack member id."),
229
239
  channel: z.string().min(1).max(200).optional().describe("One channel name."),
230
- query: z.string().min(1).max(500).optional().describe("Text to look for."),
240
+ query: z.string().min(1).max(500).optional().describe("Text to look for, as a substring."),
241
+ word: z.boolean().optional().describe("Match query as whole words, so \"excel\" stops matching \"Excellent\"."),
231
242
  since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
232
243
  until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
233
244
  limit: z.number().int().min(1).max(500).optional().describe("At most this many messages, newest first."),
@@ -240,6 +251,9 @@ export function registerOpsTools(server, deps) {
240
251
  }
241
252
  if (typeof args.limit === "number")
242
253
  body["limit"] = args.limit;
254
+ // BLI-10451: the same `word: true` the CLI's `--word` sends.
255
+ if (args.query && args.word === true)
256
+ body["word"] = true;
243
257
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/slack/read", body);
244
258
  if (!response.ok) {
245
259
  // The CLI names the open door beside the closed one on a 403; an
@@ -256,9 +270,14 @@ export function registerOpsTools(server, deps) {
256
270
  .map((message) => `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`
257
271
  + `\n ${String(message["text"] ?? "")}`)
258
272
  .join("\n");
259
- // The server's own summary first and verbatim: it is the sentence that
260
- // keeps an empty answer from reading as "nobody said anything".
261
- const summary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
273
+ // BLI-10450: `summary` is built for the JARVIS chat tool, which needs
274
+ // ONE self-contained string, so it already embeds a bullet line per
275
+ // message AND the note (tools-slack-render.ts renderSummary). Only
276
+ // the header (summary's first line) is printed here — it is the
277
+ // sentence that keeps an empty answer from reading as "nobody said
278
+ // anything" — and the messages/note print exactly once, not twice.
279
+ const rawSummary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
280
+ const summary = rawSummary.split("\n")[0] ?? rawSummary;
262
281
  const note = typeof read["note"] === "string" && read["note"] ? `\n\n${read["note"]}` : "";
263
282
  return textResult(`${summary}${lines ? `\n\n${lines}` : ""}${note}`, {
264
283
  status: read["status"] ?? null,
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
  import { registerBriefTools } from "./brief-tools.js";
11
11
  import { registerBriefWriteTools } from "./brief-write-tools.js";
12
12
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
13
+ import { registerDocsNotionTools } from "./docs-notion-tools.js";
13
14
  import { registerJarvisTools } from "./jarvis-tools.js";
14
15
  import { registerCalTools } from "./cal-tools.js";
15
16
  import { registerMailTools } from "./mail-tools.js";
@@ -415,6 +416,8 @@ export function createServer(deps) {
415
416
  // no `cockpit login` pairing yet still serves the three event tools; only a
416
417
  // docs/msg call on that machine fails, by name.
417
418
  registerDocsMsgTools(server, { fetchImpl: deps.fetchImpl });
419
+ // BLI-10430: docs_link, the Notion half of the docs tools, on the same path.
420
+ registerDocsNotionTools(server, { fetchImpl: deps.fetchImpl });
418
421
  // BLI-3716: `work_*` — the issue tracker, on the same device-token path as
419
422
  // the docs/msg tools above, for the same reason: a coding session should
420
423
  // file and move a Tower issue the way it files and moves a Linear one.
@@ -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", "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", "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"];
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 ["emit_event", "docs_create", "docs_update", "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_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "jarvis_dispatch", "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_device_revoke"];
24
+ export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "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_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "jarvis_dispatch", "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_device_revoke"];
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
  /**
@@ -16,6 +16,7 @@ export const READ_TOOLS = [
16
16
  "docs_list",
17
17
  "docs_tree",
18
18
  "docs_read",
19
+ "docs_sync_status",
19
20
  "msg_channels",
20
21
  "msg_read",
21
22
  "msg_thread",
@@ -84,6 +85,7 @@ export const WRITE_TOOLS = [
84
85
  "emit_event",
85
86
  "docs_create",
86
87
  "docs_update",
88
+ "docs_link",
87
89
  "msg_create_channel",
88
90
  "msg_dm",
89
91
  "msg_send",
@@ -234,6 +234,8 @@ export const MCP_TWINS = {
234
234
  "docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
235
235
  "docs create": { tool: "docs_create", door: "POST /api/docs/documents" },
236
236
  "docs update": { tool: "docs_update", door: "PATCH /api/docs/documents/[id]" },
237
+ "docs link": { tool: "docs_link", door: "PUT /api/docs/documents/[id]/notion-link" },
238
+ "docs sync-status": { tool: "docs_sync_status", door: "GET /api/docs/notion-sync" },
237
239
  "msg channels": { tool: "msg_channels", door: "GET /api/msg/channels" },
238
240
  "msg create": { tool: "msg_create_channel", door: "POST /api/msg/channels" },
239
241
  "msg dm": { tool: "msg_dm", door: "POST /api/msg/channels (dm)" },
@@ -0,0 +1,15 @@
1
+ import type { DoorSession, ToolDeps } from "./tool-result.js";
2
+ export interface AssigneeIdentity {
3
+ name: string;
4
+ email: string | null;
5
+ }
6
+ export declare function resolveAssigneeDirectory(deps: ToolDeps, session: DoorSession, assigneeIds: Array<string | null | undefined>): Promise<Map<string, AssigneeIdentity>>;
7
+ /** `assignee_name`/`assignee_email` alongside the bare id, for structuredContent. */
8
+ export declare function withAssigneeIdentity<T extends {
9
+ assignee_id?: string | null;
10
+ }>(row: T, directory: Map<string, AssigneeIdentity>): T & {
11
+ assignee_name: string | null;
12
+ assignee_email: string | null;
13
+ };
14
+ /** The text-line assignee phrase: a name (and email) when resolved, the bare id otherwise. */
15
+ export declare function assigneeLabel(assigneeId: string | null | undefined, directory: Map<string, AssigneeIdentity>): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * BLI-10450: `work_list_issues`/`work_get_issue` carried only `assignee_id`
3
+ * (a uuid) — answering "who owns what" needed a second `work_list_projects`-
4
+ * shaped call to `/api/team/members` and a join done by hand. One call per
5
+ * tool invocation (never per row), the same door `cockpit team members`
6
+ * calls, mirroring `commands/issue-directory.ts` on the CLI.
7
+ */
8
+ import { callAgentDoor } from "./agent-door.js";
9
+ export async function resolveAssigneeDirectory(deps, session, assigneeIds) {
10
+ const wanted = new Set(assigneeIds.filter((id) => Boolean(id)));
11
+ const directory = new Map();
12
+ if (wanted.size === 0)
13
+ return directory;
14
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/team/members");
15
+ if (!response.ok)
16
+ return directory;
17
+ const people = (Array.isArray(response.body.people) ? response.body.people : []);
18
+ for (const person of people) {
19
+ const userId = person.account?.userId;
20
+ if (!userId || !wanted.has(userId))
21
+ continue;
22
+ directory.set(userId, { name: person.name ?? userId, email: person.email ?? null });
23
+ }
24
+ return directory;
25
+ }
26
+ /** `assignee_name`/`assignee_email` alongside the bare id, for structuredContent. */
27
+ export function withAssigneeIdentity(row, directory) {
28
+ const identity = row.assignee_id ? directory.get(row.assignee_id) : undefined;
29
+ return { ...row, assignee_name: identity?.name ?? null, assignee_email: identity?.email ?? null };
30
+ }
31
+ /** The text-line assignee phrase: a name (and email) when resolved, the bare id otherwise. */
32
+ export function assigneeLabel(assigneeId, directory) {
33
+ if (!assigneeId)
34
+ return "none";
35
+ const identity = directory.get(assigneeId);
36
+ if (!identity)
37
+ return assigneeId;
38
+ return identity.email ? `${identity.name} <${identity.email}>` : identity.name;
39
+ }
@@ -17,6 +17,7 @@
17
17
  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
+ import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./work-directory.js";
20
21
  /** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
21
22
  export const WORK_ISSUE_STATES = [
22
23
  "backlog",
@@ -28,12 +29,6 @@ export const WORK_ISSUE_STATES = [
28
29
  ];
29
30
  /** `/api/work/issues`' own ceiling, and the most a caller may ask for here. */
30
31
  const ISSUE_LIST_MAX = 1000;
31
- /**
32
- * The fields `work_list_issues` promises. The door's row carries the whole
33
- * description and a dozen bookkeeping columns; with no limit that was 1,000
34
- * issues and 4.2 MB, 3.3 MB of it descriptions the tool said it never
35
- * returned (BLI-10432). work_get_issue reads one issue in full.
36
- */
37
32
  function issueListRow(row) {
38
33
  return {
39
34
  id: row.id,
@@ -47,6 +42,59 @@ function issueListRow(row) {
47
42
  updated_at: row.updated_at ?? null,
48
43
  };
49
44
  }
45
+ function listCompleteness(value) {
46
+ if (!value || typeof value !== "object")
47
+ return null;
48
+ const record = value;
49
+ const status = record["status"];
50
+ if (status !== "complete" && status !== "partial" && status !== "unknown")
51
+ return null;
52
+ return { status, total: typeof record["total"] === "number" ? record["total"] : null };
53
+ }
54
+ function outsideProject(value) {
55
+ if (!value || typeof value !== "object")
56
+ return null;
57
+ const record = value;
58
+ if (!Array.isArray(record["issues"]))
59
+ return null;
60
+ return { status: String(record["status"] ?? "complete"), issues: record["issues"] };
61
+ }
62
+ /** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
63
+ function outsideNote(outside) {
64
+ if (!outside)
65
+ return "";
66
+ const named = outside.issues.map((issue) => `${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"})`);
67
+ const found = named.length > 0 ? ` ${named.length} sub-issue(s) of these sit outside the project: ${named.join(", ")}.` : "";
68
+ const gap = outside.status === "failed" ? " The outside-project check failed (read_failed)."
69
+ : outside.status === "capped" ? " The outside-project check was capped; more may exist." : "";
70
+ return found + gap;
71
+ }
72
+ /** One issue plus its comments — a comment read that fails does not lose the issue already in hand. */
73
+ async function fetchOneIssue(deps, session, ref) {
74
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
75
+ if (!response.ok)
76
+ return { ref, ok: false, text: doorFailureText("work_get_issue", response) };
77
+ const issue = response.body.issue;
78
+ const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
79
+ const comments = commentsResponse.ok
80
+ ? (commentsResponse.body.comments ?? [])
81
+ : [];
82
+ return {
83
+ ref,
84
+ ok: true,
85
+ issue,
86
+ comments,
87
+ ...(commentsResponse.ok ? {} : { commentsUnread: true }),
88
+ };
89
+ }
90
+ function renderOneIssue(result, directory) {
91
+ const { issue, comments } = result;
92
+ const commentText = comments.map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`).join("\n\n");
93
+ return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"}) · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
94
+ + `\n\n${issue?.description ?? "(no description)"}`
95
+ + (commentText ? `\n\n--- comments ---\n${commentText}` : "")
96
+ + (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
97
+ }
50
98
  /**
51
99
  * A row the Linear import could not give state names to says which it is.
52
100
  * Printing "(none) -> (none)" is honest and useless; naming the gap keeps
@@ -111,33 +159,72 @@ export function registerWorkTools(server, deps) {
111
159
  if (!response.ok)
112
160
  return errorResult(doorFailureText("work_list_issues", response));
113
161
  const all = (Array.isArray(response.body.issues) ? response.body.issues : []);
114
- const more = args.limit === undefined && all.length > DEFAULT_LIST_LIMIT;
115
- const issues = more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
162
+ // BLI-10447: the door says whether a full read was every match; a named
163
+ // limit that filled up used to answer "more: false" regardless.
164
+ const completeness = listCompleteness(response.body.completeness);
165
+ const more = args.limit === undefined
166
+ ? all.length > DEFAULT_LIST_LIMIT
167
+ : completeness?.status === "partial" || completeness?.status === "unknown";
168
+ const issues = args.limit === undefined && more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
169
+ const outside = outsideProject(response.body.outside_project);
170
+ // BLI-10450: one directory call for the whole page, never one per row.
171
+ const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
116
172
  const lines = issues
117
173
  .map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
118
- + ` [p${issue.priority ?? 0}, assignee ${issue.assignee_id ?? "none"}, project ${issue.project_id ?? "none"}]`)
174
+ + ` [p${issue.priority ?? 0}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`)
119
175
  .join("\n");
120
- return textResult(`${issues.length} issue(s).${moreNote(more, ISSUE_LIST_MAX)}${lines ? `\n${lines}` : ""}`, { issues: issues.map(issueListRow), more });
176
+ const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
177
+ return textResult(`${issues.length} issue(s).${totalNote}${moreNote(more, ISSUE_LIST_MAX)}${outsideNote(outside)}${lines ? `\n${lines}` : ""}`, {
178
+ issues: issues.map((issue) => withAssigneeIdentity(issueListRow(issue), directory)),
179
+ more,
180
+ ...(completeness ? { total: completeness.total } : {}),
181
+ ...(outside ? { outside_project: outside } : {}),
182
+ });
121
183
  }));
122
184
  register("work_get_issue", {
123
185
  title: "Read a Tower issue",
124
- description: "One issue's title, state, priority, assignee, description and every comment on it.",
125
- inputSchema: { id: ISSUE_REF },
186
+ description: "One issue's title, state, priority, assignee (name and email, not just its id), description and every "
187
+ + "comment on it. `ids` reads several in one call — still one request per issue plus its comments, "
188
+ + "no bulk door exists yet. A single id answers `{ issue, comments }`; several answer "
189
+ + "`{ issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }` so one bad id never "
190
+ + "loses the rest.",
191
+ inputSchema: {
192
+ id: ISSUE_REF.optional().describe("A single issue. Omit when passing ids."),
193
+ ids: z.array(ISSUE_REF).min(1).max(50).optional().describe("Several issues in one call. Omit when passing id."),
194
+ },
126
195
  }, async (args) => withSession(deps, async (session) => {
127
- const ref = String(args.id ?? "");
128
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
129
- if (!response.ok)
130
- return errorResult(doorFailureText("work_get_issue", response));
131
- const issue = response.body.issue;
132
- // A comment read that fails does not lose the issue already in hand.
133
- const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
134
- const comments = commentsResponse.ok ? (commentsResponse.body.comments ?? []) : [];
135
- const commentText = comments
136
- .map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`)
137
- .join("\n\n");
138
- return textResult(`${issue?.identifier ?? ref} ${issue?.title ?? ""} (${issue?.state ?? "?"})\n\n${issue?.description ?? "(no description)"}`
139
- + (commentText ? `\n\n--- comments ---\n${commentText}` : "")
140
- + (commentsResponse.ok ? "" : "\n\n(comments could not be read on this call)"), { issue, comments, ...(commentsResponse.ok ? {} : { comments_unread: true }) });
196
+ const refs = Array.isArray(args.ids) && args.ids.length > 0
197
+ ? args.ids.map(String)
198
+ : [String(args.id ?? "")];
199
+ if (refs.length === 1 && refs[0] === "")
200
+ return errorResult("work_get_issue needs id or ids.");
201
+ const results = await Promise.all(refs.map((ref) => fetchOneIssue(deps, session, ref)));
202
+ const directory = await resolveAssigneeDirectory(deps, session, results.filter((result) => result.ok).map((result) => result.issue?.assignee_id));
203
+ if (refs.length === 1) {
204
+ const [result] = results;
205
+ if (!result?.ok)
206
+ return errorResult(result?.text ?? "work_get_issue could not read that issue.");
207
+ return textResult(renderOneIssue(result, directory), {
208
+ issue: withAssigneeIdentity(result.issue ?? {}, directory),
209
+ comments: result.comments,
210
+ ...(result.commentsUnread ? { comments_unread: true } : {}),
211
+ });
212
+ }
213
+ const failed = results.filter((result) => !result.ok);
214
+ const lines = results
215
+ .map((result) => (result.ok ? renderOneIssue(result, directory) : `${result.ref}: ${result.text}`))
216
+ .join("\n\n---\n\n");
217
+ return textResult(`${results.length - failed.length}/${results.length} issue(s) read.\n\n${lines}`, {
218
+ issues: results.map((result) => result.ok
219
+ ? {
220
+ ref: result.ref,
221
+ ok: true,
222
+ issue: withAssigneeIdentity(result.issue ?? {}, directory),
223
+ comments: result.comments,
224
+ ...(result.commentsUnread ? { comments_unread: true } : {}),
225
+ }
226
+ : { ref: result.ref, ok: false, error: result.text }),
227
+ });
141
228
  }));
142
229
  register("work_create_issue", {
143
230
  title: "File a Tower issue",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.61",
3
+ "version": "0.1.65",
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",