@henryqw/pi-pr 6.0.7 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,19 +20,20 @@ import {
20
20
  type PullRequestTarget,
21
21
  } from "./pr-routing.ts";
22
22
  import {
23
- assertOnlyDeclaredStatusChanged,
24
23
  extensionExecApi,
25
24
  inspectWorktree,
25
+ inspectWorktreeState,
26
+ parseNulPaths,
27
+ parseStatusSnapshot,
28
+ validateResolvedConflictPaths,
26
29
  isAncestor,
27
30
  isRecord,
28
- parseNulPaths,
29
31
  parseSingleOutputLine,
30
32
  readHead,
31
33
  readRemoteOid,
32
34
  requiredOid,
33
35
  requiredText,
34
36
  runChecked,
35
- validateResolvedConflictPaths,
36
37
  withWorktreeLock,
37
38
  } from "./pr-execution.ts";
38
39
 
@@ -51,7 +52,7 @@ type CreateBaseAuthority = {
51
52
  fetchSource: string;
52
53
  };
53
54
 
54
- type CreatePhase = "unprepared" | "prepared" | "conflict-awaiting-user" | "verified" | "pushed" | "published" | "blocked";
55
+ type CreatePhase = "unprepared" | "prepared" | "verified" | "pushed" | "published" | "blocked";
55
56
 
56
57
  type CrossRepositoryCreateAuthority = {
57
58
  baseRepositoryId: string;
@@ -63,14 +64,14 @@ type CreatePullRequestState = {
63
64
  phase: CreatePhase;
64
65
  base?: CreateBaseAuthority;
65
66
  createAuthority?: CrossRepositoryCreateAuthority;
67
+ pending?: { head: string; status: string };
66
68
  publicationHead?: string;
67
- conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
69
+ verifiedHead?: string;
68
70
  };
69
71
 
70
72
  type CreatePullRequestResult =
71
73
  | { kind: "prepared"; base: CreateBaseAuthority; mergeBase: string }
72
74
  | { kind: "verified"; head: string; fastForward: boolean }
73
- | { kind: "conflict"; paths: string[] }
74
75
  | { kind: "pushed"; head: string }
75
76
  | { kind: "published"; url: string };
76
77
 
@@ -170,7 +171,6 @@ export class PullRequestCreator {
170
171
  private readonly agentDir?: string;
171
172
  private readonly exec: Exec;
172
173
  private readonly load: Load;
173
- private explicitBase?: string;
174
174
 
175
175
  constructor(options: CreatePullRequestOptions) {
176
176
  if (options.target.remoteOid !== null) requiredOid(options.target.remoteOid, "remote OID");
@@ -196,7 +196,7 @@ export class PullRequestCreator {
196
196
  }
197
197
 
198
198
  private async freshNone() {
199
- const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
199
+ const discovery = await this.load(this.pi(), this.context());
200
200
  if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
201
201
  throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
202
202
  }
@@ -232,18 +232,16 @@ export class PullRequestCreator {
232
232
  return await readHead(this.exec, this.options());
233
233
  }
234
234
 
235
- async prepare(explicitBase?: string): Promise<CreatePullRequestResult> {
235
+ async prepare(): Promise<CreatePullRequestResult> {
236
236
  if (this.state.phase !== "unprepared") {
237
237
  throw new Error("PR creation prepare action was already consumed");
238
238
  }
239
239
  return await withWorktreeLock(this.cwd, async () => {
240
- this.explicitBase = explicitBase;
241
240
  await this.freshNone();
242
241
  const preflight = await preflightPullRequestCreation(
243
242
  this.pi(),
244
243
  this.context(),
245
244
  this.target,
246
- this.explicitBase,
247
245
  );
248
246
  if (preflight.worktree === "operation") {
249
247
  throw new Error("PR creation cannot prepare while a Git operation is in progress");
@@ -277,83 +275,57 @@ export class PullRequestCreator {
277
275
  }, { agentDir: this.agentDir, signal: this.signal });
278
276
  }
279
277
 
280
- private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
281
- const base = this.state.base!;
282
- const head = await readHead(this.exec, this.options());
283
- const commits = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
284
- .split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
285
- if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
286
- let fastForward = false;
287
- if (head === base.oid && commits.length >= 2 && await isAncestor(this.exec, this.options(), originalHead, head)) fastForward = true;
288
- else if (commits.length !== 3 || commits[1] !== originalHead || commits[2] !== base.oid) {
289
- throw new Error("PR creation did not produce the exact configured fast-forward or two-parent merge");
290
- }
291
- await this.requireCleanHead();
292
- this.state.phase = "verified";
293
- delete this.state.conflict;
294
- return { head, fastForward };
295
- }
296
-
297
- private async captureConflict(originalHead: string): Promise<string[]> {
298
- const base = this.state.base!;
299
- const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
300
- if (mergeHead !== base.oid) throw new Error("Failed merge did not retain the frozen base");
301
- const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
302
- if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
303
- const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
304
- this.state.phase = "conflict-awaiting-user";
305
- this.state.conflict = { paths, statusBaseline: status.stdout, originalHead };
306
- return paths;
278
+ async inspect(): Promise<{ paths: string[]; head: string }> {
279
+ if (this.state.phase !== "prepared") throw new Error("PR creation must be prepared before inspecting pending work");
280
+ return await withWorktreeLock(this.cwd, async () => {
281
+ await this.freshNone();
282
+ if (await inspectWorktreeState(this.exec, this.options()) === "operation") throw new Error("Git operation in progress");
283
+ const head = await readHead(this.exec, this.options());
284
+ const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options())).stdout;
285
+ const paths = [...parseStatusSnapshot(status).keys()];
286
+ parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
287
+ this.state.pending = { head, status };
288
+ return { paths, head };
289
+ }, { agentDir: this.agentDir, signal: this.signal });
307
290
  }
308
291
 
309
- async merge(): Promise<CreatePullRequestResult> {
310
- if (this.state.phase !== "prepared" || !this.state.base) {
311
- throw new Error("PR creation is not prepared for merge");
292
+ async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
293
+ if (this.state.phase !== "prepared" || !this.state.pending) throw new Error("PR creation has no inspected pending work");
294
+ const paths = validateResolvedConflictPaths(pathsInput, []);
295
+ if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.state.pending!.status).has(path))) {
296
+ throw new Error("Commit paths must be reviewed pending paths");
312
297
  }
298
+ requiredText(message, "commit message");
313
299
  return await withWorktreeLock(this.cwd, async () => {
314
300
  await this.freshNone();
315
- if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
316
- const originalHead = await this.requireCleanHead();
317
- if (await isAncestor(this.exec, this.options(), this.state.base!.oid, originalHead)) {
318
- this.state.phase = "verified";
319
- return { kind: "verified", head: originalHead, fastForward: false };
320
- }
321
- this.state.phase = "blocked";
322
- const result = await this.exec("git", ["merge", "--no-edit", this.state.base!.oid], this.options());
323
- if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
324
- if (result.code === 0) {
325
- const verified = await this.verifyMerge(originalHead);
326
- return { kind: "verified", ...verified };
327
- }
328
- try {
329
- const paths = await this.captureConflict(originalHead);
330
- return { kind: "conflict", paths };
331
- } catch (error) {
332
- throw new Error(`git merge failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
301
+ if (await readHead(this.exec, this.options()) !== this.state.pending!.head ||
302
+ await inspectWorktreeState(this.exec, this.options()) === "operation" ||
303
+ (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options())).stdout !== this.state.pending!.status) {
304
+ throw new Error("Pending work changed after inspection");
333
305
  }
306
+ const staged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--cached", "--no-renames", "--name-only", "-z"], this.options())).stdout, "Staged paths");
307
+ if (staged.some((path) => !paths.includes(path))) throw new Error("Unrelated staged changes require an ownership decision");
308
+ this.state.pending = undefined; // Commit outcome may be uncertain; never replay it.
309
+ await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "-A", "--", ...paths], this.options());
310
+ await runChecked(this.exec, "git", ["commit", "-m", message], this.options());
311
+ return { head: await readHead(this.exec, this.options()) };
334
312
  }, { agentDir: this.agentDir, signal: this.signal });
335
313
  }
336
314
 
337
- async continue(pathsInput: readonly string[]): Promise<CreatePullRequestResult> {
338
- if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict || !this.state.base) {
339
- throw new Error("PR creation has no conflict awaiting continuation");
315
+ async verify(): Promise<CreatePullRequestResult> {
316
+ if (this.state.phase !== "prepared" || !this.state.base) {
317
+ throw new Error("PR creation is not prepared for verification");
340
318
  }
341
- const paths = validateResolvedConflictPaths(pathsInput, this.state.conflict.paths);
342
319
  return await withWorktreeLock(this.cwd, async () => {
343
320
  await this.freshNone();
344
321
  if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
345
- if (await readHead(this.exec, this.options()) !== this.state.conflict!.originalHead) throw new Error("PR creation merge HEAD changed");
346
- const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
347
- if (mergeHead !== this.state.base!.oid) throw new Error("PR creation merge context changed");
348
- const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
349
- assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
350
- this.state.phase = "blocked";
351
- await runChecked(this.exec, "git", ["add", "--", ...paths], this.options());
352
- const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
353
- if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
354
- await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.options());
355
- const verified = await this.verifyMerge(this.state.conflict!.originalHead);
356
- return { kind: "verified", ...verified };
322
+ const head = await this.requireCleanHead();
323
+ if (!isPullRequestCreationEligible((await this.freshNone()).branch) || await readHead(this.exec, this.options()) !== head) {
324
+ throw new Error("PR creation changed during verification or no longer has a committed change");
325
+ }
326
+ this.state.verifiedHead = head;
327
+ this.state.phase = "verified";
328
+ return { kind: "verified", head, fastForward: false };
357
329
  }, { agentDir: this.agentDir, signal: this.signal });
358
330
  }
359
331
 
@@ -374,16 +346,14 @@ export class PullRequestCreator {
374
346
  }
375
347
 
376
348
  async push(): Promise<CreatePullRequestResult> {
377
- if (this.state.phase !== "verified" || !this.state.base) {
349
+ if (this.state.phase !== "verified" || !this.state.base || !this.state.verifiedHead) {
378
350
  throw new Error("PR creation is not ready to push");
379
351
  }
380
352
  return await withWorktreeLock(this.cwd, async () => {
381
353
  await this.freshNone();
382
354
  if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
383
355
  const head = await this.requireCleanHead();
384
- if (!(await isAncestor(this.exec, this.options(), this.state.base!.oid, head))) {
385
- throw new Error("PR creation HEAD does not contain the frozen base");
386
- }
356
+ if (head !== this.state.verifiedHead) throw new Error("PR creation verified HEAD changed");
387
357
  const original = this.target.remoteOid;
388
358
  if (original !== null && !(await isAncestor(this.exec, this.options(), original, head))) {
389
359
  throw new Error("PR creation push would not fast-forward the frozen remote OID");
@@ -423,7 +393,7 @@ export class PullRequestCreator {
423
393
  }
424
394
  return;
425
395
  }
426
- const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
396
+ const discovery = await this.load(this.pi(), this.context());
427
397
  if (discovery.kind === "none") {
428
398
  if (!sameTarget(this.target, discovery.creationTarget, head)) throw new Error("Published target authority changed");
429
399
  return;
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
5
+ import { spawnBounded, type Exec } from "@henryqw/pi-process";
6
+ import { collectPullRequestFeedback, feedbackAuthorityFromCurrent, feedbackEntries, type FeedbackSnapshot } from "./pr-feedback.ts";
7
+ import { loadCurrentPullRequest, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
8
+ import { extensionExecApi, isRecord, parseSingleOutputLine, runChecked } from "./pr-execution.ts";
9
+
10
+ function fingerprint(snapshot: FeedbackSnapshot, blockedIds: ReadonlySet<string> = new Set()): string {
11
+ const entries = feedbackEntries(snapshot).filter(({ id }) => !blockedIds.has(id)).map(({ id, kind, node }) => ({
12
+ id, kind, body: "body" in node ? node.body : null,
13
+ })).sort((a, b) => a.id.localeCompare(b.id));
14
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
15
+ }
16
+
17
+ async function markerPath(cwd: string, agentDir?: string, signal?: AbortSignal, exec: Exec = spawnBounded): Promise<string> {
18
+ const root = parseSingleOutputLine((await runChecked(exec, "git", ["rev-parse", "--show-toplevel"], { cwd, signal })).stdout, "worktree root");
19
+ const identity = createHash("sha256").update(await realpath(root)).digest("hex");
20
+ return join(extensionConfigDir("pi-pr", agentDir), "feedback", `${identity}.json`);
21
+ }
22
+
23
+ async function readMarker(path: string, signal?: AbortSignal): Promise<{ url: string; fingerprint: string } | null> {
24
+ let raw: string;
25
+ try {
26
+ raw = await readTextFileBounded(path, 2_048, { signal });
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
29
+ throw error;
30
+ }
31
+ let marker: unknown;
32
+ try { marker = JSON.parse(raw); } catch { throw new Error(`Invalid feedback attention marker is preserved at ${path}`); }
33
+ if (!isRecord(marker) || Object.keys(marker).sort().join(",") !== "fingerprint,url,version" ||
34
+ marker.version !== 1 || typeof marker.url !== "string" || typeof marker.fingerprint !== "string" ||
35
+ !/^[0-9a-f]{64}$/.test(marker.fingerprint)) {
36
+ throw new Error(`Invalid feedback attention marker is preserved at ${path}`);
37
+ }
38
+ return { url: marker.url, fingerprint: marker.fingerprint };
39
+ }
40
+
41
+ export async function needsFeedbackAttention(
42
+ current: CurrentPullRequest,
43
+ options: { cwd: string; agentDir?: string; signal?: AbortSignal; exec?: Exec; load?: typeof loadCurrentPullRequest },
44
+ ): Promise<boolean> {
45
+ const { cwd, agentDir, signal } = options;
46
+ const exec = options.exec ?? spawnBounded;
47
+ const snapshot = await collectPullRequestFeedback(feedbackAuthorityFromCurrent(current), { exec, cwd, signal });
48
+ const discovery = await (options.load ?? loadCurrentPullRequest)(extensionExecApi(exec, cwd, signal),
49
+ { cwd, signal: signal ?? new AbortController().signal });
50
+ if (discovery.kind !== "current" || !samePullRequestSnapshot(current, discovery.pullRequest) ||
51
+ current.base.oid !== discovery.pullRequest.base.oid) {
52
+ throw new Error("Feedback discovery cancelled: pull request authority changed");
53
+ }
54
+ const marker = await readMarker(await markerPath(cwd, agentDir, signal, exec), signal);
55
+ const entries = feedbackEntries(snapshot);
56
+ if (!entries.length) return false;
57
+ return !marker || marker.url !== current.url.href || marker.fingerprint !== fingerprint(snapshot);
58
+ }
59
+
60
+ export async function markFeedbackHandled(
61
+ snapshot: FeedbackSnapshot,
62
+ options: { cwd: string; agentDir?: string; signal?: AbortSignal; exec?: Exec; blockedIds?: readonly string[] },
63
+ ): Promise<void> {
64
+ const path = await markerPath(options.cwd, options.agentDir, options.signal, options.exec);
65
+ await readMarker(path, options.signal);
66
+ await writePrivateTextFileAtomically(path, `${JSON.stringify({ version: 1, url: snapshot.pullRequest.url, fingerprint: fingerprint(snapshot, new Set(options.blockedIds)) })}\n`, {
67
+ signal: options.signal,
68
+ });
69
+ }
@@ -32,6 +32,8 @@ query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on Pull
32
32
  }}}`;
33
33
  const RESOLVE_THREAD_MUTATION = `
34
34
  mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
35
+ const REPLY_THREAD_MUTATION = `
36
+ mutation ReplyThread($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id body}}}`;
35
37
 
36
38
  type FeedbackAuthor = { login: string } | null;
37
39
  type FeedbackComment = {
@@ -377,7 +379,9 @@ class FeedbackClient {
377
379
 
378
380
  private async request(query: string, variables: Record<string, string | number | null>, host: string, readOnly: boolean): Promise<Record<string, unknown>> {
379
381
  const args = ["api", "graphql", "--hostname", requiredText(host, "GitHub host"), "-F", "query=@-"];
380
- for (const [name, value] of Object.entries(variables)) if (value !== null) args.push("-F", `${name}=${value}`);
382
+ for (const [name, value] of Object.entries(variables)) {
383
+ if (value !== null) args.push(typeof value === "number" ? "-F" : "-f", `${name}=${value}`);
384
+ }
381
385
  for (let attempt = 0; ; attempt += 1) {
382
386
  try {
383
387
  this.pages += 1;
@@ -499,6 +503,24 @@ export async function collectPullRequestFeedback(
499
503
  });
500
504
  }
501
505
 
506
+ export async function replyToPullRequestThread(
507
+ authorityInput: FeedbackAuthority,
508
+ threadIdInput: string,
509
+ bodyInput: string,
510
+ options: FeedbackClientOptions,
511
+ ): Promise<string> {
512
+ const authority = parseAuthority(authorityInput);
513
+ const threadId = requiredText(threadIdInput, "review thread ID");
514
+ const body = requiredText(bodyInput, "review thread reply");
515
+ const data = await new FeedbackClient(options).mutate(REPLY_THREAD_MUTATION, { threadId, body }, authority.host);
516
+ if (!isRecord(data.addPullRequestReviewThreadReply) || !isRecord(data.addPullRequestReviewThreadReply.comment) ||
517
+ data.addPullRequestReviewThreadReply.comment.body !== body ||
518
+ typeof data.addPullRequestReviewThreadReply.comment.id !== "string") {
519
+ throw new Error(`GitHub did not confirm reply to ${threadId}`);
520
+ }
521
+ return requiredText(data.addPullRequestReviewThreadReply.comment.id, "review reply ID");
522
+ }
523
+
502
524
  export async function resolvePullRequestThread(
503
525
  authorityInput: FeedbackAuthority,
504
526
  threadIdInput: string,
@@ -0,0 +1,143 @@
1
+ import { spawnBounded, type Exec } from "@henryqw/pi-process";
2
+ import { cloneCurrentPullRequest, loadCurrentPullRequest, readValidatedRemoteAuthority, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
3
+ import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked, validateResolvedConflictPaths, withWorktreeLock } from "./pr-execution.ts";
4
+
5
+ type Check = { command: string; args: string[] };
6
+ type Options = { cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
7
+
8
+ /** One local publication attempt. An uncertain push consumes the run, even when a later read recovers. */
9
+ export class PullRequestWorkPublisher {
10
+ private readonly authority: CurrentPullRequest;
11
+ private readonly exec: Exec;
12
+ private readonly load: typeof loadCurrentPullRequest;
13
+ private readonly options: Options;
14
+ private initialHead?: string;
15
+ private status?: string;
16
+ private validatedHead?: string;
17
+ private consumed = false;
18
+
19
+ constructor(options: Options) {
20
+ if (options.authority.lifecycle !== "open" || options.authority.target.provenance !== "configured") {
21
+ throw new Error("Local publication requires a configured open pull request");
22
+ }
23
+ if (options.authority.local.head === "behind" || options.authority.local.head === "diverged") {
24
+ throw new Error("Local publication requires a branch descended from the published PR head");
25
+ }
26
+ this.options = options;
27
+ this.authority = cloneCurrentPullRequest(options.authority);
28
+ this.exec = options.exec ?? spawnBounded;
29
+ this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
30
+ }
31
+
32
+ private execOptions() { return { cwd: this.options.cwd, signal: this.options.signal }; }
33
+
34
+ private async authorityCheck(): Promise<void> {
35
+ const { cwd, signal } = this.options;
36
+ const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal: signal ?? new AbortController().signal });
37
+ if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest)) {
38
+ throw new Error("Local publication cancelled: PR identity or remote head changed");
39
+ }
40
+ const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal),
41
+ { cwd, signal: signal ?? new AbortController().signal }, this.authority.target.remote);
42
+ if (remote.fetchSource !== this.authority.target.fetchSource || remote.host !== this.authority.target.host ||
43
+ remote.repository.toLowerCase() !== this.authority.target.repository.toLowerCase()) {
44
+ throw new Error("Local publication cancelled: push destination changed");
45
+ }
46
+ if (await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref) !== this.authority.head.oid) {
47
+ throw new Error("Local publication cancelled: remote lease changed");
48
+ }
49
+ const branch = requiredText((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout.trim(), "current branch");
50
+ if (branch !== this.authority.target.branch) throw new Error("Local publication cancelled: branch changed");
51
+ }
52
+
53
+ async inspect(): Promise<{ paths: string[]; head: string }> {
54
+ if (this.initialHead) throw new Error("Pending changes already inspected");
55
+ return await withWorktreeLock(this.options.cwd, async () => {
56
+ await this.authorityCheck();
57
+ if (await inspectWorktreeState(this.exec, this.execOptions()) === "operation") throw new Error("Git operation in progress");
58
+ const head = await readHead(this.exec, this.execOptions());
59
+ if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
60
+ throw new Error("Local HEAD is not a descendant of the published PR head");
61
+ }
62
+ const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout;
63
+ const paths = [...parseStatusSnapshot(status).keys()];
64
+ parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
65
+ this.initialHead = head;
66
+ this.status = status;
67
+ return { paths, head };
68
+ }, { agentDir: this.options.agentDir, signal: this.options.signal });
69
+ }
70
+
71
+ async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
72
+ if (!this.initialHead || this.status === undefined || this.consumed) throw new Error("Pending changes must be inspected first");
73
+ const paths = validateResolvedConflictPaths(pathsInput, []);
74
+ if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.status!).has(path))) {
75
+ throw new Error("Commit paths must be reviewed pending paths");
76
+ }
77
+ requiredText(message, "commit message");
78
+ return await withWorktreeLock(this.options.cwd, async () => {
79
+ await this.authorityCheck();
80
+ if (await readHead(this.exec, this.execOptions()) !== this.initialHead ||
81
+ await inspectWorktreeState(this.exec, this.execOptions()) === "operation" ||
82
+ (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout !== this.status) {
83
+ throw new Error("Pending changes moved after inspection; inspect again in a new /pr");
84
+ }
85
+ // Never let an already-staged unrelated path enter this commit.
86
+ const staged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--cached", "--no-renames", "--name-only", "-z"], this.execOptions())).stdout, "Staged paths");
87
+ if (staged.some((path) => !paths.includes(path))) throw new Error("Unrelated staged changes require an ownership decision");
88
+ this.consumed = true;
89
+ await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "-A", "--", ...paths], this.execOptions());
90
+ await runChecked(this.exec, "git", ["commit", "-m", message], this.execOptions());
91
+ return { head: await readHead(this.exec, this.execOptions()) };
92
+ }, { agentDir: this.options.agentDir, signal: this.options.signal });
93
+ }
94
+
95
+ async validate(checks: Check[]): Promise<{ head: string; checks: number }> {
96
+ if (!this.initialHead || this.validatedHead) throw new Error("Local publication validation is unavailable");
97
+ if (!Array.isArray(checks) || checks.length > 32 || checks.some(({ command, args }) =>
98
+ typeof command !== "string" || !command || !Array.isArray(args) || args.some((arg) => typeof arg !== "string"))) {
99
+ throw new Error("Invalid validation commands");
100
+ }
101
+ return await withWorktreeLock(this.options.cwd, async () => {
102
+ await this.authorityCheck();
103
+ if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Unrelated pending changes remain; ask about ownership before publishing");
104
+ const head = await readHead(this.exec, this.execOptions());
105
+ if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
106
+ throw new Error("Local HEAD is not a descendant of the published PR head");
107
+ }
108
+ await runChecked(this.exec, "git", ["diff", "--check", this.authority.head.oid, head], this.execOptions());
109
+ for (const check of checks) await runChecked(this.exec, check.command, check.args, this.execOptions());
110
+ if (await readHead(this.exec, this.execOptions()) !== head || await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
111
+ throw new Error("Validation changed local HEAD or worktree");
112
+ }
113
+ this.validatedHead = head;
114
+ return { head, checks: checks.length + 1 };
115
+ }, { agentDir: this.options.agentDir, signal: this.options.signal });
116
+ }
117
+
118
+ async publish(): Promise<{ kind: "published"; head: string }> {
119
+ if (!this.validatedHead) throw new Error("Local work is not validated");
120
+ const head = this.validatedHead;
121
+ this.validatedHead = undefined; // A response loss must never allow a second push.
122
+ return await withWorktreeLock(this.options.cwd, async () => {
123
+ await this.authorityCheck();
124
+ if (await inspectWorktree(this.exec, this.execOptions()) !== "clean" || await readHead(this.exec, this.execOptions()) !== head) {
125
+ throw new Error("Validated local HEAD or worktree changed");
126
+ }
127
+ const original = this.authority.head.oid;
128
+ if (head === original) return { kind: "published", head };
129
+ let error: unknown;
130
+ try {
131
+ await runChecked(this.exec, "git", ["push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
132
+ "--recurse-submodules=no", "--", this.authority.target.fetchSource,
133
+ `${head}:refs/heads/${this.authority.target.ref}`], this.execOptions());
134
+ } catch (failure) { error = failure; }
135
+ let remote: string | null;
136
+ try { remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref); }
137
+ catch { throw new Error("Local publication outcome unknown; do not retry"); }
138
+ if (remote === head) return { kind: "published", head };
139
+ if (remote === original) throw new Error(`Local publication not applied${error ? `: ${String(error)}` : ""}`);
140
+ throw new Error("Local publication outcome unknown; do not retry");
141
+ }, { agentDir: this.options.agentDir, signal: this.options.signal });
142
+ }
143
+ }
@@ -60,19 +60,7 @@ export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
60
60
  | { kind: "blocked"; issue: DiscoveryIssue }
61
61
  | { kind: "inactive" };
62
62
 
63
- export type NextStep = "create" | "link-branch" | "blocked" | "none" | "update-branch" | "sweep" | "fix-ci" | "merge";
64
- export type RouteIntent = "automatic" | "feedback";
65
- export type FeedbackRouteBlocker =
66
- | { kind: "discovery-blocked" }
67
- | { kind: "pull-request-unavailable" }
68
- | { kind: "pull-request-not-open" }
69
- | { kind: "target-not-configured" }
70
- | { kind: "worktree-dirty" }
71
- | { kind: "head-not-equal"; relation: Exclude<LocalHeadRelation, "equal"> };
72
- type RouteDecision =
73
- | { kind: "selected"; nextStep: NextStep }
74
- | { kind: "feedback-blocked"; blocker: FeedbackRouteBlocker };
75
-
63
+ export type NextStep = "create" | "link-branch" | "blocked" | "none" | "publish-work" | "update-branch" | "sweep" | "fix-ci" | "merge";
76
64
  function localMutationSafe(local: LocalMergeSafety): boolean {
77
65
  return local.worktree === "clean" && local.head === "equal";
78
66
  }
@@ -84,7 +72,9 @@ function localMergeSafe(local: LocalMergeSafety): boolean {
84
72
  export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<NextStep, "create" | "link-branch" | "blocked"> {
85
73
  const { lifecycle, conditions, local } = pullRequest;
86
74
  if (lifecycle !== "open" || conditions.draft) return "none";
87
- if (conditions.baseUpdateRequired || conditions.conflict) {
75
+ if (local.worktree === "dirty" && (local.head === "behind" || local.head === "diverged")) return "none";
76
+ if (local.worktree === "dirty" || local.head === "ahead") return "publish-work";
77
+ if (conditions.conflict) {
88
78
  return localMutationSafe(local) ? "update-branch" : "none";
89
79
  }
90
80
  if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
@@ -116,36 +106,6 @@ function deriveAutomaticNextStep(discovery: PullRequestDiscovery<PullRequest & {
116
106
  return derivePullRequestNextStep(discovery.pullRequest);
117
107
  }
118
108
 
119
- export function deriveRouteDecision(
120
- discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>,
121
- intent: RouteIntent = "automatic",
122
- ): RouteDecision {
123
- if (intent === "automatic") return { kind: "selected", nextStep: deriveAutomaticNextStep(discovery) };
124
- if (discovery.kind === "blocked") {
125
- return { kind: "feedback-blocked", blocker: { kind: "discovery-blocked" } };
126
- }
127
- if (discovery.kind !== "current") {
128
- return { kind: "feedback-blocked", blocker: { kind: "pull-request-unavailable" } };
129
- }
130
- const pullRequest = discovery.pullRequest;
131
- if (pullRequest.lifecycle !== "open") {
132
- return { kind: "feedback-blocked", blocker: { kind: "pull-request-not-open" } };
133
- }
134
- if (pullRequest.target.provenance !== "configured") {
135
- return { kind: "feedback-blocked", blocker: { kind: "target-not-configured" } };
136
- }
137
- if (pullRequest.local.worktree !== "clean") {
138
- return { kind: "feedback-blocked", blocker: { kind: "worktree-dirty" } };
139
- }
140
- if (pullRequest.local.head !== "equal") {
141
- return {
142
- kind: "feedback-blocked",
143
- blocker: { kind: "head-not-equal", relation: pullRequest.local.head },
144
- };
145
- }
146
- return { kind: "selected", nextStep: "sweep" };
147
- }
148
-
149
109
  export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
150
110
  return deriveAutomaticNextStep(discovery);
151
111
  }
@@ -93,6 +93,8 @@ function footerStatus(input: PrDisplayInput): Pick<PrFooter, "text" | "color"> {
93
93
 
94
94
  function widgetText(input: PrDisplayInput, nextStep: NextStep): string | undefined {
95
95
  switch (nextStep) {
96
+ case "publish-work":
97
+ return "Run /pr to publish local work";
96
98
  case "update-branch":
97
99
  return input.conditions.conflict
98
100
  ? "Run /pr to resolve merge conflict"