@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/README.md +112 -0
- package/package.json +27 -10
- package/src/compaction.ts +78 -0
- package/src/plugin.ts +2529 -504
- package/src/services.ts +885 -0
- package/src/utils.ts +180 -0
- package/LICENSE +0 -21
package/src/plugin.ts
CHANGED
|
@@ -1,282 +1,2239 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ship OpenCode Plugin
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
478
|
+
if (args.includes("--json")) {
|
|
479
|
+
return extractJson(result.stdout);
|
|
480
|
+
}
|
|
18
481
|
|
|
19
|
-
|
|
482
|
+
return result.stdout;
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
485
|
+
};
|
|
20
486
|
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
674
|
+
const makeShipService = Effect.gen(function* () {
|
|
675
|
+
const shell = yield* ShellService;
|
|
45
676
|
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
194
|
-
}
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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),
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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 `
|
|
429
|
-
}
|
|
430
|
-
|
|
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
|
-
|
|
591
|
-
|
|
2503
|
+
// =============================================================================
|
|
2504
|
+
// Compaction Context Hooks
|
|
2505
|
+
// =============================================================================
|
|
592
2506
|
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
601
|
-
|
|
2569
|
+
if (workdir) {
|
|
2570
|
+
contextParts.push(`**Workspace:** ${workdir}`);
|
|
2571
|
+
}
|
|
602
2572
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
2596
|
+
// =============================================================================
|
|
2597
|
+
// Tool Execute Hook
|
|
2598
|
+
// =============================================================================
|
|
629
2599
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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:
|
|
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;
|