@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/SKILL-ORBIT.md +2 -0
- package/build.mjs +11 -5
- package/dist/full/index.html +169 -20
- package/dist/full/js/app.ccc442f7.js +706 -0
- package/dist/full/js/app.ccc442f7.js.map +7 -0
- package/dist/full/styles.baccc8e3.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/package.json +19 -18
- package/public/index.html +167 -18
- package/public/js/board-menu.js +16 -2
- package/public/js/card-actions.js +19 -0
- package/public/js/create-card.js +1 -1
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +7 -8
- package/public/js/kanban.js +167 -101
- package/public/js/main.js +2 -1
- package/public/js/motion-preference.js +14 -3
- package/public/js/search.js +1 -1
- package/public/js/settings.js +162 -46
- package/public/js/ticket-detail.js +92 -24
- package/public/styles.css +569 -476
- package/src/cli/orbit.js +22 -0
- package/src/core/board.js +1 -0
- package/src/core/css-bundle.js +29 -0
- package/src/core/db.js +19 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/http.js +10 -3
- package/src/core/registry.js +2 -2
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/snapshots.js +429 -9
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
- package/dist/full/styles.836872e7.css +0 -5962
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;
|
package/src/core/dispatch.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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 =
|
|
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"
|
package/src/core/registry.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|