@starlove/orbit 1.1.2 → 1.2.0

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.
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/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";
@@ -0,0 +1,29 @@
1
+ // CSS bundling seam for Starscape UI package imports.
2
+ //
3
+ // public/styles.css remains Orbit's editable source of truth, but it can now
4
+ // import @starlove/ui component CSS. Static builds and source dev serving both
5
+ // pass that source through esbuild so browsers never have to resolve package
6
+ // specifiers from raw CSS.
7
+
8
+ const CURSOR_ASSET_LOADERS = {
9
+ ".cur": "file"
10
+ };
11
+
12
+ export async function bundleCss({ entryPoint, outfile, minify = false }) {
13
+ const { build } = await import("esbuild");
14
+ const result = await build({
15
+ entryPoints: [entryPoint],
16
+ bundle: true,
17
+ target: "es2022",
18
+ minify,
19
+ sourcemap: false,
20
+ write: false,
21
+ outfile,
22
+ assetNames: "cursors/[name]",
23
+ loader: CURSOR_ASSET_LOADERS,
24
+ legalComments: "none"
25
+ });
26
+ const cssFile = result.outputFiles.find((file) => file.path.endsWith(".css"));
27
+ if (!cssFile) throw new Error("CSS bundle did not produce a stylesheet");
28
+ return { cssFile, outputFiles: result.outputFiles };
29
+ }
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;
@@ -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
 
@@ -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
  }
package/src/core/http.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { createReadStream } from "node:fs";
3
- import { extname, resolve as resolvePath } from "node:path";
4
- import { PUBLIC_DIR } from "./paths.js";
3
+ import { extname, join, resolve as resolvePath } from "node:path";
4
+ import { bundleCss } from "./css-bundle.js";
5
+ import { PUBLIC_DIR, ROOT_DIR } from "./paths.js";
5
6
  import { httpError } from "./util.js";
6
7
 
7
8
  export function setCors(res) {
@@ -70,6 +71,10 @@ function contentType(filePath) {
70
71
  return types[extname(filePath)] || "application/octet-stream";
71
72
  }
72
73
 
74
+ function shouldBundleSourceStyles(pathname, filePath) {
75
+ return pathname === "/styles.css" && filePath === join(ROOT_DIR, "public", "styles.css");
76
+ }
77
+
73
78
  export async function serveStatic(req, res, pathname) {
74
79
  if (req.method !== "GET" && req.method !== "HEAD") {
75
80
  sendEmpty(res, 405);
@@ -84,7 +89,9 @@ export async function serveStatic(req, res, pathname) {
84
89
  }
85
90
 
86
91
  try {
87
- const data = await readFile(filePath);
92
+ const data = shouldBundleSourceStyles(pathname, filePath)
93
+ ? Buffer.from((await bundleCss({ entryPoint: filePath, outfile: filePath })).cssFile.contents)
94
+ : await readFile(filePath);
88
95
  res.writeHead(200, {
89
96
  "Content-Type": contentType(filePath),
90
97
  "Cache-Control": "no-store"
@@ -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();
@@ -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
+ }