@vintasoftware/pr-review-canvas 0.4.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 (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
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
+ }
@@ -2,6 +2,7 @@
2
2
  // and keep the transcript. The lock is what makes `CHAT_BUSY` a real answer rather than two
3
3
  // agents writing into one thread.
4
4
  import type { AgentRunner } from '../acpx/acpx.js'
5
+ import { latestModel } from '../acpx/models.js'
5
6
  import type { ChatContext, ChatEvent, ChatThreadsResponse, ChatTurn } from '../contract/chat.js'
6
7
  import type { FileEntry, Repo, ReviewArtifact } from '../contract/review-artifact.js'
7
8
  import type { Settings, SettingsOverrides } from '../contract/settings.js'
@@ -75,6 +76,29 @@ interface RunningTurn {
75
76
  stopped: boolean
76
77
  }
77
78
 
79
+ /**
80
+ * The `--model` of one turn: the newest model of the saved family. With no saved model the agent's
81
+ * own default applies, unless the session is on a model that has since been replaced: a thread
82
+ * started months ago, or a default set to an old id.
83
+ */
84
+ async function modelForTurn(
85
+ runner: AgentRunner,
86
+ settings: Settings,
87
+ session: string,
88
+ cwd: string
89
+ ): Promise<string | undefined> {
90
+ const upgrades = await runner.modelUpgrades(settings.agent)
91
+ if (settings.model !== null) {
92
+ return latestModel(settings.agent, settings.model, upgrades)
93
+ }
94
+ const current = await runner.sessionModel({ agent: settings.agent, session, cwd })
95
+ if (current === null) {
96
+ return undefined
97
+ }
98
+ const latest = latestModel(settings.agent, current, upgrades)
99
+ return latest === current ? undefined : latest
100
+ }
101
+
78
102
  export function createChatManager(deps: ChatManagerDeps): ChatManager {
79
103
  const running = new Map<ReviewKey, RunningTurn>()
80
104
 
@@ -232,6 +256,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
232
256
  context: input.context,
233
257
  })
234
258
 
259
+ const model = await modelForTurn(deps.runner, settings, thread.name, deps.repoRoot)
260
+
235
261
  // A stop that arrived while the turn was setting up means no agent is started at all. The
236
262
  // transcript is written before the events, so a reader who leaves now still finds it there.
237
263
  if (slot.stopped) {
@@ -255,7 +281,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
255
281
  prompt,
256
282
  cwd: deps.repoRoot,
257
283
  timeoutSec: settings.chatTimeoutSec,
258
- model: settings.model ?? undefined,
284
+ model,
259
285
  maxTurns: settings.maxTurns ?? undefined,
260
286
  // The runner scrubs the line before it gets here; this only writes it down.
261
287
  onRawLine: line => {
package/src/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
1
3
  import path from 'node:path'
4
+ import { createInterface } from 'node:readline/promises'
2
5
  import { parseArgs } from 'node:util'
3
- import { createAgentRunner } from './acpx/acpx.js'
6
+ import { ACPX_BIN, createAgentRunner, findOnPath } from './acpx/acpx.js'
4
7
  import {
5
8
  type CliIo,
6
9
  EXIT,
@@ -23,8 +26,10 @@ import { loadProjectConfig } from './project-config.js'
23
26
  import { checkSkill } from './review/doctor.js'
24
27
  import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
25
28
  import { startServer } from './server/node-server.js'
29
+ import { PACKAGE_ROOT } from './paths.js'
26
30
  import { readJson } from './store/atomic-json.js'
27
31
  import { ensureDataDir } from './store/data-dir.js'
32
+ import { type CommandResult, runUpgrade } from './upgrade.js'
28
33
 
29
34
  const SUBCOMMANDS = [
30
35
  'serve',
@@ -35,6 +40,7 @@ const SUBCOMMANDS = [
35
40
  'import',
36
41
  'install-skill',
37
42
  'doctor',
43
+ 'upgrade',
38
44
  ] as const
39
45
 
40
46
  const USAGE = `usage: pr-review <command> [flags]
@@ -55,6 +61,9 @@ const USAGE = `usage: pr-review <command> [flags]
55
61
  (both flags: the named commit is exported and the number stamps the zip)
56
62
  import <zip> [--pr <n>] [--force] [--repo <dir>] [--data-dir <dir>]
57
63
  doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
64
+ upgrade [--yes] [--only package,acpx,skill] [--repo <dir>]
65
+ (updates pr-review and acpx with npm, and refreshes the project's skill copies;
66
+ lists the changes and asks first unless --yes)
58
67
 
59
68
  Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
60
69
  Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
@@ -155,6 +164,52 @@ async function installSkillCommand(argv: string[]): Promise<number> {
155
164
  return runInstallSkill({ repoRoot, cwd }, rest, io)
156
165
  }
157
166
 
167
+ function runCommand(file: string, args: string[]): Promise<CommandResult> {
168
+ return new Promise(resolve => {
169
+ execFile(file, args, { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) =>
170
+ resolve({ ok: err === null, stdout, stderr })
171
+ )
172
+ })
173
+ }
174
+
175
+ async function confirmOnTerminal(question: string): Promise<boolean | null> {
176
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return null
177
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
178
+ try {
179
+ return /^y(es)?$/i.test((await rl.question(question)).trim())
180
+ } finally {
181
+ rl.close()
182
+ }
183
+ }
184
+
185
+ async function upgradeCommand(argv: string[]): Promise<number> {
186
+ const { repo, rest } = splitCommonFlags(argv)
187
+ const cwd = process.cwd()
188
+ let repoRoot: string | null
189
+ try {
190
+ repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
191
+ } catch {
192
+ repoRoot = null
193
+ }
194
+ const pkg = JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { name: string }
195
+ return runUpgrade(
196
+ {
197
+ packageName: pkg.name,
198
+ version: readPackageVersion(),
199
+ packageRoot: PACKAGE_ROOT,
200
+ repoRoot,
201
+ acpxVersion: () => createAgentRunner().acpxVersion(),
202
+ acpxPath: findOnPath(ACPX_BIN, process.env),
203
+ run: runCommand,
204
+ runInstalled: args =>
205
+ runCommand(process.execPath, [path.join(PACKAGE_ROOT, 'bin', 'pr-review.mjs'), ...args]),
206
+ confirm: confirmOnTerminal,
207
+ },
208
+ rest,
209
+ io
210
+ )
211
+ }
212
+
158
213
  export async function main(argv: string[]): Promise<number> {
159
214
  // `pnpm review -- --port 3011` forwards the `--` itself; drop it so parseArgs sees the flags.
160
215
  const [command, ...rest] = argv.filter(a => a !== '--')
@@ -174,6 +229,8 @@ export async function main(argv: string[]): Promise<number> {
174
229
  return await installSkillCommand(rest)
175
230
  case 'doctor':
176
231
  return await doctorCommand(rest)
232
+ case 'upgrade':
233
+ return await upgradeCommand(rest)
177
234
  default: {
178
235
  const { repo, dataDir, rest: own } = splitCommonFlags(rest)
179
236
  const ctx = await buildContext(repo, dataDir)
package/src/commands.ts CHANGED
@@ -213,7 +213,11 @@ async function validateFile(
213
213
  }
214
214
  const artifact = ReviewArtifactSchema.safeParse(parsed.raw)
215
215
  const input = artifact.success ? artifactToModelOutput(artifact.data satisfies ReviewArtifact) : parsed.raw
216
- const result = validateModelOutput(input, await validationInput(ctx, context, input))
216
+ // A stored canvas may predate the rules about what a generation must hide; only its correctness is checked.
217
+ const result = validateModelOutput(input, {
218
+ ...(await validationInput(ctx, context, input)),
219
+ storedArtifact: artifact.success,
220
+ })
217
221
  return { ok: result.ok, errors: result.errors }
218
222
  }
219
223
 
@@ -2,7 +2,7 @@ import type { z } from 'zod'
2
2
  import type { CanvasManifest } from './canvas-manifest.js'
3
3
  import type { CommentsPayload, IssueComment, ReviewComment } from './comments.js'
4
4
  import type { SharedCanvasInfoSchema } from './discovery.js'
5
- import type { FileEntry, Pr, ReviewArtifact } from './review-artifact.js'
5
+ import type { FileEntry, FoldLevel, Pr, ReviewArtifact } from './review-artifact.js'
6
6
  import type { LocalKey, ReviewKey } from './review-key.js'
7
7
  import type { PrState } from './state.js'
8
8
 
@@ -123,6 +123,12 @@ export interface PrBundle {
123
123
  stale?: StaleInfo
124
124
  /** Set on a ready bundle whose canvas was generated for another commit with an identical diff. */
125
125
  carriedOver?: CarriedOverInfo
126
+ /**
127
+ * The canvas the reviewed marks on this page were made on, when they followed a line of descent
128
+ * onto this one; absent when none did. Not necessarily this canvas's own basis: the reviewer may
129
+ * have marked nothing on the canvases in between.
130
+ */
131
+ marksCarriedFrom?: string
126
132
  sharedCanvas?: SharedCanvasInfo
127
133
  skillCommand: string
128
134
  /** Set on a local review: work that has no pull request, so the forge side of the page is off. */
@@ -162,12 +168,20 @@ export interface ReviewSummary {
162
168
  export interface ReviewBodyResponse {
163
169
  headSha: string
164
170
  body: string
171
+ /** How many pending comments would go out with the review. */
172
+ pending: number
165
173
  /** The titles of the layers that still need a look; approve is refused while this is not empty. */
166
174
  unreviewed: string[]
167
175
  }
168
176
 
169
177
  export interface PostReviewResponse {
170
178
  review: ReviewSummary
179
+ comments: ReviewComment[]
180
+ warnings: string[]
181
+ /** How many pending comments went out with the review. */
182
+ submitted: number
183
+ /** The state after the pending review was cleared, so the page drops its drafts in one step. */
184
+ state: PrState
171
185
  }
172
186
 
173
187
  export interface PatchesResponse {
@@ -219,3 +233,14 @@ export interface ChatStatus {
219
233
  export interface HomeData {
220
234
  recentPrs: Array<{ number: number; title: string; updatedAt: string }>
221
235
  }
236
+
237
+ /** The JSON the review page carries in its bootstrap script; the app reads it before any request. */
238
+ export interface ReviewBootstrap {
239
+ prNumber: ReviewKey
240
+ owner: string
241
+ repo: string
242
+ version: string
243
+ host: PublicHost
244
+ /** The reading level the canvas opens at, from the settings file. */
245
+ foldLevel: FoldLevel
246
+ }
@@ -23,6 +23,11 @@ export const CanvasIndexSchema = z.object({
23
23
  generatedAt: z.string(),
24
24
  source: z.enum(['local', 'import']),
25
25
  importedAt: z.string().optional(),
26
+ /**
27
+ * The canvas this one was generated from. Kept here as well as on the artifact so the line
28
+ * of descent can be walked from the index alone, without opening every canvas on the way.
29
+ */
30
+ basisCanvasSha: z.string().optional(),
26
31
  /** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
27
32
  worktree: z.boolean().optional(),
28
33
  })
@@ -2,7 +2,14 @@ import { z } from 'zod'
2
2
  import { DefaultLayerSchema, GenerationModeSchema, HighRiskRuleSchema } from '../project-config.js'
3
3
  import { DEFAULT_TEST_PATTERNS } from '../review/test-paths.js'
4
4
  import { type LocalKey, LocalKeySchema } from './review-key.js'
5
- import { FileEntrySchema, LIMITS, PrSchema, RepoSchema, type TextCaps } from './review-artifact.js'
5
+ import {
6
+ FileEntrySchema,
7
+ LIMITS,
8
+ POINT_KINDS,
9
+ PrSchema,
10
+ RepoSchema,
11
+ type TextCaps,
12
+ } from './review-artifact.js'
6
13
 
7
14
  /**
8
15
  * What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
@@ -46,6 +53,48 @@ const capsShape = {
46
53
  diagram: z.number().int().positive(),
47
54
  } satisfies Record<keyof TextCaps, z.ZodNumber>
48
55
 
56
+ /** Which files the head changes relative to the basis canvas's diff, by path. */
57
+ export const FileDeltaSchema = z.object({
58
+ unchanged: z.array(z.string()),
59
+ changed: z.array(z.string()),
60
+ added: z.array(z.string()),
61
+ removed: z.array(z.string()),
62
+ })
63
+ export type FileDelta = z.infer<typeof FileDeltaSchema>
64
+
65
+ export const BasisSplitLayerSchema = z.object({
66
+ key: z.string().min(1),
67
+ title: z.string(),
68
+ /** `carried` when the head touches none of the layer's files. */
69
+ status: z.enum(['carried', 're-judged']),
70
+ carriedFiles: z.array(z.string()),
71
+ reJudgedFiles: z.array(z.string()),
72
+ })
73
+ export type BasisSplitLayer = z.infer<typeof BasisSplitLayerSchema>
74
+
75
+ export const BasisSplitPointSchema = z.object({
76
+ kind: z.enum(POINT_KINDS),
77
+ path: z.string().min(1),
78
+ title: z.string(),
79
+ status: z.enum(['carried', 're-judged']),
80
+ })
81
+ export type BasisSplitPoint = z.infer<typeof BasisSplitPointSchema>
82
+
83
+ /**
84
+ * The basis canvas of an incremental run, already divided into what the head leaves untouched and
85
+ * what has to be decided anew. `prepare` computes it, so the generator reads two lists instead of
86
+ * comparing diffs itself, and `publish` records `canvasSha` on the canvas it stores.
87
+ */
88
+ export const BasisSplitSchema = z.object({
89
+ canvasSha: z.string().regex(/^[0-9a-f]{40}$/),
90
+ /** Absolute path of the basis canvas's `review.json`, in the canvas store. */
91
+ reviewJsonPath: z.string().min(1),
92
+ files: FileDeltaSchema,
93
+ layers: z.array(BasisSplitLayerSchema),
94
+ points: z.array(BasisSplitPointSchema),
95
+ })
96
+ export type BasisSplit = z.infer<typeof BasisSplitSchema>
97
+
49
98
  /**
50
99
  * `context.json`: everything the agent and `publish` need about one prepared canvas. Written by
51
100
  * `prepare` next to `prompt.md`; `publish` validates `model.json` against the hunk index and the
@@ -87,6 +136,8 @@ export const GenerationContextSchema = z.object({
87
136
  smallPr: z.boolean(),
88
137
  /** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
89
138
  largePr: z.boolean(),
139
+ /** Absent when this canvas is generated from a blank page: no basis, `--force`, or turned off. */
140
+ basis: BasisSplitSchema.optional(),
90
141
  preparedAt: z.string(),
91
142
  })
92
143
  export type GenerationContext = z.infer<typeof GenerationContextSchema>
@@ -9,6 +9,7 @@ export {
9
9
  parseHunkId,
10
10
  parseLineId,
11
11
  pointAnchorId,
12
+ reviewedId,
12
13
  sanitizeKey,
13
14
  uniqueKey,
14
15
  } from '../../static/js/keys.js'
@@ -0,0 +1,49 @@
1
+ // A review the reviewer is still writing: comments kept on this machine until the review is
2
+ // submitted, the way a pending review works on the forge's own page.
3
+ import { z } from 'zod'
4
+ import { SideSchema } from './review-artifact.js'
5
+ import { COMMENT_BODY_MAX } from './comments.js'
6
+
7
+ /**
8
+ * One comment waiting in the pending review. It names a line the way a posted inline comment
9
+ * does, so submitting it needs nothing the page has to look up again.
10
+ */
11
+ export const PendingCommentSchema = z.object({
12
+ /** Local id, unique inside one target's state. Never a forge id: nothing was posted yet. */
13
+ id: z.string().min(1),
14
+ path: z.string().min(1),
15
+ line: z.number().int().positive(),
16
+ side: SideSchema,
17
+ /** The first line of a multi-line comment, absent when it covers one line. */
18
+ startLine: z.number().int().positive().optional(),
19
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
20
+ /** Set when the comment came from an attention point, so the point can be marked posted. */
21
+ pointFingerprint: z.string().min(1).optional(),
22
+ /** The commit the reviewer was reading when they wrote it. */
23
+ headSha: z.string(),
24
+ createdAt: z.string(),
25
+ /** Creation time until the reviewer edits the draft. */
26
+ updatedAt: z.string(),
27
+ })
28
+ export type PendingComment = z.infer<typeof PendingCommentSchema>
29
+
30
+ /** What the page sends to `POST /api/prs/:n/pending`. */
31
+ export const AddPendingInputSchema = z.object({
32
+ path: z.string().min(1),
33
+ line: z.number().int().positive(),
34
+ side: SideSchema,
35
+ startLine: z.number().int().positive().optional(),
36
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
37
+ pointFingerprint: z.string().min(1).optional(),
38
+ headSha: z
39
+ .string()
40
+ .regex(/^[0-9a-f]{40}$/)
41
+ .optional(),
42
+ })
43
+ export type AddPendingInput = z.infer<typeof AddPendingInputSchema>
44
+
45
+ /** What the page sends to `PATCH /api/prs/:n/pending/:id`. */
46
+ export const EditPendingInputSchema = z.object({
47
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
48
+ })
49
+ export type EditPendingInput = z.infer<typeof EditPendingInputSchema>