@aduverger/pi-ship 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,788 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, resolve } from "node:path";
6
+ import { collectChangedFiles, dirtyPaths, discoverRepositoryPaths, git, hasChangesAgainstBase, isClean, parseGitHubRepository, requireGit, } from "./git.js";
7
+ import { withRelatedPullRequests } from "./related-prs.js";
8
+ import { formatFullReview, formatReviewSummary, REVIEW_ENTRY_TYPE, reviewHeadline, } from "./review-display.js";
9
+ import { collectReviewerPriorDecisions, runWorkspaceReviewer } from "./reviewer.js";
10
+ import { buildWorkspaceSimplificationPrompt } from "./simplify.js";
11
+ const STATE_ENTRY = "pi-ship-state";
12
+ const ACTIVE_STAGES = new Set([
13
+ "preflight",
14
+ "rebasing",
15
+ "resolving-conflicts",
16
+ "simplifying",
17
+ "reviewing",
18
+ "awaiting-decision",
19
+ "fixing",
20
+ "drafting",
21
+ "publishing",
22
+ ]);
23
+ function errorMessage(error) {
24
+ return error instanceof Error ? error.message : String(error);
25
+ }
26
+ function changedRepositories(run) {
27
+ return run.repositories.filter((repository) => repository.changed);
28
+ }
29
+ function testsMarkdown(tests) {
30
+ return tests
31
+ .map((test) => `- \`${test.command}\` — ${test.status}${test.summary ? `: ${test.summary}` : ""}`)
32
+ .join("\n");
33
+ }
34
+ function storedReviews(run) {
35
+ return [...(run.reviewHistory ?? []), ...(run.review ? [run.review] : [])];
36
+ }
37
+ function isStoredRun(value) {
38
+ if (!value || typeof value !== "object")
39
+ return false;
40
+ const run = value;
41
+ return run.version === 1 && typeof run.id === "string" && Array.isArray(run.repositories);
42
+ }
43
+ export class ShipWorkflow {
44
+ pi;
45
+ reviewer;
46
+ run;
47
+ runCommand;
48
+ constructor(pi, reviewer = runWorkspaceReviewer) {
49
+ this.pi = pi;
50
+ this.reviewer = reviewer;
51
+ this.runCommand = (command, args, options) => this.pi.exec(command, args, options);
52
+ }
53
+ restore(ctx) {
54
+ this.run = undefined;
55
+ const branch = ctx.sessionManager.getBranch();
56
+ for (let index = branch.length - 1; index >= 0; index--) {
57
+ const entry = branch[index];
58
+ if (entry?.type === "custom" && entry.customType === STATE_ENTRY && isStoredRun(entry.data)) {
59
+ this.run = structuredClone(entry.data);
60
+ break;
61
+ }
62
+ }
63
+ this.updateStatus(ctx);
64
+ }
65
+ guidance() {
66
+ if (!this.run || !ACTIVE_STAGES.has(this.run.stage))
67
+ return undefined;
68
+ switch (this.run.stage) {
69
+ case "awaiting-decision":
70
+ return "pi-ship is awaiting the user's review decision. Analyze their latest message against every finding, then call ship_report with action decision. Do not edit files before that tool accepts the decision.";
71
+ case "fixing":
72
+ return "pi-ship is applying approved review fixes. Make only the approved changes, run relevant tests in every changed repository, do not commit, then call ship_report with action fixes-complete.";
73
+ case "simplifying":
74
+ return "pi-ship is simplifying rebased changes. Follow the displayed changed-line scope, do not commit, then call ship_report with action simplification-complete.";
75
+ case "resolving-conflicts":
76
+ return "pi-ship is resolving a rebase conflict. Resolve and stage every unmerged path in the named repository, do not create a commit manually, then call ship_report with action conflict-resolved.";
77
+ case "drafting":
78
+ return "pi-ship is ready to publish. Prepare one GitHub PR title and body per changed repository, then call ship_report with action publish. Do not ask for another confirmation.";
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+ async start(args, ctx) {
84
+ await ctx.waitForIdle();
85
+ if (this.run && ACTIVE_STAGES.has(this.run.stage)) {
86
+ throw new Error(`A /ship run is already ${this.run.stage}. Use /ship status, /ship resume, or /ship abort.`);
87
+ }
88
+ const requested = args.trim() ? args.trim().split(/\s+/) : [];
89
+ const discovered = await discoverRepositoryPaths(this.runCommand, ctx.cwd, requested);
90
+ const dirty = [];
91
+ for (const path of discovered.repositories) {
92
+ if (!(await isClean(this.runCommand, path)))
93
+ dirty.push(basename(path));
94
+ }
95
+ if (dirty.length > 0) {
96
+ throw new Error(`Every selected repository must be clean and committed. Dirty: ${dirty.join(", ")}`);
97
+ }
98
+ const ghAuth = await this.pi.exec("gh", ["auth", "status", "--hostname", "github.com"], {
99
+ cwd: discovered.root,
100
+ timeout: 30_000,
101
+ });
102
+ if (ghAuth.code !== 0)
103
+ throw new Error(ghAuth.stderr.trim() || "GitHub CLI authentication failed.");
104
+ for (const path of discovered.repositories) {
105
+ const fetch = await git(this.runCommand, path, ["fetch", "--prune", "origin"]);
106
+ if (fetch.code !== 0)
107
+ throw new Error(`git fetch failed in ${basename(path)}: ${fetch.stderr.trim()}`);
108
+ }
109
+ const repositories = [];
110
+ for (const path of discovered.repositories)
111
+ repositories.push(await this.inspectRepository(path));
112
+ this.run = {
113
+ version: 1,
114
+ id: randomUUID(),
115
+ root: discovered.root,
116
+ stage: "rebasing",
117
+ createdAt: Date.now(),
118
+ updatedAt: Date.now(),
119
+ repositories,
120
+ rebaseIndex: 0,
121
+ };
122
+ this.persist(ctx);
123
+ const prompt = await this.continueRebases(ctx);
124
+ if (prompt)
125
+ this.sendPrompt(prompt);
126
+ }
127
+ status(ctx) {
128
+ if (!this.run)
129
+ return "No /ship run is recorded in this session.";
130
+ const lines = [`Ship ${this.run.id.slice(0, 8)}: ${this.run.stage}`];
131
+ for (const repository of this.run.repositories) {
132
+ const labels = [repository.changed ? "changed" : "context"];
133
+ if (repository.reviewedHead === repository.head)
134
+ labels.push("reviewed");
135
+ if (repository.pushed)
136
+ labels.push("pushed");
137
+ if (repository.pullRequestUrl)
138
+ labels.push(repository.pullRequestUrl);
139
+ lines.push(`- ${repository.name}: ${repository.branch} -> ${repository.baseBranch} (${labels.join(", ")})`);
140
+ }
141
+ if (this.run.lastError)
142
+ lines.push(`Last error: ${this.run.lastError}`);
143
+ const reviews = storedReviews(this.run);
144
+ if (reviews.length > 0) {
145
+ lines.push("", "Reviews:", ...reviews.flatMap((review) => formatReviewSummary(review).split("\n")));
146
+ }
147
+ this.updateStatus(ctx);
148
+ return lines.join("\n");
149
+ }
150
+ async resume(ctx) {
151
+ await ctx.waitForIdle();
152
+ if (!this.run)
153
+ throw new Error("No /ship run to resume.");
154
+ let prompt;
155
+ switch (this.run.stage) {
156
+ case "rebasing":
157
+ prompt = await this.continueRebases(ctx);
158
+ break;
159
+ case "resolving-conflicts":
160
+ prompt = await this.conflictPrompt();
161
+ break;
162
+ case "simplifying":
163
+ prompt = buildWorkspaceSimplificationPrompt(this.run.repositories);
164
+ break;
165
+ case "reviewing":
166
+ prompt = (await this.performReview(ctx)).content[0]?.text;
167
+ break;
168
+ case "awaiting-decision":
169
+ prompt = `${this.formatReview()}\n\nAnalyze these findings, recommend a disposition for each, and wait for the user's explicit decision.`;
170
+ break;
171
+ case "fixing":
172
+ prompt = this.buildFixPrompt();
173
+ break;
174
+ case "drafting":
175
+ prompt = this.buildDraftPrompt();
176
+ break;
177
+ case "publishing":
178
+ if (!this.run.drafts)
179
+ throw new Error("Publication drafts are missing.");
180
+ prompt = (await this.publish(ctx, this.run.drafts)).content[0]?.text;
181
+ break;
182
+ case "complete":
183
+ case "aborted":
184
+ ctx.ui.notify(`Ship run is ${this.run.stage}.`, "info");
185
+ return;
186
+ case "preflight":
187
+ throw new Error("Preflight did not finish; start a new /ship run.");
188
+ }
189
+ if (prompt)
190
+ this.sendPrompt(prompt);
191
+ }
192
+ async abort(ctx) {
193
+ if (!this.run || !ACTIVE_STAGES.has(this.run.stage)) {
194
+ ctx.ui.notify("No active /ship run.", "info");
195
+ return;
196
+ }
197
+ for (const repository of this.run.repositories) {
198
+ if (await this.rebaseInProgress(repository.path)) {
199
+ await git(this.runCommand, repository.path, ["rebase", "--abort"]);
200
+ }
201
+ }
202
+ this.run.stage = "aborted";
203
+ delete this.run.conflictRepository;
204
+ this.persist(ctx);
205
+ ctx.ui.notify("Ship run aborted. Existing completed rebases and commits were preserved.", "warning");
206
+ }
207
+ recordError(ctx, error) {
208
+ if (!this.run)
209
+ return;
210
+ this.run.lastError = errorMessage(error);
211
+ this.persist(ctx);
212
+ }
213
+ async handleReport(input, ctx, signal, onProgress) {
214
+ if (!this.run)
215
+ throw new Error("No active /ship run.");
216
+ delete this.run.lastError;
217
+ switch (input.action) {
218
+ case "conflict-resolved":
219
+ return this.handleConflictResolved(ctx);
220
+ case "simplification-complete":
221
+ return this.handleSimplificationComplete(input, ctx, signal, onProgress);
222
+ case "decision":
223
+ return this.handleDecision(input, ctx);
224
+ case "fixes-complete":
225
+ return this.handleFixesComplete(input, ctx, signal, onProgress);
226
+ case "publish":
227
+ if (!input.drafts)
228
+ throw new Error("publish requires drafts.");
229
+ return this.publish(ctx, input.drafts);
230
+ }
231
+ }
232
+ async inspectRepository(path) {
233
+ const branch = await requireGit(this.runCommand, path, ["symbolic-ref", "--quiet", "--short", "HEAD"], "read branch");
234
+ const origin = await requireGit(this.runCommand, path, ["remote", "get-url", "origin"], "read origin");
235
+ const githubRepository = parseGitHubRepository(origin);
236
+ const baseBranch = await this.resolveBaseBranch(path, githubRepository);
237
+ const baseRef = `refs/remotes/origin/${baseBranch}`;
238
+ const initialHead = await requireGit(this.runCommand, path, ["rev-parse", "HEAD"]);
239
+ const baseSha = await requireGit(this.runCommand, path, ["rev-parse", baseRef]);
240
+ const remoteBranch = await git(this.runCommand, path, ["rev-parse", "--verify", `refs/remotes/origin/${branch}`]);
241
+ const changed = await hasChangesAgainstBase(this.runCommand, path, baseRef);
242
+ if (branch === baseBranch) {
243
+ throw new Error(`${basename(path)} is checked out on its default branch ${baseBranch}; every selected repository must use a feature branch.`);
244
+ }
245
+ return {
246
+ name: basename(path),
247
+ path,
248
+ githubRepository,
249
+ branch,
250
+ baseBranch,
251
+ baseRef,
252
+ initialHead,
253
+ head: initialHead,
254
+ baseSha,
255
+ ...(remoteBranch.code === 0 ? { remoteBranchSha: remoteBranch.stdout.trim() } : {}),
256
+ changed,
257
+ simplifyScope: [],
258
+ tests: [],
259
+ pushed: false,
260
+ };
261
+ }
262
+ async resolveBaseBranch(path, githubRepository) {
263
+ const symbolic = await git(this.runCommand, path, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
264
+ if (symbolic.code === 0 && symbolic.stdout.trim().startsWith("origin/")) {
265
+ return symbolic.stdout.trim().slice("origin/".length);
266
+ }
267
+ const gh = await this.pi.exec("gh", ["repo", "view", githubRepository, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], { cwd: path, timeout: 30_000 });
268
+ if (gh.code === 0 && gh.stdout.trim())
269
+ return gh.stdout.trim();
270
+ for (const fallback of ["main", "master"]) {
271
+ const result = await git(this.runCommand, path, ["rev-parse", "--verify", `refs/remotes/origin/${fallback}`]);
272
+ if (result.code === 0)
273
+ return fallback;
274
+ }
275
+ throw new Error(`Could not resolve the default branch for ${githubRepository}.`);
276
+ }
277
+ async continueRebases(ctx) {
278
+ if (!this.run)
279
+ throw new Error("No active run.");
280
+ this.run.stage = "rebasing";
281
+ delete this.run.conflictRepository;
282
+ this.persist(ctx);
283
+ for (; this.run.rebaseIndex < this.run.repositories.length; this.run.rebaseIndex++) {
284
+ const repository = this.run.repositories[this.run.rebaseIndex];
285
+ if (!repository?.changed)
286
+ continue;
287
+ const result = await git(this.runCommand, repository.path, ["rebase", repository.baseRef]);
288
+ if (result.code !== 0) {
289
+ const conflicts = await this.unmergedPaths(repository.path);
290
+ if (conflicts.length === 0) {
291
+ throw new Error(`Rebase failed in ${repository.name}: ${result.stderr.trim() || result.stdout.trim()}`);
292
+ }
293
+ this.run.stage = "resolving-conflicts";
294
+ this.run.conflictRepository = repository.name;
295
+ this.persist(ctx);
296
+ return this.conflictPrompt(conflicts);
297
+ }
298
+ await this.refreshRepository(repository);
299
+ }
300
+ for (const repository of this.run.repositories) {
301
+ await this.refreshRepository(repository);
302
+ repository.simplifyScope = repository.changed
303
+ ? await collectChangedFiles(this.runCommand, repository.path, repository.baseRef)
304
+ : [];
305
+ }
306
+ if (changedRepositories(this.run).length === 0) {
307
+ this.run.stage = "complete";
308
+ this.persist(ctx);
309
+ return "No selected repository has a committed change against its default branch. Nothing was pushed and no pull request was created.";
310
+ }
311
+ this.run.stage = "simplifying";
312
+ this.persist(ctx);
313
+ return buildWorkspaceSimplificationPrompt(this.run.repositories);
314
+ }
315
+ async handleConflictResolved(ctx) {
316
+ if (!this.run || this.run.stage !== "resolving-conflicts" || !this.run.conflictRepository) {
317
+ throw new Error("pi-ship is not waiting for a conflict resolution.");
318
+ }
319
+ const repository = this.repository(this.run.conflictRepository);
320
+ const conflicts = await this.unmergedPaths(repository.path);
321
+ if (conflicts.length > 0)
322
+ throw new Error(`Unmerged paths remain in ${repository.name}: ${conflicts.join(", ")}`);
323
+ if (await this.rebaseInProgress(repository.path)) {
324
+ const result = await git(this.runCommand, repository.path, ["-c", "core.editor=true", "rebase", "--continue"]);
325
+ if (result.code !== 0) {
326
+ const nextConflicts = await this.unmergedPaths(repository.path);
327
+ if (nextConflicts.length > 0) {
328
+ this.persist(ctx);
329
+ return this.result(await this.conflictPrompt(nextConflicts));
330
+ }
331
+ throw new Error(`Could not continue rebase in ${repository.name}: ${result.stderr.trim()}`);
332
+ }
333
+ }
334
+ await this.refreshRepository(repository);
335
+ this.run.rebaseIndex += 1;
336
+ const prompt = await this.continueRebases(ctx);
337
+ return this.result(prompt ?? "Rebase completed.");
338
+ }
339
+ async handleSimplificationComplete(input, ctx, signal, onProgress) {
340
+ if (!this.run || this.run.stage !== "simplifying")
341
+ throw new Error("pi-ship is not in simplification stage.");
342
+ if (!input.intent?.trim())
343
+ throw new Error("simplification-complete requires the workspace intent.");
344
+ const reports = this.validateReports(input.repositories, changedRepositories(this.run).map((repository) => repository.name));
345
+ for (const repository of changedRepositories(this.run)) {
346
+ const currentHead = await requireGit(this.runCommand, repository.path, ["rev-parse", "HEAD"]);
347
+ if (currentHead !== repository.head)
348
+ throw new Error(`${repository.name} was committed during simplification; pi-ship owns commits.`);
349
+ const allowed = new Set(repository.simplifyScope.map((file) => file.path));
350
+ const outOfScope = (await dirtyPaths(this.runCommand, repository.path)).filter((path) => !allowed.has(path));
351
+ if (outOfScope.length > 0) {
352
+ throw new Error(`Out-of-scope simplification edits in ${repository.name}: ${outOfScope.join(", ")}`);
353
+ }
354
+ }
355
+ this.run.intent = input.intent.trim();
356
+ for (const repository of changedRepositories(this.run)) {
357
+ const report = reports.get(repository.name);
358
+ if (!report)
359
+ continue;
360
+ repository.summary = report.summary;
361
+ repository.tests = report.tests;
362
+ await this.commitIfDirty(repository, "refactor: simplify branch changes");
363
+ await this.refreshRepository(repository);
364
+ }
365
+ this.persist(ctx);
366
+ return this.performReview(ctx, signal, onProgress);
367
+ }
368
+ async performReview(ctx, signal, onProgress) {
369
+ if (!this.run || !this.run.intent)
370
+ throw new Error("Cannot review without an active run and intent.");
371
+ for (const repository of this.run.repositories) {
372
+ if (!(await isClean(this.runCommand, repository.path)))
373
+ throw new Error(`${repository.name} is dirty before review.`);
374
+ await this.refreshRepository(repository);
375
+ }
376
+ this.run.stage = "reviewing";
377
+ this.persist(ctx);
378
+ onProgress?.("Starting independent workspace review");
379
+ const review = await this.reviewer(ctx, {
380
+ root: this.run.root,
381
+ intent: this.run.intent,
382
+ repositories: this.run.repositories.map((repository) => ({
383
+ name: repository.name,
384
+ path: repository.path,
385
+ baseRef: repository.baseRef,
386
+ baseBranch: repository.baseBranch,
387
+ branch: repository.branch,
388
+ changed: repository.changed,
389
+ })),
390
+ priorDecisions: collectReviewerPriorDecisions(storedReviews(this.run)),
391
+ }, signal, onProgress);
392
+ const ids = new Set();
393
+ for (const finding of review.findings) {
394
+ if (ids.has(finding.id))
395
+ throw new Error(`Reviewer returned duplicate finding id ${finding.id}.`);
396
+ ids.add(finding.id);
397
+ this.repository(finding.repository);
398
+ for (const relatedRepository of finding.relatedRepositories)
399
+ this.repository(relatedRepository);
400
+ }
401
+ for (const repository of changedRepositories(this.run)) {
402
+ if (!(await isClean(this.runCommand, repository.path)))
403
+ throw new Error(`Reviewer modified ${repository.name}.`);
404
+ const head = await requireGit(this.runCommand, repository.path, ["rev-parse", "HEAD"]);
405
+ if (head !== repository.head)
406
+ throw new Error(`HEAD changed during review in ${repository.name}.`);
407
+ repository.reviewedHead = head;
408
+ repository.baseShaAtReview = await requireGit(this.runCommand, repository.path, ["rev-parse", repository.baseRef]);
409
+ }
410
+ const previousRound = this.run.review?.round ?? this.run.reviewHistory?.at(-1)?.round ?? 0;
411
+ this.archiveCurrentReview();
412
+ this.run.review = {
413
+ round: previousRound + 1,
414
+ completedAt: Date.now(),
415
+ result: review,
416
+ };
417
+ this.run.stage = review.findings.length > 0 ? "awaiting-decision" : "drafting";
418
+ this.persist(ctx);
419
+ this.appendReviewEntry();
420
+ if (review.findings.length > 0) {
421
+ return this.result(`${this.formatReview()}\n\nAnalyze every finding against the code and intent. Present your recommended disposition—fix, accept, or defer—and then stop. Do not call ship_report with action decision until the user explicitly responds.`);
422
+ }
423
+ return this.result(`${this.formatReview()}\n\n${this.buildDraftPrompt()}`);
424
+ }
425
+ handleDecision(input, ctx) {
426
+ if (!this.run || this.run.stage !== "awaiting-decision" || !this.run.review) {
427
+ throw new Error("pi-ship is not awaiting a review decision.");
428
+ }
429
+ if (!this.hasUserMessageAfterReview(ctx)) {
430
+ throw new Error("The user has not responded to the review yet. Present the plan and wait for their decision.");
431
+ }
432
+ const decisions = input.decisions ?? [];
433
+ const byId = new Map(decisions.map((decision) => [decision.findingId, decision]));
434
+ const findingIds = new Set(this.run.review.result.findings.map((finding) => finding.id));
435
+ if (byId.size !== decisions.length)
436
+ throw new Error("Finding decisions contain duplicate ids.");
437
+ for (const findingId of findingIds) {
438
+ if (!byId.has(findingId))
439
+ throw new Error(`Missing decision for finding ${findingId}.`);
440
+ }
441
+ for (const findingId of byId.keys()) {
442
+ if (!findingIds.has(findingId))
443
+ throw new Error(`Unknown finding decision ${findingId}.`);
444
+ }
445
+ this.run.review.decisions = decisions;
446
+ if (decisions.some((decision) => decision.action === "fix")) {
447
+ this.run.stage = "fixing";
448
+ this.persist(ctx);
449
+ this.appendReviewEntry();
450
+ return this.result(this.buildFixPrompt());
451
+ }
452
+ this.run.stage = "drafting";
453
+ this.persist(ctx);
454
+ this.appendReviewEntry();
455
+ return this.result(this.buildDraftPrompt());
456
+ }
457
+ async handleFixesComplete(input, ctx, signal, onProgress) {
458
+ if (!this.run || this.run.stage !== "fixing" || !this.run.review?.decisions) {
459
+ throw new Error("pi-ship is not in the review-fix stage.");
460
+ }
461
+ const dirtyRepositories = [];
462
+ for (const repository of this.run.repositories) {
463
+ const head = await requireGit(this.runCommand, repository.path, ["rev-parse", "HEAD"]);
464
+ if (repository.changed && head !== repository.reviewedHead) {
465
+ throw new Error(`${repository.name} was committed during review fixes; pi-ship owns commits.`);
466
+ }
467
+ if (!(await isClean(this.runCommand, repository.path)))
468
+ dirtyRepositories.push(repository);
469
+ }
470
+ if (dirtyRepositories.length === 0)
471
+ throw new Error("No review-fix changes were made.");
472
+ const requiredReports = [...new Set([...changedRepositories(this.run), ...dirtyRepositories].map((repository) => repository.name))];
473
+ const reports = this.validateReports(input.repositories, requiredReports);
474
+ for (const repository of this.run.repositories) {
475
+ const report = reports.get(repository.name);
476
+ if (!report)
477
+ continue;
478
+ repository.summary = [repository.summary, `Review fixes: ${report.summary}`].filter(Boolean).join("\n");
479
+ repository.tests = report.tests;
480
+ }
481
+ for (const repository of dirtyRepositories) {
482
+ await this.commitIfDirty(repository, "fix: address independent review findings");
483
+ }
484
+ for (const repository of this.run.repositories)
485
+ await this.refreshRepository(repository);
486
+ this.persist(ctx);
487
+ return this.performReview(ctx, signal, onProgress);
488
+ }
489
+ async publish(ctx, drafts) {
490
+ if (!this.run || (this.run.stage !== "drafting" && this.run.stage !== "publishing")) {
491
+ throw new Error("pi-ship is not ready to publish.");
492
+ }
493
+ const changed = changedRepositories(this.run);
494
+ const byRepository = new Map(drafts.map((draft) => [draft.repository, draft]));
495
+ if (byRepository.size !== drafts.length)
496
+ throw new Error("PR drafts contain duplicate repositories.");
497
+ for (const repository of changed) {
498
+ const draft = byRepository.get(repository.name);
499
+ if (!draft?.title.trim() || !draft.body.trim())
500
+ throw new Error(`Missing complete PR draft for ${repository.name}.`);
501
+ }
502
+ for (const name of byRepository.keys()) {
503
+ if (!this.repository(name).changed)
504
+ throw new Error(`PR draft supplied for unchanged repository ${name}.`);
505
+ }
506
+ this.run.drafts = [...drafts];
507
+ this.run.stage = "publishing";
508
+ this.persist(ctx);
509
+ let baseAdvanced = false;
510
+ for (const repository of changed) {
511
+ const fetch = await git(this.runCommand, repository.path, ["fetch", "--prune", "origin"]);
512
+ if (fetch.code !== 0)
513
+ throw new Error(`git fetch failed in ${repository.name}: ${fetch.stderr.trim()}`);
514
+ const baseSha = await requireGit(this.runCommand, repository.path, ["rev-parse", repository.baseRef]);
515
+ if (baseSha !== repository.baseShaAtReview)
516
+ baseAdvanced = true;
517
+ }
518
+ if (baseAdvanced) {
519
+ this.run.stage = "rebasing";
520
+ this.run.rebaseIndex = 0;
521
+ for (const repository of changed)
522
+ repository.pushed = false;
523
+ this.archiveCurrentReview();
524
+ delete this.run.review;
525
+ delete this.run.drafts;
526
+ this.persist(ctx);
527
+ const prompt = await this.continueRebases(ctx);
528
+ return this.result(`A default branch advanced after review. Publication was deferred.\n\n${prompt ?? "Rebase completed."}`);
529
+ }
530
+ for (const repository of changed) {
531
+ if (!(await isClean(this.runCommand, repository.path)))
532
+ throw new Error(`${repository.name} is dirty before push.`);
533
+ const head = await requireGit(this.runCommand, repository.path, ["rev-parse", "HEAD"]);
534
+ if (head !== repository.reviewedHead)
535
+ throw new Error(`${repository.name} HEAD no longer matches its reviewed SHA.`);
536
+ if (!repository.pushed)
537
+ await this.pushRepository(repository);
538
+ this.persist(ctx);
539
+ }
540
+ for (const repository of changed) {
541
+ if (repository.pullRequestUrl)
542
+ continue;
543
+ const draft = byRepository.get(repository.name);
544
+ if (!draft)
545
+ continue;
546
+ repository.pullRequestUrl = await this.upsertPullRequest(repository, draft.title, draft.body);
547
+ this.persist(ctx);
548
+ }
549
+ const links = new Map(changed.flatMap((repository) => repository.pullRequestUrl ? [[repository.name, repository.pullRequestUrl]] : []));
550
+ for (const repository of changed) {
551
+ const draft = byRepository.get(repository.name);
552
+ if (!draft || !repository.pullRequestUrl)
553
+ continue;
554
+ const body = withRelatedPullRequests(draft.body, links, repository.name);
555
+ await this.editPullRequest(repository, draft.title, body);
556
+ }
557
+ this.run.stage = "complete";
558
+ this.persist(ctx);
559
+ const summary = changed.map((repository) => `- ${repository.name}: ${repository.pullRequestUrl}`).join("\n");
560
+ return this.result(`Ship complete.\n\n${summary}`);
561
+ }
562
+ async pushRepository(repository) {
563
+ const destination = `HEAD:refs/heads/${repository.branch}`;
564
+ const args = repository.remoteBranchSha
565
+ ? [
566
+ "push",
567
+ `--force-with-lease=refs/heads/${repository.branch}:${repository.remoteBranchSha}`,
568
+ "origin",
569
+ destination,
570
+ ]
571
+ : ["push", "-u", "origin", destination];
572
+ const result = await git(this.runCommand, repository.path, args);
573
+ if (result.code !== 0)
574
+ throw new Error(`Push failed in ${repository.name}: ${result.stderr.trim()}`);
575
+ repository.remoteBranchSha = repository.head;
576
+ repository.pushed = true;
577
+ }
578
+ async upsertPullRequest(repository, title, body) {
579
+ const existing = await this.pi.exec("gh", ["pr", "list", "--repo", repository.githubRepository, "--head", repository.branch, "--state", "open", "--json", "number,url"], { cwd: repository.path, timeout: 30_000 });
580
+ if (existing.code !== 0)
581
+ throw new Error(`Could not list PRs for ${repository.name}: ${existing.stderr.trim()}`);
582
+ const pullRequests = JSON.parse(existing.stdout);
583
+ if (pullRequests[0]) {
584
+ await this.editPullRequest(repository, title, body, pullRequests[0].number);
585
+ return pullRequests[0].url;
586
+ }
587
+ return this.withBodyFile(body, async (bodyPath) => {
588
+ const created = await this.pi.exec("gh", [
589
+ "pr",
590
+ "create",
591
+ "--repo",
592
+ repository.githubRepository,
593
+ "--base",
594
+ repository.baseBranch,
595
+ "--head",
596
+ repository.branch,
597
+ "--title",
598
+ title,
599
+ "--body-file",
600
+ bodyPath,
601
+ ], { cwd: repository.path, timeout: 60_000 });
602
+ if (created.code !== 0)
603
+ throw new Error(`Could not create PR for ${repository.name}: ${created.stderr.trim()}`);
604
+ return created.stdout.trim();
605
+ });
606
+ }
607
+ async editPullRequest(repository, title, body, knownNumber) {
608
+ const selector = knownNumber ? String(knownNumber) : repository.pullRequestUrl;
609
+ if (!selector)
610
+ throw new Error(`No PR selector for ${repository.name}.`);
611
+ await this.withBodyFile(body, async (bodyPath) => {
612
+ const edited = await this.pi.exec("gh", ["pr", "edit", selector, "--repo", repository.githubRepository, "--title", title, "--body-file", bodyPath], { cwd: repository.path, timeout: 60_000 });
613
+ if (edited.code !== 0)
614
+ throw new Error(`Could not update PR for ${repository.name}: ${edited.stderr.trim()}`);
615
+ });
616
+ }
617
+ async withBodyFile(body, callback) {
618
+ const directory = await mkdtemp(resolve(tmpdir(), "pi-ship-pr-"));
619
+ const path = resolve(directory, "body.md");
620
+ try {
621
+ await writeFile(path, body, { encoding: "utf8", mode: 0o600 });
622
+ return await callback(path);
623
+ }
624
+ finally {
625
+ await rm(directory, { recursive: true, force: true });
626
+ }
627
+ }
628
+ buildFixPrompt() {
629
+ if (!this.run?.review?.decisions)
630
+ throw new Error("No approved decisions are available.");
631
+ const findings = new Map(this.run.review.result.findings.map((finding) => [finding.id, finding]));
632
+ const fixes = this.run.review.decisions
633
+ .filter((decision) => decision.action === "fix")
634
+ .map((decision) => {
635
+ const finding = findings.get(decision.findingId);
636
+ return `- ${decision.findingId} (${finding?.repository ?? "unknown"}): ${finding?.recommendation ?? ""}\n User guidance: ${decision.rationale}`;
637
+ })
638
+ .join("\n");
639
+ return `Apply only these user-approved review fixes across the selected workspace:\n\n${fixes}\n\nRead surrounding and cross-repository code as needed. Do not commit. Run relevant tests in every changed repository, then call ship_report with action "fixes-complete" and repository reports containing exact test commands and outcomes.`;
640
+ }
641
+ buildDraftPrompt() {
642
+ if (!this.run?.intent)
643
+ throw new Error("No ship intent is available.");
644
+ const repositories = changedRepositories(this.run)
645
+ .map((repository) => `### ${repository.name}\n\nSummary:\n${repository.summary ?? "Inspect the final diff."}\n\nTests:\n${testsMarkdown(repository.tests) || "- Not reported"}`)
646
+ .join("\n\n");
647
+ const reviews = storedReviews(this.run)
648
+ .map((review) => {
649
+ const decisions = new Map(review.decisions?.map((decision) => [decision.findingId, decision]));
650
+ const findings = review.result.findings.length === 0
651
+ ? "- No actionable findings"
652
+ : review.result.findings
653
+ .map((finding) => {
654
+ const decision = decisions.get(finding.id);
655
+ return `- ${finding.id} (${finding.repository}, ${finding.severity}): ${finding.title}${decision ? ` — ${decision.action}: ${decision.rationale}` : ""}`;
656
+ })
657
+ .join("\n");
658
+ return `Round ${review.round}: ${review.result.summary}\n${findings}`;
659
+ })
660
+ .join("\n\n");
661
+ return `Prepare one concise GitHub pull request title and body for every changed repository below. Each body must help a human reviewer who was not in this session and include: Intent, Changes, Decisions and tradeoffs, Cross-repository context, Independent review, Testing, and Risks or follow-ups. Explicitly mention review findings that were fixed, accepted, or deferred. Do not include secrets or the raw conversation. Do not ask for publication confirmation; /ship already authorized it. Call ship_report with action "publish" and all drafts.\n\n## Workspace intent\n\n${this.run.intent}\n\n## Review history\n\n${reviews || "No review history recorded."}\n\n${repositories}`;
662
+ }
663
+ formatReview() {
664
+ return this.run?.review ? formatFullReview(this.run.review) : "No review result.";
665
+ }
666
+ validateReports(reports, requiredRepositories) {
667
+ if (!reports)
668
+ throw new Error("Repository reports are required.");
669
+ const reportsByName = new Map(reports.map((report) => [report.repository, report]));
670
+ if (reportsByName.size !== reports.length)
671
+ throw new Error("Repository reports contain duplicate names.");
672
+ for (const name of requiredRepositories) {
673
+ const report = reportsByName.get(name);
674
+ if (!report)
675
+ throw new Error(`Missing repository report for ${name}.`);
676
+ if (!report.summary.trim())
677
+ throw new Error(`Repository report for ${name} has no summary.`);
678
+ if (report.tests.length === 0)
679
+ throw new Error(`Repository report for ${name} must report tests or an explicit skip.`);
680
+ for (const test of report.tests) {
681
+ if (!test.command.trim())
682
+ throw new Error(`Repository report for ${name} contains an empty test command.`);
683
+ if (test.status === "skipped" && !test.summary?.trim()) {
684
+ throw new Error(`Skipped test in ${name} requires a reason.`);
685
+ }
686
+ }
687
+ }
688
+ for (const name of reportsByName.keys())
689
+ this.repository(name);
690
+ return reportsByName;
691
+ }
692
+ hasUserMessageAfterReview(ctx) {
693
+ const completedAt = this.run?.review?.completedAt ?? Number.POSITIVE_INFINITY;
694
+ return ctx.sessionManager.getBranch().some((entry) => {
695
+ if (entry.type !== "message" || entry.message.role !== "user")
696
+ return false;
697
+ return entry.message.timestamp > completedAt;
698
+ });
699
+ }
700
+ async refreshRepository(repository) {
701
+ repository.head = await requireGit(this.runCommand, repository.path, ["rev-parse", "HEAD"]);
702
+ repository.baseSha = await requireGit(this.runCommand, repository.path, ["rev-parse", repository.baseRef]);
703
+ repository.changed = await hasChangesAgainstBase(this.runCommand, repository.path, repository.baseRef);
704
+ }
705
+ async commitIfDirty(repository, message) {
706
+ if (await isClean(this.runCommand, repository.path))
707
+ return;
708
+ await requireGit(this.runCommand, repository.path, ["add", "-A"], "stage ship changes");
709
+ const staged = await git(this.runCommand, repository.path, ["diff", "--cached", "--quiet", "--"]);
710
+ if (staged.code === 0)
711
+ return;
712
+ if (staged.code !== 1)
713
+ throw new Error(`Could not inspect staged changes in ${repository.name}.`);
714
+ await requireGit(this.runCommand, repository.path, ["commit", "-m", message], "commit ship changes");
715
+ }
716
+ async unmergedPaths(path) {
717
+ const output = await requireGit(this.runCommand, path, ["diff", "--name-only", "--diff-filter=U", "-z"]);
718
+ return output.split("\0").filter(Boolean);
719
+ }
720
+ async rebaseInProgress(path) {
721
+ for (const name of ["rebase-merge", "rebase-apply"]) {
722
+ const gitPath = await requireGit(this.runCommand, path, ["rev-parse", "--git-path", name]);
723
+ if (existsSync(resolve(path, gitPath)))
724
+ return true;
725
+ }
726
+ return false;
727
+ }
728
+ async conflictPrompt(conflicts) {
729
+ if (!this.run?.conflictRepository)
730
+ throw new Error("No conflict repository is recorded.");
731
+ const repository = this.repository(this.run.conflictRepository);
732
+ const paths = conflicts ?? await this.unmergedPaths(repository.path);
733
+ return `Rebase conflicts must be resolved in ${repository.name} (${repository.path}):\n\n${paths.map((path) => `- ${path}`).join("\n")}\n\nResolve the conflicts according to the workspace intent and surrounding contracts. Remove conflict markers and stage every resolved path with git add. Do not commit or abort the rebase. When all unmerged paths are staged, call ship_report with action "conflict-resolved".`;
734
+ }
735
+ archiveCurrentReview() {
736
+ const run = this.run;
737
+ const review = run?.review;
738
+ if (!review)
739
+ return;
740
+ run.reviewHistory ??= [];
741
+ if (!run.reviewHistory.some((archived) => archived.completedAt === review.completedAt)) {
742
+ run.reviewHistory.push(review);
743
+ }
744
+ }
745
+ repository(name) {
746
+ const repository = this.run?.repositories.find((candidate) => candidate.name === name);
747
+ if (!repository)
748
+ throw new Error(`Unknown selected repository: ${name}`);
749
+ return repository;
750
+ }
751
+ appendReviewEntry() {
752
+ if (!this.run?.review)
753
+ return;
754
+ this.pi.appendEntry(REVIEW_ENTRY_TYPE, {
755
+ runId: this.run.id,
756
+ review: structuredClone(this.run.review),
757
+ });
758
+ }
759
+ sendPrompt(content) {
760
+ this.pi.sendMessage({ customType: "pi-ship", content, display: true, details: { runId: this.run?.id, stage: this.run?.stage } }, { triggerTurn: true });
761
+ }
762
+ result(text) {
763
+ return { content: [{ type: "text", text }], details: { ...(this.run ? { run: this.run } : {}) } };
764
+ }
765
+ persist(ctx) {
766
+ if (!this.run)
767
+ return;
768
+ this.run.updatedAt = Date.now();
769
+ this.pi.appendEntry(STATE_ENTRY, structuredClone(this.run));
770
+ this.updateStatus(ctx);
771
+ }
772
+ updateStatus(ctx) {
773
+ if (!this.run || !ACTIVE_STAGES.has(this.run.stage)) {
774
+ ctx.ui.setStatus("pi-ship", undefined);
775
+ ctx.ui.setWidget("pi-ship", undefined);
776
+ return;
777
+ }
778
+ const changed = changedRepositories(this.run).length;
779
+ const latestReview = this.run.review ?? this.run.reviewHistory?.at(-1);
780
+ ctx.ui.setStatus("pi-ship", `ship: ${this.run.stage} (${changed}/${this.run.repositories.length} repos)`);
781
+ ctx.ui.setWidget("pi-ship", [
782
+ `Ship ${this.run.id.slice(0, 8)} — ${this.run.stage}`,
783
+ ...(latestReview ? [` ${reviewHeadline(latestReview)}`] : []),
784
+ ...this.run.repositories.map((repository) => ` ${repository.changed ? "●" : "○"} ${repository.name}`),
785
+ ], { placement: "belowEditor" });
786
+ }
787
+ }
788
+ //# sourceMappingURL=workflow.js.map