@orboto/mcp 0.218.0 → 0.219.0

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.
@@ -56,7 +56,7 @@ function applyAliases(toolName, shape, value) {
56
56
  }
57
57
  }
58
58
  if ('id' in out && !('id' in shape)) {
59
- const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out));
59
+ const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out) && !(toolName === 'orboto_get_ticket' && c === 'commentId'));
60
60
  if (candidates.length === 1) {
61
61
  const canonical = candidates[0];
62
62
  const { id, ...rest } = out;
package/dist/tool-docs.js CHANGED
@@ -13,7 +13,7 @@ export const SUMMARY_MAX_CHARS = 220;
13
13
  */
14
14
  const SUMMARY_OVERRIDES = {
15
15
  orboto_create_full_backup: 'Start a full-workspace backup job; returns the job id to poll via orboto_list_backups.',
16
- orboto_get_ticket: 'Fetch one ticket by key or UUID with description, status, assignees, checklists and comments.',
16
+ orboto_get_ticket: 'Fetch a ticket by key: fields, checklists, comments.',
17
17
  orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets.',
18
18
  orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
19
19
  orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
@@ -26,7 +26,7 @@ describe('summarizeToolDescription', () => {
26
26
  });
27
27
  it('uses the hand-written override when one exists', () => {
28
28
  const s = summarizeToolDescription('orboto_get_ticket', 'A very long first sentence that would otherwise be cut mid-flow because it enumerates every field the response carries and never stops');
29
- expect(s).toContain('Fetch one ticket');
29
+ expect(s).toContain('Fetch a ticket');
30
30
  expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
31
31
  });
32
32
  it('falls back to a word-boundary cut when the first sentence overruns the cap', () => {
@@ -0,0 +1,60 @@
1
+ /** ORB-2484 - the comment thread newest first, the newest few in full and older ones shortened. */
2
+ export const COMMENT_FULL_COUNT = 3;
3
+ export const COMMENT_EXCERPT_CHARS = 300;
4
+ const COMMENT_ID_MIN_PREFIX = 8;
5
+ const COMMENT_PAGE_SIZE = 50;
6
+ const COMMENT_MAX_PAGES = 20;
7
+ /** Reads the whole thread, oldest first, across cursor pages. */
8
+ export async function fetchThread(client, ticketId) {
9
+ const out = [];
10
+ let cursor = null;
11
+ for (let page = 0; page < COMMENT_MAX_PAGES; page++) {
12
+ const query = `limit=${COMMENT_PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
13
+ const res = await client
14
+ .get(`/tickets/${ticketId}/comments?${query}`)
15
+ .catch(() => ({ items: [], nextCursor: null }));
16
+ out.push(...res.items);
17
+ cursor = res.nextCursor;
18
+ if (!cursor)
19
+ break;
20
+ }
21
+ return out;
22
+ }
23
+ /** Cuts at a word boundary within max characters and reports how many characters it left out. */
24
+ export function shortenComment(content, max) {
25
+ const chars = Array.from(content);
26
+ if (chars.length <= max)
27
+ return { excerpt: content, rest: 0 };
28
+ let end = max;
29
+ if (!/\s/u.test(chars[max])) {
30
+ for (let i = max - 1; i > 0; i--) {
31
+ if (/\s/u.test(chars[i])) {
32
+ end = i;
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ const excerpt = chars.slice(0, end).join('').trimEnd();
38
+ return { excerpt, rest: chars.length - Array.from(excerpt).length };
39
+ }
40
+ /** Newest first; a commentId (full or an 8-character prefix) selects one comment in full. */
41
+ export function selectComments(thread, opts) {
42
+ const entry = (row, whole) => {
43
+ const length = Array.from(row.content).length;
44
+ if (whole)
45
+ return { row, excerpt: row.content, length, rest: 0 };
46
+ return { row, length, ...shortenComment(row.content, COMMENT_EXCERPT_CHARS) };
47
+ };
48
+ if (opts.commentId) {
49
+ const want = opts.commentId.trim().toLowerCase();
50
+ const exact = thread.filter((c) => c.id.toLowerCase() === want);
51
+ const hits = exact.length > 0 ? exact : want.length >= COMMENT_ID_MIN_PREFIX ? thread.filter((c) => c.id.toLowerCase().startsWith(want)) : [];
52
+ if (hits.length === 0)
53
+ return { shown: [], error: `No comment with id "${opts.commentId}" on this ticket (a prefix needs at least ${COMMENT_ID_MIN_PREFIX} characters).` };
54
+ if (hits.length > 1)
55
+ return { shown: [], error: `The id prefix "${opts.commentId}" matches ${hits.length} comments: ${hits.map((c) => c.id).join(', ')}.` };
56
+ return { shown: [entry(hits[0], true)] };
57
+ }
58
+ const newestFirst = [...thread].reverse();
59
+ return { shown: newestFirst.map((row, n) => entry(row, opts.full === true || n < COMMENT_FULL_COUNT)) };
60
+ }
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { selectComments, shortenComment } from './comment-thread.js';
4
+ import { makeGetTicketHandler } from './get-ticket.js';
5
+ beforeEach(() => { vi.restoreAllMocks(); });
6
+ afterEach(() => { vi.restoreAllMocks(); });
7
+ const words = (label, n) => Array.from({ length: n }, (_, i) => `${label}${String(i).padStart(2, '0')}`).join(' ');
8
+ function thread(bodies) {
9
+ return bodies.map((content, i) => ({
10
+ id: `0000000${i + 1}-aaaa-4000-8000-00000000000${i}`,
11
+ content,
12
+ createdAt: `2026-09-${String(i + 1).padStart(2, '0')}`,
13
+ userName: 'Ada',
14
+ isInternal: false,
15
+ }));
16
+ }
17
+ describe('ORB-2484 shortened comment thread', () => {
18
+ it('lists newest first, the 3 newest in full and older ones shortened', () => {
19
+ const { shown } = selectComments(thread([words('old', 120), 'b', 'c', 'd', 'e']), {});
20
+ expect(shown.map((s) => s.row.content.slice(0, 3))).toEqual(['e', 'd', 'c', 'b', 'old']);
21
+ expect(shown[3].rest).toBe(0);
22
+ expect(shown[4].rest).toBeGreaterThan(0);
23
+ expect(Array.from(shown[4].excerpt).length).toBeLessThanOrEqual(300);
24
+ });
25
+ it('full keeps every body, commentId selects one by id or 8-character prefix', () => {
26
+ const t = thread([words('old', 120), 'b', 'c', 'd']);
27
+ expect(selectComments(t, { full: true }).shown.every((s) => s.rest === 0)).toBe(true);
28
+ const byPrefix = selectComments(t, { commentId: '00000001' });
29
+ expect(byPrefix.shown).toHaveLength(1);
30
+ expect(byPrefix.shown[0].rest).toBe(0);
31
+ expect(selectComments(t, { commentId: t[1].id }).shown[0].row.content).toBe('b');
32
+ expect(selectComments(t, { commentId: '99999999' }).error).toMatch(/No comment with id/);
33
+ expect(selectComments(t, { commentId: '0000' }).error).toMatch(/at least 8/);
34
+ const twins = [{ ...t[0], id: 'abcdef01-1' }, { ...t[1], id: 'abcdef01-2' }];
35
+ expect(selectComments(twins, { commentId: 'abcdef01' }).error).toMatch(/matches 2 comments/);
36
+ });
37
+ it('cuts at a word boundary and never inside a multi-byte character', () => {
38
+ const cut = shortenComment(`${'x'.repeat(295)} ${'y'.repeat(40)}`, 300);
39
+ expect(cut.excerpt).toBe('x'.repeat(295));
40
+ const umlauts = shortenComment('ä'.repeat(500), 300);
41
+ expect(Array.from(umlauts.excerpt)).toHaveLength(300);
42
+ expect(umlauts.rest).toBe(200);
43
+ expect(shortenComment('short', 300)).toEqual({ excerpt: 'short', rest: 0 });
44
+ });
45
+ it('orboto_get_ticket prints the shortened thread with the pointer and honours commentId', async () => {
46
+ const rows = thread([words('old', 120), 'b', 'c', 'd']);
47
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
48
+ const route = (url) => {
49
+ if (url.includes('/comments'))
50
+ return { items: rows, nextCursor: null };
51
+ if (url.includes('/tickets?parentTicketId'))
52
+ return { items: [], nextCursor: null };
53
+ if (url.endsWith('/attachments'))
54
+ return [];
55
+ if (url.includes('/projects/by-key/'))
56
+ return { id: 'p1', key: 'ACME' };
57
+ return { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', statusName: 'To Do', type: 'task', priority: 'normal', commentCount: 4 };
58
+ };
59
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ({
60
+ ok: true, status: 200, statusText: 'OK', json: async () => route(url.toString()), text: async () => '',
61
+ }));
62
+ const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', include: ['comments'] });
63
+ const text = res.content[0].text;
64
+ expect(text).toContain('## Comments (4, newest first; the 3 newest in full, full: true prints all)');
65
+ expect(text).toContain('more characters: orboto_get_ticket { ticketKey: "ACME-6", commentId: "00000001-aaaa-4000-8000-000000000000" }');
66
+ expect(text).not.toContain('old119');
67
+ const one = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', commentId: '00000001' });
68
+ expect(one.content[0].text).toContain('old119');
69
+ const missing = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', commentId: 'ffffffff' });
70
+ expect(missing.isError).toBe(true);
71
+ });
72
+ });
@@ -6,34 +6,34 @@ import { specReleaseInfo } from './shared.js';
6
6
  */
7
7
  import { z } from 'zod';
8
8
  import { OrbotoApiError } from '../orboto-client.js';
9
+ import { COMMENT_FULL_COUNT, fetchThread, selectComments } from './comment-thread.js';
9
10
  import { formatDependencySummary, resolveTicketByKey } from './shared.js';
10
- const COMMENT_PAGE_SIZE = 50;
11
11
  export const getTicketToolConfig = {
12
12
  title: 'Get ticket details',
13
- description: 'Return a ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, estimate/logged time, checklist progress, parent + sub-ticket count, the OPEN dependency edges in both directions ("Blocked by" / "Blocks", keys + statuses - the full graph stays on orboto_list_ticket_dependencies) - plus COUNTS for everything omitted (commentCount, gitActivityCount, attachmentCount, childCount). Full blocks are opt-in via `include` (ORB-1698): pass e.g. include: ["comments"] to get the comment bodies, ["git","attachments","children","raci","checklistItems"] likewise. Input is the ticket key like "ACME-42".',
13
+ description: 'A ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, checklist progress, parent, open dependency edges and COUNTS of what is omitted (`include` opts blocks in). Comments: newest first, 3 newest in full, older shortened; `full` all, `commentId` one. Input: ticket key like "ACME-42".',
14
14
  inputSchema: z.object({
15
- ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
15
+ ticketKey: z.string().min(3).describe('Key like "ACME-42".'),
16
16
  include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems']))
17
17
  .optional()
18
- .describe('Blocks to inline in full. Default: none (counts only).'),
18
+ .describe('Blocks to inline.'),
19
+ full: z.boolean().optional().describe('Comments in full.'),
20
+ commentId: z.string().optional().describe('One comment (id or prefix).'),
19
21
  }).shape,
20
22
  annotations: { readOnlyHint: true, idempotentHint: true },
21
23
  };
22
24
  export function makeGetTicketHandler(client) {
23
- return async ({ ticketKey, include }) => {
25
+ return async ({ ticketKey, include, full: fullComments, commentId }) => {
24
26
  const ticket = await resolveTicketByKey(client, ticketKey);
25
- const inc = new Set(include ?? []);
27
+ const inc = new Set(commentId ? [...(include ?? []), 'comments'] : (include ?? []));
26
28
  const parentId = ticket.parentTicketId ?? null;
27
- const [enriched, parent, childrenPage, attachments, commentsPage, checklists, gitActivity] = await Promise.all([
29
+ const [enriched, parent, childrenPage, attachments, thread, checklists, gitActivity] = await Promise.all([
28
30
  client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}`).catch(swallow404(null)),
29
31
  parentId
30
32
  ? client.get(`/projects/${ticket.projectId}/tickets/${parentId}`).catch(swallow404(null))
31
33
  : Promise.resolve(null),
32
34
  client.get(`/projects/${ticket.projectId}/tickets?parentTicketId=${ticket.id}&limit=50`).catch(swallow404({ items: [], nextCursor: null })),
33
35
  client.get(`/tickets/${ticket.id}/attachments`).catch(swallow404([])),
34
- inc.has('comments')
35
- ? client.get(`/tickets/${ticket.id}/comments?limit=${COMMENT_PAGE_SIZE}`).catch(swallow404({ items: [], nextCursor: null }))
36
- : Promise.resolve({ items: [], nextCursor: null }),
36
+ inc.has('comments') ? fetchThread(client, ticket.id) : Promise.resolve([]),
37
37
  inc.has('checklistItems')
38
38
  ? client.get(`/tickets/${ticket.id}/checklists`).catch(swallow404([]))
39
39
  : Promise.resolve([]),
@@ -41,11 +41,14 @@ export function makeGetTicketHandler(client) {
41
41
  ? client.get(`/tickets/${ticket.id}/git-activity`).catch(swallow404([]))
42
42
  : Promise.resolve([]),
43
43
  ]);
44
- const comments = commentsPage.items;
45
- const hasMoreComments = !!commentsPage.nextCursor;
44
+ const selected = selectComments(thread, { full: fullComments, commentId });
45
+ if (selected.error)
46
+ return { isError: true, content: [{ type: 'text', text: selected.error }] };
47
+ const comments = selected.shown;
48
+ const commentTotal = thread.length;
46
49
  const children = childrenPage.items;
47
50
  const full = enriched ?? ticket;
48
- const commentCount = full.commentCount ?? (inc.has('comments') ? comments.length : 0);
51
+ const commentCount = full.commentCount ?? (inc.has('comments') ? commentTotal : 0);
49
52
  const gitCount = full.gitActivityCount ?? (inc.has('git') ? gitActivity.length : 0);
50
53
  const omitted = [];
51
54
  if (!inc.has('comments') && commentCount > 0)
@@ -62,7 +65,7 @@ export function makeGetTicketHandler(client) {
62
65
  ? `Omitted blocks: ${omitted.join(', ')}. Re-call orboto_get_ticket with include: ["comments"|"git"|"attachments"|"children"|"raci"|"checklistItems"] for the ones you need.`
63
66
  : undefined;
64
67
  return {
65
- content: [{ type: 'text', text: formatTicket(full, inc, comments, hasMoreComments, checklists, gitActivity, parent, children, attachments, includeHint) }],
68
+ content: [{ type: 'text', text: formatTicket(full, inc, comments, commentTotal, ticketKey, checklists, gitActivity, parent, children, attachments, includeHint) }],
66
69
  structuredContent: {
67
70
  id: full.id,
68
71
  key: full.ticketKey,
@@ -119,15 +122,18 @@ export function makeGetTicketHandler(client) {
119
122
  checklistProgress: full.checklistProgress ?? { done: 0, total: 0 },
120
123
  ...(includeHint ? { includeHint } : {}),
121
124
  ...(inc.has('comments') ? {
122
- comments: comments.map((c) => ({
123
- id: c.id, // ORB-1285 - needed to target a comment for edit/delete
125
+ comments: comments.map(({ row: c, excerpt, length, rest }) => ({
126
+ id: c.id,
124
127
  author: c.userName ?? null,
125
- body: c.content,
128
+ body: excerpt,
129
+ bodyLength: length,
130
+ ...(rest > 0 ? { bodyTruncated: true } : {}),
126
131
  createdAt: c.createdAt,
127
132
  editedAt: c.editedAt ?? null,
128
133
  isInternal: c.isInternal,
129
134
  })),
130
- commentsHasMore: hasMoreComments,
135
+ commentTotal,
136
+ commentsHasMore: false,
131
137
  } : {}),
132
138
  ...(inc.has('checklistItems') ? {
133
139
  checklists: checklists.map((cl) => ({
@@ -183,7 +189,7 @@ function dependencyEdge(e) {
183
189
  ...(e.external ? { external: true } : {}),
184
190
  };
185
191
  }
186
- function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitActivity, parent, children, attachments, includeHint) {
192
+ function formatTicket(ticket, inc, comments, commentTotal, ticketKey, checklists, gitActivity, parent, children, attachments, includeHint) {
187
193
  const header = [
188
194
  `[${ticket.ticketKey}] ${ticket.title}`,
189
195
  `Status: ${ticket.statusName ?? ticket.status} Priority: ${ticket.priority} Type: ${ticket.type}`,
@@ -214,6 +220,7 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
214
220
  ticket.labels && ticket.labels.length > 0
215
221
  ? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
216
222
  : null,
223
+ ticket.branchName ? `Branch: ${ticket.branchName}` : null,
217
224
  ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
218
225
  formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
219
226
  formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
@@ -249,12 +256,13 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
249
256
  commentLines.push('', `Comments: ${ticket.commentCount}`);
250
257
  }
251
258
  if (inc.has('comments') && comments.length > 0) {
252
- const headerLine = hasMoreComments
253
- ? `## Comments (${comments.length} shown, more in the UI)`
254
- : `## Comments (${comments.length})`;
255
- commentLines.push('', headerLine);
256
- for (const c of comments) {
257
- commentLines.push(`**${c.userName ?? '(unknown author)'}** - ${c.createdAt}${c.isInternal ? ' [internal]' : ''}`, c.content, '');
259
+ const shortened = comments.some((c) => c.rest > 0);
260
+ commentLines.push('', `## Comments (${commentTotal}, newest first${shortened ? `; the ${COMMENT_FULL_COUNT} newest in full, full: true prints all` : ''})`);
261
+ for (const { row: c, excerpt, length, rest } of comments) {
262
+ commentLines.push(`**${c.userName ?? '(unknown author)'}** - ${c.createdAt}${c.isInternal ? ' [internal]' : ''} - ${length} chars - ${c.id}`, excerpt);
263
+ if (rest > 0)
264
+ commentLines.push(`... ${rest} more characters: orboto_get_ticket { ticketKey: "${ticketKey}", commentId: "${c.id}" }`);
265
+ commentLines.push('');
258
266
  }
259
267
  }
260
268
  const gitLines = [];
@@ -13,32 +13,36 @@ import { resolveProjectByKey, resolveTicketByKey, ticketLine, agentTicketListRow
13
13
  import { resolveMilestoneByNameOrId } from './milestones.js';
14
14
  export const listTicketsToolConfig = {
15
15
  title: 'List tickets',
16
- description: 'List tickets in a project, optionally filtered by status category, milestone name, or assignee email. Set unscheduled=true for the product backlog (everything not planned into a milestone yet). Returns up to 50 tickets per call.',
16
+ description: 'List tickets in a project, filtered by status category, milestone or assignee email. unscheduled=true = the backlog (no milestone yet). Up to 50 per call.',
17
17
  inputSchema: z.object({
18
18
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
19
19
  specState: TicketSpecStateSchema.optional(),
20
20
  statusCategory: z
21
21
  .enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
22
22
  .optional()
23
- .describe('Filter to one workflow category. Omit for all.'),
23
+ .describe('One workflow category. Omit for all.'),
24
+ open: z
25
+ .boolean()
26
+ .optional()
27
+ .describe('true = only todo, in_progress, in_review. Not with statusCategory.'),
24
28
  milestone: z
25
29
  .string()
26
30
  .optional()
27
- .describe('Key (ORB-M3), name, or UUID. Omit for all, backlog included.'),
31
+ .describe('Key (ORB-M3), name or UUID.'),
28
32
  unscheduled: z
29
33
  .boolean()
30
34
  .optional()
31
- .describe('true = only the backlog: tickets with no milestone. Cannot be combined with `milestone`.'),
35
+ .describe('true = only tickets with no milestone. Not with `milestone`.'),
32
36
  assigneeEmail: z
33
37
  .string()
34
38
  .optional()
35
- .describe('Project-member email. Omit for all, unassigned included.'),
39
+ .describe('Project-member email.'),
36
40
  parentTicketKey: z
37
41
  .string()
38
42
  .optional()
39
43
  .describe('Only children of this ticket - walks an epic.'),
40
44
  limit: z.number().int().min(1).max(50).default(25).describe('Max rows to return.'),
41
- verbose: z.boolean().default(false).describe('true = full rows; default is the decision fields only.'),
45
+ verbose: z.boolean().default(false).describe('true = full rows.'),
42
46
  }).shape,
43
47
  annotations: { readOnlyHint: true, idempotentHint: true },
44
48
  };
@@ -51,6 +55,11 @@ export function makeListTicketsHandler(client) {
51
55
  qs.set('specState', input.specState);
52
56
  if (input.statusCategory)
53
57
  qs.set('statusCategory', input.statusCategory);
58
+ if (input.open && input.statusCategory) {
59
+ throw new Error('Pass either `open` or `statusCategory`, not both.');
60
+ }
61
+ if (input.open)
62
+ qs.set('open', 'true');
54
63
  if (input.unscheduled && input.milestone) {
55
64
  throw new Error('Pass either `milestone` or `unscheduled: true`, not both.');
56
65
  }
@@ -256,7 +256,7 @@ describe('orboto_get_ticket', () => {
256
256
  expect(size).toBeLessThan(4000);
257
257
  expect(res.structuredContent.commentCount).toBe(34);
258
258
  });
259
- it('include: ["comments"] restores cursor-paged bodies exactly as before', async () => {
259
+ it('include: ["comments"] returns the thread newest first with the body length', async () => {
260
260
  const calls = stub([
261
261
  { json: PROJ },
262
262
  { json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
@@ -266,7 +266,7 @@ describe('orboto_get_ticket', () => {
266
266
  {
267
267
  json: {
268
268
  items: [{ id: 'c1', ticketId: 't1', userId: 'u1', content: 'first thought', isInternal: false, createdAt: 'now', userName: 'Ada' }],
269
- nextCursor: 'next-page-token',
269
+ nextCursor: null,
270
270
  },
271
271
  },
272
272
  ]);
@@ -276,12 +276,13 @@ describe('orboto_get_ticket', () => {
276
276
  expect(sc.comments[0]).toEqual({
277
277
  id: 'c1', // ORB-1285 - comment id is surfaced for edit/delete targeting
278
278
  body: 'first thought',
279
+ bodyLength: 13,
279
280
  author: 'Ada',
280
281
  createdAt: 'now',
281
282
  editedAt: null,
282
283
  isInternal: false,
283
284
  });
284
- expect(sc.commentsHasMore).toBe(true);
285
+ expect(sc.commentTotal).toBe(1);
285
286
  expect(sc.parentTicket).toBeNull();
286
287
  });
287
288
  it('surfaces waitingForGitIngestion in the header and structured content', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.218.0",
3
+ "version": "0.219.0",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",