@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,438 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orbit CLI — `orbit init` provisions `.orbit/board.db`, registry row, and
4
+ * copies SKILL-ORBIT.md into the target repo. Does not import board.js.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
13
+
14
+ function printUsage() {
15
+ console.log(`Usage:
16
+ orbit init [options] Provision .orbit/board.db + SKILL-ORBIT.md + AGENTS.md
17
+ orbit reset [options] Delete .orbit/ + SKILL-ORBIT.md + registry row
18
+ orbit serve [options] Start the Orbit web app + HTTP API
19
+ orbit docker [options] Build/run Orbit's web app in Docker
20
+ orbit mcp [options] Start the stdio MCP server (for agent clients)
21
+
22
+ Options (init / reset / serve / docker / mcp):
23
+ --cwd <dir> Project root (default: process.cwd())
24
+
25
+ Options (init only):
26
+ --ai Enable AI collaboration lanes
27
+ --example Create example onboarding tickets
28
+ --refresh-agents-md Overwrite SKILL-ORBIT.md and refresh Orbit section in AGENTS.md
29
+
30
+ Options (serve / docker):
31
+ --port <n> HTTP port (default: 3337, or $PORT)
32
+
33
+ Options (docker only):
34
+ --image <name> Docker image tag (default: starscape-orbit:local)
35
+ --data-dir <dir> Docker registry/export data dir (default: <cwd>/.orbit/docker-data)
36
+ --name <name> Optional Docker container name
37
+ --detach Run the container in the background
38
+ --no-build Skip docker build and run an existing image
39
+ --dry-run Print docker commands without running them`);
40
+ }
41
+
42
+ function shellQuote(value) {
43
+ return `"${String(value || "").replace(/"/g, '\\"')}"`;
44
+ }
45
+
46
+ function commandQuote(value) {
47
+ const text = String(value);
48
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text;
49
+ return `'${text.replace(/'/g, "'\\''")}'`;
50
+ }
51
+
52
+ function formatCommand(executable, args) {
53
+ return [executable, ...args].map(commandQuote).join(" ");
54
+ }
55
+
56
+ const ORBIT_AGENTS_START = "<!-- ORBIT:AGENTS-START -->";
57
+ const ORBIT_AGENTS_END = "<!-- ORBIT:AGENTS-END -->";
58
+
59
+ async function loadCliCore() {
60
+ const [
61
+ { enableAiCollaboration },
62
+ { backupBoardDatabase, backupRegistry },
63
+ { createRegistrySchema, openConnection },
64
+ { DATA_DIR, ROOT_DIR },
65
+ { provisionRepoBoard },
66
+ { deleteBoard, getBoardByRepoPath },
67
+ { normalizePath }
68
+ ] = await Promise.all([
69
+ import("../core/boards.js"),
70
+ import("../core/backups.js"),
71
+ import("../core/db.js"),
72
+ import("../core/paths.js"),
73
+ import("../core/provision-repo-board.js"),
74
+ import("../core/registry.js"),
75
+ import("../core/util.js")
76
+ ]);
77
+
78
+ return {
79
+ DATA_DIR,
80
+ ROOT_DIR,
81
+ backupBoardDatabase,
82
+ backupRegistry,
83
+ createRegistrySchema,
84
+ deleteBoard,
85
+ enableAiCollaboration,
86
+ getBoardByRepoPath,
87
+ normalizePath,
88
+ openConnection,
89
+ provisionRepoBoard
90
+ };
91
+ }
92
+
93
+ function orbitAgentsSection() {
94
+ return `${ORBIT_AGENTS_START}
95
+ When work mentions Orbit, kanban, board, lane, ticket, card, epic, blocker, claim, AI Ready, implementation fields, planning state, project memory, or handoff: read \`SKILL-ORBIT.md\` first and follow it.
96
+ ${ORBIT_AGENTS_END}\n`;
97
+ }
98
+
99
+ function buildAgentsMd() {
100
+ return `# AGENTS.md
101
+
102
+ Git is canonical for code. \`SKILL-ORBIT.md\` is canonical for Orbit/kanban/ticket/card workflow.
103
+
104
+ ${orbitAgentsSection()}`;
105
+ }
106
+
107
+ function syncAgentsMd(projectRoot, options) {
108
+ const agentsPath = resolve(projectRoot, "AGENTS.md");
109
+ const section = orbitAgentsSection();
110
+
111
+ if (!existsSync(agentsPath)) {
112
+ writeFileSync(agentsPath, buildAgentsMd(), "utf8");
113
+ console.log(`Wrote ${agentsPath}`);
114
+ return;
115
+ }
116
+
117
+ const current = readFileSync(agentsPath, "utf8");
118
+ const startIndex = current.indexOf(ORBIT_AGENTS_START);
119
+ const endIndex = current.indexOf(ORBIT_AGENTS_END);
120
+
121
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
122
+ if (!options.refreshAgentsMd) {
123
+ console.log("AGENTS.md already includes Orbit instructions — left unchanged (use --refresh-agents-md to replace).");
124
+ return;
125
+ }
126
+ const before = current.slice(0, startIndex).replace(/\s*$/, "\n\n");
127
+ const after = current.slice(endIndex + ORBIT_AGENTS_END.length).replace(/^\s*/, "");
128
+ writeFileSync(agentsPath, `${before}${section}${after}`, "utf8");
129
+ console.log(`Refreshed Orbit instructions inside ${agentsPath}`);
130
+ return;
131
+ }
132
+
133
+ writeFileSync(agentsPath, `${current.replace(/\s*$/, "\n\n")}${section}`, "utf8");
134
+ console.log(`Appended Orbit instructions to ${agentsPath}`);
135
+ }
136
+
137
+ function enableAiIfRequested(options, registryRow, core) {
138
+ if (!options.ai || !registryRow) return;
139
+ const { enableAiCollaboration, openConnection } = core;
140
+ const db = openConnection(registryRow.db_path);
141
+ enableAiCollaboration(db, registryRow.id, { stageOnboardingTicket: options.example, actor: "orbit init --ai" });
142
+ const suffix = options.example ? "; example ticket #12 is staged in AI Ready." : ".";
143
+ console.log(`AI collaboration enabled — AI Ready, In Progress, and Review lanes are present${suffix}`);
144
+ }
145
+
146
+ function parseArgs(argv) {
147
+ const args = {
148
+ command: null,
149
+ cwd: process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : process.cwd(),
150
+ cwdProvided: false,
151
+ refreshAgentsMd: false,
152
+ ai: false,
153
+ example: false,
154
+ port: null,
155
+ image: "starscape-orbit:local",
156
+ dataDir: null,
157
+ containerName: null,
158
+ buildImage: true,
159
+ detach: false,
160
+ dryRun: false
161
+ };
162
+ const rest = argv.slice(2);
163
+ if (rest.length === 0 || rest[0] === "-h" || rest[0] === "--help") {
164
+ args.command = "help";
165
+ return args;
166
+ }
167
+ args.command = rest[0];
168
+ for (let i = 1; i < rest.length; i++) {
169
+ const a = rest[i];
170
+ if (a === "--refresh-agents-md") args.refreshAgentsMd = true;
171
+ else if (a === "--ai") args.ai = true;
172
+ else if (a === "--example") args.example = true;
173
+ else if (a === "--cwd" && rest[i + 1]) {
174
+ args.cwdProvided = true;
175
+ args.cwd = resolve(rest[++i]);
176
+ } else if (a.startsWith("--cwd=")) {
177
+ args.cwdProvided = true;
178
+ args.cwd = resolve(a.slice("--cwd=".length));
179
+ } else if (a === "--port" && rest[i + 1]) {
180
+ args.port = rest[++i];
181
+ } else if (a.startsWith("--port=")) {
182
+ args.port = a.slice("--port=".length);
183
+ } else if (a === "--image" && rest[i + 1]) {
184
+ args.image = rest[++i];
185
+ } else if (a.startsWith("--image=")) {
186
+ args.image = a.slice("--image=".length);
187
+ } else if (a === "--data-dir" && rest[i + 1]) {
188
+ args.dataDir = resolve(rest[++i]);
189
+ } else if (a.startsWith("--data-dir=")) {
190
+ args.dataDir = resolve(a.slice("--data-dir=".length));
191
+ } else if (a === "--name" && rest[i + 1]) {
192
+ args.containerName = rest[++i];
193
+ } else if (a.startsWith("--name=")) {
194
+ args.containerName = a.slice("--name=".length);
195
+ } else if (a === "--no-build") {
196
+ args.buildImage = false;
197
+ } else if (a === "--detach") {
198
+ args.detach = true;
199
+ } else if (a === "--dry-run") {
200
+ args.dryRun = true;
201
+ } else {
202
+ console.error(`Unknown argument: ${a}`);
203
+ args.command = "help";
204
+ return args;
205
+ }
206
+ }
207
+ return args;
208
+ }
209
+
210
+ async function runInit(options) {
211
+ const core = await loadCliCore();
212
+ const { DATA_DIR, ROOT_DIR, createRegistrySchema, normalizePath, provisionRepoBoard } = core;
213
+ const projectRoot = normalizePath(resolve(options.cwd));
214
+ mkdirSync(DATA_DIR, { recursive: true });
215
+ createRegistrySchema();
216
+
217
+ const result = provisionRepoBoard(projectRoot, { includeExamples: options.example });
218
+ const skillSrc = resolve(ROOT_DIR, "SKILL-ORBIT.md");
219
+ const skillDest = resolve(projectRoot, "SKILL-ORBIT.md");
220
+
221
+ if (!existsSync(skillSrc)) {
222
+ console.warn(`Warning: missing ${skillSrc} — skipped copying SKILL-ORBIT.md and AGENTS.md Orbit section.`);
223
+ } else {
224
+ if (skillSrc === skillDest) {
225
+ console.log("SKILL-ORBIT.md source is already the project copy — left unchanged.");
226
+ } else if (existsSync(skillDest) && !options.refreshAgentsMd) {
227
+ console.log(`SKILL-ORBIT.md already exists — left unchanged (use --refresh-agents-md to replace).`);
228
+ } else {
229
+ copyFileSync(skillSrc, skillDest);
230
+ console.log(`Wrote ${skillDest}`);
231
+ }
232
+ syncAgentsMd(projectRoot, options);
233
+ }
234
+
235
+ enableAiIfRequested(options, result.registryRow, core);
236
+
237
+ if (result.message && result.status === "skipped") {
238
+ console.log(result.message);
239
+ } else if (result.status === "created") {
240
+ console.log(`Initialized Orbit board at ${resolve(projectRoot, ".orbit", "board.db")}`);
241
+ } else if (result.status === "repaired") {
242
+ console.log(result.message || "Linked existing board database to the registry.");
243
+ }
244
+
245
+ const cliPath = resolve(ROOT_DIR, "src", "cli", "orbit.js");
246
+ const mcpLine = ["claude mcp add starscape-orbit", "--", "node", shellQuote(cliPath), "mcp", "--cwd", shellQuote(projectRoot)].join(" ");
247
+ console.log(`
248
+ --- Next: MCP (run once per Claude Code install) ---
249
+ ${mcpLine}
250
+
251
+ --- Next: agent prompt (from this repo) ---
252
+ Read AGENTS.md in this repository, then use the Orbit MCP tools. Use board_list, board_set_active, and ticket context tools per docs/AGENT_PROTOCOL.md before claiming or updating work.
253
+
254
+ ${options.example ? (options.ai ? "Example ticket #12 is \"Try Orbit MCP on this ticket\" in AI Ready — good first exercise after MCP is connected." : "Example tickets were created. Add `--ai` to also create AI collaboration lanes and stage ticket #12 in AI Ready.") : "No example tickets were created. Rerun on a fresh board with `orbit init --example` if you want the onboarding examples."}
255
+ `);
256
+
257
+ if (result.registryRow) {
258
+ console.log(`Board: ${result.registryRow.name} (${result.registryRow.slug}) registry id: ${result.registryRow.id}`);
259
+ }
260
+ }
261
+
262
+ async function runReset(options) {
263
+ const {
264
+ DATA_DIR,
265
+ backupBoardDatabase,
266
+ backupRegistry,
267
+ createRegistrySchema,
268
+ deleteBoard,
269
+ getBoardByRepoPath,
270
+ normalizePath,
271
+ openConnection
272
+ } = await loadCliCore();
273
+ const projectRoot = normalizePath(resolve(options.cwd));
274
+ mkdirSync(DATA_DIR, { recursive: true });
275
+ createRegistrySchema();
276
+
277
+ const orbitDir = resolve(projectRoot, ".orbit");
278
+ const skillMd = resolve(projectRoot, "SKILL-ORBIT.md");
279
+ const removed = [];
280
+ const registryRow = getBoardByRepoPath(projectRoot);
281
+
282
+ if (registryRow && existsSync(registryRow.db_path)) {
283
+ backupBoardDatabase(registryRow, openConnection(registryRow.db_path), "pre-cli-reset");
284
+ }
285
+ if (registryRow) backupRegistry("pre-cli-reset");
286
+
287
+ if (existsSync(orbitDir)) {
288
+ rmSync(orbitDir, { recursive: true, force: true });
289
+ removed.push(orbitDir);
290
+ }
291
+ if (existsSync(skillMd)) {
292
+ unlinkSync(skillMd);
293
+ removed.push(skillMd);
294
+ }
295
+
296
+ if (registryRow) {
297
+ deleteBoard(registryRow.id);
298
+ removed.push(`registry row "${registryRow.slug}"`);
299
+ }
300
+
301
+ if (removed.length === 0) {
302
+ console.log(`Nothing to reset under ${projectRoot}.`);
303
+ } else {
304
+ for (const item of removed) console.log(`Removed ${item}`);
305
+ console.log(`\nRun \`orbit init\` from ${projectRoot} to provision a fresh board.`);
306
+ }
307
+ }
308
+
309
+ async function runServe(options) {
310
+ // PROJECT_ROOT must be set BEFORE importing the server, because src/core/paths.js
311
+ // captures it at module load. cwd defaults are equivalent for most users, but
312
+ // an explicit --cwd lets a global install target a board outside cwd.
313
+ process.env.PROJECT_ROOT = resolve(options.cwd);
314
+ if (options.port) process.env.PORT = String(options.port);
315
+ await import("../server.js");
316
+ }
317
+
318
+ function containerPathFor(hostPath, fallbackPath) {
319
+ // Linux/macOS Docker can mount the same absolute path into the container,
320
+ // which keeps existing Orbit board metadata meaningful. Windows paths are
321
+ // not valid Linux container paths, so use stable in-container fallbacks.
322
+ return process.platform === "win32" ? fallbackPath : hostPath;
323
+ }
324
+
325
+ function buildDockerPlan(options) {
326
+ const projectRoot = resolve(options.cwd);
327
+ const dataDir = resolve(options.dataDir || resolve(projectRoot, ".orbit", "docker-data"));
328
+ const containerProjectRoot = containerPathFor(projectRoot, "/workspace");
329
+ const containerDataDir = containerPathFor(dataDir, "/data");
330
+ const port = String(options.port || process.env.PORT || 3337);
331
+
332
+ if (!existsSync(projectRoot)) {
333
+ throw new Error(`Project root does not exist: ${projectRoot}`);
334
+ }
335
+ if (!existsSync(resolve(PACKAGE_ROOT, "Dockerfile"))) {
336
+ throw new Error(`Dockerfile not found in Orbit package root: ${PACKAGE_ROOT}`);
337
+ }
338
+
339
+ const buildArgs = ["build", "-t", options.image, PACKAGE_ROOT];
340
+ const runArgs = ["run", "--rm"];
341
+ if (options.detach) {
342
+ runArgs.push("--detach");
343
+ } else if (process.stdin.isTTY && process.stdout.isTTY) {
344
+ runArgs.push("-it");
345
+ }
346
+ if (options.containerName) runArgs.push("--name", options.containerName);
347
+ runArgs.push(
348
+ "-p",
349
+ `${port}:${port}`,
350
+ "-e",
351
+ `PORT=${port}`,
352
+ "-e",
353
+ `PROJECT_ROOT=${containerProjectRoot}`,
354
+ "-e",
355
+ `DATA_DIR=${containerDataDir}`,
356
+ "-e",
357
+ `SYSTEM_PATH=${projectRoot}`,
358
+ "-v",
359
+ `${projectRoot}:${containerProjectRoot}`,
360
+ "-v",
361
+ `${dataDir}:${containerDataDir}`,
362
+ "-w",
363
+ containerProjectRoot,
364
+ options.image,
365
+ "serve",
366
+ "--cwd",
367
+ containerProjectRoot,
368
+ "--port",
369
+ port
370
+ );
371
+
372
+ return { buildArgs, containerDataDir, containerProjectRoot, dataDir, port, projectRoot, runArgs };
373
+ }
374
+
375
+ function runDockerCommand(args) {
376
+ const result = spawnSync("docker", args, { stdio: "inherit" });
377
+ if (result.error) {
378
+ throw new Error(`Failed to run docker: ${result.error.message}`);
379
+ }
380
+ if (result.signal) {
381
+ process.exitCode = 1;
382
+ return false;
383
+ }
384
+ if (result.status !== 0) {
385
+ process.exitCode = result.status ?? 1;
386
+ return false;
387
+ }
388
+ return true;
389
+ }
390
+
391
+ async function runDocker(options) {
392
+ const plan = buildDockerPlan(options);
393
+ const dryRun = options.dryRun || process.env.ORBIT_DOCKER_DRY_RUN === "1";
394
+
395
+ if (dryRun) {
396
+ console.log(`Project root: ${plan.projectRoot}`);
397
+ console.log(`Docker data: ${plan.dataDir}`);
398
+ if (options.buildImage) {
399
+ console.log(`Docker build:\n ${formatCommand("docker", plan.buildArgs)}`);
400
+ }
401
+ console.log(`Docker run:\n ${formatCommand("docker", plan.runArgs)}`);
402
+ return;
403
+ }
404
+
405
+ mkdirSync(plan.dataDir, { recursive: true });
406
+ if (options.buildImage && !runDockerCommand(plan.buildArgs)) return;
407
+ runDockerCommand(plan.runArgs);
408
+ }
409
+
410
+ async function runMcp(options) {
411
+ process.env.PROJECT_ROOT = resolve(options.cwd);
412
+ await import("../mcp-server.js");
413
+ }
414
+
415
+ async function main() {
416
+ const args = parseArgs(process.argv);
417
+ try {
418
+ if (args.command === "init") {
419
+ await runInit(args);
420
+ } else if (args.command === "reset") {
421
+ await runReset(args);
422
+ } else if (args.command === "serve") {
423
+ await runServe(args);
424
+ } else if (args.command === "docker") {
425
+ await runDocker(args);
426
+ } else if (args.command === "mcp") {
427
+ await runMcp(args);
428
+ } else {
429
+ printUsage();
430
+ process.exitCode = args.command === "help" ? 0 : 1;
431
+ }
432
+ } catch (err) {
433
+ console.error(err?.message || err);
434
+ process.exitCode = 1;
435
+ }
436
+ }
437
+
438
+ main();
@@ -0,0 +1,262 @@
1
+ import {
2
+ addComment,
3
+ boardById,
4
+ childTicketsFor,
5
+ compactTicket,
6
+ labelsForTicket,
7
+ relationRows,
8
+ stateByName,
9
+ stateByRole,
10
+ ticketById,
11
+ ticketByNumber,
12
+ ticketByTitle,
13
+ uniqueTickets,
14
+ unresolvedBlockers
15
+ } from "./queries.js";
16
+ import { canAccessBoard, requireBoardAccess } from "./auth.js";
17
+ import { tx } from "./db.js";
18
+ import { recordEvent } from "./events.js";
19
+ import {
20
+ appendFieldNote,
21
+ httpError,
22
+ now,
23
+ normalizeTicketType,
24
+ requiredString
25
+ } from "./util.js";
26
+ import { boardManual } from "./boards.js";
27
+ import { reindexTicket } from "./tickets.js";
28
+
29
+ /**
30
+ * Claim the next AI-ready ticket on the resolved board. The router/MCP layer
31
+ * is responsible for picking which board this runs against (via path slug,
32
+ * body field, or session board); this function never scans other boards or
33
+ * mutates global state. If you want to claim from another board, the caller
34
+ * resolves a different ctx and calls again.
35
+ */
36
+ export function claimNext(body, ctx) {
37
+ const { db, board, actor } = ctx;
38
+ const filters = {
39
+ type: body.type ? normalizeTicketType(body.type) : "",
40
+ includeEpics: Boolean(body.include_epics)
41
+ };
42
+
43
+ const inner = boardById(db, board.id);
44
+ if (!inner || !canAccessBoard(actor, inner)) {
45
+ throw httpError(403, "forbidden");
46
+ }
47
+
48
+ const candidates = db
49
+ .prepare(
50
+ `SELECT t.*, b.slug AS board_slug
51
+ FROM tickets t
52
+ JOIN states s ON s.id = t.state_id
53
+ JOIN boards b ON b.id = t.board_id
54
+ WHERE (s.role = 'ai_ready' OR (s.role IS NULL AND s.name = 'AI Ready'))
55
+ AND t.archived_at IS NULL
56
+ ORDER BY t.priority DESC, t.created_at ASC`
57
+ )
58
+ .all();
59
+
60
+ const filtered = candidates.filter((ticket) =>
61
+ !filters.type ? filters.includeEpics || ticket.type !== "epic" : ticket.type === filters.type
62
+ );
63
+
64
+ for (const candidate of filtered) {
65
+ const blockedBy = unresolvedBlockers(db, candidate.id);
66
+ if (blockedBy.length > 0) continue;
67
+
68
+ const inProgress =
69
+ stateByRole(db, candidate.board_id, "in_progress") ||
70
+ stateByName(db, candidate.board_id, "In Progress");
71
+ if (!inProgress) throw httpError(400, "missing_in_progress_state");
72
+
73
+ tx(db, () => {
74
+ db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(
75
+ inProgress.id,
76
+ now(),
77
+ candidate.id
78
+ );
79
+ addComment(db, candidate.id, actor.name, "agent_note", "Agent claimed this ticket and is starting work.");
80
+ recordEvent(db, candidate.board_id, "agent_claimed", candidate.id, actor.name, { agent_id: actor.id });
81
+ reindexTicket(db, candidate.id);
82
+ });
83
+
84
+ return {
85
+ claimed: true,
86
+ ticket_id: candidate.id,
87
+ context: readTicket(candidate.id, ctx)
88
+ };
89
+ }
90
+
91
+ return { claimed: false, reason: "no_schedulable_ticket" };
92
+ }
93
+
94
+ function resolveTicketLookup(db, boardId, { ticket_id, number, title }) {
95
+ if (ticket_id) return ticketById(db, ticket_id);
96
+ if (number !== undefined && number !== null && number !== "") {
97
+ const n = Number(number);
98
+ if (!Number.isInteger(n)) throw httpError(400, "invalid_number");
99
+ return ticketByNumber(db, boardId, n);
100
+ }
101
+ if (title) return ticketByTitle(db, boardId, String(title));
102
+ throw httpError(400, "ticket_id_or_number_or_title_required");
103
+ }
104
+
105
+ export function readTicket(lookup, ctx) {
106
+ const { db, board, actor } = ctx;
107
+ const args = typeof lookup === "string" ? { ticket_id: lookup } : lookup || {};
108
+ const ticket = resolveTicketLookup(db, board.id, args);
109
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
110
+ const innerBoard = boardById(db, ticket.board_id);
111
+ requireBoardAccess(actor, innerBoard);
112
+
113
+ const comments = db
114
+ .prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
115
+ .all(ticket.id);
116
+ const labels = labelsForTicket(db, ticket.id);
117
+
118
+ return {
119
+ ticket: {
120
+ id: ticket.id,
121
+ number: ticket.number,
122
+ title: ticket.title,
123
+ description: ticket.description,
124
+ state_name: ticket.state_name,
125
+ type: ticket.type,
126
+ priority: ticket.priority,
127
+ labels
128
+ },
129
+ comments,
130
+ board_manual: boardManual(innerBoard.id, ctx)
131
+ };
132
+ }
133
+
134
+ export function readComments(lookup, ctx) {
135
+ const { db, board, actor } = ctx;
136
+ const args = typeof lookup === "string" ? { ticket_id: lookup } : lookup || {};
137
+ const ticket = resolveTicketLookup(db, board.id, args);
138
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
139
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
140
+ const comments = db
141
+ .prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
142
+ .all(ticket.id);
143
+ return { ticket_id: ticket.id, comments };
144
+ }
145
+
146
+ export function checkpointTicket(body, ctx) {
147
+ const { db, board, actor } = ctx;
148
+ const tid = requiredString(body.ticket_id, "ticket_id");
149
+ const ticket = ticketById(db, tid);
150
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
151
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
152
+
153
+ // Same review lane as `complete`; distinguish mid-flight pause via checkpoint comment + event kind.
154
+ const review =
155
+ stateByRole(db, ticket.board_id, "review") ||
156
+ stateByName(db, ticket.board_id, "Review");
157
+ if (!review) throw httpError(400, "missing_review_state");
158
+ const message = requiredString(body.message, "message");
159
+ tx(db, () => {
160
+ db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(review.id, now(), ticket.id);
161
+ addComment(db, ticket.id, actor.name, "checkpoint", message);
162
+ recordEvent(db, ticket.board_id, "checkpoint_requested", ticket.id, actor.name, {
163
+ from: ticket.state_name,
164
+ state: review.name,
165
+ to: review.name,
166
+ actor_type: actor.type,
167
+ actor_id: actor.id
168
+ });
169
+ reindexTicket(db, ticket.id);
170
+ });
171
+ return getContextPack(ticket.id, ctx, 1);
172
+ }
173
+
174
+ export function completeTicket(body, ctx) {
175
+ const { db, board, actor } = ctx;
176
+ const tid = requiredString(body.ticket_id, "ticket_id");
177
+ const ticket = ticketById(db, tid);
178
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
179
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
180
+
181
+ // If the caller specified a next_state, honor that name verbatim. Otherwise
182
+ // resolve the review lane by role so renaming the lane does not break this.
183
+ const nextState = body.next_state
184
+ ? stateByName(db, ticket.board_id, body.next_state)
185
+ : stateByRole(db, ticket.board_id, "review") || stateByName(db, ticket.board_id, "Review");
186
+ if (!nextState) throw httpError(400, "missing_next_state");
187
+
188
+ const summary = requiredString(body.summary, "summary");
189
+ const lines = [summary];
190
+ if (body.pr_url) lines.push(`PR: ${body.pr_url}`);
191
+ const updates = body.updates ? appendFieldNote(ticket.implementation_updates, body.updates, actor.name) : ticket.implementation_updates;
192
+ tx(db, () => {
193
+ addComment(db, ticket.id, actor.name, "completion", lines.join("\n\n"));
194
+ db.prepare(
195
+ "UPDATE tickets SET state_id = ?, implementation_summary = ?, implementation_updates = ?, updated_at = ? WHERE id = ?"
196
+ ).run(nextState.id, summary, updates, now(), ticket.id);
197
+ recordEvent(db, ticket.board_id, "agent_completed", ticket.id, actor.name, {
198
+ from: ticket.state_name,
199
+ next_state: nextState.name,
200
+ to: nextState.name,
201
+ pr_url: body.pr_url || "",
202
+ actor_type: actor.type,
203
+ actor_id: actor.id
204
+ });
205
+ reindexTicket(db, ticket.id);
206
+ });
207
+ return getContextPack(ticket.id, ctx, 1);
208
+ }
209
+
210
+ export function getContextPack(ticketId, ctx, depth = 1) {
211
+ const { db, board, actor } = ctx;
212
+ const ticket = ticketById(db, ticketId);
213
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
214
+ const innerBoard = boardById(db, ticket.board_id);
215
+ requireBoardAccess(actor, innerBoard);
216
+
217
+ const comments = db.prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC").all(ticketId);
218
+ const labels = labelsForTicket(db, ticketId);
219
+ const relations = relationRows(db, ticketId);
220
+ const blockers = unresolvedBlockers(db, ticketId);
221
+ const parentTicket = ticket.parent_ticket_id ? ticketById(db, ticket.parent_ticket_id) : null;
222
+ const childTickets = childTicketsFor(db, ticketId).map((child) => ({
223
+ ...child,
224
+ labels: labelsForTicket(db, child.id),
225
+ blockers: unresolvedBlockers(db, child.id)
226
+ }));
227
+
228
+ const relatedTickets = uniqueTickets([
229
+ ...relations.map((relation) => relation.other_ticket),
230
+ ...childTickets,
231
+ ...(parentTicket ? [parentTicket] : [])
232
+ ]);
233
+ const relatedCommentRows =
234
+ depth > 0
235
+ ? relatedTickets.flatMap((related) =>
236
+ db
237
+ .prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at DESC LIMIT 5")
238
+ .all(related.id)
239
+ .reverse()
240
+ .map((comment) => ({ ...comment, ticket_id: related.id, ticket_title: related.title }))
241
+ )
242
+ : [];
243
+
244
+ return {
245
+ ticket: {
246
+ ...ticket,
247
+ labels
248
+ },
249
+ board: innerBoard,
250
+ board_manual: boardManual(innerBoard.id, ctx),
251
+ comments,
252
+ parent_ticket: parentTicket ? compactTicket(parentTicket) : null,
253
+ child_tickets: childTickets.map((child) => ({
254
+ ...child,
255
+ child_count: childTicketsFor(db, child.id).length
256
+ })),
257
+ relations,
258
+ blockers,
259
+ related_tickets: relatedTickets,
260
+ related_comments: relatedCommentRows
261
+ };
262
+ }