@starlove/orbit 1.3.5 → 1.4.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 (129) hide show
  1. package/Dockerfile +2 -1
  2. package/README.md +10 -9
  3. package/SKILL-ORBIT.md +41 -17
  4. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  5. package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  6. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  7. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  8. package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  9. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  10. package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  11. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  12. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  13. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  14. package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
  15. package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  16. package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  17. package/dist/full/fonts/Inter/OFL.txt +93 -0
  18. package/dist/full/fonts/Inter/README.txt +118 -0
  19. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  20. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  21. package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
  22. package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
  23. package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
  24. package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
  25. package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  26. package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  27. package/dist/full/fonts/Open_Sans/README.txt +100 -0
  28. package/dist/full/fonts/Orbitron/OFL.txt +93 -0
  29. package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  30. package/dist/full/fonts/Orbitron/README.txt +68 -0
  31. package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
  32. package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  33. package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  34. package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  35. package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  36. package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  37. package/dist/full/fonts/runescape-uf.ttf +0 -0
  38. package/dist/full/index.html +135 -25
  39. package/dist/full/js/app.4e6fbe20.js +952 -0
  40. package/dist/full/js/app.4e6fbe20.js.map +7 -0
  41. package/dist/full/styles.4e60d824.css +1 -0
  42. package/dist/full/version.json +2 -2
  43. package/docs/AGENT_PROTOCOL.md +20 -13
  44. package/docs/ORBIT_DISPATCH.md +73 -38
  45. package/docs/UPDATING.md +114 -0
  46. package/package.json +5 -7
  47. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  48. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  49. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  50. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  51. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  52. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  53. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  54. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  55. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  56. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  57. package/public/fonts/Chakra_Petch/OFL.txt +93 -0
  58. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  59. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  60. package/public/fonts/Inter/OFL.txt +93 -0
  61. package/public/fonts/Inter/README.txt +118 -0
  62. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  63. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  64. package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
  65. package/public/fonts/JetBrains_Mono/README.txt +79 -0
  66. package/public/fonts/OpenRing-Regular.ttf +0 -0
  67. package/public/fonts/Open_Sans/OFL.txt +93 -0
  68. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  69. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  70. package/public/fonts/Open_Sans/README.txt +100 -0
  71. package/public/fonts/Orbitron/OFL.txt +93 -0
  72. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  73. package/public/fonts/Orbitron/README.txt +68 -0
  74. package/public/fonts/Rajdhani/OFL.txt +93 -0
  75. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  76. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  77. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  78. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  79. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  80. package/public/fonts/runescape-uf.ttf +0 -0
  81. package/public/index.html +133 -23
  82. package/public/js/artifact-drawer.js +79 -0
  83. package/public/js/board-export.js +161 -0
  84. package/public/js/board-menu.js +219 -17
  85. package/public/js/card-actions.js +1 -1
  86. package/public/js/create-card.js +60 -23
  87. package/public/js/drawer.js +164 -4
  88. package/public/js/font-preference.js +230 -0
  89. package/public/js/format.js +0 -10
  90. package/public/js/icons.js +16 -0
  91. package/public/js/local-backend.js +31 -4
  92. package/public/js/main.js +7 -1
  93. package/public/js/modal.js +3 -1
  94. package/public/js/panel.js +58 -0
  95. package/public/js/settings-tabs.js +1 -1
  96. package/public/js/settings.js +606 -275
  97. package/public/js/sse.js +3 -0
  98. package/public/js/state.js +1 -1
  99. package/public/js/text-editing.js +128 -0
  100. package/public/js/ticket-detail.js +377 -148
  101. package/public/js/ticket-typeahead.js +49 -0
  102. package/public/js/toast.js +19 -4
  103. package/public/js/topbar-notes.js +3 -3
  104. package/public/js/typeahead.js +206 -0
  105. package/public/js/update-check.js +58 -0
  106. package/public/styles.css +5447 -3572
  107. package/src/cli/orbit.js +9 -5
  108. package/src/core/agent.js +12 -4
  109. package/src/core/agents-md.js +3 -3
  110. package/src/core/artifacts.js +149 -0
  111. package/src/core/board.js +3 -2
  112. package/src/core/boards.js +0 -1
  113. package/src/core/css-bundle.js +1 -0
  114. package/src/core/db.js +6 -0
  115. package/src/core/dispatch.js +344 -34
  116. package/src/core/http.js +9 -2
  117. package/src/core/paths.js +26 -2
  118. package/src/core/router.js +33 -0
  119. package/src/core/search.js +3 -2
  120. package/src/core/snapshots.js +10 -5
  121. package/src/core/tickets.js +34 -3
  122. package/src/core/util.js +3 -2
  123. package/src/core/version-check.js +154 -0
  124. package/src/mcp/orbit-client.js +18 -2
  125. package/src/mcp-server.js +19 -14
  126. package/src/server.js +28 -3
  127. package/dist/full/js/app.4ae80764.js +0 -742
  128. package/dist/full/js/app.4ae80764.js.map +0 -7
  129. package/dist/full/styles.b9e0c130.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { extname, join, resolve } from "node:path";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
4
  import { localOwnerActor } from "./auth.js";
5
5
  import { DISPATCH_RUNS_DIR } from "./paths.js";
6
6
  import { getContextPackFull } from "./agent.js";
@@ -52,14 +52,34 @@ function resolveTicket(db, boardId, ticketRef) {
52
52
  return ticket;
53
53
  }
54
54
 
55
- function remoteDispatchError(options) {
56
- if (!options.remote && !options.serverUrl) return null;
57
- const target = options.serverUrl ? ` (${options.serverUrl})` : "";
58
- return [
59
- `Remote dispatch is not supported by the local CLI path yet${target}.`,
60
- "`orbit dispatch` currently mutates a board through the local registry and SQLite board file only.",
61
- "Run it on the board host without --server-url/--remote, or use remote MCP/manual orchestration for hosted boards."
62
- ].join("\n");
55
+ function parseTicketUrl(value) {
56
+ if (!value) return null;
57
+ let url;
58
+ try {
59
+ url = new URL(String(value));
60
+ } catch {
61
+ return null;
62
+ }
63
+ const hash = String(url.hash || "").replace(/^#/, "");
64
+ const match = hash.match(/(?:^|\/)b\/([^/]+)\/t\/([^/?#]+)/);
65
+ if (!match) return null;
66
+ url.hash = "";
67
+ url.search = "";
68
+ url.pathname = url.pathname.replace(/\/+$/, "");
69
+ return {
70
+ serverUrl: url.toString().replace(/\/+$/, ""),
71
+ board: decodeURIComponent(match[1]),
72
+ ticket: decodeURIComponent(match[2])
73
+ };
74
+ }
75
+
76
+ function remoteTarget(options) {
77
+ const parsed = parseTicketUrl(options.ticketUrl || options.ticket);
78
+ return {
79
+ serverUrl: options.serverUrl || parsed?.serverUrl || process.env.ORBIT_SERVER_URL || process.env.ORBIT_API_URL || "",
80
+ board: options.board || parsed?.board || process.env.ORBIT_DEFAULT_BOARD || "",
81
+ ticket: parsed?.ticket || options.ticket || ""
82
+ };
63
83
  }
64
84
 
65
85
  function resolveExecutable(command) {
@@ -210,6 +230,7 @@ function buildRunRecordJson({
210
230
  commit_shas: [],
211
231
  residual_risks: [],
212
232
  server_url: sanitizeUrlBearingValue(serverUrl),
233
+ transport: options.remote || options.serverUrl || options.ticketUrl ? "remote" : "local",
213
234
  mode: options.noSpawn ? "prepare-only" : "spawn",
214
235
  command: `${options.hermesBin || "hermes"} ${hermesArgs.map((arg) => (arg.includes(" ") ? q(arg) : arg)).join(" ")}`,
215
236
  created_at: new Date().toISOString(),
@@ -377,6 +398,10 @@ function buildHandoff({ boardRow, context, profile, repoRoot, worktreePath, bran
377
398
  const childText = children.length
378
399
  ? children.map((child) => `- #${child.number} ${child.title} (${child.state_name})`).join("\n")
379
400
  : "None.";
401
+ const recentComments = (context.recent_comments || [])
402
+ .slice(-5)
403
+ .map((comment) => `- ${comment.author || "unknown"} (${comment.kind || "comment"}): ${comment.body || ""}`)
404
+ .join("\n");
380
405
  const verificationText = verificationCommands.map((command) => `- ${command}`).join("\n");
381
406
 
382
407
  return `# Orbit Agent Handoff
@@ -400,29 +425,30 @@ Implement the ticket faithfully. Treat this ticket as the source of truth for co
400
425
  ## Read first
401
426
  1. AGENTS.md in the repository.
402
427
  2. SKILL-ORBIT.md in the repository.
403
- 3. This AI Written-Plan handoff.
404
- 4. The ticket description, implementation fields, blockers, parent/child cards, and board journal entries below.
428
+ 3. This linked markdown handoff.
429
+ 4. The ticket description, terse visible AI Plan / Work Summary, Revisions, blockers, parent/child cards, and board Journal entries below.
405
430
 
406
431
  ## Ticket description
407
432
  ${ticket.description || "(No description.)"}
408
433
 
409
- ## Implementation records
410
- AI Written-Plan: ${ticket.ai_plan || "(Empty.)"}
434
+ ## Human-visible card fields
435
+ AI Plan: ${ticket.ai_plan || "(Empty.)"}
436
+
437
+ Linked plan artifact: ${ticket.plan_artifact_path || "(None.)"}
411
438
 
412
- AI Implementation Summary: ${ticket.implementation_summary || "(Empty.)"}
439
+ Work Summary: ${ticket.implementation_summary || "(Empty.)"}
413
440
 
414
- Implementation Updates/Lessons: ${ticket.implementation_updates || "(Empty.)"}
441
+ Linked implementation artifact: ${ticket.implementation_artifact_path || "(None.)"}
442
+
443
+ Revisions: ${ticket.implementation_updates || "(Empty.)"}
415
444
 
416
445
  ## Board agent instructions
417
446
  ${board.agent_instructions || "(No board-level agent instructions.)"}
418
447
 
419
- ## Board notes
420
- ${board.project_notes || "(No board notes.)"}
421
-
422
448
  ## Board journal: lessons and decisions
423
- Board journal entries are project constraints and lessons, not persona or roleplay instructions. Apply them only when they clarify architecture, workflow, product boundaries, or reusable pitfalls.
449
+ Board Journal entries are the most important code, pattern, project, and workflow context loaded for every agent working with this board. Treat decisions and lessons as project constraints, not persona or roleplay instructions.
424
450
 
425
- ${boardEntries || "(No active board journal entries.)"}
451
+ ${boardEntries || "(No active board Journal entries.)"}
426
452
 
427
453
  ## Parent ticket
428
454
  ${parent ? `#${parent.number} ${parent.title} (${parent.state_name})` : "None."}
@@ -433,6 +459,7 @@ ${childText}
433
459
  ## Unresolved blockers
434
460
  ${blockerText}
435
461
 
462
+ ${recentComments ? `## Recent ticket comments\n${recentComments}\n` : ""}
436
463
  ## Scope boundaries
437
464
  - Keep changes local to this repository/worktree.
438
465
  - Do not push, deploy, publish, or open/merge PRs.
@@ -445,7 +472,7 @@ Allowed without asking: read/search files, inspect git status/diff/log, edit fil
445
472
  Blocked or requires explicit human approval: Docker, git push, deploy/publish/release commands, destructive git operations (reset/clean/rebase/force checkout/worktree removal), package install/publish/version commands, recursive/force filesystem deletion, SSH/SCP/rsync, cloud CLIs, GitHub CLI, direct curl/wget network calls, sudo, and secret exfiltration.
446
473
 
447
474
  ## Declared verification commands
448
- Run the commands that apply before handoff. If a conditional command does not apply, say why in Implementation Updates.
475
+ Run the commands that apply before handoff. If a conditional command does not apply, say why in the completion comment or Work Summary.
449
476
 
450
477
  ${verificationText}
451
478
 
@@ -460,10 +487,11 @@ ${verificationText}
460
487
  When done:
461
488
  1. Run the relevant automated tests and any focused manual/API sanity checks.
462
489
  2. Commit the work locally on the dispatch branch.
463
- 3. Update the ticket's AI Implementation Summary with what changed, commit SHA, branch/worktree, verification, and manual checks still needed.
464
- 4. Add Implementation Updates/Lessons for pitfalls, remediation, or future-agent guidance.
465
- 5. Add a completion comment/run record on the ticket.
466
- 6. Move the ticket to Review, not Done, unless the human operator explicitly requested auto-completion.
490
+ 3. Put a terse, human-readable result summary in Work Summary (implementation_summary).
491
+ 4. If full detail matters, write a markdown report artifact and link it with implementation_artifact_path.
492
+ 5. Leave Revisions alone unless this run revises earlier implementation; promote important code/pattern/project lessons and decisions to board Journal entries.
493
+ 6. Add a compact completion comment/run receipt on the ticket.
494
+ 7. Move the ticket to Review, not Done, unless the human operator explicitly requested auto-completion.
467
495
  `;
468
496
  }
469
497
 
@@ -472,10 +500,19 @@ function buildShortPrompt({ boardRow, ticket, profile, serverUrl }) {
472
500
  `You are ${profile}, dispatched by Orbit.`,
473
501
  `Work board ${boardRow.slug}, ticket #${ticket.number}: ${ticket.title}.`,
474
502
  "Read AGENTS.md and SKILL-ORBIT.md in this repository first.",
475
- "The full generated handoff is stored on the ticket in AI Written-Plan; treat the Orbit ticket as source of truth.",
503
+ "The full generated handoff is linked from the ticket as a markdown artifact; treat the Orbit ticket plus linked artifact as source of truth.",
476
504
  serverUrl ? `Orbit server URL: ${serverUrl}` : "Use local Orbit/MCP/API access available from this repo.",
477
505
  "Follow the autonomous policy in the handoff. Do not push, deploy, publish, run Docker, or expose secrets.",
478
- "When complete, commit locally, update AI Implementation Summary/Implementation Updates on the ticket, comment the run record, and move the ticket to Review."
506
+ "When complete, commit locally, update a terse Work Summary plus any linked markdown report on the ticket, use Revisions only for post-initial corrections, comment the run receipt, and move the ticket to Review."
507
+ ].join("\n");
508
+ }
509
+
510
+ function buildPlanExcerpt({ ticket, profile, handoffPath }) {
511
+ return [
512
+ `- ${profile} dispatched to work #${ticket.number}: ${ticket.title}.`,
513
+ `- Full generated handoff is linked as markdown: ${handoffPath}`,
514
+ "- Follow blockers, scope boundaries, autonomous policy, and declared verification in that artifact.",
515
+ "- Keep AI Plan and Work Summary terse; put full detail in linked markdown/report artifacts."
479
516
  ].join("\n");
480
517
  }
481
518
 
@@ -490,10 +527,281 @@ function ensureWorktree({ repoRoot, boardSlug, ticketNumber, profile, branchName
490
527
  return { branchName: runBranch, worktreePath: runWorktree };
491
528
  }
492
529
 
493
- export function dispatchTicket(options) {
494
- const remoteError = remoteDispatchError(options);
495
- if (remoteError) throw new Error(remoteError);
530
+ function remoteRunId(ticket, profile) {
531
+ return `orbit-${ticket.number}-${profile}-${shortId()}`;
532
+ }
533
+
534
+ function remoteRunDir(repoRoot, runId) {
535
+ return join(repoRoot, ".orbit", "dispatch-runs", runId);
536
+ }
537
+
538
+ function remoteWorktreePath(repoRoot, runId) {
539
+ return join(dirname(repoRoot), ".worktrees", basename(repoRoot), runId);
540
+ }
541
+
542
+ function contextFromDispatchPacket(packet, blockers) {
543
+ const blockerList = Array.isArray(blockers?.blockers) ? blockers.blockers : packet.blockers?.blockers || [];
544
+ return {
545
+ ticket: packet.ticket,
546
+ board: packet.board,
547
+ board_manual: { entries: [] },
548
+ parent_ticket: packet.parent_ticket || null,
549
+ child_tickets: [],
550
+ blockers: blockerList,
551
+ recent_comments: packet.recent_comments || []
552
+ };
553
+ }
554
+
555
+ async function createRemoteClient(options, target) {
556
+ if (options.remoteClient) return options.remoteClient;
557
+ const { createHttpOrbitClient } = await import("../mcp/orbit-client.js");
558
+ return createHttpOrbitClient({
559
+ ...process.env,
560
+ ORBIT_MODE: "remote",
561
+ ORBIT_API_URL: target.serverUrl,
562
+ ORBIT_DEFAULT_BOARD: target.board
563
+ });
564
+ }
565
+
566
+ async function dispatchTicketRemote(options) {
567
+ const target = remoteTarget(options);
568
+ if (!target.serverUrl) throw new Error("Remote dispatch requires --server-url or a hosted Orbit ticket URL.");
569
+ if (!target.board) throw new Error("Remote dispatch requires --board or a hosted Orbit ticket URL containing /b/<slug>.");
570
+ if (!target.ticket) throw new Error("Remote dispatch requires --ticket or a hosted Orbit ticket URL containing /t/<ticket>.");
571
+
572
+ const profile = options.profile || "agent";
573
+ const policyName = options.policy === undefined ? DEFAULT_POLICY : options.policy;
574
+ const verificationCommands = normalizeVerificationCommands(options.verifyCommands);
575
+ const remoteClient = await createRemoteClient(options, target);
576
+ await remoteClient.boardSetActive?.({ slug: target.board });
577
+
578
+ let packet;
579
+ if (/^\d+$/.test(String(target.ticket))) {
580
+ const found = await remoteClient.readTicket({ board_slug: target.board, number: Number(target.ticket) });
581
+ const ticketId = found.ticket?.id || found.results?.[0]?.id || found.id;
582
+ if (!ticketId) throw new Error(`Ticket not found on remote board: ${target.ticket}`);
583
+ packet = await remoteClient.getAgentDispatchPacket({ board_slug: target.board, ticket_id: ticketId, comment_limit: 5 });
584
+ } else {
585
+ packet = await remoteClient.getAgentDispatchPacket({ board_slug: target.board, ticket_id: target.ticket, comment_limit: 5 });
586
+ }
587
+ const blockers = await remoteClient.getTicketBlockers({ board_slug: target.board, ticket_id: packet.ticket.id });
588
+ const blockerList = Array.isArray(blockers?.blockers) ? blockers.blockers : [];
589
+ if (blockerList.length && !options.force) {
590
+ throw new Error(`Ticket #${packet.ticket.number} is blocked: ${blockerList.map((b) => `#${b.number} ${b.title}`).join(", ")}`);
591
+ }
592
+
593
+ const boardRow = {
594
+ id: packet.board.id,
595
+ slug: packet.board.slug,
596
+ name: packet.board.name,
597
+ repo_path: packet.board.repo_path || packet.board.system_path || "",
598
+ default_branch: packet.board.default_branch || "main"
599
+ };
600
+ const ticket = packet.ticket;
601
+ const repoRoot = resolveRepoRoot(options, boardRow);
602
+
603
+ if (options.dryRun) {
604
+ return {
605
+ dry_run: true,
606
+ no_spawn: Boolean(options.noSpawn),
607
+ run_id: null,
608
+ board: { id: boardRow.id, slug: boardRow.slug, name: boardRow.name },
609
+ ticket: { id: ticket.id, number: ticket.number, title: ticket.title },
610
+ profile,
611
+ policy: policyName || "none",
612
+ repo_root: repoRoot,
613
+ worktree_path: repoRoot,
614
+ branch: options.branch || "",
615
+ handoff_path: null,
616
+ policy_bin: null,
617
+ pid: null,
618
+ spawned: false,
619
+ prompt: "",
620
+ run_record: "",
621
+ run_record_path: null,
622
+ verification_commands: verificationCommands,
623
+ remote: true
624
+ };
625
+ }
626
+
627
+ const inProgressId = packet.relevant_state_ids?.in_progress;
628
+ if (!options.noSpawn && !inProgressId) throw new Error("Remote board is missing an In Progress state id in the dispatch packet.");
629
+ const hermesExecutable = options.noSpawn ? null : preflightHermes(options, profile);
630
+ const runId = remoteRunId(ticket, profile);
631
+ const runDir = remoteRunDir(repoRoot, runId);
632
+ const baseCommit = optionalCommandOutput("git", ["-C", repoRoot, "rev-parse", "HEAD"]);
633
+ const environmentVariance = collectEnvironmentVariance();
634
+ let branchName = options.branch || "";
635
+ let worktreePath = "";
636
+
637
+ if (options.worktree) {
638
+ const worktree = ensureWorktree({
639
+ repoRoot,
640
+ boardSlug: boardRow.slug,
641
+ ticketNumber: ticket.number,
642
+ profile,
643
+ branchName: options.branch || `orbit/${ticket.number}-${cleanName(profile)}-remote-${shortId()}`,
644
+ worktreePath: options.worktreePath || remoteWorktreePath(repoRoot, runId)
645
+ });
646
+ branchName = worktree.branchName;
647
+ worktreePath = worktree.worktreePath;
648
+ }
649
+
650
+ mkdirSync(runDir, { recursive: true });
651
+ const policyBin = createPolicyBin(runDir, policyName);
652
+ const context = contextFromDispatchPacket(packet, blockers);
653
+ const displayServerUrl = sanitizeUrlBearingValue(target.serverUrl);
654
+ const handoff = `${buildHandoff({
655
+ boardRow,
656
+ context,
657
+ profile,
658
+ repoRoot,
659
+ worktreePath,
660
+ branchName,
661
+ baseCommit,
662
+ policyName,
663
+ runId,
664
+ serverUrl: displayServerUrl,
665
+ verificationCommands
666
+ })}\n## Remote Orbit environment for spawned agents\n- ORBIT_MODE=remote\n- ORBIT_DEFAULT_BOARD=${boardRow.slug}\n- ORBIT_API_URL is provided by dispatch when spawning; URL-bearing values are redacted from run records.\n`;
667
+ const handoffPath = join(runDir, "handoff.md");
668
+ writeFileSync(handoffPath, handoff, "utf8");
669
+
670
+ const prompt = buildShortPrompt({ boardRow, ticket, profile, serverUrl: displayServerUrl });
671
+ const hermesArgs = [];
672
+ if (profile) hermesArgs.push("-p", profile);
673
+ if (options.yolo) hermesArgs.push("--yolo");
674
+ hermesArgs.push("chat", "-q", prompt);
675
+ const runRecordPath = join(runDir, "run-record.json");
676
+ const writeRunRecordFile = options.writeRunRecordFile || ((path, runRecordJson) => {
677
+ writeFileSync(path, `${JSON.stringify(runRecordJson, null, 2)}\n`, "utf8");
678
+ });
679
+ const writeRunRecordJson = (status, childProcess = null) => {
680
+ const runRecordJson = buildRunRecordJson({
681
+ runId,
682
+ status,
683
+ boardRow,
684
+ ticket,
685
+ profile,
686
+ policyName,
687
+ repoRoot,
688
+ worktreePath,
689
+ branchName,
690
+ baseCommit,
691
+ handoffPath,
692
+ policyBin,
693
+ child: childProcess,
694
+ hermesArgs,
695
+ verificationCommands,
696
+ options: { ...options, remote: true },
697
+ serverUrl: target.serverUrl,
698
+ environmentVariance
699
+ });
700
+ writeRunRecordFile(runRecordPath, runRecordJson);
701
+ return runRecordJson;
702
+ };
703
+
704
+ let status = "prepared";
705
+ writeRunRecordJson(status, null);
706
+ const planSummary = buildPlanExcerpt({ ticket, profile, handoffPath });
707
+ let linkedHandoffPath = handoffPath;
708
+ if (typeof remoteClient.uploadMarkdownArtifact === "function") {
709
+ const uploaded = await remoteClient.uploadMarkdownArtifact({
710
+ ticket_id: ticket.id,
711
+ board_slug: boardRow.slug,
712
+ filename: `${runId}-handoff.md`,
713
+ source_path: handoffPath,
714
+ content: readFileSync(handoffPath, "utf8")
715
+ });
716
+ if (uploaded?.path) linkedHandoffPath = uploaded.path;
717
+ }
718
+ const ticketPatch = { ticket_id: ticket.id, board_slug: boardRow.slug, ai_plan: planSummary, plan_artifact_path: linkedHandoffPath };
719
+ if (!options.noSpawn) ticketPatch.state_id = inProgressId;
720
+ await remoteClient.updateTicket(ticketPatch);
721
+
722
+ const cwd = worktreePath || repoRoot;
723
+ const env = {
724
+ ...process.env,
725
+ ORBIT_MODE: "remote",
726
+ ORBIT_API_URL: target.serverUrl,
727
+ ORBIT_DEFAULT_BOARD: boardRow.slug,
728
+ ORBIT_DISPATCH_RUN_ID: runId,
729
+ ORBIT_BOARD: boardRow.slug,
730
+ ORBIT_TICKET: String(ticket.number),
731
+ ORBIT_TICKET_ID: ticket.id,
732
+ ORBIT_HANDOFF_PATH: handoffPath,
733
+ ORBIT_AUTONOMOUS_POLICY: policyName || "none",
734
+ ...(policyBin ? { PATH: `${policyBin}:${process.env.PATH || ""}` } : {})
735
+ };
736
+
737
+ let child = null;
738
+ if (!options.noSpawn) {
739
+ child = spawn(hermesExecutable.command, [...hermesExecutable.argsPrefix, ...hermesArgs], {
740
+ cwd,
741
+ env,
742
+ detached: !options.foreground,
743
+ stdio: options.foreground ? "inherit" : "ignore"
744
+ });
745
+ if (!options.foreground) child.unref();
746
+ status = "launched";
747
+ writeRunRecordJson(status, child);
748
+ }
749
+
750
+ const runRecord = [
751
+ `Orbit remote dispatch run ${options.noSpawn ? "prepared" : "started"}.`,
752
+ `- run_id: ${runId}`,
753
+ `- profile: ${profile}`,
754
+ `- status: ${status}`,
755
+ `- transport: remote hosted board`,
756
+ `- policy: ${policyName || "none"}`,
757
+ `- ticket: #${ticket.number} ${ticket.title}`,
758
+ `- branch: ${branchName || "current branch"}`,
759
+ `- base_commit: ${baseCommit || "unknown"}`,
760
+ `- worktree: ${worktreePath || repoRoot}`,
761
+ `- handoff: ${handoffPath}`,
762
+ linkedHandoffPath !== handoffPath ? `- hosted_handoff: ${linkedHandoffPath}` : null,
763
+ `- run_record_json: ${runRecordPath}`,
764
+ `- policy_bin: ${policyBin || "none"}`,
765
+ `- pid: ${child?.pid || "not spawned"}`,
766
+ `- verification_commands: ${verificationCommands.join("; ")}`,
767
+ `- command: ${options.hermesBin || "hermes"} ${hermesArgs.map((arg) => (arg.includes(" ") ? q(arg) : arg)).join(" ")}`,
768
+ options.noSpawn ? "- mode: prepare-only; no agent spawned and ticket state left unchanged" : "- mode: spawned; ticket moved to In Progress"
769
+ ].filter(Boolean).join("\n");
770
+
771
+ await remoteClient.addComment({ ticket_id: ticket.id, board_slug: boardRow.slug, author: "orbit dispatch", kind: "agent_note", body: runRecord });
496
772
 
773
+ return {
774
+ dry_run: false,
775
+ no_spawn: Boolean(options.noSpawn),
776
+ run_id: runId,
777
+ board: { id: boardRow.id, slug: boardRow.slug, name: boardRow.name },
778
+ ticket: { id: ticket.id, number: ticket.number, title: ticket.title },
779
+ profile,
780
+ policy: policyName || "none",
781
+ repo_root: repoRoot,
782
+ worktree_path: worktreePath || repoRoot,
783
+ branch: branchName || "",
784
+ handoff_path: handoffPath,
785
+ linked_handoff_path: linkedHandoffPath,
786
+ policy_bin: policyBin,
787
+ pid: child?.pid || null,
788
+ spawned: Boolean(child),
789
+ prompt,
790
+ run_record: runRecord,
791
+ run_record_path: runRecordPath,
792
+ verification_commands: verificationCommands,
793
+ remote: true
794
+ };
795
+ }
796
+
797
+ export async function dispatchTicketAsync(options) {
798
+ if (options.remote || options.serverUrl || options.ticketUrl || parseTicketUrl(options.ticket)) {
799
+ return dispatchTicketRemote({ ...options, remote: true });
800
+ }
801
+ return dispatchTicket(options);
802
+ }
803
+
804
+ export function dispatchTicket(options) {
497
805
  const profile = options.profile || "agent";
498
806
  const policyName = options.policy === undefined ? DEFAULT_POLICY : options.policy;
499
807
  const verificationCommands = normalizeVerificationCommands(options.verifyCommands);
@@ -616,10 +924,11 @@ export function dispatchTicket(options) {
616
924
  writeRunRecordJson(status, null);
617
925
 
618
926
  touchBoardActive(boardRow.id);
927
+ const planSummary = buildPlanExcerpt({ ticket, profile, handoffPath });
619
928
  if (options.noSpawn) {
620
- updateTicket(ticket.id, { ai_plan: handoff }, ctx);
929
+ updateTicket(ticket.id, { ai_plan: planSummary, plan_artifact_path: handoffPath }, ctx);
621
930
  } else {
622
- updateTicket(ticket.id, { ai_plan: handoff, state_id: inProgress.id }, ctx);
931
+ updateTicket(ticket.id, { ai_plan: planSummary, plan_artifact_path: handoffPath, state_id: inProgress.id }, ctx);
623
932
  }
624
933
 
625
934
  const cwd = worktreePath || repoRoot;
@@ -657,7 +966,7 @@ export function dispatchTicket(options) {
657
966
  `- branch: ${branchName || "current branch"}`,
658
967
  `- base_commit: ${baseCommit || "unknown"}`,
659
968
  `- worktree: ${worktreePath || repoRoot}`,
660
- `- handoff: AI Written-Plan field${options.keepHandoffFile ? ` and ${handoffPath}` : ""}`,
969
+ `- handoff: linked markdown artifact${options.keepHandoffFile ? ` and ${handoffPath}` : ""}`,
661
970
  `- run_record_json: ${runRecordPath}`,
662
971
  `- policy_bin: ${policyBin || "none"}`,
663
972
  `- pid: ${child?.pid || "not spawned"}`,
@@ -669,7 +978,7 @@ export function dispatchTicket(options) {
669
978
  createComment(ticket.id, { author: "orbit dispatch", kind: "agent_note", body: runRecord }, ctx);
670
979
 
671
980
  if (!options.keepHandoffFile) {
672
- // The canonical copy is the ticket's AI Written-Plan. Keep run metadata and
981
+ // The canonical copy is the linked markdown artifact. Keep run metadata and
673
982
  // policy wrappers. Local-only agents may still use ORBIT_HANDOFF_PATH as a
674
983
  // bootstrap hint before they read the card, so leave the file in place.
675
984
  }
@@ -686,6 +995,7 @@ export function dispatchTicket(options) {
686
995
  worktree_path: worktreePath || repoRoot,
687
996
  branch: branchName || "",
688
997
  handoff_path: handoffPath,
998
+ linked_handoff_path: handoffPath,
689
999
  policy_bin: policyBin,
690
1000
  pid: child?.pid || null,
691
1001
  spawned: Boolean(child),
package/src/core/http.js CHANGED
@@ -75,13 +75,20 @@ function shouldBundleSourceStyles(pathname, filePath) {
75
75
  return pathname === "/styles.css" && filePath === join(ROOT_DIR, "public", "styles.css");
76
76
  }
77
77
 
78
+ export function staticAssetPathname(pathname) {
79
+ return pathname === "/app" || pathname.startsWith("/app/")
80
+ ? pathname.slice(4) || "/"
81
+ : pathname;
82
+ }
83
+
78
84
  export async function serveStatic(req, res, pathname) {
79
85
  if (req.method !== "GET" && req.method !== "HEAD") {
80
86
  sendEmpty(res, 405);
81
87
  return;
82
88
  }
83
89
 
84
- const requested = pathname === "/" ? "/index.html" : pathname;
90
+ const staticPathname = staticAssetPathname(pathname);
91
+ const requested = staticPathname === "/" ? "/index.html" : staticPathname;
85
92
  const filePath = resolvePath(PUBLIC_DIR, `.${decodeURIComponent(requested)}`);
86
93
  if (!filePath.startsWith(PUBLIC_DIR)) {
87
94
  sendEmpty(res, 403);
@@ -89,7 +96,7 @@ export async function serveStatic(req, res, pathname) {
89
96
  }
90
97
 
91
98
  try {
92
- const data = shouldBundleSourceStyles(pathname, filePath)
99
+ const data = shouldBundleSourceStyles(staticPathname, filePath)
93
100
  ? Buffer.from((await bundleCss({ entryPoint: filePath, outfile: filePath })).cssFile.contents)
94
101
  : await readFile(filePath);
95
102
  res.writeHead(200, {
package/src/core/paths.js CHANGED
@@ -6,9 +6,33 @@ import { fileURLToPath } from "node:url";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
 
8
8
  export const ROOT_DIR = resolve(__dirname, "..", "..");
9
- // Prefer the pre-built bundle (present in published package); fall back to raw source for dev.
9
+
10
10
  const _distFull = join(ROOT_DIR, "dist", "full");
11
- export const PUBLIC_DIR = existsSync(_distFull) ? _distFull : join(ROOT_DIR, "public");
11
+ const _publicDir = join(ROOT_DIR, "public");
12
+
13
+ /** Where static assets (HTML/JS/CSS) are served from.
14
+ *
15
+ * public/ — editable source; `pnpm dev` bundles @starlove/ui imports on the fly.
16
+ * dist/full — prebuilt, hashed bundles from `pnpm build`; what Docker/npm ship.
17
+ *
18
+ * Auto (default): public/ when it exists, else dist/full. Repo dev therefore
19
+ * always sees live CSS/JS edits. Docker and other dist-only installs fall back
20
+ * to the bundle without needing an env var.
21
+ *
22
+ * Override: ORBIT_USE_DIST=1 force dist/full, ORBIT_USE_DIST=0 force public/. */
23
+ function resolvePublicDir() {
24
+ const distExists = existsSync(_distFull);
25
+ const publicExists = existsSync(_publicDir);
26
+ const pref = process.env.ORBIT_USE_DIST;
27
+
28
+ if (pref === "1" && distExists) return _distFull;
29
+ if (pref === "0" && publicExists) return _publicDir;
30
+ if (publicExists) return _publicDir;
31
+ if (distExists) return _distFull;
32
+ return _publicDir;
33
+ }
34
+
35
+ export const PUBLIC_DIR = resolvePublicDir();
12
36
  export const MCP_SERVER_PATH = join(ROOT_DIR, "src", "mcp-server.js");
13
37
  /** Repo root for default board placement; cwd unless PROJECT_ROOT is set. */
14
38
  export const PROJECT_ROOT = process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : resolve(process.cwd());
@@ -42,10 +42,12 @@ import {
42
42
  deleteTicket,
43
43
  getTicketStatusHistory,
44
44
  restoreTicket,
45
+ updateCommentAiOmission,
45
46
  updateTicket
46
47
  } from "./tickets.js";
47
48
  import { archivedTicketsForBoard } from "./queries.js";
48
49
  import { httpError } from "./util.js";
50
+ import { getVersionInfo, startSelfUpdate } from "./version-check.js";
49
51
  import { pickFolder } from "./system-picker.js";
50
52
  import {
51
53
  createRelation,
@@ -72,6 +74,7 @@ import {
72
74
  readTicket
73
75
  } from "./agent.js";
74
76
  import { createReviewVerdict, getReviewVerdict, listReviewVerdicts } from "./review-verdicts.js";
77
+ import { getTicketMarkdownArtifact, storeTicketMarkdownArtifact } from "./artifacts.js";
75
78
 
76
79
  /** Build a ctx for an already-resolved registry row + actor. */
77
80
  function ctxFromBoardRow(boardRow, actor) {
@@ -124,6 +127,21 @@ export async function handleApi(req, res, url) {
124
127
  return;
125
128
  }
126
129
 
130
+ // Update check (no actor needed). The client polls this on load and on a
131
+ // manual "Check now" (?refresh=1 bypasses the server's cached result).
132
+ if (req.method === "GET" && url.pathname === "/api/version") {
133
+ sendJson(res, 200, await getVersionInfo({ force: url.searchParams.get("refresh") === "1" }));
134
+ return;
135
+ }
136
+
137
+ // Opt-in self-update. Hidden (404) unless the operator set ORBIT_SELF_UPDATE=1,
138
+ // since it pulls + rebuilds + restarts the running server.
139
+ if (req.method === "POST" && url.pathname === "/api/update") {
140
+ if (process.env.ORBIT_SELF_UPDATE !== "1") throw httpError(404, "not_found");
141
+ sendJson(res, 202, startSelfUpdate());
142
+ return;
143
+ }
144
+
127
145
  const actor = actorFromHttpRequest(req);
128
146
 
129
147
  // Bootstrap: read-only registry + one chosen board. No global state mutated.
@@ -309,11 +327,26 @@ export async function handleApi(req, res, url) {
309
327
  sendJson(res, 200, readComments(ticketId, ctx));
310
328
  return;
311
329
  }
330
+ if (sub === "artifacts/markdown" && req.method === "GET") {
331
+ sendJson(res, 200, getTicketMarkdownArtifact(ticketId, url.searchParams.get("path"), ctx));
332
+ return;
333
+ }
334
+ if (sub === "artifacts/markdown" && req.method === "POST") {
335
+ const body = await readJson(req);
336
+ sendJson(res, 201, storeTicketMarkdownArtifact(ticketId, body, ctx));
337
+ return;
338
+ }
312
339
  if (sub === "comments" && req.method === "POST") {
313
340
  const body = await readJson(req);
314
341
  sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
315
342
  return;
316
343
  }
344
+ const commentPath = sub.match(/^comments\/([^/]+)$/);
345
+ if (commentPath && req.method === "PATCH") {
346
+ const body = await readJson(req);
347
+ sendMutationJson(res, 200, updateCommentAiOmission(ticketId, decodeURIComponent(commentPath[1]), body, ctx), ctx);
348
+ return;
349
+ }
317
350
  if (sub === "review-verdicts" && req.method === "GET") {
318
351
  sendJson(res, 200, { review_verdicts: listReviewVerdicts(ticketId, ctx, { limit: url.searchParams.get("limit") }) });
319
352
  return;
@@ -57,7 +57,7 @@ export function searchTickets(args, ctx) {
57
57
  const numberRank = ticketNumber !== null ? "CASE WHEN t.number = ? THEN -1 ELSE 0 END" : "0";
58
58
  const numberPredicate = ticketNumber !== null ? "OR t.number = ?" : "";
59
59
  const fallbackArgs = ticketNumber !== null ? [ticketNumber, board.id] : [board.id];
60
- fallbackArgs.push(like, like, like, like, like, like);
60
+ fallbackArgs.push(like, like, like, like, like, like, like, like);
61
61
  if (ticketNumber !== null) fallbackArgs.push(ticketNumber);
62
62
  fallbackArgs.push(limit);
63
63
 
@@ -71,7 +71,8 @@ export function searchTickets(args, ctx) {
71
71
  LEFT JOIN comments c ON c.ticket_id = t.id
72
72
  WHERE t.board_id = ? AND t.archived_at IS NULL
73
73
  AND (t.title LIKE ? OR t.description LIKE ? OR t.ai_plan LIKE ?
74
- OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ? OR c.body LIKE ?
74
+ OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ?
75
+ OR t.plan_artifact_path LIKE ? OR t.implementation_artifact_path LIKE ? OR c.body LIKE ?
75
76
  ${numberPredicate})
76
77
  ORDER BY t.updated_at DESC, t.id ASC
77
78
  LIMIT ?`