@starlove/orbit 1.1.2 → 1.1.11

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 (103) hide show
  1. package/.dockerignore +0 -0
  2. package/Dockerfile +0 -0
  3. package/LICENSE +0 -0
  4. package/README.md +0 -0
  5. package/SKILL-ORBIT.md +2 -0
  6. package/build.mjs +0 -0
  7. package/dist/full/apple-touch-icon.png +0 -0
  8. package/dist/full/cursors/live_arrow.cur +0 -0
  9. package/dist/full/cursors/live_link.cur +0 -0
  10. package/dist/full/cursors/live_move.cur +0 -0
  11. package/dist/full/cursors/live_select.cur +0 -0
  12. package/dist/full/cursors/live_unavail.cur +0 -0
  13. package/dist/full/favicon-32x32.png +0 -0
  14. package/dist/full/index.html +160 -15
  15. package/dist/full/js/app.55b38e12.js +698 -0
  16. package/dist/full/js/app.55b38e12.js.map +7 -0
  17. package/dist/full/orbits-favicon.png +0 -0
  18. package/dist/full/orbits-logo-bg.png +0 -0
  19. package/dist/full/orbits-logo.png +0 -0
  20. package/dist/full/{styles.836872e7.css → styles.273fc37a.css} +79 -39
  21. package/dist/full/version.json +2 -2
  22. package/docs/AGENT_PROTOCOL.md +0 -0
  23. package/docs/MCP_SETUP.md +0 -0
  24. package/docs/ORBIT_DISPATCH.md +68 -8
  25. package/docs/SS-Orbit-Dark.png +0 -0
  26. package/docs/SS-Orbit-Light.png +0 -0
  27. package/docs/mcp-client.example.json +0 -0
  28. package/docs/setup/orbit-init-wizard.md +0 -0
  29. package/package.json +1 -1
  30. package/public/apple-touch-icon.png +0 -0
  31. package/public/cursors/live_arrow.cur +0 -0
  32. package/public/cursors/live_link.cur +0 -0
  33. package/public/cursors/live_move.cur +0 -0
  34. package/public/cursors/live_select.cur +0 -0
  35. package/public/cursors/live_unavail.cur +0 -0
  36. package/public/favicon-32x32.png +0 -0
  37. package/public/index.html +158 -13
  38. package/public/js/actor-labels.js +0 -0
  39. package/public/js/api.js +0 -0
  40. package/public/js/app.js +0 -0
  41. package/public/js/board-menu.js +0 -0
  42. package/public/js/card-actions.js +19 -0
  43. package/public/js/config.js +0 -0
  44. package/public/js/create-card.js +0 -0
  45. package/public/js/dom.js +0 -0
  46. package/public/js/drawer.js +17 -43
  47. package/public/js/format.js +5 -5
  48. package/public/js/kanban.js +157 -92
  49. package/public/js/local-backend.js +0 -0
  50. package/public/js/main.js +2 -1
  51. package/public/js/modal.js +0 -0
  52. package/public/js/motion-preference.js +14 -3
  53. package/public/js/router.js +0 -0
  54. package/public/js/search.js +0 -0
  55. package/public/js/settings-tabs.js +0 -0
  56. package/public/js/settings.js +131 -27
  57. package/public/js/sse.js +0 -0
  58. package/public/js/starscape.js +0 -0
  59. package/public/js/state.js +0 -0
  60. package/public/js/theme-preference.js +0 -0
  61. package/public/js/ticket-detail.js +88 -20
  62. package/public/js/toast.js +0 -0
  63. package/public/js/transport.js +0 -0
  64. package/public/js/unread.js +0 -0
  65. package/public/js/url-routes.js +0 -0
  66. package/public/orbits-favicon.png +0 -0
  67. package/public/orbits-logo-bg.png +0 -0
  68. package/public/orbits-logo.png +0 -0
  69. package/public/styles.css +79 -39
  70. package/src/cli/orbit.js +22 -0
  71. package/src/core/agent.js +0 -0
  72. package/src/core/agents-md.js +0 -0
  73. package/src/core/attachments.js +0 -0
  74. package/src/core/auth.js +0 -0
  75. package/src/core/backups.js +0 -0
  76. package/src/core/board.js +1 -0
  77. package/src/core/boards.js +0 -0
  78. package/src/core/bootstrap.js +0 -0
  79. package/src/core/db.js +19 -0
  80. package/src/core/delete-board.js +0 -0
  81. package/src/core/dispatch.js +176 -11
  82. package/src/core/events.js +0 -0
  83. package/src/core/http.js +0 -0
  84. package/src/core/migrate.js +0 -0
  85. package/src/core/paths.js +0 -0
  86. package/src/core/provision-repo-board.js +0 -0
  87. package/src/core/queries.js +0 -0
  88. package/src/core/registry.js +2 -2
  89. package/src/core/relations.js +0 -0
  90. package/src/core/review-verdicts.js +138 -0
  91. package/src/core/router.js +32 -3
  92. package/src/core/search.js +0 -0
  93. package/src/core/seed.js +0 -0
  94. package/src/core/snapshots.js +429 -9
  95. package/src/core/states.js +0 -0
  96. package/src/core/system-picker.js +0 -0
  97. package/src/core/tickets.js +0 -0
  98. package/src/core/util.js +0 -0
  99. package/src/mcp/orbit-client.js +45 -19
  100. package/src/mcp-server.js +102 -27
  101. package/src/server.js +0 -0
  102. package/dist/full/js/app.a4f045fc.js +0 -676
  103. package/dist/full/js/app.a4f045fc.js.map +0 -7
File without changes
File without changes
File without changes
@@ -39,15 +39,15 @@ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
39
39
  * cache; otherwise leave the board DOM alone.
40
40
  */
41
41
  async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
42
- state.selectedTicketId = ticketId;
43
- state.detailMode = "ticket";
44
42
  const [context, statusHistory, commentsResult] = await Promise.all([
45
43
  api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
46
44
  api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
47
45
  api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
48
46
  ]);
47
+ if (context.ticket?.id !== ticketId) return;
49
48
  upsertTicket(context.ticket);
50
49
  if (renderBoardAfter) renderBoard();
50
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId) return;
51
51
  await renderDetail({ context, statusHistory, comments: commentsResult.comments || [] });
52
52
  }
53
53
 
@@ -71,11 +71,62 @@ async function patchTicket(ticketId, body) {
71
71
  * - initialValue: text shown in the editor on open.
72
72
  * - ariaLabel: full label for screen readers.
73
73
  * - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
74
+ * - sourceEvent: optional pointer/click event used to seed the textarea caret near the clicked text.
74
75
  * - rerender: async fn called on cancel / no-change to put the static node back.
75
76
  * - commit: async fn called with the new value when the user accepts a real change.
76
77
  * Receives the *raw* editor value; the caller is responsible for any
77
78
  * trimming, cleanText, or PATCH wiring.
78
79
  */
80
+ function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
81
+ if (!node || !sourceEvent || typeof sourceEvent.clientX !== "number" || typeof sourceEvent.clientY !== "number") return null;
82
+
83
+ let range = null;
84
+ if (document.caretPositionFromPoint) {
85
+ const position = document.caretPositionFromPoint(sourceEvent.clientX, sourceEvent.clientY);
86
+ if (position?.offsetNode && (position.offsetNode === node || node.contains(position.offsetNode))) {
87
+ range = document.createRange();
88
+ range.setStart(node, 0);
89
+ range.setEnd(position.offsetNode, position.offset);
90
+ }
91
+ } else if (document.caretRangeFromPoint) {
92
+ const caretRange = document.caretRangeFromPoint(sourceEvent.clientX, sourceEvent.clientY);
93
+ if (caretRange?.startContainer && (caretRange.startContainer === node || node.contains(caretRange.startContainer))) {
94
+ range = document.createRange();
95
+ range.setStart(node, 0);
96
+ range.setEnd(caretRange.startContainer, caretRange.startOffset);
97
+ }
98
+ }
99
+
100
+ if (!range) return null;
101
+ return Math.max(0, Math.min(maxLength, range.toString().length));
102
+ }
103
+
104
+ function captureInlineEditScrollState(node) {
105
+ const scrollContainer = node?.closest?.(".drawer-inner");
106
+ if (!scrollContainer) return null;
107
+ return {
108
+ scrollContainer,
109
+ nodeRect: node.getBoundingClientRect(),
110
+ scrollTop: scrollContainer.scrollTop
111
+ };
112
+ }
113
+
114
+ function restoreInlineEditScroll(scrollState, editor) {
115
+ if (!scrollState?.scrollContainer || !editor?.isConnected) return;
116
+ const editorRect = editor.getBoundingClientRect();
117
+ const nextScrollTop = Math.max(0, scrollState.scrollContainer.scrollTop + (editorRect.top - scrollState.nodeRect.top));
118
+ if (Number.isFinite(nextScrollTop)) scrollState.scrollContainer.scrollTop = nextScrollTop;
119
+ else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
120
+ }
121
+
122
+ function focusWithoutScrolling(editor) {
123
+ try {
124
+ editor.focus({ preventScroll: true });
125
+ } catch {
126
+ editor.focus();
127
+ }
128
+ }
129
+
79
130
  export function startInlineEdit(node, opts) {
80
131
  if (!node || node.dataset.editing === "true") return;
81
132
  const {
@@ -84,15 +135,20 @@ export function startInlineEdit(node, opts) {
84
135
  initialValue = "",
85
136
  ariaLabel,
86
137
  emptyMessage,
138
+ sourceEvent,
87
139
  rerender,
88
140
  commit: commitHandler
89
141
  } = opts;
90
142
 
143
+ const scrollState = multiline ? captureInlineEditScrollState(node) : null;
144
+ const initialString = String(initialValue);
145
+ const clickedCaretOffset = multiline ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length) : null;
146
+
91
147
  node.dataset.editing = "true";
92
148
 
93
149
  const editor = document.createElement(multiline ? "textarea" : "input");
94
150
  editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
95
- editor.value = initialValue;
151
+ editor.value = initialString;
96
152
  if (fieldName) editor.setAttribute("data-editing-field", fieldName);
97
153
  if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
98
154
  if (!multiline) {
@@ -101,13 +157,22 @@ export function startInlineEdit(node, opts) {
101
157
  editor.autocomplete = "off";
102
158
  editor.spellcheck = true;
103
159
  } else {
104
- editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
160
+ editor.rows = Math.max(3, initialString.split("\n").length + 1);
161
+ const sourceHeight = node.getBoundingClientRect().height;
162
+ if (Number.isFinite(sourceHeight) && sourceHeight > 0) {
163
+ editor.style.minHeight = `${Math.ceil(sourceHeight)}px`;
164
+ }
105
165
  }
106
166
 
107
167
  node.replaceWith(editor);
108
- editor.focus();
109
- if (!multiline) editor.select();
110
- else editor.setSelectionRange(editor.value.length, editor.value.length);
168
+ focusWithoutScrolling(editor);
169
+ if (!multiline) {
170
+ editor.select();
171
+ } else {
172
+ const selectionStart = clickedCaretOffset ?? 0;
173
+ editor.setSelectionRange(selectionStart, selectionStart);
174
+ restoreInlineEditScroll(scrollState, editor);
175
+ }
111
176
 
112
177
  let done = false;
113
178
 
@@ -115,11 +180,9 @@ export function startInlineEdit(node, opts) {
115
180
  document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
116
181
  };
117
182
 
118
- // The title row uses a persistent shell element (#drawerTitle) that
119
- // renderDrawerShell re-queries by id on every render. Leaving the editor in
120
- // its place means the next render finds nothing, throws, and the input is
121
- // stranded on screen until a full reload. Put the original node back before
122
- // any downstream render runs.
183
+ // If a downstream render rebuilds the title block while the editor is open,
184
+ // the editor is dropped along with everything else; only restore the saved
185
+ // node when our editor is still connected to the DOM.
123
186
  const restoreNode = () => {
124
187
  if (editor.isConnected) editor.replaceWith(node);
125
188
  delete node.dataset.editing;
@@ -209,12 +272,13 @@ function wireTicketDetailEditors(ticket) {
209
272
  }
210
273
 
211
274
  const descEl = drawerInner.querySelector('[data-edit-field="description"]');
212
- const editDescription = () =>
275
+ const editDescription = (sourceEvent) =>
213
276
  startInlineEdit(descEl, {
214
277
  fieldName: "description",
215
278
  multiline: true,
216
279
  initialValue: ticket.description || "",
217
280
  ariaLabel: "Edit ticket description",
281
+ sourceEvent,
218
282
  rerender: () => renderDetail(),
219
283
  commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
220
284
  });
@@ -222,7 +286,7 @@ function wireTicketDetailEditors(ticket) {
222
286
  if (descEl) {
223
287
  descEl.addEventListener("click", (event) => {
224
288
  event.preventDefault();
225
- editDescription();
289
+ editDescription(event);
226
290
  });
227
291
  descEl.addEventListener("keydown", (event) => {
228
292
  if (event.key === "Enter" || event.key === " ") {
@@ -245,18 +309,19 @@ function wireTicketDetailEditors(ticket) {
245
309
  for (const { key, label } of AI_FIELDS) {
246
310
  const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
247
311
  if (!fieldEl) continue;
248
- const edit = () =>
312
+ const edit = (sourceEvent) =>
249
313
  startInlineEdit(fieldEl, {
250
314
  fieldName: key,
251
315
  multiline: true,
252
316
  initialValue: ticket[key] || "",
253
317
  ariaLabel: `Edit ${label}`,
318
+ sourceEvent,
254
319
  rerender: () => renderDetail(),
255
320
  commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
256
321
  });
257
322
  fieldEl.addEventListener("click", (event) => {
258
323
  event.preventDefault();
259
- edit();
324
+ edit(event);
260
325
  });
261
326
  fieldEl.addEventListener("keydown", (event) => {
262
327
  if (event.key === "Enter" || event.key === " ") {
@@ -337,23 +402,26 @@ export async function renderDetail(options = {}) {
337
402
  return;
338
403
  }
339
404
 
405
+ const requestedTicketId = state.selectedTicketId;
340
406
  const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
341
407
  const [context, statusHistory, comments, attachmentList] = await Promise.all([
342
408
  hasOption("context")
343
409
  ? Promise.resolve(options.context)
344
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
410
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`)),
345
411
  hasOption("statusHistory")
346
412
  ? Promise.resolve(options.statusHistory)
347
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`)),
413
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
348
414
  hasOption("comments")
349
415
  ? Promise.resolve(options.comments)
350
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/comments`)).then((result) => result.comments || []),
416
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then((result) => result.comments || []),
351
417
  hasOption("attachmentList")
352
418
  ? Promise.resolve(options.attachmentList)
353
419
  : features.attachments
354
- ? api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
420
+ ? api(withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
355
421
  : Promise.resolve({ attachments: [] })
356
422
  ]);
423
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== requestedTicketId) return;
424
+ if (context.ticket?.id !== requestedTicketId) return;
357
425
  const ticket = context.ticket;
358
426
  // Acknowledge the read receipt: clear the unread dot on this card.
359
427
  // Also wipe the dot from the already-rendered card in the board so the
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/public/styles.css CHANGED
@@ -321,7 +321,7 @@ textarea:focus {
321
321
  }
322
322
 
323
323
  @media (prefers-reduced-motion: reduce) {
324
- [data-theme="dark"] .topbar {
324
+ :root:not([data-motion="full"])[data-theme="dark"] .topbar {
325
325
  transition: none;
326
326
  }
327
327
  }
@@ -1037,8 +1037,8 @@ h1 {
1037
1037
  }
1038
1038
 
1039
1039
  @media (prefers-reduced-motion: reduce) {
1040
- .create-flyout,
1041
- .create-flyout-backdrop {
1040
+ :root:not([data-motion="full"]) .create-flyout,
1041
+ :root:not([data-motion="full"]) .create-flyout-backdrop {
1042
1042
  transition: none;
1043
1043
  }
1044
1044
  }
@@ -1347,7 +1347,7 @@ h1 {
1347
1347
  }
1348
1348
 
1349
1349
  @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: reduce) {
1350
- .add-card-phantom {
1350
+ :root:not([data-motion="full"]) .add-card-phantom {
1351
1351
  transition:
1352
1352
  color 160ms ease,
1353
1353
  border-color 160ms ease,
@@ -1402,7 +1402,7 @@ h1 {
1402
1402
  }
1403
1403
 
1404
1404
  @media (prefers-reduced-motion: reduce) {
1405
- .agent-dot {
1405
+ :root:not([data-motion="full"]) .agent-dot {
1406
1406
  animation: none;
1407
1407
  }
1408
1408
  }
@@ -1443,7 +1443,7 @@ h1 {
1443
1443
  }
1444
1444
 
1445
1445
  @media (prefers-reduced-motion: reduce) {
1446
- .card-unread-dot {
1446
+ :root:not([data-motion="full"]) .card-unread-dot {
1447
1447
  animation: none;
1448
1448
  }
1449
1449
  }
@@ -1691,7 +1691,7 @@ h1 {
1691
1691
  }
1692
1692
 
1693
1693
  @media (prefers-reduced-motion: reduce) {
1694
- .card {
1694
+ :root:not([data-motion="full"]) .card {
1695
1695
  animation: none;
1696
1696
  }
1697
1697
  }
@@ -1823,8 +1823,10 @@ h1 {
1823
1823
  [data-theme="dark"] .card {
1824
1824
  border-color: rgba(var(--starlight-rgb),0.12);
1825
1825
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
1826
- backdrop-filter: blur(16px);
1827
- -webkit-backdrop-filter: blur(16px);
1826
+ /* No backdrop-filter: --card is 85% opaque so the blur showed through ~15%
1827
+ (near-invisible) yet forced every card to re-blur the animated starfield
1828
+ behind it on every frame — the page's biggest GPU cost. The opaque fill
1829
+ carries the surface; the starfield still animates in the gaps. */
1828
1830
  }
1829
1831
 
1830
1832
  [data-theme="dark"] .card.type-epic {
@@ -2134,7 +2136,7 @@ h1 {
2134
2136
  }
2135
2137
 
2136
2138
  @media (prefers-reduced-motion: reduce) {
2137
- .priority-pill-urgent {
2139
+ :root:not([data-motion="full"]) .priority-pill-urgent {
2138
2140
  animation: none;
2139
2141
  }
2140
2142
  }
@@ -2165,11 +2167,17 @@ h1 {
2165
2167
  }
2166
2168
 
2167
2169
  @media (prefers-reduced-motion: no-preference) {
2168
- [data-theme="dark"] .priority-pill-urgent {
2170
+ :root:not([data-motion="reduce"])[data-theme="dark"] .priority-pill-urgent {
2169
2171
  box-shadow: 0 0 0 1px rgba(var(--coral-rgb),0.35), 0 4px 10px rgba(var(--coral-rgb),0.3);
2170
2172
  }
2171
2173
  }
2172
2174
 
2175
+ /* Full-motion escape hatch: show the urgent glow even when the OS prefers
2176
+ reduced motion but the user forced full motion. */
2177
+ :root[data-motion="full"][data-theme="dark"] .priority-pill-urgent {
2178
+ box-shadow: 0 0 0 1px rgba(var(--coral-rgb),0.35), 0 4px 10px rgba(var(--coral-rgb),0.3);
2179
+ }
2180
+
2173
2181
  .card-expand-trigger {
2174
2182
  display: flex;
2175
2183
  align-items: center;
@@ -2339,8 +2347,8 @@ h1 {
2339
2347
  }
2340
2348
 
2341
2349
  @media (prefers-reduced-motion: reduce) {
2342
- .drawer,
2343
- .drawer-backdrop {
2350
+ :root:not([data-motion="full"]) .drawer,
2351
+ :root:not([data-motion="full"]) .drawer-backdrop {
2344
2352
  transition: none;
2345
2353
  }
2346
2354
  }
@@ -3671,16 +3679,13 @@ details.section > summary .mcp-title-row {
3671
3679
  min-height: 2rem;
3672
3680
  }
3673
3681
 
3674
- /* Board Notes is the long-form home for project guidance; reserve a textarea
3675
- sized landing area so it doesn't collapse to a one-liner when empty. */
3676
- .settings-notes-body {
3682
+ /* Long-form settings fields reserve textarea-sized landing areas so they don't
3683
+ collapse to one-liners when empty. */
3684
+ .settings-notes-body,
3685
+ .settings-agent-instructions-body {
3677
3686
  min-height: 11rem;
3678
3687
  }
3679
3688
 
3680
- .ai-context-setup textarea[name="agent_instructions"] {
3681
- min-height: 9rem;
3682
- }
3683
-
3684
3689
  .ai-context-setup > .description {
3685
3690
  margin-bottom: 0.7rem;
3686
3691
  }
@@ -5170,8 +5175,8 @@ button:disabled,
5170
5175
  .toast-error .toast-icon { color: #ef4444; }
5171
5176
 
5172
5177
  @media (prefers-reduced-motion: reduce) {
5173
- .toast-enter { animation: none; opacity: 1; transform: none; }
5174
- .toast-exit { animation: none; }
5178
+ :root:not([data-motion="full"]) .toast-enter { animation: none; opacity: 1; transform: none; }
5179
+ :root:not([data-motion="full"]) .toast-exit { animation: none; }
5175
5180
  }
5176
5181
 
5177
5182
  [data-theme="dark"] .toast {
@@ -5332,8 +5337,8 @@ button:disabled,
5332
5337
  }
5333
5338
 
5334
5339
  @media (prefers-reduced-motion: reduce) {
5335
- .orbit-dialog,
5336
- .orbit-dialog::backdrop {
5340
+ :root:not([data-motion="full"]) .orbit-dialog,
5341
+ :root:not([data-motion="full"]) .orbit-dialog::backdrop {
5337
5342
  animation: none;
5338
5343
  }
5339
5344
  }
@@ -5669,40 +5674,75 @@ button:disabled,
5669
5674
  }
5670
5675
  }
5671
5676
 
5677
+ /* Reduced motion — Starscape v3 `data-motion` contract (migrated from the
5678
+ * legacy `data-reduced-motion="reduce|allow"`):
5679
+ * • OS path: reduce when the OS asks, unless the user forces full motion.
5680
+ * • Explicit path: `data-motion="reduce"` reduces regardless of OS.
5681
+ * • `data-motion="full"` opts out of the OS path (full motion even on OS reduce). */
5672
5682
  @media (prefers-reduced-motion: reduce) {
5673
- [data-theme="dark"] .starfield,
5674
- [data-theme="dark"] .milky-way,
5675
- [data-theme="dark"] .grain::before,
5676
- [data-theme="dark"] .grain::after,
5677
- [data-theme="dark"] .signature-star {
5683
+ :root:not([data-motion="full"]) *,
5684
+ :root:not([data-motion="full"]) *::before,
5685
+ :root:not([data-motion="full"]) *::after {
5686
+ scroll-behavior: auto !important;
5687
+ transition-duration: 0.01ms !important;
5688
+ animation-duration: 0.01ms !important;
5689
+ animation-iteration-count: 1 !important;
5690
+ }
5691
+
5692
+ :root:not([data-motion="full"])[data-theme="dark"] .starfield,
5693
+ :root:not([data-motion="full"])[data-theme="dark"] .milky-way,
5694
+ :root:not([data-motion="full"])[data-theme="dark"] .grain::before,
5695
+ :root:not([data-motion="full"])[data-theme="dark"] .grain::after,
5696
+ :root:not([data-motion="full"])[data-theme="dark"] .signature-star {
5678
5697
  animation: none;
5679
5698
  }
5680
- [data-theme="dark"] .meteor {
5699
+
5700
+ :root:not([data-motion="full"])[data-theme="dark"] .meteor {
5681
5701
  display: none;
5682
5702
  }
5683
5703
  }
5684
5704
 
5685
- [data-reduced-motion="reduce"] *,
5686
- [data-reduced-motion="reduce"] *::before,
5687
- [data-reduced-motion="reduce"] *::after {
5705
+ [data-motion="reduce"] *,
5706
+ [data-motion="reduce"] *::before,
5707
+ [data-motion="reduce"] *::after {
5688
5708
  scroll-behavior: auto !important;
5689
5709
  transition-duration: 0.01ms !important;
5690
5710
  animation-duration: 0.01ms !important;
5691
5711
  animation-iteration-count: 1 !important;
5692
5712
  }
5693
5713
 
5694
- [data-reduced-motion="reduce"][data-theme="dark"] .starfield,
5695
- [data-reduced-motion="reduce"][data-theme="dark"] .milky-way,
5696
- [data-reduced-motion="reduce"][data-theme="dark"] .grain::before,
5697
- [data-reduced-motion="reduce"][data-theme="dark"] .grain::after,
5698
- [data-reduced-motion="reduce"][data-theme="dark"] .signature-star {
5714
+ [data-motion="reduce"][data-theme="dark"] .starfield,
5715
+ [data-motion="reduce"][data-theme="dark"] .milky-way,
5716
+ [data-motion="reduce"][data-theme="dark"] .grain::before,
5717
+ [data-motion="reduce"][data-theme="dark"] .grain::after,
5718
+ [data-motion="reduce"][data-theme="dark"] .signature-star {
5699
5719
  animation: none;
5700
5720
  }
5701
5721
 
5702
- [data-reduced-motion="reduce"][data-theme="dark"] .meteor {
5722
+ [data-motion="reduce"][data-theme="dark"] .meteor {
5703
5723
  display: none;
5704
5724
  }
5705
5725
 
5726
+ /* Empty-lane icon micro-motion — a low-amplitude exception to the OS reduced-
5727
+ * motion reset above. These are small (64px), slow, localized loops, not the
5728
+ * large-area/parallax motion that triggers vestibular discomfort, so they
5729
+ * survive an *OS-level* reduced-motion preference. They are still fully stopped
5730
+ * by an *explicit* in-app choice (data-motion="reduce") — a deliberate "calm it
5731
+ * down" wins over the ambient OS default. The !important + selector specificity
5732
+ * is what overrides the universal `* { animation-duration: 0.01ms !important }`
5733
+ * reset for just these elements. */
5734
+ @media (prefers-reduced-motion: reduce) {
5735
+ :root:not([data-motion="reduce"]) .empty-state-icon.star {
5736
+ animation: star-pulse 4s ease-in-out infinite !important;
5737
+ }
5738
+ :root:not([data-motion="reduce"]) .empty-state-icon.ai-sparkle {
5739
+ animation: ai-float 6s ease-in-out infinite !important;
5740
+ }
5741
+ :root:not([data-motion="reduce"]) .empty-state-icon.orbit .satellite {
5742
+ animation: orbit-rotate 10s linear infinite !important;
5743
+ }
5744
+ }
5745
+
5706
5746
  [data-theme="dark"] {
5707
5747
  /* Tells the browser to render native widgets (dropdowns, scrollbars,
5708
5748
  date pickers, etc.) in their dark variant so option popups match. */
package/src/cli/orbit.js CHANGED
@@ -43,6 +43,7 @@ Options (dispatch):
43
43
  --worktree-path <dir> Worktree path when --worktree is used
44
44
  --branch <name> Branch name for the preserved worktree
45
45
  --hermes-bin <cmd> Hermes executable (default: hermes)
46
+ --verify-command <cmd> Declare an expected verification command (repeatable)
46
47
  --no-spawn Prepare card/worktree/run record but do not start Hermes or move In Progress
47
48
  --no-yolo Do not pass --yolo to Hermes (default dispatch passes --yolo)
48
49
  --foreground Attach spawned Hermes process to this terminal
@@ -157,6 +158,13 @@ function resetBusyError(error, projectRoot) {
157
158
  return wrapped;
158
159
  }
159
160
 
161
+ function invalidArgs(args, message) {
162
+ console.error(message);
163
+ args.command = "help";
164
+ args.invalid = true;
165
+ return args;
166
+ }
167
+
160
168
  function parseArgs(argv) {
161
169
  const args = {
162
170
  command: null,
@@ -180,6 +188,7 @@ function parseArgs(argv) {
180
188
  worktreePath: null,
181
189
  branch: null,
182
190
  hermesBin: "hermes",
191
+ verifyCommands: [],
183
192
  noSpawn: false,
184
193
  yolo: true,
185
194
  keepHandoffFile: false,
@@ -254,6 +263,18 @@ function parseArgs(argv) {
254
263
  args.hermesBin = rest[++i];
255
264
  } else if (a.startsWith("--hermes-bin=")) {
256
265
  args.hermesBin = a.slice("--hermes-bin=".length);
266
+ } else if (a === "--verify-command") {
267
+ const value = rest[i + 1];
268
+ if (!value || value.startsWith("--")) {
269
+ return invalidArgs(args, "--verify-command requires a non-empty value. Use --verify-command=<cmd> for values that start with --.");
270
+ }
271
+ args.verifyCommands.push(rest[++i]);
272
+ } else if (a.startsWith("--verify-command=")) {
273
+ const value = a.slice("--verify-command=".length);
274
+ if (!value) {
275
+ return invalidArgs(args, "--verify-command requires a non-empty value.");
276
+ }
277
+ args.verifyCommands.push(value);
257
278
  } else if (a === "--no-spawn") {
258
279
  args.noSpawn = true;
259
280
  } else if (a === "--no-yolo") {
@@ -560,6 +581,7 @@ async function runDispatch(options) {
560
581
  console.log(`Policy: ${result.policy}`);
561
582
  console.log(`Worktree: ${result.worktree_path}`);
562
583
  if (result.branch) console.log(`Branch: ${result.branch}`);
584
+ if (result.run_record_path) console.log(`Run record: ${result.run_record_path}`);
563
585
  if (result.pid) console.log(`PID: ${result.pid}`);
564
586
  console.log("AI Written-Plan updated with the generated handoff.");
565
587
  console.log("Run record comment added to the ticket.");
package/src/core/agent.js CHANGED
File without changes
File without changes
File without changes
package/src/core/auth.js CHANGED
File without changes
File without changes
package/src/core/board.js CHANGED
@@ -132,6 +132,7 @@ export {
132
132
  boardManual
133
133
  } from "./boards.js";
134
134
  export { archiveTicket, createComment, createTicket, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
135
+ export { createReviewVerdict, getReviewVerdict, listReviewVerdicts } from "./review-verdicts.js";
135
136
  export { archivedTicketsForBoard } from "./queries.js";
136
137
  export { getTicketBlockers, getTicketRelations } from "./relations.js";
137
138
  export { checkpointTicket, claimNext, completeTicket, getAgentDispatchPacket, getContextPack, getContextPackFull, readComments, readTicket } from "./agent.js";
File without changes
File without changes
package/src/core/db.js CHANGED
@@ -179,6 +179,22 @@ export function createBoardSchema(db) {
179
179
  created_at TEXT NOT NULL
180
180
  );
181
181
 
182
+ CREATE TABLE IF NOT EXISTS review_verdicts (
183
+ id TEXT PRIMARY KEY,
184
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
185
+ verdict TEXT NOT NULL CHECK (verdict IN ('PASS', 'BLOCK', 'QUESTION')),
186
+ blocking_findings_json TEXT NOT NULL DEFAULT '[]',
187
+ optional_findings_json TEXT NOT NULL DEFAULT '[]',
188
+ evidence_commands_json TEXT NOT NULL DEFAULT '[]',
189
+ reviewer_profile TEXT NOT NULL DEFAULT '',
190
+ reviewer_session_id TEXT NOT NULL DEFAULT '',
191
+ reviewed_commit_sha TEXT NOT NULL DEFAULT '',
192
+ dispatch_run_id TEXT NOT NULL DEFAULT '',
193
+ supersedes_prior_review_id TEXT REFERENCES review_verdicts(id) ON DELETE SET NULL,
194
+ created_by TEXT NOT NULL DEFAULT 'human',
195
+ created_at TEXT NOT NULL
196
+ );
197
+
182
198
  CREATE TABLE IF NOT EXISTS ticket_attachments (
183
199
  id TEXT PRIMARY KEY,
184
200
  ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
@@ -251,6 +267,8 @@ export function createBoardSchema(db) {
251
267
  CREATE INDEX IF NOT EXISTS idx_tickets_archived ON tickets(board_id, archived_at);
252
268
  CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_ticket_id);
253
269
  CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id, created_at);
270
+ CREATE INDEX IF NOT EXISTS idx_review_verdicts_ticket ON review_verdicts(ticket_id, created_at);
271
+ CREATE INDEX IF NOT EXISTS idx_review_verdicts_supersedes ON review_verdicts(supersedes_prior_review_id);
254
272
  CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket ON ticket_attachments(ticket_id, created_at);
255
273
  CREATE INDEX IF NOT EXISTS idx_ticket_attachments_board ON ticket_attachments(board_id, created_at);
256
274
  CREATE INDEX IF NOT EXISTS idx_board_entries_board ON board_entries(board_id, type, created_at);
@@ -266,6 +284,7 @@ export function resetBoard(db) {
266
284
  DELETE FROM ticket_labels;
267
285
  DELETE FROM relations;
268
286
  DELETE FROM comments;
287
+ DELETE FROM review_verdicts;
269
288
  DELETE FROM ticket_attachments;
270
289
  DELETE FROM board_entries;
271
290
  DELETE FROM events;
File without changes