@bli-cockpit/mcp 0.1.63 → 0.1.66

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.
@@ -53,6 +53,7 @@ export declare function docListLine(row: DocRow): string;
53
53
  export declare function docsListPath(args: {
54
54
  parent_id?: unknown;
55
55
  query?: unknown;
56
+ word?: unknown;
56
57
  limit?: unknown;
57
58
  }): string;
58
59
  export declare function resolveDocumentId(deps: DocsMsgDeps, session: {
@@ -67,8 +67,12 @@ export function docsListPath(args) {
67
67
  const params = new URLSearchParams();
68
68
  if (typeof args.parent_id === "string" && args.parent_id.length > 0)
69
69
  params.set("parent_id", args.parent_id);
70
- if (typeof args.query === "string" && args.query.trim().length > 0)
70
+ const hasQuery = typeof args.query === "string" && args.query.trim().length > 0;
71
+ if (hasQuery)
71
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");
72
76
  if (typeof args.limit === "number" && Number.isInteger(args.limit))
73
77
  params.set("limit", String(args.limit));
74
78
  // No limit named: one page plus one row, so "more exist" is read off the
@@ -102,5 +106,5 @@ export async function resolveChannelId(deps, session, ref) {
102
106
  /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
103
107
  export function messageLine(message) {
104
108
  return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
105
- + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
109
+ + `${String(message.agent_label ?? message.user_id ?? message.slack_author_label ?? "?")} ${String(message.content ?? "")}`;
106
110
  }
@@ -13,6 +13,7 @@ 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
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";
16
17
  export { docsListPath } from "./docs-msg-helpers.js";
17
18
  export function registerDocsMsgTools(server, deps) {
18
19
  const register = registrarFor(server);
@@ -20,11 +21,13 @@ export function registerDocsMsgTools(server, deps) {
20
21
  title: "List Tower documents",
21
22
  description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
22
23
  + "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
23
- + "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\"), "
24
26
  + `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
25
27
  inputSchema: {
26
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.'),
27
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."),
28
31
  limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
29
32
  },
30
33
  }, async (args) => withSession(deps, async (session) => {
@@ -52,21 +55,25 @@ export function registerDocsMsgTools(server, deps) {
52
55
  }));
53
56
  register("docs_read", {
54
57
  title: "Read a Tower document",
55
- description: "One document's title and full body, by its id or slug (docs_list returns both).",
56
- inputSchema: { id: z.string().min(1).max(200).describe("A document id, or its slug.") },
57
- }, async (args) => withSession(deps, async (session) => {
58
- const ref = String(args.id ?? "");
59
- const resolved = await resolveDocumentId(deps, session, ref);
60
- if (resolved.status === "list_failed")
61
- return errorResult(resolved.text);
62
- if (resolved.status === "not_found")
63
- return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
64
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents/${encodeURIComponent(resolved.id)}`);
65
- if (!response.ok)
66
- return errorResult(doorFailureText("docs_read", response));
67
- const document = response.body.document;
68
- return textResult(`${document?.title ?? ""}\n\n${document?.body_markdown ?? ""}`, { document });
69
- }));
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));
70
77
  register("docs_create", {
71
78
  title: "Create a Tower document",
72
79
  description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
@@ -179,7 +186,15 @@ export function registerDocsMsgTools(server, deps) {
179
186
  // which is what msg_thread takes. It used to say only "2 message(s)",
180
187
  // so a Claude-family client never saw one (BLI-10432).
181
188
  const lines = messages.map(messageLine).join("\n");
182
- 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 });
183
198
  }));
184
199
  register("msg_thread", {
185
200
  title: "Read a Tower message thread",
@@ -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,
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The row shapes the `work_*` tools read off `/api/work/**`, and the pure
3
+ * functions that turn them into what a tool answers with: the trimmed list
4
+ * row, the completeness and outside-project notes, one issue's text, one
5
+ * history line.
6
+ *
7
+ * Split out of `work-tools.ts` (BLI-10445) when that file crossed the
8
+ * 500-line readability ratchet. The seam: `work-tools.ts` registers the tools
9
+ * and calls the doors; nothing here calls a door or knows a session, so this
10
+ * module changes when a door's row shape or a rendered sentence does, and can
11
+ * be read without the registration code around it.
12
+ *
13
+ * Every string moved verbatim.
14
+ */
15
+ import { type AssigneeIdentity } from "./work-directory.js";
16
+ export interface IssueRow {
17
+ id: string;
18
+ identifier: string;
19
+ title: string;
20
+ state: string;
21
+ priority?: number | null;
22
+ assignee_id?: string | null;
23
+ project_id?: string | null;
24
+ parent_id?: string | null;
25
+ updated_at?: string | null;
26
+ }
27
+ /**
28
+ * The fields `work_list_issues` promises. The door's row carries the whole
29
+ * description and a dozen bookkeeping columns; with no limit that was 1,000
30
+ * issues and 4.2 MB, 3.3 MB of it descriptions the tool said it never
31
+ * returned (BLI-10432). work_get_issue reads one issue in full.
32
+ */
33
+ export interface IssueListRow {
34
+ id: string;
35
+ identifier: string;
36
+ title: string;
37
+ state: string;
38
+ priority: number | null;
39
+ assignee_id: string | null;
40
+ project_id: string | null;
41
+ parent_id: string | null;
42
+ updated_at: string | null;
43
+ }
44
+ export declare function issueListRow(row: IssueRow): IssueListRow;
45
+ export interface ListCompleteness {
46
+ status: "complete" | "partial" | "unknown";
47
+ total: number | null;
48
+ }
49
+ export declare function listCompleteness(value: unknown): ListCompleteness | null;
50
+ export interface OutsideProject {
51
+ status: string;
52
+ issues: {
53
+ identifier: string;
54
+ title: string;
55
+ project_id: string | null;
56
+ parent_identifier: string | null;
57
+ }[];
58
+ }
59
+ export declare function outsideProject(value: unknown): OutsideProject | null;
60
+ /** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
61
+ export declare function outsideNote(outside: OutsideProject | null): string;
62
+ export interface OneIssueOk {
63
+ ref: string;
64
+ ok: true;
65
+ issue?: IssueRow & {
66
+ description?: string | null;
67
+ };
68
+ comments: Array<{
69
+ body_markdown?: string;
70
+ created_at?: string;
71
+ }>;
72
+ commentsUnread?: boolean;
73
+ }
74
+ export interface OneIssueFailure {
75
+ ref: string;
76
+ ok: false;
77
+ text: string;
78
+ }
79
+ export declare function renderOneIssue(result: OneIssueOk, directory: Map<string, AssigneeIdentity>): string;
80
+ export interface HistoryRow {
81
+ occurred_at?: string;
82
+ change_type?: string;
83
+ from_value?: string | null;
84
+ to_value?: string | null;
85
+ actor_name?: string | null;
86
+ source?: string;
87
+ }
88
+ /**
89
+ * A row the Linear import could not give state names to says which it is.
90
+ * Printing "(none) -> (none)" is honest and useless; naming the gap keeps
91
+ * nobody reading a real gap as a broken tool (`commands/issue.ts` says the
92
+ * same thing in the same words).
93
+ */
94
+ export declare function historyChange(row: HistoryRow): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The row shapes the `work_*` tools read off `/api/work/**`, and the pure
3
+ * functions that turn them into what a tool answers with: the trimmed list
4
+ * row, the completeness and outside-project notes, one issue's text, one
5
+ * history line.
6
+ *
7
+ * Split out of `work-tools.ts` (BLI-10445) when that file crossed the
8
+ * 500-line readability ratchet. The seam: `work-tools.ts` registers the tools
9
+ * and calls the doors; nothing here calls a door or knows a session, so this
10
+ * module changes when a door's row shape or a rendered sentence does, and can
11
+ * be read without the registration code around it.
12
+ *
13
+ * Every string moved verbatim.
14
+ */
15
+ import { assigneeLabel } from "./work-directory.js";
16
+ export function issueListRow(row) {
17
+ return {
18
+ id: row.id,
19
+ identifier: row.identifier,
20
+ title: row.title,
21
+ state: row.state,
22
+ priority: row.priority ?? null,
23
+ assignee_id: row.assignee_id ?? null,
24
+ project_id: row.project_id ?? null,
25
+ parent_id: row.parent_id ?? null,
26
+ updated_at: row.updated_at ?? null,
27
+ };
28
+ }
29
+ export function listCompleteness(value) {
30
+ if (!value || typeof value !== "object")
31
+ return null;
32
+ const record = value;
33
+ const status = record["status"];
34
+ if (status !== "complete" && status !== "partial" && status !== "unknown")
35
+ return null;
36
+ return { status, total: typeof record["total"] === "number" ? record["total"] : null };
37
+ }
38
+ export function outsideProject(value) {
39
+ if (!value || typeof value !== "object")
40
+ return null;
41
+ const record = value;
42
+ if (!Array.isArray(record["issues"]))
43
+ return null;
44
+ return { status: String(record["status"] ?? "complete"), issues: record["issues"] };
45
+ }
46
+ /** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
47
+ export function outsideNote(outside) {
48
+ if (!outside)
49
+ return "";
50
+ const named = outside.issues.map((issue) => `${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"})`);
51
+ const found = named.length > 0 ? ` ${named.length} sub-issue(s) of these sit outside the project: ${named.join(", ")}.` : "";
52
+ const gap = outside.status === "failed" ? " The outside-project check failed (read_failed)."
53
+ : outside.status === "capped" ? " The outside-project check was capped; more may exist." : "";
54
+ return found + gap;
55
+ }
56
+ export function renderOneIssue(result, directory) {
57
+ const { issue, comments } = result;
58
+ 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)}`
60
+ + `\n\n${issue?.description ?? "(no description)"}`
61
+ + (commentText ? `\n\n--- comments ---\n${commentText}` : "")
62
+ + (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
63
+ }
64
+ /**
65
+ * A row the Linear import could not give state names to says which it is.
66
+ * Printing "(none) -> (none)" is honest and useless; naming the gap keeps
67
+ * nobody reading a real gap as a broken tool (`commands/issue.ts` says the
68
+ * same thing in the same words).
69
+ */
70
+ export function historyChange(row) {
71
+ if (row.from_value === null && row.to_value === null) {
72
+ return `(no state names recorded — imported from ${row.source ?? "elsewhere"})`;
73
+ }
74
+ return `${row.from_value ?? "(none)"} -> ${row.to_value ?? "(none)"}`;
75
+ }
@@ -17,6 +17,8 @@
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";
21
+ import { historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, } from "./work-issue-rows.js";
20
22
  /** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
21
23
  export const WORK_ISSUE_STATES = [
22
24
  "backlog",
@@ -28,37 +30,24 @@ export const WORK_ISSUE_STATES = [
28
30
  ];
29
31
  /** `/api/work/issues`' own ceiling, and the most a caller may ask for here. */
30
32
  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
- function issueListRow(row) {
33
+ /** One issue plus its comments — a comment read that fails does not lose the issue already in hand. */
34
+ async function fetchOneIssue(deps, session, ref) {
35
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
36
+ if (!response.ok)
37
+ return { ref, ok: false, text: doorFailureText("work_get_issue", response) };
38
+ const issue = response.body.issue;
39
+ const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
40
+ const comments = commentsResponse.ok
41
+ ? (commentsResponse.body.comments ?? [])
42
+ : [];
38
43
  return {
39
- id: row.id,
40
- identifier: row.identifier,
41
- title: row.title,
42
- state: row.state,
43
- priority: row.priority ?? null,
44
- assignee_id: row.assignee_id ?? null,
45
- project_id: row.project_id ?? null,
46
- parent_id: row.parent_id ?? null,
47
- updated_at: row.updated_at ?? null,
44
+ ref,
45
+ ok: true,
46
+ issue,
47
+ comments,
48
+ ...(commentsResponse.ok ? {} : { commentsUnread: true }),
48
49
  };
49
50
  }
50
- /**
51
- * A row the Linear import could not give state names to says which it is.
52
- * Printing "(none) -> (none)" is honest and useless; naming the gap keeps
53
- * nobody reading a real gap as a broken tool (`commands/issue.ts` says the
54
- * same thing in the same words).
55
- */
56
- function historyChange(row) {
57
- if (row.from_value === null && row.to_value === null) {
58
- return `(no state names recorded — imported from ${row.source ?? "elsewhere"})`;
59
- }
60
- return `${row.from_value ?? "(none)"} -> ${row.to_value ?? "(none)"}`;
61
- }
62
51
  /** A project id from a project NAME or a uuid — exact after case-folding, never fuzzy. */
63
52
  async function resolveProjectId(deps, session, ref) {
64
53
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/work/projects?include_archived=true");
@@ -111,33 +100,72 @@ export function registerWorkTools(server, deps) {
111
100
  if (!response.ok)
112
101
  return errorResult(doorFailureText("work_list_issues", response));
113
102
  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;
103
+ // BLI-10447: the door says whether a full read was every match; a named
104
+ // limit that filled up used to answer "more: false" regardless.
105
+ const completeness = listCompleteness(response.body.completeness);
106
+ const more = args.limit === undefined
107
+ ? all.length > DEFAULT_LIST_LIMIT
108
+ : completeness?.status === "partial" || completeness?.status === "unknown";
109
+ const issues = args.limit === undefined && more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
110
+ const outside = outsideProject(response.body.outside_project);
111
+ // BLI-10450: one directory call for the whole page, never one per row.
112
+ const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
116
113
  const lines = issues
117
114
  .map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
118
- + ` [p${issue.priority ?? 0}, assignee ${issue.assignee_id ?? "none"}, project ${issue.project_id ?? "none"}]`)
115
+ + ` [p${issue.priority ?? 0}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`)
119
116
  .join("\n");
120
- return textResult(`${issues.length} issue(s).${moreNote(more, ISSUE_LIST_MAX)}${lines ? `\n${lines}` : ""}`, { issues: issues.map(issueListRow), more });
117
+ const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
118
+ 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)),
120
+ more,
121
+ ...(completeness ? { total: completeness.total } : {}),
122
+ ...(outside ? { outside_project: outside } : {}),
123
+ });
121
124
  }));
122
125
  register("work_get_issue", {
123
126
  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 },
127
+ description: "One issue's title, state, priority, assignee (name and email, not just its id), description and every "
128
+ + "comment on it. `ids` reads several in one call — still one request per issue plus its comments, "
129
+ + "no bulk door exists yet. A single id answers `{ issue, comments }`; several answer "
130
+ + "`{ issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }` so one bad id never "
131
+ + "loses the rest.",
132
+ inputSchema: {
133
+ id: ISSUE_REF.optional().describe("A single issue. Omit when passing ids."),
134
+ ids: z.array(ISSUE_REF).min(1).max(50).optional().describe("Several issues in one call. Omit when passing id."),
135
+ },
126
136
  }, 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 }) });
137
+ const refs = Array.isArray(args.ids) && args.ids.length > 0
138
+ ? args.ids.map(String)
139
+ : [String(args.id ?? "")];
140
+ if (refs.length === 1 && refs[0] === "")
141
+ return errorResult("work_get_issue needs id or ids.");
142
+ const results = await Promise.all(refs.map((ref) => fetchOneIssue(deps, session, ref)));
143
+ const directory = await resolveAssigneeDirectory(deps, session, results.filter((result) => result.ok).map((result) => result.issue?.assignee_id));
144
+ if (refs.length === 1) {
145
+ const [result] = results;
146
+ if (!result?.ok)
147
+ return errorResult(result?.text ?? "work_get_issue could not read that issue.");
148
+ return textResult(renderOneIssue(result, directory), {
149
+ issue: withAssigneeIdentity(result.issue ?? {}, directory),
150
+ comments: result.comments,
151
+ ...(result.commentsUnread ? { comments_unread: true } : {}),
152
+ });
153
+ }
154
+ const failed = results.filter((result) => !result.ok);
155
+ const lines = results
156
+ .map((result) => (result.ok ? renderOneIssue(result, directory) : `${result.ref}: ${result.text}`))
157
+ .join("\n\n---\n\n");
158
+ return textResult(`${results.length - failed.length}/${results.length} issue(s) read.\n\n${lines}`, {
159
+ issues: results.map((result) => result.ok
160
+ ? {
161
+ ref: result.ref,
162
+ ok: true,
163
+ issue: withAssigneeIdentity(result.issue ?? {}, directory),
164
+ comments: result.comments,
165
+ ...(result.commentsUnread ? { comments_unread: true } : {}),
166
+ }
167
+ : { ref: result.ref, ok: false, error: result.text }),
168
+ });
141
169
  }));
142
170
  register("work_create_issue", {
143
171
  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.63",
3
+ "version": "0.1.66",
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",