@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,931 @@
1
+ // Project settings drawer (the cog/settings icon). Six tabs: Lanes, AI,
2
+ // Notes, Journal, Repository, Archive. Owns lane CRUD/reorder, the
3
+ // archive list, project context/notes form persistence, and per-tab handler wiring.
4
+
5
+ import { state, getSettingsTab, statesForProject, ticketsForProject, currentBoard } from "./state.js";
6
+ import { drawerInner, $ } from "./dom.js";
7
+ import { escapeHtml, formatDate, repoLabelFromUrl } from "./format.js";
8
+ import { api, withBoardQuery } from "./api.js";
9
+ import { toast, downloadJson } from "./toast.js";
10
+ import { renderDrawerShell } from "./drawer.js";
11
+ import { navigate } from "./router.js";
12
+ import { renderBoard } from "./kanban.js";
13
+ import { renderDetailCard } from "./ticket-detail.js";
14
+ import { load } from "./app.js";
15
+ import { features } from "./config.js";
16
+
17
+ const repositoryDelete = {
18
+ boardId: "",
19
+ step: "idle",
20
+ exported: false
21
+ };
22
+
23
+ export async function renderProjectDetail() {
24
+ const context = await api(`/api/boards/${state.boardId}/context?include_struck=true`);
25
+ const project = context.board;
26
+ const activeTab = getSettingsTab();
27
+
28
+ const tabs = [
29
+ { id: "lanes", label: "Lanes" },
30
+ { id: "archive", label: "Card Archive" },
31
+ { id: "notes", label: "Notes" },
32
+ ...(features.ai ? [{ id: "ai", label: "AI" }] : []),
33
+ { id: "journal", label: "Journal" },
34
+ { id: "repository", label: "Repository" }
35
+ ];
36
+
37
+ const resolvedTab = activeTab === "ai" && !features.ai ? "lanes" : activeTab;
38
+
39
+ renderDrawerShell({
40
+ eyebrow: "SETTINGS",
41
+ mode: "settings",
42
+ title: project.name,
43
+ tabs,
44
+ activeTab: resolvedTab,
45
+ onTabSelect: async (id) => {
46
+ navigate({
47
+ boardId: state.boardId,
48
+ view: "settings",
49
+ tab: id
50
+ });
51
+ },
52
+ body: renderProjectTabBody(context, resolvedTab)
53
+ });
54
+
55
+ bindProjectTabHandlers(context, resolvedTab);
56
+ }
57
+
58
+ function renderProjectTabBody(context, tab) {
59
+ switch (tab) {
60
+ case "notes":
61
+ return renderNotesSettingsTab(context);
62
+ case "lanes":
63
+ return renderProjectLanesTab();
64
+ case "ai":
65
+ return features.ai ? renderProjectAiTab(context) : renderProjectLanesTab();
66
+ case "journal":
67
+ return renderProjectJournalTab(context);
68
+ case "archive":
69
+ return renderProjectArchiveTab();
70
+ case "repository":
71
+ default:
72
+ return renderProjectRepositoryTab(context);
73
+ }
74
+ }
75
+
76
+ const MCP_OS_OPTIONS = {
77
+ windows: {
78
+ label: "Windows",
79
+ shell: "PowerShell",
80
+ serverPath: "C:\\Path\\To\\StarscapeOrbit\\src\\mcp-server.js"
81
+ },
82
+ macos: {
83
+ label: "macOS",
84
+ shell: "Terminal",
85
+ serverPath: "/Users/you/apps/starscape-orbit/src/mcp-server.js"
86
+ },
87
+ linux: {
88
+ label: "Linux",
89
+ shell: "Terminal",
90
+ serverPath: "/home/you/apps/starscape-orbit/src/mcp-server.js"
91
+ }
92
+ };
93
+
94
+ const MCP_CLIENTS = {
95
+ "claude-code": {
96
+ label: "Claude Code",
97
+ format: "CLI command",
98
+ helper: "Run this command once. Claude Code records the local Orbit tool server for the project."
99
+ },
100
+ codex: {
101
+ label: "Codex",
102
+ format: "config.toml",
103
+ helper: "Paste this TOML into Codex config. Project-local config may require the project to be trusted."
104
+ },
105
+ cursor: {
106
+ label: "Cursor",
107
+ format: "MCP JSON",
108
+ helper: "Paste this JSON into Cursor MCP settings. Cursor starts Orbit when an agent needs the tools."
109
+ },
110
+ opencode: {
111
+ label: "OpenCode",
112
+ format: "opencode.json",
113
+ helper: "Paste this into OpenCode config under the local MCP server list."
114
+ },
115
+ openclaw: {
116
+ label: "OpenClaw",
117
+ format: "MCP JSON",
118
+ helper: "Paste this JSON into OpenClaw's MCP server list. OpenClaw will spawn Orbit on demand."
119
+ },
120
+ generic: {
121
+ label: "Other MCP app",
122
+ format: "MCP JSON",
123
+ helper: "Most MCP clients accept this JSON shape for local stdio servers."
124
+ }
125
+ };
126
+
127
+ function renderProjectAiTab(context) {
128
+ const project = context.board;
129
+ const aiEnabled = project.ai_enabled !== 0;
130
+ const aiDivider = aiEnabled ? `<div class="ai-section-divider" aria-hidden="true"></div>` : "";
131
+ const agentContextSetup = aiEnabled ? renderAgentContextSection(project) : "";
132
+ const mcpSetup = aiEnabled ? renderMcpSetupSection(context) : "";
133
+
134
+ return `
135
+ <div class="section">
136
+ <label class="orbit-check" for="aiEnabledToggle">
137
+ <input
138
+ type="checkbox"
139
+ id="aiEnabledToggle"
140
+ ${aiEnabled ? "checked" : ""}
141
+ />
142
+ <span class="orbit-check-box" aria-hidden="true">
143
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
144
+ </span>
145
+ <span class="orbit-check-label">Enable AI</span>
146
+ </label>
147
+ <p class="description">Provisions the <strong>AI Ready</strong>, <strong>In Progress</strong>, and <strong>Review</strong> lanes if missing, surfaces agent context fields, and exposes the MCP setup snippet. Disabling AI keeps the lanes in place.</p>
148
+ </div>
149
+ ${aiDivider}
150
+ ${agentContextSetup}
151
+ ${aiDivider}
152
+ ${mcpSetup}
153
+ `;
154
+ }
155
+
156
+ function renderAgentContextSection(project) {
157
+ const ph =
158
+ "This project is about…\n\nHow any AI agent should use this board and work in this repo.";
159
+ return `
160
+ <div class="section ai-subsection ai-context-setup">
161
+ <div class="ai-context-title-row">
162
+ <span class="ai-context-inline-mark" aria-hidden="true">Context</span>
163
+ <h3>Agent Instructions</h3>
164
+ </div>
165
+ <p class="description">This project-level context is provided to agents when they work on tickets.</p>
166
+ <form id="agentInstructionsForm" class="field-form">
167
+ <textarea name="agent_instructions" placeholder="${escapeHtml(ph)}">${escapeHtml(project.agent_instructions || "")}</textarea>
168
+ <button type="submit">Save</button>
169
+ </form>
170
+ </div>
171
+ `;
172
+ }
173
+
174
+ function renderMcpSetupSection(context) {
175
+ const osId = detectedMcpOs();
176
+ const clientId = "claude-code";
177
+ const paths = mcpDetectedPaths(context, osId);
178
+ const configText = mcpConfigText(clientId, paths);
179
+ const commandText = mcpRunCommand(osId, paths);
180
+ const client = MCP_CLIENTS[clientId];
181
+ return `
182
+ <details class="section ai-subsection mcp-setup" data-mcp-setup>
183
+ <summary>
184
+ <div class="mcp-title-row">
185
+ <span class="mcp-inline-mark" aria-hidden="true">MCP</span>
186
+ <h3>Connect Your Agent to MCP</h3>
187
+ </div>
188
+ </summary>
189
+ <div>
190
+ <p class="description">A tiny service running on your computer alongside your AI agent, providing it with tools and context to collaborate with you more effectively. Tell your AI to use the snippet below, it knows what MCP is.</p>
191
+ <div class="mcp-flow">
192
+ <label>
193
+ <span>Operating system</span>
194
+ <select id="mcpOsSelect" class="select-chevron-field">
195
+ ${Object.entries(MCP_OS_OPTIONS)
196
+ .map(([id, option]) => `<option value="${id}" ${id === osId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
197
+ .join("")}
198
+ </select>
199
+ </label>
200
+ <label>
201
+ <span>Agent app</span>
202
+ <select id="mcpClientSelect" class="select-chevron-field">
203
+ ${Object.entries(MCP_CLIENTS)
204
+ .map(([id, option]) => `<option value="${id}" ${id === clientId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
205
+ .join("")}
206
+ </select>
207
+ </label>
208
+ </div>
209
+ <div class="mcp-path-grid">
210
+ <label>
211
+ <span>Orbit MCP server file</span>
212
+ <input id="mcpServerPathInput" value="${escapeHtml(paths.serverPath)}" />
213
+ </label>
214
+ </div>
215
+ </div>
216
+
217
+ <div class="mcp-options-stack">
218
+ <div class="mcp-option-card">
219
+ <div class="mcp-option-card-head">
220
+ <div>
221
+ <strong data-mcp-output-title>${escapeHtml(client.label)} setup</strong>
222
+ <p class="description" data-mcp-helper>${escapeHtml(client.helper)}</p>
223
+ </div>
224
+ <button type="button" class="ghost" data-copy-mcp="config">Copy setup</button>
225
+ </div>
226
+ <div class="mcp-terminal">
227
+ <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
228
+ <pre class="mcp-snippet"><code data-mcp-config>${escapeHtml(configText)}</code></pre>
229
+ </div>
230
+ </div>
231
+
232
+ <div class="mcp-or-divider" aria-hidden="true"><span>OR</span></div>
233
+
234
+ <div class="mcp-option-card">
235
+ <div class="mcp-option-card-head">
236
+ <div>
237
+ <strong>Manual run command</strong>
238
+ <p class="description">Use this only when an app asks you to start the MCP server yourself.</p>
239
+ </div>
240
+ <button type="button" class="ghost" data-copy-mcp="command">Copy command</button>
241
+ </div>
242
+ <div class="mcp-terminal">
243
+ <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
244
+ <pre class="mcp-snippet mcp-snippet--compact"><code data-mcp-command>${escapeHtml(commandText)}</code></pre>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </details>
249
+ `;
250
+ }
251
+
252
+ function detectedMcpOs() {
253
+ const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase();
254
+ if (platform.includes("win")) return "windows";
255
+ if (platform.includes("mac")) return "macos";
256
+ return "linux";
257
+ }
258
+
259
+ function mcpDefaultPaths(osId) {
260
+ const option = MCP_OS_OPTIONS[osId] || MCP_OS_OPTIONS.linux;
261
+ return {
262
+ serverPath: option.serverPath
263
+ };
264
+ }
265
+
266
+ function mcpDetectedPaths(context, osId) {
267
+ const template = mcpDefaultPaths(osId);
268
+ const deployment = context.deployment || {};
269
+ return {
270
+ serverPath: deployment.mcp_server_path || template.serverPath
271
+ };
272
+ }
273
+
274
+ function mcpPathsFromForm() {
275
+ return {
276
+ serverPath: $("#mcpServerPathInput")?.value || ""
277
+ };
278
+ }
279
+
280
+ function mcpConfigText(clientId, paths) {
281
+ if (clientId === "claude-code") return claudeCodeMcpCommand(paths);
282
+ if (clientId === "codex") return codexMcpToml(paths);
283
+ if (clientId === "opencode") return opencodeMcpJson(paths);
284
+
285
+ const config = {
286
+ mcpServers: {
287
+ "starscape-orbit": {
288
+ command: "node",
289
+ args: [paths.serverPath]
290
+ }
291
+ }
292
+ };
293
+ return JSON.stringify(config, null, 2);
294
+ }
295
+
296
+ function opencodeMcpJson(paths) {
297
+ return JSON.stringify(
298
+ {
299
+ $schema: "https://opencode.ai/config.json",
300
+ mcp: {
301
+ "starscape-orbit": {
302
+ type: "local",
303
+ command: ["node", paths.serverPath],
304
+ enabled: true
305
+ }
306
+ }
307
+ },
308
+ null,
309
+ 2
310
+ );
311
+ }
312
+
313
+ function codexMcpToml(paths) {
314
+ return `[mcp_servers.starscape-orbit]
315
+ command = "node"
316
+ args = ["${escapeToml(paths.serverPath)}"]
317
+ enabled = true`;
318
+ }
319
+
320
+ function claudeCodeMcpCommand(paths) {
321
+ return ["claude mcp add starscape-orbit", "--", "node", shellQuote(paths.serverPath)].join(" ");
322
+ }
323
+
324
+ function mcpRunCommand(osId, paths) {
325
+ return `node ${shellQuote(paths.serverPath)}`;
326
+ }
327
+
328
+ function shellQuote(value) {
329
+ return `"${String(value || "").replace(/"/g, '\\"')}"`;
330
+ }
331
+
332
+ function escapeToml(value) {
333
+ return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
334
+ }
335
+
336
+ function renderProjectArchiveTab() {
337
+ return `
338
+ <p class="description">Archived cards are hidden from the kanban, search, and agent claim-next. Restore to put a card back on the board, or delete to permanently remove it.</p>
339
+ <div class="section">
340
+ <div id="archiveList" class="archive-list">Loading…</div>
341
+ </div>
342
+ `;
343
+ }
344
+
345
+ function renderProjectRepositoryTab(context) {
346
+ const project = context.board;
347
+ if (repositoryDelete.boardId !== project.id) {
348
+ repositoryDelete.boardId = project.id;
349
+ repositoryDelete.step = "idle";
350
+ repositoryDelete.exported = false;
351
+ }
352
+ const repoMeta = features.multiBoard
353
+ ? `
354
+ <div class="detail-head project-manual-head">
355
+ <div class="meta-grid">
356
+ <div class="meta meta--repo-path">
357
+ <span>Repo</span>
358
+ <strong>${escapeHtml(repoLabelFromUrl(project.repo_url))}</strong>
359
+ <span>Path</span>
360
+ <strong>${escapeHtml(project.system_path || context.deployment?.system_path || "Not available")}</strong>
361
+ </div>
362
+ </div>
363
+ </div>`
364
+ : `
365
+ <p class="description">This board lives in your browser only. Export a snapshot if you want to move it to a real Orbit install, or import one to seed this preview.</p>`;
366
+ return `
367
+ ${repoMeta}
368
+
369
+ <div class="section">
370
+ <h3>Board Snapshot</h3>
371
+ <div class="deployment-actions">
372
+ <button type="button" class="ghost" id="exportProject">Export</button>
373
+ <label class="import-button">
374
+ <input type="file" id="importSnapshotFile" accept=".orbit.json,.json,application/json" />
375
+ <span>Import</span>
376
+ </label>
377
+ </div>
378
+ </div>
379
+
380
+ ${features.multiBoard ? renderDeleteBoardSection(project) : ""}
381
+ `;
382
+ }
383
+
384
+ function renderDeleteBoardSection(project) {
385
+ const slug = escapeHtml(project.slug || "");
386
+ if (repositoryDelete.step === "choice") {
387
+ return `
388
+ <div class="section repository-danger-zone">
389
+ <h3>Delete Board</h3>
390
+ <p class="description">Delete this board from Orbit and remove its local database. Export a snapshot first if you may need this board again.</p>
391
+ <div class="repository-delete-actions">
392
+ <button type="button" class="ghost" id="deleteBoardExportFirst">Export first</button>
393
+ <button type="button" class="ghost danger-button" id="deleteBoardSkipExport">Delete without exporting</button>
394
+ <button type="button" class="ghost" id="deleteBoardCancel">Cancel</button>
395
+ </div>
396
+ </div>
397
+ `;
398
+ }
399
+
400
+ if (repositoryDelete.step === "confirm") {
401
+ const exportedCopy = repositoryDelete.exported
402
+ ? "Snapshot exported. Type the board slug to confirm permanent deletion."
403
+ : "Type the board slug to confirm permanent deletion.";
404
+ return `
405
+ <div class="section repository-danger-zone is-confirming">
406
+ <h3>Delete Board</h3>
407
+ <p class="description">${escapeHtml(exportedCopy)}</p>
408
+ <label class="repository-confirm-field">
409
+ <span>Board slug</span>
410
+ <strong>${slug}</strong>
411
+ <input id="deleteBoardConfirmInput" autocomplete="off" spellcheck="false" placeholder="${slug}" autofocus />
412
+ </label>
413
+ <div class="repository-delete-actions">
414
+ <button type="button" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
415
+ <button type="button" class="ghost" id="deleteBoardCancel">Cancel</button>
416
+ </div>
417
+ </div>
418
+ `;
419
+ }
420
+
421
+ return `
422
+ <div class="section repository-danger-zone">
423
+ <h3>Delete Board</h3>
424
+ <p class="description">Remove this board from Orbit and delete its local database. This cannot be undone without an exported snapshot.</p>
425
+ <button type="button" class="ghost danger-button" id="deleteBoardStart">Delete Board</button>
426
+ </div>
427
+ `;
428
+ }
429
+
430
+ function renderNotesSettingsTab(context) {
431
+ const project = context.board;
432
+ const notesPlaceholder =
433
+ "Remember to mention SKILL-ORBIT.md to my AI agents if they get confused";
434
+ return `
435
+ <div class="section ai-fields">
436
+ <form id="notesSettingsForm" class="field-form settings-notes-form">
437
+ <label class="settings-notes-field-group">
438
+ <span>Notes For You</span>
439
+ <textarea
440
+ name="project_notes"
441
+ class="settings-notes-textarea"
442
+ placeholder="${escapeHtml(notesPlaceholder)}"
443
+ >${escapeHtml(project.project_notes || "")}</textarea>
444
+ </label>
445
+ <button type="submit" class="settings-notes-save">Save</button>
446
+ </form>
447
+ </div>
448
+ `;
449
+ }
450
+
451
+ function renderProjectLanesTab() {
452
+ const showPriority = state.showPriority;
453
+ return `
454
+ <div class="section">
455
+ <label class="orbit-check" for="showPriorityToggle">
456
+ <input
457
+ type="checkbox"
458
+ id="showPriorityToggle"
459
+ ${showPriority ? "checked" : ""}
460
+ />
461
+ <span class="orbit-check-box" aria-hidden="true">
462
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
463
+ </span>
464
+ <span class="orbit-check-label">Show priority label on cards</span>
465
+ </label>
466
+ <p class="description lanes-description">Rename, reorder, add, or remove lanes.</p>
467
+ ${renderLaneManager()}
468
+ <form id="laneCreateForm" class="lane-create-form">
469
+ <input name="name" placeholder="New lane name..." required />
470
+ <button type="submit">Add Lane</button>
471
+ </form>
472
+ </div>
473
+ `;
474
+ }
475
+
476
+ function renderProjectJournalTab(context) {
477
+ const aiEnabled = context.board?.ai_enabled !== 0;
478
+ return `
479
+ ${aiEnabled ? `<p class="description">Agent-facing project memory: decisions to follow and lessons to apply. Struck entries stay visible here but are excluded from agent context.</p>` : ""}
480
+ <div class="section">
481
+ ${renderProjectEntries(context.entries)}
482
+ <form id="projectEntryForm" class="comment-form project-entry-form">
483
+ <select name="type" class="select-chevron-field" aria-label="Entry type">
484
+ <option value="decision">Decision</option>
485
+ <option value="lesson">Lesson</option>
486
+ </select>
487
+ <input name="title" placeholder="Entry title..." required />
488
+ <textarea name="body" placeholder="Decision rationale or lesson: do X instead of Y when working with Z." required></textarea>
489
+ <button type="submit">Add Entry</button>
490
+ </form>
491
+ </div>
492
+ `;
493
+ }
494
+
495
+ function bindProjectTabHandlers(context, tab) {
496
+ const project = context.board;
497
+
498
+ if (tab === "repository") {
499
+ const exportSnapshot = async () => {
500
+ const snapshot = await api(`/api/boards/${project.id}/export`);
501
+ downloadJson(`${project.slug}.orbit.json`, snapshot);
502
+ };
503
+
504
+ $("#exportProject")?.addEventListener("click", async () => {
505
+ await exportSnapshot();
506
+ });
507
+
508
+ $("#importSnapshotFile")?.addEventListener("change", async (event) => {
509
+ const file = event.currentTarget.files?.[0];
510
+ if (!file) return;
511
+ const text = await file.text();
512
+ const snapshot = JSON.parse(text);
513
+ await api("/api/admin/import", {
514
+ method: "POST",
515
+ body: { snapshot, replace_existing: true, board_id: project.id }
516
+ });
517
+ await load();
518
+ state.detailMode = "settings";
519
+ toast.success("Board imported");
520
+ event.currentTarget.value = "";
521
+ });
522
+
523
+ $("#deleteBoardStart")?.addEventListener("click", async () => {
524
+ repositoryDelete.step = "choice";
525
+ repositoryDelete.exported = false;
526
+ await renderProjectDetail();
527
+ });
528
+
529
+ $("#deleteBoardExportFirst")?.addEventListener("click", async () => {
530
+ await exportSnapshot();
531
+ repositoryDelete.step = "confirm";
532
+ repositoryDelete.exported = true;
533
+ await renderProjectDetail();
534
+ });
535
+
536
+ $("#deleteBoardSkipExport")?.addEventListener("click", async () => {
537
+ repositoryDelete.step = "confirm";
538
+ repositoryDelete.exported = false;
539
+ await renderProjectDetail();
540
+ });
541
+
542
+ $("#deleteBoardCancel")?.addEventListener("click", async () => {
543
+ repositoryDelete.step = "idle";
544
+ repositoryDelete.exported = false;
545
+ await renderProjectDetail();
546
+ });
547
+
548
+ const confirmInput = $("#deleteBoardConfirmInput");
549
+ const confirmButton = $("#deleteBoardConfirm");
550
+ confirmInput?.addEventListener("input", () => {
551
+ if (confirmButton) confirmButton.disabled = confirmInput.value.trim() !== project.slug;
552
+ });
553
+ confirmButton?.addEventListener("click", async () => {
554
+ await api(`/api/boards/${encodeURIComponent(project.id)}`, {
555
+ method: "DELETE",
556
+ body: { confirm_slug: project.slug, delete_files: true }
557
+ });
558
+ repositoryDelete.step = "idle";
559
+ repositoryDelete.exported = false;
560
+ await load();
561
+ toast.success(`Deleted board: ${project.name}`);
562
+ navigate({ boardId: state.boardId, view: "board" }, { replace: true });
563
+ });
564
+ }
565
+
566
+ if (tab === "notes") {
567
+ $("#notesSettingsForm")?.addEventListener("submit", async (event) => {
568
+ event.preventDefault();
569
+ const form = new FormData(event.currentTarget);
570
+ await api(`/api/boards/${project.id}`, {
571
+ method: "PATCH",
572
+ body: { project_notes: String(form.get("project_notes") ?? "") }
573
+ });
574
+ await load();
575
+ state.detailMode = "settings";
576
+ toast.success("Saved");
577
+ });
578
+ }
579
+
580
+ if (tab === "lanes") {
581
+ drawerInner.querySelectorAll("[data-lane-action]").forEach((button) => {
582
+ button.addEventListener("click", async () => {
583
+ await handleLaneAction(button);
584
+ });
585
+ });
586
+
587
+ drawerInner.querySelectorAll(".lane-row input, .lane-row select").forEach((field) => {
588
+ field.addEventListener("change", async () => {
589
+ await updateLane(field.closest(".lane-row"));
590
+ });
591
+ });
592
+
593
+ $("#laneCreateForm")?.addEventListener("submit", async (event) => {
594
+ event.preventDefault();
595
+ const form = new FormData(event.currentTarget);
596
+ await api(`/api/boards/${project.id}/states`, {
597
+ method: "POST",
598
+ body: Object.fromEntries(form.entries())
599
+ });
600
+ await load();
601
+ state.detailMode = "settings";
602
+ toast.success("Lane added");
603
+ });
604
+
605
+ $("#showPriorityToggle")?.addEventListener("change", (event) => {
606
+ state.showPriority = !!event.currentTarget.checked;
607
+ localStorage.setItem("mab_show_priority", state.showPriority ? "true" : "false");
608
+ renderBoard();
609
+ });
610
+ }
611
+
612
+ if (tab === "ai") {
613
+ const updateMcpPreview = () => {
614
+ const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
615
+ const clientId = $("#mcpClientSelect")?.value || "claude-code";
616
+ const client = MCP_CLIENTS[clientId] || MCP_CLIENTS.generic;
617
+ const paths = mcpPathsFromForm();
618
+ const configText = mcpConfigText(clientId, paths);
619
+ const commandText = mcpRunCommand(osId, paths);
620
+
621
+ const osNote = drawerInner.querySelector("[data-mcp-os-note]");
622
+ if (osNote) osNote.textContent = `Detected: ${MCP_OS_OPTIONS[detectedMcpOs()]?.label || "Linux"}`;
623
+
624
+ const format = drawerInner.querySelector("[data-mcp-client-format]");
625
+ if (format) format.textContent = client.format;
626
+
627
+ const title = drawerInner.querySelector("[data-mcp-output-title]");
628
+ if (title) title.textContent = `${client.label} setup`;
629
+
630
+ const helper = drawerInner.querySelector("[data-mcp-helper]");
631
+ if (helper) helper.textContent = client.helper;
632
+
633
+ const configEl = drawerInner.querySelector("[data-mcp-config]");
634
+ if (configEl) configEl.textContent = configText;
635
+
636
+ const commandEl = drawerInner.querySelector("[data-mcp-command]");
637
+ if (commandEl) commandEl.textContent = commandText;
638
+ };
639
+
640
+ $("#mcpOsSelect")?.addEventListener("change", () => updateMcpPreview());
641
+ $("#mcpClientSelect")?.addEventListener("change", () => updateMcpPreview());
642
+ ["#mcpServerPathInput"].forEach((selector) => {
643
+ $(selector)?.addEventListener("input", () => updateMcpPreview());
644
+ });
645
+
646
+ drawerInner.querySelectorAll("[data-copy-mcp]").forEach((button) => {
647
+ button.addEventListener("click", async () => {
648
+ const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
649
+ const clientId = $("#mcpClientSelect")?.value || "claude-code";
650
+ const paths = mcpPathsFromForm();
651
+ const value = button.dataset.copyMcp === "command" ? mcpRunCommand(osId, paths) : mcpConfigText(clientId, paths);
652
+ await copyText(value);
653
+ toast.success(button.dataset.copyMcp === "command" ? "MCP command copied" : "MCP config copied");
654
+ });
655
+ });
656
+
657
+ $("#agentInstructionsForm")?.addEventListener("submit", async (event) => {
658
+ event.preventDefault();
659
+ const form = new FormData(event.currentTarget);
660
+ await api(`/api/boards/${project.id}`, {
661
+ method: "PATCH",
662
+ body: Object.fromEntries(form.entries())
663
+ });
664
+ await load();
665
+ state.detailMode = "settings";
666
+ toast.success("Saved");
667
+ });
668
+
669
+ $("#aiEnabledToggle")?.addEventListener("change", async (event) => {
670
+ const enabled = !!event.currentTarget.checked;
671
+ await api(`/api/boards/${project.id}`, {
672
+ method: "PATCH",
673
+ body: { ai_enabled: enabled }
674
+ });
675
+ await load();
676
+ state.detailMode = "settings";
677
+ toast.success(enabled ? "AI features enabled — agent lanes provisioned" : "AI features disabled");
678
+ });
679
+
680
+ }
681
+
682
+ if (tab === "archive") {
683
+ const listEl = $("#archiveList");
684
+ if (listEl) {
685
+ const refresh = async () => {
686
+ try {
687
+ const result = await api(`/api/boards/${encodeURIComponent(project.id)}/archive`);
688
+ const tickets = result.tickets || [];
689
+ if (tickets.length === 0) {
690
+ listEl.innerHTML = `<p class="description">No archived cards yet. Click the trash icon in the card detail header to archive a card.</p>`;
691
+ return;
692
+ }
693
+ listEl.innerHTML = `<div class="detail-card-grid">${tickets
694
+ .map((t) => renderDetailCard(t, { archive: { id: t.id }, disableOpen: true }))
695
+ .join("")}</div>`;
696
+ } catch (err) {
697
+ listEl.innerHTML = `<p class="description">Could not load archive: ${escapeHtml(err?.message || "unknown error")}</p>`;
698
+ }
699
+ };
700
+
701
+ refresh();
702
+
703
+ listEl.addEventListener("click", async (event) => {
704
+ const restoreBtn = event.target.closest("[data-archive-restore]");
705
+ const deleteBtn = event.target.closest("[data-archive-delete]");
706
+ if (!restoreBtn && !deleteBtn) return;
707
+ event.stopPropagation();
708
+ const card = (restoreBtn || deleteBtn).closest("article");
709
+ const cardTitle = card?.querySelector("h3")?.textContent || "card";
710
+ if (restoreBtn) {
711
+ const id = restoreBtn.dataset.archiveRestore;
712
+ await api(withBoardQuery(`/api/tickets/${encodeURIComponent(id)}/restore`), { method: "POST" });
713
+ toast.success(`Restored: ${cardTitle}`);
714
+ await load();
715
+ await refresh();
716
+ return;
717
+ }
718
+ if (deleteBtn) {
719
+ const id = deleteBtn.dataset.archiveDelete;
720
+ if (!confirm(`Permanently delete "${cardTitle}"? This cannot be undone.`)) return;
721
+ await api(withBoardQuery(`/api/tickets/${encodeURIComponent(id)}`), { method: "DELETE" });
722
+ toast.success(`Deleted: ${cardTitle}`);
723
+ await refresh();
724
+ }
725
+ });
726
+ }
727
+ }
728
+
729
+ if (tab === "journal") {
730
+ drawerInner.querySelectorAll("[data-entry-action='toggle-struck']").forEach((button) => {
731
+ button.addEventListener("click", async () => {
732
+ await api(withBoardQuery(`/api/board-entries/${button.dataset.entryId}`), {
733
+ method: "PATCH",
734
+ body: { struck: button.dataset.struck !== "true" }
735
+ });
736
+ await load();
737
+ state.detailMode = "settings";
738
+ toast.info(button.dataset.struck === "true" ? "Project entry restored" : "Project entry struck");
739
+ });
740
+ });
741
+
742
+ $("#projectEntryForm")?.addEventListener("submit", async (event) => {
743
+ event.preventDefault();
744
+ const form = new FormData(event.currentTarget);
745
+ await api(`/api/boards/${project.id}/entries`, {
746
+ method: "POST",
747
+ body: Object.fromEntries(form.entries())
748
+ });
749
+ await load();
750
+ state.detailMode = "settings";
751
+ toast.success("Project entry added");
752
+ });
753
+ }
754
+ }
755
+
756
+ async function copyText(value) {
757
+ if (navigator.clipboard?.writeText) {
758
+ await navigator.clipboard.writeText(value);
759
+ return;
760
+ }
761
+
762
+ const textArea = document.createElement("textarea");
763
+ textArea.value = value;
764
+ textArea.setAttribute("readonly", "");
765
+ textArea.style.position = "fixed";
766
+ textArea.style.left = "-9999px";
767
+ document.body.append(textArea);
768
+ textArea.select();
769
+ document.execCommand("copy");
770
+ textArea.remove();
771
+ }
772
+
773
+ function renderProjectEntries(entries = []) {
774
+ if (!entries.length) return `<p class="description">No project entries yet.</p>`;
775
+ return entries
776
+ .map(
777
+ (entry) => {
778
+ const isStruck = Boolean(entry.struck_at);
779
+ return `
780
+ <div class="comment project-entry ${escapeHtml(entry.type)} ${isStruck ? "is-struck" : ""}">
781
+ <div class="comment-meta">
782
+ <div>
783
+ <strong>${escapeHtml(entry.type.toUpperCase())}: ${escapeHtml(entry.title)}</strong>
784
+ ${isStruck ? `<span class="project-entry-status">Excluded from agent context</span>` : ""}
785
+ </div>
786
+ <div class="project-entry-actions">
787
+ <span>${escapeHtml(entry.created_by)} - ${formatDate(entry.created_at)}</span>
788
+ <button
789
+ type="button"
790
+ class="ghost project-entry-strike"
791
+ data-entry-action="toggle-struck"
792
+ data-entry-id="${escapeHtml(entry.id)}"
793
+ data-struck="${isStruck ? "true" : "false"}"
794
+ aria-pressed="${isStruck ? "true" : "false"}"
795
+ >${isStruck ? "Restore" : "Strike"}</button>
796
+ </div>
797
+ </div>
798
+ <div class="comment-body">${escapeHtml(entry.body)}</div>
799
+ </div>
800
+ `;
801
+ }
802
+ )
803
+ .join("");
804
+ }
805
+
806
+ // In Progress and Review are core kanban lanes — undeletable regardless of
807
+ // AI. AI Ready is only locked while AI is enabled; turn AI off to remove it.
808
+ const CORE_LANE_ROLES = new Set(["in_progress", "review"]);
809
+
810
+ /** A lane can't be deleted if it has cards (orphan risk), if it's the default
811
+ * "new cards land here" lane (every project must keep exactly one), if it's
812
+ * a core kanban lane (in_progress / review), or if it's the AI Ready lane
813
+ * while AI is enabled. */
814
+ function deleteDisabled(lane, counts, aiEnabled) {
815
+ if (counts.get(lane.id)) return true;
816
+ if (lane.is_default) return true;
817
+ if (CORE_LANE_ROLES.has(lane.role)) return true;
818
+ if (aiEnabled && lane.role === "ai_ready") return true;
819
+ return false;
820
+ }
821
+
822
+ function deleteTitle(lane, counts, aiEnabled) {
823
+ const count = counts.get(lane.id) || 0;
824
+ if (count > 0) {
825
+ return `Cannot delete — ${count} card${count === 1 ? "" : "s"} in this lane`;
826
+ }
827
+ if (lane.is_default) {
828
+ return "Cannot delete — default lane for new cards (move it first)";
829
+ }
830
+ if (CORE_LANE_ROLES.has(lane.role)) {
831
+ return "Cannot delete — core kanban lane";
832
+ }
833
+ if (aiEnabled && lane.role === "ai_ready") {
834
+ return "Cannot delete — required by agent flow (disable AI in Settings → AI to remove)";
835
+ }
836
+ return "Delete lane";
837
+ }
838
+
839
+ function renderLaneManager() {
840
+ const lanes = statesForProject();
841
+ const aiEnabled = currentBoard()?.ai_enabled !== 0;
842
+ const counts = new Map();
843
+ for (const ticket of ticketsForProject()) {
844
+ counts.set(ticket.state_id, (counts.get(ticket.state_id) || 0) + 1);
845
+ }
846
+
847
+ return `
848
+ <div class="lane-list">
849
+ ${lanes
850
+ .map((lane, index) => {
851
+ const isAiReady = lane.role === "ai_ready";
852
+ const nameTitle = isAiReady
853
+ ? "AI Ready lane name is locked — toggle from Settings → AI to remove this column"
854
+ : "";
855
+ return `
856
+ <div class="lane-row${isAiReady ? " lane-row--locked" : ""}" data-lane-id="${lane.id}">
857
+ <button type="button" class="lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
858
+ <button type="button" class="lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
859
+ <input name="name" value="${escapeHtml(lane.name)}" aria-label="Lane name" ${isAiReady ? `readonly title="${escapeHtml(nameTitle)}"` : ""} />
860
+ <button type="button" class="lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
861
+ <span class="lane-new-sun" aria-hidden="true"></span>
862
+ <span class="lane-new-label">New</span>
863
+ </button>
864
+ <button type="button" class="lane-delete-btn" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">×</button>
865
+ </div>
866
+ `;
867
+ })
868
+ .join("")}
869
+ </div>
870
+ `;
871
+ }
872
+
873
+ async function handleLaneAction(button) {
874
+ const row = button.closest(".lane-row");
875
+ const laneId = row.dataset.laneId;
876
+ const action = button.dataset.laneAction;
877
+ if (action === "delete") {
878
+ await api(withBoardQuery(`/api/states/${laneId}`), { method: "DELETE" });
879
+ await load();
880
+ state.detailMode = "settings";
881
+ toast.success("Lane deleted");
882
+ return;
883
+ }
884
+
885
+ if (action === "toggle-default") {
886
+ // Radio-style: one lane is always the default. Clicking the current
887
+ // default is a no-op — to move it you click a different lane. This
888
+ // guarantees new-card creation always has somewhere to land.
889
+ const wasOn = button.getAttribute("aria-pressed") === "true";
890
+ if (wasOn) return;
891
+ drawerInner.querySelectorAll('[data-lane-action="toggle-default"]').forEach((btn) => {
892
+ btn.setAttribute("aria-pressed", "false");
893
+ btn.classList.remove("is-on");
894
+ });
895
+ button.setAttribute("aria-pressed", "true");
896
+ button.classList.add("is-on");
897
+ await updateLane(row, { silent: true });
898
+ toast.success("Default lane updated");
899
+ return;
900
+ }
901
+
902
+ const lanes = statesForProject().map((lane) => lane.id);
903
+ const index = lanes.indexOf(laneId);
904
+ const swapWith = action === "up" ? index - 1 : index + 1;
905
+ if (swapWith < 0 || swapWith >= lanes.length) return;
906
+ [lanes[index], lanes[swapWith]] = [lanes[swapWith], lanes[index]];
907
+ await api(`/api/boards/${state.boardId}/states`, {
908
+ method: "PATCH",
909
+ body: { state_ids: lanes }
910
+ });
911
+ await load();
912
+ state.detailMode = "settings";
913
+ toast.success("Lanes reordered");
914
+ }
915
+
916
+ async function updateLane(row, { silent = false } = {}) {
917
+ const name = row.querySelector('input[name="name"]').value;
918
+ const toggle = row.querySelector('[data-lane-action="toggle-default"]');
919
+ const isDefault = toggle?.getAttribute("aria-pressed") === "true";
920
+
921
+ await api(withBoardQuery(`/api/states/${row.dataset.laneId}`), {
922
+ method: "PATCH",
923
+ body: {
924
+ name,
925
+ is_default: isDefault
926
+ }
927
+ });
928
+ await load();
929
+ state.detailMode = "settings";
930
+ if (!silent) toast.success("Lane updated");
931
+ }