@vintasoftware/pr-review-canvas 0.3.0 → 0.5.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.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pr-review-canvas
3
3
  model: sonnet
4
- description: Generate a review canvas for a GitHub pull request (or two refs) with the pr-review tool. Runs `pr-review prepare`, writes the layered model.json the prompt asks for, and runs `pr-review publish` until the validator passes. Use when the user runs `/pr-review-canvas <pr-number>`, `/pr-review-canvas --base <ref> --head <ref>`, or asks for a review canvas for a PR.
4
+ description: Generate a review canvas for a GitHub pull request or GitLab merge request, for the work in this clone before a pull request exists, or for two refs, with the pr-review tool. Runs `pr-review prepare`, writes the layered model.json the prompt asks for, and runs `pr-review publish` to validate and automatically share it as a compressed PR/MR comment. Use when the user runs `/pr-review-canvas <pr-number>`, `/pr-review-canvas branch`, `/pr-review-canvas uncommitted`, `/pr-review-canvas --base <ref> --head <ref>`, or asks for a review canvas for a PR or MR, for their branch, or for what they have not committed.
5
5
  ---
6
6
 
7
7
  # pr-review-canvas
@@ -11,31 +11,60 @@ points, and hand it to the `pr-review` CLI. The CLI does the deterministic work
11
11
  validation, storage); you do the reading and the writing of `model.json`. Nothing here checks out
12
12
  a branch or writes outside the canvas directory.
13
13
 
14
- Arguments: `<pr-number> [--force]` or `--base <ref> --head <ref> [--force]`. `--force` regenerates
15
- a canvas that already exists for the head commit: prepare removes the old `model.json` and any
16
- other leftovers from the canvas directory, keeping `derived/`, `publish.log` (the attempts history),
17
- and the published `review.json` + `manifest.json` (the page keeps showing the old canvas until your
18
- publish replaces it), so you start a fresh `model.json`. Run every `pr-review` command from the
19
- repository root.
14
+ When the target already has a canvas for a commit this head was built on, prepare renders a
15
+ different prompt: it names that canvas, lists which files the new commits left untouched, and tells
16
+ you what to carry from it word for word and what to decide again. Follow that prompt as written;
17
+ the two lists are computed, not suggestions.
18
+
19
+ Arguments: `<pr-number> [--force]`, `branch [--base <ref>] [--force]`,
20
+ `uncommitted [--base <ref>] [--force]`, or `--base <ref> --head <ref> [--force]`. `--force`
21
+ regenerates a canvas that already exists for the head commit: prepare removes the old `model.json` and any other leftovers from the canvas
22
+ directory, keeping `derived/`, `publish.log` (the attempts history), and the published
23
+ `review.json` + `manifest.json` (the page keeps showing the old canvas until your publish replaces
24
+ it), so you start a fresh `model.json`. Run every `pr-review` command from the repository root.
20
25
 
21
26
  ## Flow
22
27
 
23
28
  ### Model choice
24
29
 
25
- Claude Code defaults this skill to Sonnet. If the prepared diff changes authentication, access
26
- policy, or protected health information (PHI) handling, use an Opus agent for the generation and
27
- validation steps when available. Pass it the prepared prompt and context paths; it writes the
28
- same model file. Honor an explicit user model choice. Other hosts keep their selected model.
30
+ Use a mid-tier model, such as Sonnet, by default. If the prepared diff changes authentication,
31
+ access policy, or protected health information (PHI) handling, use a more capable model, such as
32
+ Opus, for the generation and validation steps when available. When delegating to another agent,
33
+ pass it the prepared prompt and context paths; it writes the same model file. Honor an explicit
34
+ user model choice. If the host cannot select models, keep its selected model.
29
35
  Record the model that actually generated the canvas when publishing.
30
36
 
31
37
  ### 1. Prepare
32
38
 
33
39
  ```bash
34
40
  pr-review prepare --pr <n> [--force]
35
- # or, before a PR exists:
41
+ # or, before a pull request exists, one of the two reviews of this clone:
42
+ pr-review prepare --branch [--base <ref>] [--force]
43
+ pr-review prepare --uncommitted [--base <ref>] [--force]
44
+ # or, for any two refs:
36
45
  pr-review prepare --base <ref> --head <ref> [--force]
37
46
  ```
38
47
 
48
+ The skill word maps to the flag: `/pr-review-canvas branch` runs `prepare --branch`, and
49
+ `/pr-review-canvas uncommitted` runs `prepare --uncommitted`.
50
+
51
+ Both compare against the default branch, which prepare reads from `origin/HEAD` unless `--base`
52
+ names another one. They differ in what the head holds:
53
+
54
+ - `--branch` is the tip of the current branch. Whatever is in the working tree is left out.
55
+ - `--uncommitted` is the working tree itself: prepare snapshots the edits and the untracked files
56
+ into a commit of its own, without touching anything the user has staged. With a clean tree it is
57
+ the branch tip, and the canvas is the same one `--branch` would build.
58
+
59
+ They are separate reviews with separate pages, progress and chat threads, so preparing one leaves
60
+ the other alone. Pick the one the user asked for; when they only say "review my work", ask which,
61
+ unless the words already decide it ("before I commit" is `uncommitted`, "before I open the PR" with
62
+ everything committed is `branch`).
63
+
64
+ The JSON carries an extra `local` object with the review's name, the base that was resolved, the
65
+ branch the head is on, and whether uncommitted work is in it. Tell the user all four: a review of
66
+ the wrong base, or of a tree that has moved, is worth catching early.
67
+
39
68
  Progress goes to stderr. The last stdout line is JSON:
40
69
 
41
70
  ```json
@@ -53,12 +82,14 @@ Progress goes to stderr. The last stdout line is JSON:
53
82
  "canvas already exists for <headSha>; run with --force to regenerate".
54
83
  - A line of the form `{ "error": { "code", "message", "hint" } }` means prepare failed. Report the
55
84
  code, message, and hint verbatim and stop. `pr-review doctor` names which of git, origin,
56
- `gh`, the data dir, and the skill install is missing.
85
+ `gh` or `glab`, the data dir, and the skill install is missing.
57
86
 
58
87
  ### 2. Read the task
59
88
 
60
89
  Read `promptPath` in full: it holds the pull request, the manifest with every hunk id, the diffs
61
- (inline or by file path), the layering and length rules, the rulebook, and the JSON schema. Read
90
+ (inline or by file path), the layering and length rules, the rulebook, and the JSON schema. On an
91
+ incremental run it also names the basis canvas and its `review.json`: read that file for the exact
92
+ wording of everything it tells you to carry. Read
62
93
  `contextPath` when you need the paths of the head files, the base files, or the patches. Read any
63
94
  untouched file with `git show <headSha>:<path>` from the repository root, using the SHA returned
64
95
  by prepare. The working tree may be on another branch. Do not check anything out.
@@ -98,12 +129,20 @@ pr-review publish <canvasDir> --agent <your agent id> --model <model id if you k
98
129
  ```
99
130
 
100
131
  - `--agent`: a free-text id of the agent product you are: `claude`, `codex`, `gemini`, ...
101
- - `--model`: the model id when you know it (`claude-opus-4-1`, `gpt-5`, ...); omit it otherwise.
132
+ - `--model`: the model id when you know it (`claude-opus-5-5`, `gpt-6-astra`, ...); omit it otherwise.
102
133
  - `--harness`: `claude-code` when you run inside Claude Code, `codex` inside Codex, `other`
103
134
  anywhere else.
104
135
 
105
136
  On success the last line is `{ "status": "published", "headSha", "reviewJsonPath", "attempts",
106
- "reviewUrl" }` (`reviewUrl` is absent for a `--base/--head` run).
137
+ "reviewUrl", "sharing" }` (`reviewUrl` is absent only for a `--base/--head` run; a local run
138
+ points at `/review/branch` or `/review/uncommitted`).
139
+ For PR/MR runs, publish automatically creates or updates your canvas comment using the host CLI login.
140
+ Always inspect `sharing.status`: local validation success does not mean remote sharing succeeded.
141
+
142
+ For a local run there is nothing to share: `sharing.status` is `"local"`. Report `reviewUrl` and
143
+ tell the user to start `pr-review serve` to read the canvas. If publish prints `CANVAS_STALE`, the
144
+ branch or the working tree changed while you worked; offer to prepare again rather than passing
145
+ `--allow-stale`, because the canvas would then describe code the user has already changed.
107
146
 
108
147
  On failure the command prints one line per problem, then an error line, and exits 5:
109
148
 
@@ -120,35 +159,33 @@ verbatim. Do not weaken the content to pass: shorten text, move hunks, fix links
120
159
  If publish prints `CANVAS_STALE`, the branch moved while you worked. Tell the user and offer to run
121
160
  prepare again; pass `--allow-stale` only when the user asks for the canvas of the old commit.
122
161
 
123
- ### 6. Export the zip
162
+ ### 6. Report the sharing result
124
163
 
125
- ```bash
126
- pr-review export --head <headSha> [--pr <n>]
127
- ```
128
-
129
- Pass `--pr <n>` when the run had a PR number, so the file name and the manifest carry it. The
130
- command prints one JSON line with the absolute `path` of the zip.
164
+ For a PR/MR run, report the local `reviewUrl` (start it with `pr-review serve`) and inspect `sharing`:
131
165
 
132
- ### 7. Finish
166
+ - `status: "shared"`: link to `sharing.url` and say the canvas was shared automatically.
167
+ - `status: "failed"`: clearly warn that automatic sharing failed, quote `sharing.warning`, and
168
+ give the absolute `sharing.zipPath`. Tell the user to open the PR/MR, edit its description,
169
+ drag the ZIP into the editor, wait for upload to finish, and save. Replace any older canvas
170
+ attachment link. Include these instructions in your final response; the local canvas is ready,
171
+ but reviewers still need the upload. Do not regenerate the model to repair a sharing failure.
133
172
 
134
- For a PR run, report the `reviewUrl` from publish, the absolute zip path from export, and a link
135
- to the GitHub PR from the prepared context. End with upload instructions:
173
+ For a local run, `sharing.status` is `"local"` and there is nothing to share. Give the user
174
+ `reviewUrl` (`http://localhost:<port>/review/branch` or `.../review/uncommitted`) and tell them to
175
+ start `pr-review serve` if it is not running. Say which base was compared and whether uncommitted
176
+ work was included, both from the `local` object prepare printed.
136
177
 
137
- > The canvas is ready at <reviewUrl> (start the server with `pr-review serve` if it is not running).
138
- > ZIP: <path>
139
- > If you're happy with the produced canvas, open <PR URL>, edit the PR description, drag the ZIP
140
- > into the editor, wait for the upload to finish, and save.
178
+ For a `--base/--head` run, `sharing.status` is `"local"` too, but the canvas has no page of its
179
+ own. Report the stored commit and export it:
141
180
 
142
- For an update, tell the user to replace the old canvas attachment link with the new one.
143
- Include these instructions in the final response without asking a question or waiting for a reply.
144
-
145
- Uploading and saving the description are manual browser steps. Do not create a release or claim
146
- the ZIP was uploaded. GitHub's `gh --attach` supports images and video, but not ZIP files
147
- ([supported types](https://github.com/cli/cli/blob/trunk/internal/attachments/userasset.go)).
181
+ ```bash
182
+ pr-review export --head <headSha>
183
+ ```
148
184
 
149
- For a `--base/--head` run, say the canvas is stored for `<headSha>`, that the zip has no PR number
150
- yet, and that `pr-review export --pr <n>` re-exports it once the pull request exists. Include the
151
- manual upload instructions for when the PR is ready.
185
+ Give the returned absolute ZIP path. Once a PR exists, `pr-review export --head <headSha> --pr <n>`
186
+ stamps its number for manual upload, or rerun this skill for the PR number with `--force` to share
187
+ automatically. A canvas of a working-tree snapshot cannot be carried to a pull request this way:
188
+ its commit is on no branch, so generate a fresh one for the PR.
152
189
 
153
190
  ## Rules the validator enforces (and models tend to break)
154
191
 
@@ -178,7 +215,9 @@ manual upload instructions for when the PR is ready.
178
215
 
179
216
  ## Updating a shared canvas
180
217
 
181
- After new commits, run this skill again for the PR number. Add `--force` to regenerate a canvas
182
- for the same commit. Export the new zip and ask the user to replace the attachment in their PR
183
- description. Reviewers click **refresh** to load it. A canvas for a different
218
+ After new commits, run this skill again for the PR number. The run updates the canvas of the
219
+ nearest earlier commit instead of writing one from nothing, and the reviewer's progress on the
220
+ untouched files follows it. Add `--force` to regenerate a canvas for the same commit, or to start
221
+ over from a blank page. Publish updates your canvas comment; follow the sharing-result instructions
222
+ above if it fails. Reviewers click **refresh** to load it. A canvas for a different
184
223
  PR head shows **Canvas is outdated**; an older canvas remains readable with posting disabled.
package/src/acpx/acpx.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * `src/testing/fake-runner.ts` or spawn `src/testing/fake-acpx.mjs` through this adapter.
5
5
  */
6
6
  import { type ChildProcess, execFile, type SpawnOptions, spawn } from 'node:child_process'
7
+ import { accessSync, constants } from 'node:fs'
8
+ import path from 'node:path'
7
9
  import { promisify } from 'node:util'
10
+ import { z } from 'zod'
8
11
  import {
9
12
  type AgentErrorCode,
10
13
  type AgentEvent,
@@ -13,6 +16,7 @@ import {
13
16
  mapAcpxMessage,
14
17
  scrubForLog,
15
18
  } from './events.js'
19
+ import type { ModelUpgrades } from './models.js'
16
20
  import { createNdjsonSplitter, NdjsonError } from './ndjson.js'
17
21
 
18
22
  const execFileAsync = promisify(execFile)
@@ -23,6 +27,8 @@ export const ACPX_BIN = 'acpx'
23
27
  export const KILL_GRACE_MS = 3000
24
28
  /** A cancel that has not answered by then is given up on, and the child is killed instead. */
25
29
  export const CANCEL_TIMEOUT_SEC = 30
30
+ /** `sessions show` reads a local record, so it answers fast or not at all. */
31
+ export const SHOW_TIMEOUT_SEC = 20
26
32
  /** The runner's deadline sits this far past acpx's, so acpx reports its own timeout first. */
27
33
  export const DEADLINE_SLACK_MS = 15_000
28
34
 
@@ -68,6 +74,10 @@ export interface AgentRunner {
68
74
  acpxVersion(): Promise<string | null>
69
75
  /** Whether one agent's own CLI is installed and logged in. */
70
76
  availability(agent: string): Promise<{ installed: boolean; authenticated: boolean; reason?: string }>
77
+ /** Which models the agent's catalog says were replaced, and by what. Empty when it says nothing. */
78
+ modelUpgrades(agent: string): Promise<ModelUpgrades>
79
+ /** The model the named session last ran, or null when acpx does not say. */
80
+ sessionModel(options: { agent: string; session: string; cwd: string }): Promise<string | null>
71
81
  }
72
82
 
73
83
  /**
@@ -115,6 +125,10 @@ export function buildEnsureArgs(agent: string, session: string, cwd: string, tim
115
125
  return [...commonAcpxArgs(cwd, timeoutSec), agent, 'sessions', 'ensure', '-s', session]
116
126
  }
117
127
 
128
+ export function buildShowArgs(agent: string, session: string, cwd: string): string[] {
129
+ return [...commonAcpxArgs(cwd, SHOW_TIMEOUT_SEC), agent, 'sessions', 'show', session]
130
+ }
131
+
118
132
  export function buildExecArgs(agent: string, cwd: string, timeoutSec: number, prompt: string): string[] {
119
133
  return [...commonAcpxArgs(cwd, timeoutSec), agent, 'exec', prompt]
120
134
  }
@@ -125,16 +139,62 @@ const AUTH_CHECKS: Readonly<Record<string, { bin: string; args: string[] }>> = {
125
139
  codex: { bin: 'codex', args: ['login', 'status'] },
126
140
  }
127
141
 
142
+ /** The part of `codex debug models` read here: each model and the one that replaced it. */
143
+ const CodexCatalogSchema = z.object({
144
+ models: z.array(z.object({ slug: z.string(), upgrade: z.object({ model: z.string() }).nullish() })),
145
+ })
146
+
147
+ /** The part of `sessions show` read here: the model the session last ran. */
148
+ const SessionRecordSchema = z.object({ acpx: z.object({ current_model_id: z.string().min(1) }) })
149
+
150
+ /** The path `name` runs from on this PATH, or null when it is not there. */
151
+ export function findOnPath(name: string, env: NodeJS.ProcessEnv): string | null {
152
+ for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
153
+ if (dir === '') {
154
+ continue
155
+ }
156
+ const candidate = path.join(dir, name)
157
+ try {
158
+ accessSync(candidate, constants.X_OK)
159
+ return candidate
160
+ } catch {
161
+ // Not in this directory.
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ /**
168
+ * The environment acpx runs under. Without `CLAUDE_CODE_EXECUTABLE`, the Claude adapter runs the
169
+ * Claude Code build bundled with it, which can be months behind the installed one and so resolves
170
+ * `opus` to an older model. A value the user set already is kept.
171
+ */
172
+ export function acpxEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
173
+ if (env['CLAUDE_CODE_EXECUTABLE'] !== undefined) {
174
+ return env
175
+ }
176
+ const claude = findOnPath('claude', env)
177
+ return claude === null ? env : { ...env, CLAUDE_CODE_EXECUTABLE: claude }
178
+ }
179
+
128
180
  /** Only the one call shape the runner makes, so a test double is a plain function. */
129
181
  export type SpawnImpl = (file: string, args: string[], options: SpawnOptions) => ChildProcess
130
182
  export type ExecFileImpl = (
131
183
  file: string,
132
184
  args: string[],
133
- options: { cwd?: string; timeout?: number; maxBuffer?: number; killSignal?: NodeJS.Signals }
185
+ options: {
186
+ cwd?: string
187
+ timeout?: number
188
+ maxBuffer?: number
189
+ killSignal?: NodeJS.Signals
190
+ env?: NodeJS.ProcessEnv
191
+ }
134
192
  ) => Promise<{ stdout: string; stderr: string }>
135
193
 
136
194
  export interface CreateAgentRunnerOptions {
137
195
  bin?: string
196
+ /** The environment acpx gets, before `acpxEnv` fills it in. Defaults to this process's. */
197
+ env?: NodeJS.ProcessEnv
138
198
  spawnImpl?: SpawnImpl
139
199
  execFileImpl?: ExecFileImpl
140
200
  /** How far the runner's own deadline sits past acpx's. Tests shorten it. */
@@ -189,6 +249,7 @@ function createEventQueue(): {
189
249
 
190
250
  export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRunner {
191
251
  const bin = opts.bin ?? ACPX_BIN
252
+ const env = acpxEnv(opts.env ?? process.env)
192
253
  const slackMs = opts.deadlineSlackMs ?? DEADLINE_SLACK_MS
193
254
  const cancelGraceMs = opts.cancelGraceMs ?? CANCEL_TIMEOUT_SEC * 1000
194
255
  const spawnImpl = opts.spawnImpl ?? spawn
@@ -201,9 +262,7 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
201
262
  args: string[],
202
263
  options: { cwd?: string; timeoutSec?: number; killSignal?: NodeJS.Signals } = {}
203
264
  ): Promise<{ ok: boolean; stdout: string; stderr: string; error?: unknown }> => {
204
- const call: { cwd?: string; timeout?: number; maxBuffer?: number; killSignal?: NodeJS.Signals } = {
205
- maxBuffer: 4 * 1024 * 1024,
206
- }
265
+ const call: Parameters<ExecFileImpl>[2] = { maxBuffer: 4 * 1024 * 1024, env }
207
266
  if (options.cwd !== undefined) {
208
267
  call.cwd = options.cwd
209
268
  }
@@ -226,6 +285,7 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
226
285
  run(options) {
227
286
  return startRun(
228
287
  bin,
288
+ env,
229
289
  spawnImpl,
230
290
  options,
231
291
  // The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
@@ -307,6 +367,34 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
307
367
  reason: `\`${check.bin} ${check.args.join(' ')}\` failed; log in and try again`,
308
368
  }
309
369
  },
370
+
371
+ async modelUpgrades(agent) {
372
+ if (agent !== 'codex') {
373
+ return new Map()
374
+ }
375
+ // Codex keeps the catalog it last fetched on disk, so this answers in milliseconds.
376
+ const result = await execQuiet('codex', ['debug', 'models'], { timeoutSec: 20 })
377
+ // A failed call, output that is not JSON, and an unknown shape all mean no known upgrades.
378
+ try {
379
+ const catalog = CodexCatalogSchema.parse(JSON.parse(result.stdout))
380
+ return new Map(catalog.models.flatMap(m => (m.upgrade ? [[m.slug, m.upgrade.model] as const] : [])))
381
+ } catch {
382
+ return new Map()
383
+ }
384
+ },
385
+
386
+ async sessionModel(options) {
387
+ const result = await execQuiet(bin, buildShowArgs(options.agent, options.session, options.cwd), {
388
+ cwd: options.cwd,
389
+ timeoutSec: SHOW_TIMEOUT_SEC,
390
+ })
391
+ // A missing session prints an error line instead, which fails the schema like any other.
392
+ try {
393
+ return SessionRecordSchema.parse(JSON.parse(result.stdout)).acpx.current_model_id
394
+ } catch {
395
+ return null
396
+ }
397
+ },
310
398
  }
311
399
  }
312
400
 
@@ -367,6 +455,7 @@ export function readExecStream(stdout: string): {
367
455
  /** Spawns one prompt turn and turns its output into events. */
368
456
  function startRun(
369
457
  bin: string,
458
+ env: NodeJS.ProcessEnv,
370
459
  spawnImpl: SpawnImpl,
371
460
  options: AgentRunOptions,
372
461
  cancelCall: (args: string[]) => Promise<unknown>,
@@ -385,7 +474,11 @@ function startRun(
385
474
 
386
475
  let child: ChildProcess
387
476
  try {
388
- child = spawnImpl(bin, buildPromptArgs(options), { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'] })
477
+ child = spawnImpl(bin, buildPromptArgs(options), {
478
+ cwd: options.cwd,
479
+ env,
480
+ stdio: ['pipe', 'pipe', 'pipe'],
481
+ })
389
482
  } catch {
390
483
  queue.push({ type: 'error', code: 'AGENT_MISSING', message: `${bin} could not be started` })
391
484
  queue.end()
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A saved model id names a family, not a frozen version: every turn runs the newest model of it.
3
+ *
4
+ * Claude has family aliases (`opus`, `sonnet[1m]`, ...) that the Claude CLI resolves to its newest
5
+ * model, so a versioned id is rewritten to its alias. GPT has no aliases, and a family's next model can
6
+ * carry a new name (`gpt-5.6-terra` became `gpt-6-sol`), so the Codex catalog's own `upgrade` links
7
+ * are followed instead. `pin:<id>` opts out: the id after it is sent as written.
8
+ */
9
+
10
+ export const PIN_PREFIX = 'pin:'
11
+
12
+ /**
13
+ * `claude-opus-4-8`, `claude-sonnet-5[1m]`, `claude-haiku-4-5-20251001`. Bedrock and Vertex ids
14
+ * (`us.anthropic.claude-...`, `claude-...@date`) do not match, so they run as written too.
15
+ */
16
+ const CLAUDE_VERSIONED = /^claude-(opus|sonnet|haiku|fable)-\d[\w.-]*?(\[[^\]]+\])?$/i
17
+
18
+ /** A trailing `[...]` is a setting on the model (`[1m]`, `[high]`), kept across the upgrade. */
19
+ const SUFFIX = /^([^[]+)(\[[^\]]+\])?$/
20
+
21
+ /** Old model slug to the slug that replaced it, as the agent's catalog reports it. */
22
+ export type ModelUpgrades = ReadonlyMap<string, string>
23
+
24
+ export function latestModel(agent: string, model: string, upgrades: ModelUpgrades): string {
25
+ if (model.startsWith(PIN_PREFIX)) {
26
+ return model.slice(PIN_PREFIX.length).trim()
27
+ }
28
+ if (agent === 'claude') {
29
+ const versioned = CLAUDE_VERSIONED.exec(model)
30
+ return versioned === null ? model : `${versioned[1]?.toLowerCase()}${versioned[2] ?? ''}`
31
+ }
32
+ const parts = SUFFIX.exec(model)
33
+ if (parts?.[1] === undefined) {
34
+ return model
35
+ }
36
+ let slug = parts[1]
37
+ const seen = new Set([slug])
38
+ for (let next = upgrades.get(slug); next !== undefined && !seen.has(next); next = upgrades.get(slug)) {
39
+ seen.add(next)
40
+ slug = next
41
+ }
42
+ return `${slug}${parts[2] ?? ''}`
43
+ }
@@ -0,0 +1,24 @@
1
+ import type { CanvasZip } from './export.js'
2
+
3
+ export const CANVAS_COMMENT_MARKER = '<!-- pr-review-canvas:v1\n'
4
+
5
+ /** The ZIP stays in the raw comment body; the rendered comment gives reviewer instructions. */
6
+ export function buildCanvasComment(zip: CanvasZip, limit: number): string {
7
+ const body = `PR Review Canvas for commit ${zip.headSha}.\n\nRun \`pr-review serve\` and open #${zip.prNumber}. If already reviewing, click **refresh**.\n\n${CANVAS_COMMENT_MARKER}${zip.name}\n${Buffer.from(zip.bytes).toString('base64')}\n-->`
8
+ if (body.length > limit) {
9
+ throw new Error(
10
+ `the compressed canvas comment needs ${body.length} characters; the host limit is ${limit}`
11
+ )
12
+ }
13
+ return body
14
+ }
15
+
16
+ /** Only the versioned envelope is decoded. ZIP contents are checked by the normal importer. */
17
+ export function readCanvasComment(body: string): { name: string; bytes: Uint8Array } | null {
18
+ const match = /<!-- pr-review-canvas:v1\n([A-Za-z0-9._-]+\.zip)\n([A-Za-z0-9+/]+={0,2})\n-->/.exec(body)
19
+ if (match === null) return null
20
+ const name = match[1]!
21
+ const encoded = match[2]!
22
+ if (encoded.length > 1_000_000 || encoded.length % 4 !== 0) return null
23
+ return { name, bytes: Buffer.from(encoded, 'base64') }
24
+ }
@@ -1,18 +1,20 @@
1
1
  // One import path for the three ways a canvas arrives: the drop zone, `pr-review import`, and a
2
2
  // zip discovered on the pull request. Everything is checked here, so no caller can skip a step.
3
3
  import type { CanvasRelation, ImportResult } from '../contract/api.js'
4
- import type { CanvasManifest } from '../contract/canvas-manifest.js'
5
- import type { ReviewArtifact } from '../contract/review-artifact.js'
4
+ import { type DiffedCommit, standsForHead } from '../review/carry-over.js'
6
5
  import type { AppContext } from '../server/context.js'
7
6
  import { AppError } from '../server/errors.js'
8
- import { CanvasZipError, readCanvasZip } from './zip.js'
7
+ import { type CanvasZipContents, CanvasZipError, readCanvasZip } from './zip.js'
9
8
 
10
9
  export interface ImportOptions {
11
10
  bytes: Uint8Array
12
11
  /** The pull request the canvas is imported for; recorded on the canvas. */
13
12
  prNumber?: number | undefined
14
- /** The head the page is looking at. Absent means the canvas is taken as the current one. */
15
- currentHeadSha?: string | undefined
13
+ /**
14
+ * The head the page is looking at, with the merge base its diff runs from. Absent means the
15
+ * canvas is taken as the current one.
16
+ */
17
+ currentHead?: DiffedCommit | undefined
16
18
  /** Accepts a canvas exported from another repository. */
17
19
  force?: boolean | undefined
18
20
  }
@@ -79,13 +81,13 @@ async function relateToHead(
79
81
  * canvas already on disk is kept unless the incoming one was generated later.
80
82
  */
81
83
  export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promise<ImportResult> {
82
- let contents: { manifest: CanvasManifest; artifact: ReviewArtifact }
84
+ let contents: CanvasZipContents
83
85
  try {
84
86
  contents = readCanvasZip(opts.bytes)
85
87
  } catch (err) {
86
88
  throw err instanceof CanvasZipError ? toAppErrorFromZip(err) : err
87
89
  }
88
- const { manifest, artifact } = contents
90
+ const { manifest, artifact, prNumber: canvasPr } = contents
89
91
  const warnings: string[] = []
90
92
  if (!sameRepo(manifest.repo, ctx.config.repo)) {
91
93
  const from = `${manifest.repo.owner}/${manifest.repo.name}`
@@ -100,8 +102,21 @@ export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promis
100
102
  warnings.push(`imported a canvas exported from ${from}`)
101
103
  }
102
104
 
105
+ // The wrong zip attached to a pull request is the common mistake, and the head check below does
106
+ // not catch it: two open pull requests have unrelated heads either way. This is the one check
107
+ // --force cannot lift: the canvas in this zip may only be stored under the pull request it
108
+ // names, so forcing could only write an index entry that contradicts the zip it came from.
109
+ if (opts.prNumber !== undefined && canvasPr !== undefined && canvasPr !== opts.prNumber) {
110
+ throw new AppError(
111
+ 'CANVAS_PR_MISMATCH',
112
+ `this canvas was exported for #${canvasPr}, and it is being imported for #${opts.prNumber}`,
113
+ 400,
114
+ `import it without --pr to store it under #${canvasPr}, or generate a canvas for #${opts.prNumber}`
115
+ )
116
+ }
117
+
103
118
  const headSha = manifest.headSha
104
- const currentHeadSha = opts.currentHeadSha ?? headSha
119
+ const currentHeadSha = opts.currentHead?.headSha ?? headSha
105
120
  const index = await ctx.canvases.readIndex()
106
121
  const stored = index.canvases[headSha]
107
122
  const keepStored = stored !== undefined && stored.generatedAt >= artifact.generatedAt
@@ -119,7 +134,13 @@ export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promis
119
134
  if (keepStored) {
120
135
  return { status: 'exists', headSha, currentHeadSha, derivable, warnings }
121
136
  }
122
- if (headSha === currentHeadSha) {
137
+ // The same rule the page reads: a canvas of another commit is current when the head's diff is
138
+ // identical to the one it was generated from, so the CLI never calls stale what the page shows
139
+ // as carried over.
140
+ if (
141
+ opts.currentHead === undefined ||
142
+ (await standsForHead(ctx, opts.currentHead, { headSha, mergeBaseSha }))
143
+ ) {
123
144
  return { status: 'ready', headSha, currentHeadSha, derivable, warnings }
124
145
  }
125
146
  const related = await relateToHead(ctx, headSha, currentHeadSha)
@@ -33,6 +33,7 @@ export function buildCanvasZipName(opts: BuildNameOptions): string {
33
33
  return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
34
34
  }
35
35
 
36
+ /** The strict form of the grammar the drop zone applies to a picked file (`validateCanvasFilename`). */
36
37
  const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
37
38
 
38
39
  /**
package/src/canvas/zip.ts CHANGED
@@ -14,6 +14,12 @@ export const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04] as const
14
14
  export interface CanvasZipContents {
15
15
  manifest: CanvasManifest
16
16
  artifact: ReviewArtifact
17
+ /**
18
+ * The pull request the canvas was made for, once for every reader: the two entries are proved to
19
+ * agree below, and the two ways of holding "no pull request" are settled into one. A canvas
20
+ * generated before the pull request existed names none, and joins the PR that imports it.
21
+ */
22
+ prNumber?: number
17
23
  }
18
24
 
19
25
  /** A zip that cannot be read as a canvas. `issues` names the problems, never the file's content. */
@@ -122,5 +128,19 @@ export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
122
128
  `${REVIEW_ENTRY} is for ${artifact.data.pr.headSha.slice(0, 7)} while ${MANIFEST_ENTRY} says ${manifest.data.headSha.slice(0, 7)}`,
123
129
  ])
124
130
  }
125
- return { manifest: manifest.data, artifact: artifact.data }
131
+ // A canvas generated before the pull request existed carries no number in review.json, and the
132
+ // export stamps one on the manifest; only two numbers that are both there must agree.
133
+ const artifactPr = artifact.data.pr.number
134
+ const manifestPr = manifest.data.prNumber
135
+ if (artifactPr !== null && manifestPr !== undefined && artifactPr !== manifestPr) {
136
+ throw new CanvasZipError('CANVAS_INVALID', 'the zip is not a review canvas', [
137
+ `${REVIEW_ENTRY} is for #${artifactPr} while ${MANIFEST_ENTRY} says #${manifestPr}`,
138
+ ])
139
+ }
140
+ const contents: CanvasZipContents = { manifest: manifest.data, artifact: artifact.data }
141
+ const prNumber = manifestPr ?? artifactPr
142
+ if (prNumber !== null) {
143
+ contents.prNumber = prNumber
144
+ }
145
+ return contents
126
146
  }