@henryqw/pi-pr 6.2.7 → 6.2.10

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 CHANGED
@@ -40,7 +40,7 @@ Run `/pr` in a GitHub checkout. It reads fresh local and GitHub state, takes the
40
40
  | Footer | ui | Linked PR number and plain-language status. |
41
41
  | Widget | ui | Action hint or transient routing status. |
42
42
 
43
- `/pr` accepts no flags, prose, or base argument. Start with the command, not a helper skill or tool: direct calls cannot establish route authority. The helper run is bound to the current session and worktree.
43
+ `/pr` accepts no flags, prose, or base argument. Start with the command, not a helper skill or tool: direct calls cannot establish route authority. The helper run is bound to the current session and worktree. Its agent tools use plain-object parameter schemas so providers that omit root-union tools can expose them; action-specific arguments are still checked before the workflow runs.
44
44
 
45
45
  ## Flow
46
46
 
@@ -108,13 +108,9 @@ type CiEvidence = {
108
108
  failures: CiFailureEvidence[];
109
109
  };
110
110
 
111
- type CiPushAttempt = "before-launch" | "applied" | "not-applied" | "unknown";
112
111
  type CiFixPhase = "ready" | "collecting" | "collected" | "published" | "blocked";
113
112
 
114
- type CiFixState = {
115
- phase: CiFixPhase;
116
- pushAttempt: CiPushAttempt;
117
- };
113
+ type CiFixState = { phase: CiFixPhase };
118
114
 
119
115
  export type PullRequestCiFixOptions = {
120
116
  cwd: string;
@@ -293,7 +289,7 @@ function errorMessage(error: unknown): string {
293
289
  }
294
290
 
295
291
  export class PullRequestCiFixer {
296
- readonly state: CiFixState = { phase: "ready", pushAttempt: "before-launch" };
292
+ readonly state: CiFixState = { phase: "ready" };
297
293
 
298
294
  private readonly cwd: string;
299
295
  private readonly authority: CurrentPullRequest;
@@ -301,20 +297,16 @@ export class PullRequestCiFixer {
301
297
  private readonly agentDir?: string;
302
298
  private readonly exec: Exec;
303
299
  private readonly load: Load;
304
- private collectConsumed = false;
305
- private publishConsumed = false;
306
300
  private collectedFingerprint?: string;
307
301
 
308
302
  constructor(options: PullRequestCiFixOptions) {
309
- if (!options.authority || options.authority.target.provenance !== "configured") {
303
+ if (options.authority.target.provenance !== "configured") {
310
304
  throw new TypeError("CI repair requires a configured failed pull request with a clean equal local HEAD");
311
305
  }
312
- const head = requiredOid(options.authority.head.oid, "pull request head OID");
313
- const remote = options.authority.target.remoteOid === null
314
- ? null
315
- : requiredOid(options.authority.target.remoteOid, "remote OID");
316
- if (remote !== head) throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
317
- this.cwd = requiredText(options.cwd, "cwd");
306
+ if (options.authority.target.remoteOid !== options.authority.head.oid) {
307
+ throw new TypeError("CI repair requires the pull request head to match the configured remote OID");
308
+ }
309
+ this.cwd = options.cwd;
318
310
  this.authority = cloneCurrentPullRequest(options.authority);
319
311
  this.signal = options.signal;
320
312
  this.agentDir = options.agentDir;
@@ -331,7 +323,7 @@ export class PullRequestCiFixer {
331
323
  }
332
324
 
333
325
  private context(): PullRequestLoadContext {
334
- return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
326
+ return { cwd: this.cwd, signal: this.signal };
335
327
  }
336
328
 
337
329
  private async freshAuthority(requireOriginalLocal: boolean): Promise<CurrentPullRequest> {
@@ -619,8 +611,7 @@ export class PullRequestCiFixer {
619
611
  }
620
612
 
621
613
  async collect(): Promise<CiEvidence> {
622
- if (this.collectConsumed || this.state.phase !== "ready") throw new Error("CI evidence collect action was already consumed");
623
- this.collectConsumed = true;
614
+ if (this.state.phase !== "ready") throw new Error("CI evidence collect action was already consumed");
624
615
  this.state.phase = "collecting";
625
616
  try {
626
617
  const before = await this.readSnapshot(true);
@@ -699,7 +690,6 @@ export class PullRequestCiFixer {
699
690
  if (repairHead === original || !(await isAncestor(this.exec, this.options(), original, repairHead))) {
700
691
  throw new Error("CI repair HEAD must be a new descendant of the frozen pull request head");
701
692
  }
702
- if (await readHead(this.exec, this.options()) !== repairHead) throw new Error("CI repair HEAD changed before push");
703
693
  return repairHead;
704
694
  }
705
695
 
@@ -714,8 +704,7 @@ export class PullRequestCiFixer {
714
704
  }
715
705
 
716
706
  async publish(): Promise<CiPublishResult> {
717
- if (this.publishConsumed || this.state.phase !== "collected") throw new Error("CI repair publish action is unavailable or was already consumed");
718
- this.publishConsumed = true;
707
+ if (this.state.phase !== "collected") throw new Error("CI repair publish action is unavailable or was already consumed");
719
708
  try {
720
709
  return await withWorktreeLock(this.cwd, async () => {
721
710
  const repairHead = await this.validatePublishAuthority();
@@ -741,19 +730,15 @@ export class PullRequestCiFixer {
741
730
  this.authority.target.ref,
742
731
  );
743
732
  } catch (error) {
744
- this.state.pushAttempt = "unknown";
745
733
  throw new Error(`CI repair push outcome is unknown: ${errorMessage(error)}`);
746
734
  }
747
735
  if (postcondition === repairHead) {
748
- this.state.pushAttempt = "applied";
749
736
  this.state.phase = "published";
750
737
  return { kind: "published", head: repairHead, attempt: "applied" };
751
738
  }
752
739
  if (postcondition === original) {
753
- this.state.pushAttempt = "not-applied";
754
740
  throw new Error(`CI repair push was not applied${pushError ? `: ${errorMessage(pushError)}` : ""}`);
755
741
  }
756
- this.state.pushAttempt = "unknown";
757
742
  throw new Error("CI repair push outcome is unknown: remote target has an unexpected OID");
758
743
  }, { agentDir: this.agentDir, signal: this.signal });
759
744
  } catch (error) {
@@ -25,6 +25,7 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
25
25
  sweep: "skill:pi-pr-comment-sweep",
26
26
  "fix-ci": "skill:pi-pr-fix-ci",
27
27
  };
28
+ export const WORKFLOW_ROUTES: ReadonlySet<string> = new Set(Object.keys(WORKFLOWS));
28
29
  type WorkflowReservation =
29
30
  | { route: "create"; target: PullRequestTarget }
30
31
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
@@ -43,6 +43,7 @@ import {
43
43
  requiredOid,
44
44
  requiredText,
45
45
  runChecked,
46
+ validatePaths,
46
47
  withWorktreeLock,
47
48
  type AttemptState,
48
49
  } from "./pr-execution.ts";
@@ -190,20 +191,12 @@ function authorityFromCurrent(pullRequest: CurrentPullRequest) {
190
191
  if (pullRequest.lifecycle !== "open" || pullRequest.target.provenance !== "configured") {
191
192
  throw new Error("Comment sweep requires a configured open pull request");
192
193
  }
193
- const feedback = feedbackAuthorityFromCurrent(pullRequest);
194
- const remoteOid = requiredOid(pullRequest.target.remoteOid, "remote lease OID");
194
+ const { branch, remote, ref, repository, host, fetchSource, remoteOid } = pullRequest.target;
195
+ if (remoteOid === null) throw new Error("Comment sweep requires a published remote lease");
195
196
  return {
196
- ...feedback,
197
- headFetchSource: requiredText(pullRequest.headFetchSource, "head fetch source"),
198
- target: {
199
- branch: requiredText(pullRequest.target.branch, "target branch"),
200
- remote: requiredText(pullRequest.target.remote, "target remote"),
201
- ref: requiredText(pullRequest.target.ref, "target ref"),
202
- repository: requiredText(pullRequest.target.repository, "target repository"),
203
- host: requiredText(pullRequest.target.host, "target host").toLowerCase(),
204
- fetchSource: requiredText(pullRequest.target.fetchSource, "target fetch source"),
205
- remoteOid,
206
- },
197
+ ...feedbackAuthorityFromCurrent(pullRequest),
198
+ headFetchSource: pullRequest.headFetchSource,
199
+ target: { branch, remote, ref, repository, host, fetchSource, remoteOid },
207
200
  };
208
201
  }
209
202
 
@@ -270,8 +263,7 @@ function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAu
270
263
 
271
264
  function parseOwnedPaths(value: unknown): string[] {
272
265
  if (!Array.isArray(value) || value.some((path) => typeof path !== "string")) throw new Error("ownedPaths must be an array of paths");
273
- const encoded = value.length ? `${value.join("\0")}\0` : "";
274
- return parseNulPaths(encoded, "Sweep owned paths");
266
+ return validatePaths(value as string[], "Sweep owned paths");
275
267
  }
276
268
 
277
269
  function parseLedgerEntry(value: unknown, label: string): SweepLedgerEntry {
@@ -635,7 +627,7 @@ export class PullRequestCommentSweep {
635
627
  private readonly pause?: (milliseconds: number) => Promise<void>;
636
628
 
637
629
  constructor(options: PullRequestCommentSweepOptions) {
638
- this.cwd = requiredText(options.cwd, "cwd");
630
+ this.cwd = options.cwd;
639
631
  this.suppliedAuthority = options.authority ? authorityFromCurrent(options.authority) : undefined;
640
632
  this.signal = options.signal;
641
633
  this.agentDir = options.agentDir;
@@ -654,7 +646,7 @@ export class PullRequestCommentSweep {
654
646
  }
655
647
 
656
648
  private context(): PullRequestLoadContext {
657
- return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
649
+ return { cwd: this.cwd, signal: this.signal };
658
650
  }
659
651
 
660
652
  private async location(): Promise<{ root: string; id: string; path: string }> {
@@ -1011,7 +1003,6 @@ export class PullRequestCommentSweep {
1011
1003
 
1012
1004
  async commit(guard: SweepRunGuard, message: string): Promise<{ head: string }> {
1013
1005
  requiredText(message, "commit message");
1014
- if (message.length > 256) throw new Error("commit message exceeds 256 characters");
1015
1006
  return await withWorktreeLock(this.cwd, async () => {
1016
1007
  const location = await this.location();
1017
1008
  const state = await this.loadState(location);
@@ -1144,7 +1135,7 @@ export class PullRequestCommentSweep {
1144
1135
  }, { agentDir: this.agentDir, signal: this.signal });
1145
1136
  }
1146
1137
 
1147
- async resolve(guard: SweepRunGuard, threadIdsInput?: string[]): Promise<SweepStatus> {
1138
+ async resolve(guard: SweepRunGuard): Promise<SweepStatus> {
1148
1139
  return await withWorktreeLock(this.cwd, async () => {
1149
1140
  const location = await this.location();
1150
1141
  const state = await this.loadState(location);
@@ -1155,31 +1146,19 @@ export class PullRequestCommentSweep {
1155
1146
  if (state.attempts.resolutions.some(({ state: attempt }) => attempt !== "applied")) {
1156
1147
  throw new Error("Comment sweep has an unreconciled thread mutation; use resume");
1157
1148
  }
1158
- if (threadIdsInput !== undefined && (!Array.isArray(threadIdsInput) || threadIdsInput.length > FEEDBACK_MAX_RECORDS)) {
1159
- throw new Error("threadIds must be a bounded array");
1160
- }
1161
1149
  const ledger = new Map(state.ledger.map((entry) => [entry.id, entry]));
1162
- const threadIds = (threadIdsInput ?? state.feedback.snapshot.reviewThreads.filter((thread) =>
1150
+ const threadIds = state.feedback.snapshot.reviewThreads.filter((thread) =>
1163
1151
  !thread.isResolved && ["addressed", "non-actionable"].includes(ledger.get(thread.id)?.disposition ?? "blocked") &&
1164
1152
  !thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")
1165
- ).map(({ id }) => id)).map((id, index) => requiredText(id, `thread ID ${index + 1}`));
1166
- if (new Set(threadIds).size !== threadIds.length) throw new Error("threadIds contain duplicates");
1153
+ ).map(({ id }) => id);
1167
1154
  for (const threadId of threadIds) {
1168
- const thread = state.feedback.snapshot.reviewThreads.find(({ id }) => id === threadId);
1169
- const disposition = ledger.get(threadId)?.disposition;
1170
- if (!thread || thread.isResolved || ledger.get(threadId)?.kind !== "thread" ||
1171
- (disposition !== "addressed" && disposition !== "non-actionable")) {
1172
- throw new Error(`Only addressed or non-actionable unresolved review thread IDs may be resolved: ${threadId}`);
1173
- }
1174
1155
  if (state.attempts.resolutions.some((attempt) => attempt.generation === state.feedback.generation && attempt.threadId === threadId && attempt.step === "resolve")) {
1175
1156
  throw new Error(`Review thread resolution was already attempted: ${threadId}`);
1176
1157
  }
1177
- if (disposition === "non-actionable" && !ledger.get(threadId)!.note.trim()) {
1158
+ const entry = ledger.get(threadId)!;
1159
+ if (entry.disposition === "non-actionable" && !entry.note.trim()) {
1178
1160
  throw new Error(`Non-actionable review thread needs a reason: ${threadId}`);
1179
1161
  }
1180
- if (thread.comments.some((comment) => ledger.get(comment.id)?.disposition === "blocked")) {
1181
- throw new Error(`Review thread has blocked child feedback: ${threadId}`);
1182
- }
1183
1162
  }
1184
1163
  const hasReply = (threadId: string) => state.attempts.resolutions.some((attempt) =>
1185
1164
  attempt.threadId === threadId && attempt.step === "reply" && attempt.state === "applied" &&
@@ -25,13 +25,12 @@ import {
25
25
  inspectWorktreeState,
26
26
  parseNulPaths,
27
27
  parseStatusSnapshot,
28
- validateResolvedConflictPaths,
28
+ validatePaths,
29
29
  isAncestor,
30
30
  isRecord,
31
31
  parseSingleOutputLine,
32
32
  readHead,
33
33
  readRemoteOid,
34
- requiredOid,
35
34
  requiredText,
36
35
  runChecked,
37
36
  withWorktreeLock,
@@ -173,7 +172,6 @@ export class PullRequestCreator {
173
172
  private readonly load: Load;
174
173
 
175
174
  constructor(options: CreatePullRequestOptions) {
176
- if (options.target.remoteOid !== null) requiredOid(options.target.remoteOid, "remote OID");
177
175
  this.cwd = options.cwd;
178
176
  this.target = { ...options.target };
179
177
  this.noTarget = options.target.provenance === "inferred" && options.target.remoteOid === null;
@@ -192,7 +190,7 @@ export class PullRequestCreator {
192
190
  }
193
191
 
194
192
  private context(): PullRequestLoadContext {
195
- return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
193
+ return { cwd: this.cwd, signal: this.signal };
196
194
  }
197
195
 
198
196
  private async freshNone() {
@@ -282,8 +280,7 @@ export class PullRequestCreator {
282
280
  if (await inspectWorktreeState(this.exec, this.options()) === "operation") throw new Error("Git operation in progress");
283
281
  const head = await readHead(this.exec, this.options());
284
282
  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");
283
+ const paths = validatePaths([...parseStatusSnapshot(status).keys()], "Pending paths");
287
284
  this.state.pending = { head, status };
288
285
  return { paths, head };
289
286
  }, { agentDir: this.agentDir, signal: this.signal });
@@ -291,7 +288,7 @@ export class PullRequestCreator {
291
288
 
292
289
  async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
293
290
  if (this.state.phase !== "prepared" || !this.state.pending) throw new Error("PR creation has no inspected pending work");
294
- const paths = validateResolvedConflictPaths(pathsInput, []);
291
+ const paths = validatePaths(pathsInput, "Commit paths");
295
292
  if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.state.pending!.status).has(path))) {
296
293
  throw new Error("Commit paths must be reviewed pending paths");
297
294
  }
@@ -111,12 +111,9 @@ export async function withWorktreeLock<T>(
111
111
  options: { agentDir?: string; signal?: AbortSignal } = {},
112
112
  ): Promise<T> {
113
113
  options.signal?.throwIfAborted();
114
- const rootResult = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], {
115
- cwd: requiredText(cwd, "cwd"),
116
- signal: options.signal,
117
- });
114
+ const rootResult = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], { cwd, signal: options.signal });
118
115
  const root = parseSingleOutputLine(rootResult.stdout, "Git worktree root resolution");
119
- const canonical = requiredText(await realpath(root), "canonical Git worktree root");
116
+ const canonical = await realpath(root);
120
117
  const lockNamespace = resolve(extensionConfigDir("pi-pr", options.agentDir));
121
118
  const lockDirectory = join(lockNamespace, "worktree-locks");
122
119
  const identity = createHash("sha256").update(canonical).digest("hex");
@@ -147,7 +144,11 @@ export async function withWorktreeLock<T>(
147
144
  export function parseNulPaths(output: string, label: string): string[] {
148
145
  if (output === "") return [];
149
146
  if (!output.endsWith("\0")) throw new Error(`${label} returned malformed paths`);
150
- const paths = output.slice(0, -1).split("\0");
147
+ return validatePaths(output.slice(0, -1).split("\0"), label);
148
+ }
149
+
150
+ /** Reject unsafe, oversized, or duplicate repository-relative paths. */
151
+ export function validatePaths(paths: readonly string[], label: string): string[] {
151
152
  let bytes = 0;
152
153
  if (paths.length > MAX_CONFLICT_PATHS) throw new Error(`${label} returned more than ${MAX_CONFLICT_PATHS} paths`);
153
154
  for (const path of paths) {
@@ -160,16 +161,7 @@ export function parseNulPaths(output: string, label: string): string[] {
160
161
  }
161
162
  if (bytes > MAX_CONFLICT_PATHS_BYTES) throw new Error(`${label} returned too much path data`);
162
163
  if (new Set(paths).size !== paths.length) throw new Error(`${label} returned duplicate paths`);
163
- return paths;
164
- }
165
-
166
- export function validateResolvedConflictPaths(paths: readonly string[], expected: readonly string[]): string[] {
167
- if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
168
- const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
169
- if (expected.some((path) => !parsed.includes(path))) {
170
- throw new Error("Resolved paths must include every original conflict path");
171
- }
172
- return parsed;
164
+ return [...paths];
173
165
  }
174
166
 
175
167
  function statusPath(record: string): { path: string; rename: boolean } {
@@ -45,8 +45,7 @@ export async function needsFeedbackAttention(
45
45
  const { cwd, agentDir, signal } = options;
46
46
  const exec = options.exec ?? spawnBounded;
47
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 });
48
+ const discovery = await (options.load ?? loadCurrentPullRequest)(extensionExecApi(exec, cwd, signal), { cwd, signal });
50
49
  if (discovery.kind !== "current" || !samePullRequestSnapshot(current, discovery.pullRequest) ||
51
50
  current.base.oid !== discovery.pullRequest.base.oid) {
52
51
  throw new Error("Feedback discovery cancelled: pull request authority changed");
@@ -116,7 +116,7 @@ export type PullRequestObservation = {
116
116
  target: { repository: string; branch: string; remote: string; ref: string };
117
117
  };
118
118
 
119
- export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
119
+ export type PullRequestLoadContext = { cwd: string; signal?: AbortSignal };
120
120
 
121
121
  type PullRequestCreationPreflight = {
122
122
  head: string;
@@ -764,17 +764,20 @@ function parseSearchPage(output: string, pushTarget: Pick<PushTarget, "repositor
764
764
  };
765
765
  }
766
766
 
767
- function matchingSearchPullRequests(candidates: PullRequestCandidate[], pushTarget: PushTarget): PullRequestCandidate[] {
768
- return candidates.filter((candidate) =>
769
- candidate.url.hostname.toLowerCase() === pushTarget.repository.host &&
770
- candidate.headRepository !== null &&
771
- normalizeRepository(candidate.headRepository) === pushTarget.repository.normalizedName &&
772
- candidate.headRef === pushTarget.ref
773
- );
767
+ function matchesPushTarget(
768
+ url: URL,
769
+ headRepository: string | null,
770
+ headRef: string,
771
+ pushTarget: Pick<PushTarget, "repository" | "ref">,
772
+ ): boolean {
773
+ return url.hostname.toLowerCase() === pushTarget.repository.host && headRepository !== null &&
774
+ normalizeRepository(headRepository) === pushTarget.repository.normalizedName && headRef === pushTarget.ref;
774
775
  }
775
776
 
776
777
  function selectSearchPullRequest(candidates: PullRequestCandidate[], pushTarget: PushTarget): SearchSelection {
777
- const matching = matchingSearchPullRequests(candidates, pushTarget);
778
+ const matching = candidates.filter((candidate) =>
779
+ matchesPushTarget(candidate.url, candidate.headRepository, candidate.headRef, pushTarget)
780
+ );
778
781
  const open = matching.filter((candidate) => candidate.lifecycle === "open");
779
782
  if (open.length > 1) return { kind: "ambiguous", urls: open.map(({ url }) => url) };
780
783
  if (open.length === 1) {
@@ -830,33 +833,6 @@ function parsePullRequestPublication(output: string, expectedUrl: URL): PullRequ
830
833
  };
831
834
  }
832
835
 
833
- function selectPullRequest(
834
- candidates: ListedPullRequest[],
835
- pushTarget: PushTarget,
836
- ): ListedPullRequest | null {
837
- const matching = candidates.filter((candidate) =>
838
- candidate.url.hostname.toLowerCase() === pushTarget.repository.host &&
839
- normalizeRepository(candidate.head.repository) === pushTarget.repository.normalizedName &&
840
- candidate.head.ref === pushTarget.ref,
841
- );
842
- const open = matching.filter((candidate) => candidate.lifecycle === "open");
843
- if (open.length > 1) fail("Find pull requests", "multiple open pull requests match current push target");
844
- if (open.length === 1) {
845
- if (pushTarget.remoteHeadOid === null) fail("Find pull requests", "remote push ref is absent for open pull request");
846
- if (open[0].head.oid !== pushTarget.remoteHeadOid) {
847
- fail("Find pull requests", "open pull request head does not match remote push ref");
848
- }
849
- return open[0];
850
- }
851
- if (pushTarget.remoteHeadOid === null) return null;
852
-
853
- const historical = matching.filter((candidate) =>
854
- candidate.lifecycle !== "open" && candidate.head.oid === pushTarget.remoteHeadOid
855
- );
856
- if (historical.length > 1) fail("Find pull requests", "multiple historical pull requests match remote push ref");
857
- return historical[0] ?? null;
858
- }
859
-
860
836
  function ciStatus(checks: CheckState[]): CiStatus {
861
837
  if (!checks.length) return "none";
862
838
  let failed = false;
@@ -958,27 +934,6 @@ function parseBaseRefAuthority(
958
934
  return oid(repository.ref.target.oid, "Read base ref", "target OID");
959
935
  }
960
936
 
961
- function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
962
- return parseBaseRefAuthority(output, candidate.base);
963
- }
964
-
965
- function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
966
- if (!isRecord(target)) fail("Read creation target", "invalid target");
967
- if (target.provenance !== "configured" && target.provenance !== "inferred") {
968
- fail("Read creation target", "invalid provenance");
969
- }
970
- return {
971
- provenance: target.provenance,
972
- branch: text(target.branch, "Read creation target", "branch"),
973
- remote: text(target.remote, "Read creation target", "remote"),
974
- ref: text(target.ref, "Read creation target", "ref"),
975
- repository: repositoryName(target.repository, "Read creation target", "repository"),
976
- host: text(target.host, "Read creation target", "host").toLowerCase(),
977
- fetchSource: text(target.fetchSource, "Read creation target", "fetch source"),
978
- remoteOid: target.remoteOid === null ? null : oid(target.remoteOid, "Read creation target", "remote OID"),
979
- };
980
- }
981
-
982
937
  function sameCreationTarget(left: PullRequestTarget, right: PullRequestTarget): boolean {
983
938
  return left.provenance === right.provenance && left.branch === right.branch &&
984
939
  left.remote === right.remote && left.ref === right.ref &&
@@ -1006,20 +961,19 @@ async function captureCreationIdentity(
1006
961
  context: PullRequestLoadContext,
1007
962
  target: PullRequestTarget,
1008
963
  ): Promise<CreationIdentity> {
1009
- const validatedTarget = validatedCreationTarget(target);
1010
964
  const branch = singleLine(
1011
965
  (await execute(pi, context, "Read creation branch", "git", ["branch", "--show-current"])).stdout,
1012
966
  "Read creation branch",
1013
967
  "branch",
1014
968
  );
1015
- if (branch !== validatedTarget.branch) fail("Read creation branch", "branch changed");
969
+ if (branch !== target.branch) fail("Read creation branch", "branch changed");
1016
970
  await validateCreationRef(pi, context, branch);
1017
971
  const head = oid(singleLine(
1018
972
  (await execute(pi, context, "Read creation HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout,
1019
973
  "Read creation HEAD",
1020
974
  "OID",
1021
975
  ), "Read creation HEAD", "OID");
1022
- return { target: validatedTarget, head };
976
+ return { target, head };
1023
977
  }
1024
978
 
1025
979
  async function readConfiguredCreationBaseRef(
@@ -1139,20 +1093,12 @@ async function inspectCreationWorktree(
1139
1093
  async function preflightCreation(
1140
1094
  pi: Pick<ExtensionAPI, "exec">,
1141
1095
  context: PullRequestLoadContext,
1142
- target: PullRequestTarget,
1143
- explicitBaseRef: string | undefined,
1144
1096
  identity: CreationIdentity,
1145
1097
  ): Promise<CreationPreflightResult> {
1146
- const validatedTarget = validatedCreationTarget(target);
1147
- if (!sameCreationTarget(identity.target, validatedTarget)) {
1148
- fail("Read creation target", "target changed");
1149
- }
1150
1098
  const origin = await readRemoteAuthority(pi, context, "origin", true);
1151
1099
  if (!origin) fail("Read creation repository", "origin is unavailable");
1152
- const configuredBaseRef = explicitBaseRef === undefined
1153
- ? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
1154
- : await validateCreationRef(pi, context, explicitBaseRef);
1155
- const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
1100
+ const baseRef = await readConfiguredCreationBaseRef(pi, context, identity.target.branch) ??
1101
+ await readDefaultCreationBaseRef(pi, context, origin);
1156
1102
  const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
1157
1103
  const worktree = await inspectCreationWorktree(pi, context);
1158
1104
  if (relation === "same-ref") return { kind: relation, worktree };
@@ -1196,10 +1142,9 @@ export async function preflightPullRequestCreation(
1196
1142
  pi: Pick<ExtensionAPI, "exec">,
1197
1143
  context: PullRequestLoadContext,
1198
1144
  target: PullRequestTarget,
1199
- explicitBaseRef?: string,
1200
1145
  ): Promise<PullRequestCreationPreflight> {
1201
1146
  const identity = await captureCreationIdentity(pi, context, target);
1202
- const result = await preflightCreation(pi, context, target, explicitBaseRef, identity);
1147
+ const result = await preflightCreation(pi, context, identity);
1203
1148
  if (result.kind === "same-ref") fail("Read creation repository", "head and base refs match");
1204
1149
  return result.preflight;
1205
1150
  }
@@ -1208,11 +1153,10 @@ async function creationDiscovery(
1208
1153
  pi: Pick<ExtensionAPI, "exec">,
1209
1154
  context: PullRequestLoadContext,
1210
1155
  target: PullRequestTarget,
1211
- explicitBaseRef: string | undefined,
1212
1156
  identity?: CreationIdentity,
1213
1157
  ): Promise<CurrentPullRequestDiscovery> {
1214
1158
  const captured = identity ?? await captureCreationIdentity(pi, context, target);
1215
- const result = await preflightCreation(pi, context, target, explicitBaseRef, captured);
1159
+ const result = await preflightCreation(pi, context, captured);
1216
1160
  return {
1217
1161
  kind: "none",
1218
1162
  creationTarget: target,
@@ -1569,7 +1513,7 @@ async function readBaseRefOid(
1569
1513
  "-F",
1570
1514
  `qualifiedName=refs/heads/${candidate.base.ref}`,
1571
1515
  ]);
1572
- return parseBaseRefOid(result.stdout, candidate);
1516
+ return parseBaseRefAuthority(result.stdout, candidate.base);
1573
1517
  }
1574
1518
 
1575
1519
  export async function readPullRequestBaseRefOid(
@@ -1730,14 +1674,11 @@ export async function findExactHeadPullRequests(
1730
1674
  const host = text(target.host, "Find pull requests", "host").toLowerCase();
1731
1675
  const repository = repositoryName(target.repository, "Find pull requests", "head repository");
1732
1676
  const ref = text(target.ref, "Find pull requests", "head ref");
1733
- const candidates = await enumerateSearchPullRequests(pi, context, {
1734
- repository: { host, nameWithOwner: repository, normalizedName: normalizeRepository(repository) },
1735
- ref,
1736
- });
1677
+ const pushTarget = { repository: { host, nameWithOwner: repository, normalizedName: normalizeRepository(repository) }, ref };
1678
+ const candidates = await enumerateSearchPullRequests(pi, context, pushTarget);
1737
1679
  if (candidates === null) return fail("Find pull requests", "published head ref is unavailable");
1738
1680
  return candidates.filter((candidate) =>
1739
- candidate.lifecycle === "open" && candidate.headRepository !== null &&
1740
- normalizeRepository(candidate.headRepository) === normalizeRepository(repository) && candidate.headRef === ref
1681
+ candidate.lifecycle === "open" && matchesPushTarget(candidate.url, candidate.headRepository, candidate.headRef, pushTarget)
1741
1682
  );
1742
1683
  }
1743
1684
 
@@ -1774,9 +1715,8 @@ export async function loadCurrentPullRequest(
1774
1715
  context: PullRequestLoadContext,
1775
1716
  inspectedLocal?: LocalMergeSafety,
1776
1717
  observed?: unknown,
1777
- explicitCreationBase?: string,
1778
1718
  ): Promise<CurrentPullRequestDiscovery> {
1779
- return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase);
1719
+ return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed);
1780
1720
  }
1781
1721
 
1782
1722
  async function loadCurrentPullRequestInternal(
@@ -1784,7 +1724,6 @@ async function loadCurrentPullRequestInternal(
1784
1724
  context: PullRequestLoadContext,
1785
1725
  inspectedLocal: LocalMergeSafety | undefined,
1786
1726
  observed: unknown,
1787
- explicitCreationBase: string | undefined,
1788
1727
  creationIdentity?: CreationIdentity,
1789
1728
  ): Promise<CurrentPullRequestDiscovery> {
1790
1729
  const read = await readPushTarget(pi, context);
@@ -1809,15 +1748,13 @@ async function loadCurrentPullRequestInternal(
1809
1748
  const target = publicTarget(inferred.target);
1810
1749
  if (creationIdentity === undefined) {
1811
1750
  const captured = await captureCreationIdentity(pi, context, target);
1812
- return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase, captured);
1813
- }
1814
- if (!sameCreationTarget(creationIdentity.target, validatedCreationTarget(target))) {
1815
- fail("Read creation target", "target changed");
1751
+ return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, captured);
1816
1752
  }
1753
+ if (!sameCreationTarget(creationIdentity.target, target)) fail("Read creation target", "target changed");
1817
1754
  if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
1818
1755
  return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
1819
1756
  }
1820
- return await creationDiscovery(pi, context, target, explicitCreationBase, creationIdentity);
1757
+ return await creationDiscovery(pi, context, target, creationIdentity);
1821
1758
  }
1822
1759
  pushTarget = inferred.target;
1823
1760
  } else {
@@ -1860,64 +1797,32 @@ async function loadCurrentPullRequestInternal(
1860
1797
  if (search.kind === "target-invalid") {
1861
1798
  return { kind: "blocked", issue: { kind: "target-invalid" } };
1862
1799
  }
1863
- const candidates = search.kind === "candidate" && search.pullRequest !== null ? [search.pullRequest] : [];
1864
- let candidate: ListedPullRequest | null;
1800
+ // The loaded view may differ from the search candidate; it must still match the push target exactly.
1801
+ const loaded = search.kind === "candidate" ? search.pullRequest : null;
1802
+ const matches = loaded !== null && matchesPushTarget(loaded.url, loaded.head.repository, loaded.head.ref, pushTarget);
1803
+ const oidMismatch = (url: URL): CurrentPullRequestDiscovery => ({
1804
+ kind: "blocked",
1805
+ issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls: [url] },
1806
+ });
1807
+ let candidate: ListedPullRequest;
1865
1808
  if (pushTarget.provenance === "inferred") {
1866
- const matching = candidates.filter((item) =>
1867
- item.lifecycle === "open" &&
1868
- item.url.hostname.toLowerCase() === pushTarget.repository.host &&
1869
- normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName &&
1870
- item.head.ref === pushTarget.ref
1871
- );
1872
- if (matching.length > 1) {
1873
- return {
1874
- kind: "blocked",
1875
- issue: { kind: "candidate-prs-ambiguous", urls: matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href)) },
1876
- };
1877
- }
1878
- if (matching.length === 0) {
1809
+ if (!matches || loaded.lifecycle !== "open") {
1879
1810
  return { kind: "blocked", issue: { kind: "published-without-pr", remote: pushTarget.remote } };
1880
1811
  }
1881
- candidate = matching[0];
1882
- if (candidate.head.oid !== pushTarget.remoteHeadOid) {
1883
- return {
1884
- kind: "blocked",
1885
- issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls: [candidate.url] },
1886
- };
1887
- }
1812
+ if (loaded.head.oid !== pushTarget.remoteHeadOid) return oidMismatch(loaded.url);
1888
1813
  if (!canLinkTarget(await readLinkConfiguration(pi, context, pushTarget), pushTarget)) {
1889
1814
  return { kind: "blocked", issue: { kind: "link-configuration", remote: pushTarget.remote } };
1890
1815
  }
1816
+ candidate = loaded;
1817
+ } else if (matches && loaded.lifecycle === "open") {
1818
+ if (pushTarget.remoteHeadOid === null) return { kind: "blocked", issue: { kind: "target-invalid" } };
1819
+ if (loaded.head.oid !== pushTarget.remoteHeadOid) return oidMismatch(loaded.url);
1820
+ candidate = loaded;
1821
+ } else if (matches && loaded.head.oid === pushTarget.remoteHeadOid) {
1822
+ candidate = loaded;
1891
1823
  } else {
1892
- try {
1893
- candidate = selectPullRequest(candidates, pushTarget);
1894
- } catch (error) {
1895
- if (!(error instanceof PullRequestLoadError)) throw error;
1896
- const matching = candidates.filter((item) =>
1897
- normalizeRepository(item.head.repository) === pushTarget.repository.normalizedName && item.head.ref === pushTarget.ref
1898
- );
1899
- const urls = matching.map(({ url }) => url).sort((a, b) => a.href.localeCompare(b.href));
1900
- if (error.message.includes("multiple ")) {
1901
- return {
1902
- kind: "blocked",
1903
- issue: { kind: "candidate-prs-ambiguous", urls },
1904
- };
1905
- }
1906
- if (error.message.includes("does not match remote push ref")) {
1907
- return {
1908
- kind: "blocked",
1909
- issue: { kind: "candidate-oid-mismatch", remote: pushTarget.remote, urls },
1910
- };
1911
- }
1912
- if (error.message.includes("remote push ref is absent")) {
1913
- return { kind: "blocked", issue: { kind: "target-invalid" } };
1914
- }
1915
- throw error;
1916
- }
1917
- if (candidate === null) {
1918
- if (creationIdentity !== undefined) fail("Read creation target", "target changed");
1919
- return await creationDiscovery(pi, context, publicTarget(pushTarget), explicitCreationBase);
1920
- }
1824
+ if (creationIdentity !== undefined) fail("Read creation target", "target changed");
1825
+ return await creationDiscovery(pi, context, publicTarget(pushTarget));
1921
1826
  }
1922
1827
 
1923
1828
  return {
@@ -3,8 +3,6 @@ import {
3
3
  inspectWorktree,
4
4
  isRecord,
5
5
  readHead,
6
- requiredOid,
7
- requiredText,
8
6
  runChecked,
9
7
  } from "./pr-execution.ts";
10
8
  import type { LocalMergeSafety } from "./pr-routing.ts";
@@ -33,17 +31,8 @@ type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
33
31
  revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
34
32
  };
35
33
 
36
- function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
37
- requiredText(input.cwd, "cwd");
38
- requiredOid(input.expectedHead, "expected PR head");
39
- requiredText(input.headFetchSource, "PR head fetch source");
40
- if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
41
- }
42
-
43
34
  /** Inspect local state without changing branches, the index, or the worktree. */
44
35
  export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
45
- validateInspectionInput(input);
46
-
47
36
  const worktree = await inspectWorktree(input.exec, { cwd: input.cwd });
48
37
  await runChecked(input.exec, "git", [
49
38
  "fetch",
@@ -77,17 +66,6 @@ export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInpu
77
66
  return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid };
78
67
  }
79
68
 
80
- function validateExecuteInput(input: ExecuteGitHubMergeInput): void {
81
- validateInspectionInput(input);
82
- requiredText(input.pullRequestId, "pullRequestId");
83
- requiredText(input.hostname, "hostname");
84
- if (!isRecord(input.expectedBase)) throw new TypeError("expectedBase must be an object");
85
- requiredText(input.expectedBase.repository, "expected base repository");
86
- requiredText(input.expectedBase.ref, "expected base ref");
87
- requiredText(input.expectedBase.oid, "expected base OID");
88
- if (typeof input.revalidateReadiness !== "function") throw new TypeError("revalidateReadiness must be a function");
89
- }
90
-
91
69
  function parseGraphQLResponse(output: string, action: string): Record<string, unknown> {
92
70
  let value: unknown;
93
71
  try {
@@ -124,7 +102,6 @@ function parseMergeResponse(output: string, expectedId: string): void {
124
102
  }
125
103
 
126
104
  export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise<void> {
127
- validateExecuteInput(input);
128
105
  const local = await inspectLocalMergeSafety(input);
129
106
  if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) {
130
107
  throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
@@ -1,6 +1,6 @@
1
1
  import { spawnBounded, type Exec } from "@henryqw/pi-process";
2
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";
3
+ import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked, validatePaths, withWorktreeLock } from "./pr-execution.ts";
4
4
 
5
5
  type Check = { command: string; args: string[] };
6
6
  type Options = { cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
@@ -33,12 +33,11 @@ export class PullRequestWorkPublisher {
33
33
 
34
34
  private async authorityCheck(): Promise<void> {
35
35
  const { cwd, signal } = this.options;
36
- const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal: signal ?? new AbortController().signal });
36
+ const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal });
37
37
  if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest)) {
38
38
  throw new Error("Local publication cancelled: PR identity or remote head changed");
39
39
  }
40
- const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal),
41
- { cwd, signal: signal ?? new AbortController().signal }, this.authority.target.remote);
40
+ const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal), { cwd, signal }, this.authority.target.remote);
42
41
  if (remote.fetchSource !== this.authority.target.fetchSource || remote.host !== this.authority.target.host ||
43
42
  remote.repository.toLowerCase() !== this.authority.target.repository.toLowerCase()) {
44
43
  throw new Error("Local publication cancelled: push destination changed");
@@ -60,8 +59,7 @@ export class PullRequestWorkPublisher {
60
59
  throw new Error("Local HEAD is not a descendant of the published PR head");
61
60
  }
62
61
  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");
62
+ const paths = validatePaths([...parseStatusSnapshot(status).keys()], "Pending paths");
65
63
  this.initialHead = head;
66
64
  this.status = status;
67
65
  return { paths, head };
@@ -70,7 +68,7 @@ export class PullRequestWorkPublisher {
70
68
 
71
69
  async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
72
70
  if (!this.initialHead || this.status === undefined || this.consumed) throw new Error("Pending changes must be inspected first");
73
- const paths = validateResolvedConflictPaths(pathsInput, []);
71
+ const paths = validatePaths(pathsInput, "Commit paths");
74
72
  if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.status!).has(path))) {
75
73
  throw new Error("Commit paths must be reviewed pending paths");
76
74
  }
@@ -94,10 +92,6 @@ export class PullRequestWorkPublisher {
94
92
 
95
93
  async validate(checks: Check[]): Promise<{ head: string; checks: number }> {
96
94
  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
95
  return await withWorktreeLock(this.options.cwd, async () => {
102
96
  await this.authorityCheck();
103
97
  if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Unrelated pending changes remain; ask about ownership before publishing");
@@ -24,7 +24,7 @@ import {
24
24
  requiredOid,
25
25
  resolveRepositoryFetchSource,
26
26
  runChecked,
27
- validateResolvedConflictPaths,
27
+ validatePaths,
28
28
  withWorktreeLock,
29
29
  } from "./pr-execution.ts";
30
30
 
@@ -60,6 +60,8 @@ function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): b
60
60
  type RebaseRecovery = { version: 1; phase: "pending" | "verified" | "published"; identity: string;
61
61
  original: string; remote: string; verified: string | null };
62
62
 
63
+ const isOid = (value: unknown): value is string => typeof value === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
64
+
63
65
  function recoveryIdentity(pr: CurrentPullRequest): string {
64
66
  return JSON.stringify([pr.id, pr.number, pr.url.href, pr.host, pr.base.repository, pr.base.ref, pr.base.oid,
65
67
  pr.head.repository, pr.head.ref, pr.target.provenance, pr.target.branch, pr.target.remote,
@@ -83,9 +85,7 @@ async function readRecovery(path: string, signal?: AbortSignal): Promise<RebaseR
83
85
  if (!isRecord(record) || Object.keys(record).sort().join(",") !== "identity,original,phase,remote,verified,version" ||
84
86
  record.version !== 1 || !["pending", "verified", "published"].includes(record.phase as string) ||
85
87
  typeof record.identity !== "string" || record.identity.length > 2048 ||
86
- typeof record.original !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.original) ||
87
- typeof record.remote !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.remote) ||
88
- (record.verified !== null && (typeof record.verified !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.verified))) ||
88
+ !isOid(record.original) || !isOid(record.remote) || (record.verified !== null && !isOid(record.verified)) ||
89
89
  (record.phase === "pending" && record.verified !== null) ||
90
90
  (record.phase !== "pending" && record.verified === null)) {
91
91
  throw new Error(`Invalid branch update recovery is preserved at ${path}`);
@@ -142,7 +142,7 @@ export class PullRequestBranchUpdater {
142
142
  }
143
143
 
144
144
  private context(): PullRequestLoadContext {
145
- return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
145
+ return { cwd: this.cwd, signal: this.signal };
146
146
  }
147
147
 
148
148
  private async freshAuthority(expectedHead: string, requireClean: boolean, published = false): Promise<CurrentPullRequest> {
@@ -272,7 +272,10 @@ export class PullRequestBranchUpdater {
272
272
  if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
273
273
  throw new Error("Branch rebase has no conflict awaiting continuation");
274
274
  }
275
- const paths = validateResolvedConflictPaths(resolvedPaths, this.state.conflict.paths);
275
+ const paths = validatePaths(resolvedPaths, "Resolved conflict paths");
276
+ if (this.state.conflict.paths.some((path) => !paths.includes(path))) {
277
+ throw new Error("Resolved paths must include every original conflict path");
278
+ }
276
279
  return await withWorktreeLock(this.cwd, async () => {
277
280
  const discovery = await this.load(this.pi(), this.context());
278
281
  if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
package/extensions/pr.ts CHANGED
@@ -8,12 +8,14 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
10
10
  import { createHerdrClient } from "@henryqw/pi-herdr";
11
- import { Type } from "typebox";
11
+ import { Type, type Static, type TObject, type TSchema, type TUnion } from "typebox";
12
+ import { Check, Errors } from "typebox/value";
12
13
  import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
13
14
  import { PullRequestCommentSweep, type PullRequestCommentSweepOptions } from "./pr-comment-sweep.ts";
14
15
  import { needsFeedbackAttention } from "./pr-feedback-attention.ts";
15
16
  import {
16
17
  createPrCommandHandler,
18
+ WORKFLOW_ROUTES,
17
19
  type PrCommandDependencies,
18
20
  type PrCommandInvocation,
19
21
  type WorkflowPromptIdentity,
@@ -48,7 +50,6 @@ const OBSERVATION_ENTRY = "pi-pr-observation";
48
50
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
49
51
  const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
50
52
  const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
51
- const WORKFLOW_ROUTES = new Set(["create", "publish-work", "update-branch", "sweep", "fix-ci"]);
52
53
  const DELEGATED_TOOLS = new Set(["delegate_task"]);
53
54
  const CLOSED = { additionalProperties: false } as const;
54
55
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -76,13 +77,38 @@ const SweepChecks = Type.Array(Type.Object({
76
77
  args: Type.Array(Type.String({ maxLength: 4_096 }), { maxItems: 256 }),
77
78
  }, CLOSED), { maxItems: 32 });
78
79
 
79
- // OpenAI-compatible endpoints require an object root even when every union variant is an object.
80
- const UpdateBranchParameters = Type.Union([
80
+ // Strict OpenAI-compatible endpoints reject a parameters root without type "object", and Claude Code
81
+ // drops tools whose root is a union. Register a flat object root derived from the per-action variants
82
+ // and enforce the exact variant at execution (also after any before_tool argument replacement).
83
+ function flatRoot(actions: TUnion<TObject[]>): TObject {
84
+ const properties = new Map<string, TSchema[]>();
85
+ const requiredCount = new Map<string, number>();
86
+ for (const variant of actions.anyOf) {
87
+ for (const [key, schema] of Object.entries(variant.properties)) {
88
+ const seen = properties.get(key) ?? [];
89
+ if (!seen.some((candidate) => JSON.stringify(candidate) === JSON.stringify(schema))) seen.push(schema);
90
+ properties.set(key, seen);
91
+ if (variant.required?.includes(key)) requiredCount.set(key, (requiredCount.get(key) ?? 0) + 1);
92
+ }
93
+ }
94
+ return Type.Object(Object.fromEntries([...properties].map(([key, schemas]) => {
95
+ const schema = schemas.length === 1 ? schemas[0]! : Type.Union(schemas);
96
+ return [key, requiredCount.get(key) === actions.anyOf.length ? schema : Type.Optional(schema)];
97
+ })), CLOSED);
98
+ }
99
+
100
+ function checkedAction<T extends TUnion<TObject[]>>(actions: T, args: unknown): Static<T> {
101
+ if (Check(actions, args)) return args as Static<T>;
102
+ const issue = [...Errors(actions, args)][0];
103
+ throw new Error(`Tool arguments do not match one action${issue ? `: ${issue.message}` : ""}`);
104
+ }
105
+
106
+ const UpdateBranchActions = Type.Union([
81
107
  Type.Object({ runId: RouteRunId, action: Type.Literal("rebase") }, CLOSED),
82
108
  Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
83
109
  Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
84
- ], { type: "object" });
85
- const CreateParameters = Type.Union([
110
+ ]);
111
+ const CreateActions = Type.Union([
86
112
  Type.Object({ runId: RouteRunId, action: Type.Literal("prepare") }, CLOSED),
87
113
  Type.Object({ runId: RouteRunId, action: Type.Literal("inspect") }, CLOSED),
88
114
  Type.Object({ runId: RouteRunId, action: Type.Literal("commit"), ownedPaths: OwnedPaths, message: Type.String({ minLength: 1, maxLength: 256 }) }, CLOSED),
@@ -94,8 +120,8 @@ const CreateParameters = Type.Union([
94
120
  title: Type.String({ minLength: 1, maxLength: 256 }),
95
121
  body: Type.String({ maxLength: 65_536 }),
96
122
  }, CLOSED),
97
- ], { type: "object" });
98
- const SweepParameters = Type.Union([
123
+ ]);
124
+ const SweepActions = Type.Union([
99
125
  Type.Object({ runId: RouteRunId, action: Type.Literal("start") }, CLOSED),
100
126
  Type.Object({ runId: RouteRunId, action: Type.Literal("resume") }, CLOSED),
101
127
  Type.Object({ runId: RouteRunId, action: Type.Literal("show"), guard: SweepGuard, id: Type.String({ minLength: 1, maxLength: 1_024 }) }, CLOSED),
@@ -111,17 +137,17 @@ const SweepParameters = Type.Union([
111
137
  Type.Object({ runId: RouteRunId, action: Type.Literal("refresh"), guard: SweepGuard }, CLOSED),
112
138
  Type.Object({ runId: RouteRunId, action: Type.Literal("resolve"), guard: SweepGuard }, CLOSED),
113
139
  Type.Object({ runId: RouteRunId, action: Type.Literal("finalize"), guard: SweepGuard, checks: SweepChecks }, CLOSED),
114
- ], { type: "object" });
115
- const WorkParameters = Type.Union([
140
+ ]);
141
+ const WorkActions = Type.Union([
116
142
  Type.Object({ runId: RouteRunId, action: Type.Literal("inspect") }, CLOSED),
117
143
  Type.Object({ runId: RouteRunId, action: Type.Literal("commit"), ownedPaths: OwnedPaths, message: Type.String({ minLength: 1, maxLength: 256 }) }, CLOSED),
118
144
  Type.Object({ runId: RouteRunId, action: Type.Literal("validate"), checks: SweepChecks }, CLOSED),
119
145
  Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
120
- ], { type: "object" });
121
- const FixCiParameters = Type.Union([
146
+ ]);
147
+ const FixCiActions = Type.Union([
122
148
  Type.Object({ runId: RouteRunId, action: Type.Literal("collect") }, CLOSED),
123
149
  Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
124
- ], { type: "object" });
150
+ ]);
125
151
 
126
152
  type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "recoveryLaunchAction" | "rebase" | "continue" | "publish">;
127
153
  type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "inspect" | "commit" | "verify" | "push" | "publish">;
@@ -247,15 +273,9 @@ export default function pullRequestExtension(
247
273
  const newRunId = dependencies.newRunId ?? randomUUID;
248
274
  let context: ExtensionContext | undefined;
249
275
  let observation: PullRequestObservation | undefined;
250
- const load: typeof loadCurrentPullRequest = async (
251
- api,
252
- loadContext,
253
- inspectedLocal,
254
- _observed,
255
- explicitCreationBase,
256
- ) => {
276
+ const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
257
277
  const generation = sessionGeneration;
258
- const discovery = await discover(api, loadContext, inspectedLocal, observation, explicitCreationBase);
278
+ const discovery = await discover(api, loadContext, inspectedLocal, observation);
259
279
  if (generation !== sessionGeneration) return discovery;
260
280
  if (discovery.kind === "current") {
261
281
  const current = pullRequestObservation(discovery.pullRequest);
@@ -302,7 +322,6 @@ export default function pullRequestExtension(
302
322
  invocation.assertCurrent();
303
323
  if (workflowContext) throw new Error(`PR workflow ${workflowContext.runId} is still active`);
304
324
  const runId = newRunId();
305
- if (!UUID.test(runId)) throw new Error("PR workflow runId generator returned an invalid UUID");
306
325
  const common: WorkflowContextBase = {
307
326
  runId,
308
327
  sessionGeneration: invocation.sessionGeneration,
@@ -444,9 +463,10 @@ export default function pullRequestExtension(
444
463
  name: "pi_pr_update_branch",
445
464
  label: "Update PR Branch",
446
465
  description: "Run one guarded action for the /pr branch-update route.",
447
- parameters: UpdateBranchParameters,
466
+ parameters: flatRoot(UpdateBranchActions),
448
467
  executionMode: "sequential",
449
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
468
+ async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
469
+ const params = checkedAction(UpdateBranchActions, raw);
450
470
  return executeWorkflowAction(params.runId, "update-branch", ctx, signal, async (selected) => {
451
471
  switch (params.action) {
452
472
  case "rebase": return await selected.workflow.rebase();
@@ -465,9 +485,10 @@ export default function pullRequestExtension(
465
485
  name: "pi_pr_create",
466
486
  label: "Create Pull Request",
467
487
  description: "Run one guarded action for the /pr creation route.",
468
- parameters: CreateParameters,
488
+ parameters: flatRoot(CreateActions),
469
489
  executionMode: "sequential",
470
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
490
+ async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
491
+ const params = checkedAction(CreateActions, raw);
471
492
  return executeWorkflowAction(params.runId, "create", ctx, signal, async (selected) => {
472
493
  switch (params.action) {
473
494
  case "prepare": return await selected.workflow.prepare();
@@ -489,9 +510,10 @@ export default function pullRequestExtension(
489
510
  name: "pi_pr_publish_work",
490
511
  label: "Publish PR Work",
491
512
  description: "Inspect, commit, validate, and publish only reviewed local work for the /pr route.",
492
- parameters: WorkParameters,
513
+ parameters: flatRoot(WorkActions),
493
514
  executionMode: "sequential",
494
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
515
+ async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
516
+ const params = checkedAction(WorkActions, raw);
495
517
  return executeWorkflowAction(params.runId, "publish-work", ctx, signal, async (selected) => {
496
518
  switch (params.action) {
497
519
  case "inspect": return await selected.workflow.inspect();
@@ -511,9 +533,10 @@ export default function pullRequestExtension(
511
533
  name: "pi_pr_sweep",
512
534
  label: "Sweep PR Feedback",
513
535
  description: "Run one guarded action for the /pr feedback route.",
514
- parameters: SweepParameters,
536
+ parameters: flatRoot(SweepActions),
515
537
  executionMode: "sequential",
516
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
538
+ async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
539
+ const params = checkedAction(SweepActions, raw);
517
540
  return executeWorkflowAction(params.runId, "sweep", ctx, signal, async (selected) => {
518
541
  switch (params.action) {
519
542
  case "start": return await selected.workflow.start();
@@ -538,9 +561,10 @@ export default function pullRequestExtension(
538
561
  name: "pi_pr_fix_ci",
539
562
  label: "Fix PR CI",
540
563
  description: "Run one guarded action for the /pr failed-CI route.",
541
- parameters: FixCiParameters,
564
+ parameters: flatRoot(FixCiActions),
542
565
  executionMode: "sequential",
543
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
566
+ async execute(_toolCallId, raw, signal, _onUpdate, ctx) {
567
+ const params = checkedAction(FixCiActions, raw);
544
568
  return executeWorkflowAction(params.runId, "fix-ci", ctx, signal, async (selected) => {
545
569
  switch (params.action) {
546
570
  case "collect": return await selected.workflow.collect();
@@ -624,9 +648,6 @@ export default function pullRequestExtension(
624
648
  }
625
649
  const display = projectPrDisplay(discovery);
626
650
  const footer = formatPrFooter(display, ctx.ui.theme);
627
- if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
628
- throw new Error("Pull request display is missing a footer");
629
- }
630
651
  displayedWidget = display.widget === undefined ? undefined : display;
631
652
  ctx.ui.setStatus(UI_KEY, footer);
632
653
  reconcileWidget(ctx);
@@ -674,25 +695,15 @@ export default function pullRequestExtension(
674
695
  const category = error instanceof GitHubRateLimitError ? "quota" : "generic";
675
696
  if (!ctx || reportedRefreshFailure === category || reportedRefreshFailure === "quota") return;
676
697
  reportedRefreshFailure = category;
677
- try {
678
- ctx.ui.notify(
679
- error instanceof GitHubRateLimitError
680
- ? error.message
681
- : "PR status refresh failed: status unavailable",
682
- "error",
683
- );
684
- } catch {
685
- console.error("PR status refresh failed and could not be reported");
686
- }
698
+ ctx.ui.notify(
699
+ error instanceof GitHubRateLimitError ? error.message : "PR status refresh failed: status unavailable",
700
+ "error",
701
+ );
687
702
  };
688
703
 
689
704
  const reportHerdrRenameFailure = (ctx: ExtensionContext, error: unknown): void => {
690
- try {
691
- const message = error instanceof Error ? error.message : String(error);
692
- ctx.ui.notify(`Herdr workspace rename failed: ${message.slice(0, 500)}`, "warning");
693
- } catch (reportError) {
694
- console.error("Herdr workspace rename failed and could not be reported", error, reportError);
695
- }
705
+ const message = error instanceof Error ? error.message : String(error);
706
+ ctx.ui.notify(`Herdr workspace rename failed: ${message.slice(0, 500)}`, "warning");
696
707
  };
697
708
 
698
709
  const refresh = async (): Promise<void> => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "6.2.7",
3
+ "version": "6.2.10",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,8 +27,8 @@
27
27
  "pack:check": "npm pack --dry-run"
28
28
  },
29
29
  "peerDependencies": {
30
- "@earendil-works/pi-coding-agent": ">=0.86.0 <0.88.0",
31
- "@earendil-works/pi-tui": ">=0.86.0 <0.88.0",
30
+ "@earendil-works/pi-coding-agent": ">=0.86.0 <0.100.0",
31
+ "@earendil-works/pi-tui": ">=0.86.0 <0.100.0",
32
32
  "typebox": "^1.3.15"
33
33
  },
34
34
  "dependencies": {
@@ -38,8 +38,8 @@
38
38
  "proper-lockfile": "^4.1.2"
39
39
  },
40
40
  "devDependencies": {
41
- "@earendil-works/pi-coding-agent": "0.87.0",
42
- "@earendil-works/pi-tui": "0.87.0",
41
+ "@earendil-works/pi-coding-agent": "0.99.1",
42
+ "@earendil-works/pi-tui": "0.99.1",
43
43
  "@types/proper-lockfile": "^4.1.4"
44
44
  },
45
45
  "repository": {