@starlove/orbit 1.0.7 → 1.1.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.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +26 -1
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.a4f045fc.js +676 -0
- package/dist/full/js/app.a4f045fc.js.map +7 -0
- package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +23 -5
- package/docs/MCP_SETUP.md +5 -3
- package/docs/ORBIT_DISPATCH.md +185 -0
- package/docs/setup/orbit-init-wizard.md +174 -0
- package/package.json +2 -2
- package/public/js/actor-labels.js +65 -0
- package/public/js/app.js +8 -1
- package/public/js/card-actions.js +526 -0
- package/public/js/config.js +2 -1
- package/public/js/format.js +204 -10
- package/public/js/kanban.js +15 -0
- package/public/js/local-backend.js +24 -8
- package/public/js/router.js +3 -2
- package/public/js/search.js +6 -3
- package/public/js/settings.js +96 -26
- package/public/js/state.js +50 -0
- package/public/js/ticket-detail.js +428 -125
- package/public/styles.css +836 -39
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +114 -24
- package/src/core/agents-md.js +15 -10
- package/src/core/attachments.js +238 -0
- package/src/core/auth.js +36 -0
- package/src/core/backups.js +9 -3
- package/src/core/board.js +32 -1
- package/src/core/boards.js +1 -1
- package/src/core/db.js +42 -2
- package/src/core/delete-board.js +87 -21
- package/src/core/dispatch.js +532 -0
- package/src/core/http.js +31 -11
- package/src/core/migrate.js +198 -0
- package/src/core/paths.js +6 -0
- package/src/core/registry.js +12 -9
- package/src/core/router.js +81 -7
- package/src/core/search.js +83 -10
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +109 -46
- package/src/mcp-server.js +238 -12
- package/src/server.js +20 -0
- package/dist/full/js/app.f4fd5e94.js +0 -604
- package/dist/full/js/app.f4fd5e94.js.map +0 -7
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { extname, join, resolve } from "node:path";
|
|
4
|
+
import { localOwnerActor } from "./auth.js";
|
|
5
|
+
import { DISPATCH_RUNS_DIR } from "./paths.js";
|
|
6
|
+
import { getContextPack } from "./agent.js";
|
|
7
|
+
import { openBoardDb, getBoardByRegistryId, getBoardByRepoPath, getBoardBySlug, touchBoardActive } from "./registry.js";
|
|
8
|
+
import { stateByName, stateByRole, ticketById, ticketByNumber, unresolvedBlockers } from "./queries.js";
|
|
9
|
+
import { createRegistrySchema } from "./db.js";
|
|
10
|
+
import { createComment, updateTicket } from "./tickets.js";
|
|
11
|
+
import { id, normalizePath, slugify } from "./util.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_POLICY = "agent-safe";
|
|
14
|
+
|
|
15
|
+
function shortId() {
|
|
16
|
+
return id().replace(/-/g, "").slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cleanName(value, fallback = "agent") {
|
|
20
|
+
const cleaned = slugify(String(value || fallback)).replace(/^-+|-+$/g, "");
|
|
21
|
+
return cleaned || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveBoard(options) {
|
|
25
|
+
if (options.board) {
|
|
26
|
+
const row = getBoardByRegistryId(options.board) || getBoardBySlug(options.board);
|
|
27
|
+
if (!row) throw new Error(`Board not found: ${options.board}`);
|
|
28
|
+
return row;
|
|
29
|
+
}
|
|
30
|
+
const cwd = normalizePath(resolve(options.cwd));
|
|
31
|
+
const row = getBoardByRepoPath(cwd);
|
|
32
|
+
if (!row) throw new Error(`No Orbit board registered for ${cwd}; pass --board <slug-or-id>.`);
|
|
33
|
+
return row;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveTicket(db, boardId, ticketRef) {
|
|
37
|
+
if (ticketRef === undefined || ticketRef === null || ticketRef === "") {
|
|
38
|
+
throw new Error("--ticket <number-or-id> is required");
|
|
39
|
+
}
|
|
40
|
+
const text = String(ticketRef);
|
|
41
|
+
const byNumber = /^\d+$/.test(text) ? ticketByNumber(db, boardId, Number(text)) : null;
|
|
42
|
+
const ticket = byNumber || ticketById(db, text);
|
|
43
|
+
if (!ticket || ticket.board_id !== boardId) throw new Error(`Ticket not found on board: ${ticketRef}`);
|
|
44
|
+
if (ticket.archived_at) throw new Error(`Ticket #${ticket.number} is archived and cannot be dispatched.`);
|
|
45
|
+
return ticket;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function remoteDispatchError(options) {
|
|
49
|
+
if (!options.remote && !options.serverUrl) return null;
|
|
50
|
+
const target = options.serverUrl ? ` (${options.serverUrl})` : "";
|
|
51
|
+
return [
|
|
52
|
+
`Remote dispatch is not supported by the local CLI path yet${target}.`,
|
|
53
|
+
"`orbit dispatch` currently mutates a board through the local registry and SQLite board file only.",
|
|
54
|
+
"Run it on the board host without --server-url/--remote, or use remote MCP/manual orchestration for hosted boards."
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveExecutable(command) {
|
|
59
|
+
const executable = command || "hermes";
|
|
60
|
+
if ((executable.includes("/") || executable.includes("\\")) && existsSync(executable)) return executable;
|
|
61
|
+
const result = spawnSync("sh", ["-lc", `command -v ${q(executable)}`], { encoding: "utf8" });
|
|
62
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveCommandSpec(command) {
|
|
66
|
+
const executable = resolveExecutable(command);
|
|
67
|
+
if (!executable) return null;
|
|
68
|
+
if (process.platform === "win32" && existsSync(executable)) {
|
|
69
|
+
const extension = extname(executable).toLowerCase();
|
|
70
|
+
if (!extension || extension === ".js") {
|
|
71
|
+
try {
|
|
72
|
+
const firstLine = readFileSync(executable, "utf8").split(/\r?\n/, 1)[0] || "";
|
|
73
|
+
if (/^#!.*\bnode\b/i.test(firstLine)) {
|
|
74
|
+
return { command: process.execPath, argsPrefix: [executable], display: executable };
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Fall through to direct execution for non-text executables.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { command: executable, argsPrefix: [], display: executable };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function preflightHermes(options, profile) {
|
|
85
|
+
const hermes = resolveCommandSpec(options.hermesBin || "hermes");
|
|
86
|
+
if (!hermes) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Hermes binary not found: ${options.hermesBin || "hermes"}. Install Hermes or pass --hermes-bin <path>. ` +
|
|
89
|
+
"For prepare-only handoff without spawning, rerun with --no-spawn."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const result = spawnSync(hermes.command, [...hermes.argsPrefix, "-p", profile, "--help"], { encoding: "utf8" });
|
|
93
|
+
if (result.error) {
|
|
94
|
+
throw new Error(`Hermes preflight failed for profile ${profile}: ${result.error.message}. Try --no-spawn for prepare-only.`);
|
|
95
|
+
}
|
|
96
|
+
if (result.status !== 0) {
|
|
97
|
+
const detail = (result.stderr || result.stdout || "").trim();
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Hermes preflight failed for profile ${profile}${detail ? `: ${detail}` : ""}. ` +
|
|
100
|
+
"Check the profile name or rerun with --no-spawn for prepare-only."
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return hermes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveRepoRoot(options, boardRow) {
|
|
107
|
+
const cwd = normalizePath(resolve(options.cwd));
|
|
108
|
+
if (options.cwdProvided || existsSync(join(cwd, ".git"))) return cwd;
|
|
109
|
+
if (boardRow.repo_path && existsSync(join(boardRow.repo_path, ".git"))) return boardRow.repo_path;
|
|
110
|
+
return cwd;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function commandOutput(command, args, options = {}) {
|
|
114
|
+
const result = spawnSync(command, args, { encoding: "utf8", ...options });
|
|
115
|
+
if (result.error) throw result.error;
|
|
116
|
+
if (result.status !== 0) {
|
|
117
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
118
|
+
throw new Error(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`);
|
|
119
|
+
}
|
|
120
|
+
return String(result.stdout || "").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findExecutable(name) {
|
|
124
|
+
const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
|
|
125
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function q(value) {
|
|
129
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function createWrapper(path, content) {
|
|
133
|
+
writeFileSync(path, content, "utf8");
|
|
134
|
+
chmodSync(path, 0o755);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createWindowsCmdWrapper(path, blockedMessage) {
|
|
138
|
+
writeFileSync(
|
|
139
|
+
`${path}.cmd`,
|
|
140
|
+
`@echo off\r\necho ${blockedMessage} 1>&2\r\nexit /b 126\r\n`,
|
|
141
|
+
"utf8"
|
|
142
|
+
);
|
|
143
|
+
chmodSync(`${path}.cmd`, 0o755);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function createPolicyBin(runDir, policyName) {
|
|
147
|
+
if (!policyName || policyName === "none") return null;
|
|
148
|
+
const binDir = join(runDir, "policy-bin");
|
|
149
|
+
mkdirSync(binDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const realGit = findExecutable("git") || "/usr/bin/git";
|
|
152
|
+
const realNpm = findExecutable("npm") || "/usr/bin/npm";
|
|
153
|
+
const realPnpm = findExecutable("pnpm") || "/usr/bin/pnpm";
|
|
154
|
+
const realYarn = findExecutable("yarn") || "/usr/bin/yarn";
|
|
155
|
+
const realRm = findExecutable("rm") || "/usr/bin/rm";
|
|
156
|
+
|
|
157
|
+
createWrapper(
|
|
158
|
+
join(binDir, "git"),
|
|
159
|
+
`#!/bin/sh
|
|
160
|
+
cmd="\${1:-}"
|
|
161
|
+
shift 2>/dev/null || true
|
|
162
|
+
block() {
|
|
163
|
+
echo "Blocked by Orbit ${policyName} policy: git $cmd $*" >&2
|
|
164
|
+
exit 126
|
|
165
|
+
}
|
|
166
|
+
case "$cmd" in
|
|
167
|
+
status|diff|log|show|add|rev-parse|describe|ls-files|grep|merge-base)
|
|
168
|
+
exec ${q(realGit)} "$cmd" "$@"
|
|
169
|
+
;;
|
|
170
|
+
commit)
|
|
171
|
+
for arg in "$@"; do
|
|
172
|
+
case "$arg" in --amend|--fixup=*|--squash=*) block "$@" ;; esac
|
|
173
|
+
done
|
|
174
|
+
exec ${q(realGit)} "$cmd" "$@"
|
|
175
|
+
;;
|
|
176
|
+
branch)
|
|
177
|
+
for arg in "$@"; do
|
|
178
|
+
case "$arg" in -d|-D|--delete|-m|-M|--move) block "$@" ;; esac
|
|
179
|
+
done
|
|
180
|
+
exec ${q(realGit)} "$cmd" "$@"
|
|
181
|
+
;;
|
|
182
|
+
push|reset|clean|rebase|merge|checkout|switch|restore|tag|cherry-pick|worktree)
|
|
183
|
+
block "$@"
|
|
184
|
+
;;
|
|
185
|
+
*)
|
|
186
|
+
echo "Blocked by Orbit ${policyName} policy: git $cmd (allowed: status, diff, log, show, add, commit, safe branch inspection/creation, rev-parse, describe, ls-files, grep, merge-base)" >&2
|
|
187
|
+
exit 126
|
|
188
|
+
;;
|
|
189
|
+
esac
|
|
190
|
+
`
|
|
191
|
+
);
|
|
192
|
+
if (process.platform === "win32") {
|
|
193
|
+
createWindowsCmdWrapper(join(binDir, "git"), `Blocked by Orbit ${policyName} policy: git %~1`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const packageWrapper = (realCommand, name) => `#!/bin/sh
|
|
197
|
+
cmd="\${1:-}"
|
|
198
|
+
script="\${2:-}"
|
|
199
|
+
case "$cmd" in
|
|
200
|
+
test)
|
|
201
|
+
exec ${q(realCommand)} "$@"
|
|
202
|
+
;;
|
|
203
|
+
run)
|
|
204
|
+
case "$script" in
|
|
205
|
+
test|test:*|build|build:*|lint|lint:*|check|check:*|typecheck|typecheck:*)
|
|
206
|
+
exec ${q(realCommand)} "$@"
|
|
207
|
+
;;
|
|
208
|
+
esac
|
|
209
|
+
;;
|
|
210
|
+
publish|version|deploy|release)
|
|
211
|
+
echo "Blocked by Orbit ${policyName} policy: ${name} $cmd" >&2
|
|
212
|
+
exit 126
|
|
213
|
+
;;
|
|
214
|
+
esac
|
|
215
|
+
echo "Blocked by Orbit ${policyName} policy: ${name} $cmd (allowed package commands: test, run test/build/lint/check/typecheck)" >&2
|
|
216
|
+
exit 126
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
createWrapper(join(binDir, "npm"), packageWrapper(realNpm, "npm"));
|
|
220
|
+
createWrapper(join(binDir, "pnpm"), packageWrapper(realPnpm, "pnpm"));
|
|
221
|
+
createWrapper(join(binDir, "yarn"), packageWrapper(realYarn, "yarn"));
|
|
222
|
+
if (process.platform === "win32") {
|
|
223
|
+
createWindowsCmdWrapper(join(binDir, "npm"), `Blocked by Orbit ${policyName} policy: npm %1 (allowed package commands: test)`);
|
|
224
|
+
createWindowsCmdWrapper(join(binDir, "pnpm"), `Blocked by Orbit ${policyName} policy: pnpm %1 (allowed package commands: test)`);
|
|
225
|
+
createWindowsCmdWrapper(join(binDir, "yarn"), `Blocked by Orbit ${policyName} policy: yarn %1 (allowed package commands: test)`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
createWrapper(
|
|
229
|
+
join(binDir, "docker"),
|
|
230
|
+
`#!/bin/sh
|
|
231
|
+
echo "Blocked by Orbit ${policyName} policy: Docker requires explicit human approval." >&2
|
|
232
|
+
exit 126
|
|
233
|
+
`
|
|
234
|
+
);
|
|
235
|
+
if (process.platform === "win32") {
|
|
236
|
+
createWindowsCmdWrapper(join(binDir, "docker"), `Blocked by Orbit ${policyName} policy: Docker requires explicit human approval.`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const name of ["kubectl", "vercel", "netlify", "fly", "railway", "scp", "rsync", "ssh", "sudo", "gh", "curl", "wget", "aws", "gcloud", "az"]) {
|
|
240
|
+
createWrapper(
|
|
241
|
+
join(binDir, name),
|
|
242
|
+
`#!/bin/sh
|
|
243
|
+
echo "Blocked by Orbit ${policyName} policy: ${name} is outside this autonomous run." >&2
|
|
244
|
+
exit 126
|
|
245
|
+
`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
createWrapper(
|
|
250
|
+
join(binDir, "rm"),
|
|
251
|
+
`#!/bin/sh
|
|
252
|
+
for arg in "$@"; do
|
|
253
|
+
case "$arg" in
|
|
254
|
+
-r|-R|-f|-rf|-fr|-Rf|-fR|--recursive|--force)
|
|
255
|
+
echo "Blocked by Orbit ${policyName} policy: recursive/force rm requires human approval." >&2
|
|
256
|
+
exit 126
|
|
257
|
+
;;
|
|
258
|
+
esac
|
|
259
|
+
done
|
|
260
|
+
exec ${q(realRm)} "$@"
|
|
261
|
+
`
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return binDir;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildHandoff({ boardRow, context, profile, repoRoot, worktreePath, branchName, policyName, runId, serverUrl }) {
|
|
268
|
+
const { ticket, board, board_manual: manual, parent_ticket: parent, child_tickets: children, blockers } = context;
|
|
269
|
+
const boardEntries = (manual.entries || [])
|
|
270
|
+
.slice(0, 12)
|
|
271
|
+
.map((entry) => `- ${entry.type}: ${entry.title}\n ${entry.body}`)
|
|
272
|
+
.join("\n");
|
|
273
|
+
const blockerText = blockers.length
|
|
274
|
+
? blockers.map((blocker) => `- #${blocker.number} ${blocker.title} (${blocker.state_name})`).join("\n")
|
|
275
|
+
: "None.";
|
|
276
|
+
const childText = children.length
|
|
277
|
+
? children.map((child) => `- #${child.number} ${child.title} (${child.state_name})`).join("\n")
|
|
278
|
+
: "None.";
|
|
279
|
+
|
|
280
|
+
return `# Orbit Agent Handoff
|
|
281
|
+
|
|
282
|
+
Run ID: ${runId}
|
|
283
|
+
Agent profile: ${profile}
|
|
284
|
+
Board: ${board.slug} — ${board.name}
|
|
285
|
+
Ticket: #${ticket.number} — ${ticket.title}
|
|
286
|
+
Ticket id: ${ticket.id}
|
|
287
|
+
Ticket type: ${ticket.type}
|
|
288
|
+
Initial state: ${ticket.state_name}
|
|
289
|
+
Repository root: ${repoRoot}
|
|
290
|
+
Worktree: ${worktreePath || repoRoot}
|
|
291
|
+
Branch: ${branchName || "current branch"}
|
|
292
|
+
${serverUrl ? `Orbit server: ${serverUrl}` : ""}
|
|
293
|
+
|
|
294
|
+
## Mission
|
|
295
|
+
Implement the ticket faithfully. Treat this ticket as the source of truth for communication between the human, orchestrator, and dispatched agent.
|
|
296
|
+
|
|
297
|
+
## Read first
|
|
298
|
+
1. AGENTS.md in the repository.
|
|
299
|
+
2. SKILL-ORBIT.md in the repository.
|
|
300
|
+
3. This AI Written-Plan handoff.
|
|
301
|
+
4. The ticket description, implementation fields, blockers, parent/child cards, and board journal entries below.
|
|
302
|
+
|
|
303
|
+
## Ticket description
|
|
304
|
+
${ticket.description || "(No description.)"}
|
|
305
|
+
|
|
306
|
+
## Implementation records
|
|
307
|
+
AI Written-Plan: ${ticket.ai_plan || "(Empty.)"}
|
|
308
|
+
|
|
309
|
+
AI Implementation Summary: ${ticket.implementation_summary || "(Empty.)"}
|
|
310
|
+
|
|
311
|
+
Implementation Updates/Lessons: ${ticket.implementation_updates || "(Empty.)"}
|
|
312
|
+
|
|
313
|
+
## Board agent instructions
|
|
314
|
+
${board.agent_instructions || "(No board-level agent instructions.)"}
|
|
315
|
+
|
|
316
|
+
## Board notes
|
|
317
|
+
${board.project_notes || "(No board notes.)"}
|
|
318
|
+
|
|
319
|
+
## Board journal: lessons and decisions
|
|
320
|
+
${boardEntries || "(No active board journal entries.)"}
|
|
321
|
+
|
|
322
|
+
## Parent ticket
|
|
323
|
+
${parent ? `#${parent.number} ${parent.title} (${parent.state_name})` : "None."}
|
|
324
|
+
|
|
325
|
+
## Child tickets
|
|
326
|
+
${childText}
|
|
327
|
+
|
|
328
|
+
## Unresolved blockers
|
|
329
|
+
${blockerText}
|
|
330
|
+
|
|
331
|
+
## Scope boundaries
|
|
332
|
+
- Keep changes local to this repository/worktree.
|
|
333
|
+
- Do not push, deploy, publish, or open/merge PRs.
|
|
334
|
+
- Do not run Docker unless the human operator explicitly approves it outside this autonomous run.
|
|
335
|
+
- Do not expose secrets, tokens, credentials, private keys, or connection strings.
|
|
336
|
+
- Prefer small, reviewable commits.
|
|
337
|
+
|
|
338
|
+
## Autonomous policy: ${policyName}
|
|
339
|
+
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
|
+
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
|
+
|
|
342
|
+
## Completion protocol
|
|
343
|
+
When done:
|
|
344
|
+
1. Run the relevant automated tests and any focused manual/API sanity checks.
|
|
345
|
+
2. Commit the work locally on the dispatch branch.
|
|
346
|
+
3. Update the ticket's AI Implementation Summary with what changed, commit SHA, branch/worktree, verification, and manual checks still needed.
|
|
347
|
+
4. Add Implementation Updates/Lessons for pitfalls, remediation, or future-agent guidance.
|
|
348
|
+
5. Add a completion comment/run record on the ticket.
|
|
349
|
+
6. Move the ticket to Review, not Done, unless the human operator explicitly requested auto-completion.
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function buildShortPrompt({ boardRow, ticket, profile, serverUrl }) {
|
|
354
|
+
return [
|
|
355
|
+
`You are ${profile}, dispatched by Orbit.`,
|
|
356
|
+
`Work board ${boardRow.slug}, ticket #${ticket.number}: ${ticket.title}.`,
|
|
357
|
+
"Read AGENTS.md and SKILL-ORBIT.md in this repository first.",
|
|
358
|
+
"The full generated handoff is stored on the ticket in AI Written-Plan; treat the Orbit ticket as source of truth.",
|
|
359
|
+
serverUrl ? `Orbit server URL: ${serverUrl}` : "Use local Orbit/MCP/API access available from this repo.",
|
|
360
|
+
"Follow the autonomous policy in the handoff. Do not push, deploy, publish, run Docker, or expose secrets.",
|
|
361
|
+
"When complete, commit locally, update AI Implementation Summary/Implementation Updates on the ticket, comment the run record, and move the ticket to Review."
|
|
362
|
+
].join("\n");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function ensureWorktree({ repoRoot, boardSlug, ticketNumber, profile, branchName, worktreePath }) {
|
|
366
|
+
commandOutput("git", ["-C", repoRoot, "rev-parse", "--show-toplevel"]);
|
|
367
|
+
const runBranch = branchName || `orbit/${cleanName(boardSlug)}-${ticketNumber}-${cleanName(profile)}-${shortId()}`;
|
|
368
|
+
const runWorktree = worktreePath || join(repoRoot, ".worktrees", `${cleanName(boardSlug)}-${ticketNumber}-${cleanName(profile)}-${shortId()}`);
|
|
369
|
+
mkdirSync(resolve(runWorktree, ".."), { recursive: true });
|
|
370
|
+
if (!existsSync(runWorktree)) {
|
|
371
|
+
commandOutput("git", ["-C", repoRoot, "worktree", "add", "-b", runBranch, runWorktree, "HEAD"]);
|
|
372
|
+
}
|
|
373
|
+
return { branchName: runBranch, worktreePath: runWorktree };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function dispatchTicket(options) {
|
|
377
|
+
const remoteError = remoteDispatchError(options);
|
|
378
|
+
if (remoteError) throw new Error(remoteError);
|
|
379
|
+
|
|
380
|
+
const profile = options.profile || "agent";
|
|
381
|
+
const policyName = options.policy === undefined ? DEFAULT_POLICY : options.policy;
|
|
382
|
+
createRegistrySchema();
|
|
383
|
+
const actor = localOwnerActor();
|
|
384
|
+
const boardRow = resolveBoard(options);
|
|
385
|
+
const db = openBoardDb(boardRow);
|
|
386
|
+
const ctx = { actor, board: boardRow, db };
|
|
387
|
+
|
|
388
|
+
const ticket = resolveTicket(db, boardRow.id, options.ticket);
|
|
389
|
+
const blockers = unresolvedBlockers(db, ticket.id);
|
|
390
|
+
if (blockers.length && !options.force) {
|
|
391
|
+
throw new Error(`Ticket #${ticket.number} is blocked: ${blockers.map((b) => `#${b.number} ${b.title}`).join(", ")}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const repoRoot = resolveRepoRoot(options, boardRow);
|
|
395
|
+
const inProgress = stateByRole(db, boardRow.id, "in_progress") || stateByName(db, boardRow.id, "In Progress");
|
|
396
|
+
if (!options.noSpawn && !options.dryRun && !inProgress) throw new Error("Board is missing an In Progress state.");
|
|
397
|
+
|
|
398
|
+
if (options.dryRun) {
|
|
399
|
+
return {
|
|
400
|
+
dry_run: true,
|
|
401
|
+
no_spawn: Boolean(options.noSpawn),
|
|
402
|
+
run_id: null,
|
|
403
|
+
board: { id: boardRow.id, slug: boardRow.slug, name: boardRow.name },
|
|
404
|
+
ticket: { id: ticket.id, number: ticket.number, title: ticket.title },
|
|
405
|
+
profile,
|
|
406
|
+
policy: policyName || "none",
|
|
407
|
+
repo_root: repoRoot,
|
|
408
|
+
worktree_path: repoRoot,
|
|
409
|
+
branch: options.branch || "",
|
|
410
|
+
handoff_path: null,
|
|
411
|
+
policy_bin: null,
|
|
412
|
+
pid: null,
|
|
413
|
+
spawned: false,
|
|
414
|
+
prompt: "",
|
|
415
|
+
run_record: ""
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const hermesExecutable = options.noSpawn ? null : preflightHermes(options, profile);
|
|
420
|
+
const runId = `orbit-${ticket.number}-${profile}-${shortId()}`;
|
|
421
|
+
const runDir = join(DISPATCH_RUNS_DIR, boardRow.slug, runId);
|
|
422
|
+
const serverUrl = options.serverUrl || process.env.ORBIT_SERVER_URL || "";
|
|
423
|
+
let branchName = options.branch || "";
|
|
424
|
+
let worktreePath = "";
|
|
425
|
+
|
|
426
|
+
if (options.worktree) {
|
|
427
|
+
const worktree = ensureWorktree({
|
|
428
|
+
repoRoot,
|
|
429
|
+
boardSlug: boardRow.slug,
|
|
430
|
+
ticketNumber: ticket.number,
|
|
431
|
+
profile,
|
|
432
|
+
branchName: options.branch,
|
|
433
|
+
worktreePath: options.worktreePath
|
|
434
|
+
});
|
|
435
|
+
branchName = worktree.branchName;
|
|
436
|
+
worktreePath = worktree.worktreePath;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
mkdirSync(runDir, { recursive: true });
|
|
440
|
+
const policyBin = createPolicyBin(runDir, policyName);
|
|
441
|
+
const context = getContextPack(ticket.id, ctx, 1);
|
|
442
|
+
const handoff = buildHandoff({
|
|
443
|
+
boardRow,
|
|
444
|
+
context,
|
|
445
|
+
profile,
|
|
446
|
+
repoRoot,
|
|
447
|
+
worktreePath,
|
|
448
|
+
branchName,
|
|
449
|
+
policyName,
|
|
450
|
+
runId,
|
|
451
|
+
serverUrl
|
|
452
|
+
});
|
|
453
|
+
const prompt = buildShortPrompt({ boardRow, ticket, profile, serverUrl });
|
|
454
|
+
const handoffPath = join(runDir, "handoff.md");
|
|
455
|
+
writeFileSync(handoffPath, handoff, "utf8");
|
|
456
|
+
|
|
457
|
+
touchBoardActive(boardRow.id);
|
|
458
|
+
if (options.noSpawn) {
|
|
459
|
+
updateTicket(ticket.id, { ai_plan: handoff }, ctx);
|
|
460
|
+
} else {
|
|
461
|
+
updateTicket(ticket.id, { ai_plan: handoff, state_id: inProgress.id }, ctx);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const hermesArgs = [];
|
|
465
|
+
if (profile) hermesArgs.push("-p", profile);
|
|
466
|
+
if (options.yolo) hermesArgs.push("--yolo");
|
|
467
|
+
hermesArgs.push("chat", "-q", prompt);
|
|
468
|
+
const cwd = worktreePath || repoRoot;
|
|
469
|
+
const env = {
|
|
470
|
+
...process.env,
|
|
471
|
+
ORBIT_DISPATCH_RUN_ID: runId,
|
|
472
|
+
ORBIT_BOARD: boardRow.slug,
|
|
473
|
+
ORBIT_TICKET: String(ticket.number),
|
|
474
|
+
ORBIT_TICKET_ID: ticket.id,
|
|
475
|
+
ORBIT_HANDOFF_PATH: handoffPath,
|
|
476
|
+
ORBIT_AUTONOMOUS_POLICY: policyName || "none",
|
|
477
|
+
...(policyBin ? { PATH: `${policyBin}:${process.env.PATH || ""}` } : {})
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
let child = null;
|
|
481
|
+
if (!options.noSpawn) {
|
|
482
|
+
child = spawn(hermesExecutable.command, [...hermesExecutable.argsPrefix, ...hermesArgs], {
|
|
483
|
+
cwd,
|
|
484
|
+
env,
|
|
485
|
+
detached: !options.foreground,
|
|
486
|
+
stdio: options.foreground ? "inherit" : "ignore"
|
|
487
|
+
});
|
|
488
|
+
if (!options.foreground) child.unref();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const runRecord = [
|
|
492
|
+
`Orbit dispatch run ${options.noSpawn ? "prepared" : "started"}.`,
|
|
493
|
+
`- run_id: ${runId}`,
|
|
494
|
+
`- profile: ${profile}`,
|
|
495
|
+
`- policy: ${policyName || "none"}`,
|
|
496
|
+
`- ticket: #${ticket.number} ${ticket.title}`,
|
|
497
|
+
`- branch: ${branchName || "current branch"}`,
|
|
498
|
+
`- worktree: ${worktreePath || repoRoot}`,
|
|
499
|
+
`- handoff: AI Written-Plan field${options.keepHandoffFile ? ` and ${handoffPath}` : ""}`,
|
|
500
|
+
`- policy_bin: ${policyBin || "none"}`,
|
|
501
|
+
`- pid: ${child?.pid || "not spawned"}`,
|
|
502
|
+
`- command: ${options.hermesBin || "hermes"} ${hermesArgs.map((arg) => (arg.includes(" ") ? q(arg) : arg)).join(" ")}`,
|
|
503
|
+
options.noSpawn ? "- mode: prepare-only; no agent spawned and ticket state left unchanged" : "- mode: spawned; ticket moved to In Progress"
|
|
504
|
+
].join("\n");
|
|
505
|
+
|
|
506
|
+
createComment(ticket.id, { author: "orbit dispatch", kind: "agent_note", body: runRecord }, ctx);
|
|
507
|
+
|
|
508
|
+
if (!options.keepHandoffFile) {
|
|
509
|
+
// The canonical copy is the ticket's AI Written-Plan. Keep run metadata and
|
|
510
|
+
// policy wrappers. Local-only agents may still use ORBIT_HANDOFF_PATH as a
|
|
511
|
+
// bootstrap hint before they read the card, so leave the file in place.
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
dry_run: false,
|
|
516
|
+
no_spawn: Boolean(options.noSpawn),
|
|
517
|
+
run_id: runId,
|
|
518
|
+
board: { id: boardRow.id, slug: boardRow.slug, name: boardRow.name },
|
|
519
|
+
ticket: { id: ticket.id, number: ticket.number, title: ticket.title },
|
|
520
|
+
profile,
|
|
521
|
+
policy: policyName || "none",
|
|
522
|
+
repo_root: repoRoot,
|
|
523
|
+
worktree_path: worktreePath || repoRoot,
|
|
524
|
+
branch: branchName || "",
|
|
525
|
+
handoff_path: handoffPath,
|
|
526
|
+
policy_bin: policyBin,
|
|
527
|
+
pid: child?.pid || null,
|
|
528
|
+
spawned: Boolean(child),
|
|
529
|
+
prompt,
|
|
530
|
+
run_record: runRecord
|
|
531
|
+
};
|
|
532
|
+
}
|
package/src/core/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
2
3
|
import { extname, resolve as resolvePath } from "node:path";
|
|
3
4
|
import { PUBLIC_DIR } from "./paths.js";
|
|
4
5
|
import { httpError } from "./util.js";
|
|
@@ -6,7 +7,10 @@ import { httpError } from "./util.js";
|
|
|
6
7
|
export function setCors(res) {
|
|
7
8
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
8
9
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
9
|
-
res.setHeader(
|
|
10
|
+
res.setHeader(
|
|
11
|
+
"Access-Control-Allow-Headers",
|
|
12
|
+
"Content-Type,Authorization,X-Orbit-Actor-Type,X-Orbit-Actor-Name,X-Orbit-Actor-Id,X-Orbit-Agent-Name,X-File-Name"
|
|
13
|
+
);
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export function sendJson(res, status, body) {
|
|
@@ -21,16 +25,12 @@ export function sendEmpty(res, status) {
|
|
|
21
25
|
res.end();
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
chunks.push(chunk);
|
|
31
|
-
}
|
|
32
|
-
if (chunks.length === 0) return {};
|
|
33
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
export const MAX_JSON_BODY_BYTES = 100 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
export async function readJson(req, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
31
|
+
const buffer = await readRaw(req, maxBytes);
|
|
32
|
+
if (buffer.length === 0) return {};
|
|
33
|
+
const text = buffer.toString("utf8");
|
|
34
34
|
if (!text.trim()) return {};
|
|
35
35
|
try {
|
|
36
36
|
return JSON.parse(text);
|
|
@@ -39,6 +39,26 @@ export async function readJson(req) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export async function readRaw(req, maxBytes) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
size += chunk.length;
|
|
47
|
+
if (size > maxBytes) throw httpError(413, "payload_too_large");
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(chunks);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sendFile(res, filePath, mimeType) {
|
|
54
|
+
setCors(res);
|
|
55
|
+
res.writeHead(200, {
|
|
56
|
+
"Content-Type": mimeType || contentType(filePath),
|
|
57
|
+
"Cache-Control": "private, max-age=3600"
|
|
58
|
+
});
|
|
59
|
+
createReadStream(filePath).pipe(res);
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
function contentType(filePath) {
|
|
43
63
|
const types = {
|
|
44
64
|
".html": "text/html; charset=utf-8",
|