@starlove/orbit 1.0.1

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.
Files changed (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,773 @@
1
+ // MCP server. One process = one session = (usually) one board, but the
2
+ // per-process board lives in a closure variable that every tool reads
3
+ // explicitly — never via a hidden global. Each tool builds its own ctx for
4
+ // the call it's about to make, so adding new tools never has to think about
5
+ // "active board" state.
6
+
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import {
10
+ archivedTicketsForBoard,
11
+ archiveTicket,
12
+ checkpointTicket,
13
+ claimNext,
14
+ completeTicket,
15
+ createBoardEntry,
16
+ createComment,
17
+ deleteTicket,
18
+ exportBoard,
19
+ getBoardContext,
20
+ getContextPack,
21
+ readComments,
22
+ readTicket,
23
+ getTicketBlockers,
24
+ getTicketRelations,
25
+ localAgentActor,
26
+ restoreTicket,
27
+ searchTickets,
28
+ updateBoard,
29
+ updateTicket
30
+ } from "./core/board.js";
31
+ import { createBoardSchema, openConnection } from "./core/db.js";
32
+ import {
33
+ getBoardByRegistryId,
34
+ getBoardBySlug,
35
+ getBoardByRepoPath,
36
+ insertBoard,
37
+ listBoards,
38
+ openBoardDb,
39
+ syncRegistryFromBoardDb,
40
+ touchBoardActive
41
+ } from "./core/registry.js";
42
+ import { scheduleAutomaticBoardBackup } from "./core/backups.js";
43
+ import { seedIfEmpty } from "./core/seed.js";
44
+ import { now, normalizePath } from "./core/util.js";
45
+
46
+ const SERVER_INFO = {
47
+ name: "minimal-agent-board",
48
+ version: "0.1.0"
49
+ };
50
+
51
+ // Per-process session board. Set on init by walking up from cwd, mutable via
52
+ // `board_set_active`. NEVER read by anything outside this file — every
53
+ // downstream call receives an explicit ctx.
54
+ let sessionBoardRow = null;
55
+
56
+ function setSessionBoard(row) {
57
+ sessionBoardRow = row;
58
+ if (row) touchBoardActive(row.id);
59
+ }
60
+
61
+ function getSessionBoard() {
62
+ return sessionBoardRow;
63
+ }
64
+
65
+ /**
66
+ * Walk up from `startDir` toward the filesystem root, returning the first
67
+ * directory that satisfies `predicate`. Returns null if none found.
68
+ */
69
+ function walkUp(startDir, predicate) {
70
+ let dir = startDir;
71
+ while (true) {
72
+ if (predicate(dir)) return dir;
73
+ const parent = dirname(dir);
74
+ if (parent === dir) return null;
75
+ dir = parent;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Initialise the MCP session board. Resolution order:
81
+ * 1. Walk up from PROJECT_ROOT (cwd unless `orbit mcp --cwd` or env overrides it)
82
+ * for an existing `.orbit/board.db` — finds the board even when the agent
83
+ * is launched from a subdirectory or MCP config uses an absolute command.
84
+ * 2. Walk up from PROJECT_ROOT for a `.git/` directory — auto-creates a board at
85
+ * the git root on first launch.
86
+ * 3. Fall back to PROJECT_ROOT itself — supports non-git projects and global installs.
87
+ */
88
+ function initMcpSessionBoard() {
89
+ const start = normalizePath(process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : process.cwd());
90
+
91
+ // 1. Walk up for an existing board db.
92
+ const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
93
+ if (boardRoot) {
94
+ const root = normalizePath(boardRoot);
95
+ const existing = getBoardByRepoPath(root);
96
+ if (existing) { setSessionBoard(existing); return; }
97
+ // db exists on disk but isn't in the registry yet — register it.
98
+ const dbPath = join(boardRoot, ".orbit", "board.db");
99
+ const db = openConnection(dbPath);
100
+ createBoardSchema(db);
101
+ const seeded = seedIfEmpty(db, root);
102
+ if (seeded) {
103
+ const t = now();
104
+ insertBoard({ id: seeded.id, slug: seeded.slug, name: seeded.name, repo_path: root, db_path: dbPath, repo_url: seeded.repo_url || "", default_branch: seeded.default_branch || "main", last_active_at: t, created_at: t, updated_at: t });
105
+ }
106
+ const fresh = getBoardByRepoPath(root);
107
+ if (fresh) { syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
108
+ return;
109
+ }
110
+
111
+ // 2. Walk up for a git root; fall back to cwd for non-git projects.
112
+ const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
113
+ createBoardAtRoot(normalizePath(gitRoot || start));
114
+ }
115
+
116
+ function createBoardAtRoot(root) {
117
+ const dbPath = join(root, ".orbit", "board.db");
118
+ mkdirSync(dirname(dbPath), { recursive: true });
119
+ const db = openConnection(dbPath);
120
+ createBoardSchema(db);
121
+ const seeded = seedIfEmpty(db, root);
122
+ if (seeded) {
123
+ const t = now();
124
+ insertBoard({ id: seeded.id, slug: seeded.slug, name: seeded.name, repo_path: root, db_path: dbPath, repo_url: seeded.repo_url || "", default_branch: seeded.default_branch || "main", last_active_at: t, created_at: t, updated_at: t });
125
+ }
126
+ const fresh = getBoardByRepoPath(root);
127
+ if (fresh) { syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
128
+ }
129
+
130
+ initMcpSessionBoard();
131
+
132
+ /** Build a ctx for the session board (or a board the tool argument selected). */
133
+ function ctxFor(boardRow, actor) {
134
+ if (!boardRow) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
135
+ return { actor, board: boardRow, db: openBoardDb(boardRow) };
136
+ }
137
+
138
+ function sessionCtx(actor) {
139
+ return ctxFor(getSessionBoard(), actor);
140
+ }
141
+
142
+ function withAutomaticBackup(ctx, result) {
143
+ scheduleAutomaticBoardBackup(ctx.board, ctx.db);
144
+ return result;
145
+ }
146
+
147
+ /** Resolve a registry row from explicit args (no session fallback). */
148
+ function resolveExplicitBoard(args) {
149
+ if (args.board_id) {
150
+ const row = getBoardByRegistryId(args.board_id);
151
+ if (!row) throw rpcError(-32004, "Board id not found.");
152
+ return row;
153
+ }
154
+ if (args.board_slug || args.board) {
155
+ const row = getBoardBySlug(args.board_slug || args.board);
156
+ if (!row) throw rpcError(-32004, "Board slug not found.");
157
+ return row;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /** Resolve a board for a tool that accepts board_id/board_slug AND can fall
163
+ * back to the session board. */
164
+ function resolveBoardOrSession(args) {
165
+ return resolveExplicitBoard(args) || getSessionBoard();
166
+ }
167
+
168
+ const TOOL_DEFS = [
169
+ {
170
+ name: "board_context",
171
+ description:
172
+ "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.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ board_id: { type: "string" },
177
+ board_slug: { type: "string" },
178
+ board: { type: "string" },
179
+ include_struck: { type: "boolean", description: "When true, journal entries include struck items." }
180
+ },
181
+ additionalProperties: false
182
+ },
183
+ handler: (args) => {
184
+ const actor = localAgentActor();
185
+ const row = resolveBoardOrSession(args);
186
+ const ctx = ctxFor(row, actor);
187
+ return getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) });
188
+ }
189
+ },
190
+ {
191
+ name: "board_list",
192
+ description:
193
+ "Tiny registry index: id, slug, name, repo_path per board. Does not open board databases or load tickets. Pair with board_set_active and board_context / ticket tools; the web UI loads full rows via GET /api/bootstrap.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {},
197
+ additionalProperties: false
198
+ },
199
+ handler: () => {
200
+ return {
201
+ boards: listBoards().map((row) => ({
202
+ id: row.id,
203
+ slug: row.slug,
204
+ name: row.name,
205
+ repo_path: row.repo_path
206
+ }))
207
+ };
208
+ }
209
+ },
210
+ {
211
+ name: "board_set_active",
212
+ description:
213
+ "Switch the MCP session to another board by slug (updates last_active_at for multi-board repos). Subsequent tools use that board until switched again.",
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ slug: { type: "string" }
218
+ },
219
+ required: ["slug"],
220
+ additionalProperties: false
221
+ },
222
+ handler: (args) => {
223
+ const slug = String(args.slug || "").trim();
224
+ if (!slug) throw rpcError(-32602, "slug is required.");
225
+ const row = getBoardBySlug(slug);
226
+ if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`);
227
+ setSessionBoard(row);
228
+ return { ok: true, board_id: row.id, slug: row.slug, name: row.name, db_path: row.db_path };
229
+ }
230
+ },
231
+ {
232
+ name: "board_claim_next",
233
+ description:
234
+ "Claim the next schedulable AI-ready ticket on the session's active board. Pass `board` (slug) only to explicitly switch to another board you mean to work on; omitted `board` never scans other boards.",
235
+ inputSchema: {
236
+ type: "object",
237
+ properties: {
238
+ board: { type: "string" },
239
+ board_slug: { type: "string" },
240
+ board_id: { type: "string" },
241
+ type: { type: "string" },
242
+ include_epics: { type: "boolean" }
243
+ },
244
+ additionalProperties: false
245
+ },
246
+ handler: (args) => {
247
+ const actor = localAgentActor();
248
+ const row = resolveBoardOrSession(args);
249
+ const ctx = ctxFor(row, actor);
250
+ return withAutomaticBackup(ctx, claimNext(args, ctx));
251
+ }
252
+ },
253
+ {
254
+ name: "board_get_ticket_context",
255
+ description:
256
+ "Read the full context pack for a ticket, including board agent_instructions (project-level agent context), relations, blockers, comments, and child cards. Heavy — prefer board_read_ticket when you only need title/description/comments.",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ ticket_id: { type: "string" },
261
+ depth: { type: "integer", minimum: 1, maximum: 3 }
262
+ },
263
+ required: ["ticket_id"],
264
+ additionalProperties: false
265
+ },
266
+ handler: (args) => {
267
+ const actor = localAgentActor();
268
+ const ctx = sessionCtx(actor);
269
+ return getContextPack(args.ticket_id, ctx, Number(args.depth || 1));
270
+ }
271
+ },
272
+ {
273
+ name: "board_read_ticket",
274
+ description:
275
+ "Look up a ticket by ticket_id, number, or exact title (case-insensitive) on the session's active board, and return its title, description, labels, state, and full comment thread plus the board manual (agent_instructions + journal + deployment paths).",
276
+ inputSchema: {
277
+ type: "object",
278
+ properties: {
279
+ ticket_id: { type: "string" },
280
+ number: { type: "integer" },
281
+ title: { type: "string" }
282
+ },
283
+ additionalProperties: false
284
+ },
285
+ handler: (args) => {
286
+ const actor = localAgentActor();
287
+ const ctx = sessionCtx(actor);
288
+ return readTicket(args, ctx);
289
+ }
290
+ },
291
+ {
292
+ name: "board_read_comments",
293
+ description:
294
+ "Return every comment on a ticket (oldest → newest). Look up by ticket_id, number, or exact title (case-insensitive) on the session's active board. Comment kinds include human_comment, agent_note, checkpoint, completion, note.",
295
+ inputSchema: {
296
+ type: "object",
297
+ properties: {
298
+ ticket_id: { type: "string" },
299
+ number: { type: "integer" },
300
+ title: { type: "string" }
301
+ },
302
+ additionalProperties: false
303
+ },
304
+ handler: (args) => {
305
+ const actor = localAgentActor();
306
+ const ctx = sessionCtx(actor);
307
+ return readComments(args, ctx);
308
+ }
309
+ },
310
+ {
311
+ name: "board_get_ticket_relations",
312
+ description: "List every link touching a ticket. Each entry has `type`, `direction`, `other_ticket`, and `source`. `source='relation'` rows come from the relations table (`relates_to` / `blocks` / `blocked_by`) and are the SSOT for peer dependencies — write via `POST /api/relations`, delete via `DELETE /api/relations/:id`. `source='hierarchy'` rows are read-only synthetic entries surfacing epic ownership (`child_of` toward the parent epic, `parent_of` toward each child); they have `id: null` and are mutated by patching the ticket's `parent_ticket_id`, not by calling the relations endpoints.",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ ticket_id: { type: "string" }
317
+ },
318
+ required: ["ticket_id"],
319
+ additionalProperties: false
320
+ },
321
+ handler: (args) => {
322
+ const actor = localAgentActor();
323
+ return getTicketRelations(args.ticket_id, sessionCtx(actor));
324
+ }
325
+ },
326
+ {
327
+ name: "board_get_ticket_blockers",
328
+ description: "Return unresolved blockers for a ticket and a can_start boolean. The single source of truth for dependencies is the per-card `blocked_by` row in the relations table; if a row exists the blocker is unresolved. Rows are removed two ways: (1) automatically when the blocking ticket moves into the Done lane (state role='done'), or (2) manually by the user in the UI. A ticket with can_start=false must not be worked on until its blockers resolve. Epic blocker targets expand to that epic's open children (children in any lane whose role is not 'done', not archived); each expanded entry carries via_epic_id.",
329
+ inputSchema: {
330
+ type: "object",
331
+ properties: {
332
+ ticket_id: { type: "string" }
333
+ },
334
+ required: ["ticket_id"],
335
+ additionalProperties: false
336
+ },
337
+ handler: (args) => {
338
+ const actor = localAgentActor();
339
+ return getTicketBlockers(args.ticket_id, sessionCtx(actor));
340
+ }
341
+ },
342
+ {
343
+ name: "board_search",
344
+ description:
345
+ "Search tickets, comments, and implementation records by text on the session's active board. Pass `board` (slug) only to explicitly search another board; omitted `board` never merges results from other boards.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ q: { type: "string" },
350
+ board: { type: "string" },
351
+ board_slug: { type: "string" },
352
+ board_id: { type: "string" },
353
+ limit: { type: "integer", minimum: 1, maximum: 50 }
354
+ },
355
+ required: ["q"],
356
+ additionalProperties: false
357
+ },
358
+ handler: (args) => {
359
+ const actor = localAgentActor();
360
+ const row = resolveBoardOrSession(args);
361
+ const ctx = ctxFor(row, actor);
362
+ return searchTickets({ q: args.q, limit: args.limit }, ctx);
363
+ }
364
+ },
365
+ {
366
+ name: "board_update_ticket",
367
+ description:
368
+ "Update ticket fields such as AI plan, implementation summary, implementation updates, state, type, priority, or labels (full replace: array of label names).",
369
+ inputSchema: {
370
+ type: "object",
371
+ properties: {
372
+ ticket_id: { type: "string" },
373
+ title: { type: "string" },
374
+ description: { type: "string" },
375
+ type: { type: "string" },
376
+ parent_ticket_id: { type: ["string", "null"] },
377
+ ai_plan: { type: "string" },
378
+ implementation_summary: { type: "string" },
379
+ implementation_updates: { type: "string" },
380
+ state_id: { type: "string" },
381
+ priority: { type: "integer" },
382
+ labels: { type: "array", items: { type: "string" }, description: "Replaces all ticket labels when provided." }
383
+ },
384
+ required: ["ticket_id"],
385
+ additionalProperties: false
386
+ },
387
+ handler: ({ ticket_id, ...patch }) => {
388
+ const actor = localAgentActor();
389
+ const ctx = sessionCtx(actor);
390
+ return withAutomaticBackup(ctx, updateTicket(ticket_id, patch, ctx));
391
+ }
392
+ },
393
+ {
394
+ name: "board_add_comment",
395
+ description: "Add a ticket comment or agent breadcrumb.",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ ticket_id: { type: "string" },
400
+ body: { type: "string" },
401
+ kind: { type: "string" }
402
+ },
403
+ required: ["ticket_id", "body"],
404
+ additionalProperties: false
405
+ },
406
+ handler: ({ ticket_id, ...body }) => {
407
+ const actor = localAgentActor();
408
+ const ctx = sessionCtx(actor);
409
+ return withAutomaticBackup(ctx, createComment(ticket_id, body, ctx));
410
+ }
411
+ },
412
+ {
413
+ name: "board_add_board_entry",
414
+ description: "Add a durable board-level decision or lesson for future agents.",
415
+ inputSchema: {
416
+ type: "object",
417
+ properties: {
418
+ board_id: { type: "string" },
419
+ board_slug: { type: "string" },
420
+ type: { type: "string", enum: ["decision", "lesson"] },
421
+ title: { type: "string" },
422
+ body: { type: "string" },
423
+ ticket_id: { type: "string" }
424
+ },
425
+ required: ["type", "title"],
426
+ additionalProperties: false
427
+ },
428
+ handler: (args) => {
429
+ const actor = localAgentActor();
430
+ const row = resolveBoardOrSession(args);
431
+ const ctx = ctxFor(row, actor);
432
+ return withAutomaticBackup(ctx, createBoardEntry(ctx.board.id, args, ctx));
433
+ }
434
+ },
435
+ {
436
+ name: "board_checkpoint",
437
+ description:
438
+ "Pause mid-flight for a human: moves the ticket to Review (same lane as board_complete; checkpoint distinguishes a blocking question) and posts a checkpoint-kind comment with your message. Use when you cannot proceed without the user—not for normal 'work is done' handoff (use board_complete).",
439
+ inputSchema: {
440
+ type: "object",
441
+ properties: {
442
+ ticket_id: { type: "string" },
443
+ message: { type: "string" }
444
+ },
445
+ required: ["ticket_id", "message"],
446
+ additionalProperties: false
447
+ },
448
+ handler: (args) => {
449
+ const actor = localAgentActor();
450
+ const ctx = sessionCtx(actor);
451
+ return withAutomaticBackup(ctx, checkpointTicket(args, ctx));
452
+ }
453
+ },
454
+ {
455
+ name: "board_complete",
456
+ description:
457
+ "Hand off finished work for human review: moves the ticket to the Review lane (by role or name), writes a completion comment (summary, optional PR URL), and optional implementation_updates. This is the usual 'ready for you to look' path; use board_checkpoint only when blocked mid-flight.",
458
+ inputSchema: {
459
+ type: "object",
460
+ properties: {
461
+ ticket_id: { type: "string" },
462
+ summary: { type: "string" },
463
+ updates: { type: "string" },
464
+ pr_url: { type: "string" },
465
+ next_state: { type: "string" }
466
+ },
467
+ required: ["ticket_id", "summary"],
468
+ additionalProperties: false
469
+ },
470
+ handler: (args) => {
471
+ const actor = localAgentActor();
472
+ const ctx = sessionCtx(actor);
473
+ return withAutomaticBackup(ctx, completeTicket(args, ctx));
474
+ }
475
+ },
476
+ {
477
+ name: "board_archive_ticket",
478
+ description:
479
+ "Archive (soft-delete) a ticket. The ticket is moved off the board into the Archive: it is excluded from bootstrap, search, claim-next, blockers, and relations, but its data and comments are preserved. Reverse with board_restore_ticket; permanently remove with board_delete_ticket.",
480
+ inputSchema: {
481
+ type: "object",
482
+ properties: { ticket_id: { type: "string" } },
483
+ required: ["ticket_id"],
484
+ additionalProperties: false
485
+ },
486
+ handler: (args) => {
487
+ const actor = localAgentActor();
488
+ const ctx = sessionCtx(actor);
489
+ return withAutomaticBackup(ctx, archiveTicket(args.ticket_id, ctx));
490
+ }
491
+ },
492
+ {
493
+ name: "board_restore_ticket",
494
+ description: "Restore an archived ticket back to the board in its previous lane (state).",
495
+ inputSchema: {
496
+ type: "object",
497
+ properties: { ticket_id: { type: "string" } },
498
+ required: ["ticket_id"],
499
+ additionalProperties: false
500
+ },
501
+ handler: (args) => {
502
+ const actor = localAgentActor();
503
+ const ctx = sessionCtx(actor);
504
+ return withAutomaticBackup(ctx, restoreTicket(args.ticket_id, ctx));
505
+ }
506
+ },
507
+ {
508
+ name: "board_delete_ticket",
509
+ description:
510
+ "Permanently delete a ticket. The ticket MUST already be archived (call board_archive_ticket first) — otherwise this returns 409 ticket_not_archived. This cannot be undone: comments, labels, and relations are removed via cascade. Events for this ticket are kept but their ticket_id becomes NULL.",
511
+ inputSchema: {
512
+ type: "object",
513
+ properties: { ticket_id: { type: "string" } },
514
+ required: ["ticket_id"],
515
+ additionalProperties: false
516
+ },
517
+ handler: (args) => {
518
+ const actor = localAgentActor();
519
+ const ctx = sessionCtx(actor);
520
+ return withAutomaticBackup(ctx, deleteTicket(args.ticket_id, ctx));
521
+ }
522
+ },
523
+ {
524
+ name: "board_list_archive",
525
+ description: "List archived tickets for a board, ordered by archive time (most recent first). Identify the board with board_id or board_slug.",
526
+ inputSchema: {
527
+ type: "object",
528
+ properties: {
529
+ board_id: { type: "string" },
530
+ board_slug: { type: "string" }
531
+ },
532
+ additionalProperties: false
533
+ },
534
+ handler: (args) => {
535
+ const actor = localAgentActor();
536
+ const row = resolveBoardOrSession(args);
537
+ const ctx = ctxFor(row, actor);
538
+ return { tickets: archivedTicketsForBoard(ctx.db, ctx.board.id) };
539
+ }
540
+ },
541
+ {
542
+ name: "board_export_board",
543
+ description: "Export a board snapshot as JSON for backup or transfer.",
544
+ inputSchema: {
545
+ type: "object",
546
+ properties: {
547
+ board_id: { type: "string" },
548
+ board_slug: { type: "string" }
549
+ },
550
+ additionalProperties: false
551
+ },
552
+ handler: (args) => {
553
+ const actor = localAgentActor();
554
+ const row = resolveBoardOrSession(args);
555
+ const ctx = ctxFor(row, actor);
556
+ return exportBoard(ctx.board.id, ctx);
557
+ }
558
+ },
559
+ {
560
+ name: "board_update_settings",
561
+ description:
562
+ "PATCH-equivalent for board settings: name, repo_url, system_path, default_branch, agent_instructions (project-level agent context), and project_notes (Notes For You). Same fields as PATCH /api/boards/:id.",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ board_id: { type: "string" },
567
+ board_slug: { type: "string" },
568
+ name: { type: "string" },
569
+ repo_url: { type: "string" },
570
+ system_path: { type: "string" },
571
+ default_branch: { type: "string" },
572
+ project_notes: { type: "string" },
573
+ agent_instructions: { type: "string" }
574
+ },
575
+ additionalProperties: false
576
+ },
577
+ handler: (args) => {
578
+ const actor = localAgentActor();
579
+ const row = resolveBoardOrSession(args);
580
+ const ctx = ctxFor(row, actor);
581
+ const { board_id, board_slug, ...patch } = args;
582
+ return withAutomaticBackup(ctx, updateBoard(ctx.board.id, patch, ctx));
583
+ }
584
+ }
585
+ ];
586
+
587
+ const tools = new Map(TOOL_DEFS.map((tool) => [tool.name, tool]));
588
+
589
+ let inputBuffer = Buffer.alloc(0);
590
+ let expectedBodyLength = -1;
591
+
592
+ process.stdin.on("data", (chunk) => {
593
+ inputBuffer = Buffer.concat([inputBuffer, chunk]);
594
+ drainInput();
595
+ });
596
+
597
+ process.stdin.on("end", () => process.exit(0));
598
+ process.stdin.resume();
599
+
600
+ function drainInput() {
601
+ while (true) {
602
+ if (expectedBodyLength >= 0) {
603
+ if (inputBuffer.byteLength < expectedBodyLength) return;
604
+ const body = inputBuffer.slice(0, expectedBodyLength);
605
+ inputBuffer = inputBuffer.slice(expectedBodyLength);
606
+ expectedBodyLength = -1;
607
+ handleMessage(body.toString("utf8"), "content-length");
608
+ continue;
609
+ }
610
+
611
+ const preview = inputBuffer.slice(0, Math.min(inputBuffer.byteLength, 32)).toString("utf8").toLowerCase();
612
+ if (preview.startsWith("content-length:")) {
613
+ const headerEnd = inputBuffer.indexOf(Buffer.from("\r\n\r\n"));
614
+ if (headerEnd === -1) return;
615
+
616
+ const headerText = inputBuffer.slice(0, headerEnd).toString("utf8");
617
+ inputBuffer = inputBuffer.slice(headerEnd + 4);
618
+ const headers = parseHeaders(headerText);
619
+ expectedBodyLength = Number(headers["content-length"] || -1);
620
+ if (!Number.isFinite(expectedBodyLength) || expectedBodyLength < 0) {
621
+ writeMessage(errorResponse(null, -32700, "Missing Content-Length header."), "content-length");
622
+ expectedBodyLength = -1;
623
+ }
624
+ continue;
625
+ }
626
+
627
+ const lineEnd = inputBuffer.indexOf(Buffer.from("\n"));
628
+ if (lineEnd === -1) return;
629
+ const line = inputBuffer.slice(0, lineEnd).toString("utf8").replace(/\r$/, "");
630
+ inputBuffer = inputBuffer.slice(lineEnd + 1);
631
+ if (!line.trim()) continue;
632
+ handleMessage(line, "line");
633
+ }
634
+ }
635
+
636
+ function parseHeaders(text) {
637
+ const headers = {};
638
+ for (const line of text.split("\r\n")) {
639
+ const index = line.indexOf(":");
640
+ if (index === -1) continue;
641
+ const key = line.slice(0, index).trim().toLowerCase();
642
+ const value = line.slice(index + 1).trim();
643
+ headers[key] = value;
644
+ }
645
+ return headers;
646
+ }
647
+
648
+ async function handleMessage(body, transport = "content-length") {
649
+ let message;
650
+ try {
651
+ message = JSON.parse(body);
652
+ } catch {
653
+ writeMessage(errorResponse(null, -32700, "Invalid JSON."), transport);
654
+ return;
655
+ }
656
+
657
+ if (!message || typeof message !== "object") {
658
+ writeMessage(errorResponse(null, -32600, "Invalid request."), transport);
659
+ return;
660
+ }
661
+
662
+ if (!("id" in message)) {
663
+ if (message.method === "notifications/initialized") return;
664
+ return;
665
+ }
666
+
667
+ try {
668
+ const result = await dispatch(message);
669
+ writeMessage({
670
+ jsonrpc: "2.0",
671
+ id: message.id,
672
+ result
673
+ }, transport);
674
+ } catch (error) {
675
+ writeMessage(errorResponse(message.id, error.code || -32000, error.message || "Internal error."), transport);
676
+ }
677
+ }
678
+
679
+ async function dispatch(message) {
680
+ switch (message.method) {
681
+ case "initialize":
682
+ return {
683
+ protocolVersion: message.params?.protocolVersion || "2024-11-05",
684
+ capabilities: {
685
+ tools: {}
686
+ },
687
+ serverInfo: SERVER_INFO
688
+ };
689
+ case "ping":
690
+ return {};
691
+ case "tools/list":
692
+ return {
693
+ tools: TOOL_DEFS.map(({ name, description, inputSchema }) => ({
694
+ name,
695
+ description,
696
+ inputSchema
697
+ }))
698
+ };
699
+ case "tools/call":
700
+ return callTool(message.params || {});
701
+ default:
702
+ throw rpcError(-32601, `Method not found: ${message.method}`);
703
+ }
704
+ }
705
+
706
+ async function callTool(params) {
707
+ const name = params.name;
708
+ const args = params.arguments || {};
709
+ const tool = tools.get(name);
710
+ if (!tool) throw rpcError(-32601, `Unknown tool: ${name}`);
711
+
712
+ let result;
713
+ try {
714
+ result = await tool.handler(args);
715
+ } catch (error) {
716
+ return {
717
+ content: [
718
+ {
719
+ type: "text",
720
+ text: error.message || "Tool call failed."
721
+ }
722
+ ],
723
+ isError: true
724
+ };
725
+ }
726
+
727
+ return {
728
+ content: [
729
+ {
730
+ type: "text",
731
+ text: JSON.stringify(result, null, 2)
732
+ }
733
+ ]
734
+ };
735
+ }
736
+
737
+ function writeMessage(message, transport = "content-length") {
738
+ const json = JSON.stringify(message);
739
+ if (transport === "line") {
740
+ process.stdout.write(`${json}\n`);
741
+ return;
742
+ }
743
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`);
744
+ }
745
+
746
+ function errorResponse(id, code, message) {
747
+ return {
748
+ jsonrpc: "2.0",
749
+ id,
750
+ error: {
751
+ code,
752
+ message
753
+ }
754
+ };
755
+ }
756
+
757
+ function rpcError(code, message) {
758
+ const error = new Error(message);
759
+ error.code = code;
760
+ return error;
761
+ }
762
+
763
+ process.on("uncaughtException", (error) => {
764
+ writeMessage(errorResponse(null, -32000, error.message || "Uncaught exception."));
765
+ });
766
+
767
+ process.on("unhandledRejection", (error) => {
768
+ writeMessage(errorResponse(null, -32000, error?.message || "Unhandled rejection."));
769
+ });
770
+
771
+ if (process.env.MAB_MCP_STDERR_LOG === "1") {
772
+ console.error(`Starscape Orbit MCP ready. Session board: ${getSessionBoard()?.db_path || "(none)"}`);
773
+ }