@henryqw/pi-pr 3.1.2 → 3.1.4

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
@@ -21,10 +21,13 @@ keeps the complete JSON output. Read one item with
21
21
  `pr-feedback.mjs show --snapshot FILE --id ID`.
22
22
 
23
23
  `show` prints one JSON record with its exact stored body and fields. A thread
24
- record includes child IDs without child bodies. A nested comment includes only
25
- its parent thread's ID, state, and location. Missing, unknown, duplicate, or
26
- ambiguous IDs fail. `show` does not run Git, call GitHub, use the network, or
27
- write files.
24
+ record includes child IDs without child bodies. Treat that record as a container.
25
+ Inspect the `thread_comment` IDs directly. Do not call `show` on the parent only
26
+ to find children. Show the parent only when it has no child, or when you need
27
+ parent-level metadata. Issue independent `show` lookups in one tool-call round.
28
+ A nested comment includes only its parent thread's ID, state, and location.
29
+ Missing, unknown, duplicate, or ambiguous IDs fail. `show` does not run Git, call
30
+ GitHub, use the network, or write files.
28
31
 
29
32
  ## Works with
30
33
 
@@ -73,9 +76,9 @@ Multiple candidate remotes, multiple matching PRs, OID mismatches, and unsafe Gi
73
76
 
74
77
  The creation workflow repeats destination, remote OID, PR, and configuration checks immediately before pushing. It pushes to the saved validated URL, not a mutable remote name. Every push uses the saved remote OID as an exact lease. Existing refs must also be ancestors of the captured local OID. A missing ref uses an empty lease as a create-only compare-and-swap.
75
78
 
76
- After a `/pr` create workflow settles, the extension waits for a refresh that finds an open current PR. It then prefixes the Herdr workspace label with `#<number> • `.
79
+ After a `/pr` create workflow settles, the extension waits for a refresh that finds a configured current PR. It then prefixes the Herdr workspace label with `#<number> • `.
77
80
 
78
- Failed or empty discovery leaves one rename pending for a later refresh. Closed or merged historical matches do not trigger it.
81
+ Failed or empty discovery leaves one rename pending for a later refresh. A restored configured PR completes the rename even when GitHub reports it as merged or closed.
79
82
 
80
83
  It removes repeated leading `#<number> • ` prefixes and legacy trailing ` · PR #<number>` suffixes before adding one current prefix. The remaining workspace name must be non-empty. This requires `HERDR_ENV=1` and a non-empty, trimmed `HERDR_WORKSPACE_ID`.
81
84
 
@@ -106,12 +109,20 @@ The comment sweep resolves its bundled helper and references from the installed
106
109
 
107
110
  The footer and widget load at session start. A directory outside a Git worktree stays silent and does not start polling. The UI shows `PR · status unavailable` for other discovery failures and reports only a generic error.
108
111
 
109
- They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. They also refresh after any successful delegated task settles. Active Git worktrees poll every 30 seconds. Polling updates presentation only and may be stale.
112
+ They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. Active Git worktrees poll every 30 seconds. Polling updates presentation only and may be stale.
110
113
 
111
114
  The create widget stays hidden until the local branch has a commit beyond its creation point. Any displayed widget clears as soon as `/pr` starts. A dispatched workflow keeps it hidden until the agent settles. A direct merge, no-action route, or failed command refreshes the widget when the handler finishes.
112
115
 
113
116
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
114
117
 
118
+ ### Session identity
119
+
120
+ The extension records one configured PR identity in the Pi session. It stores only the PR URL, number, host, head identity, and configured target identity. It does not store lifecycle, CI, review, readiness, or base state. Repeated polling does not add duplicate entries, and no repository cache file is created.
121
+
122
+ Normal discovery always runs first. If the configured remote ref was deleted, the footer and `/pr` may reload the exact observed PR URL. The current host, repository, branch, remote, ref, and local HEAD must still match the observation. Repository names use case-insensitive GitHub matching.
123
+
124
+ The GitHub response must match the observed URL, host, repository, head ref, head OID, and PR number. GitHub supplies fresh mutable state. Invalid session data is ignored. A failed GitHub lookup stops routing and cannot start PR creation.
125
+
115
126
  ## Limits and recovery
116
127
 
117
128
  - `/pr` takes no arguments and does not open a browser.
@@ -95,6 +95,12 @@ export type CurrentPullRequest = PullRequest & {
95
95
 
96
96
  export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
97
97
 
98
+ export type PullRequestObservation = {
99
+ pullRequest: { url: string; number: number; host: string };
100
+ head: PullRequestRef;
101
+ target: { repository: string; branch: string; remote: string; ref: string };
102
+ };
103
+
98
104
  export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
99
105
 
100
106
  type CommandOutput = {
@@ -423,6 +429,67 @@ function parsePullRequestUrl(value: unknown, number: number): { url: URL; reposi
423
429
  };
424
430
  }
425
431
 
432
+ export function parsePullRequestObservation(value: unknown): PullRequestObservation | null {
433
+ try {
434
+ if (!isRecord(value) || !isRecord(value.pullRequest) || !isRecord(value.head) || !isRecord(value.target)) {
435
+ return null;
436
+ }
437
+ const number = value.pullRequest.number;
438
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0) return null;
439
+ const parsedUrl = parsePullRequestUrl(value.pullRequest.url, number).url;
440
+ const host = text(value.pullRequest.host, "Read pull request observation", "host").toLowerCase();
441
+ if (parsedUrl.hostname.toLowerCase() !== host) return null;
442
+ return {
443
+ pullRequest: { url: parsedUrl.href, number, host },
444
+ head: {
445
+ repository: repositoryName(value.head.repository, "Read pull request observation", "head repository"),
446
+ ref: text(value.head.ref, "Read pull request observation", "head ref"),
447
+ oid: oid(value.head.oid, "Read pull request observation", "head OID"),
448
+ },
449
+ target: {
450
+ repository: repositoryName(value.target.repository, "Read pull request observation", "target repository"),
451
+ branch: text(value.target.branch, "Read pull request observation", "target branch"),
452
+ remote: text(value.target.remote, "Read pull request observation", "target remote"),
453
+ ref: text(value.target.ref, "Read pull request observation", "target ref"),
454
+ },
455
+ };
456
+ } catch (error) {
457
+ if (error instanceof PullRequestLoadError) return null;
458
+ throw error;
459
+ }
460
+ }
461
+
462
+ export function pullRequestObservation(pullRequest: CurrentPullRequest): PullRequestObservation | null {
463
+ if (pullRequest.target.provenance !== "configured") return null;
464
+ return {
465
+ pullRequest: {
466
+ url: pullRequest.url.href,
467
+ number: pullRequest.number,
468
+ host: pullRequest.host,
469
+ },
470
+ head: { ...pullRequest.head },
471
+ target: {
472
+ repository: pullRequest.target.repository,
473
+ branch: pullRequest.target.branch,
474
+ remote: pullRequest.target.remote,
475
+ ref: pullRequest.target.ref,
476
+ },
477
+ };
478
+ }
479
+
480
+ export function samePullRequestObservation(
481
+ left: PullRequestObservation | undefined,
482
+ right: PullRequestObservation,
483
+ ): boolean {
484
+ return left !== undefined && left.pullRequest.url === right.pullRequest.url &&
485
+ left.pullRequest.number === right.pullRequest.number && left.pullRequest.host === right.pullRequest.host &&
486
+ normalizeRepository(left.head.repository) === normalizeRepository(right.head.repository) &&
487
+ left.head.ref === right.head.ref && left.head.oid === right.head.oid &&
488
+ normalizeRepository(left.target.repository) === normalizeRepository(right.target.repository) &&
489
+ left.target.branch === right.target.branch && left.target.remote === right.target.remote &&
490
+ left.target.ref === right.target.ref;
491
+ }
492
+
426
493
  function lifecycle(value: unknown): PullRequestLifecycle {
427
494
  if (value === "OPEN") return "open";
428
495
  if (value === "MERGED") return "merged";
@@ -1332,6 +1399,45 @@ async function loadPullRequestDetails(
1332
1399
  };
1333
1400
  }
1334
1401
 
1402
+ async function loadObservedPullRequest(
1403
+ pi: Pick<ExtensionAPI, "exec">,
1404
+ context: PullRequestLoadContext,
1405
+ pushTarget: PushTarget,
1406
+ observation: PullRequestObservation | null,
1407
+ inspectedLocal?: LocalMergeSafety,
1408
+ ): Promise<CurrentPullRequest | null> {
1409
+ if (
1410
+ observation === null || observation.pullRequest.host !== pushTarget.repository.host ||
1411
+ normalizeRepository(observation.target.repository) !== pushTarget.repository.normalizedName ||
1412
+ observation.target.branch !== pushTarget.branch || observation.target.remote !== pushTarget.remote ||
1413
+ observation.target.ref !== pushTarget.ref
1414
+ ) return null;
1415
+
1416
+ const localHead = oid(singleLine((await execute(pi, context, "Read local HEAD", "git", [
1417
+ "rev-parse", "--verify", "HEAD^{commit}",
1418
+ ])).stdout, "Read local HEAD", "OID"), "Read local HEAD", "OID");
1419
+ if (localHead !== observation.head.oid) return null;
1420
+
1421
+ const loaded = await execute(pi, context, "Load observed pull request", "gh", [
1422
+ "pr",
1423
+ "view",
1424
+ observation.pullRequest.url,
1425
+ "--json",
1426
+ PR_FIELDS,
1427
+ ]);
1428
+ const candidate = parseLoadedPullRequest(loaded.stdout, new URL(observation.pullRequest.url));
1429
+ if (candidate === null) fail("Load observed pull request", "pull request head repository is unavailable");
1430
+ if (
1431
+ candidate.number !== observation.pullRequest.number ||
1432
+ candidate.url.hostname.toLowerCase() !== observation.pullRequest.host ||
1433
+ normalizeRepository(candidate.head.repository) !== normalizeRepository(observation.head.repository) ||
1434
+ normalizeRepository(candidate.head.repository) !== pushTarget.repository.normalizedName ||
1435
+ candidate.head.ref !== observation.head.ref || candidate.head.ref !== pushTarget.ref ||
1436
+ candidate.head.oid !== observation.head.oid
1437
+ ) return null;
1438
+ return loadPullRequestDetails(pi, context, candidate, pushTarget, inspectedLocal);
1439
+ }
1440
+
1335
1441
  async function searchPullRequests(
1336
1442
  pi: Pick<ExtensionAPI, "exec">,
1337
1443
  context: PullRequestLoadContext,
@@ -1399,6 +1505,7 @@ export async function loadCurrentPullRequest(
1399
1505
  pi: Pick<ExtensionAPI, "exec">,
1400
1506
  context: PullRequestLoadContext,
1401
1507
  inspectedLocal?: LocalMergeSafety,
1508
+ observed?: unknown,
1402
1509
  ): Promise<CurrentPullRequestDiscovery> {
1403
1510
  const read = await readPushTarget(pi, context);
1404
1511
  if (read.kind === "inactive") return { kind: "inactive" };
@@ -1430,6 +1537,19 @@ export async function loadCurrentPullRequest(
1430
1537
  }
1431
1538
 
1432
1539
  const search = await searchPullRequests(pi, context, pushTarget);
1540
+ if (
1541
+ pushTarget.provenance === "configured" && pushTarget.remoteHeadOid === null &&
1542
+ (search.kind === "none" || search.kind === "target-invalid")
1543
+ ) {
1544
+ const restored = await loadObservedPullRequest(
1545
+ pi,
1546
+ context,
1547
+ pushTarget,
1548
+ parsePullRequestObservation(observed),
1549
+ inspectedLocal,
1550
+ );
1551
+ if (restored !== null) return { kind: "current", pullRequest: restored };
1552
+ }
1433
1553
  if (search.kind === "ambiguous") {
1434
1554
  return {
1435
1555
  kind: "blocked",
package/extensions/pr.ts CHANGED
@@ -8,6 +8,10 @@ import { createPrCommandHandler } from "./pr-command.ts";
8
8
  import {
9
9
  hasLocalCommit,
10
10
  loadCurrentPullRequest,
11
+ parsePullRequestObservation,
12
+ pullRequestObservation,
13
+ samePullRequestObservation,
14
+ type PullRequestObservation,
11
15
  } from "./pr-github.ts";
12
16
  import {
13
17
  discoveryIssueKey,
@@ -22,6 +26,7 @@ import {
22
26
  const POLL_INTERVAL_MS = 30_000;
23
27
  const HERDR_TIMEOUT_MS = 10_000;
24
28
  const UI_KEY = "pi-pr";
29
+ const OBSERVATION_ENTRY = "pi-pr-observation";
25
30
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
26
31
  const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
27
32
  const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
@@ -51,6 +56,14 @@ function parseWorkspaceLabel(response: Record<string, unknown>, workspaceId: str
51
56
  return label;
52
57
  }
53
58
 
59
+ function latestObservation(ctx: ExtensionContext): PullRequestObservation | undefined {
60
+ for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
61
+ if (entry.type !== "custom" || entry.customType !== OBSERVATION_ENTRY) continue;
62
+ const observation = parsePullRequestObservation(entry.data);
63
+ if (observation !== null) return observation;
64
+ }
65
+ }
66
+
54
67
  export default function pullRequestExtension(
55
68
  pi: ExtensionAPI,
56
69
  dependencies: PullRequestExtensionDependencies = {},
@@ -86,10 +99,24 @@ export default function pullRequestExtension(
86
99
  signal.throwIfAborted();
87
100
  };
88
101
 
89
- const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
102
+ const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
90
103
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
91
104
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
92
105
  let context: ExtensionContext | undefined;
106
+ let observation: PullRequestObservation | undefined;
107
+ const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
108
+ const generation = sessionGeneration;
109
+ const discovery = await discover(api, loadContext, inspectedLocal, observation);
110
+ if (generation !== sessionGeneration) return discovery;
111
+ if (discovery.kind === "current") {
112
+ const current = pullRequestObservation(discovery.pullRequest);
113
+ if (current !== null && !samePullRequestObservation(observation, current)) {
114
+ pi.appendEntry(OBSERVATION_ENTRY, current);
115
+ observation = current;
116
+ }
117
+ }
118
+ return discovery;
119
+ };
93
120
  let sessionGeneration = 0;
94
121
  let timer: ReturnType<typeof setInterval> | undefined;
95
122
  let active: AbortController | undefined;
@@ -164,6 +191,7 @@ export default function pullRequestExtension(
164
191
  const stop = (): void => {
165
192
  sessionGeneration += 1;
166
193
  context = undefined;
194
+ observation = undefined;
167
195
  queued = false;
168
196
  refreshFailureReported = false;
169
197
  displayEstablished = false;
@@ -203,7 +231,7 @@ export default function pullRequestExtension(
203
231
 
204
232
  const refresh = async (): Promise<void> => {
205
233
  const ctx = context;
206
- if (!ctx) return;
234
+ if (!ctx || [...activeInvocations.values()].includes("create-workflow")) return;
207
235
  const generation = sessionGeneration;
208
236
  if (active) {
209
237
  queued = true;
@@ -240,10 +268,7 @@ export default function pullRequestExtension(
240
268
  refreshFailureReported = false;
241
269
 
242
270
  const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
243
- if (
244
- pendingWorkspaceRename && pullRequest?.lifecycle === "open" &&
245
- pullRequest.target.provenance === "configured"
246
- ) {
271
+ if (pendingWorkspaceRename && pullRequest?.target.provenance === "configured") {
247
272
  pendingWorkspaceRename = false;
248
273
  const workspaceId = process.env.HERDR_WORKSPACE_ID?.trim();
249
274
  if (process.env.HERDR_ENV === "1" && workspaceId) {
@@ -279,6 +304,7 @@ export default function pullRequestExtension(
279
304
  pi.on("session_start", async (_event, ctx) => {
280
305
  stop();
281
306
  const generation = sessionGeneration;
307
+ observation = latestObservation(ctx);
282
308
  if (!ctx.hasUI) return;
283
309
  context = ctx;
284
310
  await refresh();
@@ -318,7 +344,7 @@ export default function pullRequestExtension(
318
344
  }
319
345
  });
320
346
 
321
- const commandHandler = createCommandHandler(pi);
347
+ const commandHandler = createCommandHandler(pi, { loadCurrentPullRequest: load });
322
348
  pi.registerCommand("pr", {
323
349
  description: "[instructions] — Run the current branch pull request next step",
324
350
  handler: async (args, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
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",
@@ -33,13 +33,17 @@ Run standalone; never invoke, defer to, or modify Shipyard.
33
33
 
34
34
  2. Initial fetch prints a compact index of every feedback ID. Later fetches into
35
35
  a copied snapshot print compact indexes of additions, edits, and state changes.
36
- The snapshot remains complete. For each non-obvious item, inspect only that item:
36
+ The snapshot remains complete. A thread record with child IDs is a container:
37
+ inspect `thread_comment` IDs directly. Do not call `show` on the parent solely
38
+ to discover its children. Show the parent only if it has no child or parent-level
39
+ metadata is actually needed. For each non-obvious item, inspect only that item:
37
40
 
38
41
  ```bash
39
42
  node "<skill>/scripts/pr-feedback.mjs" show --snapshot "$SNAPSHOT" --id "$ID"
40
43
  ```
41
44
 
42
- Always inspect every unresolved feedback item before triage. Never
45
+ Issue independent `show` lookups in one tool-call round. Always inspect every
46
+ unresolved feedback item before triage. Never
43
47
  read or print the whole raw snapshot when `show` provides the bounded lookup.
44
48
  Follow [Thread triage](<skill>/references/thread-triage.md>) and ledger every item as
45
49
  `actionable`, `non-actionable`, or `blocked`, with terse evidence, smallest