@vintasoftware/pr-review-canvas 0.1.0 → 0.3.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 (98) hide show
  1. package/README.md +30 -28
  2. package/docs/reference.md +142 -98
  3. package/package.json +12 -3
  4. package/pr-review.config.example.yml +12 -31
  5. package/prompts/generation-format.md +4 -4
  6. package/prompts/layering-guidance.md +18 -0
  7. package/skills/pr-review-canvas/SKILL.md +8 -1
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/export.ts +11 -2
  13. package/src/canvas/name.ts +13 -13
  14. package/src/canvas/zip.ts +4 -1
  15. package/src/chat/chat-manager.ts +16 -3
  16. package/src/chat/context.ts +10 -3
  17. package/src/chat/seed.ts +3 -1
  18. package/src/cli.ts +16 -13
  19. package/src/commands.ts +41 -11
  20. package/src/config.ts +15 -3
  21. package/src/contract/comments.ts +3 -1
  22. package/src/contract/generation-context.ts +3 -1
  23. package/src/contract/review-artifact.ts +14 -8
  24. package/src/contract/state.ts +3 -1
  25. package/src/git/diff-collector.ts +2 -1
  26. package/src/git/git.ts +9 -4
  27. package/src/github/attachments.ts +3 -3
  28. package/src/github/capabilities.ts +5 -1
  29. package/src/github/comments.ts +6 -3
  30. package/src/github/gh.ts +18 -3
  31. package/src/github/post-comment.ts +4 -1
  32. package/src/github/pr.ts +4 -1
  33. package/src/github/threads.ts +5 -1
  34. package/src/project-config.ts +14 -54
  35. package/src/prompt-files.ts +4 -3
  36. package/src/review/doctor.ts +38 -10
  37. package/src/review/install-skill.ts +24 -28
  38. package/src/review/normalize.ts +3 -1
  39. package/src/review/prepare.ts +24 -8
  40. package/src/review/prompt.ts +17 -11
  41. package/src/review/publish.ts +15 -3
  42. package/src/review/skill-content.ts +20 -0
  43. package/src/review/trim-caps.ts +10 -6
  44. package/src/review/validate.ts +65 -13
  45. package/src/server/app.ts +12 -4
  46. package/src/server/bundle.ts +17 -4
  47. package/src/server/context.ts +14 -2
  48. package/src/server/errors.ts +26 -2
  49. package/src/server/html.ts +2 -2
  50. package/src/server/node-server.ts +4 -2
  51. package/src/server/routes/api.ts +29 -5
  52. package/src/server/routes/chat-routes.ts +30 -9
  53. package/src/server/routes/pages.ts +3 -1
  54. package/src/server/routes/review-routes.ts +19 -4
  55. package/src/server/sse.ts +3 -1
  56. package/src/store/atomic-json.ts +5 -1
  57. package/src/store/canvas-store.ts +20 -6
  58. package/src/store/derived-store.ts +5 -1
  59. package/src/store/pr-store.ts +2 -1
  60. package/src/store/state-store.ts +2 -1
  61. package/static/brand.svg +19 -0
  62. package/static/js/api.js +15 -3
  63. package/static/js/app.js +8 -2
  64. package/static/js/chat-panel.js +93 -0
  65. package/static/js/chat.js +38 -15
  66. package/static/js/composer.js +6 -3
  67. package/static/js/diagram.js +2 -1
  68. package/static/js/diff-decorations.js +5 -3
  69. package/static/js/diff-renderer.js +6 -1
  70. package/static/js/dom.js +5 -7
  71. package/static/js/empty-state.js +3 -1
  72. package/static/js/header.js +1 -1
  73. package/static/js/interactions.js +55 -15
  74. package/static/js/keyboard.js +7 -2
  75. package/static/js/layers.js +23 -11
  76. package/static/js/links.js +8 -2
  77. package/static/js/nav.js +5 -2
  78. package/static/js/overview.js +32 -4
  79. package/static/js/points.js +3 -2
  80. package/static/js/progress.js +2 -1
  81. package/static/js/proposed-comment.js +4 -1
  82. package/static/js/quick-questions.js +2 -1
  83. package/static/js/regenerate.js +4 -1
  84. package/static/js/settings.js +1 -1
  85. package/static/js/signoff.js +6 -2
  86. package/static/styles/base.css +16 -6
  87. package/static/styles/chat-panel.css +81 -0
  88. package/static/styles/chat-tools.css +28 -0
  89. package/static/styles/chat.css +1 -1
  90. package/static/styles/commands.css +4 -4
  91. package/static/styles/diff.css +1 -1
  92. package/static/styles/header.css +18 -4
  93. package/static/styles/layout.css +4 -4
  94. package/static/styles/responsive.css +1 -15
  95. package/static/styles/review.css +24 -3
  96. package/static/styles/skin-github.css +93 -100
  97. package/static/styles.css +13 -12
  98. package/prompts/layers-default.md +0 -13
package/src/acpx/acpx.ts CHANGED
@@ -231,7 +231,11 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
231
231
  // The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
232
232
  // must neither hold the kill back nor stay behind as a process of its own.
233
233
  agentArgs =>
234
- execQuiet(bin, agentArgs, { cwd: options.cwd, timeoutSec: CANCEL_TIMEOUT_SEC, killSignal: 'SIGKILL' }),
234
+ execQuiet(bin, agentArgs, {
235
+ cwd: options.cwd,
236
+ timeoutSec: CANCEL_TIMEOUT_SEC,
237
+ killSignal: 'SIGKILL',
238
+ }),
235
239
  slackMs,
236
240
  cancelGraceMs
237
241
  )
@@ -268,7 +272,12 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
268
272
  }
269
273
  }
270
274
  if (!ended) {
271
- return { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing' }
275
+ return {
276
+ ok: false,
277
+ text: '',
278
+ code: 'AGENT_INCOMPLETE',
279
+ message: 'the agent stopped before finishing',
280
+ }
272
281
  }
273
282
  return text === ''
274
283
  ? { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent replied nothing' }
@@ -516,11 +525,19 @@ function startRun(
516
525
  const exit = code ?? 1
517
526
  const mapped = exitCodeToAgentCode(exit)
518
527
  if (mapped !== null) {
519
- finish({ type: 'error', code: mapped, message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim() })
528
+ finish({
529
+ type: 'error',
530
+ code: mapped,
531
+ message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim(),
532
+ })
520
533
  return
521
534
  }
522
535
  // acpx 0.13.2 exits 0 when its own --timeout elapses, without a terminal event.
523
- finish({ type: 'error', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing its answer' })
536
+ finish({
537
+ type: 'error',
538
+ code: 'AGENT_INCOMPLETE',
539
+ message: 'the agent stopped before finishing its answer',
540
+ })
524
541
  })
525
542
 
526
543
  child.stdin?.on('error', () => undefined)
@@ -64,7 +64,11 @@ function contentText(value: unknown): string {
64
64
  */
65
65
  export function mapAcpxMessage(message: unknown): AgentEvent | null {
66
66
  if (!isRecord(message)) {
67
- return { type: 'error', code: 'AGENT_PROTOCOL_INVALID', message: 'the agent wrote a line that is not a message' }
67
+ return {
68
+ type: 'error',
69
+ code: 'AGENT_PROTOCOL_INVALID',
70
+ message: 'the agent wrote a line that is not a message',
71
+ }
68
72
  }
69
73
  const error = read(message, 'error')
70
74
  if (isRecord(error)) {
@@ -50,7 +50,9 @@ export function createNdjsonSplitter(lineMax: number = NDJSON_LINE_MAX): NdjsonS
50
50
  const raw = buffer.slice(0, at)
51
51
  buffer = buffer.slice(at + 1)
52
52
  if (raw.length > lineMax) {
53
- throw new NdjsonError(`the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`)
53
+ throw new NdjsonError(
54
+ `the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`
55
+ )
54
56
  }
55
57
  out.push(...take(raw))
56
58
  at = buffer.indexOf('\n')
@@ -16,7 +16,11 @@ export interface PreflightProbe {
16
16
  * Is acpx on PATH? The answer barely changes while the server runs, so it is asked once and
17
17
  * reused; the page shows a banner and hides the chat when it says no.
18
18
  */
19
- export function createPreflightProbe(runner: AgentRunner, now: () => Date, ttlMs = PREFLIGHT_TTL_MS): PreflightProbe {
19
+ export function createPreflightProbe(
20
+ runner: AgentRunner,
21
+ now: () => Date,
22
+ ttlMs = PREFLIGHT_TTL_MS
23
+ ): PreflightProbe {
20
24
  let cached: { at: number; value: AcpxPreflight } | null = null
21
25
  let inFlight: Promise<AcpxPreflight> | null = null
22
26
  return {
@@ -37,7 +37,12 @@ export async function buildCanvasZipFor(
37
37
  const number = prNumber ?? stored.prNumber
38
38
  const manifest: CanvasManifest = number === undefined ? stored : { ...stored, prNumber: number }
39
39
  const zip: CanvasZip = {
40
- name: buildCanvasZipName({ repo: manifest.repo, headSha, prNumber: number }),
40
+ name: buildCanvasZipName({
41
+ repo: manifest.repo,
42
+ headSha,
43
+ prNumber: number,
44
+ generatedAt: manifest.generatedAt,
45
+ }),
41
46
  bytes: buildCanvasZip(manifest, artifact),
42
47
  headSha,
43
48
  }
@@ -64,7 +69,11 @@ async function isDirectory(target: string): Promise<boolean> {
64
69
  }
65
70
 
66
71
  /** `--out` names a file when it ends in `.zip` or is not an existing directory. */
67
- export async function resolveOutPath(out: string | undefined, defaultDir: string, name: string): Promise<string> {
72
+ export async function resolveOutPath(
73
+ out: string | undefined,
74
+ defaultDir: string,
75
+ name: string
76
+ ): Promise<string> {
68
77
  if (out === undefined) {
69
78
  return path.join(defaultDir, name)
70
79
  }
@@ -4,11 +4,9 @@ import type { Repo } from '../contract/review-artifact.js'
4
4
 
5
5
  export interface ParsedCanvasName {
6
6
  prNumber?: number
7
- sha7: string
7
+ shaPrefix: string
8
8
  }
9
9
 
10
- export const CANVAS_NAME_PREFIX = 'pr-review-canvas'
11
-
12
10
  /** Owner and repo names may hold characters a file name should not; runs of them become one dash. */
13
11
  function slugPart(value: string): string {
14
12
  return value
@@ -24,32 +22,34 @@ export function repoSlug(repo: Repo): string {
24
22
  export interface BuildNameOptions {
25
23
  repo: Repo
26
24
  headSha: string
25
+ generatedAt: string
27
26
  prNumber?: number | undefined
28
27
  }
29
28
 
30
- /** `pr-review-canvas-<owner>-<repo>-pr<n>-<sha7>.zip`, without `-pr<n>` before the PR exists. */
29
+ /** PR or ref, UTC generation time to seconds, commit prefix, repository, and canvas marker. */
31
30
  export function buildCanvasZipName(opts: BuildNameOptions): string {
32
- const pr = opts.prNumber === undefined ? '' : `-pr${opts.prNumber}`
33
- return `${CANVAS_NAME_PREFIX}-${repoSlug(opts.repo)}${pr}-${opts.headSha.slice(0, 7)}.zip`
31
+ const target = opts.prNumber === undefined ? 'ref' : `pr-${opts.prNumber}`
32
+ const timestamp = new Date(opts.generatedAt).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z'
33
+ return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
34
34
  }
35
35
 
36
- const TAIL_RE = /^(?:pr(\d+)-)?([0-9a-f]{7})\.zip$/
36
+ const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
37
37
 
38
38
  /**
39
39
  * Reads a file name as a canvas zip for `repo`. A name for another repository, or one that does
40
40
  * not follow the grammar, returns null: discovery uses this to skip attachments that are not ours.
41
41
  */
42
42
  export function parseCanvasZipName(filename: string, repo: Repo): ParsedCanvasName | null {
43
- const prefix = `${CANVAS_NAME_PREFIX}-${repoSlug(repo)}-`
43
+ const suffix = `-${repoSlug(repo)}-canvas.zip`
44
44
  const lower = filename.toLowerCase()
45
- if (!lower.startsWith(prefix)) {
45
+ if (!lower.endsWith(suffix)) {
46
46
  return null
47
47
  }
48
- const m = TAIL_RE.exec(lower.slice(prefix.length))
49
- const sha7 = m?.[2]
50
- if (sha7 === undefined) {
48
+ const m = NAME_RE.exec(lower.slice(0, -suffix.length))
49
+ const shaPrefix = m?.[2]
50
+ if (shaPrefix === undefined) {
51
51
  return null
52
52
  }
53
53
  const pr = m?.[1]
54
- return pr === undefined ? { sha7 } : { prNumber: Number(pr), sha7 }
54
+ return pr === undefined ? { shaPrefix } : { prNumber: Number(pr), shaPrefix }
55
55
  }
package/src/canvas/zip.ts CHANGED
@@ -66,7 +66,10 @@ function decode(entry: Uint8Array | undefined, name: string, issues: string[]):
66
66
  */
67
67
  export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
68
68
  if (bytes.length > CANVAS_ZIP_MAX_BYTES) {
69
- throw new CanvasZipError('CANVAS_TOO_LARGE', `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`)
69
+ throw new CanvasZipError(
70
+ 'CANVAS_TOO_LARGE',
71
+ `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`
72
+ )
70
73
  }
71
74
  if (!hasZipMagic(bytes)) {
72
75
  throw new CanvasZipError('CANVAS_INVALID', 'this file is not a zip', [
@@ -10,7 +10,13 @@ import type { SettingsStore } from '../store/settings-store.js'
10
10
  import type { StateStore } from '../store/state-store.js'
11
11
  import { type ContextSources, renderChatContext } from './context.js'
12
12
  import { renderSeed, type SeedPaths } from './seed.js'
13
- import { NEW_THREAD_TITLE, nextThreadIndex, type TranscriptStore, threadName, threadTitle } from './threads.js'
13
+ import {
14
+ NEW_THREAD_TITLE,
15
+ nextThreadIndex,
16
+ type TranscriptStore,
17
+ threadName,
18
+ threadTitle,
19
+ } from './threads.js'
14
20
 
15
21
  export class ChatBusyError extends Error {
16
22
  constructor() {
@@ -188,7 +194,11 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
188
194
  }
189
195
  }
190
196
 
191
- async function* runTurn(target: ChatTarget, input: ChatSendInput, slot: RunningTurn): AsyncIterable<ChatEvent> {
197
+ async function* runTurn(
198
+ target: ChatTarget,
199
+ input: ChatSendInput,
200
+ slot: RunningTurn
201
+ ): AsyncIterable<ChatEvent> {
192
202
  const settings = await effectiveSettings()
193
203
  const thread = await resolveThread(target.prNumber, settings.agent, input.thread)
194
204
  const seeded = thread.seededHeadSha !== target.headSha
@@ -350,7 +360,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
350
360
  if (thread === undefined) {
351
361
  return null
352
362
  }
353
- await deps.state.update(prNumber, current => ({ ...current, chat: { ...current.chat, activeThread: name } }))
363
+ await deps.state.update(prNumber, current => ({
364
+ ...current,
365
+ chat: { ...current.chat, activeThread: name },
366
+ }))
354
367
  return thread
355
368
  },
356
369
  send,
@@ -106,7 +106,10 @@ export async function renderChatContext(context: ChatContext, sources: ContextSo
106
106
  * An attention point: what it says, plus the lines it sits on. The agent needs the text to know
107
107
  * what the reader is reacting to; the lines alone leave it guessing. One context, one heading.
108
108
  */
109
- async function renderPoint(context: Extract<ChatContext, { kind: 'point' }>, sources: ContextSources): Promise<string> {
109
+ async function renderPoint(
110
+ context: Extract<ChatContext, { kind: 'point' }>,
111
+ sources: ContextSources
112
+ ): Promise<string> {
110
113
  const point = sources.artifact.points.find(p => p.fingerprint === context.fingerprint)
111
114
  if (point === undefined) {
112
115
  throw new ChatContextError('this canvas has no such attention point')
@@ -140,7 +143,10 @@ async function renderLines(
140
143
  }
141
144
 
142
145
  /** The quoted lines and the hunk around them, with no heading of their own. */
143
- async function linesBody(context: Extract<ChatContext, { kind: 'lines' }>, sources: ContextSources): Promise<string> {
146
+ async function linesBody(
147
+ context: Extract<ChatContext, { kind: 'lines' }>,
148
+ sources: ContextSources
149
+ ): Promise<string> {
144
150
  if (context.end < context.start) {
145
151
  throw new ChatContextError('the last line of a selection comes after the first')
146
152
  }
@@ -155,6 +161,7 @@ async function linesBody(context: Extract<ChatContext, { kind: 'lines' }>, sourc
155
161
  lines === null || lines.length === 0
156
162
  ? '_these lines are not available locally_'
157
163
  : fence(lines.map((text, i) => `${context.start + i}: ${text}`).join('\n'))
158
- const hunkBlock = hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
164
+ const hunkBlock =
165
+ hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
159
166
  return `${numbered}${hunkBlock}`
160
167
  }
package/src/chat/seed.ts CHANGED
@@ -42,7 +42,9 @@ export function pointsMarkdown(artifact: ReviewArtifact): string {
42
42
  if (artifact.points.length === 0) {
43
43
  return '_none_'
44
44
  }
45
- return artifact.points.map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`).join('\n')
45
+ return artifact.points
46
+ .map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`)
47
+ .join('\n')
46
48
  }
47
49
 
48
50
  export function testsMarkdown(artifact: ReviewArtifact): string {
package/src/cli.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import path from 'node:path'
3
2
  import { parseArgs } from 'node:util'
4
3
  import { createAgentRunner } from './acpx/acpx.js'
@@ -21,12 +20,22 @@ import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-art
21
20
  import { createGit } from './git/git.js'
22
21
  import { createGitHubClient } from './github/gh.js'
23
22
  import { loadProjectConfig } from './project-config.js'
23
+ import { checkSkill } from './review/doctor.js'
24
24
  import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
25
25
  import { startServer } from './server/node-server.js'
26
26
  import { readJson } from './store/atomic-json.js'
27
27
  import { ensureDataDir } from './store/data-dir.js'
28
28
 
29
- const SUBCOMMANDS = ['serve', 'prepare', 'validate', 'publish', 'export', 'import', 'install-skill', 'doctor'] as const
29
+ const SUBCOMMANDS = [
30
+ 'serve',
31
+ 'prepare',
32
+ 'validate',
33
+ 'publish',
34
+ 'export',
35
+ 'import',
36
+ 'install-skill',
37
+ 'doctor',
38
+ ] as const
30
39
 
31
40
  const USAGE = `usage: pr-review <command> [flags]
32
41
 
@@ -78,7 +87,8 @@ async function buildContext(
78
87
  )
79
88
  const projectConfig = await loadProjectConfig(config.repoRoot)
80
89
  await ensureDataDir(config.dataDir)
81
- const fixtureArtifact = config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
90
+ const fixtureArtifact =
91
+ config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
82
92
  return createAppContext({ config, projectConfig, fixtureArtifact })
83
93
  }
84
94
 
@@ -109,6 +119,8 @@ async function serve(argv: string[]): Promise<number> {
109
119
  agent: values.agent,
110
120
  model: values.model,
111
121
  })
122
+ const skill = await checkSkill(ctx.config.repoRoot)
123
+ if (!skill.ok) io.stderr(`pr-review doctor: ${skill.detail}. ${skill.hint ?? ''}`)
112
124
  startServer(ctx, line => process.stderr.write(`${line}\n`))
113
125
  return EXIT.ok
114
126
  }
@@ -125,15 +137,6 @@ async function doctorCommand(argv: string[]): Promise<number> {
125
137
  version: readPackageVersion(),
126
138
  acpxVersion: () => createAgentRunner().acpxVersion(),
127
139
  dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
128
- exists: async file => {
129
- try {
130
- // A read, so a dangling symlink and a directory both answer no.
131
- await readFile(file)
132
- return true
133
- } catch {
134
- return false
135
- }
136
- },
137
140
  },
138
141
  rest,
139
142
  io
@@ -144,7 +147,7 @@ async function installSkillCommand(argv: string[]): Promise<number> {
144
147
  const { repo, rest } = splitCommonFlags(argv)
145
148
  const cwd = process.cwd()
146
149
  const repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
147
- return runInstallSkill({ repoRoot, cwd, platform: process.platform }, rest, io)
150
+ return runInstallSkill({ repoRoot, cwd }, rest, io)
148
151
  }
149
152
 
150
153
  export async function main(argv: string[]): Promise<number> {
package/src/commands.ts CHANGED
@@ -63,7 +63,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
63
63
  }
64
64
 
65
65
  function isParseArgsError(err: unknown): err is Error {
66
- return err instanceof Error && 'code' in err && typeof err.code === 'string' && err.code.startsWith('ERR_PARSE_ARGS')
66
+ return (
67
+ err instanceof Error &&
68
+ 'code' in err &&
69
+ typeof err.code === 'string' &&
70
+ err.code.startsWith('ERR_PARSE_ARGS')
71
+ )
67
72
  }
68
73
 
69
74
  /** Prints the envelope for any failure and picks the exit code. */
@@ -156,7 +161,12 @@ export function parsePrepareTarget(values: { pr?: string; base?: string; head?:
156
161
  export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
157
162
  const { values } = parseArgs({
158
163
  args: argv,
159
- options: { pr: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' }, force: { type: 'boolean' } },
164
+ options: {
165
+ pr: { type: 'string' },
166
+ base: { type: 'string' },
167
+ head: { type: 'string' },
168
+ force: { type: 'boolean' },
169
+ },
160
170
  strict: true,
161
171
  })
162
172
  const target = parsePrepareTarget(values)
@@ -194,7 +204,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
194
204
  })
195
205
  const file = positionals[0]
196
206
  if (file === undefined || positionals.length > 1) {
197
- throw new UsageError('validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>')
207
+ throw new UsageError(
208
+ 'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
209
+ )
198
210
  }
199
211
  if (values.canvas === undefined) {
200
212
  throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
@@ -202,7 +214,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
202
214
  const context = await readContext(path.resolve(values.canvas))
203
215
  const text = await readText(path.resolve(file))
204
216
  if (text === null) {
205
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'pass the model.json or review.json to check')
217
+ throw new PublishError(
218
+ 'NOT_FOUND',
219
+ `${file} does not exist`,
220
+ 'pass the model.json or review.json to check'
221
+ )
206
222
  }
207
223
  const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
208
224
  const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
@@ -214,7 +230,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
214
230
  if (trim.outcome === 'fixed') {
215
231
  io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
216
232
  } else {
217
- io.stdout(`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`)
233
+ io.stdout(
234
+ `unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
235
+ )
218
236
  }
219
237
  }
220
238
  if (report.ok) {
@@ -277,7 +295,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
277
295
  })
278
296
  const canvasDir = positionals[0]
279
297
  if (canvasDir === undefined || positionals.length > 1) {
280
- throw new UsageError('publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>')
298
+ throw new UsageError(
299
+ 'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
300
+ )
281
301
  }
282
302
  if (values.agent === undefined || values.agent === '') {
283
303
  throw new UsageError('publish needs --agent <id>')
@@ -306,20 +326,22 @@ export async function runDoctor(deps: DoctorDeps, argv: string[], io: CliIo): Pr
306
326
  export interface InstallSkillEnv {
307
327
  repoRoot: string
308
328
  cwd: string
309
- platform: NodeJS.Platform
310
329
  }
311
330
 
312
331
  /** `install-skill [--claude-dir <dir>] [--codex-dir <dir>] [--force]`, both dirs under the repo root by default. */
313
332
  export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
314
333
  const { values } = parseArgs({
315
334
  args: argv,
316
- options: { 'claude-dir': { type: 'string' }, 'codex-dir': { type: 'string' }, force: { type: 'boolean' } },
335
+ options: {
336
+ 'claude-dir': { type: 'string' },
337
+ 'codex-dir': { type: 'string' },
338
+ force: { type: 'boolean' },
339
+ },
317
340
  strict: true,
318
341
  })
319
342
  const resolve = (flag: string | undefined, fallback: string): string =>
320
343
  flag === undefined ? path.join(env.repoRoot, fallback) : path.resolve(env.cwd, flag)
321
344
  const result = await installSkill({
322
- platform: env.platform,
323
345
  force: values.force === true,
324
346
  targets: [
325
347
  { kind: 'claude', dir: resolve(values['claude-dir'], CLAUDE_SKILLS_DIR) },
@@ -359,7 +381,11 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
359
381
  strict: true,
360
382
  })
361
383
  const target = await resolveHead(ctx, values)
362
- const result = await exportCanvas(ctx, { headSha: target.headSha, prNumber: target.prNumber, out: values.out })
384
+ const result = await exportCanvas(ctx, {
385
+ headSha: target.headSha,
386
+ prNumber: target.prNumber,
387
+ out: values.out,
388
+ })
363
389
  printJson(io, result)
364
390
  io.stderr(`drag ${result.path} into the pull request description or a comment`)
365
391
  return EXIT.ok
@@ -377,7 +403,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
377
403
  } catch {
378
404
  throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
379
405
  }
380
- const tooLarge = new AppError('CANVAS_TOO_LARGE', `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`, 413)
406
+ const tooLarge = new AppError(
407
+ 'CANVAS_TOO_LARGE',
408
+ `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
409
+ 413
410
+ )
381
411
  try {
382
412
  // One byte past the cap is read, so a file of exactly the cap still fits and anything longer
383
413
  // is refused without the rest of it ever being in memory.
package/src/config.ts CHANGED
@@ -47,7 +47,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
47
47
  return await git.topLevel()
48
48
  } catch (err) {
49
49
  if (err instanceof GitError) {
50
- throw new ConfigError('NOT_A_REPO', 'not inside a git repository', 'run from a clone or pass --repo <dir>')
50
+ throw new ConfigError(
51
+ 'NOT_A_REPO',
52
+ 'not inside a git repository',
53
+ 'run from a clone or pass --repo <dir>'
54
+ )
51
55
  }
52
56
  throw err
53
57
  }
@@ -72,7 +76,11 @@ export function parseGithubRemote(url: string): Repo | null {
72
76
  export async function resolveGithubRepo(git: Git): Promise<Repo> {
73
77
  const url = await git.remoteUrl('origin')
74
78
  if (url === null) {
75
- throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', 'add one that points at GitHub')
79
+ throw new ConfigError(
80
+ 'NO_ORIGIN',
81
+ 'the repository has no "origin" remote',
82
+ 'add one that points at GitHub'
83
+ )
76
84
  }
77
85
  const repo = parseGithubRemote(url)
78
86
  if (repo === null) {
@@ -90,7 +98,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
90
98
  const overrides: SettingsOverrides = {}
91
99
  if (flags.agent !== undefined) {
92
100
  if (!isChatAgent(flags.agent)) {
93
- throw new ConfigError('BAD_REQUEST', `unknown chat agent: ${flags.agent}`, 'use --agent claude or --agent codex')
101
+ throw new ConfigError(
102
+ 'BAD_REQUEST',
103
+ `unknown chat agent: ${flags.agent}`,
104
+ 'use --agent claude or --agent codex'
105
+ )
94
106
  }
95
107
  overrides.agent = flags.agent
96
108
  }
@@ -79,7 +79,9 @@ export const PostCommentInputSchema = z.discriminatedUnion('kind', [
79
79
  export type PostCommentInput = z.infer<typeof PostCommentInputSchema>
80
80
 
81
81
  /** The posted comment: a review comment for inline and reply, an issue comment for PR-level. */
82
- export type PostCommentResult = { kind: 'review'; comment: ReviewComment } | { kind: 'issue'; comment: IssueComment }
82
+ export type PostCommentResult =
83
+ | { kind: 'review'; comment: ReviewComment }
84
+ | { kind: 'issue'; comment: IssueComment }
83
85
 
84
86
  // The proposed-comment block a chat answer can carry. The parser lives in
85
87
  // static/js/proposed-comment.js so the browser loads the same code without a bundler.
@@ -57,7 +57,9 @@ export const GenerationContextSchema = z.object({
57
57
  }),
58
58
  /** The globs that make a file a test here. Defaulted, so a context written before this
59
59
  * field existed still reads. */
60
- tests: z.object({ patterns: z.array(z.string().min(1)) }).default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
60
+ tests: z
61
+ .object({ patterns: z.array(z.string().min(1)) })
62
+ .default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
61
63
  /** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
62
64
  smallPr: z.boolean(),
63
65
  /** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
@@ -48,7 +48,9 @@ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
48
48
  }
49
49
 
50
50
  /** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
51
- export function effectiveCaps(overrides: { [K in keyof TextCaps]?: number | undefined } | undefined): TextCaps {
51
+ export function effectiveCaps(
52
+ overrides: { [K in keyof TextCaps]?: number | undefined } | undefined
53
+ ): TextCaps {
52
54
  return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
53
55
  }
54
56
 
@@ -162,13 +164,17 @@ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
162
164
 
163
165
  function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
164
166
  const visibleCap = caps[key] / HARD_CAP_FACTOR
165
- const diagrams = key === 'summary' || key === 'rationale'
166
- ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
167
- : ''
168
- return z.string().max(caps[key]).meta({
169
- description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
170
- 'x-visibleMaxLength': visibleCap,
171
- })
167
+ const diagrams =
168
+ key === 'summary' || key === 'rationale'
169
+ ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
170
+ : ''
171
+ return z
172
+ .string()
173
+ .max(caps[key])
174
+ .meta({
175
+ description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
176
+ 'x-visibleMaxLength': visibleCap,
177
+ })
172
178
  }
173
179
 
174
180
  export function testEntrySchema(caps: Caps) {
@@ -25,7 +25,9 @@ export const PrStateSchema = z.object({
25
25
  */
26
26
  reviewedHeadSha: z.string().optional(),
27
27
  hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
28
- posted: z.array(z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })),
28
+ posted: z.array(
29
+ z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
30
+ ),
29
31
  dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
30
32
  chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
31
33
  updatedAt: z.string(),
@@ -79,7 +79,8 @@ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
79
79
  return null
80
80
  }
81
81
 
82
- const isBinary = header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
82
+ const isBinary =
83
+ header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
83
84
  let status: FileStatus
84
85
  if (renameFrom !== null) {
85
86
  status = 'renamed'
package/src/git/git.ts CHANGED
@@ -62,10 +62,15 @@ interface ExecResult {
62
62
  /** Runs git with an argument array; never a shell. */
63
63
  export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
64
64
  return new Promise(resolve => {
65
- execFile('git', args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
66
- const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
67
- resolve({ stdout, stderr: stderr.toString('utf8'), code })
68
- })
65
+ execFile(
66
+ 'git',
67
+ args,
68
+ { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
69
+ (error, stdout, stderr) => {
70
+ const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
71
+ resolve({ stdout, stderr: stderr.toString('utf8'), code })
72
+ }
73
+ )
69
74
  })
70
75
  }
71
76
 
@@ -94,9 +94,9 @@ export function rankCandidates(
94
94
  candidates: AttachmentCandidate[],
95
95
  target: { headSha: string; prNumber: number }
96
96
  ): AttachmentCandidate[] {
97
- const sha7 = target.headSha.slice(0, 7)
97
+ const shaPrefix = target.headSha.slice(0, 8)
98
98
  const score = (c: AttachmentCandidate): number =>
99
- (c.parsed.sha7 === sha7 ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
99
+ (c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
100
100
  return [...candidates].sort(
101
101
  (a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
102
102
  )
@@ -261,7 +261,7 @@ async function tryCandidate(
261
261
  const shared = {
262
262
  url: candidate.url,
263
263
  name: candidate.name,
264
- matchesHead: candidate.parsed.sha7 === pr.headSha.slice(0, 7),
264
+ matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
265
265
  }
266
266
  const download = await downloadAttachment(ctx, candidate.url)
267
267
  if (!download.ok) {
@@ -10,7 +10,11 @@ const RepoBodySchema = z.object({
10
10
  })
11
11
 
12
12
  /** What a page assumes before the probe answers: posting is tried and GitHub decides. */
13
- export const UNKNOWN_CAPABILITIES: Capabilities = { canComment: 'unknown', tokenKind: 'unprobed', login: null }
13
+ export const UNKNOWN_CAPABILITIES: Capabilities = {
14
+ canComment: 'unknown',
15
+ tokenKind: 'unprobed',
16
+ login: null,
17
+ }
14
18
 
15
19
  export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
16
20