cortex-agents 2.1.0 → 2.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/.opencode/agents/build.md +71 -16
- package/.opencode/agents/plan.md +11 -4
- package/README.md +248 -364
- package/dist/cli.js +25 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/tools/cortex.js +1 -1
- package/dist/tools/task.d.ts +20 -0
- package/dist/tools/task.d.ts.map +1 -0
- package/dist/tools/task.js +302 -0
- package/dist/tools/worktree.d.ts +32 -0
- package/dist/tools/worktree.d.ts.map +1 -1
- package/dist/tools/worktree.js +403 -2
- package/dist/utils/plan-extract.d.ts +37 -0
- package/dist/utils/plan-extract.d.ts.map +1 -0
- package/dist/utils/plan-extract.js +137 -0
- package/dist/utils/propagate.d.ts +22 -0
- package/dist/utils/propagate.d.ts.map +1 -0
- package/dist/utils/propagate.js +64 -0
- package/dist/utils/worktree-detect.d.ts +20 -0
- package/dist/utils/worktree-detect.d.ts.map +1 -0
- package/dist/utils/worktree-detect.js +42 -0
- package/package.json +9 -6
package/dist/tools/worktree.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
|
|
4
|
+
import { propagatePlan } from "../utils/propagate.js";
|
|
5
|
+
const WORKTREE_ROOT = ".worktrees";
|
|
5
6
|
export const create = tool({
|
|
6
|
-
description: "Create a new git worktree for isolated development. Worktrees are created in
|
|
7
|
+
description: "Create a new git worktree for isolated development. Worktrees are created in .worktrees/ at the project root.",
|
|
7
8
|
args: {
|
|
8
9
|
name: tool.schema
|
|
9
10
|
.string()
|
|
@@ -196,3 +197,403 @@ ${instructions}
|
|
|
196
197
|
Worktree path: ${absoluteWorktreePath}`;
|
|
197
198
|
},
|
|
198
199
|
});
|
|
200
|
+
// ─── Terminal Detection ──────────────────────────────────────────────────────
|
|
201
|
+
/**
|
|
202
|
+
* Detect the user's terminal emulator on macOS.
|
|
203
|
+
* Returns "iterm2", "terminal", or "unknown".
|
|
204
|
+
*/
|
|
205
|
+
function detectMacTerminal() {
|
|
206
|
+
// iTerm2 sets ITERM_SESSION_ID and TERM_PROGRAM
|
|
207
|
+
if (process.env.ITERM_SESSION_ID || process.env.TERM_PROGRAM === "iTerm.app") {
|
|
208
|
+
return "iterm2";
|
|
209
|
+
}
|
|
210
|
+
if (process.env.TERM_PROGRAM === "Apple_Terminal") {
|
|
211
|
+
return "terminal";
|
|
212
|
+
}
|
|
213
|
+
// Check __CFBundleIdentifier for the running app
|
|
214
|
+
const bundleId = process.env.__CFBundleIdentifier;
|
|
215
|
+
if (bundleId?.includes("iterm2") || bundleId?.includes("iTerm")) {
|
|
216
|
+
return "iterm2";
|
|
217
|
+
}
|
|
218
|
+
if (bundleId?.includes("Terminal") || bundleId?.includes("apple.Terminal")) {
|
|
219
|
+
return "terminal";
|
|
220
|
+
}
|
|
221
|
+
return "unknown";
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Detect the user's terminal emulator on Linux.
|
|
225
|
+
* Returns the terminal name or "unknown".
|
|
226
|
+
*/
|
|
227
|
+
function detectLinuxTerminal() {
|
|
228
|
+
const termProgram = process.env.TERM_PROGRAM;
|
|
229
|
+
if (termProgram)
|
|
230
|
+
return termProgram.toLowerCase();
|
|
231
|
+
// Check common environment hints
|
|
232
|
+
if (process.env.KITTY_WINDOW_ID)
|
|
233
|
+
return "kitty";
|
|
234
|
+
if (process.env.ALACRITTY_SOCKET)
|
|
235
|
+
return "alacritty";
|
|
236
|
+
if (process.env.WEZTERM_PANE)
|
|
237
|
+
return "wezterm";
|
|
238
|
+
if (process.env.GNOME_TERMINAL_SERVICE)
|
|
239
|
+
return "gnome-terminal";
|
|
240
|
+
if (process.env.KONSOLE_VERSION)
|
|
241
|
+
return "konsole";
|
|
242
|
+
return "unknown";
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Find the opencode binary path, checking common locations.
|
|
246
|
+
*/
|
|
247
|
+
async function findOpencodeBinary() {
|
|
248
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
249
|
+
// Check well-known path first
|
|
250
|
+
const wellKnown = path.join(homeDir, ".opencode", "bin", "opencode");
|
|
251
|
+
if (fs.existsSync(wellKnown))
|
|
252
|
+
return wellKnown;
|
|
253
|
+
// Try which/where
|
|
254
|
+
try {
|
|
255
|
+
const result = await Bun.$ `which opencode`.quiet().text();
|
|
256
|
+
const bin = result.trim();
|
|
257
|
+
if (bin && fs.existsSync(bin))
|
|
258
|
+
return bin;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Not in PATH
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Build the prompt string for the new OpenCode session.
|
|
267
|
+
*/
|
|
268
|
+
function buildLaunchPrompt(planFilename, customPrompt) {
|
|
269
|
+
if (customPrompt)
|
|
270
|
+
return customPrompt;
|
|
271
|
+
if (planFilename) {
|
|
272
|
+
return `Load the plan at .cortex/plans/${planFilename} and implement all tasks listed in it. Follow the plan's technical approach and phases.`;
|
|
273
|
+
}
|
|
274
|
+
return "Check for plans in .cortex/plans/ and begin implementation. If no plan exists, analyze the codebase and suggest next steps.";
|
|
275
|
+
}
|
|
276
|
+
// ─── Mode A: New Terminal Tab ────────────────────────────────────────────────
|
|
277
|
+
async function launchTerminalTab(worktreePath, opencodeBin, agent, prompt) {
|
|
278
|
+
const platform = process.platform;
|
|
279
|
+
// Build the command to run inside the new terminal
|
|
280
|
+
const innerCmd = `cd "${worktreePath}" && "${opencodeBin}" --agent ${agent} --prompt "${prompt.replace(/"/g, '\\"')}"`;
|
|
281
|
+
if (platform === "darwin") {
|
|
282
|
+
const terminal = detectMacTerminal();
|
|
283
|
+
if (terminal === "iterm2") {
|
|
284
|
+
const script = `tell application "iTerm2"
|
|
285
|
+
tell current window
|
|
286
|
+
create tab with default profile
|
|
287
|
+
tell current session of current tab
|
|
288
|
+
write text "cd \\"${worktreePath}\\" && \\"${opencodeBin}\\" --agent ${agent}"
|
|
289
|
+
end tell
|
|
290
|
+
end tell
|
|
291
|
+
end tell`;
|
|
292
|
+
try {
|
|
293
|
+
await Bun.$ `osascript -e ${script}`;
|
|
294
|
+
return `✓ Opened new iTerm2 tab in worktree`;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Fall back to generic open
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (terminal === "terminal" || terminal === "unknown") {
|
|
301
|
+
// Terminal.app: `do script` opens in a new window by default
|
|
302
|
+
// Using "do script in window 1" would reuse, so we use a plain `do script`
|
|
303
|
+
const script = `tell application "Terminal"
|
|
304
|
+
activate
|
|
305
|
+
do script "cd \\"${worktreePath}\\" && \\"${opencodeBin}\\" --agent ${agent}"
|
|
306
|
+
end tell`;
|
|
307
|
+
try {
|
|
308
|
+
await Bun.$ `osascript -e ${script}`;
|
|
309
|
+
return `✓ Opened new Terminal.app window in worktree`;
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// Last resort: use open -a
|
|
313
|
+
try {
|
|
314
|
+
await Bun.$ `open -a Terminal "${worktreePath}"`;
|
|
315
|
+
return `✓ Opened Terminal.app in worktree directory (run opencode manually)`;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return `✗ Could not open terminal. Manual command:\n ${innerCmd}`;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (platform === "linux") {
|
|
324
|
+
const terminal = detectLinuxTerminal();
|
|
325
|
+
const launchers = {
|
|
326
|
+
"kitty": ["kitty", "--directory", worktreePath, "--", "bash", "-c", innerCmd],
|
|
327
|
+
"alacritty": ["alacritty", "--working-directory", worktreePath, "-e", "bash", "-c", innerCmd],
|
|
328
|
+
"wezterm": ["wezterm", "start", "--cwd", worktreePath, "--", "bash", "-c", innerCmd],
|
|
329
|
+
"gnome-terminal": ["gnome-terminal", "--working-directory", worktreePath, "--", "bash", "-c", innerCmd],
|
|
330
|
+
"konsole": ["konsole", "--workdir", worktreePath, "-e", "bash", "-c", innerCmd],
|
|
331
|
+
};
|
|
332
|
+
const args = launchers[terminal];
|
|
333
|
+
if (args) {
|
|
334
|
+
try {
|
|
335
|
+
Bun.spawn(args, { cwd: worktreePath, stdout: "ignore", stderr: "ignore" });
|
|
336
|
+
return `✓ Opened ${terminal} in worktree`;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// Fall through to generic attempt
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Generic fallback: try common terminals in order
|
|
343
|
+
for (const [name, cmdArgs] of Object.entries(launchers)) {
|
|
344
|
+
try {
|
|
345
|
+
Bun.spawn(cmdArgs, { cwd: worktreePath, stdout: "ignore", stderr: "ignore" });
|
|
346
|
+
return `✓ Opened ${name} in worktree`;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return `✗ Could not detect terminal emulator. Manual command:\n ${innerCmd}`;
|
|
353
|
+
}
|
|
354
|
+
if (platform === "win32") {
|
|
355
|
+
try {
|
|
356
|
+
await Bun.$ `start cmd /k "cd /d ${worktreePath} && ${opencodeBin} --agent ${agent}"`;
|
|
357
|
+
return `✓ Opened new cmd window in worktree`;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return `✗ Could not open terminal. Manual command:\n ${innerCmd}`;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return `✗ Unsupported platform: ${platform}. Manual command:\n ${innerCmd}`;
|
|
364
|
+
}
|
|
365
|
+
// ─── Mode B: In-App PTY ─────────────────────────────────────────────────────
|
|
366
|
+
async function launchPty(client, worktreePath, branchName, opencodeBin, agent, prompt) {
|
|
367
|
+
try {
|
|
368
|
+
await client.pty.create({
|
|
369
|
+
body: {
|
|
370
|
+
command: opencodeBin,
|
|
371
|
+
args: ["--agent", agent, "--prompt", prompt],
|
|
372
|
+
cwd: worktreePath,
|
|
373
|
+
title: `Worktree: ${branchName}`,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
return `✓ Created in-app PTY session for worktree
|
|
377
|
+
|
|
378
|
+
Branch: ${branchName}
|
|
379
|
+
Title: "Worktree: ${branchName}"
|
|
380
|
+
|
|
381
|
+
The PTY is running OpenCode with agent '${agent}' in the worktree.
|
|
382
|
+
Switch to it using OpenCode's terminal panel.`;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return `✗ Failed to create PTY session: ${error.message || error}
|
|
386
|
+
|
|
387
|
+
Falling back to manual command:
|
|
388
|
+
cd "${worktreePath}" && "${opencodeBin}" --agent ${agent}`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// ─── Mode C: Background Session ─────────────────────────────────────────────
|
|
392
|
+
async function launchBackground(client, worktreePath, branchName, opencodeBin, agent, prompt) {
|
|
393
|
+
// Spawn opencode run as a detached background process
|
|
394
|
+
const proc = Bun.spawn([opencodeBin, "run", "--agent", agent, prompt], {
|
|
395
|
+
cwd: worktreePath,
|
|
396
|
+
stdout: "pipe",
|
|
397
|
+
stderr: "pipe",
|
|
398
|
+
env: {
|
|
399
|
+
...process.env,
|
|
400
|
+
// Ensure the background instance knows its directory
|
|
401
|
+
HOME: process.env.HOME || "",
|
|
402
|
+
PATH: process.env.PATH || "",
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
// Save PID for tracking
|
|
406
|
+
const cortexDir = path.join(worktreePath, ".cortex");
|
|
407
|
+
if (!fs.existsSync(cortexDir)) {
|
|
408
|
+
fs.mkdirSync(cortexDir, { recursive: true });
|
|
409
|
+
}
|
|
410
|
+
fs.writeFileSync(path.join(cortexDir, ".background-pid"), JSON.stringify({
|
|
411
|
+
pid: proc.pid,
|
|
412
|
+
branch: branchName,
|
|
413
|
+
agent,
|
|
414
|
+
startedAt: new Date().toISOString(),
|
|
415
|
+
}));
|
|
416
|
+
// Show initial toast
|
|
417
|
+
try {
|
|
418
|
+
await client.tui.showToast({
|
|
419
|
+
body: {
|
|
420
|
+
title: `Background: ${branchName}`,
|
|
421
|
+
message: `Started background implementation with agent '${agent}'`,
|
|
422
|
+
variant: "info",
|
|
423
|
+
duration: 5000,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Toast failure is non-fatal
|
|
429
|
+
}
|
|
430
|
+
// Monitor completion in background (fire-and-forget)
|
|
431
|
+
monitorBackgroundProcess(proc, client, branchName, worktreePath);
|
|
432
|
+
return `✓ Launched background implementation
|
|
433
|
+
|
|
434
|
+
Branch: ${branchName}
|
|
435
|
+
PID: ${proc.pid}
|
|
436
|
+
Agent: ${agent}
|
|
437
|
+
Working in: ${worktreePath}
|
|
438
|
+
|
|
439
|
+
The AI is implementing in the background. You'll get a toast notification
|
|
440
|
+
when it completes or fails.
|
|
441
|
+
|
|
442
|
+
PID tracking file: ${path.join(cortexDir, ".background-pid")}
|
|
443
|
+
|
|
444
|
+
To check worktree status later:
|
|
445
|
+
git -C "${worktreePath}" status
|
|
446
|
+
git -C "${worktreePath}" log --oneline -5`;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Monitor a background process and notify via toast on completion.
|
|
450
|
+
* This runs asynchronously — does not block the tool response.
|
|
451
|
+
*/
|
|
452
|
+
async function monitorBackgroundProcess(proc, client, branchName, worktreePath) {
|
|
453
|
+
try {
|
|
454
|
+
// Wait for the process to exit (non-blocking from the tool's perspective)
|
|
455
|
+
const exitCode = await proc.exited;
|
|
456
|
+
// Clean up PID file
|
|
457
|
+
const pidFile = path.join(worktreePath, ".cortex", ".background-pid");
|
|
458
|
+
if (fs.existsSync(pidFile)) {
|
|
459
|
+
fs.unlinkSync(pidFile);
|
|
460
|
+
}
|
|
461
|
+
if (exitCode === 0) {
|
|
462
|
+
await client.tui.showToast({
|
|
463
|
+
body: {
|
|
464
|
+
title: `Background: ${branchName}`,
|
|
465
|
+
message: "Implementation complete! Check the worktree for changes.",
|
|
466
|
+
variant: "success",
|
|
467
|
+
duration: 10000,
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
await client.tui.showToast({
|
|
473
|
+
body: {
|
|
474
|
+
title: `Background: ${branchName}`,
|
|
475
|
+
message: `Process exited with code ${exitCode}. Check the worktree.`,
|
|
476
|
+
variant: "warning",
|
|
477
|
+
duration: 10000,
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
try {
|
|
484
|
+
await client.tui.showToast({
|
|
485
|
+
body: {
|
|
486
|
+
title: `Background: ${branchName}`,
|
|
487
|
+
message: `Error: ${error.message || "Process monitoring failed"}`,
|
|
488
|
+
variant: "error",
|
|
489
|
+
duration: 10000,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// If toast fails too, nothing we can do
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ─── worktree_launch Factory ─────────────────────────────────────────────────
|
|
499
|
+
/**
|
|
500
|
+
* Factory function that creates the worktree_launch tool with access
|
|
501
|
+
* to the OpenCode client (for PTY and toast) and shell.
|
|
502
|
+
*
|
|
503
|
+
* This uses a closure to capture `client` and `shell` since ToolContext
|
|
504
|
+
* does not provide access to the OpenCode client API.
|
|
505
|
+
*/
|
|
506
|
+
export function createLaunch(client, shell) {
|
|
507
|
+
return tool({
|
|
508
|
+
description: "Launch an OpenCode session in an existing worktree. Supports three modes: " +
|
|
509
|
+
"'terminal' opens a new terminal tab, 'pty' creates an in-app PTY session, " +
|
|
510
|
+
"'background' runs implementation headlessly with progress notifications.",
|
|
511
|
+
args: {
|
|
512
|
+
name: tool.schema
|
|
513
|
+
.string()
|
|
514
|
+
.describe("Worktree name (must already exist — use worktree_create first)"),
|
|
515
|
+
mode: tool.schema
|
|
516
|
+
.enum(["terminal", "pty", "background"])
|
|
517
|
+
.describe("Launch mode: 'terminal' = new terminal tab, 'pty' = in-app PTY session, " +
|
|
518
|
+
"'background' = headless execution with toast notifications"),
|
|
519
|
+
plan: tool.schema
|
|
520
|
+
.string()
|
|
521
|
+
.optional()
|
|
522
|
+
.describe("Plan filename to propagate into the worktree (e.g., '2026-02-22-feature-auth.md')"),
|
|
523
|
+
agent: tool.schema
|
|
524
|
+
.string()
|
|
525
|
+
.optional()
|
|
526
|
+
.describe("Agent to use in the new session (default: 'build')"),
|
|
527
|
+
prompt: tool.schema
|
|
528
|
+
.string()
|
|
529
|
+
.optional()
|
|
530
|
+
.describe("Custom prompt for the new session (auto-generated from plan if omitted)"),
|
|
531
|
+
},
|
|
532
|
+
async execute(args, context) {
|
|
533
|
+
const { name, mode, plan: planFilename, agent = "build", prompt: customPrompt, } = args;
|
|
534
|
+
const worktreePath = path.join(context.worktree, WORKTREE_ROOT, name);
|
|
535
|
+
const absoluteWorktreePath = path.resolve(worktreePath);
|
|
536
|
+
// ── Validate worktree exists ───────────────────────────────
|
|
537
|
+
if (!fs.existsSync(absoluteWorktreePath)) {
|
|
538
|
+
return `✗ Error: Worktree not found at ${absoluteWorktreePath}
|
|
539
|
+
|
|
540
|
+
Use worktree_create to create it first, then worktree_launch to start working in it.
|
|
541
|
+
Use worktree_list to see existing worktrees.`;
|
|
542
|
+
}
|
|
543
|
+
// ── Find opencode binary ───────────────────────────────────
|
|
544
|
+
const opencodeBin = await findOpencodeBinary();
|
|
545
|
+
if (!opencodeBin) {
|
|
546
|
+
return `✗ Error: Could not find the 'opencode' binary.
|
|
547
|
+
|
|
548
|
+
Checked:
|
|
549
|
+
- ~/.opencode/bin/opencode
|
|
550
|
+
- $PATH (via 'which opencode')
|
|
551
|
+
|
|
552
|
+
Install OpenCode or ensure it's in your PATH.`;
|
|
553
|
+
}
|
|
554
|
+
// ── Detect branch name ─────────────────────────────────────
|
|
555
|
+
let branchName = name;
|
|
556
|
+
try {
|
|
557
|
+
const branch = await Bun.$ `git -C ${absoluteWorktreePath} branch --show-current`.quiet().text();
|
|
558
|
+
if (branch.trim())
|
|
559
|
+
branchName = branch.trim();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// Use worktree name as fallback
|
|
563
|
+
}
|
|
564
|
+
// ── Propagate plan to worktree ─────────────────────────────
|
|
565
|
+
let planInfo = "";
|
|
566
|
+
if (planFilename || fs.existsSync(path.join(context.worktree, ".cortex", "plans"))) {
|
|
567
|
+
const result = propagatePlan({
|
|
568
|
+
sourceWorktree: context.worktree,
|
|
569
|
+
targetWorktree: absoluteWorktreePath,
|
|
570
|
+
planFilename,
|
|
571
|
+
});
|
|
572
|
+
if (result.copied.length > 0) {
|
|
573
|
+
planInfo = `\nPlans propagated: ${result.copied.join(", ")}`;
|
|
574
|
+
if (result.initialized) {
|
|
575
|
+
planInfo += " (.cortex initialized in worktree)";
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// ── Build prompt ───────────────────────────────────────────
|
|
580
|
+
const launchPrompt = buildLaunchPrompt(planFilename, customPrompt);
|
|
581
|
+
// ── Launch based on mode ───────────────────────────────────
|
|
582
|
+
let launchResult;
|
|
583
|
+
switch (mode) {
|
|
584
|
+
case "terminal":
|
|
585
|
+
launchResult = await launchTerminalTab(absoluteWorktreePath, opencodeBin, agent, launchPrompt);
|
|
586
|
+
break;
|
|
587
|
+
case "pty":
|
|
588
|
+
launchResult = await launchPty(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
|
|
589
|
+
break;
|
|
590
|
+
case "background":
|
|
591
|
+
launchResult = await launchBackground(client, absoluteWorktreePath, branchName, opencodeBin, agent, launchPrompt);
|
|
592
|
+
break;
|
|
593
|
+
default:
|
|
594
|
+
launchResult = `✗ Unknown mode: ${mode}`;
|
|
595
|
+
}
|
|
596
|
+
return `${launchResult}${planInfo}`;
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections extracted from a plan for use in a PR body.
|
|
3
|
+
*/
|
|
4
|
+
export interface PlanSections {
|
|
5
|
+
/** Plan title from frontmatter or first heading */
|
|
6
|
+
title: string;
|
|
7
|
+
/** Summary paragraph(s) */
|
|
8
|
+
summary: string;
|
|
9
|
+
/** Task list in markdown checkbox format */
|
|
10
|
+
tasks: string;
|
|
11
|
+
/** Key decisions section */
|
|
12
|
+
decisions: string;
|
|
13
|
+
/** The raw plan filename */
|
|
14
|
+
filename: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extract relevant sections from a plan markdown file for composing a PR body.
|
|
18
|
+
*
|
|
19
|
+
* Parses the plan looking for ## Summary, ## Tasks, and ## Key Decisions sections.
|
|
20
|
+
* Falls back gracefully if sections are missing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractPlanSections(planContent: string, filename: string): PlanSections;
|
|
23
|
+
/**
|
|
24
|
+
* Build a PR body from extracted plan sections.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildPrBodyFromPlan(sections: PlanSections): string;
|
|
27
|
+
/**
|
|
28
|
+
* Find and read a plan file from .cortex/plans/.
|
|
29
|
+
*
|
|
30
|
+
* If a specific filename is given, reads that file.
|
|
31
|
+
* Otherwise, finds the most recent plan matching the branch type prefix.
|
|
32
|
+
*/
|
|
33
|
+
export declare function findPlanContent(worktree: string, planFilename?: string, branchName?: string): {
|
|
34
|
+
content: string;
|
|
35
|
+
filename: string;
|
|
36
|
+
} | null;
|
|
37
|
+
//# sourceMappingURL=plan-extract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-extract.d.ts","sourceRoot":"","sources":["../../src/utils/plan-extract.ts"],"names":[],"mappings":"AAMA;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAuCvF;AAmCD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,CAwBlE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,MAAM,EACrB,UAAU,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAqC9C"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
const CORTEX_DIR = ".cortex";
|
|
4
|
+
const PLANS_DIR = "plans";
|
|
5
|
+
/**
|
|
6
|
+
* Extract relevant sections from a plan markdown file for composing a PR body.
|
|
7
|
+
*
|
|
8
|
+
* Parses the plan looking for ## Summary, ## Tasks, and ## Key Decisions sections.
|
|
9
|
+
* Falls back gracefully if sections are missing.
|
|
10
|
+
*/
|
|
11
|
+
export function extractPlanSections(planContent, filename) {
|
|
12
|
+
const result = {
|
|
13
|
+
title: "",
|
|
14
|
+
summary: "",
|
|
15
|
+
tasks: "",
|
|
16
|
+
decisions: "",
|
|
17
|
+
filename,
|
|
18
|
+
};
|
|
19
|
+
// Extract title from frontmatter
|
|
20
|
+
const frontmatterMatch = planContent.match(/^---\n([\s\S]*?)\n---/);
|
|
21
|
+
if (frontmatterMatch) {
|
|
22
|
+
const titleMatch = frontmatterMatch[1].match(/title:\s*"?([^"\n]+)"?/);
|
|
23
|
+
if (titleMatch)
|
|
24
|
+
result.title = titleMatch[1].trim();
|
|
25
|
+
}
|
|
26
|
+
// Fallback: extract title from first # heading
|
|
27
|
+
if (!result.title) {
|
|
28
|
+
const headingMatch = planContent.match(/^#\s+(.+)$/m);
|
|
29
|
+
if (headingMatch)
|
|
30
|
+
result.title = headingMatch[1].trim();
|
|
31
|
+
}
|
|
32
|
+
// Extract sections by heading
|
|
33
|
+
// Split on ## headings and capture each section
|
|
34
|
+
const sections = splitByHeadings(planContent);
|
|
35
|
+
for (const [heading, content] of sections) {
|
|
36
|
+
const h = heading.toLowerCase();
|
|
37
|
+
if (h.includes("summary")) {
|
|
38
|
+
result.summary = content.trim();
|
|
39
|
+
}
|
|
40
|
+
else if (h.includes("task")) {
|
|
41
|
+
result.tasks = content.trim();
|
|
42
|
+
}
|
|
43
|
+
else if (h.includes("decision") || h.includes("key decision")) {
|
|
44
|
+
result.decisions = content.trim();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Split markdown content into sections based on ## headings.
|
|
51
|
+
* Returns an array of [heading, content] tuples.
|
|
52
|
+
*/
|
|
53
|
+
function splitByHeadings(content) {
|
|
54
|
+
const sections = [];
|
|
55
|
+
const lines = content.split("\n");
|
|
56
|
+
let currentHeading = "";
|
|
57
|
+
let currentContent = [];
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const headingMatch = line.match(/^##\s+(.+)$/);
|
|
60
|
+
if (headingMatch) {
|
|
61
|
+
// Save previous section
|
|
62
|
+
if (currentHeading) {
|
|
63
|
+
sections.push([currentHeading, currentContent.join("\n")]);
|
|
64
|
+
}
|
|
65
|
+
currentHeading = headingMatch[1];
|
|
66
|
+
currentContent = [];
|
|
67
|
+
}
|
|
68
|
+
else if (currentHeading) {
|
|
69
|
+
currentContent.push(line);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Save last section
|
|
73
|
+
if (currentHeading) {
|
|
74
|
+
sections.push([currentHeading, currentContent.join("\n")]);
|
|
75
|
+
}
|
|
76
|
+
return sections;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build a PR body from extracted plan sections.
|
|
80
|
+
*/
|
|
81
|
+
export function buildPrBodyFromPlan(sections) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
if (sections.summary) {
|
|
84
|
+
parts.push(`## Summary\n\n${sections.summary}`);
|
|
85
|
+
}
|
|
86
|
+
if (sections.tasks) {
|
|
87
|
+
parts.push(`## Tasks\n\n${sections.tasks}`);
|
|
88
|
+
}
|
|
89
|
+
if (sections.decisions) {
|
|
90
|
+
parts.push(`## Key Decisions\n\n${sections.decisions}`);
|
|
91
|
+
}
|
|
92
|
+
if (parts.length === 0) {
|
|
93
|
+
return `Implementation based on plan: \`${sections.filename}\``;
|
|
94
|
+
}
|
|
95
|
+
parts.push(`---\n*Auto-generated by cortex-agents from plan: \`${sections.filename}\`*`);
|
|
96
|
+
return parts.join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Find and read a plan file from .cortex/plans/.
|
|
100
|
+
*
|
|
101
|
+
* If a specific filename is given, reads that file.
|
|
102
|
+
* Otherwise, finds the most recent plan matching the branch type prefix.
|
|
103
|
+
*/
|
|
104
|
+
export function findPlanContent(worktree, planFilename, branchName) {
|
|
105
|
+
const plansDir = path.join(worktree, CORTEX_DIR, PLANS_DIR);
|
|
106
|
+
if (!fs.existsSync(plansDir))
|
|
107
|
+
return null;
|
|
108
|
+
if (planFilename) {
|
|
109
|
+
const filepath = path.join(plansDir, planFilename);
|
|
110
|
+
if (fs.existsSync(filepath)) {
|
|
111
|
+
return { content: fs.readFileSync(filepath, "utf-8"), filename: planFilename };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// Try to find a matching plan by branch type
|
|
116
|
+
// e.g., branch "feature/auth" → look for plans with "feature" type
|
|
117
|
+
const planFiles = fs
|
|
118
|
+
.readdirSync(plansDir)
|
|
119
|
+
.filter((f) => f.endsWith(".md") && f !== ".gitkeep")
|
|
120
|
+
.sort()
|
|
121
|
+
.reverse(); // Most recent first
|
|
122
|
+
if (planFiles.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
// If branch name provided, try to match by type
|
|
125
|
+
if (branchName) {
|
|
126
|
+
const branchType = branchName.split("/")[0]; // "feature", "bugfix", etc.
|
|
127
|
+
const matched = planFiles.find((f) => f.includes(branchType));
|
|
128
|
+
if (matched) {
|
|
129
|
+
const filepath = path.join(plansDir, matched);
|
|
130
|
+
return { content: fs.readFileSync(filepath, "utf-8"), filename: matched };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Fall back to most recent plan
|
|
134
|
+
const mostRecent = planFiles[0];
|
|
135
|
+
const filepath = path.join(plansDir, mostRecent);
|
|
136
|
+
return { content: fs.readFileSync(filepath, "utf-8"), filename: mostRecent };
|
|
137
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface PropagateResult {
|
|
2
|
+
/** Plans that were copied */
|
|
3
|
+
copied: string[];
|
|
4
|
+
/** Whether .cortex was newly initialized in the target */
|
|
5
|
+
initialized: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Propagate plans from the main project into a worktree's .cortex/plans/ directory.
|
|
9
|
+
*
|
|
10
|
+
* Ensures the worktree is self-contained with its own copy of the plans,
|
|
11
|
+
* so the new OpenCode session has full context without referencing the parent.
|
|
12
|
+
*/
|
|
13
|
+
export declare function propagatePlan(opts: {
|
|
14
|
+
/** Main project root (source) */
|
|
15
|
+
sourceWorktree: string;
|
|
16
|
+
/** Worktree path (target) */
|
|
17
|
+
targetWorktree: string;
|
|
18
|
+
/** Specific plan filename to copy — copies all plans if omitted */
|
|
19
|
+
planFilename?: string;
|
|
20
|
+
}): PropagateResult;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=propagate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"propagate.d.ts","sourceRoot":"","sources":["../../src/utils/propagate.ts"],"names":[],"mappings":"AAMA,UAAU,eAAe;IACvB,6BAA6B;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,iCAAiC;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,6BAA6B;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,eAAe,CA6DlB"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
const CORTEX_DIR = ".cortex";
|
|
4
|
+
const PLANS_DIR = "plans";
|
|
5
|
+
/**
|
|
6
|
+
* Propagate plans from the main project into a worktree's .cortex/plans/ directory.
|
|
7
|
+
*
|
|
8
|
+
* Ensures the worktree is self-contained with its own copy of the plans,
|
|
9
|
+
* so the new OpenCode session has full context without referencing the parent.
|
|
10
|
+
*/
|
|
11
|
+
export function propagatePlan(opts) {
|
|
12
|
+
const { sourceWorktree, targetWorktree, planFilename } = opts;
|
|
13
|
+
const sourcePlansDir = path.join(sourceWorktree, CORTEX_DIR, PLANS_DIR);
|
|
14
|
+
const targetCortexDir = path.join(targetWorktree, CORTEX_DIR);
|
|
15
|
+
const targetPlansDir = path.join(targetCortexDir, PLANS_DIR);
|
|
16
|
+
const result = { copied: [], initialized: false };
|
|
17
|
+
// Check source has plans
|
|
18
|
+
if (!fs.existsSync(sourcePlansDir)) {
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
// Initialize target .cortex if needed
|
|
22
|
+
if (!fs.existsSync(targetCortexDir)) {
|
|
23
|
+
fs.mkdirSync(targetCortexDir, { recursive: true });
|
|
24
|
+
result.initialized = true;
|
|
25
|
+
}
|
|
26
|
+
if (!fs.existsSync(targetPlansDir)) {
|
|
27
|
+
fs.mkdirSync(targetPlansDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
// Copy config.json if it exists (for consistent configuration)
|
|
30
|
+
const sourceConfig = path.join(sourceWorktree, CORTEX_DIR, "config.json");
|
|
31
|
+
if (fs.existsSync(sourceConfig)) {
|
|
32
|
+
const targetConfig = path.join(targetCortexDir, "config.json");
|
|
33
|
+
if (!fs.existsSync(targetConfig)) {
|
|
34
|
+
fs.copyFileSync(sourceConfig, targetConfig);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Create sessions dir in target (tools expect it)
|
|
38
|
+
const targetSessionsDir = path.join(targetCortexDir, "sessions");
|
|
39
|
+
if (!fs.existsSync(targetSessionsDir)) {
|
|
40
|
+
fs.mkdirSync(targetSessionsDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
if (planFilename) {
|
|
43
|
+
// Copy specific plan
|
|
44
|
+
const sourcePlan = path.join(sourcePlansDir, planFilename);
|
|
45
|
+
if (fs.existsSync(sourcePlan)) {
|
|
46
|
+
const targetPlan = path.join(targetPlansDir, planFilename);
|
|
47
|
+
fs.copyFileSync(sourcePlan, targetPlan);
|
|
48
|
+
result.copied.push(planFilename);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Copy all plans
|
|
53
|
+
const planFiles = fs
|
|
54
|
+
.readdirSync(sourcePlansDir)
|
|
55
|
+
.filter((f) => f.endsWith(".md") && f !== ".gitkeep");
|
|
56
|
+
for (const file of planFiles) {
|
|
57
|
+
const sourcePlan = path.join(sourcePlansDir, file);
|
|
58
|
+
const targetPlan = path.join(targetPlansDir, file);
|
|
59
|
+
fs.copyFileSync(sourcePlan, targetPlan);
|
|
60
|
+
result.copied.push(file);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|