@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,93 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { normalizePath } from "./util.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ const DEFAULT_TITLE = "Choose repo folder for Orbit board";
8
+
9
+ export function folderPickerCommands(platform = process.platform, title = DEFAULT_TITLE) {
10
+ if (platform === "darwin") {
11
+ return [
12
+ {
13
+ command: "osascript",
14
+ args: ["-e", `POSIX path of (choose folder with prompt ${JSON.stringify(title)})`]
15
+ }
16
+ ];
17
+ }
18
+
19
+ if (platform === "win32") {
20
+ return [
21
+ {
22
+ command: "powershell.exe",
23
+ args: [
24
+ "-NoProfile",
25
+ "-STA",
26
+ "-Command",
27
+ [
28
+ "Add-Type -AssemblyName System.Windows.Forms",
29
+ "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog",
30
+ `$dialog.Description = ${JSON.stringify(title)}`,
31
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
32
+ " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
33
+ " Write-Output $dialog.SelectedPath",
34
+ "}"
35
+ ].join("; ")
36
+ ]
37
+ }
38
+ ];
39
+ }
40
+
41
+ return [
42
+ {
43
+ command: "zenity",
44
+ args: ["--file-selection", "--directory", "--title", title]
45
+ },
46
+ {
47
+ command: "kdialog",
48
+ args: ["--getexistingdirectory", ".", title]
49
+ }
50
+ ];
51
+ }
52
+
53
+ function isMissingCommand(error) {
54
+ return error?.code === "ENOENT";
55
+ }
56
+
57
+ function isPickerCancel(error) {
58
+ const stderr = String(error?.stderr || "");
59
+ return error?.code === 1 && /cancel|user canceled|user cancelled/i.test(stderr);
60
+ }
61
+
62
+ export async function pickFolder(options = {}) {
63
+ const platform = options.platform || process.platform;
64
+ const title = options.title || DEFAULT_TITLE;
65
+ const execFileImpl = options.execFileImpl || execFileAsync;
66
+ const commands = folderPickerCommands(platform, title);
67
+ let missingCommands = 0;
68
+
69
+ for (const spec of commands) {
70
+ try {
71
+ const result = await execFileImpl(spec.command, spec.args, {
72
+ encoding: "utf8",
73
+ timeout: 120000,
74
+ windowsHide: false
75
+ });
76
+ const selectedPath = normalizePath(String(result.stdout || "").trim());
77
+ return selectedPath ? { path: selectedPath } : { canceled: true };
78
+ } catch (error) {
79
+ if (isMissingCommand(error)) {
80
+ missingCommands += 1;
81
+ continue;
82
+ }
83
+ if (isPickerCancel(error)) return { canceled: true };
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ if (missingCommands === commands.length) {
89
+ return { unsupported: true };
90
+ }
91
+
92
+ return { canceled: true };
93
+ }
@@ -0,0 +1,344 @@
1
+ import {
2
+ addComment,
3
+ boardById,
4
+ defaultStateId,
5
+ ensureLabel,
6
+ ticketById
7
+ } from "./queries.js";
8
+ import { requireBoardAccess, requirePermission } from "./auth.js";
9
+ import { tx } from "./db.js";
10
+ import { recordEvent } from "./events.js";
11
+ import {
12
+ httpError,
13
+ id,
14
+ now,
15
+ normalizePriority,
16
+ normalizeTicketType,
17
+ requiredString
18
+ } from "./util.js";
19
+
20
+ export function createTicket(body, ctx) {
21
+ const { db, board, actor } = ctx;
22
+ // The router resolves `ctx.board` from path/body/query before we get here.
23
+ // If the caller also sent `body.board_id`, it must agree with ctx.board.
24
+ if (body.board_id && body.board_id !== board.id) {
25
+ throw httpError(400, "board_id_mismatch");
26
+ }
27
+ const boardId = board.id;
28
+ const innerBoard = boardById(db, boardId);
29
+ if (!innerBoard) throw httpError(404, "board_not_found");
30
+ requireBoardAccess(actor, innerBoard);
31
+ requirePermission(actor, "write");
32
+
33
+ const title = requiredString(body.title, "title");
34
+ const stateId = body.state_id || defaultStateId(db, boardId);
35
+ const state = db.prepare("SELECT * FROM states WHERE id = ? AND board_id = ?").get(stateId, boardId);
36
+ if (!state) throw httpError(400, "invalid_state");
37
+
38
+ const ticketType = normalizeTicketType(body.type || (body.parent_ticket_id ? "feature" : "task"));
39
+ const parentTicketId = normalizeParentTicketId(db, body.parent_ticket_id, boardId, ticketType);
40
+ const ticketId = id();
41
+ const time = now();
42
+
43
+ return tx(db, () => {
44
+ // MAX(number)+1 must be read and consumed inside the transaction: BEGIN
45
+ // IMMEDIATE holds the write lock so a concurrent process (e.g. an MCP
46
+ // agent creating a ticket) can't pick the same number and trip the
47
+ // UNIQUE(board_id, number) constraint.
48
+ const nextNumber =
49
+ (db.prepare("SELECT MAX(number) AS value FROM tickets WHERE board_id = ?").get(boardId).value || 0) + 1;
50
+
51
+ db.prepare(
52
+ `INSERT INTO tickets
53
+ (id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
54
+ implementation_summary, implementation_updates, state_id, priority, created_by, created_at, updated_at)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
56
+ ).run(
57
+ ticketId,
58
+ boardId,
59
+ nextNumber,
60
+ title,
61
+ body.description || "",
62
+ ticketType,
63
+ parentTicketId,
64
+ body.ai_plan || "",
65
+ body.implementation_summary || "",
66
+ body.implementation_updates || "",
67
+ stateId,
68
+ normalizePriority(body.priority),
69
+ actor.name,
70
+ time,
71
+ time
72
+ );
73
+
74
+ for (const labelName of body.labels || []) {
75
+ const label = ensureLabel(db, boardId, labelName);
76
+ db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
77
+ }
78
+
79
+ recordEvent(db, boardId, "ticket_created", ticketId, actor.name, {
80
+ title,
81
+ type: ticketType,
82
+ parent_ticket_id: parentTicketId
83
+ });
84
+ reindexTicket(db, ticketId);
85
+ return ticketById(db, ticketId);
86
+ });
87
+ }
88
+
89
+ export function updateTicket(ticketId, body, ctx) {
90
+ const { db, board, actor } = ctx;
91
+ const before = ticketById(db, ticketId);
92
+ if (!before) throw httpError(404, "ticket_not_found");
93
+ if (before.board_id !== board.id) throw httpError(404, "ticket_not_found");
94
+ const innerBoard = boardById(db, before.board_id);
95
+ requireBoardAccess(actor, innerBoard);
96
+ requirePermission(actor, "write");
97
+
98
+ const allowed = [
99
+ "title",
100
+ "description",
101
+ "type",
102
+ "parent_ticket_id",
103
+ "ai_plan",
104
+ "implementation_summary",
105
+ "implementation_updates",
106
+ "state_id",
107
+ "priority"
108
+ ];
109
+
110
+ const nextType = body.type !== undefined ? normalizeTicketType(body.type) : before.type;
111
+
112
+ // Resolve every requested field to the value that will actually be written,
113
+ // without mutating the caller's request body. The same resolved map drives
114
+ // both the UPDATE and the audit event, so history reflects what was stored
115
+ // (normalized priority/type, validated parent) rather than raw input.
116
+ const applied = {};
117
+ for (const field of allowed) {
118
+ if (body[field] === undefined) continue;
119
+ if (field === "type") applied.type = nextType;
120
+ else if (field === "priority") applied.priority = normalizePriority(body.priority);
121
+ else if (field === "parent_ticket_id") {
122
+ applied.parent_ticket_id = normalizeParentTicketId(db, body.parent_ticket_id, before.board_id, nextType, before.id);
123
+ } else applied[field] = body[field];
124
+ }
125
+ // Promoting a ticket to epic clears any parent, even if the caller didn't
126
+ // mention parent_ticket_id (epics cannot have parents).
127
+ if (nextType === "epic" && applied.parent_ticket_id === undefined) {
128
+ applied.parent_ticket_id = null;
129
+ }
130
+
131
+ const labelSync = Array.isArray(body.labels);
132
+ const fields = Object.keys(applied);
133
+
134
+ if (fields.length === 0 && !labelSync) return before;
135
+
136
+ if (applied.state_id) {
137
+ const state = db.prepare("SELECT * FROM states WHERE id = ? AND board_id = ?").get(applied.state_id, before.board_id);
138
+ if (!state) throw httpError(400, "invalid_state");
139
+ }
140
+
141
+ return tx(db, () => {
142
+ if (fields.length > 0) {
143
+ const sets = fields.map((field) => `${field} = ?`);
144
+ const values = fields.map((field) => applied[field]);
145
+ sets.push("updated_at = ?");
146
+ values.push(now(), ticketId);
147
+ db.prepare(`UPDATE tickets SET ${sets.join(", ")} WHERE id = ?`).run(...values);
148
+ }
149
+
150
+ // Replace ticket labels when `labels` is sent (array of names). Labels are board-scoped.
151
+ if (labelSync) {
152
+ const boardScopeId = before.board_id;
153
+ db.prepare("DELETE FROM ticket_labels WHERE ticket_id = ?").run(ticketId);
154
+ for (const labelName of body.labels) {
155
+ const clean = String(labelName ?? "")
156
+ .trim()
157
+ .replace(/\s+/g, " ");
158
+ if (!clean) continue;
159
+ const label = ensureLabel(db, boardScopeId, clean);
160
+ db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
161
+ }
162
+ if (fields.length === 0) {
163
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
164
+ }
165
+ }
166
+
167
+ const after = ticketById(db, ticketId);
168
+ if (before.state_id !== after.state_id) {
169
+ // Done lane is the auto-resolver for blocker relations. When a ticket
170
+ // lands in a role='done' lane, every relation that names this ticket as a
171
+ // blocker (`blocks` from this ticket, `blocked_by` toward this ticket)
172
+ // becomes meaningless — the relation row is the SSOT, so we delete it.
173
+ if (after.state_role === "done" && before.state_role !== "done") {
174
+ db.prepare(
175
+ `DELETE FROM relations
176
+ WHERE (source_ticket_id = ? AND type = 'blocks')
177
+ OR (target_ticket_id = ? AND type = 'blocked_by')`
178
+ ).run(ticketId, ticketId);
179
+ }
180
+ recordEvent(db, before.board_id, "state_changed", ticketId, actor.name, {
181
+ from: before.state_name,
182
+ to: after.state_name,
183
+ actor_type: actor.type,
184
+ actor_id: actor.id
185
+ });
186
+ } else {
187
+ const eventPayload = { ...applied };
188
+ if (labelSync) eventPayload.labels = body.labels;
189
+ if (Object.keys(eventPayload).length > 0) {
190
+ recordEvent(db, before.board_id, "ticket_updated", ticketId, actor.name, eventPayload);
191
+ }
192
+ }
193
+ reindexTicket(db, ticketId);
194
+ return after;
195
+ });
196
+ }
197
+
198
+ export function archiveTicket(ticketId, ctx) {
199
+ const { db, board, actor } = ctx;
200
+ const ticket = ticketById(db, ticketId);
201
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
202
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
203
+ requirePermission(actor, "write");
204
+
205
+ if (ticket.archived_at) return ticket;
206
+
207
+ return tx(db, () => {
208
+ const time = now();
209
+ db.prepare("UPDATE tickets SET archived_at = ?, updated_at = ? WHERE id = ?").run(time, time, ticketId);
210
+ recordEvent(db, ticket.board_id, "ticket_archived", ticketId, actor.name, { title: ticket.title });
211
+ return ticketById(db, ticketId);
212
+ });
213
+ }
214
+
215
+ export function restoreTicket(ticketId, ctx) {
216
+ const { db, board, actor } = ctx;
217
+ const ticket = ticketById(db, ticketId);
218
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
219
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
220
+ requirePermission(actor, "write");
221
+
222
+ if (!ticket.archived_at) return ticket;
223
+
224
+ return tx(db, () => {
225
+ const time = now();
226
+ db.prepare("UPDATE tickets SET archived_at = NULL, updated_at = ? WHERE id = ?").run(time, ticketId);
227
+ recordEvent(db, ticket.board_id, "ticket_restored", ticketId, actor.name, { title: ticket.title });
228
+ return ticketById(db, ticketId);
229
+ });
230
+ }
231
+
232
+ export function deleteTicket(ticketId, ctx) {
233
+ const { db, board, actor } = ctx;
234
+ const ticket = ticketById(db, ticketId);
235
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
236
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
237
+ requirePermission(actor, "write");
238
+
239
+ if (!ticket.archived_at) throw httpError(409, "ticket_not_archived");
240
+
241
+ return tx(db, () => {
242
+ recordEvent(db, ticket.board_id, "ticket_deleted", ticketId, actor.name, {
243
+ title: ticket.title,
244
+ type: ticket.type,
245
+ board_id: ticket.board_id
246
+ });
247
+ db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
248
+ db.prepare("DELETE FROM tickets WHERE id = ?").run(ticketId);
249
+ return { ok: true, deleted_id: ticketId };
250
+ });
251
+ }
252
+
253
+ export function createComment(ticketId, body, ctx) {
254
+ const { db, board, actor } = ctx;
255
+ const ticket = ticketById(db, ticketId);
256
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
257
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
258
+ requirePermission(actor, "write");
259
+
260
+ const commentBody = requiredString(body.body, "body");
261
+ const author = body.author || actor.name;
262
+ const kind = body.kind || (actor.type === "agent" ? "agent_note" : "human_comment");
263
+
264
+ return tx(db, () => {
265
+ const comment = addComment(db, ticketId, author, kind, commentBody);
266
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
267
+ recordEvent(db, ticket.board_id, "comment_created", ticketId, actor.name, { kind: comment.kind });
268
+ reindexTicket(db, ticketId);
269
+ return comment;
270
+ });
271
+ }
272
+
273
+ export function getTicketStatusHistory(ticketId, ctx) {
274
+ const { db, board, actor } = ctx;
275
+ const ticket = ticketById(db, ticketId);
276
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
277
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
278
+
279
+ return db
280
+ .prepare(
281
+ `SELECT * FROM events
282
+ WHERE ticket_id = ? AND type IN ('state_changed', 'checkpoint_requested', 'agent_completed')
283
+ ORDER BY created_at ASC`
284
+ )
285
+ .all(ticketId)
286
+ .map((e) => ({ ...e, body: JSON.parse(e.body_json) }));
287
+ }
288
+
289
+ export function normalizeParentTicketId(db, value, boardId, ticketType, currentTicketId = "") {
290
+ if (value === undefined || value === null || value === "") {
291
+ return null;
292
+ }
293
+ if (ticketType === "epic") throw httpError(400, "epic_cannot_have_parent");
294
+ if (value === currentTicketId) throw httpError(400, "ticket_cannot_parent_itself");
295
+
296
+ const parent = ticketById(db, value);
297
+ if (!parent || parent.board_id !== boardId) throw httpError(400, "invalid_parent_ticket");
298
+
299
+ // Walk the full ancestor chain, not just one hop: parenting onto `parent`
300
+ // must not make `currentTicketId` its own ancestor (A→B→C→A). The depth cap
301
+ // also stops a pre-existing corrupt cycle from looping forever here.
302
+ const seen = new Set();
303
+ let ancestor = parent;
304
+ let guard = 0;
305
+ while (ancestor) {
306
+ if (currentTicketId && ancestor.id === currentTicketId) {
307
+ throw httpError(400, "ticket_parent_cycle");
308
+ }
309
+ if (!ancestor.parent_ticket_id || seen.has(ancestor.parent_ticket_id) || guard++ > 1000) break;
310
+ seen.add(ancestor.parent_ticket_id);
311
+ ancestor = ticketById(db, ancestor.parent_ticket_id);
312
+ }
313
+ return parent.id;
314
+ }
315
+
316
+ export function reindexTicket(db, ticketId) {
317
+ const ticket = db.prepare("SELECT * FROM tickets WHERE id = ?").get(ticketId);
318
+ if (!ticket) return;
319
+ const comments = db
320
+ .prepare("SELECT body FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
321
+ .all(ticketId)
322
+ .map((comment) => comment.body)
323
+ .join("\n");
324
+ const implementationText = [
325
+ `Type: ${ticket.type}`,
326
+ ticket.ai_plan,
327
+ ticket.implementation_summary,
328
+ ticket.implementation_updates
329
+ ]
330
+ .filter(Boolean)
331
+ .join("\n");
332
+ db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
333
+ db.prepare("INSERT INTO ticket_fts (ticket_id, title, description, comments) VALUES (?, ?, ?, ?)").run(
334
+ ticketId,
335
+ ticket.title,
336
+ ticket.description,
337
+ [implementationText, comments].filter(Boolean).join("\n")
338
+ );
339
+ }
340
+
341
+ export function reindexAllTickets(db) {
342
+ const rows = db.prepare("SELECT id FROM tickets").all();
343
+ for (const row of rows) reindexTicket(db, row.id);
344
+ }
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+
4
+ /** Normalize repo paths for registry lookup (trailing slashes, slashes, Windows case). */
5
+ export function normalizePath(raw) {
6
+ if (raw === null || raw === undefined) return "";
7
+ let s = String(raw).trim();
8
+ if (!s) return "";
9
+ try {
10
+ s = resolve(s);
11
+ } catch {
12
+ return "";
13
+ }
14
+ s = s.replace(/\\/g, "/").replace(/\/+$/, "");
15
+ if (process.platform === "win32") s = s.toLowerCase();
16
+ return s;
17
+ }
18
+
19
+ export function id() {
20
+ return randomUUID();
21
+ }
22
+
23
+ export function now() {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ export function slugify(value) {
28
+ return String(value)
29
+ .toLowerCase()
30
+ .trim()
31
+ .replace(/[^a-z0-9]+/g, "-")
32
+ .replace(/^-+|-+$/g, "")
33
+ .slice(0, 80);
34
+ }
35
+
36
+ export function titleize(value) {
37
+ return String(value)
38
+ .replace(/[-_]+/g, " ")
39
+ .replace(/\s+/g, " ")
40
+ .trim()
41
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
42
+ }
43
+
44
+ export function httpError(status, code) {
45
+ const error = new Error(code);
46
+ error.status = status;
47
+ error.code = code;
48
+ return error;
49
+ }
50
+
51
+ export function requiredString(value, field) {
52
+ if (typeof value !== "string" || value.trim() === "") {
53
+ throw httpError(400, `missing_${field}`);
54
+ }
55
+ return value.trim();
56
+ }
57
+
58
+ export function normalizePriority(value) {
59
+ const number = Number(value ?? 2);
60
+ if (!Number.isFinite(number)) return 2;
61
+ return Math.max(0, Math.min(4, Math.round(number)));
62
+ }
63
+
64
+ export function normalizeTicketType(value) {
65
+ const type = String(value || "task").toLowerCase().trim();
66
+ const allowed = new Set(["epic", "feature", "task", "bug"]);
67
+ if (!allowed.has(type)) throw httpError(400, "invalid_ticket_type");
68
+ return type;
69
+ }
70
+
71
+ export function normalizeProjectEntryType(value) {
72
+ const type = String(value || "").toLowerCase().trim();
73
+ const allowed = new Set(["decision", "lesson"]);
74
+ if (!allowed.has(type)) throw httpError(400, "invalid_project_entry_type");
75
+ return type;
76
+ }
77
+
78
+ export function normalizeStateRole(value) {
79
+ if (value === null || value === undefined || value === "") return null;
80
+ const role = String(value).toLowerCase().trim();
81
+ // `done` marks the terminal lane that auto-resolves blockers (relation-row
82
+ // SSOT — see updateTicket / unresolvedBlockers). The agent-flow roles
83
+ // (ai_ready/in_progress/review) are anchors the claim-next pipeline keys off.
84
+ const allowed = new Set(["ai_ready", "in_progress", "review", "done"]);
85
+ if (!allowed.has(role)) throw httpError(400, "invalid_state_role");
86
+ return role;
87
+ }
88
+
89
+ export function appendFieldNote(existing, note, actorName) {
90
+ const text = String(note || "").trim();
91
+ if (!text) return existing || "";
92
+ const entry = `## ${new Date().toISOString()} - ${actorName}\n\n${text}`;
93
+ return [existing, entry].filter(Boolean).join("\n\n");
94
+ }
95
+
96
+ export function toFtsQuery(q) {
97
+ return q
98
+ .toLowerCase()
99
+ .replace(/[^a-z0-9_ -]/g, " ")
100
+ .split(/\s+/)
101
+ .map((part) => part.trim())
102
+ .filter(Boolean)
103
+ .map((part) => `${part}*`)
104
+ .join(" AND ");
105
+ }