@ship-cli/opencode 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -1,282 +1,2239 @@
1
1
  /**
2
2
  * Ship OpenCode Plugin
3
3
  *
4
- * Integrates the Ship CLI (Linear task management) with OpenCode.
5
- *
6
- * Features:
7
- * - Context injection via `ship prime` on session start and after compaction
8
- * - Ship tool for task management operations
9
- * - Task agent for autonomous issue completion
4
+ * Provides the `ship` tool for Linear task management and stacked changes workflow.
5
+ * Instructions/guidance are handled by the ship-cli skill (.opencode/skill/ship-cli/SKILL.md)
10
6
  */
11
7
 
12
- import type { Plugin, PluginInput } from "@opencode-ai/plugin";
8
+ import type { Hooks, Plugin, ToolDefinition } from "@opencode-ai/plugin";
13
9
  import { tool as createTool } from "@opencode-ai/plugin";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Data from "effect/Data";
12
+ import * as Context from "effect/Context";
13
+ import * as Layer from "effect/Layer";
14
+ import * as Schema from "effect/Schema";
15
+ import * as Option from "effect/Option";
16
+ import { sessionTaskMap, trackTask, getTrackedTask, decodeShipToolArgs } from "./compaction.js";
17
+
18
+ // =============================================================================
19
+ // Types & Errors
20
+ // =============================================================================
21
+
22
+ type BunShell = Parameters<Plugin>[0]["$"];
23
+
24
+ class ShipCommandError extends Data.TaggedError("ShipCommandError")<{
25
+ readonly command: string;
26
+ readonly message: string;
27
+ }> {}
28
+
29
+ class ShipNotConfiguredError extends Data.TaggedError("ShipNotConfiguredError")<{
30
+ readonly reason?: string;
31
+ }> {}
32
+
33
+ class JsonParseError extends Data.TaggedError("JsonParseError")<{
34
+ readonly raw: string;
35
+ readonly cause: unknown;
36
+ }> {}
37
+
38
+ interface ShipStatus {
39
+ configured: boolean;
40
+ teamId?: string;
41
+ teamKey?: string;
42
+ projectId?: string | null;
43
+ }
44
+
45
+ /**
46
+ * Webhook process management - module-level state for persistence across tool calls.
47
+ *
48
+ * Note: We previously attempted using Effect.Ref for state management, but it didn't
49
+ * persist across separate Effect runs (each tool call creates a new runtime/layer).
50
+ * Module-level state is a pragmatic solution for subprocess management.
51
+ *
52
+ * Future consideration: Refactor to use Effect Fibers with Effect.forkDaemon for
53
+ * in-process webhook forwarding, which would allow proper Effect resource management.
54
+ * See BRI-88 for details.
55
+ */
56
+ let cleanupRegistered = false;
57
+ let processToCleanup: ReturnType<typeof Bun.spawn> | null = null;
58
+
59
+ const registerProcessCleanup = (proc: ReturnType<typeof Bun.spawn>) => {
60
+ processToCleanup = proc;
61
+
62
+ if (!cleanupRegistered) {
63
+ cleanupRegistered = true;
64
+
65
+ const cleanup = () => {
66
+ if (processToCleanup && !processToCleanup.killed) {
67
+ processToCleanup.kill();
68
+ processToCleanup = null;
69
+ }
70
+ };
71
+
72
+ process.on("exit", cleanup);
73
+ process.on("SIGINT", () => {
74
+ cleanup();
75
+ process.exit(0);
76
+ });
77
+ process.on("SIGTERM", () => {
78
+ cleanup();
79
+ process.exit(0);
80
+ });
81
+ }
82
+ };
83
+
84
+ const unregisterProcessCleanup = () => {
85
+ processToCleanup = null;
86
+ };
87
+
88
+ interface ShipSubtask {
89
+ id: string;
90
+ identifier: string;
91
+ title: string;
92
+ state: string;
93
+ stateType: string;
94
+ isDone: boolean;
95
+ }
96
+
97
+ // Milestone types
98
+ interface ShipMilestone {
99
+ id: string;
100
+ slug: string;
101
+ name: string;
102
+ description?: string | null;
103
+ targetDate?: string | null;
104
+ projectId: string;
105
+ sortOrder: number;
106
+ }
107
+
108
+ interface ShipTask {
109
+ identifier: string;
110
+ title: string;
111
+ description?: string;
112
+ priority: string;
113
+ status: string;
114
+ state?: string;
115
+ labels: string[];
116
+ url: string;
117
+ branchName?: string;
118
+ subtasks?: ShipSubtask[];
119
+ milestoneId?: string | null;
120
+ milestoneName?: string | null;
121
+ }
122
+
123
+ // Stack types
124
+ interface StackChange {
125
+ changeId: string;
126
+ commitId: string;
127
+ description: string;
128
+ bookmarks: string[];
129
+ isEmpty: boolean;
130
+ isWorkingCopy: boolean;
131
+ }
132
+
133
+ interface StackStatus {
134
+ isRepo: boolean;
135
+ change?: {
136
+ changeId: string;
137
+ commitId: string;
138
+ description: string;
139
+ bookmarks: string[];
140
+ isEmpty: boolean;
141
+ };
142
+ error?: string;
143
+ }
144
+
145
+ interface StackCreateResult {
146
+ created: boolean;
147
+ changeId?: string;
148
+ bookmark?: string;
149
+ workspace?: {
150
+ name: string;
151
+ path: string;
152
+ created: boolean;
153
+ };
154
+ error?: string;
155
+ }
156
+
157
+ // Workspace types
158
+ interface WorkspaceOutput {
159
+ name: string;
160
+ path: string;
161
+ changeId: string;
162
+ description: string;
163
+ isDefault: boolean;
164
+ stackName: string | null;
165
+ taskId: string | null;
166
+ }
167
+
168
+ interface RemoveWorkspaceResult {
169
+ removed: boolean;
170
+ name: string;
171
+ filesDeleted?: boolean;
172
+ error?: string;
173
+ }
174
+
175
+ interface StackDescribeResult {
176
+ updated: boolean;
177
+ changeId?: string;
178
+ description?: string;
179
+ error?: string;
180
+ }
181
+
182
+ interface AbandonedMergedChange {
183
+ changeId: string;
184
+ bookmark?: string;
185
+ }
186
+
187
+ interface StackSyncResult {
188
+ fetched: boolean;
189
+ rebased: boolean;
190
+ trunkChangeId?: string;
191
+ stackSize?: number;
192
+ conflicted?: boolean;
193
+ /** Changes that were auto-abandoned because they were merged */
194
+ abandonedMergedChanges?: AbandonedMergedChange[];
195
+ /** Whether the entire stack was merged and workspace was cleaned up */
196
+ stackFullyMerged?: boolean;
197
+ /** Workspace that was cleaned up (only if stackFullyMerged) */
198
+ cleanedUpWorkspace?: string;
199
+ error?: { tag: string; message: string };
200
+ }
201
+
202
+ interface StackRestackResult {
203
+ restacked: boolean;
204
+ stackSize?: number;
205
+ trunkChangeId?: string;
206
+ conflicted?: boolean;
207
+ error?: string;
208
+ }
209
+
210
+ interface StackSubmitResult {
211
+ pushed: boolean;
212
+ bookmark?: string;
213
+ baseBranch?: string;
214
+ pr?: {
215
+ url: string;
216
+ number: number;
217
+ status: "created" | "updated" | "exists";
218
+ };
219
+ error?: string;
220
+ subscribed?: {
221
+ sessionId: string;
222
+ prNumbers: number[];
223
+ };
224
+ }
225
+
226
+ interface StackSquashResult {
227
+ squashed: boolean;
228
+ intoChangeId?: string;
229
+ description?: string;
230
+ error?: string;
231
+ }
232
+
233
+ interface StackAbandonResult {
234
+ abandoned: boolean;
235
+ changeId?: string;
236
+ newWorkingCopy?: string;
237
+ error?: string;
238
+ }
239
+
240
+ interface StackNavigateResult {
241
+ moved: boolean;
242
+ from?: {
243
+ changeId: string;
244
+ description: string;
245
+ };
246
+ to?: {
247
+ changeId: string;
248
+ description: string;
249
+ };
250
+ error?: string;
251
+ }
252
+
253
+ interface StackUndoResult {
254
+ undone: boolean;
255
+ operation?: string;
256
+ error?: string;
257
+ }
258
+
259
+ interface StackUpdateStaleResult {
260
+ updated: boolean;
261
+ changeId?: string;
262
+ error?: string;
263
+ }
264
+
265
+ interface StackBookmarkResult {
266
+ success: boolean;
267
+ action: "created" | "moved";
268
+ bookmark: string;
269
+ changeId?: string;
270
+ error?: string;
271
+ }
272
+
273
+ interface WebhookStartResult {
274
+ started: boolean;
275
+ pid?: number;
276
+ repo?: string;
277
+ events?: string[];
278
+ error?: string;
279
+ }
280
+
281
+ interface WebhookStopResult {
282
+ stopped: boolean;
283
+ wasRunning: boolean;
284
+ error?: string;
285
+ }
286
+
287
+ interface WebhookSubscribeResult {
288
+ subscribed: boolean;
289
+ sessionId?: string;
290
+ prNumbers?: number[];
291
+ error?: string;
292
+ }
293
+
294
+ interface WebhookUnsubscribeResult {
295
+ unsubscribed: boolean;
296
+ sessionId?: string;
297
+ prNumbers?: number[];
298
+ error?: string;
299
+ }
300
+
301
+ interface WebhookDaemonStatus {
302
+ running: boolean;
303
+ pid?: number;
304
+ repo?: string;
305
+ connectedToGitHub?: boolean;
306
+ subscriptions?: Array<{ sessionId: string; prNumbers: number[] }>;
307
+ uptime?: number;
308
+ }
309
+
310
+ interface WebhookCleanupResult {
311
+ success: boolean;
312
+ removedSessions: string[];
313
+ remainingSessions?: number;
314
+ error?: string;
315
+ }
316
+
317
+ // PR Review types
318
+ // Note: This type mirrors ReviewOutput from CLI's review.ts
319
+ // Kept separate for plugin isolation (plugin doesn't depend on CLI package)
320
+ interface PrReviewOutput {
321
+ prNumber: number;
322
+ prTitle?: string;
323
+ prUrl?: string;
324
+ reviews: Array<{
325
+ id: number;
326
+ author: string;
327
+ state: string;
328
+ body: string;
329
+ submittedAt: string;
330
+ }>;
331
+ codeComments: Array<{
332
+ id: number;
333
+ path: string;
334
+ line: number | null;
335
+ body: string;
336
+ author: string;
337
+ createdAt: string;
338
+ inReplyToId: number | null;
339
+ diffHunk?: string;
340
+ }>;
341
+ conversationComments: Array<{
342
+ id: number;
343
+ body: string;
344
+ author: string;
345
+ createdAt: string;
346
+ }>;
347
+ commentsByFile: Record<
348
+ string,
349
+ Array<{
350
+ line: number | null;
351
+ author: string;
352
+ body: string;
353
+ id: number;
354
+ diffHunk?: string;
355
+ }>
356
+ >;
357
+ error?: string;
358
+ }
359
+
360
+ // =============================================================================
361
+ // Shell Service
362
+ // =============================================================================
14
363
 
15
- type OpencodeClient = PluginInput["client"];
364
+ interface ShellService {
365
+ readonly run: (args: string[], cwd?: string) => Effect.Effect<string, ShipCommandError>;
366
+ }
367
+
368
+ const ShellService = Context.GenericTag<ShellService>("ShellService");
369
+
370
+ /**
371
+ * Create a shell service for running ship commands.
372
+ *
373
+ * @param defaultCwd - Default working directory for commands (from opencode's Instance.directory)
374
+ */
375
+ const makeShellService = (_$: BunShell, defaultCwd?: string): ShellService => {
376
+ const getCommand = (): string[] => {
377
+ if (process.env.NODE_ENV === "development") {
378
+ return ["pnpm", "ship"];
379
+ }
380
+ return ["ship"];
381
+ };
382
+
383
+ /**
384
+ * Schema for validating that a string is valid JSON.
385
+ * Uses Effect's Schema.parseJson() for safe, Effect-native JSON parsing.
386
+ */
387
+ const JsonString = Schema.parseJson();
388
+ const validateJson = Schema.decodeUnknownOption(JsonString);
389
+
390
+ /**
391
+ * Extract JSON from CLI output by finding valid JSON object or array.
392
+ *
393
+ * The CLI may output non-JSON content before the actual JSON response (e.g., spinner
394
+ * output, progress messages). Additionally, task descriptions may contain JSON code
395
+ * blocks which could be incorrectly matched if we search from the start.
396
+ *
397
+ * This function finds all potential JSON start positions and validates each candidate
398
+ * using Schema.parseJson(). We prioritize top-level JSON (no leading whitespace) to
399
+ * avoid matching nested objects inside arrays.
400
+ */
401
+ const extractJson = (output: string): string => {
402
+ // Find all potential JSON start positions (lines starting with { or [)
403
+ // The regex captures leading whitespace to distinguish top-level vs nested JSON
404
+ const matches = [...output.matchAll(/^(\s*)([[{])/gm)];
405
+ if (matches.length === 0) {
406
+ return output;
407
+ }
408
+
409
+ // Separate top-level matches (no leading whitespace) from nested ones
410
+ const topLevelMatches: Array<{ index: number }> = [];
411
+ const nestedMatches: Array<{ index: number }> = [];
412
+
413
+ for (const match of matches) {
414
+ if (match.index === undefined) continue;
415
+ const leadingWhitespace = match[1];
416
+ // Top-level JSON starts at column 0 (no leading whitespace)
417
+ if (leadingWhitespace === "") {
418
+ topLevelMatches.push({ index: match.index });
419
+ } else {
420
+ nestedMatches.push({ index: match.index });
421
+ }
422
+ }
423
+
424
+ // Try top-level matches first (most likely to be the actual response)
425
+ // Then fall back to nested matches if needed
426
+ const orderedMatches = [...topLevelMatches, ...nestedMatches];
427
+
428
+ for (const match of orderedMatches) {
429
+ const candidate = output.slice(match.index).trim();
430
+ // Validate using Schema.parseJson() - returns Option.some if valid
431
+ if (validateJson(candidate)._tag === "Some") {
432
+ return candidate;
433
+ }
434
+ }
435
+
436
+ // Fallback to original output if no valid JSON found
437
+ return output;
438
+ };
439
+
440
+ return {
441
+ run: (args: string[], cwd?: string) =>
442
+ Effect.gen(function* () {
443
+ const cmd = getCommand();
444
+ const fullArgs = [...cmd, ...args];
445
+ const workingDir = cwd ?? defaultCwd;
446
+
447
+ const result = yield* Effect.tryPromise({
448
+ try: async (signal) => {
449
+ const proc = Bun.spawn(fullArgs, {
450
+ stdout: "pipe",
451
+ stderr: "pipe",
452
+ signal,
453
+ cwd: workingDir, // Use provided cwd or default from opencode
454
+ });
455
+
456
+ const [stdout, stderr, exitCode] = await Promise.all([
457
+ new Response(proc.stdout).text(),
458
+ new Response(proc.stderr).text(),
459
+ proc.exited,
460
+ ]);
461
+
462
+ return { exitCode, stdout, stderr };
463
+ },
464
+ catch: (e) =>
465
+ new ShipCommandError({
466
+ command: args.join(" "),
467
+ message: `Failed to execute: ${e}`,
468
+ }),
469
+ });
470
+
471
+ if (result.exitCode !== 0) {
472
+ return yield* new ShipCommandError({
473
+ command: args.join(" "),
474
+ message: result.stderr || result.stdout,
475
+ });
476
+ }
16
477
 
17
- const SHIP_GUIDANCE = `## Ship Tool Guidance
478
+ if (args.includes("--json")) {
479
+ return extractJson(result.stdout);
480
+ }
18
481
 
19
- **IMPORTANT: Always use the \`ship\` tool, NEVER run \`ship\` or \`pnpm ship\` via bash/terminal.**
482
+ return result.stdout;
483
+ }),
484
+ };
485
+ };
20
486
 
21
- The \`ship\` tool is available for Linear task management. Use it instead of CLI commands.
487
+ // =============================================================================
488
+ // Ship Service
489
+ // =============================================================================
490
+
491
+ interface ShipService {
492
+ readonly checkConfigured: () => Effect.Effect<ShipStatus, ShipCommandError | JsonParseError>;
493
+ readonly getReadyTasks: () => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
494
+ readonly getBlockedTasks: () => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
495
+ readonly listTasks: (filter?: {
496
+ status?: string;
497
+ priority?: string;
498
+ mine?: boolean;
499
+ }) => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
500
+ readonly getTask: (taskId: string) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
501
+ readonly startTask: (taskId: string, sessionId?: string) => Effect.Effect<void, ShipCommandError>;
502
+ readonly completeTask: (taskId: string) => Effect.Effect<void, ShipCommandError>;
503
+ readonly createTask: (input: {
504
+ title: string;
505
+ description?: string;
506
+ priority?: string;
507
+ parentId?: string;
508
+ }) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
509
+ readonly updateTask: (
510
+ taskId: string,
511
+ input: {
512
+ title?: string;
513
+ description?: string;
514
+ priority?: string;
515
+ status?: string;
516
+ parentId?: string;
517
+ },
518
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
519
+ readonly addBlocker: (blocker: string, blocked: string) => Effect.Effect<void, ShipCommandError>;
520
+ readonly removeBlocker: (
521
+ blocker: string,
522
+ blocked: string,
523
+ ) => Effect.Effect<void, ShipCommandError>;
524
+ readonly relateTask: (
525
+ taskId: string,
526
+ relatedTaskId: string,
527
+ ) => Effect.Effect<void, ShipCommandError>;
528
+ // Stack operations - all accept optional workdir for workspace support
529
+ readonly getStackLog: (
530
+ workdir?: string,
531
+ ) => Effect.Effect<StackChange[], ShipCommandError | JsonParseError>;
532
+ readonly getStackStatus: (
533
+ workdir?: string,
534
+ ) => Effect.Effect<StackStatus, ShipCommandError | JsonParseError>;
535
+ readonly createStackChange: (input: {
536
+ message?: string;
537
+ bookmark?: string;
538
+ noWorkspace?: boolean;
539
+ taskId?: string;
540
+ workdir?: string;
541
+ }) => Effect.Effect<StackCreateResult, ShipCommandError | JsonParseError>;
542
+ readonly describeStackChange: (
543
+ input: { message?: string; title?: string; description?: string },
544
+ workdir?: string,
545
+ ) => Effect.Effect<StackDescribeResult, ShipCommandError | JsonParseError>;
546
+ readonly syncStack: (
547
+ workdir?: string,
548
+ ) => Effect.Effect<StackSyncResult, ShipCommandError | JsonParseError>;
549
+ readonly restackStack: (
550
+ workdir?: string,
551
+ ) => Effect.Effect<StackRestackResult, ShipCommandError | JsonParseError>;
552
+ readonly submitStack: (input: {
553
+ draft?: boolean;
554
+ title?: string;
555
+ body?: string;
556
+ subscribe?: string; // OpenCode session ID to subscribe to all stack PRs
557
+ workdir?: string;
558
+ }) => Effect.Effect<StackSubmitResult, ShipCommandError | JsonParseError>;
559
+ readonly squashStack: (
560
+ message: string,
561
+ workdir?: string,
562
+ ) => Effect.Effect<StackSquashResult, ShipCommandError | JsonParseError>;
563
+ readonly abandonStack: (
564
+ changeId?: string,
565
+ workdir?: string,
566
+ ) => Effect.Effect<StackAbandonResult, ShipCommandError | JsonParseError>;
567
+ // Stack navigation
568
+ readonly stackUp: (
569
+ workdir?: string,
570
+ ) => Effect.Effect<StackNavigateResult, ShipCommandError | JsonParseError>;
571
+ readonly stackDown: (
572
+ workdir?: string,
573
+ ) => Effect.Effect<StackNavigateResult, ShipCommandError | JsonParseError>;
574
+ // Stack recovery
575
+ readonly stackUndo: (
576
+ workdir?: string,
577
+ ) => Effect.Effect<StackUndoResult, ShipCommandError | JsonParseError>;
578
+ readonly stackUpdateStale: (
579
+ workdir?: string,
580
+ ) => Effect.Effect<StackUpdateStaleResult, ShipCommandError | JsonParseError>;
581
+ // Stack bookmark
582
+ readonly bookmarkStack: (
583
+ name: string,
584
+ move?: boolean,
585
+ workdir?: string,
586
+ ) => Effect.Effect<StackBookmarkResult, ShipCommandError | JsonParseError>;
587
+ // Webhook operations - use Ref for thread-safe process tracking
588
+ readonly startWebhook: (events?: string) => Effect.Effect<WebhookStartResult, never>;
589
+ readonly stopWebhook: () => Effect.Effect<WebhookStopResult, never>;
590
+ readonly getWebhookStatus: () => Effect.Effect<{ running: boolean; pid?: number }, never>;
591
+ // Daemon-based webhook operations
592
+ readonly getDaemonStatus: () => Effect.Effect<
593
+ WebhookDaemonStatus,
594
+ ShipCommandError | JsonParseError
595
+ >;
596
+ readonly subscribeToPRs: (
597
+ sessionId: string,
598
+ prNumbers: number[],
599
+ serverUrl?: string,
600
+ ) => Effect.Effect<WebhookSubscribeResult, ShipCommandError | JsonParseError>;
601
+ readonly unsubscribeFromPRs: (
602
+ sessionId: string,
603
+ prNumbers: number[],
604
+ serverUrl?: string,
605
+ ) => Effect.Effect<WebhookUnsubscribeResult, ShipCommandError | JsonParseError>;
606
+ readonly cleanupStaleSubscriptions: () => Effect.Effect<
607
+ WebhookCleanupResult,
608
+ ShipCommandError | JsonParseError
609
+ >;
610
+ // Workspace operations - accept optional workdir
611
+ readonly listWorkspaces: (
612
+ workdir?: string,
613
+ ) => Effect.Effect<WorkspaceOutput[], ShipCommandError | JsonParseError>;
614
+ readonly removeWorkspace: (
615
+ name: string,
616
+ deleteFiles?: boolean,
617
+ workdir?: string,
618
+ ) => Effect.Effect<RemoveWorkspaceResult, ShipCommandError | JsonParseError>;
619
+ // Milestone operations
620
+ readonly listMilestones: () => Effect.Effect<ShipMilestone[], ShipCommandError | JsonParseError>;
621
+ readonly getMilestone: (
622
+ milestoneId: string,
623
+ ) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
624
+ readonly createMilestone: (input: {
625
+ name: string;
626
+ description?: string;
627
+ targetDate?: string;
628
+ }) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
629
+ readonly updateMilestone: (
630
+ milestoneId: string,
631
+ input: { name?: string; description?: string; targetDate?: string },
632
+ ) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
633
+ readonly deleteMilestone: (milestoneId: string) => Effect.Effect<void, ShipCommandError>;
634
+ readonly setTaskMilestone: (
635
+ taskId: string,
636
+ milestoneId: string,
637
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
638
+ readonly unsetTaskMilestone: (
639
+ taskId: string,
640
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
641
+ // PR review operations
642
+ readonly getPrReviews: (
643
+ prNumber?: number,
644
+ unresolved?: boolean,
645
+ workdir?: string,
646
+ ) => Effect.Effect<PrReviewOutput, ShipCommandError | JsonParseError>;
647
+ }
22
648
 
23
- ### Available Actions (via ship tool)
24
- - \`ready\` - Tasks you can work on (no blockers)
25
- - \`blocked\` - Tasks waiting on dependencies
26
- - \`list\` - All tasks (with optional filters)
27
- - \`show\` - Task details (requires taskId)
28
- - \`start\` - Begin working on task (requires taskId)
29
- - \`done\` - Mark task complete (requires taskId)
30
- - \`create\` - Create new task (requires title)
31
- - \`update\` - Update task (requires taskId + fields to update)
32
- - \`block\` - Add blocking relationship (requires blocker + blocked)
33
- - \`unblock\` - Remove blocking relationship (requires blocker + blocked)
34
- - \`relate\` - Link tasks as related (requires taskId + relatedTaskId)
35
- - \`prime\` - Get AI context
36
- - \`status\` - Check configuration
649
+ const ShipService = Context.GenericTag<ShipService>("ShipService");
37
650
 
38
- ### Best Practices
39
- 1. Use \`ship\` tool with action \`ready\` to see available work
40
- 2. Use \`ship\` tool with action \`start\` before beginning work
41
- 3. Use \`ship\` tool with action \`done\` when completing tasks
42
- 4. Use \`ship\` tool with action \`block\` for dependency relationships
651
+ /**
652
+ * Parse JSON with type assertion.
653
+ *
654
+ * Note: This uses a type assertion rather than Schema.decodeUnknown for simplicity.
655
+ * The CLI is the source of truth for these types, and we trust its JSON output.
656
+ * For a more robust solution, consider importing shared schemas from the CLI package.
657
+ *
658
+ * @param raw - Raw JSON string from CLI output
659
+ * @returns Parsed object with asserted type T
660
+ */
661
+ const parseJson = <T>(raw: string): Effect.Effect<T, JsonParseError> =>
662
+ Effect.try({
663
+ try: () => {
664
+ const parsed = JSON.parse(raw);
665
+ // Basic runtime validation - ensure we got an object or array
666
+ if (parsed === null || (typeof parsed !== "object" && !Array.isArray(parsed))) {
667
+ throw new Error(`Expected object or array, got ${typeof parsed}`);
668
+ }
669
+ return parsed as T;
670
+ },
671
+ catch: (cause) => new JsonParseError({ raw: raw.slice(0, 500), cause }), // Truncate raw for readability
672
+ });
43
673
 
44
- ### Linear Task Relationships
674
+ const makeShipService = Effect.gen(function* () {
675
+ const shell = yield* ShellService;
45
676
 
46
- Linear has native relationship types. **Always use these instead of writing dependencies in text:**
677
+ const checkConfigured = () =>
678
+ Effect.gen(function* () {
679
+ const output = yield* shell.run(["status", "--json"]);
680
+ return yield* parseJson<ShipStatus>(output);
681
+ });
47
682
 
48
- **Blocking (for dependencies):**
49
- - Use ship tool: action=\`block\`, blocker=\`BRI-100\`, blocked=\`BRI-101\`
50
- - Use ship tool: action=\`unblock\` to remove relationships
51
- - Use ship tool: action=\`blocked\` to see waiting tasks
683
+ const getReadyTasks = () =>
684
+ Effect.gen(function* () {
685
+ const output = yield* shell.run(["task", "ready", "--json"]);
686
+ return yield* parseJson<ShipTask[]>(output);
687
+ });
52
688
 
53
- **Related (for cross-references):**
54
- - Use ship tool: action=\`relate\`, taskId=\`BRI-100\`, relatedTaskId=\`BRI-101\`
55
- - Use this when tasks are conceptually related but not blocking each other
689
+ const getBlockedTasks = () =>
690
+ Effect.gen(function* () {
691
+ const output = yield* shell.run(["task", "blocked", "--json"]);
692
+ return yield* parseJson<ShipTask[]>(output);
693
+ });
56
694
 
57
- **Mentioning Tasks in Descriptions (Clickable Pills):**
58
- To create clickable task pills in descriptions, use full markdown links:
59
- \`[BRI-123](https://linear.app/WORKSPACE/issue/BRI-123/slug)\`
695
+ const listTasks = (filter?: { status?: string; priority?: string; mine?: boolean }) =>
696
+ Effect.gen(function* () {
697
+ const args = ["task", "list", "--json"];
698
+ if (filter?.status) args.push("--status", filter.status);
699
+ if (filter?.priority) args.push("--priority", filter.priority);
700
+ if (filter?.mine) args.push("--mine");
60
701
 
61
- Get the full URL from ship tool (action=\`show\`, taskId=\`BRI-123\`) and use it in markdown link format.
62
- Plain text \`BRI-123\` will NOT create clickable pills.
702
+ const output = yield* shell.run(args);
703
+ return yield* parseJson<ShipTask[]>(output);
704
+ });
705
+
706
+ const getTask = (taskId: string) =>
707
+ Effect.gen(function* () {
708
+ const output = yield* shell.run(["task", "show", "--json", taskId]);
709
+ return yield* parseJson<ShipTask>(output);
710
+ });
711
+
712
+ const startTask = (taskId: string, sessionId?: string) => {
713
+ const args = ["task", "start"];
714
+ if (sessionId) {
715
+ args.push("--session", sessionId);
716
+ }
717
+ args.push(taskId);
718
+ return shell.run(args).pipe(Effect.asVoid);
719
+ };
720
+
721
+ const completeTask = (taskId: string) => shell.run(["task", "done", taskId]).pipe(Effect.asVoid);
722
+
723
+ const createTask = (input: {
724
+ title: string;
725
+ description?: string;
726
+ priority?: string;
727
+ parentId?: string;
728
+ }) =>
729
+ Effect.gen(function* () {
730
+ const args = ["task", "create", "--json"];
731
+ if (input.description) args.push("--description", input.description);
732
+ if (input.priority) args.push("--priority", input.priority);
733
+ if (input.parentId) args.push("--parent", input.parentId);
734
+ args.push(input.title);
735
+
736
+ const output = yield* shell.run(args);
737
+ const response = yield* parseJson<{ task: ShipTask }>(output);
738
+ return response.task;
739
+ });
740
+
741
+ const updateTask = (
742
+ taskId: string,
743
+ input: {
744
+ title?: string;
745
+ description?: string;
746
+ priority?: string;
747
+ status?: string;
748
+ parentId?: string;
749
+ },
750
+ ) =>
751
+ Effect.gen(function* () {
752
+ const args = ["task", "update", "--json"];
753
+ if (input.title) args.push("--title", input.title);
754
+ if (input.description) args.push("--description", input.description);
755
+ if (input.priority) args.push("--priority", input.priority);
756
+ if (input.status) args.push("--status", input.status);
757
+ if (input.parentId !== undefined) args.push("--parent", input.parentId);
758
+ args.push(taskId);
759
+
760
+ const output = yield* shell.run(args);
761
+ const response = yield* parseJson<{ task: ShipTask }>(output);
762
+ return response.task;
763
+ });
764
+
765
+ const addBlocker = (blocker: string, blocked: string) =>
766
+ shell.run(["task", "block", blocker, blocked]).pipe(Effect.asVoid);
767
+
768
+ const removeBlocker = (blocker: string, blocked: string) =>
769
+ shell.run(["task", "unblock", blocker, blocked]).pipe(Effect.asVoid);
770
+
771
+ const relateTask = (taskId: string, relatedTaskId: string) =>
772
+ shell.run(["task", "relate", taskId, relatedTaskId]).pipe(Effect.asVoid);
773
+
774
+ // Stack operations - all accept optional workdir for workspace support
775
+ const getStackLog = (workdir?: string) =>
776
+ Effect.gen(function* () {
777
+ const output = yield* shell.run(["stack", "log", "--json"], workdir);
778
+ return yield* parseJson<StackChange[]>(output);
779
+ });
780
+
781
+ const getStackStatus = (workdir?: string) =>
782
+ Effect.gen(function* () {
783
+ const output = yield* shell.run(["stack", "status", "--json"], workdir);
784
+ return yield* parseJson<StackStatus>(output);
785
+ });
786
+
787
+ const createStackChange = (input: {
788
+ message?: string;
789
+ bookmark?: string;
790
+ noWorkspace?: boolean;
791
+ taskId?: string;
792
+ workdir?: string;
793
+ }) =>
794
+ Effect.gen(function* () {
795
+ const args = ["stack", "create", "--json"];
796
+ if (input.message) args.push("--message", input.message);
797
+ if (input.bookmark) args.push("--bookmark", input.bookmark);
798
+ if (input.noWorkspace) args.push("--no-workspace");
799
+ if (input.taskId) args.push("--task-id", input.taskId);
800
+ const output = yield* shell.run(args, input.workdir);
801
+ return yield* parseJson<StackCreateResult>(output);
802
+ });
803
+
804
+ const describeStackChange = (
805
+ input: { message?: string; title?: string; description?: string },
806
+ workdir?: string,
807
+ ) =>
808
+ Effect.gen(function* () {
809
+ const args = ["stack", "describe", "--json"];
810
+ if (input.message) {
811
+ args.push("--message", input.message);
812
+ } else if (input.title) {
813
+ args.push("--title", input.title);
814
+ if (input.description) {
815
+ args.push("--description", input.description);
816
+ }
817
+ }
818
+ const output = yield* shell.run(args, workdir);
819
+ return yield* parseJson<StackDescribeResult>(output);
820
+ });
821
+
822
+ const syncStack = (workdir?: string) =>
823
+ Effect.gen(function* () {
824
+ const output = yield* shell.run(["stack", "sync", "--json"], workdir);
825
+ return yield* parseJson<StackSyncResult>(output);
826
+ });
827
+
828
+ const restackStack = (workdir?: string) =>
829
+ Effect.gen(function* () {
830
+ const output = yield* shell.run(["stack", "restack", "--json"], workdir);
831
+ return yield* parseJson<StackRestackResult>(output);
832
+ });
833
+
834
+ const submitStack = (input: {
835
+ draft?: boolean;
836
+ title?: string;
837
+ body?: string;
838
+ subscribe?: string;
839
+ workdir?: string;
840
+ }) =>
841
+ Effect.gen(function* () {
842
+ const args = ["stack", "submit", "--json"];
843
+ if (input.draft) args.push("--draft");
844
+ if (input.title) args.push("--title", input.title);
845
+ if (input.body) args.push("--body", input.body);
846
+ if (input.subscribe) args.push("--subscribe", input.subscribe);
847
+ const output = yield* shell.run(args, input.workdir);
848
+ return yield* parseJson<StackSubmitResult>(output);
849
+ });
850
+
851
+ const squashStack = (message: string, workdir?: string) =>
852
+ Effect.gen(function* () {
853
+ const output = yield* shell.run(["stack", "squash", "--json", "-m", message], workdir);
854
+ return yield* parseJson<StackSquashResult>(output);
855
+ });
856
+
857
+ const abandonStack = (changeId?: string, workdir?: string) =>
858
+ Effect.gen(function* () {
859
+ const args = ["stack", "abandon", "--json"];
860
+ if (changeId) args.push(changeId);
861
+ const output = yield* shell.run(args, workdir);
862
+ return yield* parseJson<StackAbandonResult>(output);
863
+ });
864
+
865
+ // Stack navigation
866
+ const stackUp = (workdir?: string) =>
867
+ Effect.gen(function* () {
868
+ const output = yield* shell.run(["stack", "up", "--json"], workdir);
869
+ return yield* parseJson<StackNavigateResult>(output);
870
+ });
871
+
872
+ const stackDown = (workdir?: string) =>
873
+ Effect.gen(function* () {
874
+ const output = yield* shell.run(["stack", "down", "--json"], workdir);
875
+ return yield* parseJson<StackNavigateResult>(output);
876
+ });
877
+
878
+ // Stack recovery
879
+ const stackUndo = (workdir?: string) =>
880
+ Effect.gen(function* () {
881
+ const output = yield* shell.run(["stack", "undo", "--json"], workdir);
882
+ return yield* parseJson<StackUndoResult>(output);
883
+ });
884
+
885
+ const stackUpdateStale = (workdir?: string) =>
886
+ Effect.gen(function* () {
887
+ const output = yield* shell.run(["stack", "update-stale", "--json"], workdir);
888
+ return yield* parseJson<StackUpdateStaleResult>(output);
889
+ });
890
+
891
+ // Stack bookmark
892
+ const bookmarkStack = (name: string, move?: boolean, workdir?: string) =>
893
+ Effect.gen(function* () {
894
+ const args = ["stack", "bookmark", "--json"];
895
+ if (move) args.push("--move");
896
+ args.push(name);
897
+ const output = yield* shell.run(args, workdir);
898
+ return yield* parseJson<StackBookmarkResult>(output);
899
+ });
900
+
901
+ // Webhook operations - uses module-level processToCleanup for persistence across tool calls
902
+
903
+ const startWebhook = (events?: string): Effect.Effect<WebhookStartResult, never> =>
904
+ Effect.gen(function* () {
905
+ // Check if already running using module-level state
906
+ if (processToCleanup && !processToCleanup.killed && processToCleanup.exitCode === null) {
907
+ return {
908
+ started: false,
909
+ error: "Webhook forwarding is already running",
910
+ pid: processToCleanup.pid,
911
+ };
912
+ }
913
+
914
+ // Build command
915
+ const cmd = process.env.NODE_ENV === "development" ? ["pnpm", "ship"] : ["ship"];
916
+ const args = [...cmd, "webhook", "forward"];
917
+ if (events) {
918
+ args.push("--events", events);
919
+ }
920
+
921
+ // Spawn process with stderr for error reporting, ignore stdout to avoid buffer deadlock
922
+ const proc = Bun.spawn(args, {
923
+ stdout: "ignore",
924
+ stderr: "pipe",
925
+ });
926
+
927
+ // Collect stderr output to detect errors
928
+ let stderrOutput = "";
929
+ const stderrReader = (async () => {
930
+ const reader = proc.stderr.getReader();
931
+ const decoder = new TextDecoder();
932
+ try {
933
+ while (true) {
934
+ const { done, value } = await reader.read();
935
+ if (done) break;
936
+ stderrOutput += decoder.decode(value, { stream: true });
937
+ }
938
+ } catch {
939
+ // Ignore read errors
940
+ }
941
+ })();
942
+
943
+ // Wait longer for the process to either:
944
+ // 1. Exit with error (e.g., OpenCode not running)
945
+ // 2. Start successfully and begin forwarding
946
+ yield* Effect.sleep("2 seconds");
947
+
948
+ // Check if process exited (indicates an error)
949
+ if (proc.exitCode !== null || proc.killed) {
950
+ // Wait for stderr to be fully read
951
+ yield* Effect.promise(() => stderrReader);
952
+ return {
953
+ started: false,
954
+ error: stderrOutput.trim() || "Process exited immediately",
955
+ };
956
+ }
957
+
958
+ // Check stderr for known error patterns (process might still be running but failed)
959
+ const errorPatterns = [
960
+ "OpenCode server is not running",
961
+ "No active OpenCode session",
962
+ "not installed",
963
+ "not authenticated",
964
+ "Permission denied",
965
+ ];
966
+
967
+ const hasError = errorPatterns.some((pattern) => stderrOutput.includes(pattern));
968
+ if (hasError) {
969
+ // Kill the process since it's in a bad state
970
+ proc.kill();
971
+ yield* Effect.promise(() => stderrReader);
972
+ return {
973
+ started: false,
974
+ error: stderrOutput.trim(),
975
+ };
976
+ }
977
+
978
+ // Register process for cleanup (uses module-level state)
979
+ registerProcessCleanup(proc);
980
+
981
+ return {
982
+ started: true,
983
+ pid: proc.pid,
984
+ events: events?.split(",").map((e) => e.trim()) || [
985
+ "pull_request",
986
+ "pull_request_review",
987
+ "issue_comment",
988
+ "check_run",
989
+ ],
990
+ };
991
+ });
992
+
993
+ const stopWebhook = (): Effect.Effect<WebhookStopResult, never> =>
994
+ Effect.sync(() => {
995
+ // Use module-level processToCleanup for persistence across Effect runs
996
+ if (!processToCleanup) {
997
+ return {
998
+ stopped: false,
999
+ wasRunning: false,
1000
+ error: "No webhook forwarding process is running",
1001
+ };
1002
+ }
1003
+
1004
+ const wasRunning = !processToCleanup.killed && processToCleanup.exitCode === null;
1005
+ if (wasRunning) {
1006
+ processToCleanup.kill();
1007
+ }
1008
+ unregisterProcessCleanup();
1009
+
1010
+ return {
1011
+ stopped: wasRunning,
1012
+ wasRunning,
1013
+ };
1014
+ });
1015
+
1016
+ const getWebhookStatus = (): Effect.Effect<{ running: boolean; pid?: number }, never> =>
1017
+ Effect.sync(() => {
1018
+ // Use module-level processToCleanup for persistence across Effect runs
1019
+ if (processToCleanup && !processToCleanup.killed && processToCleanup.exitCode === null) {
1020
+ return { running: true, pid: processToCleanup.pid };
1021
+ }
1022
+ return { running: false };
1023
+ });
1024
+
1025
+ // Daemon-based webhook operations - communicate with the webhook daemon via CLI
1026
+
1027
+ const getDaemonStatus = (): Effect.Effect<
1028
+ WebhookDaemonStatus,
1029
+ ShipCommandError | JsonParseError
1030
+ > =>
1031
+ Effect.gen(function* () {
1032
+ // First check if daemon is running by trying to get status
1033
+ const output = yield* shell
1034
+ .run(["webhook", "status", "--json"])
1035
+ .pipe(Effect.catchAll(() => Effect.succeed('{"running":false}')));
1036
+ return yield* parseJson<WebhookDaemonStatus>(output);
1037
+ });
1038
+
1039
+ const subscribeToPRs = (
1040
+ sessionId: string,
1041
+ prNumbers: number[],
1042
+ serverUrl?: string,
1043
+ ): Effect.Effect<WebhookSubscribeResult, ShipCommandError | JsonParseError> =>
1044
+ Effect.gen(function* () {
1045
+ const prNumbersStr = prNumbers.join(",");
1046
+ const args = ["webhook", "subscribe", "--json", "--session", sessionId];
1047
+ if (serverUrl) {
1048
+ args.push("--server-url", serverUrl);
1049
+ }
1050
+ args.push(prNumbersStr);
1051
+ const output = yield* shell.run(args);
1052
+ return yield* parseJson<WebhookSubscribeResult>(output);
1053
+ });
1054
+
1055
+ const unsubscribeFromPRs = (
1056
+ sessionId: string,
1057
+ prNumbers: number[],
1058
+ serverUrl?: string,
1059
+ ): Effect.Effect<WebhookUnsubscribeResult, ShipCommandError | JsonParseError> =>
1060
+ Effect.gen(function* () {
1061
+ const prNumbersStr = prNumbers.join(",");
1062
+ const args = ["webhook", "unsubscribe", "--json", "--session", sessionId];
1063
+ if (serverUrl) {
1064
+ args.push("--server-url", serverUrl);
1065
+ }
1066
+ args.push(prNumbersStr);
1067
+ const output = yield* shell.run(args);
1068
+ return yield* parseJson<WebhookUnsubscribeResult>(output);
1069
+ });
1070
+
1071
+ // Cleanup stale subscriptions
1072
+ const cleanupStaleSubscriptions = (): Effect.Effect<
1073
+ WebhookCleanupResult,
1074
+ ShipCommandError | JsonParseError
1075
+ > =>
1076
+ Effect.gen(function* () {
1077
+ const output = yield* shell.run(["webhook", "cleanup", "--json"]);
1078
+ return yield* parseJson<WebhookCleanupResult>(output);
1079
+ });
1080
+
1081
+ // Workspace operations - accept optional workdir
1082
+ const listWorkspaces = (
1083
+ workdir?: string,
1084
+ ): Effect.Effect<WorkspaceOutput[], ShipCommandError | JsonParseError> =>
1085
+ Effect.gen(function* () {
1086
+ const output = yield* shell.run(["stack", "workspaces", "--json"], workdir);
1087
+ return yield* parseJson<WorkspaceOutput[]>(output);
1088
+ });
1089
+
1090
+ const removeWorkspace = (
1091
+ name: string,
1092
+ deleteFiles?: boolean,
1093
+ workdir?: string,
1094
+ ): Effect.Effect<RemoveWorkspaceResult, ShipCommandError | JsonParseError> =>
1095
+ Effect.gen(function* () {
1096
+ const args = ["stack", "remove-workspace", "--json"];
1097
+ if (deleteFiles) args.push("--delete");
1098
+ args.push(name);
1099
+ const output = yield* shell.run(args, workdir);
1100
+ return yield* parseJson<RemoveWorkspaceResult>(output);
1101
+ });
1102
+
1103
+ // Milestone operations
1104
+ const listMilestones = (): Effect.Effect<ShipMilestone[], ShipCommandError | JsonParseError> =>
1105
+ Effect.gen(function* () {
1106
+ const output = yield* shell.run(["milestone", "list", "--json"]);
1107
+ return yield* parseJson<ShipMilestone[]>(output);
1108
+ });
1109
+
1110
+ const getMilestone = (
1111
+ milestoneId: string,
1112
+ ): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1113
+ Effect.gen(function* () {
1114
+ const output = yield* shell.run(["milestone", "show", "--json", milestoneId]);
1115
+ return yield* parseJson<ShipMilestone>(output);
1116
+ });
1117
+
1118
+ const createMilestone = (input: {
1119
+ name: string;
1120
+ description?: string;
1121
+ targetDate?: string;
1122
+ }): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1123
+ Effect.gen(function* () {
1124
+ const args = ["milestone", "create", "--json"];
1125
+ if (input.description) args.push("--description", input.description);
1126
+ if (input.targetDate) args.push("--target-date", input.targetDate);
1127
+ args.push(input.name);
1128
+
1129
+ const output = yield* shell.run(args);
1130
+ const response = yield* parseJson<{ milestone: ShipMilestone }>(output);
1131
+ return response.milestone;
1132
+ });
1133
+
1134
+ const updateMilestone = (
1135
+ milestoneId: string,
1136
+ input: { name?: string; description?: string; targetDate?: string },
1137
+ ): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1138
+ Effect.gen(function* () {
1139
+ const args = ["milestone", "update", "--json"];
1140
+ if (input.name) args.push("--name", input.name);
1141
+ if (input.description) args.push("--description", input.description);
1142
+ if (input.targetDate) args.push("--target-date", input.targetDate);
1143
+ args.push(milestoneId);
1144
+
1145
+ const output = yield* shell.run(args);
1146
+ const response = yield* parseJson<{ milestone: ShipMilestone }>(output);
1147
+ return response.milestone;
1148
+ });
1149
+
1150
+ const deleteMilestone = (milestoneId: string): Effect.Effect<void, ShipCommandError> =>
1151
+ shell.run(["milestone", "delete", milestoneId]).pipe(Effect.asVoid);
1152
+
1153
+ const setTaskMilestone = (
1154
+ taskId: string,
1155
+ milestoneId: string,
1156
+ ): Effect.Effect<ShipTask, ShipCommandError | JsonParseError> =>
1157
+ Effect.gen(function* () {
1158
+ const args = ["task", "update", "--json", "--milestone", milestoneId, taskId];
1159
+ const output = yield* shell.run(args);
1160
+ const response = yield* parseJson<{ task: ShipTask }>(output);
1161
+ return response.task;
1162
+ });
1163
+
1164
+ const unsetTaskMilestone = (
1165
+ taskId: string,
1166
+ ): Effect.Effect<ShipTask, ShipCommandError | JsonParseError> =>
1167
+ Effect.gen(function* () {
1168
+ // Empty string removes milestone
1169
+ const args = ["task", "update", "--json", "--milestone", "", taskId];
1170
+ const output = yield* shell.run(args);
1171
+ const response = yield* parseJson<{ task: ShipTask }>(output);
1172
+ return response.task;
1173
+ });
1174
+
1175
+ // PR reviews operations
1176
+ const getPrReviews = (
1177
+ prNumber?: number,
1178
+ unresolved?: boolean,
1179
+ workdir?: string,
1180
+ ): Effect.Effect<PrReviewOutput, ShipCommandError | JsonParseError> =>
1181
+ Effect.gen(function* () {
1182
+ const args = ["pr", "reviews", "--json"];
1183
+ if (unresolved) args.push("--unresolved");
1184
+ if (prNumber !== undefined) args.push(String(prNumber));
1185
+ const output = yield* shell.run(args, workdir);
1186
+ return yield* parseJson<PrReviewOutput>(output);
1187
+ });
1188
+
1189
+ return {
1190
+ checkConfigured,
1191
+ getReadyTasks,
1192
+ getBlockedTasks,
1193
+ listTasks,
1194
+ getTask,
1195
+ startTask,
1196
+ completeTask,
1197
+ createTask,
1198
+ updateTask,
1199
+ addBlocker,
1200
+ removeBlocker,
1201
+ relateTask,
1202
+ getStackLog,
1203
+ getStackStatus,
1204
+ createStackChange,
1205
+ describeStackChange,
1206
+ syncStack,
1207
+ restackStack,
1208
+ submitStack,
1209
+ squashStack,
1210
+ abandonStack,
1211
+ stackUp,
1212
+ stackDown,
1213
+ stackUndo,
1214
+ stackUpdateStale,
1215
+ bookmarkStack,
1216
+ startWebhook,
1217
+ stopWebhook,
1218
+ getWebhookStatus,
1219
+ getDaemonStatus,
1220
+ subscribeToPRs,
1221
+ unsubscribeFromPRs,
1222
+ cleanupStaleSubscriptions,
1223
+ listWorkspaces,
1224
+ removeWorkspace,
1225
+ listMilestones,
1226
+ getMilestone,
1227
+ createMilestone,
1228
+ updateMilestone,
1229
+ deleteMilestone,
1230
+ setTaskMilestone,
1231
+ unsetTaskMilestone,
1232
+ getPrReviews,
1233
+ } satisfies ShipService;
1234
+ });
1235
+
1236
+ // =============================================================================
1237
+ // Formatters
1238
+ // =============================================================================
1239
+
1240
+ const formatTaskList = (tasks: ShipTask[]): string =>
1241
+ tasks
1242
+ .map((t) => {
1243
+ const priority = t.priority === "urgent" ? "[!]" : t.priority === "high" ? "[^]" : " ";
1244
+ return `${priority} ${t.identifier.padEnd(10)} ${(t.state || t.status).padEnd(12)} ${t.title}`;
1245
+ })
1246
+ .join("\n");
1247
+
1248
+ const formatTaskDetails = (task: ShipTask): string => {
1249
+ let output = `# ${task.identifier}: ${task.title}
1250
+
1251
+ **Status:** ${task.state || task.status}
1252
+ **Priority:** ${task.priority}
1253
+ **Labels:** ${task.labels.length > 0 ? task.labels.join(", ") : "none"}
1254
+ **URL:** ${task.url}`;
1255
+
1256
+ if (task.branchName) {
1257
+ output += `\n**Branch:** ${task.branchName}`;
1258
+ }
1259
+
1260
+ if (task.description) {
1261
+ output += `\n\n## Description\n\n${task.description}`;
1262
+ }
1263
+
1264
+ if (task.subtasks && task.subtasks.length > 0) {
1265
+ output += `\n\n## Subtasks\n`;
1266
+ for (const subtask of task.subtasks) {
1267
+ const statusIndicator = subtask.isDone ? "[x]" : "[ ]";
1268
+ output += `\n${statusIndicator} ${subtask.identifier}: ${subtask.title} (${subtask.state})`;
1269
+ }
1270
+ }
1271
+
1272
+ return output;
1273
+ };
1274
+
1275
+ // =============================================================================
1276
+ // Tool Actions
1277
+ // =============================================================================
1278
+
1279
+ type ToolArgs = {
1280
+ action: string;
1281
+ taskId?: string;
1282
+ title?: string;
1283
+ description?: string;
1284
+ priority?: string;
1285
+ status?: string;
1286
+ blocker?: string;
1287
+ blocked?: string;
1288
+ relatedTaskId?: string;
1289
+ parentId?: string; // For creating subtasks
1290
+ filter?: {
1291
+ status?: string;
1292
+ priority?: string;
1293
+ mine?: boolean;
1294
+ };
1295
+ // Stack-specific args
1296
+ message?: string;
1297
+ bookmark?: string;
1298
+ draft?: boolean;
1299
+ body?: string;
1300
+ changeId?: string;
1301
+ move?: boolean; // For stack-bookmark to move instead of create
1302
+ // Workspace-specific args
1303
+ noWorkspace?: boolean; // For stack-create to skip workspace creation
1304
+ name?: string; // For remove-workspace (workspace name)
1305
+ deleteFiles?: boolean; // For remove-workspace
1306
+ workdir?: string; // Working directory for VCS operations (for jj workspaces)
1307
+ // Webhook-specific args
1308
+ events?: string;
1309
+ // Daemon webhook subscription args
1310
+ sessionId?: string;
1311
+ prNumbers?: number[];
1312
+ // PR review args
1313
+ prNumber?: number;
1314
+ unresolved?: boolean;
1315
+ // Milestone-specific args
1316
+ milestoneId?: string;
1317
+ milestoneName?: string;
1318
+ milestoneDescription?: string;
1319
+ milestoneTargetDate?: string;
1320
+ };
1321
+
1322
+ /**
1323
+ * Context passed to action handlers for generating guidance.
1324
+ */
1325
+ interface ActionContext {
1326
+ /** OpenCode session ID for webhook subscriptions */
1327
+ sessionId?: string;
1328
+ /** Main repository path (default workspace location) */
1329
+ mainRepoPath: string;
1330
+ /** OpenCode server URL for webhook routing (e.g., http://127.0.0.1:4097) */
1331
+ serverUrl?: string;
1332
+ }
1333
+
1334
+ /**
1335
+ * Options for the addGuidance helper function.
1336
+ */
1337
+ interface GuidanceOptions {
1338
+ /** Explicit working directory path (shown when workspace changes) */
1339
+ workdir?: string;
1340
+ /** Whether to show skill reminder */
1341
+ skill?: boolean;
1342
+ /** Contextual note/message */
1343
+ note?: string;
1344
+ }
1345
+
1346
+ /**
1347
+ * Helper function to format guidance blocks consistently.
1348
+ * Reduces repetition and ensures consistent format across all actions.
1349
+ *
1350
+ * @param next - Suggested next actions (e.g., "action=done | action=ready")
1351
+ * @param opts - Optional workdir, skill reminder, and note
1352
+ * @returns Formatted guidance string to append to command output
1353
+ */
1354
+ const addGuidance = (next: string, opts?: GuidanceOptions): string => {
1355
+ let g = `\n---\nNext: ${next}`;
1356
+ if (opts?.workdir) g += `\nWorkdir: ${opts.workdir}`;
1357
+ if (opts?.skill) g += `\nIMPORTANT: Load skill first → skill(name="ship-cli")`;
1358
+ if (opts?.note) g += `\nNote: ${opts.note}`;
1359
+ return g;
1360
+ };
1361
+
1362
+ /**
1363
+ * Action handlers for the ship tool.
1364
+ * Each handler returns an Effect that produces a formatted string result.
1365
+ *
1366
+ * Using a record of handlers provides:
1367
+ * - Cleaner separation of action logic
1368
+ * - No fall-through bugs
1369
+ * - Easier to add new actions
1370
+ * - Each handler is self-contained
1371
+ */
1372
+ type ActionHandler = (
1373
+ ship: ShipService,
1374
+ args: ToolArgs,
1375
+ context: ActionContext,
1376
+ ) => Effect.Effect<string, ShipCommandError | JsonParseError, never>;
1377
+
1378
+ const actionHandlers: Record<string, ActionHandler> = {
1379
+ status: (ship, _args, _ctx) =>
1380
+ Effect.gen(function* () {
1381
+ const status = yield* ship.checkConfigured();
1382
+ if (status.configured) {
1383
+ const guidance = addGuidance("action=ready (find tasks to work on)", { skill: true });
1384
+ return `Ship is configured.\n\nTeam: ${status.teamKey}\nProject: ${status.projectId || "none"}${guidance}`;
1385
+ }
1386
+ return "Ship is not configured. Run 'ship init' first.";
1387
+ }),
1388
+
1389
+ ready: (ship, _args, _ctx) =>
1390
+ Effect.gen(function* () {
1391
+ const tasks = yield* ship.getReadyTasks();
1392
+ if (tasks.length === 0) {
1393
+ const guidance = addGuidance(
1394
+ "action=blocked (check blocked tasks) | action=create (create a new task)",
1395
+ { skill: true },
1396
+ );
1397
+ return `No tasks ready to work on (all tasks are either blocked or completed).${guidance}`;
1398
+ }
1399
+ const guidance = addGuidance("action=start (begin working on a task)", { skill: true });
1400
+ return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}${guidance}`;
1401
+ }),
1402
+
1403
+ blocked: (ship, _args, _ctx) =>
1404
+ Effect.gen(function* () {
1405
+ const tasks = yield* ship.getBlockedTasks();
1406
+ if (tasks.length === 0) {
1407
+ const guidance = addGuidance("action=ready (check ready tasks)");
1408
+ return `No blocked tasks.${guidance}`;
1409
+ }
1410
+ const guidance = addGuidance(
1411
+ "action=show (view task details) | action=unblock (remove blocker)",
1412
+ );
1413
+ return `Blocked tasks:\n\n${formatTaskList(tasks)}${guidance}`;
1414
+ }),
1415
+
1416
+ list: (ship, args, _ctx) =>
1417
+ Effect.gen(function* () {
1418
+ const tasks = yield* ship.listTasks(args.filter);
1419
+ if (tasks.length === 0) {
1420
+ const guidance = addGuidance("action=create (create a new task)");
1421
+ return `No tasks found matching the filter.${guidance}`;
1422
+ }
1423
+ const guidance = addGuidance(
1424
+ "action=show (view task details) | action=start (begin working)",
1425
+ );
1426
+ return `Tasks:\n\n${formatTaskList(tasks)}${guidance}`;
1427
+ }),
1428
+
1429
+ show: (ship, args, _ctx) =>
1430
+ Effect.gen(function* () {
1431
+ if (!args.taskId) {
1432
+ return "Error: taskId is required for show action";
1433
+ }
1434
+ const task = yield* ship.getTask(args.taskId);
1435
+ const guidance = addGuidance("action=start (begin work) | action=update (modify task)");
1436
+ return formatTaskDetails(task) + guidance;
1437
+ }),
1438
+
1439
+ start: (ship, args, ctx) =>
1440
+ Effect.gen(function* () {
1441
+ if (!args.taskId) {
1442
+ return "Error: taskId is required for start action";
1443
+ }
1444
+ yield* ship.startTask(args.taskId, ctx.sessionId);
1445
+ const sessionInfo = ctx.sessionId ? ` (labeled with session:${ctx.sessionId})` : "";
1446
+ const guidance = addGuidance(
1447
+ `action=stack-create with taskId="${args.taskId}" (creates isolated workspace for changes)`,
1448
+ {
1449
+ skill: true,
1450
+ note: "IMPORTANT: Create workspace before making any file changes",
1451
+ },
1452
+ );
1453
+ return `Task ${args.taskId} is now in progress${sessionInfo}.\n\nNext step: Create a workspace to isolate your changes.${guidance}`;
1454
+ }),
1455
+
1456
+ done: (ship, args, _ctx) =>
1457
+ Effect.gen(function* () {
1458
+ if (!args.taskId) {
1459
+ return "Error: taskId is required for done action";
1460
+ }
1461
+ yield* ship.completeTask(args.taskId);
1462
+ const guidance = addGuidance(
1463
+ "action=ready (find next task) | action=stack-sync (cleanup if in workspace)",
1464
+ );
1465
+ return `Completed ${args.taskId}${guidance}`;
1466
+ }),
1467
+
1468
+ create: (ship, args, _ctx) =>
1469
+ Effect.gen(function* () {
1470
+ if (!args.title) {
1471
+ return "Error: title is required for create action";
1472
+ }
1473
+ const task = yield* ship.createTask({
1474
+ title: args.title,
1475
+ description: args.description,
1476
+ priority: args.priority,
1477
+ parentId: args.parentId,
1478
+ });
1479
+ const guidance = addGuidance("action=start (begin work) | action=block (add dependencies)");
1480
+ if (args.parentId) {
1481
+ return `Created subtask ${task.identifier}: ${task.title}\nParent: ${args.parentId}\nURL: ${task.url}${guidance}`;
1482
+ }
1483
+ return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}${guidance}`;
1484
+ }),
1485
+
1486
+ update: (ship, args, _ctx) =>
1487
+ Effect.gen(function* () {
1488
+ if (!args.taskId) {
1489
+ return "Error: taskId is required for update action";
1490
+ }
1491
+ if (
1492
+ !args.title &&
1493
+ !args.description &&
1494
+ !args.priority &&
1495
+ !args.status &&
1496
+ args.parentId === undefined
1497
+ ) {
1498
+ return "Error: at least one of title, description, priority, status, or parentId is required for update";
1499
+ }
1500
+ const task = yield* ship.updateTask(args.taskId, {
1501
+ title: args.title,
1502
+ description: args.description,
1503
+ priority: args.priority,
1504
+ status: args.status,
1505
+ parentId: args.parentId,
1506
+ });
1507
+ let output = `Updated task ${task.identifier}: ${task.title}`;
1508
+ if (args.parentId !== undefined) {
1509
+ output += args.parentId === "" ? "\nParent: removed" : `\nParent: ${args.parentId}`;
1510
+ }
1511
+ output += `\nURL: ${task.url}`;
1512
+ const guidance = addGuidance("action=show (verify changes)");
1513
+ return output + guidance;
1514
+ }),
1515
+
1516
+ block: (ship, args, _ctx) =>
1517
+ Effect.gen(function* () {
1518
+ if (!args.blocker || !args.blocked) {
1519
+ return "Error: both blocker and blocked task IDs are required";
1520
+ }
1521
+ yield* ship.addBlocker(args.blocker, args.blocked);
1522
+ const guidance = addGuidance(
1523
+ "action=ready (find unblocked tasks) | action=blocked (view blocked tasks)",
1524
+ );
1525
+ return `${args.blocker} now blocks ${args.blocked}${guidance}`;
1526
+ }),
1527
+
1528
+ unblock: (ship, args, _ctx) =>
1529
+ Effect.gen(function* () {
1530
+ if (!args.blocker || !args.blocked) {
1531
+ return "Error: both blocker and blocked task IDs are required";
1532
+ }
1533
+ yield* ship.removeBlocker(args.blocker, args.blocked);
1534
+ const guidance = addGuidance("action=ready (find unblocked tasks)");
1535
+ return `Removed ${args.blocker} as blocker of ${args.blocked}${guidance}`;
1536
+ }),
1537
+
1538
+ relate: (ship, args, _ctx) =>
1539
+ Effect.gen(function* () {
1540
+ if (!args.taskId || !args.relatedTaskId) {
1541
+ return "Error: both taskId and relatedTaskId are required for relate action";
1542
+ }
1543
+ yield* ship.relateTask(args.taskId, args.relatedTaskId);
1544
+ const guidance = addGuidance("action=show (view task details)");
1545
+ return `Linked ${args.taskId} ↔ ${args.relatedTaskId} as related${guidance}`;
1546
+ }),
1547
+
1548
+ "stack-log": (ship, args, _ctx) =>
1549
+ Effect.gen(function* () {
1550
+ const changes = yield* ship.getStackLog(args.workdir);
1551
+ if (changes.length === 0) {
1552
+ return "No changes in stack (working copy is on trunk)";
1553
+ }
1554
+ return `Stack (${changes.length} changes):\n\n${changes
1555
+ .map((c) => {
1556
+ const marker = c.isWorkingCopy ? "@" : "○";
1557
+ const empty = c.isEmpty ? " (empty)" : "";
1558
+ const bookmarks = c.bookmarks.length > 0 ? ` [${c.bookmarks.join(", ")}]` : "";
1559
+ const desc = c.description.split("\n")[0] || "(no description)";
1560
+ return `${marker} ${c.changeId.slice(0, 8)} ${desc}${empty}${bookmarks}`;
1561
+ })
1562
+ .join("\n")}`;
1563
+ }),
1564
+
1565
+ "stack-status": (ship, args, _ctx) =>
1566
+ Effect.gen(function* () {
1567
+ const status = yield* ship.getStackStatus(args.workdir);
1568
+ if (!status.isRepo) {
1569
+ return `Error: ${status.error || "Not a jj repository"}`;
1570
+ }
1571
+ if (!status.change) {
1572
+ return "Error: Could not get current change";
1573
+ }
1574
+ const c = status.change;
1575
+ let output = `Change: ${c.changeId.slice(0, 8)}
1576
+ Commit: ${c.commitId.slice(0, 12)}
1577
+ Description: ${c.description.split("\n")[0] || "(no description)"}`;
1578
+ if (c.bookmarks.length > 0) {
1579
+ output += `\nBookmarks: ${c.bookmarks.join(", ")}`;
1580
+ }
1581
+ output += `\nStatus: ${c.isEmpty ? "empty (no changes)" : "has changes"}`;
1582
+ const guidance = addGuidance(
1583
+ "action=stack-submit (push changes) | action=stack-sync (fetch latest)",
1584
+ );
1585
+ return output + guidance;
1586
+ }),
1587
+
1588
+ "stack-create": (ship, args, _ctx) =>
1589
+ Effect.gen(function* () {
1590
+ const result = yield* ship.createStackChange({
1591
+ message: args.message,
1592
+ bookmark: args.bookmark,
1593
+ noWorkspace: args.noWorkspace,
1594
+ taskId: args.taskId,
1595
+ workdir: args.workdir,
1596
+ });
1597
+ if (!result.created) {
1598
+ return `Error: ${result.error || "Failed to create change"}`;
1599
+ }
1600
+ let output = `Created change: ${result.changeId}`;
1601
+ if (result.bookmark) {
1602
+ output += `\nCreated bookmark: ${result.bookmark}`;
1603
+ }
1604
+ if (result.workspace?.created) {
1605
+ output += `\nCreated workspace: ${result.workspace.name} at ${result.workspace.path}`;
1606
+ const guidance = addGuidance(
1607
+ "Implement the task (edit files) | action=stack-status (check change state)",
1608
+ {
1609
+ workdir: result.workspace.path,
1610
+ note: "Workspace created. Use the workdir above for all subsequent commands.",
1611
+ },
1612
+ );
1613
+ output += guidance;
1614
+ } else {
1615
+ const guidance = addGuidance(
1616
+ "Implement the task (edit files) | action=stack-status (check change state)",
1617
+ );
1618
+ output += guidance;
1619
+ }
1620
+ return output;
1621
+ }),
1622
+
1623
+ "stack-describe": (ship, args, _ctx) =>
1624
+ Effect.gen(function* () {
1625
+ if (!args.message && !args.title) {
1626
+ return "Error: Either message or title is required for stack-describe action";
1627
+ }
1628
+ const result = yield* ship.describeStackChange(
1629
+ { message: args.message, title: args.title, description: args.description },
1630
+ args.workdir,
1631
+ );
1632
+ if (!result.updated) {
1633
+ return `Error: ${result.error || "Failed to update description"}`;
1634
+ }
1635
+ return `Updated change ${result.changeId?.slice(0, 8) || ""}\nDescription: ${result.description || args.title || args.message}`;
1636
+ }),
1637
+
1638
+ "stack-sync": (ship, args, ctx) =>
1639
+ Effect.gen(function* () {
1640
+ const result = yield* ship.syncStack(args.workdir);
1641
+ if (result.error) {
1642
+ return `Sync failed: [${result.error.tag}] ${result.error.message}`;
1643
+ }
1644
+
1645
+ const parts: string[] = [];
1646
+
1647
+ if (result.abandonedMergedChanges && result.abandonedMergedChanges.length > 0) {
1648
+ parts.push("Auto-abandoned merged changes:");
1649
+ for (const change of result.abandonedMergedChanges) {
1650
+ const bookmarkInfo = change.bookmark ? ` (${change.bookmark})` : "";
1651
+ parts.push(` - ${change.changeId}${bookmarkInfo}`);
1652
+ }
1653
+ parts.push("");
1654
+ }
1655
+
1656
+ if (result.stackFullyMerged) {
1657
+ parts.push("Stack fully merged! All changes are now in trunk.");
1658
+ if (result.cleanedUpWorkspace) {
1659
+ parts.push(`Cleaned up workspace: ${result.cleanedUpWorkspace}`);
1660
+ }
1661
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1662
+ const guidance = addGuidance(
1663
+ "action=done (mark task complete) | action=ready (find next task)",
1664
+ {
1665
+ workdir: ctx.mainRepoPath,
1666
+ skill: true,
1667
+ note: `Workspace '${result.cleanedUpWorkspace}' was deleted. Use the workdir above for subsequent commands.`,
1668
+ },
1669
+ );
1670
+ parts.push(guidance);
1671
+ } else if (result.conflicted) {
1672
+ parts.push("Sync completed with conflicts!");
1673
+ parts.push(` Fetched: yes`);
1674
+ parts.push(` Rebased: yes (with conflicts)`);
1675
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1676
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1677
+ parts.push("");
1678
+ parts.push("Resolve conflicts with 'jj status' and edit the conflicted files.");
1679
+ const guidance = addGuidance(
1680
+ "resolve conflicts manually | action=stack-status (check conflict state)",
1681
+ {
1682
+ skill: true,
1683
+ note: "Conflicts detected during rebase. Resolve them before continuing.",
1684
+ },
1685
+ );
1686
+ parts.push(guidance);
1687
+ } else if (!result.rebased) {
1688
+ parts.push("Already up to date.");
1689
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1690
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1691
+ const guidance = addGuidance("continue work | action=stack-submit (if ready to push)");
1692
+ parts.push(guidance);
1693
+ } else {
1694
+ parts.push("Sync completed successfully.");
1695
+ parts.push(` Fetched: yes`);
1696
+ parts.push(` Rebased: yes`);
1697
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1698
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1699
+ const guidance = addGuidance("continue work | action=stack-submit (push rebased changes)");
1700
+ parts.push(guidance);
1701
+ }
1702
+
1703
+ return parts.join("\n");
1704
+ }),
1705
+
1706
+ "stack-restack": (ship, args, _ctx) =>
1707
+ Effect.gen(function* () {
1708
+ const result = yield* ship.restackStack(args.workdir);
1709
+ if (result.error) {
1710
+ return `Restack failed: ${result.error}`;
1711
+ }
1712
+ if (!result.restacked) {
1713
+ return `Nothing to restack (working copy is on trunk).
1714
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`;
1715
+ }
1716
+ if (result.conflicted) {
1717
+ return `Restack completed with conflicts!
1718
+ Rebased: yes (with conflicts)
1719
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}
1720
+ Stack: ${result.stackSize} change(s)
1721
+
1722
+ Resolve conflicts with 'jj status' and edit the conflicted files.`;
1723
+ }
1724
+ return `Restack completed successfully.
1725
+ Rebased: ${result.stackSize} change(s)
1726
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}
1727
+ Stack: ${result.stackSize} change(s)`;
1728
+ }),
1729
+
1730
+ "stack-submit": (ship, args, ctx) =>
1731
+ Effect.gen(function* () {
1732
+ const subscribeSessionId = args.sessionId || ctx.sessionId;
1733
+
1734
+ const result = yield* ship.submitStack({
1735
+ draft: args.draft,
1736
+ title: args.title,
1737
+ body: args.body,
1738
+ subscribe: subscribeSessionId,
1739
+ workdir: args.workdir,
1740
+ });
1741
+ if (result.error) {
1742
+ if (result.pushed) {
1743
+ return `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}\nWarning: ${result.error}`;
1744
+ }
1745
+ return `Error: ${result.error}`;
1746
+ }
1747
+ let output = "";
1748
+ if (result.pr) {
1749
+ const statusMsg =
1750
+ result.pr.status === "created"
1751
+ ? "Created PR"
1752
+ : result.pr.status === "exists"
1753
+ ? "PR already exists"
1754
+ : "Updated PR";
1755
+ output = `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}\n${statusMsg}: #${result.pr.number}\nURL: ${result.pr.url}`;
1756
+ } else {
1757
+ output = `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}`;
1758
+ }
1759
+ if (result.subscribed) {
1760
+ output += `\n\nAuto-subscribed to stack PRs: ${result.subscribed.prNumbers.join(", ")}`;
1761
+ }
1762
+ // Add guidance for submit
1763
+ const prCreated = result.pr?.status === "created" || result.pr?.status === "updated";
1764
+ const guidance = addGuidance(
1765
+ prCreated
1766
+ ? "Wait for review | action=stack-create (start next change in stack) | action=done (if single-change task)"
1767
+ : "action=stack-status (check change state)",
1768
+ );
1769
+ output += guidance;
1770
+ return output;
1771
+ }),
1772
+
1773
+ "stack-squash": (ship, args, _ctx) =>
1774
+ Effect.gen(function* () {
1775
+ if (!args.message) {
1776
+ return "Error: message is required for stack-squash action";
1777
+ }
1778
+ const result = yield* ship.squashStack(args.message, args.workdir);
1779
+ if (!result.squashed) {
1780
+ return `Error: ${result.error || "Failed to squash"}`;
1781
+ }
1782
+ return `Squashed into ${result.intoChangeId?.slice(0, 8) || "parent"}\nDescription: ${result.description?.split("\n")[0] || "(no description)"}`;
1783
+ }),
1784
+
1785
+ "stack-abandon": (ship, args, _ctx) =>
1786
+ Effect.gen(function* () {
1787
+ const result = yield* ship.abandonStack(args.changeId, args.workdir);
1788
+ if (!result.abandoned) {
1789
+ return `Error: ${result.error || "Failed to abandon"}`;
1790
+ }
1791
+ let output = `Abandoned ${result.changeId?.slice(0, 8) || "change"}\nWorking copy now at: ${result.newWorkingCopy?.slice(0, 8) || "unknown"}`;
1792
+ // Note: We don't know if workspace was deleted from this result
1793
+ // The guidance for workspace deletion happens in stack-sync when stack is fully merged
1794
+ const guidance = addGuidance("action=stack-log (view remaining stack) | continue work");
1795
+ return output + guidance;
1796
+ }),
1797
+
1798
+ "stack-up": (ship, args, _ctx) =>
1799
+ Effect.gen(function* () {
1800
+ const result = yield* ship.stackUp(args.workdir);
1801
+ if (!result.moved) {
1802
+ return result.error || "Already at the tip of the stack (no child change)";
1803
+ }
1804
+ return `Moved up in stack:\n From: ${result.from?.changeId.slice(0, 8) || "unknown"} ${result.from?.description || ""}\n To: ${result.to?.changeId.slice(0, 8) || "unknown"} ${result.to?.description || ""}`;
1805
+ }),
1806
+
1807
+ "stack-down": (ship, args, _ctx) =>
1808
+ Effect.gen(function* () {
1809
+ const result = yield* ship.stackDown(args.workdir);
1810
+ if (!result.moved) {
1811
+ return result.error || "Already at the base of the stack (on trunk)";
1812
+ }
1813
+ return `Moved down in stack:\n From: ${result.from?.changeId.slice(0, 8) || "unknown"} ${result.from?.description || ""}\n To: ${result.to?.changeId.slice(0, 8) || "unknown"} ${result.to?.description || ""}`;
1814
+ }),
1815
+
1816
+ "stack-undo": (ship, args, _ctx) =>
1817
+ Effect.gen(function* () {
1818
+ const result = yield* ship.stackUndo(args.workdir);
1819
+ if (!result.undone) {
1820
+ return `Error: ${result.error || "Failed to undo"}`;
1821
+ }
1822
+ return result.operation ? `Undone: ${result.operation}` : "Undone last operation";
1823
+ }),
1824
+
1825
+ "stack-update-stale": (ship, args, _ctx) =>
1826
+ Effect.gen(function* () {
1827
+ const result = yield* ship.stackUpdateStale(args.workdir);
1828
+ if (!result.updated) {
1829
+ return `Error: ${result.error || "Failed to update stale workspace"}`;
1830
+ }
1831
+ return result.changeId
1832
+ ? `Working copy updated. Now at: ${result.changeId}`
1833
+ : "Working copy updated.";
1834
+ }),
1835
+
1836
+ "stack-bookmark": (ship, args, _ctx) =>
1837
+ Effect.gen(function* () {
1838
+ if (!args.name) {
1839
+ return "Error: name is required for stack-bookmark action";
1840
+ }
1841
+ const result = yield* ship.bookmarkStack(args.name, args.move, args.workdir);
1842
+ if (!result.success) {
1843
+ return `Error: ${result.error || "Failed to create/move bookmark"}`;
1844
+ }
1845
+ const action = result.action === "moved" ? "Moved" : "Created";
1846
+ return `${action} bookmark '${result.bookmark}' at ${result.changeId?.slice(0, 8) || "current change"}`;
1847
+ }),
1848
+
1849
+ "stack-workspaces": (ship, args, _ctx) =>
1850
+ Effect.gen(function* () {
1851
+ const workspaces = yield* ship.listWorkspaces(args.workdir);
1852
+ if (workspaces.length === 0) {
1853
+ return "No workspaces found.";
1854
+ }
1855
+ return `Workspaces (${workspaces.length}):\n\n${workspaces
1856
+ .map((ws: WorkspaceOutput) => {
1857
+ const defaultMark = ws.isDefault ? " (default)" : "";
1858
+ const stack = ws.stackName ? ` stack:${ws.stackName}` : "";
1859
+ const task = ws.taskId ? ` task:${ws.taskId}` : "";
1860
+ return `${ws.name}${defaultMark}${stack}${task}\n Change: ${ws.changeId} - ${ws.description}\n Path: ${ws.path}`;
1861
+ })
1862
+ .join("\n\n")}`;
1863
+ }),
1864
+
1865
+ "stack-remove-workspace": (ship, args, ctx) =>
1866
+ Effect.gen(function* () {
1867
+ if (!args.name) {
1868
+ return "Error: name is required for stack-remove-workspace action";
1869
+ }
1870
+ const result = yield* ship.removeWorkspace(args.name, args.deleteFiles, args.workdir);
1871
+ if (!result.removed) {
1872
+ return `Error: ${result.error || "Failed to remove workspace"}`;
1873
+ }
1874
+ let output = `Removed workspace: ${result.name}`;
1875
+ if (result.filesDeleted !== undefined) {
1876
+ output += result.filesDeleted ? "\nFiles deleted." : "\nFiles remain on disk.";
1877
+ }
1878
+ const guidance = addGuidance(
1879
+ "action=ready (find next task) | action=stack-workspaces (list remaining)",
1880
+ {
1881
+ workdir: ctx.mainRepoPath,
1882
+ note: "Workspace removed. Use the workdir above for subsequent commands.",
1883
+ },
1884
+ );
1885
+ return output + guidance;
1886
+ }),
1887
+
1888
+ "webhook-start": (ship, args, _ctx) =>
1889
+ Effect.gen(function* () {
1890
+ const result = yield* ship.startWebhook(args.events);
1891
+ if (!result.started) {
1892
+ return `Error: ${result.error}${result.pid ? ` (PID: ${result.pid})` : ""}`;
1893
+ }
1894
+ return `Webhook forwarding started (PID: ${result.pid})
1895
+ Events: ${result.events?.join(", ") || "default"}
1896
+
1897
+ GitHub events will be forwarded to the current OpenCode session.
1898
+ Use action 'webhook-stop' to stop forwarding.`;
1899
+ }),
1900
+
1901
+ "webhook-stop": (ship, _args, _ctx) =>
1902
+ Effect.gen(function* () {
1903
+ const result = yield* ship.stopWebhook();
1904
+ if (!result.stopped) {
1905
+ return result.error || "No webhook forwarding process is running";
1906
+ }
1907
+ return "Webhook forwarding stopped.";
1908
+ }),
1909
+
1910
+ "webhook-status": (ship, _args, _ctx) =>
1911
+ Effect.gen(function* () {
1912
+ const status = yield* ship.getWebhookStatus();
1913
+ if (status.running) {
1914
+ return `Webhook forwarding is running (PID: ${status.pid})`;
1915
+ }
1916
+ return "Webhook forwarding is not running.";
1917
+ }),
1918
+
1919
+ "webhook-daemon-status": (ship, _args, _ctx) =>
1920
+ Effect.gen(function* () {
1921
+ const status = yield* ship.getDaemonStatus();
1922
+ if (!status.running) {
1923
+ return "Webhook daemon is not running.\n\nStart it with: ship webhook start";
1924
+ }
1925
+ let output = `Webhook Daemon Status
1926
+ ─────────────────────────────────────────
1927
+ Status: Running
1928
+ PID: ${status.pid || "unknown"}
1929
+ Repository: ${status.repo || "unknown"}
1930
+ GitHub WebSocket: ${status.connectedToGitHub ? "Connected" : "Disconnected"}
1931
+ Uptime: ${status.uptime ? `${status.uptime}s` : "unknown"}
1932
+
1933
+ Subscriptions:`;
1934
+ if (!status.subscriptions || status.subscriptions.length === 0) {
1935
+ output += "\n No active subscriptions.";
1936
+ } else {
1937
+ for (const sub of status.subscriptions) {
1938
+ output += `\n Session ${sub.sessionId}: PRs ${sub.prNumbers.join(", ")}`;
1939
+ }
1940
+ }
1941
+ return output;
1942
+ }),
1943
+
1944
+ "webhook-subscribe": (ship, args, ctx) =>
1945
+ Effect.gen(function* () {
1946
+ const sessionId = args.sessionId || ctx.sessionId;
1947
+ if (!sessionId) {
1948
+ return "Error: sessionId is required for webhook-subscribe action (not provided and could not auto-detect from context)";
1949
+ }
1950
+ if (!args.prNumbers || args.prNumbers.length === 0) {
1951
+ return "Error: prNumbers is required for webhook-subscribe action";
1952
+ }
1953
+ const result = yield* ship.subscribeToPRs(sessionId, args.prNumbers, ctx.serverUrl);
1954
+ if (!result.subscribed) {
1955
+ return `Error: ${result.error || "Failed to subscribe"}`;
1956
+ }
1957
+ const serverInfo = ctx.serverUrl ? ` (server: ${ctx.serverUrl})` : "";
1958
+ return `Subscribed session ${sessionId} to PRs: ${args.prNumbers.join(", ")}${serverInfo}
1959
+
1960
+ The daemon will forward GitHub events for these PRs to your session.
1961
+ Use 'webhook-unsubscribe' to stop receiving events.`;
1962
+ }),
1963
+
1964
+ "webhook-unsubscribe": (ship, args, ctx) =>
1965
+ Effect.gen(function* () {
1966
+ const sessionId = args.sessionId || ctx.sessionId;
1967
+ if (!sessionId) {
1968
+ return "Error: sessionId is required for webhook-unsubscribe action";
1969
+ }
1970
+ if (!args.prNumbers || args.prNumbers.length === 0) {
1971
+ return "Error: prNumbers is required for webhook-unsubscribe action";
1972
+ }
1973
+ const result = yield* ship.unsubscribeFromPRs(sessionId, args.prNumbers, ctx.serverUrl);
1974
+ if (!result.unsubscribed) {
1975
+ return `Error: ${result.error || "Failed to unsubscribe"}`;
1976
+ }
1977
+ return `Unsubscribed session ${sessionId} from PRs: ${args.prNumbers.join(", ")}`;
1978
+ }),
1979
+
1980
+ "webhook-cleanup": (ship, _args, _ctx) =>
1981
+ Effect.gen(function* () {
1982
+ const result = yield* ship.cleanupStaleSubscriptions();
1983
+ if (!result.success) {
1984
+ return `Error: ${result.error || "Failed to cleanup"}`;
1985
+ }
1986
+ if (result.removedSessions.length === 0) {
1987
+ return "No stale subscriptions found. All subscribed sessions are still active.";
1988
+ }
1989
+ return `Cleaned up ${result.removedSessions.length} stale subscription(s):\n${result.removedSessions.map((s: string) => ` - ${s}`).join("\n")}\n\nThese sessions no longer exist in OpenCode.`;
1990
+ }),
1991
+
1992
+ // PR reviews action
1993
+ "pr-reviews": (ship, args, _ctx) =>
1994
+ Effect.gen(function* () {
1995
+ const result = yield* ship.getPrReviews(args.prNumber, args.unresolved, args.workdir);
1996
+
1997
+ if (result.error) {
1998
+ return `Error: ${result.error}`;
1999
+ }
2000
+
2001
+ // Format the output in a human-readable way similar to the CLI
2002
+ const lines: string[] = [];
2003
+
2004
+ lines.push(`## PR #${result.prNumber}${result.prTitle ? `: ${result.prTitle}` : ""}`);
2005
+ if (result.prUrl) {
2006
+ lines.push(`URL: ${result.prUrl}`);
2007
+ }
2008
+ lines.push("");
2009
+
2010
+ // Reviews section
2011
+ if (result.reviews.length > 0) {
2012
+ lines.push("### Reviews");
2013
+ const sortedReviews = [...result.reviews].sort(
2014
+ (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
2015
+ );
2016
+ for (const review of sortedReviews) {
2017
+ const stateLabel =
2018
+ review.state === "APPROVED"
2019
+ ? "[APPROVED]"
2020
+ : review.state === "CHANGES_REQUESTED"
2021
+ ? "[CHANGES_REQUESTED]"
2022
+ : `[${review.state}]`;
2023
+ lines.push(`- @${review.author}: ${stateLabel}`);
2024
+ if (review.body) {
2025
+ const bodyLines = review.body.split("\n").map((l: string) => ` ${l}`);
2026
+ lines.push(...bodyLines);
2027
+ }
2028
+ }
2029
+ lines.push("");
2030
+ }
63
2031
 
64
- ### Task Description Template
2032
+ // Code comments section
2033
+ const fileKeys = Object.keys(result.commentsByFile);
2034
+ if (fileKeys.length > 0) {
2035
+ lines.push(`### Code Comments (${result.codeComments.length} total)`);
2036
+ lines.push("");
2037
+
2038
+ for (const filePath of fileKeys.sort()) {
2039
+ const fileComments = result.commentsByFile[filePath];
2040
+ lines.push(`#### ${filePath}`);
2041
+
2042
+ for (const comment of fileComments) {
2043
+ const lineInfo = comment.line !== null ? `:${comment.line}` : "";
2044
+ lines.push(`**${filePath}${lineInfo}** - @${comment.author}:`);
2045
+ if (comment.diffHunk) {
2046
+ lines.push("```diff");
2047
+ lines.push(comment.diffHunk);
2048
+ lines.push("```");
2049
+ }
2050
+ const bodyLines = comment.body.split("\n").map((l: string) => `> ${l}`);
2051
+ lines.push(...bodyLines);
2052
+ lines.push("");
2053
+ }
2054
+ }
2055
+ }
65
2056
 
66
- \`\`\`markdown
67
- ## Context
68
- Brief explanation of why this task exists and where it fits.
2057
+ // Conversation comments section
2058
+ if (result.conversationComments.length > 0) {
2059
+ lines.push("### Conversation");
2060
+ for (const comment of result.conversationComments) {
2061
+ lines.push(`- @${comment.author}:`);
2062
+ const bodyLines = comment.body.split("\n").map((l: string) => ` ${l}`);
2063
+ lines.push(...bodyLines);
2064
+ lines.push("");
2065
+ }
2066
+ }
69
2067
 
70
- ## Problem Statement
71
- What specific problem does this task solve? Current vs desired behavior.
2068
+ // Summary if no feedback
2069
+ if (
2070
+ result.reviews.length === 0 &&
2071
+ result.codeComments.length === 0 &&
2072
+ result.conversationComments.length === 0
2073
+ ) {
2074
+ lines.push("No reviews or comments found.");
2075
+ }
72
2076
 
73
- ## Implementation Notes
74
- - Key files: \`path/to/file.ts\`
75
- - Patterns: Reference existing implementations
76
- - Technical constraints
2077
+ const guidance = addGuidance("address review feedback | action=stack-submit (push updates)");
2078
+ return lines.join("\n").trim() + guidance;
2079
+ }),
77
2080
 
78
- ## Acceptance Criteria
79
- - [ ] Specific, testable requirement 1
80
- - [ ] Specific, testable requirement 2
81
- - [ ] Tests pass
2081
+ // Milestone actions
2082
+ "milestone-list": (ship, _args, _ctx) =>
2083
+ Effect.gen(function* () {
2084
+ const milestones = yield* ship.listMilestones();
2085
+ if (milestones.length === 0) {
2086
+ return "No milestones found for this project.\n\nCreate one with: milestone-create action";
2087
+ }
2088
+ return `Milestones (${milestones.length}):\n\n${milestones
2089
+ .map((m) => {
2090
+ const targetDate = m.targetDate ? ` (due: ${m.targetDate})` : "";
2091
+ return `${m.slug.padEnd(25)} ${m.name}${targetDate}`;
2092
+ })
2093
+ .join("\n")}`;
2094
+ }),
82
2095
 
83
- ## Out of Scope
84
- - What NOT to include
2096
+ "milestone-show": (ship, args, _ctx) =>
2097
+ Effect.gen(function* () {
2098
+ if (!args.milestoneId) {
2099
+ return "Error: milestoneId is required for milestone-show action";
2100
+ }
2101
+ const milestone = yield* ship.getMilestone(args.milestoneId);
2102
+ let output = `# ${milestone.name}\n\n`;
2103
+ output += `**Slug:** ${milestone.slug}\n`;
2104
+ output += `**ID:** ${milestone.id}\n`;
2105
+ if (milestone.targetDate) {
2106
+ output += `**Target Date:** ${milestone.targetDate}\n`;
2107
+ }
2108
+ if (milestone.description) {
2109
+ output += `\n## Description\n\n${milestone.description}`;
2110
+ }
2111
+ return output;
2112
+ }),
85
2113
 
86
- ## Dependencies
87
- - Blocked by: [BRI-XXX](url) (brief reason)
88
- - Blocks: [BRI-YYY](url) (brief reason)
89
- \`\`\`
2114
+ "milestone-create": (ship, args, _ctx) =>
2115
+ Effect.gen(function* () {
2116
+ if (!args.milestoneName) {
2117
+ return "Error: milestoneName is required for milestone-create action";
2118
+ }
2119
+ const milestone = yield* ship.createMilestone({
2120
+ name: args.milestoneName,
2121
+ description: args.milestoneDescription,
2122
+ targetDate: args.milestoneTargetDate,
2123
+ });
2124
+ let output = `Created milestone: ${milestone.name}\nSlug: ${milestone.slug}`;
2125
+ if (milestone.targetDate) {
2126
+ output += `\nTarget Date: ${milestone.targetDate}`;
2127
+ }
2128
+ return output;
2129
+ }),
90
2130
 
91
- **Important:**
92
- 1. Set blocking relationships via ship tool action=\`block\` (appears in Linear sidebar)
93
- 2. ALSO document in description using markdown links for context
94
- 3. Get task URLs from ship tool action=\`show\`
2131
+ "milestone-update": (ship, args, _ctx) =>
2132
+ Effect.gen(function* () {
2133
+ if (!args.milestoneId) {
2134
+ return "Error: milestoneId is required for milestone-update action";
2135
+ }
2136
+ if (!args.milestoneName && !args.milestoneDescription && !args.milestoneTargetDate) {
2137
+ return "Error: at least one of milestoneName, milestoneDescription, or milestoneTargetDate is required";
2138
+ }
2139
+ const milestone = yield* ship.updateMilestone(args.milestoneId, {
2140
+ name: args.milestoneName,
2141
+ description: args.milestoneDescription,
2142
+ targetDate: args.milestoneTargetDate,
2143
+ });
2144
+ let output = `Updated milestone: ${milestone.name}\nSlug: ${milestone.slug}`;
2145
+ if (milestone.targetDate) {
2146
+ output += `\nTarget Date: ${milestone.targetDate}`;
2147
+ }
2148
+ return output;
2149
+ }),
95
2150
 
96
- ### Task Quality Checklist
97
- - Title is actionable and specific (not "Fix bug" but "Fix null pointer in UserService.getById")
98
- - Context explains WHY, not just WHAT
99
- - Acceptance criteria are testable
100
- - **Dependencies set via \`ship block\`** AND documented with markdown links
101
- - Links use full URL format: \`[BRI-123](https://linear.app/...)\`
102
- - Priority reflects business impact (urgent/high/medium/low)`;
2151
+ "milestone-delete": (ship, args, _ctx) =>
2152
+ Effect.gen(function* () {
2153
+ if (!args.milestoneId) {
2154
+ return "Error: milestoneId is required for milestone-delete action";
2155
+ }
2156
+ yield* ship.deleteMilestone(args.milestoneId);
2157
+ return `Deleted milestone: ${args.milestoneId}`;
2158
+ }),
2159
+
2160
+ "task-set-milestone": (ship, args, _ctx) =>
2161
+ Effect.gen(function* () {
2162
+ if (!args.taskId) {
2163
+ return "Error: taskId is required for task-set-milestone action";
2164
+ }
2165
+ if (!args.milestoneId) {
2166
+ return "Error: milestoneId is required for task-set-milestone action";
2167
+ }
2168
+ const task = yield* ship.setTaskMilestone(args.taskId, args.milestoneId);
2169
+ return `Assigned ${task.identifier} to milestone: ${task.milestoneName || args.milestoneId}\nURL: ${task.url}`;
2170
+ }),
2171
+
2172
+ "task-unset-milestone": (ship, args, _ctx) =>
2173
+ Effect.gen(function* () {
2174
+ if (!args.taskId) {
2175
+ return "Error: taskId is required for task-unset-milestone action";
2176
+ }
2177
+ const task = yield* ship.unsetTaskMilestone(args.taskId);
2178
+ return `Removed ${task.identifier} from its milestone\nURL: ${task.url}`;
2179
+ }),
2180
+ };
103
2181
 
104
- /**
105
- * Get the current model/agent context for a session by querying messages.
106
- */
107
- async function getSessionContext(
108
- client: OpencodeClient,
109
- sessionID: string
110
- ): Promise<
111
- { model?: { providerID: string; modelID: string }; agent?: string } | undefined
112
- > {
113
- try {
114
- const response = await client.session.messages({
115
- path: { id: sessionID },
116
- query: { limit: 50 },
117
- });
118
-
119
- if (response.data) {
120
- for (const msg of response.data) {
121
- if (msg.info.role === "user" && "model" in msg.info && msg.info.model) {
122
- return { model: msg.info.model, agent: msg.info.agent };
123
- }
2182
+ const executeAction = (
2183
+ args: ToolArgs,
2184
+ context: ActionContext,
2185
+ ): Effect.Effect<string, ShipCommandError | JsonParseError | ShipNotConfiguredError, ShipService> =>
2186
+ Effect.gen(function* () {
2187
+ const ship = yield* ShipService;
2188
+
2189
+ // Check configuration for all actions except status
2190
+ if (args.action !== "status") {
2191
+ const status = yield* ship
2192
+ .checkConfigured()
2193
+ .pipe(Effect.catchAll(() => Effect.succeed({ configured: false })));
2194
+ if (!status.configured) {
2195
+ return yield* new ShipNotConfiguredError({});
124
2196
  }
125
2197
  }
126
- } catch {
127
- // On error, return undefined (let opencode use its default)
128
- }
129
-
130
- return undefined;
131
- }
132
-
133
- /**
134
- * Get the ship command based on NODE_ENV.
135
- *
136
- * - NODE_ENV=development: Use "pnpm ship" (for developing the CLI in this repo)
137
- * - Otherwise: Use "ship" (globally installed CLI)
138
- */
139
- function getShipCommand(): string[] {
140
- if (process.env.NODE_ENV === "development") {
141
- return ["pnpm", "ship"];
142
- }
143
- return ["ship"];
144
- }
145
-
146
- /**
147
- * Inject ship context into a session.
148
- *
149
- * Injects static guidance for using the ship tool. Does NOT fetch live data
150
- * from Linear - the AI should use the ship tool to get task data.
151
- * This ensures instant response on first message.
152
- */
153
- async function injectShipContext(
154
- client: OpencodeClient,
155
- sessionID: string,
156
- context?: { model?: { providerID: string; modelID: string }; agent?: string }
157
- ): Promise<void> {
158
- try {
159
- // Inject only the static guidance - no API calls
160
- // The AI will use the ship tool to fetch live data when needed
161
- await client.session.prompt({
162
- path: { id: sessionID },
163
- body: {
164
- noReply: true,
165
- model: context?.model,
166
- agent: context?.agent,
167
- parts: [{ type: "text", text: SHIP_GUIDANCE, synthetic: true }],
168
- },
169
- });
170
- } catch {
171
- // Silent skip on error
172
- }
173
- }
174
2198
 
175
- /**
176
- * Execute ship CLI command and return output
177
- */
178
- async function runShip(
179
- $: PluginInput["$"],
180
- args: string[]
181
- ): Promise<{ success: boolean; output: string }> {
182
- try {
183
- const cmd = getShipCommand();
184
- // Use quiet() to prevent output from bleeding into TUI
185
- const result = await $`${cmd} ${args}`.quiet().nothrow();
186
- const stdout = await new Response(result.stdout).text();
187
- const stderr = await new Response(result.stderr).text();
188
-
189
- if (result.exitCode !== 0) {
190
- return { success: false, output: stderr || stdout };
2199
+ // Look up handler from the record
2200
+ const handler = actionHandlers[args.action];
2201
+ if (!handler) {
2202
+ return `Unknown action: ${args.action}`;
191
2203
  }
192
2204
 
193
- return { success: true, output: stdout };
194
- } catch (error) {
195
- return {
196
- success: false,
197
- output: `Failed to run ship: ${error instanceof Error ? error.message : String(error)}`,
198
- };
199
- }
200
- }
201
-
202
- /**
203
- * Check if ship is configured by attempting to run a ship command.
204
- * This handles both local (.ship/config.yaml) and global configurations.
205
- */
206
- async function isShipConfigured($: PluginInput["$"]): Promise<boolean> {
207
- try {
208
- const cmd = getShipCommand();
209
- // Try running ship prime - it will fail if not configured
210
- // Use quiet() to suppress stdout/stderr from bleeding into TUI
211
- const result = await $`${cmd} prime`.quiet().nothrow();
212
- return result.exitCode === 0;
213
- } catch {
214
- return false;
215
- }
216
- }
2205
+ return yield* handler(ship, args, context);
2206
+ });
217
2207
 
218
- /**
219
- * Format a list of tasks for display
220
- */
221
- function formatTaskList(
222
- tasks: Array<{
223
- identifier: string;
224
- title: string;
225
- priority: string;
226
- status: string;
227
- url: string;
228
- }>
229
- ): string {
230
- return tasks
231
- .map((t) => {
232
- const priority =
233
- t.priority === "urgent"
234
- ? "[!]"
235
- : t.priority === "high"
236
- ? "[^]"
237
- : " ";
238
- return `${priority} ${t.identifier.padEnd(10)} ${t.status.padEnd(12)} ${t.title}`;
239
- })
240
- .join("\n");
241
- }
2208
+ // =============================================================================
2209
+ // Tool Creation
2210
+ // =============================================================================
242
2211
 
243
2212
  /**
244
- * Format task details for display
2213
+ * Create the ship tool with the opencode context.
2214
+ *
2215
+ * @param $ - Bun shell from opencode
2216
+ * @param directory - Current working directory from opencode (Instance.directory)
2217
+ * @returns ToolDefinition for the ship tool
245
2218
  */
246
- function formatTaskDetails(task: {
247
- identifier: string;
248
- title: string;
249
- description?: string;
250
- priority: string;
251
- status: string;
252
- labels: string[];
253
- url: string;
254
- branchName?: string;
255
- }): string {
256
- let output = `# ${task.identifier}: ${task.title}
257
-
258
- **Status:** ${task.status}
259
- **Priority:** ${task.priority}
260
- **Labels:** ${task.labels.length > 0 ? task.labels.join(", ") : "none"}
261
- **URL:** ${task.url}`;
262
-
263
- if (task.branchName) {
264
- output += `\n**Branch:** ${task.branchName}`;
265
- }
266
-
267
- if (task.description) {
268
- output += `\n\n## Description\n\n${task.description}`;
269
- }
2219
+ const createShipTool = ($: BunShell, directory: string, serverUrl?: string): ToolDefinition => {
2220
+ const shellService = makeShellService($, directory);
2221
+ const ShellServiceLive = Layer.succeed(ShellService, shellService);
2222
+ const ShipServiceLive = Layer.effect(ShipService, makeShipService).pipe(
2223
+ Layer.provide(ShellServiceLive),
2224
+ );
270
2225
 
271
- return output;
272
- }
2226
+ const runEffect = <A, E>(effect: Effect.Effect<A, E, ShipService>): Promise<A> =>
2227
+ Effect.runPromise(Effect.provide(effect, ShipServiceLive));
273
2228
 
274
- /**
275
- * Create ship tool with captured $ from plugin context
276
- */
277
- function createShipTool($: PluginInput["$"]) {
278
2229
  return createTool({
279
- description: `Linear task management for the current project.
2230
+ description: `Linear task management and VCS operations for the current project.
2231
+
2232
+ IMPORTANT: Always use this tool for VCS operations. NEVER run jj, gh, or git commands directly via bash.
2233
+ - Use stack-create instead of: jj new, jj describe, jj bookmark create
2234
+ - Use stack-describe instead of: jj describe
2235
+ - Use stack-submit instead of: jj git push, gh pr create
2236
+ - Use stack-sync instead of: jj git fetch, jj rebase
280
2237
 
281
2238
  Use this tool to:
282
2239
  - List tasks ready to work on (no blockers)
@@ -285,6 +2242,9 @@ Use this tool to:
285
2242
  - Create new tasks
286
2243
  - Manage task dependencies (blocking relationships)
287
2244
  - Get AI-optimized context about current work
2245
+ - Manage stacked changes (jj workflow)
2246
+ - Start/stop GitHub webhook forwarding for real-time event notifications
2247
+ - Subscribe to PR events via the webhook daemon (multi-session support)
288
2248
 
289
2249
  Requires ship to be configured in the project (.ship/config.yaml).
290
2250
  Run 'ship init' in the terminal first if not configured.`,
@@ -303,24 +2263,57 @@ Run 'ship init' in the terminal first if not configured.`,
303
2263
  "block",
304
2264
  "unblock",
305
2265
  "relate",
306
- "prime",
307
2266
  "status",
2267
+ "stack-log",
2268
+ "stack-status",
2269
+ "stack-create",
2270
+ "stack-describe",
2271
+ "stack-sync",
2272
+ "stack-restack",
2273
+ "stack-submit",
2274
+ "stack-squash",
2275
+ "stack-abandon",
2276
+ "stack-up",
2277
+ "stack-down",
2278
+ "stack-undo",
2279
+ "stack-update-stale",
2280
+ "stack-bookmark",
2281
+ "stack-workspaces",
2282
+ "stack-remove-workspace",
2283
+ "webhook-daemon-status",
2284
+ "webhook-subscribe",
2285
+ "webhook-unsubscribe",
2286
+ "webhook-cleanup",
2287
+ "pr-reviews",
2288
+ "milestone-list",
2289
+ "milestone-show",
2290
+ "milestone-create",
2291
+ "milestone-update",
2292
+ "milestone-delete",
2293
+ "task-set-milestone",
2294
+ "task-unset-milestone",
308
2295
  ])
309
2296
  .describe(
310
- "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), update (modify task), block/unblock (dependencies), relate (link related tasks), prime (AI context), status (current config)"
2297
+ "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), update (modify task), block/unblock (dependencies), relate (link related tasks), status (current config), stack-log (view stack), stack-status (current change), stack-create (new change with workspace by default), stack-describe (update description), stack-bookmark (create or move a bookmark on current change), stack-sync (fetch and rebase), stack-restack (rebase stack onto trunk without fetching), stack-submit (push and create/update PR, auto-subscribes to webhook events), stack-squash (squash into parent), stack-abandon (abandon change), stack-up (move to child change toward tip), stack-down (move to parent change toward trunk), stack-undo (undo last jj operation), stack-update-stale (update stale working copy after workspace or remote changes), stack-workspaces (list all jj workspaces), stack-remove-workspace (remove a jj workspace), webhook-daemon-status (check daemon status), webhook-subscribe (subscribe to PR events), webhook-unsubscribe (unsubscribe from PR events), webhook-cleanup (cleanup stale subscriptions for sessions that no longer exist), pr-reviews (fetch PR reviews and comments), milestone-list (list project milestones), milestone-show (view milestone details), milestone-create (create new milestone), milestone-update (modify milestone), milestone-delete (delete milestone), task-set-milestone (assign task to milestone), task-unset-milestone (remove task from milestone)",
311
2298
  ),
312
2299
  taskId: createTool.schema
313
2300
  .string()
314
2301
  .optional()
315
- .describe("Task identifier (e.g., BRI-123) - required for show, start, done, update"),
2302
+ .describe(
2303
+ "Task identifier (e.g., BRI-123) - required for show, start, done, update; optional for stack-create to associate workspace with task",
2304
+ ),
316
2305
  title: createTool.schema
317
2306
  .string()
318
2307
  .optional()
319
- .describe("Task title - required for create, optional for update"),
2308
+ .describe(
2309
+ "Title - for task create/update OR for stack-describe (first line of commit message)",
2310
+ ),
320
2311
  description: createTool.schema
321
2312
  .string()
322
2313
  .optional()
323
- .describe("Task description - optional for create/update"),
2314
+ .describe(
2315
+ "Description - for task create/update OR for stack-describe (commit body after title)",
2316
+ ),
324
2317
  priority: createTool.schema
325
2318
  .enum(["urgent", "high", "medium", "low", "none"])
326
2319
  .optional()
@@ -341,6 +2334,10 @@ Run 'ship init' in the terminal first if not configured.`,
341
2334
  .string()
342
2335
  .optional()
343
2336
  .describe("Related task ID - required for relate (use with taskId)"),
2337
+ parentId: createTool.schema
2338
+ .string()
2339
+ .optional()
2340
+ .describe("Parent task identifier (e.g., BRI-123) - for creating subtasks"),
344
2341
  filter: createTool.schema
345
2342
  .object({
346
2343
  status: createTool.schema
@@ -351,235 +2348,151 @@ Run 'ship init' in the terminal first if not configured.`,
351
2348
  })
352
2349
  .optional()
353
2350
  .describe("Filters for list action"),
2351
+ message: createTool.schema
2352
+ .string()
2353
+ .optional()
2354
+ .describe(
2355
+ "Message for stack-create. For stack-describe, prefer using title + description params for proper multi-line commits",
2356
+ ),
2357
+ bookmark: createTool.schema
2358
+ .string()
2359
+ .optional()
2360
+ .describe("Bookmark name for stack-create action"),
2361
+ noWorkspace: createTool.schema
2362
+ .boolean()
2363
+ .optional()
2364
+ .describe(
2365
+ "Skip workspace creation - for stack-create action (by default, workspace is created for isolated development)",
2366
+ ),
2367
+ name: createTool.schema
2368
+ .string()
2369
+ .optional()
2370
+ .describe(
2371
+ "Bookmark or workspace name - required for stack-bookmark and stack-remove-workspace actions",
2372
+ ),
2373
+ move: createTool.schema
2374
+ .boolean()
2375
+ .optional()
2376
+ .describe(
2377
+ "Move an existing bookmark instead of creating a new one - for stack-bookmark action",
2378
+ ),
2379
+ deleteFiles: createTool.schema
2380
+ .boolean()
2381
+ .optional()
2382
+ .describe(
2383
+ "Also delete the workspace directory from disk - for stack-remove-workspace action",
2384
+ ),
2385
+ workdir: createTool.schema
2386
+ .string()
2387
+ .optional()
2388
+ .describe(
2389
+ "Working directory for VCS operations - use this when operating in a jj workspace (e.g., the path returned by stack-create)",
2390
+ ),
2391
+ draft: createTool.schema
2392
+ .boolean()
2393
+ .optional()
2394
+ .describe("Create PR as draft - for stack-submit action"),
2395
+ body: createTool.schema
2396
+ .string()
2397
+ .optional()
2398
+ .describe("PR body - for stack-submit action (defaults to change description)"),
2399
+ changeId: createTool.schema
2400
+ .string()
2401
+ .optional()
2402
+ .describe("Change ID to abandon - for stack-abandon action (defaults to current @)"),
2403
+ events: createTool.schema
2404
+ .string()
2405
+ .optional()
2406
+ .describe(
2407
+ "Comma-separated GitHub events to forward (e.g., 'pull_request,check_run') - for webhook-start action",
2408
+ ),
2409
+ sessionId: createTool.schema
2410
+ .string()
2411
+ .optional()
2412
+ .describe("OpenCode session ID - for webhook-subscribe/unsubscribe actions"),
2413
+ prNumbers: createTool.schema
2414
+ .array(createTool.schema.number())
2415
+ .optional()
2416
+ .describe(
2417
+ "PR numbers to subscribe/unsubscribe - for webhook-subscribe/unsubscribe actions",
2418
+ ),
2419
+ prNumber: createTool.schema
2420
+ .number()
2421
+ .optional()
2422
+ .describe(
2423
+ "PR number - for pr-reviews action (defaults to current bookmark's PR if not provided)",
2424
+ ),
2425
+ unresolved: createTool.schema
2426
+ .boolean()
2427
+ .optional()
2428
+ .describe("Show only unresolved/actionable comments - for pr-reviews action"),
2429
+ milestoneId: createTool.schema
2430
+ .string()
2431
+ .optional()
2432
+ .describe(
2433
+ "Milestone identifier (slug like 'q1-release' or UUID) - required for milestone-show, milestone-update, task-set-milestone",
2434
+ ),
2435
+ milestoneName: createTool.schema
2436
+ .string()
2437
+ .optional()
2438
+ .describe("Milestone name - required for milestone-create, optional for milestone-update"),
2439
+ milestoneDescription: createTool.schema
2440
+ .string()
2441
+ .optional()
2442
+ .describe("Milestone description - optional for milestone-create/update"),
2443
+ milestoneTargetDate: createTool.schema
2444
+ .string()
2445
+ .optional()
2446
+ .describe(
2447
+ "Milestone target date (ISO format like '2024-03-31') - optional for milestone-create/update",
2448
+ ),
354
2449
  },
355
2450
 
356
- async execute(args) {
357
- // Check if ship is configured
358
- if (args.action !== "status") {
359
- const configured = await isShipConfigured($);
360
- if (!configured) {
361
- return `Ship is not configured in this project.
2451
+ async execute(args, context) {
2452
+ // Build action context with session ID, main repo path, and server URL
2453
+ const actionContext: ActionContext = {
2454
+ sessionId: context.sessionID,
2455
+ mainRepoPath: directory,
2456
+ serverUrl,
2457
+ };
2458
+ const result = await runEffect(
2459
+ executeAction(args, actionContext).pipe(
2460
+ Effect.catchAll((error) => {
2461
+ if (error._tag === "ShipNotConfiguredError") {
2462
+ return Effect.succeed(`Ship is not configured in this project.
362
2463
 
363
2464
  Run 'ship init' in the terminal to:
364
2465
  1. Authenticate with Linear (paste your API key from https://linear.app/settings/api)
365
2466
  2. Select your team
366
2467
  3. Optionally select a project
367
2468
 
368
- After that, you can use this tool to manage tasks.`;
369
- }
370
- }
371
-
372
- switch (args.action) {
373
- case "status": {
374
- const configured = await isShipConfigured($);
375
- if (!configured) {
376
- return "Ship is not configured. Run 'ship init' first.";
377
- }
378
- return "Ship is configured in this project.";
379
- }
380
-
381
- case "ready": {
382
- const result = await runShip($, ["ready", "--json"]);
383
- if (!result.success) {
384
- return `Failed to get ready tasks: ${result.output}`;
385
- }
386
- try {
387
- const tasks = JSON.parse(result.output);
388
- if (tasks.length === 0) {
389
- return "No tasks ready to work on (all tasks are either blocked or completed).";
2469
+ After that, you can use this tool to manage tasks.`);
390
2470
  }
391
- return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}`;
392
- } catch {
393
- return result.output;
394
- }
395
- }
396
-
397
- case "list": {
398
- const listArgs = ["list", "--json"];
399
- if (args.filter?.status) listArgs.push("--status", args.filter.status);
400
- if (args.filter?.priority) listArgs.push("--priority", args.filter.priority);
401
- if (args.filter?.mine) listArgs.push("--mine");
402
-
403
- const result = await runShip($, listArgs);
404
- if (!result.success) {
405
- return `Failed to list tasks: ${result.output}`;
406
- }
407
- try {
408
- const tasks = JSON.parse(result.output);
409
- if (tasks.length === 0) {
410
- return "No tasks found matching the filter.";
2471
+ if (error._tag === "ShipCommandError") {
2472
+ return Effect.succeed(`Command failed: ${error.message}`);
411
2473
  }
412
- return `Tasks:\n\n${formatTaskList(tasks)}`;
413
- } catch {
414
- return result.output;
415
- }
416
- }
417
-
418
- case "blocked": {
419
- const result = await runShip($, ["blocked", "--json"]);
420
- if (!result.success) {
421
- return `Failed to get blocked tasks: ${result.output}`;
422
- }
423
- try {
424
- const tasks = JSON.parse(result.output);
425
- if (tasks.length === 0) {
426
- return "No blocked tasks.";
2474
+ if (error._tag === "JsonParseError") {
2475
+ return Effect.succeed(`Failed to parse response: ${error.raw}`);
427
2476
  }
428
- return `Blocked tasks:\n\n${formatTaskList(tasks)}`;
429
- } catch {
430
- return result.output;
431
- }
432
- }
433
-
434
- case "show": {
435
- if (!args.taskId) {
436
- return "Error: taskId is required for show action";
437
- }
438
- const result = await runShip($, ["show", "--json", args.taskId]);
439
- if (!result.success) {
440
- return `Failed to get task: ${result.output}`;
441
- }
442
- try {
443
- const task = JSON.parse(result.output);
444
- return formatTaskDetails(task);
445
- } catch {
446
- return result.output;
447
- }
448
- }
449
-
450
- case "start": {
451
- if (!args.taskId) {
452
- return "Error: taskId is required for start action";
453
- }
454
- const result = await runShip($, ["start", args.taskId]);
455
- if (!result.success) {
456
- return `Failed to start task: ${result.output}`;
457
- }
458
- return `Started working on ${args.taskId}`;
459
- }
460
-
461
- case "done": {
462
- if (!args.taskId) {
463
- return "Error: taskId is required for done action";
464
- }
465
- const result = await runShip($, ["done", args.taskId]);
466
- if (!result.success) {
467
- return `Failed to complete task: ${result.output}`;
468
- }
469
- return `Completed ${args.taskId}`;
470
- }
471
-
472
- case "create": {
473
- if (!args.title) {
474
- return "Error: title is required for create action";
475
- }
476
- const createArgs = ["create", "--json"];
477
- if (args.description) createArgs.push("--description", args.description);
478
- if (args.priority) createArgs.push("--priority", args.priority);
479
- createArgs.push(args.title);
480
-
481
- const result = await runShip($, createArgs);
482
- if (!result.success) {
483
- return `Failed to create task: ${result.output}`;
484
- }
485
- try {
486
- const response = JSON.parse(result.output);
487
- const task = response.task;
488
- return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
489
- } catch {
490
- return result.output;
491
- }
492
- }
493
-
494
- case "update": {
495
- if (!args.taskId) {
496
- return "Error: taskId is required for update action";
497
- }
498
- if (!args.title && !args.description && !args.priority && !args.status) {
499
- return "Error: at least one of title, description, priority, or status is required for update";
500
- }
501
- const updateArgs = ["update", "--json"];
502
- if (args.title) updateArgs.push("--title", args.title);
503
- if (args.description) updateArgs.push("--description", args.description);
504
- if (args.priority) updateArgs.push("--priority", args.priority);
505
- if (args.status) updateArgs.push("--status", args.status);
506
- updateArgs.push(args.taskId);
507
-
508
- const result = await runShip($, updateArgs);
509
- if (!result.success) {
510
- return `Failed to update task: ${result.output}`;
511
- }
512
- try {
513
- const response = JSON.parse(result.output);
514
- const task = response.task;
515
- return `Updated task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
516
- } catch {
517
- return result.output;
518
- }
519
- }
520
-
521
- case "block": {
522
- if (!args.blocker || !args.blocked) {
523
- return "Error: both blocker and blocked task IDs are required";
524
- }
525
- const result = await runShip($, ["block", args.blocker, args.blocked]);
526
- if (!result.success) {
527
- return `Failed to add blocker: ${result.output}`;
528
- }
529
- return `${args.blocker} now blocks ${args.blocked}`;
530
- }
531
-
532
- case "unblock": {
533
- if (!args.blocker || !args.blocked) {
534
- return "Error: both blocker and blocked task IDs are required";
535
- }
536
- const result = await runShip($, ["unblock", args.blocker, args.blocked]);
537
- if (!result.success) {
538
- return `Failed to remove blocker: ${result.output}`;
539
- }
540
- return `Removed ${args.blocker} as blocker of ${args.blocked}`;
541
- }
542
-
543
- case "relate": {
544
- if (!args.taskId || !args.relatedTaskId) {
545
- return "Error: both taskId and relatedTaskId are required for relate action";
546
- }
547
- const result = await runShip($, ["relate", args.taskId, args.relatedTaskId]);
548
- if (!result.success) {
549
- return `Failed to relate tasks: ${result.output}`;
550
- }
551
- return `Linked ${args.taskId} ↔ ${args.relatedTaskId} as related`;
552
- }
553
-
554
- case "prime": {
555
- const result = await runShip($, ["prime"]);
556
- if (!result.success) {
557
- return `Failed to get context: ${result.output}`;
558
- }
559
- return result.output;
560
- }
561
-
562
- default:
563
- return `Unknown action: ${args.action}`;
564
- }
2477
+ return Effect.succeed(`Unknown error: ${JSON.stringify(error)}`);
2478
+ }),
2479
+ ),
2480
+ );
2481
+ return result;
565
2482
  },
566
2483
  });
567
- }
2484
+ };
2485
+
2486
+ // =============================================================================
2487
+ // Commands
2488
+ // =============================================================================
568
2489
 
569
- /**
570
- * Ship OpenCode Plugin
571
- */
572
- // Pre-define commands (loaded at plugin init, not lazily in config hook)
573
2490
  const SHIP_COMMANDS = {
574
2491
  ready: {
575
2492
  description: "Find ready-to-work tasks with no blockers",
576
2493
  template: `Use the \`ship\` tool with action \`ready\` to find tasks that are ready to work on (no blocking dependencies).
577
2494
 
578
- Present the results in a clear format showing:
579
- - Task ID (e.g., BRI-123)
580
- - Title
581
- - Priority
582
- - URL
2495
+ Present the results in a clear format showing task ID, title, priority, and URL.
583
2496
 
584
2497
  If there are ready tasks, ask the user which one they'd like to work on. If they choose one, use the \`ship\` tool with action \`start\` to begin work on it.
585
2498
 
@@ -587,72 +2500,184 @@ If there are no ready tasks, suggest checking blocked tasks (action \`blocked\`)
587
2500
  },
588
2501
  };
589
2502
 
590
- export const ShipPlugin: Plugin = async ({ client, $ }) => {
591
- const injectedSessions = new Set<string>();
2503
+ // =============================================================================
2504
+ // Compaction Context Hooks
2505
+ // =============================================================================
592
2506
 
593
- // Create the ship tool with captured $
594
- const shipTool = createShipTool($);
2507
+ /**
2508
+ * Create the compaction context hook.
2509
+ *
2510
+ * This hook is called BEFORE compaction starts. It allows us to append
2511
+ * additional context to the compaction prompt, which will be included
2512
+ * in the generated summary.
2513
+ *
2514
+ * @param shellService - Shell service for running ship commands
2515
+ */
2516
+ const createCompactionHook = (
2517
+ shellService: ShellService,
2518
+ ): Hooks["experimental.session.compacting"] => {
2519
+ const ShellServiceLive = Layer.succeed(ShellService, shellService);
2520
+ const ShipServiceLive = Layer.effect(ShipService, makeShipService).pipe(
2521
+ Layer.provide(ShellServiceLive),
2522
+ );
2523
+
2524
+ return async (input, output) => {
2525
+ const { sessionID } = input;
2526
+
2527
+ // Try to get the tracked task for this session
2528
+ const trackedTask = getTrackedTask(sessionID);
2529
+
2530
+ if (Option.isNone(trackedTask)) {
2531
+ // No task tracked for this session, nothing to preserve
2532
+ return;
2533
+ }
595
2534
 
596
- return {
597
- "chat.message": async (_input, output) => {
598
- const sessionID = output.message.sessionID;
2535
+ const { taskId, workdir } = trackedTask.value;
2536
+
2537
+ // Fetch task details and stack status in parallel
2538
+ const [taskResult, stackResult] = await Effect.runPromise(
2539
+ Effect.all(
2540
+ [
2541
+ Effect.gen(function* () {
2542
+ const ship = yield* ShipService;
2543
+ return yield* ship.getTask(taskId);
2544
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
2545
+ Effect.gen(function* () {
2546
+ const ship = yield* ShipService;
2547
+ return yield* ship.getStackStatus(workdir);
2548
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
2549
+ ],
2550
+ { concurrency: 2 },
2551
+ ).pipe(Effect.provide(ShipServiceLive)),
2552
+ );
2553
+
2554
+ // Build the compaction context
2555
+ const contextParts: string[] = [];
2556
+
2557
+ contextParts.push("## Ship Task Context (Preserve Across Compaction)");
2558
+ contextParts.push("");
2559
+
2560
+ if (taskResult) {
2561
+ contextParts.push(`**Current Task:** ${taskResult.identifier} - ${taskResult.title}`);
2562
+ contextParts.push(`**Status:** ${taskResult.state || taskResult.status}`);
2563
+ contextParts.push(`**Priority:** ${taskResult.priority}`);
2564
+ contextParts.push(`**URL:** ${taskResult.url}`);
2565
+ } else {
2566
+ contextParts.push(`**Current Task:** ${taskId} (details unavailable)`);
2567
+ }
599
2568
 
600
- // Skip if already injected this session
601
- if (injectedSessions.has(sessionID)) return;
2569
+ if (workdir) {
2570
+ contextParts.push(`**Workspace:** ${workdir}`);
2571
+ }
602
2572
 
603
- // Check if ship-context was already injected (handles plugin reload/reconnection)
604
- try {
605
- const existing = await client.session.messages({
606
- path: { id: sessionID },
607
- });
2573
+ if (stackResult?.change) {
2574
+ const c = stackResult.change;
2575
+ contextParts.push("");
2576
+ contextParts.push("**VCS State:**");
2577
+ contextParts.push(`- Change: ${c.changeId.slice(0, 8)}`);
2578
+ contextParts.push(`- Description: ${c.description.split("\n")[0] || "(no description)"}`);
2579
+ if (c.bookmarks.length > 0) {
2580
+ contextParts.push(`- Bookmarks: ${c.bookmarks.join(", ")}`);
2581
+ }
2582
+ }
608
2583
 
609
- if (existing.data) {
610
- const hasShipContext = existing.data.some((msg) => {
611
- const parts = (msg as any).parts || (msg.info as any).parts;
612
- if (!parts) return false;
613
- return parts.some(
614
- (part: any) =>
615
- part.type === "text" && part.text?.includes("<ship-context>")
616
- );
617
- });
2584
+ contextParts.push("");
2585
+ contextParts.push("**IMPORTANT:** After compaction, immediately:");
2586
+ contextParts.push('1. Load the ship-cli skill using: `skill(name="ship-cli")`');
2587
+ contextParts.push(
2588
+ `2. Continue working on task ${taskId}${workdir ? ` in workspace ${workdir}` : ""}`,
2589
+ );
618
2590
 
619
- if (hasShipContext) {
620
- injectedSessions.add(sessionID);
621
- return;
622
- }
623
- }
624
- } catch {
625
- // On error, proceed with injection
626
- }
2591
+ // Add to output context
2592
+ output.context.push(contextParts.join("\n"));
2593
+ };
2594
+ };
627
2595
 
628
- injectedSessions.add(sessionID);
2596
+ // =============================================================================
2597
+ // Tool Execute Hook
2598
+ // =============================================================================
629
2599
 
630
- // Use output.message which has the resolved model/agent values
631
- await injectShipContext(client, sessionID, {
632
- model: output.message.model,
633
- agent: output.message.agent,
634
- });
635
- },
2600
+ /**
2601
+ * Create the tool.execute.after hook to track task state.
2602
+ *
2603
+ * This hook monitors ship tool calls to track:
2604
+ * - When tasks are started (action=start)
2605
+ * - When workspaces are created (action=stack-create)
2606
+ *
2607
+ * This state is used during compaction to preserve context.
2608
+ */
2609
+ const createToolExecuteAfterHook = (): Hooks["tool.execute.after"] => {
2610
+ return async (input, output) => {
2611
+ // Only track ship tool calls
2612
+ if (input.tool !== "ship") {
2613
+ return;
2614
+ }
2615
+
2616
+ const { sessionID } = input;
2617
+
2618
+ // Parse the args from the output metadata using Schema validation
2619
+ const argsOption = decodeShipToolArgs(output.metadata);
2620
+ if (Option.isNone(argsOption)) {
2621
+ return;
2622
+ }
2623
+ const args = argsOption.value;
2624
+
2625
+ // Track task starts
2626
+ if (args.action === "start" && args.taskId) {
2627
+ trackTask(sessionID, { taskId: args.taskId });
2628
+ }
636
2629
 
637
- event: async ({ event }) => {
638
- if (event.type === "session.compacted") {
639
- const sessionID = event.properties.sessionID;
640
- const context = await getSessionContext(client, sessionID);
641
- await injectShipContext(client, sessionID, context);
2630
+ // Track workspace creation (updates workdir for existing task)
2631
+ if (args.action === "stack-create") {
2632
+ // Extract workspace path from output
2633
+ const workspaceMatch = output.output.match(/Created workspace: \S+ at (.+?)(?:\n|$)/);
2634
+ const workdir = workspaceMatch?.[1];
2635
+
2636
+ // Extract taskId from args or from existing tracked task
2637
+ const taskId = args.taskId;
2638
+ if (taskId) {
2639
+ trackTask(sessionID, { taskId, workdir });
2640
+ } else if (workdir) {
2641
+ // Just update workdir for existing tracked task
2642
+ trackTask(sessionID, { workdir });
642
2643
  }
643
- },
2644
+ }
2645
+
2646
+ // Track task completion - clear the tracked task
2647
+ if (args.action === "done" && args.taskId) {
2648
+ const existing = sessionTaskMap.get(sessionID);
2649
+ if (existing?.taskId === args.taskId) {
2650
+ sessionTaskMap.delete(sessionID);
2651
+ }
2652
+ }
2653
+ };
2654
+ };
2655
+
2656
+ // =============================================================================
2657
+ // Plugin Export
2658
+ // =============================================================================
2659
+
2660
+ // Extended PluginInput type to include serverUrl (available in OpenCode 1.0.144+)
2661
+ type ExtendedPluginInput = Parameters<Plugin>[0] & {
2662
+ serverUrl?: URL;
2663
+ };
2664
+
2665
+ export const ShipPlugin = async (input: ExtendedPluginInput) => {
2666
+ const { $, directory, serverUrl } = input;
2667
+ const shellService = makeShellService($, directory);
2668
+ // Convert URL object to string for passing to CLI commands
2669
+ const serverUrlString = serverUrl?.toString();
644
2670
 
645
- config: async (config) => {
646
- // Register commands (using pre-defined SHIP_COMMANDS for reliability)
2671
+ return {
2672
+ config: async (config: Parameters<NonNullable<Awaited<ReturnType<Plugin>>["config"]>>[0]) => {
647
2673
  config.command = { ...config.command, ...SHIP_COMMANDS };
648
2674
  },
649
-
650
- // Register the ship tool
651
2675
  tool: {
652
- ship: shipTool,
2676
+ ship: createShipTool($, directory, serverUrlString),
653
2677
  },
2678
+ "tool.execute.after": createToolExecuteAfterHook(),
2679
+ "experimental.session.compacting": createCompactionHook(shellService),
654
2680
  };
655
2681
  };
656
2682
 
657
- // Default export for OpenCode
658
2683
  export default ShipPlugin;