@starlove/orbit 1.0.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/.dockerignore +13 -0
- package/Dockerfile +34 -0
- package/README.md +216 -0
- package/SKILL-ORBIT.md +94 -0
- package/build.mjs +204 -0
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +314 -0
- package/dist/full/js/app.70be7d06.js +554 -0
- package/dist/full/js/app.70be7d06.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.c2e3d20a.css +4892 -0
- package/dist/full/version.json +6 -0
- package/docs/AGENT_PROTOCOL.md +379 -0
- package/docs/MCP_SETUP.md +49 -0
- package/docs/mcp-client.example.json +13 -0
- package/package.json +62 -0
- package/pnpm-workspace.yaml +2 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +314 -0
- package/public/js/api.js +35 -0
- package/public/js/app.js +35 -0
- package/public/js/board-menu.js +145 -0
- package/public/js/config.js +19 -0
- package/public/js/create-card.js +153 -0
- package/public/js/dom.js +19 -0
- package/public/js/drawer.js +137 -0
- package/public/js/format.js +108 -0
- package/public/js/kanban.js +475 -0
- package/public/js/local-backend.js +934 -0
- package/public/js/main.js +142 -0
- package/public/js/modal.js +31 -0
- package/public/js/router.js +114 -0
- package/public/js/search.js +53 -0
- package/public/js/settings-tabs.js +7 -0
- package/public/js/settings.js +931 -0
- package/public/js/sse.js +156 -0
- package/public/js/starscape.js +315 -0
- package/public/js/state.js +54 -0
- package/public/js/ticket-detail.js +766 -0
- package/public/js/toast.js +74 -0
- package/public/js/transport.js +22 -0
- package/public/js/unread.js +121 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +4892 -0
- package/src/cli/orbit.js +438 -0
- package/src/core/agent.js +262 -0
- package/src/core/auth.js +49 -0
- package/src/core/backups.js +136 -0
- package/src/core/board.js +112 -0
- package/src/core/boards.js +310 -0
- package/src/core/bootstrap.js +162 -0
- package/src/core/db.js +237 -0
- package/src/core/delete-board.js +53 -0
- package/src/core/events.js +314 -0
- package/src/core/http.js +81 -0
- package/src/core/paths.js +25 -0
- package/src/core/provision-repo-board.js +113 -0
- package/src/core/queries.js +246 -0
- package/src/core/registry.js +207 -0
- package/src/core/relations.js +163 -0
- package/src/core/router.js +414 -0
- package/src/core/search.js +70 -0
- package/src/core/seed.js +226 -0
- package/src/core/snapshots.js +255 -0
- package/src/core/states.js +167 -0
- package/src/core/system-picker.js +93 -0
- package/src/core/tickets.js +344 -0
- package/src/core/util.js +105 -0
- package/src/mcp-server.js +773 -0
- package/src/server.js +48 -0
package/src/cli/orbit.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Orbit CLI — `orbit init` provisions `.orbit/board.db`, registry row, and
|
|
4
|
+
* copies SKILL-ORBIT.md into the target repo. Does not import board.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
13
|
+
|
|
14
|
+
function printUsage() {
|
|
15
|
+
console.log(`Usage:
|
|
16
|
+
orbit init [options] Provision .orbit/board.db + SKILL-ORBIT.md + AGENTS.md
|
|
17
|
+
orbit reset [options] Delete .orbit/ + SKILL-ORBIT.md + registry row
|
|
18
|
+
orbit serve [options] Start the Orbit web app + HTTP API
|
|
19
|
+
orbit docker [options] Build/run Orbit's web app in Docker
|
|
20
|
+
orbit mcp [options] Start the stdio MCP server (for agent clients)
|
|
21
|
+
|
|
22
|
+
Options (init / reset / serve / docker / mcp):
|
|
23
|
+
--cwd <dir> Project root (default: process.cwd())
|
|
24
|
+
|
|
25
|
+
Options (init only):
|
|
26
|
+
--ai Enable AI collaboration lanes
|
|
27
|
+
--example Create example onboarding tickets
|
|
28
|
+
--refresh-agents-md Overwrite SKILL-ORBIT.md and refresh Orbit section in AGENTS.md
|
|
29
|
+
|
|
30
|
+
Options (serve / docker):
|
|
31
|
+
--port <n> HTTP port (default: 3337, or $PORT)
|
|
32
|
+
|
|
33
|
+
Options (docker only):
|
|
34
|
+
--image <name> Docker image tag (default: starscape-orbit:local)
|
|
35
|
+
--data-dir <dir> Docker registry/export data dir (default: <cwd>/.orbit/docker-data)
|
|
36
|
+
--name <name> Optional Docker container name
|
|
37
|
+
--detach Run the container in the background
|
|
38
|
+
--no-build Skip docker build and run an existing image
|
|
39
|
+
--dry-run Print docker commands without running them`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shellQuote(value) {
|
|
43
|
+
return `"${String(value || "").replace(/"/g, '\\"')}"`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandQuote(value) {
|
|
47
|
+
const text = String(value);
|
|
48
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text;
|
|
49
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatCommand(executable, args) {
|
|
53
|
+
return [executable, ...args].map(commandQuote).join(" ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ORBIT_AGENTS_START = "<!-- ORBIT:AGENTS-START -->";
|
|
57
|
+
const ORBIT_AGENTS_END = "<!-- ORBIT:AGENTS-END -->";
|
|
58
|
+
|
|
59
|
+
async function loadCliCore() {
|
|
60
|
+
const [
|
|
61
|
+
{ enableAiCollaboration },
|
|
62
|
+
{ backupBoardDatabase, backupRegistry },
|
|
63
|
+
{ createRegistrySchema, openConnection },
|
|
64
|
+
{ DATA_DIR, ROOT_DIR },
|
|
65
|
+
{ provisionRepoBoard },
|
|
66
|
+
{ deleteBoard, getBoardByRepoPath },
|
|
67
|
+
{ normalizePath }
|
|
68
|
+
] = await Promise.all([
|
|
69
|
+
import("../core/boards.js"),
|
|
70
|
+
import("../core/backups.js"),
|
|
71
|
+
import("../core/db.js"),
|
|
72
|
+
import("../core/paths.js"),
|
|
73
|
+
import("../core/provision-repo-board.js"),
|
|
74
|
+
import("../core/registry.js"),
|
|
75
|
+
import("../core/util.js")
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
DATA_DIR,
|
|
80
|
+
ROOT_DIR,
|
|
81
|
+
backupBoardDatabase,
|
|
82
|
+
backupRegistry,
|
|
83
|
+
createRegistrySchema,
|
|
84
|
+
deleteBoard,
|
|
85
|
+
enableAiCollaboration,
|
|
86
|
+
getBoardByRepoPath,
|
|
87
|
+
normalizePath,
|
|
88
|
+
openConnection,
|
|
89
|
+
provisionRepoBoard
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function orbitAgentsSection() {
|
|
94
|
+
return `${ORBIT_AGENTS_START}
|
|
95
|
+
When work mentions Orbit, kanban, board, lane, ticket, card, epic, blocker, claim, AI Ready, implementation fields, planning state, project memory, or handoff: read \`SKILL-ORBIT.md\` first and follow it.
|
|
96
|
+
${ORBIT_AGENTS_END}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildAgentsMd() {
|
|
100
|
+
return `# AGENTS.md
|
|
101
|
+
|
|
102
|
+
Git is canonical for code. \`SKILL-ORBIT.md\` is canonical for Orbit/kanban/ticket/card workflow.
|
|
103
|
+
|
|
104
|
+
${orbitAgentsSection()}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function syncAgentsMd(projectRoot, options) {
|
|
108
|
+
const agentsPath = resolve(projectRoot, "AGENTS.md");
|
|
109
|
+
const section = orbitAgentsSection();
|
|
110
|
+
|
|
111
|
+
if (!existsSync(agentsPath)) {
|
|
112
|
+
writeFileSync(agentsPath, buildAgentsMd(), "utf8");
|
|
113
|
+
console.log(`Wrote ${agentsPath}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const current = readFileSync(agentsPath, "utf8");
|
|
118
|
+
const startIndex = current.indexOf(ORBIT_AGENTS_START);
|
|
119
|
+
const endIndex = current.indexOf(ORBIT_AGENTS_END);
|
|
120
|
+
|
|
121
|
+
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
|
|
122
|
+
if (!options.refreshAgentsMd) {
|
|
123
|
+
console.log("AGENTS.md already includes Orbit instructions — left unchanged (use --refresh-agents-md to replace).");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const before = current.slice(0, startIndex).replace(/\s*$/, "\n\n");
|
|
127
|
+
const after = current.slice(endIndex + ORBIT_AGENTS_END.length).replace(/^\s*/, "");
|
|
128
|
+
writeFileSync(agentsPath, `${before}${section}${after}`, "utf8");
|
|
129
|
+
console.log(`Refreshed Orbit instructions inside ${agentsPath}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
writeFileSync(agentsPath, `${current.replace(/\s*$/, "\n\n")}${section}`, "utf8");
|
|
134
|
+
console.log(`Appended Orbit instructions to ${agentsPath}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function enableAiIfRequested(options, registryRow, core) {
|
|
138
|
+
if (!options.ai || !registryRow) return;
|
|
139
|
+
const { enableAiCollaboration, openConnection } = core;
|
|
140
|
+
const db = openConnection(registryRow.db_path);
|
|
141
|
+
enableAiCollaboration(db, registryRow.id, { stageOnboardingTicket: options.example, actor: "orbit init --ai" });
|
|
142
|
+
const suffix = options.example ? "; example ticket #12 is staged in AI Ready." : ".";
|
|
143
|
+
console.log(`AI collaboration enabled — AI Ready, In Progress, and Review lanes are present${suffix}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseArgs(argv) {
|
|
147
|
+
const args = {
|
|
148
|
+
command: null,
|
|
149
|
+
cwd: process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : process.cwd(),
|
|
150
|
+
cwdProvided: false,
|
|
151
|
+
refreshAgentsMd: false,
|
|
152
|
+
ai: false,
|
|
153
|
+
example: false,
|
|
154
|
+
port: null,
|
|
155
|
+
image: "starscape-orbit:local",
|
|
156
|
+
dataDir: null,
|
|
157
|
+
containerName: null,
|
|
158
|
+
buildImage: true,
|
|
159
|
+
detach: false,
|
|
160
|
+
dryRun: false
|
|
161
|
+
};
|
|
162
|
+
const rest = argv.slice(2);
|
|
163
|
+
if (rest.length === 0 || rest[0] === "-h" || rest[0] === "--help") {
|
|
164
|
+
args.command = "help";
|
|
165
|
+
return args;
|
|
166
|
+
}
|
|
167
|
+
args.command = rest[0];
|
|
168
|
+
for (let i = 1; i < rest.length; i++) {
|
|
169
|
+
const a = rest[i];
|
|
170
|
+
if (a === "--refresh-agents-md") args.refreshAgentsMd = true;
|
|
171
|
+
else if (a === "--ai") args.ai = true;
|
|
172
|
+
else if (a === "--example") args.example = true;
|
|
173
|
+
else if (a === "--cwd" && rest[i + 1]) {
|
|
174
|
+
args.cwdProvided = true;
|
|
175
|
+
args.cwd = resolve(rest[++i]);
|
|
176
|
+
} else if (a.startsWith("--cwd=")) {
|
|
177
|
+
args.cwdProvided = true;
|
|
178
|
+
args.cwd = resolve(a.slice("--cwd=".length));
|
|
179
|
+
} else if (a === "--port" && rest[i + 1]) {
|
|
180
|
+
args.port = rest[++i];
|
|
181
|
+
} else if (a.startsWith("--port=")) {
|
|
182
|
+
args.port = a.slice("--port=".length);
|
|
183
|
+
} else if (a === "--image" && rest[i + 1]) {
|
|
184
|
+
args.image = rest[++i];
|
|
185
|
+
} else if (a.startsWith("--image=")) {
|
|
186
|
+
args.image = a.slice("--image=".length);
|
|
187
|
+
} else if (a === "--data-dir" && rest[i + 1]) {
|
|
188
|
+
args.dataDir = resolve(rest[++i]);
|
|
189
|
+
} else if (a.startsWith("--data-dir=")) {
|
|
190
|
+
args.dataDir = resolve(a.slice("--data-dir=".length));
|
|
191
|
+
} else if (a === "--name" && rest[i + 1]) {
|
|
192
|
+
args.containerName = rest[++i];
|
|
193
|
+
} else if (a.startsWith("--name=")) {
|
|
194
|
+
args.containerName = a.slice("--name=".length);
|
|
195
|
+
} else if (a === "--no-build") {
|
|
196
|
+
args.buildImage = false;
|
|
197
|
+
} else if (a === "--detach") {
|
|
198
|
+
args.detach = true;
|
|
199
|
+
} else if (a === "--dry-run") {
|
|
200
|
+
args.dryRun = true;
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`Unknown argument: ${a}`);
|
|
203
|
+
args.command = "help";
|
|
204
|
+
return args;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return args;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runInit(options) {
|
|
211
|
+
const core = await loadCliCore();
|
|
212
|
+
const { DATA_DIR, ROOT_DIR, createRegistrySchema, normalizePath, provisionRepoBoard } = core;
|
|
213
|
+
const projectRoot = normalizePath(resolve(options.cwd));
|
|
214
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
215
|
+
createRegistrySchema();
|
|
216
|
+
|
|
217
|
+
const result = provisionRepoBoard(projectRoot, { includeExamples: options.example });
|
|
218
|
+
const skillSrc = resolve(ROOT_DIR, "SKILL-ORBIT.md");
|
|
219
|
+
const skillDest = resolve(projectRoot, "SKILL-ORBIT.md");
|
|
220
|
+
|
|
221
|
+
if (!existsSync(skillSrc)) {
|
|
222
|
+
console.warn(`Warning: missing ${skillSrc} — skipped copying SKILL-ORBIT.md and AGENTS.md Orbit section.`);
|
|
223
|
+
} else {
|
|
224
|
+
if (skillSrc === skillDest) {
|
|
225
|
+
console.log("SKILL-ORBIT.md source is already the project copy — left unchanged.");
|
|
226
|
+
} else if (existsSync(skillDest) && !options.refreshAgentsMd) {
|
|
227
|
+
console.log(`SKILL-ORBIT.md already exists — left unchanged (use --refresh-agents-md to replace).`);
|
|
228
|
+
} else {
|
|
229
|
+
copyFileSync(skillSrc, skillDest);
|
|
230
|
+
console.log(`Wrote ${skillDest}`);
|
|
231
|
+
}
|
|
232
|
+
syncAgentsMd(projectRoot, options);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
enableAiIfRequested(options, result.registryRow, core);
|
|
236
|
+
|
|
237
|
+
if (result.message && result.status === "skipped") {
|
|
238
|
+
console.log(result.message);
|
|
239
|
+
} else if (result.status === "created") {
|
|
240
|
+
console.log(`Initialized Orbit board at ${resolve(projectRoot, ".orbit", "board.db")}`);
|
|
241
|
+
} else if (result.status === "repaired") {
|
|
242
|
+
console.log(result.message || "Linked existing board database to the registry.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const cliPath = resolve(ROOT_DIR, "src", "cli", "orbit.js");
|
|
246
|
+
const mcpLine = ["claude mcp add starscape-orbit", "--", "node", shellQuote(cliPath), "mcp", "--cwd", shellQuote(projectRoot)].join(" ");
|
|
247
|
+
console.log(`
|
|
248
|
+
--- Next: MCP (run once per Claude Code install) ---
|
|
249
|
+
${mcpLine}
|
|
250
|
+
|
|
251
|
+
--- Next: agent prompt (from this repo) ---
|
|
252
|
+
Read AGENTS.md in this repository, then use the Orbit MCP tools. Use board_list, board_set_active, and ticket context tools per docs/AGENT_PROTOCOL.md before claiming or updating work.
|
|
253
|
+
|
|
254
|
+
${options.example ? (options.ai ? "Example ticket #12 is \"Try Orbit MCP on this ticket\" in AI Ready — good first exercise after MCP is connected." : "Example tickets were created. Add `--ai` to also create AI collaboration lanes and stage ticket #12 in AI Ready.") : "No example tickets were created. Rerun on a fresh board with `orbit init --example` if you want the onboarding examples."}
|
|
255
|
+
`);
|
|
256
|
+
|
|
257
|
+
if (result.registryRow) {
|
|
258
|
+
console.log(`Board: ${result.registryRow.name} (${result.registryRow.slug}) registry id: ${result.registryRow.id}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function runReset(options) {
|
|
263
|
+
const {
|
|
264
|
+
DATA_DIR,
|
|
265
|
+
backupBoardDatabase,
|
|
266
|
+
backupRegistry,
|
|
267
|
+
createRegistrySchema,
|
|
268
|
+
deleteBoard,
|
|
269
|
+
getBoardByRepoPath,
|
|
270
|
+
normalizePath,
|
|
271
|
+
openConnection
|
|
272
|
+
} = await loadCliCore();
|
|
273
|
+
const projectRoot = normalizePath(resolve(options.cwd));
|
|
274
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
275
|
+
createRegistrySchema();
|
|
276
|
+
|
|
277
|
+
const orbitDir = resolve(projectRoot, ".orbit");
|
|
278
|
+
const skillMd = resolve(projectRoot, "SKILL-ORBIT.md");
|
|
279
|
+
const removed = [];
|
|
280
|
+
const registryRow = getBoardByRepoPath(projectRoot);
|
|
281
|
+
|
|
282
|
+
if (registryRow && existsSync(registryRow.db_path)) {
|
|
283
|
+
backupBoardDatabase(registryRow, openConnection(registryRow.db_path), "pre-cli-reset");
|
|
284
|
+
}
|
|
285
|
+
if (registryRow) backupRegistry("pre-cli-reset");
|
|
286
|
+
|
|
287
|
+
if (existsSync(orbitDir)) {
|
|
288
|
+
rmSync(orbitDir, { recursive: true, force: true });
|
|
289
|
+
removed.push(orbitDir);
|
|
290
|
+
}
|
|
291
|
+
if (existsSync(skillMd)) {
|
|
292
|
+
unlinkSync(skillMd);
|
|
293
|
+
removed.push(skillMd);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (registryRow) {
|
|
297
|
+
deleteBoard(registryRow.id);
|
|
298
|
+
removed.push(`registry row "${registryRow.slug}"`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (removed.length === 0) {
|
|
302
|
+
console.log(`Nothing to reset under ${projectRoot}.`);
|
|
303
|
+
} else {
|
|
304
|
+
for (const item of removed) console.log(`Removed ${item}`);
|
|
305
|
+
console.log(`\nRun \`orbit init\` from ${projectRoot} to provision a fresh board.`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runServe(options) {
|
|
310
|
+
// PROJECT_ROOT must be set BEFORE importing the server, because src/core/paths.js
|
|
311
|
+
// captures it at module load. cwd defaults are equivalent for most users, but
|
|
312
|
+
// an explicit --cwd lets a global install target a board outside cwd.
|
|
313
|
+
process.env.PROJECT_ROOT = resolve(options.cwd);
|
|
314
|
+
if (options.port) process.env.PORT = String(options.port);
|
|
315
|
+
await import("../server.js");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function containerPathFor(hostPath, fallbackPath) {
|
|
319
|
+
// Linux/macOS Docker can mount the same absolute path into the container,
|
|
320
|
+
// which keeps existing Orbit board metadata meaningful. Windows paths are
|
|
321
|
+
// not valid Linux container paths, so use stable in-container fallbacks.
|
|
322
|
+
return process.platform === "win32" ? fallbackPath : hostPath;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildDockerPlan(options) {
|
|
326
|
+
const projectRoot = resolve(options.cwd);
|
|
327
|
+
const dataDir = resolve(options.dataDir || resolve(projectRoot, ".orbit", "docker-data"));
|
|
328
|
+
const containerProjectRoot = containerPathFor(projectRoot, "/workspace");
|
|
329
|
+
const containerDataDir = containerPathFor(dataDir, "/data");
|
|
330
|
+
const port = String(options.port || process.env.PORT || 3337);
|
|
331
|
+
|
|
332
|
+
if (!existsSync(projectRoot)) {
|
|
333
|
+
throw new Error(`Project root does not exist: ${projectRoot}`);
|
|
334
|
+
}
|
|
335
|
+
if (!existsSync(resolve(PACKAGE_ROOT, "Dockerfile"))) {
|
|
336
|
+
throw new Error(`Dockerfile not found in Orbit package root: ${PACKAGE_ROOT}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const buildArgs = ["build", "-t", options.image, PACKAGE_ROOT];
|
|
340
|
+
const runArgs = ["run", "--rm"];
|
|
341
|
+
if (options.detach) {
|
|
342
|
+
runArgs.push("--detach");
|
|
343
|
+
} else if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
344
|
+
runArgs.push("-it");
|
|
345
|
+
}
|
|
346
|
+
if (options.containerName) runArgs.push("--name", options.containerName);
|
|
347
|
+
runArgs.push(
|
|
348
|
+
"-p",
|
|
349
|
+
`${port}:${port}`,
|
|
350
|
+
"-e",
|
|
351
|
+
`PORT=${port}`,
|
|
352
|
+
"-e",
|
|
353
|
+
`PROJECT_ROOT=${containerProjectRoot}`,
|
|
354
|
+
"-e",
|
|
355
|
+
`DATA_DIR=${containerDataDir}`,
|
|
356
|
+
"-e",
|
|
357
|
+
`SYSTEM_PATH=${projectRoot}`,
|
|
358
|
+
"-v",
|
|
359
|
+
`${projectRoot}:${containerProjectRoot}`,
|
|
360
|
+
"-v",
|
|
361
|
+
`${dataDir}:${containerDataDir}`,
|
|
362
|
+
"-w",
|
|
363
|
+
containerProjectRoot,
|
|
364
|
+
options.image,
|
|
365
|
+
"serve",
|
|
366
|
+
"--cwd",
|
|
367
|
+
containerProjectRoot,
|
|
368
|
+
"--port",
|
|
369
|
+
port
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return { buildArgs, containerDataDir, containerProjectRoot, dataDir, port, projectRoot, runArgs };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function runDockerCommand(args) {
|
|
376
|
+
const result = spawnSync("docker", args, { stdio: "inherit" });
|
|
377
|
+
if (result.error) {
|
|
378
|
+
throw new Error(`Failed to run docker: ${result.error.message}`);
|
|
379
|
+
}
|
|
380
|
+
if (result.signal) {
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (result.status !== 0) {
|
|
385
|
+
process.exitCode = result.status ?? 1;
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function runDocker(options) {
|
|
392
|
+
const plan = buildDockerPlan(options);
|
|
393
|
+
const dryRun = options.dryRun || process.env.ORBIT_DOCKER_DRY_RUN === "1";
|
|
394
|
+
|
|
395
|
+
if (dryRun) {
|
|
396
|
+
console.log(`Project root: ${plan.projectRoot}`);
|
|
397
|
+
console.log(`Docker data: ${plan.dataDir}`);
|
|
398
|
+
if (options.buildImage) {
|
|
399
|
+
console.log(`Docker build:\n ${formatCommand("docker", plan.buildArgs)}`);
|
|
400
|
+
}
|
|
401
|
+
console.log(`Docker run:\n ${formatCommand("docker", plan.runArgs)}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
mkdirSync(plan.dataDir, { recursive: true });
|
|
406
|
+
if (options.buildImage && !runDockerCommand(plan.buildArgs)) return;
|
|
407
|
+
runDockerCommand(plan.runArgs);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function runMcp(options) {
|
|
411
|
+
process.env.PROJECT_ROOT = resolve(options.cwd);
|
|
412
|
+
await import("../mcp-server.js");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function main() {
|
|
416
|
+
const args = parseArgs(process.argv);
|
|
417
|
+
try {
|
|
418
|
+
if (args.command === "init") {
|
|
419
|
+
await runInit(args);
|
|
420
|
+
} else if (args.command === "reset") {
|
|
421
|
+
await runReset(args);
|
|
422
|
+
} else if (args.command === "serve") {
|
|
423
|
+
await runServe(args);
|
|
424
|
+
} else if (args.command === "docker") {
|
|
425
|
+
await runDocker(args);
|
|
426
|
+
} else if (args.command === "mcp") {
|
|
427
|
+
await runMcp(args);
|
|
428
|
+
} else {
|
|
429
|
+
printUsage();
|
|
430
|
+
process.exitCode = args.command === "help" ? 0 : 1;
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(err?.message || err);
|
|
434
|
+
process.exitCode = 1;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
main();
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addComment,
|
|
3
|
+
boardById,
|
|
4
|
+
childTicketsFor,
|
|
5
|
+
compactTicket,
|
|
6
|
+
labelsForTicket,
|
|
7
|
+
relationRows,
|
|
8
|
+
stateByName,
|
|
9
|
+
stateByRole,
|
|
10
|
+
ticketById,
|
|
11
|
+
ticketByNumber,
|
|
12
|
+
ticketByTitle,
|
|
13
|
+
uniqueTickets,
|
|
14
|
+
unresolvedBlockers
|
|
15
|
+
} from "./queries.js";
|
|
16
|
+
import { canAccessBoard, requireBoardAccess } from "./auth.js";
|
|
17
|
+
import { tx } from "./db.js";
|
|
18
|
+
import { recordEvent } from "./events.js";
|
|
19
|
+
import {
|
|
20
|
+
appendFieldNote,
|
|
21
|
+
httpError,
|
|
22
|
+
now,
|
|
23
|
+
normalizeTicketType,
|
|
24
|
+
requiredString
|
|
25
|
+
} from "./util.js";
|
|
26
|
+
import { boardManual } from "./boards.js";
|
|
27
|
+
import { reindexTicket } from "./tickets.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Claim the next AI-ready ticket on the resolved board. The router/MCP layer
|
|
31
|
+
* is responsible for picking which board this runs against (via path slug,
|
|
32
|
+
* body field, or session board); this function never scans other boards or
|
|
33
|
+
* mutates global state. If you want to claim from another board, the caller
|
|
34
|
+
* resolves a different ctx and calls again.
|
|
35
|
+
*/
|
|
36
|
+
export function claimNext(body, ctx) {
|
|
37
|
+
const { db, board, actor } = ctx;
|
|
38
|
+
const filters = {
|
|
39
|
+
type: body.type ? normalizeTicketType(body.type) : "",
|
|
40
|
+
includeEpics: Boolean(body.include_epics)
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const inner = boardById(db, board.id);
|
|
44
|
+
if (!inner || !canAccessBoard(actor, inner)) {
|
|
45
|
+
throw httpError(403, "forbidden");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const candidates = db
|
|
49
|
+
.prepare(
|
|
50
|
+
`SELECT t.*, b.slug AS board_slug
|
|
51
|
+
FROM tickets t
|
|
52
|
+
JOIN states s ON s.id = t.state_id
|
|
53
|
+
JOIN boards b ON b.id = t.board_id
|
|
54
|
+
WHERE (s.role = 'ai_ready' OR (s.role IS NULL AND s.name = 'AI Ready'))
|
|
55
|
+
AND t.archived_at IS NULL
|
|
56
|
+
ORDER BY t.priority DESC, t.created_at ASC`
|
|
57
|
+
)
|
|
58
|
+
.all();
|
|
59
|
+
|
|
60
|
+
const filtered = candidates.filter((ticket) =>
|
|
61
|
+
!filters.type ? filters.includeEpics || ticket.type !== "epic" : ticket.type === filters.type
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
for (const candidate of filtered) {
|
|
65
|
+
const blockedBy = unresolvedBlockers(db, candidate.id);
|
|
66
|
+
if (blockedBy.length > 0) continue;
|
|
67
|
+
|
|
68
|
+
const inProgress =
|
|
69
|
+
stateByRole(db, candidate.board_id, "in_progress") ||
|
|
70
|
+
stateByName(db, candidate.board_id, "In Progress");
|
|
71
|
+
if (!inProgress) throw httpError(400, "missing_in_progress_state");
|
|
72
|
+
|
|
73
|
+
tx(db, () => {
|
|
74
|
+
db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(
|
|
75
|
+
inProgress.id,
|
|
76
|
+
now(),
|
|
77
|
+
candidate.id
|
|
78
|
+
);
|
|
79
|
+
addComment(db, candidate.id, actor.name, "agent_note", "Agent claimed this ticket and is starting work.");
|
|
80
|
+
recordEvent(db, candidate.board_id, "agent_claimed", candidate.id, actor.name, { agent_id: actor.id });
|
|
81
|
+
reindexTicket(db, candidate.id);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
claimed: true,
|
|
86
|
+
ticket_id: candidate.id,
|
|
87
|
+
context: readTicket(candidate.id, ctx)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { claimed: false, reason: "no_schedulable_ticket" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveTicketLookup(db, boardId, { ticket_id, number, title }) {
|
|
95
|
+
if (ticket_id) return ticketById(db, ticket_id);
|
|
96
|
+
if (number !== undefined && number !== null && number !== "") {
|
|
97
|
+
const n = Number(number);
|
|
98
|
+
if (!Number.isInteger(n)) throw httpError(400, "invalid_number");
|
|
99
|
+
return ticketByNumber(db, boardId, n);
|
|
100
|
+
}
|
|
101
|
+
if (title) return ticketByTitle(db, boardId, String(title));
|
|
102
|
+
throw httpError(400, "ticket_id_or_number_or_title_required");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readTicket(lookup, ctx) {
|
|
106
|
+
const { db, board, actor } = ctx;
|
|
107
|
+
const args = typeof lookup === "string" ? { ticket_id: lookup } : lookup || {};
|
|
108
|
+
const ticket = resolveTicketLookup(db, board.id, args);
|
|
109
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
110
|
+
const innerBoard = boardById(db, ticket.board_id);
|
|
111
|
+
requireBoardAccess(actor, innerBoard);
|
|
112
|
+
|
|
113
|
+
const comments = db
|
|
114
|
+
.prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
|
|
115
|
+
.all(ticket.id);
|
|
116
|
+
const labels = labelsForTicket(db, ticket.id);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ticket: {
|
|
120
|
+
id: ticket.id,
|
|
121
|
+
number: ticket.number,
|
|
122
|
+
title: ticket.title,
|
|
123
|
+
description: ticket.description,
|
|
124
|
+
state_name: ticket.state_name,
|
|
125
|
+
type: ticket.type,
|
|
126
|
+
priority: ticket.priority,
|
|
127
|
+
labels
|
|
128
|
+
},
|
|
129
|
+
comments,
|
|
130
|
+
board_manual: boardManual(innerBoard.id, ctx)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function readComments(lookup, ctx) {
|
|
135
|
+
const { db, board, actor } = ctx;
|
|
136
|
+
const args = typeof lookup === "string" ? { ticket_id: lookup } : lookup || {};
|
|
137
|
+
const ticket = resolveTicketLookup(db, board.id, args);
|
|
138
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
139
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
140
|
+
const comments = db
|
|
141
|
+
.prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
|
|
142
|
+
.all(ticket.id);
|
|
143
|
+
return { ticket_id: ticket.id, comments };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function checkpointTicket(body, ctx) {
|
|
147
|
+
const { db, board, actor } = ctx;
|
|
148
|
+
const tid = requiredString(body.ticket_id, "ticket_id");
|
|
149
|
+
const ticket = ticketById(db, tid);
|
|
150
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
151
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
152
|
+
|
|
153
|
+
// Same review lane as `complete`; distinguish mid-flight pause via checkpoint comment + event kind.
|
|
154
|
+
const review =
|
|
155
|
+
stateByRole(db, ticket.board_id, "review") ||
|
|
156
|
+
stateByName(db, ticket.board_id, "Review");
|
|
157
|
+
if (!review) throw httpError(400, "missing_review_state");
|
|
158
|
+
const message = requiredString(body.message, "message");
|
|
159
|
+
tx(db, () => {
|
|
160
|
+
db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(review.id, now(), ticket.id);
|
|
161
|
+
addComment(db, ticket.id, actor.name, "checkpoint", message);
|
|
162
|
+
recordEvent(db, ticket.board_id, "checkpoint_requested", ticket.id, actor.name, {
|
|
163
|
+
from: ticket.state_name,
|
|
164
|
+
state: review.name,
|
|
165
|
+
to: review.name,
|
|
166
|
+
actor_type: actor.type,
|
|
167
|
+
actor_id: actor.id
|
|
168
|
+
});
|
|
169
|
+
reindexTicket(db, ticket.id);
|
|
170
|
+
});
|
|
171
|
+
return getContextPack(ticket.id, ctx, 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function completeTicket(body, ctx) {
|
|
175
|
+
const { db, board, actor } = ctx;
|
|
176
|
+
const tid = requiredString(body.ticket_id, "ticket_id");
|
|
177
|
+
const ticket = ticketById(db, tid);
|
|
178
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
179
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
180
|
+
|
|
181
|
+
// If the caller specified a next_state, honor that name verbatim. Otherwise
|
|
182
|
+
// resolve the review lane by role so renaming the lane does not break this.
|
|
183
|
+
const nextState = body.next_state
|
|
184
|
+
? stateByName(db, ticket.board_id, body.next_state)
|
|
185
|
+
: stateByRole(db, ticket.board_id, "review") || stateByName(db, ticket.board_id, "Review");
|
|
186
|
+
if (!nextState) throw httpError(400, "missing_next_state");
|
|
187
|
+
|
|
188
|
+
const summary = requiredString(body.summary, "summary");
|
|
189
|
+
const lines = [summary];
|
|
190
|
+
if (body.pr_url) lines.push(`PR: ${body.pr_url}`);
|
|
191
|
+
const updates = body.updates ? appendFieldNote(ticket.implementation_updates, body.updates, actor.name) : ticket.implementation_updates;
|
|
192
|
+
tx(db, () => {
|
|
193
|
+
addComment(db, ticket.id, actor.name, "completion", lines.join("\n\n"));
|
|
194
|
+
db.prepare(
|
|
195
|
+
"UPDATE tickets SET state_id = ?, implementation_summary = ?, implementation_updates = ?, updated_at = ? WHERE id = ?"
|
|
196
|
+
).run(nextState.id, summary, updates, now(), ticket.id);
|
|
197
|
+
recordEvent(db, ticket.board_id, "agent_completed", ticket.id, actor.name, {
|
|
198
|
+
from: ticket.state_name,
|
|
199
|
+
next_state: nextState.name,
|
|
200
|
+
to: nextState.name,
|
|
201
|
+
pr_url: body.pr_url || "",
|
|
202
|
+
actor_type: actor.type,
|
|
203
|
+
actor_id: actor.id
|
|
204
|
+
});
|
|
205
|
+
reindexTicket(db, ticket.id);
|
|
206
|
+
});
|
|
207
|
+
return getContextPack(ticket.id, ctx, 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getContextPack(ticketId, ctx, depth = 1) {
|
|
211
|
+
const { db, board, actor } = ctx;
|
|
212
|
+
const ticket = ticketById(db, ticketId);
|
|
213
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
214
|
+
const innerBoard = boardById(db, ticket.board_id);
|
|
215
|
+
requireBoardAccess(actor, innerBoard);
|
|
216
|
+
|
|
217
|
+
const comments = db.prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC").all(ticketId);
|
|
218
|
+
const labels = labelsForTicket(db, ticketId);
|
|
219
|
+
const relations = relationRows(db, ticketId);
|
|
220
|
+
const blockers = unresolvedBlockers(db, ticketId);
|
|
221
|
+
const parentTicket = ticket.parent_ticket_id ? ticketById(db, ticket.parent_ticket_id) : null;
|
|
222
|
+
const childTickets = childTicketsFor(db, ticketId).map((child) => ({
|
|
223
|
+
...child,
|
|
224
|
+
labels: labelsForTicket(db, child.id),
|
|
225
|
+
blockers: unresolvedBlockers(db, child.id)
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const relatedTickets = uniqueTickets([
|
|
229
|
+
...relations.map((relation) => relation.other_ticket),
|
|
230
|
+
...childTickets,
|
|
231
|
+
...(parentTicket ? [parentTicket] : [])
|
|
232
|
+
]);
|
|
233
|
+
const relatedCommentRows =
|
|
234
|
+
depth > 0
|
|
235
|
+
? relatedTickets.flatMap((related) =>
|
|
236
|
+
db
|
|
237
|
+
.prepare("SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at DESC LIMIT 5")
|
|
238
|
+
.all(related.id)
|
|
239
|
+
.reverse()
|
|
240
|
+
.map((comment) => ({ ...comment, ticket_id: related.id, ticket_title: related.title }))
|
|
241
|
+
)
|
|
242
|
+
: [];
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ticket: {
|
|
246
|
+
...ticket,
|
|
247
|
+
labels
|
|
248
|
+
},
|
|
249
|
+
board: innerBoard,
|
|
250
|
+
board_manual: boardManual(innerBoard.id, ctx),
|
|
251
|
+
comments,
|
|
252
|
+
parent_ticket: parentTicket ? compactTicket(parentTicket) : null,
|
|
253
|
+
child_tickets: childTickets.map((child) => ({
|
|
254
|
+
...child,
|
|
255
|
+
child_count: childTicketsFor(db, child.id).length
|
|
256
|
+
})),
|
|
257
|
+
relations,
|
|
258
|
+
blockers,
|
|
259
|
+
related_tickets: relatedTickets,
|
|
260
|
+
related_comments: relatedCommentRows
|
|
261
|
+
};
|
|
262
|
+
}
|