@ship-cli/opencode 0.0.4 → 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 +2553 -390
- package/src/services.ts +885 -0
- package/src/utils.ts +180 -0
- package/LICENSE +0 -21
package/src/plugin.ts
CHANGED
|
@@ -1,217 +1,2239 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ship OpenCode Plugin
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Hooks, Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
|
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
|
+
// =============================================================================
|
|
363
|
+
|
|
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
|
+
}
|
|
477
|
+
|
|
478
|
+
if (args.includes("--json")) {
|
|
479
|
+
return extractJson(result.stdout);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return result.stdout;
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
|
|
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
|
+
}
|
|
648
|
+
|
|
649
|
+
const ShipService = Context.GenericTag<ShipService>("ShipService");
|
|
650
|
+
|
|
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.
|
|
5
657
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Ship tool for task management operations
|
|
9
|
-
* - Task agent for autonomous issue completion
|
|
658
|
+
* @param raw - Raw JSON string from CLI output
|
|
659
|
+
* @returns Parsed object with asserted type T
|
|
10
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
|
+
});
|
|
673
|
+
|
|
674
|
+
const makeShipService = Effect.gen(function* () {
|
|
675
|
+
const shell = yield* ShellService;
|
|
676
|
+
|
|
677
|
+
const checkConfigured = () =>
|
|
678
|
+
Effect.gen(function* () {
|
|
679
|
+
const output = yield* shell.run(["status", "--json"]);
|
|
680
|
+
return yield* parseJson<ShipStatus>(output);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const getReadyTasks = () =>
|
|
684
|
+
Effect.gen(function* () {
|
|
685
|
+
const output = yield* shell.run(["task", "ready", "--json"]);
|
|
686
|
+
return yield* parseJson<ShipTask[]>(output);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const getBlockedTasks = () =>
|
|
690
|
+
Effect.gen(function* () {
|
|
691
|
+
const output = yield* shell.run(["task", "blocked", "--json"]);
|
|
692
|
+
return yield* parseJson<ShipTask[]>(output);
|
|
693
|
+
});
|
|
694
|
+
|
|
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");
|
|
701
|
+
|
|
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
|
+
}),
|
|
11
1963
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}),
|
|
14
1979
|
|
|
15
|
-
|
|
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
|
+
}),
|
|
16
1991
|
|
|
17
|
-
|
|
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);
|
|
18
1996
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- Start/complete work on tasks
|
|
23
|
-
- Create new tasks
|
|
1997
|
+
if (result.error) {
|
|
1998
|
+
return `Error: ${result.error}`;
|
|
1999
|
+
}
|
|
24
2000
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- \`ship blocked\` - Tasks waiting on dependencies
|
|
28
|
-
- \`ship list\` - All tasks
|
|
29
|
-
- \`ship show <ID>\` - Task details
|
|
30
|
-
- \`ship start <ID>\` - Begin working on task
|
|
31
|
-
- \`ship done <ID>\` - Mark task complete
|
|
32
|
-
- \`ship create "title"\` - Create new task
|
|
33
|
-
|
|
34
|
-
### Best Practices
|
|
35
|
-
1. Check \`ship ready\` to see what can be worked on
|
|
36
|
-
2. Use \`ship start\` before beginning work
|
|
37
|
-
3. Use \`ship done\` when completing tasks
|
|
38
|
-
4. Check blockers before starting dependent tasks`;
|
|
2001
|
+
// Format the output in a human-readable way similar to the CLI
|
|
2002
|
+
const lines: string[] = [];
|
|
39
2003
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
2028
|
}
|
|
2029
|
+
lines.push("");
|
|
60
2030
|
}
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// On error, return undefined (let opencode use its default)
|
|
64
|
-
}
|
|
65
2031
|
|
|
66
|
-
|
|
67
|
-
|
|
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("");
|
|
68
2037
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* Runs `ship prime` and injects the output along with CLI guidance.
|
|
73
|
-
* Silently skips if ship is not installed or not initialized.
|
|
74
|
-
*/
|
|
75
|
-
async function injectShipContext(
|
|
76
|
-
client: OpencodeClient,
|
|
77
|
-
$: PluginInput["$"],
|
|
78
|
-
sessionID: string,
|
|
79
|
-
context?: { model?: { providerID: string; modelID: string }; agent?: string }
|
|
80
|
-
): Promise<void> {
|
|
81
|
-
try {
|
|
82
|
-
// Use quiet() to prevent any output from bleeding into TUI
|
|
83
|
-
const primeOutput = await $`ship prime`.quiet().text();
|
|
84
|
-
|
|
85
|
-
if (!primeOutput || primeOutput.trim() === "") {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
2038
|
+
for (const filePath of fileKeys.sort()) {
|
|
2039
|
+
const fileComments = result.commentsByFile[filePath];
|
|
2040
|
+
lines.push(`#### ${filePath}`);
|
|
88
2041
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
${
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
agent: context?.agent,
|
|
104
|
-
parts: [{ type: "text", text: shipContext, synthetic: true }],
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
} catch {
|
|
108
|
-
// Silent skip if ship prime fails (not installed or not initialized)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
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
|
+
}
|
|
111
2056
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const stdout = await new Response(result.stdout).text();
|
|
123
|
-
const stderr = await new Response(result.stderr).text();
|
|
124
|
-
|
|
125
|
-
if (result.exitCode !== 0) {
|
|
126
|
-
return { success: false, output: stderr || stdout };
|
|
127
|
-
}
|
|
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
|
+
}
|
|
128
2067
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
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
|
+
}
|
|
137
2076
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*/
|
|
142
|
-
async function isShipConfigured($: PluginInput["$"]): Promise<boolean> {
|
|
143
|
-
try {
|
|
144
|
-
// Try running ship prime - it will fail if not configured
|
|
145
|
-
// Use quiet() to suppress stdout/stderr from bleeding into TUI
|
|
146
|
-
const result = await $`ship prime`.quiet().nothrow();
|
|
147
|
-
return result.exitCode === 0;
|
|
148
|
-
} catch {
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
2077
|
+
const guidance = addGuidance("address review feedback | action=stack-submit (push updates)");
|
|
2078
|
+
return lines.join("\n").trim() + guidance;
|
|
2079
|
+
}),
|
|
152
2080
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const priority =
|
|
168
|
-
t.priority === "urgent"
|
|
169
|
-
? "[!]"
|
|
170
|
-
: t.priority === "high"
|
|
171
|
-
? "[^]"
|
|
172
|
-
: " ";
|
|
173
|
-
return `${priority} ${t.identifier.padEnd(10)} ${t.status.padEnd(12)} ${t.title}`;
|
|
174
|
-
})
|
|
175
|
-
.join("\n");
|
|
176
|
-
}
|
|
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
|
+
}),
|
|
177
2095
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}),
|
|
192
2113
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}),
|
|
197
2130
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}),
|
|
201
2150
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}),
|
|
205
2159
|
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
};
|
|
2181
|
+
|
|
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({});
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// Look up handler from the record
|
|
2200
|
+
const handler = actionHandlers[args.action];
|
|
2201
|
+
if (!handler) {
|
|
2202
|
+
return `Unknown action: ${args.action}`;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
return yield* handler(ship, args, context);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
// =============================================================================
|
|
2209
|
+
// Tool Creation
|
|
2210
|
+
// =============================================================================
|
|
208
2211
|
|
|
209
2212
|
/**
|
|
210
|
-
* Create ship tool with
|
|
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
|
|
211
2218
|
*/
|
|
212
|
-
|
|
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
|
+
);
|
|
2225
|
+
|
|
2226
|
+
const runEffect = <A, E>(effect: Effect.Effect<A, E, ShipService>): Promise<A> =>
|
|
2227
|
+
Effect.runPromise(Effect.provide(effect, ShipServiceLive));
|
|
2228
|
+
|
|
213
2229
|
return createTool({
|
|
214
|
-
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
|
|
215
2237
|
|
|
216
2238
|
Use this tool to:
|
|
217
2239
|
- List tasks ready to work on (no blockers)
|
|
@@ -220,6 +2242,9 @@ Use this tool to:
|
|
|
220
2242
|
- Create new tasks
|
|
221
2243
|
- Manage task dependencies (blocking relationships)
|
|
222
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)
|
|
223
2248
|
|
|
224
2249
|
Requires ship to be configured in the project (.ship/config.yaml).
|
|
225
2250
|
Run 'ship init' in the terminal first if not configured.`,
|
|
@@ -234,30 +2259,69 @@ Run 'ship init' in the terminal first if not configured.`,
|
|
|
234
2259
|
"start",
|
|
235
2260
|
"done",
|
|
236
2261
|
"create",
|
|
2262
|
+
"update",
|
|
237
2263
|
"block",
|
|
238
2264
|
"unblock",
|
|
239
|
-
"
|
|
2265
|
+
"relate",
|
|
240
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",
|
|
241
2295
|
])
|
|
242
2296
|
.describe(
|
|
243
|
-
"Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), block/unblock (dependencies),
|
|
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)",
|
|
244
2298
|
),
|
|
245
2299
|
taskId: createTool.schema
|
|
246
2300
|
.string()
|
|
247
2301
|
.optional()
|
|
248
|
-
.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
|
+
),
|
|
249
2305
|
title: createTool.schema
|
|
250
2306
|
.string()
|
|
251
2307
|
.optional()
|
|
252
|
-
.describe(
|
|
2308
|
+
.describe(
|
|
2309
|
+
"Title - for task create/update OR for stack-describe (first line of commit message)",
|
|
2310
|
+
),
|
|
253
2311
|
description: createTool.schema
|
|
254
2312
|
.string()
|
|
255
2313
|
.optional()
|
|
256
|
-
.describe(
|
|
2314
|
+
.describe(
|
|
2315
|
+
"Description - for task create/update OR for stack-describe (commit body after title)",
|
|
2316
|
+
),
|
|
257
2317
|
priority: createTool.schema
|
|
258
2318
|
.enum(["urgent", "high", "medium", "low", "none"])
|
|
259
2319
|
.optional()
|
|
260
|
-
.describe("Task priority - optional for create"),
|
|
2320
|
+
.describe("Task priority - optional for create/update"),
|
|
2321
|
+
status: createTool.schema
|
|
2322
|
+
.enum(["backlog", "todo", "in_progress", "in_review", "done", "cancelled"])
|
|
2323
|
+
.optional()
|
|
2324
|
+
.describe("Task status - optional for update"),
|
|
261
2325
|
blocker: createTool.schema
|
|
262
2326
|
.string()
|
|
263
2327
|
.optional()
|
|
@@ -266,6 +2330,14 @@ Run 'ship init' in the terminal first if not configured.`,
|
|
|
266
2330
|
.string()
|
|
267
2331
|
.optional()
|
|
268
2332
|
.describe("Blocked task ID - required for block/unblock"),
|
|
2333
|
+
relatedTaskId: createTool.schema
|
|
2334
|
+
.string()
|
|
2335
|
+
.optional()
|
|
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"),
|
|
269
2341
|
filter: createTool.schema
|
|
270
2342
|
.object({
|
|
271
2343
|
status: createTool.schema
|
|
@@ -276,245 +2348,336 @@ Run 'ship init' in the terminal first if not configured.`,
|
|
|
276
2348
|
})
|
|
277
2349
|
.optional()
|
|
278
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
|
+
),
|
|
279
2449
|
},
|
|
280
2450
|
|
|
281
|
-
async execute(args) {
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
287
2463
|
|
|
288
2464
|
Run 'ship init' in the terminal to:
|
|
289
2465
|
1. Authenticate with Linear (paste your API key from https://linear.app/settings/api)
|
|
290
2466
|
2. Select your team
|
|
291
2467
|
3. Optionally select a project
|
|
292
2468
|
|
|
293
|
-
After that, you can use this tool to manage tasks
|
|
294
|
-
|
|
295
|
-
|
|
2469
|
+
After that, you can use this tool to manage tasks.`);
|
|
2470
|
+
}
|
|
2471
|
+
if (error._tag === "ShipCommandError") {
|
|
2472
|
+
return Effect.succeed(`Command failed: ${error.message}`);
|
|
2473
|
+
}
|
|
2474
|
+
if (error._tag === "JsonParseError") {
|
|
2475
|
+
return Effect.succeed(`Failed to parse response: ${error.raw}`);
|
|
2476
|
+
}
|
|
2477
|
+
return Effect.succeed(`Unknown error: ${JSON.stringify(error)}`);
|
|
2478
|
+
}),
|
|
2479
|
+
),
|
|
2480
|
+
);
|
|
2481
|
+
return result;
|
|
2482
|
+
},
|
|
2483
|
+
});
|
|
2484
|
+
};
|
|
296
2485
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (!configured) {
|
|
301
|
-
return "Ship is not configured. Run 'ship init' first.";
|
|
302
|
-
}
|
|
303
|
-
return "Ship is configured in this project.";
|
|
304
|
-
}
|
|
2486
|
+
// =============================================================================
|
|
2487
|
+
// Commands
|
|
2488
|
+
// =============================================================================
|
|
305
2489
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
try {
|
|
312
|
-
const tasks = JSON.parse(result.output);
|
|
313
|
-
if (tasks.length === 0) {
|
|
314
|
-
return "No tasks ready to work on (all tasks are either blocked or completed).";
|
|
315
|
-
}
|
|
316
|
-
return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}`;
|
|
317
|
-
} catch {
|
|
318
|
-
return result.output;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
2490
|
+
const SHIP_COMMANDS = {
|
|
2491
|
+
ready: {
|
|
2492
|
+
description: "Find ready-to-work tasks with no blockers",
|
|
2493
|
+
template: `Use the \`ship\` tool with action \`ready\` to find tasks that are ready to work on (no blocking dependencies).
|
|
321
2494
|
|
|
322
|
-
|
|
323
|
-
const listArgs = ["list", "--json"];
|
|
324
|
-
if (args.filter?.status) listArgs.push("--status", args.filter.status);
|
|
325
|
-
if (args.filter?.priority) listArgs.push("--priority", args.filter.priority);
|
|
326
|
-
if (args.filter?.mine) listArgs.push("--mine");
|
|
2495
|
+
Present the results in a clear format showing task ID, title, priority, and URL.
|
|
327
2496
|
|
|
328
|
-
|
|
329
|
-
if (!result.success) {
|
|
330
|
-
return `Failed to list tasks: ${result.output}`;
|
|
331
|
-
}
|
|
332
|
-
try {
|
|
333
|
-
const tasks = JSON.parse(result.output);
|
|
334
|
-
if (tasks.length === 0) {
|
|
335
|
-
return "No tasks found matching the filter.";
|
|
336
|
-
}
|
|
337
|
-
return `Tasks:\n\n${formatTaskList(tasks)}`;
|
|
338
|
-
} catch {
|
|
339
|
-
return result.output;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
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.
|
|
342
2498
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
return `Failed to get blocked tasks: ${result.output}`;
|
|
347
|
-
}
|
|
348
|
-
try {
|
|
349
|
-
const tasks = JSON.parse(result.output);
|
|
350
|
-
if (tasks.length === 0) {
|
|
351
|
-
return "No blocked tasks.";
|
|
352
|
-
}
|
|
353
|
-
return `Blocked tasks:\n\n${formatTaskList(tasks)}`;
|
|
354
|
-
} catch {
|
|
355
|
-
return result.output;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
2499
|
+
If there are no ready tasks, suggest checking blocked tasks (action \`blocked\`) or creating a new task (action \`create\`).`,
|
|
2500
|
+
},
|
|
2501
|
+
};
|
|
358
2502
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
const result = await runShip($, ["show", args.taskId, "--json"]);
|
|
364
|
-
if (!result.success) {
|
|
365
|
-
return `Failed to get task: ${result.output}`;
|
|
366
|
-
}
|
|
367
|
-
try {
|
|
368
|
-
const task = JSON.parse(result.output);
|
|
369
|
-
return formatTaskDetails(task);
|
|
370
|
-
} catch {
|
|
371
|
-
return result.output;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
2503
|
+
// =============================================================================
|
|
2504
|
+
// Compaction Context Hooks
|
|
2505
|
+
// =============================================================================
|
|
374
2506
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
);
|
|
385
2523
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return "Error: taskId is required for done action";
|
|
389
|
-
}
|
|
390
|
-
const result = await runShip($, ["done", args.taskId]);
|
|
391
|
-
if (!result.success) {
|
|
392
|
-
return `Failed to complete task: ${result.output}`;
|
|
393
|
-
}
|
|
394
|
-
return `Completed ${args.taskId}`;
|
|
395
|
-
}
|
|
2524
|
+
return async (input, output) => {
|
|
2525
|
+
const { sessionID } = input;
|
|
396
2526
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
return "Error: title is required for create action";
|
|
400
|
-
}
|
|
401
|
-
const createArgs = ["create", args.title, "--json"];
|
|
402
|
-
if (args.description) createArgs.push("--description", args.description);
|
|
403
|
-
if (args.priority) createArgs.push("--priority", args.priority);
|
|
2527
|
+
// Try to get the tracked task for this session
|
|
2528
|
+
const trackedTask = getTrackedTask(sessionID);
|
|
404
2529
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
const task = JSON.parse(result.output);
|
|
411
|
-
return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
|
|
412
|
-
} catch {
|
|
413
|
-
return result.output;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
2530
|
+
if (Option.isNone(trackedTask)) {
|
|
2531
|
+
// No task tracked for this session, nothing to preserve
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
416
2534
|
|
|
417
|
-
|
|
418
|
-
if (!args.blocker || !args.blocked) {
|
|
419
|
-
return "Error: both blocker and blocked task IDs are required";
|
|
420
|
-
}
|
|
421
|
-
const result = await runShip($, ["block", args.blocker, args.blocked]);
|
|
422
|
-
if (!result.success) {
|
|
423
|
-
return `Failed to add blocker: ${result.output}`;
|
|
424
|
-
}
|
|
425
|
-
return `${args.blocker} now blocks ${args.blocked}`;
|
|
426
|
-
}
|
|
2535
|
+
const { taskId, workdir } = trackedTask.value;
|
|
427
2536
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
);
|
|
438
2553
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
2568
|
|
|
447
|
-
|
|
448
|
-
|
|
2569
|
+
if (workdir) {
|
|
2570
|
+
contextParts.push(`**Workspace:** ${workdir}`);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
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(", ")}`);
|
|
449
2581
|
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
2582
|
+
}
|
|
2583
|
+
|
|
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
|
+
);
|
|
2590
|
+
|
|
2591
|
+
// Add to output context
|
|
2592
|
+
output.context.push(contextParts.join("\n"));
|
|
2593
|
+
};
|
|
2594
|
+
};
|
|
2595
|
+
|
|
2596
|
+
// =============================================================================
|
|
2597
|
+
// Tool Execute Hook
|
|
2598
|
+
// =============================================================================
|
|
453
2599
|
|
|
454
2600
|
/**
|
|
455
|
-
*
|
|
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.
|
|
456
2608
|
*/
|
|
457
|
-
|
|
458
|
-
|
|
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
|
+
}
|
|
459
2615
|
|
|
460
|
-
|
|
461
|
-
const shipTool = createShipTool($);
|
|
2616
|
+
const { sessionID } = input;
|
|
462
2617
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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;
|
|
466
2624
|
|
|
467
|
-
|
|
468
|
-
|
|
2625
|
+
// Track task starts
|
|
2626
|
+
if (args.action === "start" && args.taskId) {
|
|
2627
|
+
trackTask(sessionID, { taskId: args.taskId });
|
|
2628
|
+
}
|
|
469
2629
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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];
|
|
475
2635
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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 });
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
485
2645
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
} catch {
|
|
492
|
-
// On error, proceed with injection
|
|
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);
|
|
493
2651
|
}
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
};
|
|
494
2655
|
|
|
495
|
-
|
|
2656
|
+
// =============================================================================
|
|
2657
|
+
// Plugin Export
|
|
2658
|
+
// =============================================================================
|
|
496
2659
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
});
|
|
502
|
-
},
|
|
2660
|
+
// Extended PluginInput type to include serverUrl (available in OpenCode 1.0.144+)
|
|
2661
|
+
type ExtendedPluginInput = Parameters<Plugin>[0] & {
|
|
2662
|
+
serverUrl?: URL;
|
|
2663
|
+
};
|
|
503
2664
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
},
|
|
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();
|
|
511
2670
|
|
|
512
|
-
|
|
2671
|
+
return {
|
|
2672
|
+
config: async (config: Parameters<NonNullable<Awaited<ReturnType<Plugin>>["config"]>>[0]) => {
|
|
2673
|
+
config.command = { ...config.command, ...SHIP_COMMANDS };
|
|
2674
|
+
},
|
|
513
2675
|
tool: {
|
|
514
|
-
ship:
|
|
2676
|
+
ship: createShipTool($, directory, serverUrlString),
|
|
515
2677
|
},
|
|
2678
|
+
"tool.execute.after": createToolExecuteAfterHook(),
|
|
2679
|
+
"experimental.session.compacting": createCompactionHook(shellService),
|
|
516
2680
|
};
|
|
517
2681
|
};
|
|
518
2682
|
|
|
519
|
-
// Default export for OpenCode
|
|
520
2683
|
export default ShipPlugin;
|