@starlove/orbit 1.1.0 → 1.1.2

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
@@ -176,8 +176,8 @@ A copy-editable client config lives at [docs/mcp-client.example.json](docs/mcp-c
176
176
  | Purpose | Tool |
177
177
  | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
178
178
  | Index | `board_list` (tiny registry), `board_set_active` |
179
- | Board context | `board_context` (settings + journal + deployment; optional board + `include_struck`) |
180
- | Read | `board_read_ticket`, `board_read_comments`, `board_get_ticket_context` (heavy), `board_get_ticket_relations`, `board_get_ticket_blockers`, `board_search`, `board_list_archive` |
179
+ | Board context | `board_context` (settings + journal + deployment; optional board + `include_struck`) |
180
+ | Read | `board_read_ticket`, `board_read_comments`, `board_get_ticket_context` (lean), `board_get_ticket_context_full` (manual/journal), `board_get_ticket_relations`, `board_get_ticket_blockers`, `board_search`, `board_list_archive` |
181
181
  | Agent flow | `board_claim_next`, `board_checkpoint`, `board_complete` |
182
182
  | Write | `board_update_ticket`, `board_add_comment`, `board_add_board_entry`, `board_archive_ticket`, `board_restore_ticket`, `board_delete_ticket` |
183
183
  | Admin | `board_export_board`, `board_update_settings` |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starlove/orbit",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "edition": "full",
5
- "builtAt": "2026-05-26T04:59:52.730Z"
5
+ "builtAt": "2026-05-26T06:59:10.275Z"
6
6
  }
@@ -72,7 +72,7 @@ Returns the first schedulable `AI Ready` ticket. The scheduler skips tickets blo
72
72
 
73
73
  By default, `claim-next` skips `epic` cards because they are index/planning cards. To claim an epic for planning, pass `"type": "epic"` or `"include_epics": true`.
74
74
 
75
- The response shape is `{ claimed: true, ticket_id, context }` where `context` is the lightweight `board_read_ticket` pack (`ticket` with title/description/labels/state and the `board_manual`). Ticket comments are not included in the default claim context; call `GET /api/tickets/:id/comments` (or MCP `board_read_comments`) when an explicit chat/comment surface needs them. For the heavier ticket pack with relations, blockers, implementation fields, and child cards, call `GET /api/tickets/:id/context` (or MCP `board_get_ticket_context`).
75
+ The response shape is `{ claimed: true, ticket_id, context }` where `context` is the lightweight `board_read_ticket` pack (`ticket` with title/description/labels/state and the `board_manual`). Ticket comments are not included in the default claim context; call `GET /api/tickets/:id/comments` (or MCP `board_read_comments`) when an explicit chat/comment surface needs them. For the lean ticket relationship pack, call `GET /api/tickets/:id/context` (or MCP `board_get_ticket_context`). For the historical heavy pack with board manual/journal attached, call `GET /api/tickets/:id/context/full` (or MCP `board_get_ticket_context_full`).
76
76
 
77
77
  ### Read Lightweight Ticket
78
78
 
@@ -86,7 +86,7 @@ MCP equivalent: `board_read_ticket`.
86
86
 
87
87
  Returns the lightweight agent read shape: `ticket` with identity, title, description, labels, state/type/priority, plus `board_manual`. Ticket comments, relations, blockers, child cards, and implementation fields are intentionally omitted. Use this for quick orientation and claim context. The `lookup` form is exact by per-board ticket number or exact title; it does not use fuzzy search or comment/implementation indexes.
88
88
 
89
- ### Get Context Pack
89
+ ### Get Lean Ticket Context Pack
90
90
 
91
91
  ```http
92
92
  GET /api/tickets/:id/context?depth=1
@@ -94,15 +94,31 @@ GET /api/tickets/:id/context?depth=1
94
94
 
95
95
  Returns:
96
96
 
97
- - Ticket, board, state, labels.
98
- - The board manual (`board_manual`), including `agent_instructions` and `project_notes`.
97
+ - Ticket, board identity, state, labels.
99
98
  - Dedicated implementation fields: `ai_plan`, `implementation_summary`, and `implementation_updates`.
100
99
  - Parent epic/story ticket when present.
101
100
  - Child feature cards when the ticket is an epic/story.
102
101
  - Related tickets.
103
102
  - Blockers and blocking tickets.
104
103
 
105
- Ticket comments are intentionally omitted from this default agent context. Explicit comment/chat consumers should call:
104
+ Project manual/journal (`agent_instructions`, `project_notes`, and board entries) and ticket comments are intentionally omitted from this default agent context. Fetch project context and comments explicitly when needed:
105
+
106
+ ```http
107
+ GET /api/boards/:board_id/context?include_struck=false
108
+ GET /api/tickets/:id/comments
109
+ ```
110
+
111
+ MCP equivalents: `board_context` and `board_read_comments`.
112
+
113
+ ### Get Full Ticket Context Pack
114
+
115
+ ```http
116
+ GET /api/tickets/:id/context/full?depth=1
117
+ ```
118
+
119
+ MCP equivalent: `board_get_ticket_context_full`.
120
+
121
+ Returns the historical heavy context shape: everything in the lean ticket context plus the full board row and `board_manual` / journal. Comments are still intentionally omitted; explicit comment/chat consumers should call:
106
122
 
107
123
  ```http
108
124
  GET /api/tickets/:id/comments
@@ -176,7 +192,7 @@ Searches ticket titles, descriptions, and comments.
176
192
  GET /api/boards/:board_id/context?include_struck=false
177
193
  ```
178
194
 
179
- Returns the same shape as MCP `board_context`: a trimmed `board` (`name`, `agent_instructions`, `updated_at`), `entries` (journal / decisions / lessons), and `deployment` paths. Use `include_struck=true` to include struck journal rows (default off).
195
+ Returns the same shape as MCP `board_context`: board metadata (`agent_instructions`, `project_notes`, repository paths, default branch), `entries` (journal / decisions / lessons), and `deployment` paths. Use `include_struck=true` to include struck journal rows (default off).
180
196
 
181
197
  MCP: `board_context` with optional `board_id` / `board_slug` / `board` and optional `include_struck`.
182
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starlove/orbit",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Starscape Orbit ??? zero-dependency, pretty, human-ai-first kanban system.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -726,7 +726,7 @@ async function handleTicketArchive(ticketId, archive) {
726
726
  });
727
727
  }
728
728
 
729
- async function handleTicketContext(ticketId) {
729
+ async function handleTicketContext(ticketId, { includeProjectContext = false } = {}) {
730
730
  const db = await openDb();
731
731
  const snap = await readBoardSnapshot(db);
732
732
  const ticket = snap.tickets.find((t) => t.id === ticketId);
@@ -776,16 +776,26 @@ async function handleTicketContext(ticketId) {
776
776
  other_ticket: compact(other)
777
777
  };
778
778
  });
779
- return {
779
+ const context = {
780
780
  ticket: { ...ticket, labels },
781
- board: snap.board,
782
- board_manual: { entries: snap.boardEntries.filter((e) => !e.struck_at) },
781
+ board: includeProjectContext
782
+ ? snap.board
783
+ : {
784
+ id: snap.board.id,
785
+ slug: snap.board.slug,
786
+ name: snap.board.name,
787
+ repo_path: snap.board.system_path || "",
788
+ system_path: snap.board.system_path || "",
789
+ default_branch: snap.board.default_branch || "main"
790
+ },
783
791
  parent_ticket: compact(parent),
784
792
  child_tickets: childTickets,
785
793
  relations,
786
794
  blockers: [],
787
795
  related_tickets: [...childTickets, ...(parent ? [parent] : [])]
788
796
  };
797
+ if (includeProjectContext) context.board_manual = { entries: snap.boardEntries.filter((e) => !e.struck_at) };
798
+ return context;
789
799
  }
790
800
 
791
801
  async function handleTicketComments(ticketId) {
@@ -955,6 +965,7 @@ const ROUTES = [
955
965
  { m: "DELETE", re: /^\/api\/tickets\/([^/]+)$/, fn: (m) => handleTicketDelete(decodeURIComponent(m[1])) },
956
966
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
957
967
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
968
+ { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context\/full$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1]), { includeProjectContext: true }) },
958
969
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
959
970
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
960
971
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
package/src/core/agent.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  relationRows,
8
8
  stateByName,
9
9
  stateByRole,
10
+ stateById,
10
11
  ticketById,
11
12
  ticketByNumber,
12
13
  ticketByTitle,
@@ -209,8 +210,22 @@ export function completeTicket(body, ctx) {
209
210
  return getContextPack(ticket.id, ctx, 1);
210
211
  }
211
212
 
212
- export function getContextPack(ticketId, ctx, depth = 1) {
213
+ function contextBoardIdentity(innerBoard, ctx) {
214
+ return {
215
+ id: innerBoard.id,
216
+ slug: innerBoard.slug,
217
+ name: innerBoard.name,
218
+ repo_path: innerBoard.system_path || ctx.board.repo_path || "",
219
+ system_path: innerBoard.system_path || ctx.board.repo_path || "",
220
+ default_branch: innerBoard.default_branch || "main"
221
+ };
222
+ }
223
+
224
+ function buildContextPack(ticketId, ctx, depth = 1, options = {}, { includeProjectContext = false } = {}) {
213
225
  const { db, board, actor } = ctx;
226
+ const maxCharsPerField = positiveInt(options.max_chars_per_field ?? options.maxCharsPerField, 0);
227
+ const includeParentFull = Boolean(options.include_parent_full ?? options.includeParentFull);
228
+ const includeRelatedFull = Boolean(options.include_related_full ?? options.includeRelatedFull);
214
229
  const ticket = ticketById(db, ticketId);
215
230
  if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
216
231
  const innerBoard = boardById(db, ticket.board_id);
@@ -232,20 +247,133 @@ export function getContextPack(ticketId, ctx, depth = 1) {
232
247
  ...(parentTicket ? [parentTicket] : [])
233
248
  ]);
234
249
 
235
- return {
236
- ticket: {
250
+ const pack = {
251
+ ticket: capTicketFields({
237
252
  ...ticket,
238
253
  labels
239
- },
240
- board: innerBoard,
241
- board_manual: boardManual(innerBoard.id, ctx),
242
- parent_ticket: parentTicket ? compactTicket(parentTicket) : null,
254
+ }, maxCharsPerField),
255
+ board: includeProjectContext ? innerBoard : contextBoardIdentity(innerBoard, ctx),
256
+ parent_ticket: parentTicket ? (includeParentFull ? capTicketFields({ ...parentTicket, labels: labelsForTicket(db, parentTicket.id) }, maxCharsPerField) : compactTicket(parentTicket)) : null,
243
257
  child_tickets: childTickets.map((child) => ({
244
- ...child,
258
+ ...capTicketFields(child, maxCharsPerField),
245
259
  child_count: childTicketsFor(db, child.id).length
246
260
  })),
247
- relations,
261
+ relations: relations.map((relation) => ({
262
+ ...relation,
263
+ other_ticket: includeRelatedFull
264
+ ? capTicketFields({ ...relation.other_ticket, labels: labelsForTicket(db, relation.other_ticket.id) }, maxCharsPerField)
265
+ : compactTicket(relation.other_ticket)
266
+ })),
248
267
  blockers,
249
- related_tickets: relatedTickets
268
+ related_tickets: relatedTickets.map((related) =>
269
+ includeRelatedFull ? capTicketFields({ ...related, labels: labelsForTicket(db, related.id) }, maxCharsPerField) : compactTicket(related)
270
+ )
271
+ };
272
+
273
+ if (includeProjectContext) pack.board_manual = boardManual(innerBoard.id, ctx);
274
+ return pack;
275
+ }
276
+
277
+ export function getContextPack(ticketId, ctx, depth = 1, options = {}) {
278
+ return buildContextPack(ticketId, ctx, depth, options, { includeProjectContext: false });
279
+ }
280
+
281
+ export function getContextPackFull(ticketId, ctx, depth = 1, options = {}) {
282
+ return buildContextPack(ticketId, ctx, depth, options, { includeProjectContext: true });
283
+ }
284
+
285
+ export function getAgentDispatchPacket(ticketId, ctx, options = {}) {
286
+ const { db, board, actor } = ctx;
287
+ const maxCharsPerField = positiveInt(options.max_chars_per_field ?? options.maxCharsPerField, 4000);
288
+ const commentLimit = Math.min(nonNegativeInt(options.comment_limit ?? options.commentLimit, 5), 20);
289
+ const ticket = ticketById(db, ticketId);
290
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
291
+ const innerBoard = boardById(db, ticket.board_id);
292
+ requireBoardAccess(actor, innerBoard);
293
+
294
+ const labels = labelsForTicket(db, ticket.id).map((label) => label.name);
295
+ const parentTicket = ticket.parent_ticket_id ? ticketById(db, ticket.parent_ticket_id) : null;
296
+ const blockers = unresolvedBlockers(db, ticket.id).map(compactTicket);
297
+ const comments = db
298
+ .prepare("SELECT id, author, kind, body, created_at FROM comments WHERE ticket_id = ? ORDER BY created_at DESC LIMIT ?")
299
+ .all(ticket.id, commentLimit)
300
+ .reverse()
301
+ .map((comment) => ({ ...comment, body: capString(comment.body, maxCharsPerField) }));
302
+
303
+ return {
304
+ board: {
305
+ id: innerBoard.id,
306
+ slug: innerBoard.slug,
307
+ name: innerBoard.name,
308
+ repo_path: innerBoard.repo_path || innerBoard.system_path || "",
309
+ system_path: innerBoard.system_path || innerBoard.repo_path || "",
310
+ default_branch: innerBoard.default_branch || "main",
311
+ agent_instructions: capString(innerBoard.agent_instructions || "", maxCharsPerField)
312
+ },
313
+ ticket: {
314
+ id: ticket.id,
315
+ ticket_id: ticket.id,
316
+ number: ticket.number,
317
+ title: ticket.title,
318
+ type: ticket.type,
319
+ priority: ticket.priority,
320
+ state_id: ticket.state_id,
321
+ state_name: ticket.state_name,
322
+ state_role: ticket.state_role,
323
+ description: capString(ticket.description || "", maxCharsPerField),
324
+ acceptance: capString(extractAcceptance(ticket.description || ""), maxCharsPerField),
325
+ ai_plan: capString(ticket.ai_plan || "", maxCharsPerField),
326
+ labels
327
+ },
328
+ blockers: {
329
+ can_start: blockers.length === 0,
330
+ blockers
331
+ },
332
+ parent_ticket: parentTicket ? compactTicket(parentTicket) : null,
333
+ relevant_state_ids: relevantStateIds(db, innerBoard.id, ticket.state_id),
334
+ recent_comments: comments
335
+ };
336
+ }
337
+
338
+ function positiveInt(value, fallback) {
339
+ const n = Number(value);
340
+ return Number.isInteger(n) && n > 0 ? n : fallback;
341
+ }
342
+
343
+ function nonNegativeInt(value, fallback) {
344
+ const n = Number(value);
345
+ return Number.isInteger(n) && n >= 0 ? n : fallback;
346
+ }
347
+
348
+ function capString(value, maxChars) {
349
+ const text = String(value || "");
350
+ return maxChars && text.length > maxChars ? text.slice(0, maxChars) : text;
351
+ }
352
+
353
+ function capTicketFields(ticket, maxChars) {
354
+ if (!maxChars) return ticket;
355
+ const copy = { ...ticket };
356
+ for (const field of ["description", "ai_plan", "implementation_summary", "implementation_updates"]) {
357
+ if (Object.hasOwn(copy, field)) copy[field] = capString(copy[field], maxChars);
358
+ }
359
+ return copy;
360
+ }
361
+
362
+ function extractAcceptance(description) {
363
+ const match = String(description || "").match(/(?:^|\n)\s*(?:acceptance criteria|acceptance)\s*:?\s*\n([\s\S]*)/i);
364
+ return match ? match[1].trim() : "";
365
+ }
366
+
367
+ function relevantStateIds(db, boardId, currentStateId) {
368
+ const byRole = (role) => stateByRole(db, boardId, role)?.id || null;
369
+ const byName = (name) => stateByName(db, boardId, name)?.id || null;
370
+ return {
371
+ current: currentStateId,
372
+ current_role: stateById(db, currentStateId)?.role || null,
373
+ ai_ready: byRole("ai_ready") || byName("AI Ready"),
374
+ in_progress: byRole("in_progress") || byName("In Progress"),
375
+ review: byRole("review") || byName("Review"),
376
+ done: byRole("done") || byName("Done"),
377
+ todo: byRole("todo") || byName("Todo")
250
378
  };
251
379
  }
package/src/core/board.js CHANGED
@@ -134,7 +134,7 @@ export {
134
134
  export { archiveTicket, createComment, createTicket, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
135
135
  export { archivedTicketsForBoard } from "./queries.js";
136
136
  export { getTicketBlockers, getTicketRelations } from "./relations.js";
137
- export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
137
+ export { checkpointTicket, claimNext, completeTicket, getAgentDispatchPacket, getContextPack, getContextPackFull, readComments, readTicket } from "./agent.js";
138
138
  export { searchTickets } from "./search.js";
139
139
  export { exportBoard } from "./snapshots.js";
140
140
 
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { localOwnerActor } from "./auth.js";
5
5
  import { DISPATCH_RUNS_DIR } from "./paths.js";
6
- import { getContextPack } from "./agent.js";
6
+ import { getContextPackFull } from "./agent.js";
7
7
  import { openBoardDb, getBoardByRegistryId, getBoardByRepoPath, getBoardBySlug, touchBoardActive } from "./registry.js";
8
8
  import { stateByName, stateByRole, ticketById, ticketByNumber, unresolvedBlockers } from "./queries.js";
9
9
  import { createRegistrySchema } from "./db.js";
@@ -438,7 +438,7 @@ export function dispatchTicket(options) {
438
438
 
439
439
  mkdirSync(runDir, { recursive: true });
440
440
  const policyBin = createPolicyBin(runDir, policyName);
441
- const context = getContextPack(ticket.id, ctx, 1);
441
+ const context = getContextPackFull(ticket.id, ctx, 1);
442
442
  const handoff = buildHandoff({
443
443
  boardRow,
444
444
  context,
@@ -65,7 +65,9 @@ import {
65
65
  checkpointTicket,
66
66
  claimNext,
67
67
  completeTicket,
68
+ getAgentDispatchPacket,
68
69
  getContextPack,
70
+ getContextPackFull,
69
71
  readComments,
70
72
  readTicket
71
73
  } from "./agent.js";
@@ -104,6 +106,15 @@ function boardHintsFromQuery(url) {
104
106
  };
105
107
  }
106
108
 
109
+ function agentContextOptions(url) {
110
+ return {
111
+ max_chars_per_field: url.searchParams.get("max_chars_per_field"),
112
+ comment_limit: url.searchParams.get("comment_limit"),
113
+ include_parent_full: url.searchParams.get("include_parent_full") === "true",
114
+ include_related_full: url.searchParams.get("include_related_full") === "true"
115
+ };
116
+ }
117
+
107
118
  export async function handleApi(req, res, url) {
108
119
  setCors(res);
109
120
 
@@ -269,7 +280,16 @@ export async function handleApi(req, res, url) {
269
280
  }
270
281
  if (sub === "context" && req.method === "GET") {
271
282
  const depth = Number(url.searchParams.get("depth") || 1);
272
- sendJson(res, 200, getContextPack(ticketId, ctx, depth));
283
+ sendJson(res, 200, getContextPack(ticketId, ctx, depth, agentContextOptions(url)));
284
+ return;
285
+ }
286
+ if (sub === "context/full" && req.method === "GET") {
287
+ const depth = Number(url.searchParams.get("depth") || 1);
288
+ sendJson(res, 200, getContextPackFull(ticketId, ctx, depth, agentContextOptions(url)));
289
+ return;
290
+ }
291
+ if ((sub === "dispatch-packet" || sub === "agent-dispatch-packet") && req.method === "GET") {
292
+ sendJson(res, 200, getAgentDispatchPacket(ticketId, ctx, agentContextOptions(url)));
273
293
  return;
274
294
  }
275
295
  if (sub === "history" && req.method === "GET") {
@@ -399,7 +419,11 @@ export async function handleApi(req, res, url) {
399
419
  if (!row) throw httpError(400, "board_id_required");
400
420
  const args = {
401
421
  q: url.searchParams.get("q") || "",
402
- limit: url.searchParams.get("limit")
422
+ limit: url.searchParams.get("limit"),
423
+ mode: url.searchParams.get("mode"),
424
+ include_full: url.searchParams.get("include_full") === "true" ? true : undefined,
425
+ fields: url.searchParams.get("fields"),
426
+ max_chars_per_field: url.searchParams.get("max_chars_per_field")
403
427
  };
404
428
  sendJson(res, 200, searchTickets(args, ctxFromBoardRow(row, actor)));
405
429
  return;
@@ -12,9 +12,12 @@ export function searchTickets(args, ctx) {
12
12
  const { db, board, actor } = ctx;
13
13
  const q = String(args.q || "").trim();
14
14
  const limit = Math.min(Number(args.limit || 20), 50);
15
+ const mode = normalizeMode(args);
16
+ const maxCharsPerField = positiveInt(args.max_chars_per_field ?? args.maxCharsPerField, 0);
17
+ const fields = parseFields(args.fields);
15
18
 
16
- if (!q) return { query: q, results: [] };
17
- if (!canAccessBoard(actor, board)) return { query: q, results: [] };
19
+ if (!q) return { query: q, mode, results: [] };
20
+ if (!canAccessBoard(actor, board)) return { query: q, mode, results: [] };
18
21
 
19
22
  const ticketNumber = ticketNumberQuery(q);
20
23
  const fts = toFtsQuery(q);
@@ -93,12 +96,82 @@ export function searchTickets(args, ctx) {
93
96
  if (deduped.length >= limit) break;
94
97
  }
95
98
 
96
- const results = deduped.map((ticket) => ({
97
- ...ticket,
98
- labels: labelsForTicket(db, ticket.id)
99
- }));
99
+ const results = deduped.map((ticket) =>
100
+ shapeSearchResult(ticket, labelsForTicket(db, ticket.id), { mode, fields, maxCharsPerField })
101
+ );
100
102
 
101
- return { query: q, results };
103
+ return { query: q, mode, results };
104
+ }
105
+
106
+ function normalizeMode(args) {
107
+ if (args.include_full === true || args.includeFull === true) return "full";
108
+ const mode = String(args.mode || "summary").trim().toLowerCase();
109
+ return ["ids", "summary", "full"].includes(mode) ? mode : "summary";
110
+ }
111
+
112
+ function positiveInt(value, fallback) {
113
+ const n = Number(value);
114
+ return Number.isInteger(n) && n > 0 ? n : fallback;
115
+ }
116
+
117
+ function parseFields(fields) {
118
+ if (Array.isArray(fields)) return new Set(fields.map(String));
119
+ if (typeof fields === "string" && fields.trim()) {
120
+ return new Set(fields.split(",").map((field) => field.trim()).filter(Boolean));
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function cap(value, maxChars) {
126
+ if (!maxChars || typeof value !== "string") return value;
127
+ return value.length > maxChars ? value.slice(0, maxChars) : value;
128
+ }
129
+
130
+ function snippetFor(ticket, maxChars) {
131
+ const text = ticket.description || ticket.ai_plan || ticket.implementation_summary || "";
132
+ return cap(text, maxChars || 240);
133
+ }
134
+
135
+ function labelNames(labels) {
136
+ return labels.map((label) => label.name);
137
+ }
138
+
139
+ function pickFields(row, fields) {
140
+ if (!fields) return row;
141
+ const picked = {};
142
+ for (const field of fields) {
143
+ if (Object.hasOwn(row, field)) picked[field] = row[field];
144
+ }
145
+ return picked;
146
+ }
147
+
148
+ function shapeSearchResult(ticket, labels, { mode, fields, maxCharsPerField }) {
149
+ if (mode === "ids") {
150
+ return pickFields({ ticket_id: ticket.id, id: ticket.id, number: ticket.number, rank: ticket.rank }, fields);
151
+ }
152
+ if (mode === "full") {
153
+ const full = { ...ticket, labels };
154
+ if (maxCharsPerField) {
155
+ for (const key of ["description", "ai_plan", "implementation_summary", "implementation_updates"]) {
156
+ full[key] = cap(full[key], maxCharsPerField);
157
+ }
158
+ }
159
+ return pickFields(full, fields);
160
+ }
161
+ return pickFields({
162
+ ticket_id: ticket.id,
163
+ id: ticket.id,
164
+ number: ticket.number,
165
+ title: ticket.title,
166
+ state_name: ticket.state_name,
167
+ state_role: ticket.state_role,
168
+ type: ticket.type,
169
+ priority: ticket.priority,
170
+ parent_ticket_id: ticket.parent_ticket_id,
171
+ rank: ticket.rank,
172
+ snippet: snippetFor(ticket, maxCharsPerField),
173
+ labels: labelNames(labels)
174
+ }, fields);
102
175
  }
103
176
 
104
177
  function ticketNumberQuery(q) {
@@ -57,6 +57,24 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
57
57
  }
58
58
 
59
59
  const ticketQuery = (args = {}) => ({ board_id: args.board_id, board: args.board_slug || args.board || defaultBoard });
60
+ const searchQuery = (args = {}) => ({
61
+ q: args.q,
62
+ limit: args.limit,
63
+ board_id: args.board_id,
64
+ board: args.board_slug || args.board || defaultBoard,
65
+ mode: args.mode,
66
+ include_full: args.include_full || args.includeFull ? "true" : undefined,
67
+ fields: Array.isArray(args.fields) ? args.fields.join(",") : args.fields,
68
+ max_chars_per_field: args.max_chars_per_field ?? args.maxCharsPerField
69
+ });
70
+ const contextQuery = (args = {}) => ({
71
+ ...ticketQuery(args),
72
+ depth: args.depth,
73
+ max_chars_per_field: args.max_chars_per_field ?? args.maxCharsPerField,
74
+ comment_limit: args.comment_limit ?? args.commentLimit,
75
+ include_parent_full: args.include_parent_full || args.includeParentFull ? "true" : undefined,
76
+ include_related_full: args.include_related_full || args.includeRelatedFull ? "true" : undefined
77
+ });
60
78
 
61
79
  return {
62
80
  mode: "remote",
@@ -68,7 +86,9 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
68
86
  async boardList() { return request("GET", "/api/boards"); },
69
87
  async boardSetActive(args = {}) { if (!args.slug) throw rpcError(-32602, "slug is required."); env.ORBIT_DEFAULT_BOARD = String(args.slug); return { ok: true, slug: String(args.slug), mode: "remote" }; },
70
88
  async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
71
- async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...ticketQuery(args), depth: args.depth || 1 } }); },
89
+ async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...contextQuery(args), depth: args.depth || 1 } }); },
90
+ async getTicketContextFull(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context/full`, { query: { ...contextQuery(args), depth: args.depth || 1 } }); },
91
+ async getAgentDispatchPacket(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/dispatch-packet`, { query: contextQuery(args) }); },
72
92
  async readTicket(args = {}) {
73
93
  if (args.ticket_id) {
74
94
  return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) });
@@ -92,7 +112,7 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
92
112
  },
93
113
  async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
94
114
  async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
95
- async search(args = {}) { return request("GET", "/api/search", { query: { q: args.q, limit: args.limit, board_id: args.board_id, board: args.board_slug || args.board || defaultBoard } }); },
115
+ async search(args = {}) { return request("GET", "/api/search", { query: searchQuery(args) }); },
96
116
  async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
97
117
  async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
98
118
  async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
@@ -196,12 +216,14 @@ export async function createLocalOrbitClient(env = process.env) {
196
216
  boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
197
217
  boardSetActive: (args = {}) => { const slug = String(args.slug || "").trim(); if (!slug) throw rpcError(-32602, "slug is required."); const row = registry.getBoardBySlug(slug); if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`); setSessionBoard(row); return { ok: true, board_id: row.id, slug: row.slug, name: row.name, db_path: row.db_path }; },
198
218
  claimNext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.claimNext(args, ctx)); },
199
- getTicketContext: (args = {}) => board.getContextPack(args.ticket_id, sessionCtx(actor()), Number(args.depth || 1)),
200
- readTicket: (args = {}) => board.readTicket(args, sessionCtx(actor())),
201
- readComments: (args = {}) => board.readComments(args, sessionCtx(actor())),
202
- getTicketRelations: (args = {}) => board.getTicketRelations(args.ticket_id, sessionCtx(actor())),
203
- getTicketBlockers: (args = {}) => board.getTicketBlockers(args.ticket_id, sessionCtx(actor())),
204
- search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets({ q: args.q, limit: args.limit }, ctx); },
219
+ getTicketContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getContextPack(args.ticket_id, ctx, Number(args.depth || 1), args); },
220
+ getTicketContextFull: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getContextPackFull(args.ticket_id, ctx, Number(args.depth || 1), args); },
221
+ getAgentDispatchPacket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getAgentDispatchPacket(args.ticket_id, ctx, args); },
222
+ readTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.readTicket(args, ctx); },
223
+ readComments: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.readComments(args, ctx); },
224
+ getTicketRelations: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketRelations(args.ticket_id, ctx); },
225
+ getTicketBlockers: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketBlockers(args.ticket_id, ctx); },
226
+ search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets(args, ctx); },
205
227
  createTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const { board_slug, board: _b, ...body } = args; body.board_id = ctx.board.id; return backup(ctx, board.createTicket(body, ctx)); },
206
228
  updateTicket: ({ ticket_id, ...patch }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.updateTicket(ticket_id, patch, ctx)); },
207
229
  addComment: ({ ticket_id, ...body }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.createComment(ticket_id, body, ctx)); },
package/src/mcp-server.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  deleteTicket,
20
20
  exportBoard,
21
21
  getBoardContext,
22
+ getAgentDispatchPacket,
22
23
  getContextPack,
23
24
  readComments,
24
25
  readTicket,
@@ -184,7 +185,7 @@ const TOOL_DEFS = [
184
185
  {
185
186
  name: "board_context",
186
187
  description:
187
- "Board context pack: name, agent_instructions, journal entries (board_entries), deployment paths. Resolves board from board_id / board_slug / board, else the MCP session board. Set include_struck true to include struck journal rows (default false). Same payload as GET /api/boards/:id/context.",
188
+ "Board/project context pack: board metadata, agent_instructions, journal entries (board_entries), and deployment paths. Complements the lean board_get_ticket_context. Resolves board from board_id / board_slug / board, else the MCP session board. Set include_struck true to include struck journal rows (default false). Same payload as GET /api/boards/:id/context.",
188
189
  inputSchema: {
189
190
  type: "object",
190
191
  properties: {
@@ -242,18 +243,64 @@ const TOOL_DEFS = [
242
243
  {
243
244
  name: "board_get_ticket_context",
244
245
  description:
245
- "Read the default agent context pack for a ticket, including board agent_instructions (project-level agent context), relations, blockers, implementation fields, and child cards. Ticket comments are intentionally omitted; use board_read_comments for explicit comment retrieval.",
246
+ "Return the default lean context pack for a ticket: ticket fields, board identity only, relations, blockers, parent/children, and related ticket summaries. Project manual/journal and comments are intentionally omitted; use board_context for board manual/journal and board_read_comments for comment threads.",
246
247
  inputSchema: {
247
248
  type: "object",
248
249
  properties: {
249
250
  ticket_id: { type: "string" },
250
- depth: { type: "integer", minimum: 1, maximum: 3 }
251
+ board_id: { type: "string" },
252
+ board_slug: { type: "string" },
253
+ board: { type: "string" },
254
+ depth: { type: "integer", minimum: 1, maximum: 3 },
255
+ max_chars_per_field: { type: "integer", minimum: 1 },
256
+ include_parent_full: { type: "boolean" },
257
+ include_related_full: { type: "boolean" }
251
258
  },
252
259
  required: ["ticket_id"],
253
260
  additionalProperties: false
254
261
  },
255
262
  handler: (args) => orbitClient.getTicketContext(args)
256
263
  },
264
+ {
265
+ name: "board_get_ticket_context_full",
266
+ description:
267
+ "Return the explicit heavy ticket context pack, preserving the historical board_get_ticket_context shape: ticket, full board row, board_manual/journal, relations, blockers, parent/children, and related tickets. Comments are still intentionally omitted; use board_read_comments for comment threads.",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ ticket_id: { type: "string" },
272
+ board_id: { type: "string" },
273
+ board_slug: { type: "string" },
274
+ board: { type: "string" },
275
+ depth: { type: "integer", minimum: 1, maximum: 3 },
276
+ max_chars_per_field: { type: "integer", minimum: 1 },
277
+ include_parent_full: { type: "boolean" },
278
+ include_related_full: { type: "boolean" }
279
+ },
280
+ required: ["ticket_id"],
281
+ additionalProperties: false
282
+ },
283
+ handler: (args) => orbitClient.getTicketContextFull(args)
284
+ },
285
+ {
286
+ name: "board_get_agent_dispatch_packet",
287
+ description:
288
+ "Return a lean agent dispatch packet for one ticket: board identity/instructions, capped ticket description/acceptance/AI plan, blockers, shallow parent, relevant workflow state IDs, repo path/default branch, recent capped comments, and label names. Parent/related/implementation bodies are intentionally omitted.",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ ticket_id: { type: "string" },
293
+ board_id: { type: "string" },
294
+ board_slug: { type: "string" },
295
+ board: { type: "string" },
296
+ max_chars_per_field: { type: "integer", minimum: 1 },
297
+ comment_limit: { type: "integer", minimum: 0, maximum: 20 }
298
+ },
299
+ required: ["ticket_id"],
300
+ additionalProperties: false
301
+ },
302
+ handler: (args) => orbitClient.getAgentDispatchPacket(args)
303
+ },
257
304
  {
258
305
  name: "board_read_ticket",
259
306
  description:
@@ -263,7 +310,10 @@ const TOOL_DEFS = [
263
310
  properties: {
264
311
  ticket_id: { type: "string" },
265
312
  number: { type: "integer" },
266
- title: { type: "string" }
313
+ title: { type: "string" },
314
+ board_id: { type: "string" },
315
+ board_slug: { type: "string" },
316
+ board: { type: "string" }
267
317
  },
268
318
  additionalProperties: false
269
319
  },
@@ -278,7 +328,10 @@ const TOOL_DEFS = [
278
328
  properties: {
279
329
  ticket_id: { type: "string" },
280
330
  number: { type: "integer" },
281
- title: { type: "string" }
331
+ title: { type: "string" },
332
+ board_id: { type: "string" },
333
+ board_slug: { type: "string" },
334
+ board: { type: "string" }
282
335
  },
283
336
  additionalProperties: false
284
337
  },
@@ -290,7 +343,10 @@ const TOOL_DEFS = [
290
343
  inputSchema: {
291
344
  type: "object",
292
345
  properties: {
293
- ticket_id: { type: "string" }
346
+ ticket_id: { type: "string" },
347
+ board_id: { type: "string" },
348
+ board_slug: { type: "string" },
349
+ board: { type: "string" }
294
350
  },
295
351
  required: ["ticket_id"],
296
352
  additionalProperties: false
@@ -303,7 +359,10 @@ const TOOL_DEFS = [
303
359
  inputSchema: {
304
360
  type: "object",
305
361
  properties: {
306
- ticket_id: { type: "string" }
362
+ ticket_id: { type: "string" },
363
+ board_id: { type: "string" },
364
+ board_slug: { type: "string" },
365
+ board: { type: "string" }
307
366
  },
308
367
  required: ["ticket_id"],
309
368
  additionalProperties: false
@@ -321,7 +380,11 @@ const TOOL_DEFS = [
321
380
  board: { type: "string" },
322
381
  board_slug: { type: "string" },
323
382
  board_id: { type: "string" },
324
- limit: { type: "integer", minimum: 1, maximum: 50 }
383
+ limit: { type: "integer", minimum: 1, maximum: 50 },
384
+ mode: { type: "string", enum: ["ids", "summary", "full"] },
385
+ include_full: { type: "boolean" },
386
+ fields: { type: "array", items: { type: "string" } },
387
+ max_chars_per_field: { type: "integer", minimum: 1 }
325
388
  },
326
389
  required: ["q"],
327
390
  additionalProperties: false