@starlove/orbit 1.1.1 → 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 +2 -2
  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 +22 -6
  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 +15 -4
  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 +25 -4
  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 +2 -1
  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 +178 -13
  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 +38 -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 +47 -19
  100. package/src/mcp-server.js +122 -26
  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
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { localOwnerActor } from "./auth.js";
5
5
  import { DISPATCH_RUNS_DIR } from "./paths.js";
6
- import { getContextPack } from "./agent.js";
6
+ import { getContextPackFull } from "./agent.js";
7
7
  import { openBoardDb, getBoardByRegistryId, getBoardByRepoPath, getBoardBySlug, touchBoardActive } from "./registry.js";
8
8
  import { stateByName, stateByRole, ticketById, ticketByNumber, unresolvedBlockers } from "./queries.js";
9
9
  import { createRegistrySchema } from "./db.js";
@@ -11,6 +11,13 @@ import { createComment, updateTicket } from "./tickets.js";
11
11
  import { id, normalizePath, slugify } from "./util.js";
12
12
 
13
13
  const DEFAULT_POLICY = "agent-safe";
14
+ const DEFAULT_VERIFICATION_COMMANDS = [
15
+ "npm test",
16
+ "npm run build (when public/ or browser bundle assets change)",
17
+ "node src/cli/orbit.js dispatch --help",
18
+ "git diff --check",
19
+ "git diff --cached --check before commit"
20
+ ];
14
21
 
15
22
  function shortId() {
16
23
  return id().replace(/-/g, "").slice(0, 10);
@@ -111,7 +118,8 @@ function resolveRepoRoot(options, boardRow) {
111
118
  }
112
119
 
113
120
  function commandOutput(command, args, options = {}) {
114
- const result = spawnSync(command, args, { encoding: "utf8", ...options });
121
+ const executable = command === "git" ? findExecutable("git") || command : command;
122
+ const result = spawnSync(executable, args, { encoding: "utf8", ...options });
115
123
  if (result.error) throw result.error;
116
124
  if (result.status !== 0) {
117
125
  const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
@@ -120,8 +128,101 @@ function commandOutput(command, args, options = {}) {
120
128
  return String(result.stdout || "").trim();
121
129
  }
122
130
 
131
+ function optionalCommandOutput(command, args, options = {}) {
132
+ try {
133
+ return commandOutput(command, args, options);
134
+ } catch {
135
+ return "";
136
+ }
137
+ }
138
+
139
+ function normalizeVerificationCommands(commands) {
140
+ const values = Array.isArray(commands) ? commands : commands ? [commands] : [];
141
+ const cleaned = values.map((value) => String(value || "").trim()).filter(Boolean);
142
+ return cleaned.length ? cleaned : [...DEFAULT_VERIFICATION_COMMANDS];
143
+ }
144
+
145
+ const REDACTED_URL_PRESENT = "[redacted-url-present]";
146
+ const URL_ENV_KEYS = new Set(["ORBIT_API_URL", "ORBIT_SERVER_URL"]);
147
+
148
+ function sanitizeUrlBearingValue(value) {
149
+ if (value === undefined || value === null || value === "") return "";
150
+ return REDACTED_URL_PRESENT;
151
+ }
152
+
153
+ function sanitizeEnvironmentValue(key, value) {
154
+ if (URL_ENV_KEYS.has(key)) return sanitizeUrlBearingValue(value);
155
+ return value;
156
+ }
157
+
158
+ function collectEnvironmentVariance() {
159
+ const envKeys = ["HOME", "PROJECT_ROOT", "DATA_DIR", "ORBIT_MODE", "ORBIT_API_URL", "ORBIT_DEFAULT_BOARD", "ORBIT_SERVER_URL"];
160
+ const env = {};
161
+ for (const key of envKeys) {
162
+ if (process.env[key] !== undefined) env[key] = sanitizeEnvironmentValue(key, process.env[key]);
163
+ }
164
+ return {
165
+ platform: process.platform,
166
+ node: process.version,
167
+ cwd: process.cwd(),
168
+ path_format: process.platform === "win32" ? "windows" : "posix",
169
+ env
170
+ };
171
+ }
172
+
173
+ function buildRunRecordJson({
174
+ runId,
175
+ status,
176
+ boardRow,
177
+ ticket,
178
+ profile,
179
+ policyName,
180
+ repoRoot,
181
+ worktreePath,
182
+ branchName,
183
+ baseCommit,
184
+ handoffPath,
185
+ policyBin,
186
+ child,
187
+ hermesArgs,
188
+ verificationCommands,
189
+ options,
190
+ serverUrl,
191
+ environmentVariance
192
+ }) {
193
+ return {
194
+ schema: "orbit.dispatch.run.v1",
195
+ run_id: runId,
196
+ ticket_id: ticket.id,
197
+ ticket_number: ticket.number,
198
+ board_id: boardRow.id,
199
+ board_slug: boardRow.slug,
200
+ profile,
201
+ prompt_path: handoffPath,
202
+ worktree: worktreePath || repoRoot,
203
+ repo_root: repoRoot,
204
+ branch: branchName || optionalCommandOutput("git", ["-C", repoRoot, "branch", "--show-current"]),
205
+ base_commit: baseCommit,
206
+ process_id: child?.pid || null,
207
+ policy: policyName || "none",
208
+ status,
209
+ verification_commands: verificationCommands,
210
+ commit_shas: [],
211
+ residual_risks: [],
212
+ server_url: sanitizeUrlBearingValue(serverUrl),
213
+ mode: options.noSpawn ? "prepare-only" : "spawn",
214
+ command: `${options.hermesBin || "hermes"} ${hermesArgs.map((arg) => (arg.includes(" ") ? q(arg) : arg)).join(" ")}`,
215
+ created_at: new Date().toISOString(),
216
+ environment_variance: environmentVariance
217
+ };
218
+ }
219
+
123
220
  function findExecutable(name) {
124
- const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
221
+ const filteredPath = String(process.env.PATH || "")
222
+ .split(process.platform === "win32" ? ";" : ":")
223
+ .filter((entry) => entry && !/(^|[/\\])policy-bin$/i.test(entry))
224
+ .join(process.platform === "win32" ? ";" : ":");
225
+ const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8", env: { ...process.env, PATH: filteredPath } });
125
226
  return result.status === 0 ? result.stdout.trim() : "";
126
227
  }
127
228
 
@@ -264,7 +365,7 @@ exec ${q(realRm)} "$@"
264
365
  return binDir;
265
366
  }
266
367
 
267
- function buildHandoff({ boardRow, context, profile, repoRoot, worktreePath, branchName, policyName, runId, serverUrl }) {
368
+ function buildHandoff({ boardRow, context, profile, repoRoot, worktreePath, branchName, baseCommit, policyName, runId, serverUrl, verificationCommands }) {
268
369
  const { ticket, board, board_manual: manual, parent_ticket: parent, child_tickets: children, blockers } = context;
269
370
  const boardEntries = (manual.entries || [])
270
371
  .slice(0, 12)
@@ -276,6 +377,7 @@ function buildHandoff({ boardRow, context, profile, repoRoot, worktreePath, bran
276
377
  const childText = children.length
277
378
  ? children.map((child) => `- #${child.number} ${child.title} (${child.state_name})`).join("\n")
278
379
  : "None.";
380
+ const verificationText = verificationCommands.map((command) => `- ${command}`).join("\n");
279
381
 
280
382
  return `# Orbit Agent Handoff
281
383
 
@@ -289,6 +391,7 @@ Initial state: ${ticket.state_name}
289
391
  Repository root: ${repoRoot}
290
392
  Worktree: ${worktreePath || repoRoot}
291
393
  Branch: ${branchName || "current branch"}
394
+ Base commit: ${baseCommit || "unknown"}
292
395
  ${serverUrl ? `Orbit server: ${serverUrl}` : ""}
293
396
 
294
397
  ## Mission
@@ -317,6 +420,8 @@ ${board.agent_instructions || "(No board-level agent instructions.)"}
317
420
  ${board.project_notes || "(No board notes.)"}
318
421
 
319
422
  ## 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.
424
+
320
425
  ${boardEntries || "(No active board journal entries.)"}
321
426
 
322
427
  ## Parent ticket
@@ -339,6 +444,18 @@ ${blockerText}
339
444
  Allowed without asking: read/search files, inspect git status/diff/log, edit files for this ticket, run package test/build/lint/check/typecheck commands, and create a local git commit.
340
445
  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.
341
446
 
447
+ ## Declared verification commands
448
+ Run the commands that apply before handoff. If a conditional command does not apply, say why in Implementation Updates.
449
+
450
+ ${verificationText}
451
+
452
+ ## Environment variance checklist
453
+ - HOME / DATA_DIR / PROJECT_ROOT / ORBIT_* inherited env vars
454
+ - Existing local board DB/files and central registry state
455
+ - Port availability when touching serve/SSE/API flows
456
+ - OS path format and shell differences
457
+ - Accidental use of real user state in tests or dispatch artifacts
458
+
342
459
  ## Completion protocol
343
460
  When done:
344
461
  1. Run the relevant automated tests and any focused manual/API sanity checks.
@@ -379,6 +496,7 @@ export function dispatchTicket(options) {
379
496
 
380
497
  const profile = options.profile || "agent";
381
498
  const policyName = options.policy === undefined ? DEFAULT_POLICY : options.policy;
499
+ const verificationCommands = normalizeVerificationCommands(options.verifyCommands);
382
500
  createRegistrySchema();
383
501
  const actor = localOwnerActor();
384
502
  const boardRow = resolveBoard(options);
@@ -412,7 +530,9 @@ export function dispatchTicket(options) {
412
530
  pid: null,
413
531
  spawned: false,
414
532
  prompt: "",
415
- run_record: ""
533
+ run_record: "",
534
+ run_record_path: null,
535
+ verification_commands: verificationCommands
416
536
  };
417
537
  }
418
538
 
@@ -420,6 +540,9 @@ export function dispatchTicket(options) {
420
540
  const runId = `orbit-${ticket.number}-${profile}-${shortId()}`;
421
541
  const runDir = join(DISPATCH_RUNS_DIR, boardRow.slug, runId);
422
542
  const serverUrl = options.serverUrl || process.env.ORBIT_SERVER_URL || "";
543
+ const displayServerUrl = sanitizeUrlBearingValue(serverUrl);
544
+ const baseCommit = optionalCommandOutput("git", ["-C", repoRoot, "rev-parse", "HEAD"]);
545
+ const environmentVariance = collectEnvironmentVariance();
423
546
  let branchName = options.branch || "";
424
547
  let worktreePath = "";
425
548
 
@@ -438,7 +561,7 @@ export function dispatchTicket(options) {
438
561
 
439
562
  mkdirSync(runDir, { recursive: true });
440
563
  const policyBin = createPolicyBin(runDir, policyName);
441
- const context = getContextPack(ticket.id, ctx, 1);
564
+ const context = getContextPackFull(ticket.id, ctx, 1);
442
565
  const handoff = buildHandoff({
443
566
  boardRow,
444
567
  context,
@@ -446,14 +569,52 @@ export function dispatchTicket(options) {
446
569
  repoRoot,
447
570
  worktreePath,
448
571
  branchName,
572
+ baseCommit,
449
573
  policyName,
450
574
  runId,
451
- serverUrl
575
+ serverUrl: displayServerUrl,
576
+ verificationCommands
452
577
  });
453
- const prompt = buildShortPrompt({ boardRow, ticket, profile, serverUrl });
578
+ const prompt = buildShortPrompt({ boardRow, ticket, profile, serverUrl: displayServerUrl });
454
579
  const handoffPath = join(runDir, "handoff.md");
455
580
  writeFileSync(handoffPath, handoff, "utf8");
456
581
 
582
+ const hermesArgs = [];
583
+ if (profile) hermesArgs.push("-p", profile);
584
+ if (options.yolo) hermesArgs.push("--yolo");
585
+ hermesArgs.push("chat", "-q", prompt);
586
+ const runRecordPath = join(runDir, "run-record.json");
587
+ const writeRunRecordFile = options.writeRunRecordFile || ((path, runRecordJson) => {
588
+ writeFileSync(path, `${JSON.stringify(runRecordJson, null, 2)}\n`, "utf8");
589
+ });
590
+ const writeRunRecordJson = (status, childProcess = null) => {
591
+ const runRecordJson = buildRunRecordJson({
592
+ runId,
593
+ status,
594
+ boardRow,
595
+ ticket,
596
+ profile,
597
+ policyName,
598
+ repoRoot,
599
+ worktreePath,
600
+ branchName,
601
+ baseCommit,
602
+ handoffPath,
603
+ policyBin,
604
+ child: childProcess,
605
+ hermesArgs,
606
+ verificationCommands,
607
+ options,
608
+ serverUrl,
609
+ environmentVariance
610
+ });
611
+ writeRunRecordFile(runRecordPath, runRecordJson);
612
+ return runRecordJson;
613
+ };
614
+
615
+ let status = "prepared";
616
+ writeRunRecordJson(status, null);
617
+
457
618
  touchBoardActive(boardRow.id);
458
619
  if (options.noSpawn) {
459
620
  updateTicket(ticket.id, { ai_plan: handoff }, ctx);
@@ -461,10 +622,6 @@ export function dispatchTicket(options) {
461
622
  updateTicket(ticket.id, { ai_plan: handoff, state_id: inProgress.id }, ctx);
462
623
  }
463
624
 
464
- const hermesArgs = [];
465
- if (profile) hermesArgs.push("-p", profile);
466
- if (options.yolo) hermesArgs.push("--yolo");
467
- hermesArgs.push("chat", "-q", prompt);
468
625
  const cwd = worktreePath || repoRoot;
469
626
  const env = {
470
627
  ...process.env,
@@ -486,19 +643,25 @@ export function dispatchTicket(options) {
486
643
  stdio: options.foreground ? "inherit" : "ignore"
487
644
  });
488
645
  if (!options.foreground) child.unref();
646
+ status = "launched";
647
+ writeRunRecordJson(status, child);
489
648
  }
490
649
 
491
650
  const runRecord = [
492
651
  `Orbit dispatch run ${options.noSpawn ? "prepared" : "started"}.`,
493
652
  `- run_id: ${runId}`,
494
653
  `- profile: ${profile}`,
654
+ `- status: ${status}`,
495
655
  `- policy: ${policyName || "none"}`,
496
656
  `- ticket: #${ticket.number} ${ticket.title}`,
497
657
  `- branch: ${branchName || "current branch"}`,
658
+ `- base_commit: ${baseCommit || "unknown"}`,
498
659
  `- worktree: ${worktreePath || repoRoot}`,
499
660
  `- handoff: AI Written-Plan field${options.keepHandoffFile ? ` and ${handoffPath}` : ""}`,
661
+ `- run_record_json: ${runRecordPath}`,
500
662
  `- policy_bin: ${policyBin || "none"}`,
501
663
  `- pid: ${child?.pid || "not spawned"}`,
664
+ `- verification_commands: ${verificationCommands.join("; ")}`,
502
665
  `- command: ${options.hermesBin || "hermes"} ${hermesArgs.map((arg) => (arg.includes(" ") ? q(arg) : arg)).join(" ")}`,
503
666
  options.noSpawn ? "- mode: prepare-only; no agent spawned and ticket state left unchanged" : "- mode: spawned; ticket moved to In Progress"
504
667
  ].join("\n");
@@ -527,6 +690,8 @@ export function dispatchTicket(options) {
527
690
  pid: child?.pid || null,
528
691
  spawned: Boolean(child),
529
692
  prompt,
530
- run_record: runRecord
693
+ run_record: runRecord,
694
+ run_record_path: runRecordPath,
695
+ verification_commands: verificationCommands
531
696
  };
532
697
  }
File without changes
package/src/core/http.js CHANGED
File without changes
File without changes
package/src/core/paths.js CHANGED
File without changes
File without changes
File without changes
@@ -27,7 +27,7 @@ export function getBoardByRepoPath(repoPathNorm) {
27
27
  .prepare(
28
28
  `SELECT * FROM boards
29
29
  WHERE repo_path = ?
30
- ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC
30
+ ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC, rowid DESC
31
31
  LIMIT 1`
32
32
  )
33
33
  .get(repoPathNorm);
@@ -39,7 +39,7 @@ export function pickDefaultBoard() {
39
39
  return getRegistry()
40
40
  .prepare(
41
41
  `SELECT * FROM boards
42
- ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC, created_at ASC
42
+ ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC, rowid DESC
43
43
  LIMIT 1`
44
44
  )
45
45
  .get();
File without changes
@@ -0,0 +1,138 @@
1
+ import { boardById, ticketById } from "./queries.js";
2
+ import { requireBoardAccess, requirePermission } from "./auth.js";
3
+ import { tx } from "./db.js";
4
+ import { recordEvent } from "./events.js";
5
+ import { bumpTicketUpdatedAt } from "./tickets.js";
6
+ import { httpError, id, now, requiredString } from "./util.js";
7
+
8
+ const VERDICTS = new Set(["PASS", "BLOCK", "QUESTION"]);
9
+ const JSON_ARRAY_FIELDS = ["blocking_findings", "optional_findings", "evidence_commands"];
10
+
11
+ export function normalizeReviewVerdict(value) {
12
+ const verdict = String(value || "").trim().toUpperCase();
13
+ if (!VERDICTS.has(verdict)) throw httpError(400, "invalid_review_verdict");
14
+ return verdict;
15
+ }
16
+
17
+ function normalizeJsonArray(value, field) {
18
+ if (value === undefined || value === null) return [];
19
+ if (!Array.isArray(value)) throw httpError(400, `invalid_${field}`);
20
+ return value;
21
+ }
22
+
23
+ function optionalString(value) {
24
+ if (value === undefined || value === null) return "";
25
+ return String(value).trim();
26
+ }
27
+
28
+ function hydrateReviewVerdict(row) {
29
+ if (!row) return null;
30
+ const hydrated = { ...row };
31
+ for (const field of JSON_ARRAY_FIELDS) {
32
+ const jsonField = `${field}_json`;
33
+ hydrated[field] = JSON.parse(row[jsonField] || "[]");
34
+ delete hydrated[jsonField];
35
+ }
36
+ hydrated.supersedes_prior_review_id = hydrated.supersedes_prior_review_id || null;
37
+ return hydrated;
38
+ }
39
+
40
+ function reviewVerdictById(db, reviewId) {
41
+ return hydrateReviewVerdict(db.prepare("SELECT * FROM review_verdicts WHERE id = ?").get(reviewId));
42
+ }
43
+
44
+ function requireTicketForReview(ticketId, ctx) {
45
+ const { db, board, actor } = ctx;
46
+ const ticket = ticketById(db, ticketId);
47
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
48
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
49
+ return ticket;
50
+ }
51
+
52
+ export function createReviewVerdict(ticketId, body = {}, ctx) {
53
+ const { db, actor } = ctx;
54
+ const ticket = requireTicketForReview(ticketId, ctx);
55
+ requirePermission(actor, "write");
56
+
57
+ const verdict = normalizeReviewVerdict(requiredString(body.verdict, "verdict"));
58
+ const blockingFindings = normalizeJsonArray(body.blocking_findings, "blocking_findings");
59
+ const optionalFindings = normalizeJsonArray(body.optional_findings, "optional_findings");
60
+ const evidenceCommands = normalizeJsonArray(body.evidence_commands, "evidence_commands");
61
+ const supersedesPriorReviewId = optionalString(body.supersedes_prior_review_id) || null;
62
+
63
+ if (supersedesPriorReviewId) {
64
+ const prior = reviewVerdictById(db, supersedesPriorReviewId);
65
+ if (!prior) throw httpError(400, "invalid_supersedes_prior_review_id");
66
+ if (prior.ticket_id !== ticket.id) throw httpError(400, "superseded_review_ticket_mismatch");
67
+ }
68
+
69
+ const reviewId = id();
70
+ return tx(db, () => {
71
+ const time = now();
72
+ db.prepare(
73
+ `INSERT INTO review_verdicts
74
+ (id, ticket_id, verdict, blocking_findings_json, optional_findings_json,
75
+ evidence_commands_json, reviewer_profile, reviewer_session_id,
76
+ reviewed_commit_sha, dispatch_run_id, supersedes_prior_review_id,
77
+ created_by, created_at)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
79
+ ).run(
80
+ reviewId,
81
+ ticket.id,
82
+ verdict,
83
+ JSON.stringify(blockingFindings),
84
+ JSON.stringify(optionalFindings),
85
+ JSON.stringify(evidenceCommands),
86
+ optionalString(body.reviewer_profile),
87
+ optionalString(body.reviewer_session_id),
88
+ optionalString(body.reviewed_commit_sha),
89
+ optionalString(body.dispatch_run_id),
90
+ supersedesPriorReviewId,
91
+ actor.name,
92
+ time
93
+ );
94
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticket.id);
95
+ recordEvent(db, ticket.board_id, "review_verdict_created", ticket.id, actor.name, {
96
+ review_id: reviewId,
97
+ verdict,
98
+ supersedes_prior_review_id: supersedesPriorReviewId,
99
+ reviewed_commit_sha: optionalString(body.reviewed_commit_sha),
100
+ dispatch_run_id: optionalString(body.dispatch_run_id),
101
+ actor_type: actor.type,
102
+ actor_id: actor.id
103
+ });
104
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
105
+ return getReviewVerdict(reviewId, ctx);
106
+ });
107
+ }
108
+
109
+ export function getReviewVerdict(reviewId, ctx) {
110
+ const { db, board, actor } = ctx;
111
+ const review = reviewVerdictById(db, reviewId);
112
+ if (!review) throw httpError(404, "review_verdict_not_found");
113
+ const ticket = ticketById(db, review.ticket_id);
114
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "review_verdict_not_found");
115
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
116
+ return review;
117
+ }
118
+
119
+ export function listReviewVerdicts(ticketId, ctx, options = {}) {
120
+ const { db } = ctx;
121
+ const ticket = requireTicketForReview(ticketId, ctx);
122
+ const limit = normalizeLimit(options.limit);
123
+ return db
124
+ .prepare(
125
+ `SELECT * FROM review_verdicts
126
+ WHERE ticket_id = ?
127
+ ORDER BY created_at DESC, rowid DESC
128
+ LIMIT ?`
129
+ )
130
+ .all(ticket.id, limit)
131
+ .map(hydrateReviewVerdict);
132
+ }
133
+
134
+ function normalizeLimit(value) {
135
+ const n = Number(value ?? 50);
136
+ if (!Number.isInteger(n) || n <= 0) return 50;
137
+ return Math.min(n, 200);
138
+ }
@@ -28,7 +28,7 @@ import {
28
28
  updateBoard,
29
29
  updateBoardEntry
30
30
  } from "./boards.js";
31
- import { exportBoard, importBoardSnapshot } from "./snapshots.js";
31
+ import { exportBoard, importBoardSnapshot, importBoardSnapshotAsNewBoard } from "./snapshots.js";
32
32
  import {
33
33
  createState,
34
34
  deleteState,
@@ -67,9 +67,11 @@ import {
67
67
  completeTicket,
68
68
  getAgentDispatchPacket,
69
69
  getContextPack,
70
+ getContextPackFull,
70
71
  readComments,
71
72
  readTicket
72
73
  } from "./agent.js";
74
+ import { createReviewVerdict, getReviewVerdict, listReviewVerdicts } from "./review-verdicts.js";
73
75
 
74
76
  /** Build a ctx for an already-resolved registry row + actor. */
75
77
  function ctxFromBoardRow(boardRow, actor) {
@@ -282,6 +284,11 @@ export async function handleApi(req, res, url) {
282
284
  sendJson(res, 200, getContextPack(ticketId, ctx, depth, agentContextOptions(url)));
283
285
  return;
284
286
  }
287
+ if (sub === "context/full" && req.method === "GET") {
288
+ const depth = Number(url.searchParams.get("depth") || 1);
289
+ sendJson(res, 200, getContextPackFull(ticketId, ctx, depth, agentContextOptions(url)));
290
+ return;
291
+ }
285
292
  if ((sub === "dispatch-packet" || sub === "agent-dispatch-packet") && req.method === "GET") {
286
293
  sendJson(res, 200, getAgentDispatchPacket(ticketId, ctx, agentContextOptions(url)));
287
294
  return;
@@ -307,6 +314,15 @@ export async function handleApi(req, res, url) {
307
314
  sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
308
315
  return;
309
316
  }
317
+ if (sub === "review-verdicts" && req.method === "GET") {
318
+ sendJson(res, 200, { review_verdicts: listReviewVerdicts(ticketId, ctx, { limit: url.searchParams.get("limit") }) });
319
+ return;
320
+ }
321
+ if (sub === "review-verdicts" && req.method === "POST") {
322
+ const body = await readJson(req);
323
+ sendMutationJson(res, 201, createReviewVerdict(ticketId, body, ctx), ctx);
324
+ return;
325
+ }
310
326
  if (sub === "attachments" && req.method === "GET") {
311
327
  sendJson(res, 200, listTicketAttachments(ticketId, ctx));
312
328
  return;
@@ -336,6 +352,16 @@ export async function handleApi(req, res, url) {
336
352
  return;
337
353
  }
338
354
 
355
+ const reviewVerdictPath = url.pathname.match(/^\/api\/review-verdicts\/([^/]+)$/);
356
+ if (reviewVerdictPath && req.method === "GET") {
357
+ const reviewId = decodeURIComponent(reviewVerdictPath[1]);
358
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
359
+ const row = resolveBoardFromHint(boardId, boardSlug);
360
+ if (!row) throw httpError(400, "board_id_required");
361
+ sendJson(res, 200, getReviewVerdict(reviewId, ctxFromBoardRow(row, actor)));
362
+ return;
363
+ }
364
+
339
365
  // /api/states/:id PATCH/DELETE — board resolved from query hint or scan.
340
366
  const statePath = url.pathname.match(/^\/api\/states\/([^/]+)$/);
341
367
  if (statePath) {
@@ -471,13 +497,22 @@ export async function handleApi(req, res, url) {
471
497
  return;
472
498
  }
473
499
 
474
- // /api/admin/import — body.board_id required so we know which DB to import
475
- // into. Imports replace within one board's DB only.
500
+ // /api/admin/import — by default callers import into a brand-new board.
501
+ // Legacy/advanced replace imports require body.board_id so we know which
502
+ // board DB to replace. Replacement is destructive within that one board DB.
476
503
  if (req.method === "POST" && url.pathname === "/api/admin/import") {
477
504
  const body = await readJson(req);
478
505
  const hintId = body.board_id || url.searchParams.get("board_id");
479
506
  const hintSlug = body.board || url.searchParams.get("board");
480
507
  const row = resolveBoardFromHint(hintId, hintSlug);
508
+ if (body.create_new) {
509
+ const seedCtx = row ? ctxFromBoardRow(row, actor) : { actor, board: {}, db: null };
510
+ const result = importBoardSnapshotAsNewBoard(body, seedCtx);
511
+ sendJson(res, 201, result);
512
+ const newRow = getBoardByRegistryId(result.imported_board_id);
513
+ if (newRow) scheduleAutomaticBoardBackup(newRow, openBoardDb(newRow));
514
+ return;
515
+ }
481
516
  if (!row) throw httpError(400, "board_id_required");
482
517
  const ctx = ctxFromBoardRow(row, actor);
483
518
  sendMutationJson(res, 201, importBoardSnapshot(body, ctx), ctx);
File without changes
package/src/core/seed.js CHANGED
File without changes