@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
@@ -2,9 +2,11 @@
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'
9
+ import type { ReviewKey } from '../contract/review-key.js'
8
10
  import type { ChatThread } from '../contract/state.js'
9
11
  import type { SettingsStore } from '../store/settings-store.js'
10
12
  import type { StateStore } from '../store/state-store.js'
@@ -38,9 +40,9 @@ export interface ChatManagerDeps {
38
40
  now: () => Date
39
41
  }
40
42
 
41
- /** Everything about the pull request one turn needs, resolved by the route. */
43
+ /** Everything about the review target one turn needs, resolved by the route. */
42
44
  export interface ChatTarget {
43
- prNumber: number
45
+ key: ReviewKey
44
46
  headSha: string
45
47
  artifact: ReviewArtifact
46
48
  files: FileEntry[]
@@ -58,13 +60,13 @@ export interface ChatSendInput {
58
60
 
59
61
  export interface ChatManager {
60
62
  effectiveSettings(): Promise<Settings>
61
- threads(prNumber: number): Promise<ChatThreadsResponse>
62
- createThread(prNumber: number): Promise<ChatThread>
63
- selectThread(prNumber: number, name: string): Promise<ChatThread | null>
63
+ threads(key: ReviewKey): Promise<ChatThreadsResponse>
64
+ createThread(key: ReviewKey): Promise<ChatThread>
65
+ selectThread(key: ReviewKey, name: string): Promise<ChatThread | null>
64
66
  send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent>
65
67
  /** True when a turn was running and has been asked to stop. */
66
- cancel(prNumber: number): Promise<boolean>
67
- busy(prNumber: number): boolean
68
+ cancel(key: ReviewKey): Promise<boolean>
69
+ busy(key: ReviewKey): boolean
68
70
  }
69
71
 
70
72
  /** The slot a turn holds while it runs. The handle is filled in once the agent has started. */
@@ -74,8 +76,31 @@ interface RunningTurn {
74
76
  stopped: boolean
75
77
  }
76
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
+
77
102
  export function createChatManager(deps: ChatManagerDeps): ChatManager {
78
- const running = new Map<number, RunningTurn>()
103
+ const running = new Map<ReviewKey, RunningTurn>()
79
104
 
80
105
  const effectiveSettings = async (): Promise<Settings> => {
81
106
  const saved = await deps.settings.read()
@@ -86,12 +111,12 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
86
111
  }
87
112
  }
88
113
 
89
- const newThread = async (prNumber: number, agent: string): Promise<ChatThread> => {
114
+ const newThread = async (key: ReviewKey, agent: string): Promise<ChatThread> => {
90
115
  const at = deps.now().toISOString()
91
116
  let created: ChatThread | null = null
92
- await deps.state.update(prNumber, state => {
117
+ await deps.state.update(key, state => {
93
118
  const thread: ChatThread = {
94
- name: threadName(deps.repo, prNumber, agent, nextThreadIndex(state.chat.threads)),
119
+ name: threadName(deps.repo, key, agent, nextThreadIndex(state.chat.threads)),
95
120
  agent,
96
121
  rev: 0,
97
122
  title: NEW_THREAD_TITLE,
@@ -108,8 +133,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
108
133
  }
109
134
 
110
135
  /** The thread a message goes to: the one asked for, the active one, or a fresh one. */
111
- const resolveThread = async (prNumber: number, agent: string, wanted?: string): Promise<ChatThread> => {
112
- const state = await deps.state.read(prNumber)
136
+ const resolveThread = async (key: ReviewKey, agent: string, wanted?: string): Promise<ChatThread> => {
137
+ const state = await deps.state.read(key)
113
138
  const byName = wanted === undefined ? undefined : state.chat.threads.find(t => t.name === wanted)
114
139
  const active =
115
140
  byName ??
@@ -118,10 +143,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
118
143
  : state.chat.threads.find(t => t.name === state.chat.activeThread))
119
144
  // Changing the agent starts a new thread: the old session belongs to the old agent.
120
145
  if (active === undefined || active.agent !== agent) {
121
- return newThread(prNumber, agent)
146
+ return newThread(key, agent)
122
147
  }
123
148
  if (state.chat.activeThread !== active.name) {
124
- await deps.state.update(prNumber, current => ({
149
+ await deps.state.update(key, current => ({
125
150
  ...current,
126
151
  chat: { ...current.chat, activeThread: active.name },
127
152
  }))
@@ -134,18 +159,18 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
134
159
  * try creates the session again and sends the seed again.
135
160
  */
136
161
  const saveTurn = async (
137
- prNumber: number,
162
+ key: ReviewKey,
138
163
  thread: ChatThread,
139
164
  userText: string,
140
165
  assistant: ChatTurn,
141
166
  seededHeadSha: string,
142
167
  reached: boolean
143
168
  ): Promise<void> => {
144
- await deps.transcripts.append(prNumber, thread.name, assistant)
169
+ await deps.transcripts.append(key, thread.name, assistant)
145
170
  if (!reached) {
146
171
  return
147
172
  }
148
- await deps.state.update(prNumber, state => ({
173
+ await deps.state.update(key, state => ({
149
174
  ...state,
150
175
  chat: {
151
176
  ...state.chat,
@@ -169,8 +194,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
169
194
  * it, so the next turn creates the session again and sends the seed with it. The revision keeps
170
195
  * counting the turns the thread has taken, which is what its title is keyed on.
171
196
  */
172
- const forgetSession = async (prNumber: number, name: string): Promise<void> => {
173
- await deps.state.update(prNumber, state => ({
197
+ const forgetSession = async (key: ReviewKey, name: string): Promise<void> => {
198
+ await deps.state.update(key, state => ({
174
199
  ...state,
175
200
  chat: {
176
201
  ...state.chat,
@@ -180,17 +205,17 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
180
205
  }
181
206
 
182
207
  async function* send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent> {
183
- if (running.has(target.prNumber)) {
208
+ if (running.has(target.key)) {
184
209
  throw new ChatBusyError()
185
210
  }
186
211
  // The slot is taken before the first await, so a second request that arrives while this one
187
212
  // is still reading the settings sees a busy chat rather than starting a second agent.
188
213
  const slot: RunningTurn = { run: null, stopped: false }
189
- running.set(target.prNumber, slot)
214
+ running.set(target.key, slot)
190
215
  try {
191
216
  yield* runTurn(target, input, slot)
192
217
  } finally {
193
- running.delete(target.prNumber)
218
+ running.delete(target.key)
194
219
  }
195
220
  }
196
221
 
@@ -200,7 +225,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
200
225
  slot: RunningTurn
201
226
  ): AsyncIterable<ChatEvent> {
202
227
  const settings = await effectiveSettings()
203
- const thread = await resolveThread(target.prNumber, settings.agent, input.thread)
228
+ const thread = await resolveThread(target.key, settings.agent, input.thread)
204
229
  const seeded = thread.seededHeadSha !== target.headSha
205
230
  const at = deps.now().toISOString()
206
231
  const contextBlock = await renderChatContext(input.context, {
@@ -224,18 +249,20 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
224
249
  })
225
250
  }
226
251
 
227
- await deps.transcripts.append(target.prNumber, thread.name, {
252
+ await deps.transcripts.append(target.key, thread.name, {
228
253
  role: 'user',
229
254
  text: input.message,
230
255
  at,
231
256
  context: input.context,
232
257
  })
233
258
 
259
+ const model = await modelForTurn(deps.runner, settings, thread.name, deps.repoRoot)
260
+
234
261
  // A stop that arrived while the turn was setting up means no agent is started at all. The
235
262
  // transcript is written before the events, so a reader who leaves now still finds it there.
236
263
  if (slot.stopped) {
237
264
  await saveTurn(
238
- target.prNumber,
265
+ target.key,
239
266
  thread,
240
267
  input.message,
241
268
  { role: 'assistant', text: '', at: deps.now().toISOString(), incomplete: 'cancelled' },
@@ -254,11 +281,11 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
254
281
  prompt,
255
282
  cwd: deps.repoRoot,
256
283
  timeoutSec: settings.chatTimeoutSec,
257
- model: settings.model ?? undefined,
284
+ model,
258
285
  maxTurns: settings.maxTurns ?? undefined,
259
286
  // The runner scrubs the line before it gets here; this only writes it down.
260
287
  onRawLine: line => {
261
- void deps.transcripts.appendEvent(target.prNumber, thread.name, line).catch(() => undefined)
288
+ void deps.transcripts.appendEvent(target.key, thread.name, line).catch(() => undefined)
262
289
  },
263
290
  })
264
291
  slot.run = run
@@ -316,7 +343,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
316
343
  await run.cancel().catch(() => undefined)
317
344
  }
318
345
  await saveTurn(
319
- target.prNumber,
346
+ target.key,
320
347
  thread,
321
348
  input.message,
322
349
  {
@@ -330,15 +357,15 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
330
357
  answer !== '' || incomplete === undefined || incomplete === 'cancelled'
331
358
  )
332
359
  if (incomplete === 'AGENT_NO_SESSION') {
333
- await forgetSession(target.prNumber, thread.name)
360
+ await forgetSession(target.key, thread.name)
334
361
  }
335
362
  }
336
363
  }
337
364
 
338
365
  return {
339
366
  effectiveSettings,
340
- async threads(prNumber) {
341
- const [state, settings] = await Promise.all([deps.state.read(prNumber), effectiveSettings()])
367
+ async threads(key) {
368
+ const [state, settings] = await Promise.all([deps.state.read(key), effectiveSettings()])
342
369
  return {
343
370
  threads: state.chat.threads.map(t => ({
344
371
  name: t.name,
@@ -350,25 +377,25 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
350
377
  agent: settings.agent,
351
378
  }
352
379
  },
353
- async createThread(prNumber) {
380
+ async createThread(key) {
354
381
  const settings = await effectiveSettings()
355
- return newThread(prNumber, settings.agent)
382
+ return newThread(key, settings.agent)
356
383
  },
357
- async selectThread(prNumber, name) {
358
- const state = await deps.state.read(prNumber)
384
+ async selectThread(key, name) {
385
+ const state = await deps.state.read(key)
359
386
  const thread = state.chat.threads.find(t => t.name === name)
360
387
  if (thread === undefined) {
361
388
  return null
362
389
  }
363
- await deps.state.update(prNumber, current => ({
390
+ await deps.state.update(key, current => ({
364
391
  ...current,
365
392
  chat: { ...current.chat, activeThread: name },
366
393
  }))
367
394
  return thread
368
395
  },
369
396
  send,
370
- async cancel(prNumber) {
371
- const slot = running.get(prNumber)
397
+ async cancel(key) {
398
+ const slot = running.get(key)
372
399
  if (slot === undefined) {
373
400
  return false
374
401
  }
@@ -378,7 +405,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
378
405
  await slot.run?.cancel()
379
406
  return true
380
407
  },
381
- busy: prNumber => running.has(prNumber),
408
+ busy: key => running.has(key),
382
409
  }
383
410
  }
384
411
 
@@ -1,9 +1,10 @@
1
1
  // A chat thread is one acpx session plus the transcript of what was said in it. Both are named
2
- // after the pull request, so two repositories never share a session and a name can never point
2
+ // after the review target, so two repositories never share a session and a name can never point
3
3
  // outside the chat directory.
4
4
  import { appendFile, mkdir, readFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { type ChatTurn, ChatTurnSchema } from '../contract/chat.js'
7
+ import { keyToString, type ReviewKey } from '../contract/review-key.js'
7
8
  import type { Repo } from '../contract/review-artifact.js'
8
9
  import type { ChatThread } from '../contract/state.js'
9
10
  import { isNotFound } from '../store/atomic-json.js'
@@ -17,19 +18,19 @@ export function slug(value: string): string {
17
18
  .slice(0, 40)
18
19
  }
19
20
 
20
- export function threadName(repo: Repo, prNumber: number, agent: string, index: number): string {
21
- return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${prNumber}-${slug(agent)}-t${index}`
21
+ export function threadName(repo: Repo, key: ReviewKey, agent: string, index: number): string {
22
+ return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${keyToString(key)}-${slug(agent)}-t${index}`
22
23
  }
23
24
 
24
- const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+)-[a-z0-9-]+-t(\d+)$/
25
+ const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+|branch|uncommitted)-[a-z0-9-]+-t(\d+)$/
25
26
 
26
- /** True when the name is one this tool could have made for this pull request. */
27
- export function isThreadNameFor(name: string, prNumber: number): boolean {
27
+ /** True when the name is one this tool could have made for this target. */
28
+ export function isThreadNameFor(name: string, key: ReviewKey): boolean {
28
29
  const m = NAME_RE.exec(name)
29
- return m !== null && m[1] === String(prNumber)
30
+ return m !== null && m[1] === keyToString(key)
30
31
  }
31
32
 
32
- /** The next `t<k>`: one past the highest index already used on this pull request. */
33
+ /** The next `t<k>`: one past the highest index already used on this target. */
33
34
  export function nextThreadIndex(threads: readonly ChatThread[]): number {
34
35
  let highest = 0
35
36
  for (const thread of threads) {
@@ -55,22 +56,22 @@ export function threadTitle(firstMessage: string): string {
55
56
  }
56
57
 
57
58
  export interface TranscriptStore {
58
- dir(prNumber: number): string
59
- file(prNumber: number, name: string): string
60
- eventsFile(prNumber: number, name: string): string
61
- read(prNumber: number, name: string): Promise<ChatTurn[]>
62
- append(prNumber: number, name: string, turn: ChatTurn): Promise<void>
59
+ dir(key: ReviewKey): string
60
+ file(key: ReviewKey, name: string): string
61
+ eventsFile(key: ReviewKey, name: string): string
62
+ read(key: ReviewKey, name: string): Promise<ChatTurn[]>
63
+ append(key: ReviewKey, name: string, turn: ChatTurn): Promise<void>
63
64
  /** Appends one already-scrubbed acpx line to the raw event log. */
64
- appendEvent(prNumber: number, name: string, line: string): Promise<void>
65
+ appendEvent(key: ReviewKey, name: string, line: string): Promise<void>
65
66
  }
66
67
 
67
- export function createTranscriptStore(prDir: (prNumber: number) => string): TranscriptStore {
68
- const dir = (prNumber: number): string => path.join(prDir(prNumber), 'chat')
69
- const named = (prNumber: number, name: string, suffix: string): string => {
70
- if (!isThreadNameFor(name, prNumber)) {
71
- throw new Error(`not a chat thread of pull request ${prNumber}: ${name}`)
68
+ export function createTranscriptStore(prDir: (key: ReviewKey) => string): TranscriptStore {
69
+ const dir = (key: ReviewKey): string => path.join(prDir(key), 'chat')
70
+ const named = (key: ReviewKey, name: string, suffix: string): string => {
71
+ if (!isThreadNameFor(name, key)) {
72
+ throw new Error(`not a chat thread of ${keyToString(key)}: ${name}`)
72
73
  }
73
- return path.join(dir(prNumber), `${name}${suffix}`)
74
+ return path.join(dir(key), `${name}${suffix}`)
74
75
  }
75
76
  const appendLine = async (file: string, line: string): Promise<void> => {
76
77
  await mkdir(path.dirname(file), { recursive: true })
@@ -78,12 +79,12 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
78
79
  }
79
80
  return {
80
81
  dir,
81
- file: (prNumber, name) => named(prNumber, name, '.jsonl'),
82
- eventsFile: (prNumber, name) => named(prNumber, name, '.events.ndjson'),
83
- async read(prNumber, name) {
82
+ file: (key, name) => named(key, name, '.jsonl'),
83
+ eventsFile: (key, name) => named(key, name, '.events.ndjson'),
84
+ async read(key, name) {
84
85
  let text: string
85
86
  try {
86
- text = await readFile(named(prNumber, name, '.jsonl'), 'utf8')
87
+ text = await readFile(named(key, name, '.jsonl'), 'utf8')
87
88
  } catch (err) {
88
89
  if (isNotFound(err)) {
89
90
  return []
@@ -108,7 +109,7 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
108
109
  }
109
110
  return turns
110
111
  },
111
- append: (prNumber, name, turn) => appendLine(named(prNumber, name, '.jsonl'), JSON.stringify(turn)),
112
- appendEvent: (prNumber, name, line) => appendLine(named(prNumber, name, '.events.ndjson'), line),
112
+ append: (key, name, turn) => appendLine(named(key, name, '.jsonl'), JSON.stringify(turn)),
113
+ appendEvent: (key, name, line) => appendLine(named(key, name, '.events.ndjson'), line),
113
114
  }
114
115
  }
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,
@@ -18,13 +21,15 @@ import {
18
21
  import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
19
22
  import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
20
23
  import { createGit } from './git/git.js'
21
- import { createGitHubClient } from './github/gh.js'
24
+ import { createHostClient } from './host/client.js'
22
25
  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,13 +40,19 @@ 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]
41
47
 
42
48
  serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
43
49
  [--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
44
- prepare (--pr <n> | --base <ref> --head <ref>) [--force] [--repo <dir>] [--data-dir <dir>]
50
+ prepare (--pr <n> | --branch | --uncommitted | --base <ref> --head <ref>) [--force]
51
+ [--base <ref>] [--repo <dir>] [--data-dir <dir>]
52
+ (--branch reviews the current branch against the default branch, and
53
+ --uncommitted reviews it with the working tree's edits and new files on top.
54
+ Both are for work with no PR yet, take --base <ref> to compare against
55
+ another branch, and show at /review/branch and /review/uncommitted.)
45
56
  validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
46
57
  (--fix trims over-cap titles in place and reports each one)
47
58
  publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
@@ -50,9 +61,12 @@ const USAGE = `usage: pr-review <command> [flags]
50
61
  (both flags: the named commit is exported and the number stamps the zip)
51
62
  import <zip> [--pr <n>] [--force] [--repo <dir>] [--data-dir <dir>]
52
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)
53
67
 
54
68
  Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
55
- Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
69
+ Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
56
70
  `
57
71
 
58
72
  const io: CliIo = {
@@ -125,15 +139,15 @@ async function serve(argv: string[]): Promise<number> {
125
139
  return EXIT.ok
126
140
  }
127
141
 
128
- /** doctor builds no AppContext: it has to answer even when the repo or `gh` is the problem. */
142
+ /** doctor builds no AppContext: it has to answer even when the repo or host CLI is the problem. */
129
143
  async function doctorCommand(argv: string[]): Promise<number> {
130
144
  const { repo, dataDir, rest } = splitCommonFlags(argv)
131
145
  const cwd = process.cwd()
132
- const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
133
146
  return runDoctor(
134
147
  {
135
- git,
136
- gh: createGitHubClient(),
148
+ git: createGit(repo === undefined ? cwd : path.resolve(cwd, repo)),
149
+ env: process.env,
150
+ client: host => createHostClient(host.cli),
137
151
  version: readPackageVersion(),
138
152
  acpxVersion: () => createAgentRunner().acpxVersion(),
139
153
  dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
@@ -150,6 +164,52 @@ async function installSkillCommand(argv: string[]): Promise<number> {
150
164
  return runInstallSkill({ repoRoot, cwd }, rest, io)
151
165
  }
152
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
+
153
213
  export async function main(argv: string[]): Promise<number> {
154
214
  // `pnpm review -- --port 3011` forwards the `--` itself; drop it so parseArgs sees the flags.
155
215
  const [command, ...rest] = argv.filter(a => a !== '--')
@@ -169,6 +229,8 @@ export async function main(argv: string[]): Promise<number> {
169
229
  return await installSkillCommand(rest)
170
230
  case 'doctor':
171
231
  return await doctorCommand(rest)
232
+ case 'upgrade':
233
+ return await upgradeCommand(rest)
172
234
  default: {
173
235
  const { repo, dataDir, rest: own } = splitCommonFlags(rest)
174
236
  const ctx = await buildContext(repo, dataDir)