@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
@@ -0,0 +1,131 @@
1
+ // Review marks following an incremental canvas forward. Each canvas names the one it was generated
2
+ // from, and that line of descent decides which of the reviewer's marks may follow — worked out here,
3
+ // on the reviewer's own machine, from the two canvases and the two diffs it can read itself. The
4
+ // published canvas is a pointer along that line and never a statement about what anyone reviewed.
5
+ import { reviewedId } from '../contract/keys.js'
6
+ import type { ReviewArtifact } from '../contract/review-artifact.js'
7
+ import type { PrState } from '../contract/state.js'
8
+ import type { AppContext } from '../server/context.js'
9
+ import { fileDelta } from './incremental.js'
10
+
11
+ function samePaths(a: readonly string[], b: readonly string[]): boolean {
12
+ const set = new Set(b)
13
+ return a.length === b.length && a.every(p => set.has(p))
14
+ }
15
+
16
+ /**
17
+ * The marks that still describe code the reviewer has seen. `marked` is the canvas they made the
18
+ * marks on. A file mark follows when that file is in both canvases under the same layer key and
19
+ * its patch is byte-identical; a layer mark follows only when the layer holds exactly the same
20
+ * files and none of them changed, since a layer mark claims the whole layer was read. Marks on
21
+ * anything else are left behind.
22
+ */
23
+ export function carriedMarks(
24
+ marked: ReviewArtifact,
25
+ next: ReviewArtifact,
26
+ unchangedPaths: ReadonlySet<string>,
27
+ reviewed: Readonly<Record<string, true>>
28
+ ): Record<string, true> {
29
+ const carried: Record<string, true> = {}
30
+ for (const layer of next.layers) {
31
+ const before = marked.layers.find(l => l.key === layer.key)
32
+ if (before === undefined) {
33
+ continue
34
+ }
35
+ const paths = layer.files.map(f => f.path)
36
+ const beforePaths = before.files.map(f => f.path)
37
+ for (const path of paths) {
38
+ const id = reviewedId(layer.key, path)
39
+ if (unchangedPaths.has(path) && beforePaths.includes(path) && reviewed[id] === true) {
40
+ carried[id] = true
41
+ }
42
+ }
43
+ const id = reviewedId(layer.key)
44
+ if (reviewed[id] === true && samePaths(paths, beforePaths) && paths.every(p => unchangedPaths.has(p))) {
45
+ carried[id] = true
46
+ }
47
+ }
48
+ return carried
49
+ }
50
+
51
+ export interface MarksForCanvas {
52
+ /** The stored state as it applies to this canvas: only the marks that count for it. */
53
+ state: PrState
54
+ /** The canvas the marks were made on, when any of them followed. The page says so. */
55
+ carriedFrom?: string
56
+ }
57
+
58
+ /**
59
+ * Whether the line of descent that starts at `basisSha` reaches `ancestorSha`. The canvas on screen
60
+ * states its own basis, so the walk starts from the artifact's own pointer; the canvases further
61
+ * back are read from the index, which one read already holds, so following the line costs no extra
62
+ * canvas reads on the page's hot path.
63
+ *
64
+ * The line matters because the reviewer's marks only move to a canvas generated from the one they
65
+ * were made on. It has to be a line rather than a single step: the stored commit advances only when
66
+ * the reviewer marks something (`setReviewed` is its only writer), so a reviewer who opens a canvas,
67
+ * finds it already ticked, and clicks nothing leaves their marks keyed to a canvas two or more
68
+ * generations back.
69
+ */
70
+ async function descendsFrom(
71
+ ctx: AppContext,
72
+ basisSha: string | undefined,
73
+ ancestorSha: string
74
+ ): Promise<boolean> {
75
+ const index = await ctx.canvases.readIndex()
76
+ const seen = new Set<string>()
77
+ let at = basisSha
78
+ while (at !== undefined && !seen.has(at)) {
79
+ if (at === ancestorSha) {
80
+ return true
81
+ }
82
+ seen.add(at)
83
+ at = index.canvases[at]?.basisCanvasSha
84
+ }
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * The marks to show for one canvas. Marks made on it stand. Marks made on a canvas it descends
90
+ * from follow it for the parts of the diff those two canvases share, whatever the canvases in
91
+ * between did: a mark says the reviewer read one patch, so the patch they read and the patch on
92
+ * screen are compared directly, and a file the intervening canvases changed and changed back reads
93
+ * as untouched because it is. Anything else describes other code and counts for nothing, which is
94
+ * also what happens when this machine cannot read the older canvas or rebuild either diff.
95
+ */
96
+ export async function marksForCanvas(
97
+ ctx: AppContext,
98
+ artifact: ReviewArtifact,
99
+ canvasSha: string,
100
+ stored: PrState
101
+ ): Promise<MarksForCanvas> {
102
+ if (stored.reviewedCanvasSha === canvasSha) {
103
+ return { state: stored }
104
+ }
105
+ const dropped: MarksForCanvas = { state: { ...stored, reviewed: {} } }
106
+ const markedOn = stored.reviewedCanvasSha
107
+ if (markedOn === undefined || !(await descendsFrom(ctx, artifact.basisCanvasSha, markedOn))) {
108
+ return dropped
109
+ }
110
+ const [markedArtifact, markedManifest, manifest] = await Promise.all([
111
+ ctx.canvases.readArtifact(markedOn),
112
+ ctx.canvases.readManifest(markedOn),
113
+ ctx.canvases.readManifest(canvasSha),
114
+ ])
115
+ if (markedArtifact === null || markedManifest === null || manifest === null) {
116
+ return dropped
117
+ }
118
+ const [markedDiff, diff] = await Promise.all([
119
+ ctx.derived.readOrBuild(markedOn, markedManifest.mergeBaseSha),
120
+ ctx.derived.readOrBuild(canvasSha, manifest.mergeBaseSha),
121
+ ])
122
+ if (markedDiff === null || diff === null) {
123
+ return dropped
124
+ }
125
+ const unchanged = new Set(fileDelta(markedDiff, diff).unchanged)
126
+ const reviewed = carriedMarks(markedArtifact, artifact, unchanged, stored.reviewed)
127
+ if (Object.keys(reviewed).length === 0) {
128
+ return dropped
129
+ }
130
+ return { state: { ...stored, reviewed, reviewedCanvasSha: canvasSha }, carriedFrom: markedOn }
131
+ }
@@ -37,7 +37,7 @@ export interface DoctorDeps {
37
37
  acpxVersion: () => Promise<string | null>
38
38
  /** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
39
39
  dataDirOverride?: string | undefined
40
- readSkill?: (file: string) => Promise<string | null>
40
+ readSkill?: ReadSkill
41
41
  }
42
42
 
43
43
  function message(err: unknown): string {
@@ -60,57 +60,91 @@ async function checkDataDir(dir: string): Promise<DoctorCheck> {
60
60
  }
61
61
  }
62
62
 
63
+ export type ReadSkill = (file: string) => Promise<string | null>
64
+
65
+ const readSkillFile: ReadSkill = file => readFile(file, 'utf8')
66
+
67
+ /** One copy of the skill in the repository, next to how it compares with the bundled one. */
68
+ export interface SkillCopy {
69
+ kind: 'claude' | 'codex'
70
+ /** The skills directory the copy sits in, absolute. */
71
+ dir: string
72
+ /** `<dir>/pr-review-canvas`, relative to the repository. */
73
+ path: string
74
+ /** True when the copy's body or recorded hash differs from the bundled skill, or it cannot be read. */
75
+ stale: boolean
76
+ /** Why the copy could not be read, when it could not. */
77
+ error?: string
78
+ }
79
+
80
+ /**
81
+ * The copies of the skill in `.claude/skills` and `.agents/skills`. A directory without one is
82
+ * left out. Throws when the bundled skill itself cannot be read.
83
+ */
84
+ export async function findSkillCopies(
85
+ repoRoot: string,
86
+ readSkill: ReadSkill = readSkillFile
87
+ ): Promise<SkillCopy[]> {
88
+ const expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
89
+ const copies: SkillCopy[] = []
90
+ for (const [kind, skillsDir] of [
91
+ ['claude', CLAUDE_SKILLS_DIR],
92
+ ['codex', CODEX_SKILLS_DIR],
93
+ ] as const) {
94
+ const dir = path.join(repoRoot, skillsDir)
95
+ const target = path.join(dir, SKILL_NAME)
96
+ const rel = path.relative(repoRoot, target)
97
+ try {
98
+ const text = await readSkill(path.join(target, 'SKILL.md'))
99
+ if (text === null) continue
100
+ const { hash, frontmatter } = skillContent(text)
101
+ const matches = hash === expected && frontmatter.getIn(['metadata', 'body-sha256']) === expected
102
+ copies.push({ kind, dir, path: rel, stale: !matches })
103
+ } catch (err) {
104
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
105
+ copies.push({ kind, dir, path: rel, stale: true, error: message(err) })
106
+ }
107
+ }
108
+ }
109
+ return copies
110
+ }
111
+
63
112
  /** The skill the generation flow needs, in either harness's directory. */
64
113
  export async function checkSkill(
65
114
  repoRoot: string | null,
66
- readSkill: NonNullable<DoctorDeps['readSkill']> = file => readFile(file, 'utf8')
115
+ readSkill: ReadSkill = readSkillFile
67
116
  ): Promise<DoctorCheck> {
68
117
  if (repoRoot === null) {
69
118
  return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
70
119
  }
71
- const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
72
- const found: string[] = []
73
- const stale: string[] = []
74
- let expected: string
120
+ let copies: SkillCopy[]
75
121
  try {
76
- expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
122
+ copies = await findSkillCopies(repoRoot, readSkill)
77
123
  } catch (err) {
78
124
  return { ok: false, detail: message(err), hint: 'reinstall the pr-review package' }
79
125
  }
80
- for (const target of targets) {
81
- try {
82
- const text = await readSkill(path.join(target, 'SKILL.md'))
83
- if (text === null) continue
84
- found.push(path.relative(repoRoot, target))
85
- const { hash, frontmatter } = skillContent(text)
86
- if (hash !== expected || frontmatter.getIn(['metadata', 'body-sha256']) !== expected) {
87
- stale.push(path.relative(repoRoot, target))
88
- }
89
- } catch (err) {
90
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
91
- stale.push(`${path.relative(repoRoot, target)}: ${message(err)}`)
92
- }
93
- }
94
- }
126
+ const stale = copies.flatMap(copy =>
127
+ copy.stale ? [copy.error === undefined ? copy.path : `${copy.path}: ${copy.error}`] : []
128
+ )
95
129
  if (stale.length > 0) {
96
130
  return {
97
131
  ok: false,
98
132
  detail: `outdated or modified skill: ${stale.join(', ')}`,
99
- hint: 'run `pr-review install-skill`',
133
+ hint: 'run `pr-review upgrade` or `pr-review install-skill`',
100
134
  }
101
135
  }
102
- if (found.length === 0) {
136
+ if (copies.length === 0) {
103
137
  return {
104
138
  ok: false,
105
139
  detail: `${SKILL_NAME} is in neither ${CLAUDE_SKILLS_DIR} nor ${CODEX_SKILLS_DIR}`,
106
140
  hint: 'run `pr-review install-skill`',
107
141
  }
108
142
  }
109
- return { ok: true, detail: found.join(', ') }
143
+ return { ok: true, detail: copies.map(copy => copy.path).join(', ') }
110
144
  }
111
145
 
112
146
  async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
113
- const hint = 'install with `npm install -g acpx` and check `acpx --version`'
147
+ const hint = 'install with `npm install -g acpx@latest` and check `acpx --version`'
114
148
  try {
115
149
  const version = (await deps.acpxVersion())?.trim()
116
150
  return version
@@ -0,0 +1,107 @@
1
+ // Incremental generation: the basis canvas an update starts from, the per-file delta between that
2
+ // canvas's diff and the head's, and the split of the basis into content the head leaves untouched
3
+ // and content the generator has to decide anew. Everything here is a function of two diffs and one
4
+ // stored canvas, so `prepare` states the split instead of asking the generator to work it out.
5
+ import type {
6
+ BasisSplit,
7
+ BasisSplitLayer,
8
+ BasisSplitPoint,
9
+ FileDelta,
10
+ } from '../contract/generation-context.js'
11
+ import type { ReviewArtifact } from '../contract/review-artifact.js'
12
+ import type { Git } from '../git/git.js'
13
+ import type { CanvasStore } from '../store/canvas-store.js'
14
+ import type { Derived } from '../store/derived-store.js'
15
+
16
+ /**
17
+ * Which files the head changes relative to the basis canvas's diff. A file is unchanged only when
18
+ * its whole patch is byte-identical: line numbers, context, and all. Anything less would let a
19
+ * fold or an annotation of the basis land on a line it was never written for. Renames arrive as a
20
+ * removal and an addition, since a file's key follows its path.
21
+ *
22
+ * The two sides are matched by path, and each side's patch is read through its own key. A key is
23
+ * the sanitized path, and two paths can sanitize to the same key (`a-b.ts` and `a_b.ts`), which
24
+ * `uniqueKey` then separates by the order the files appear in that one diff. The same key can
25
+ * therefore name different files in two diffs; the path cannot.
26
+ */
27
+ export function fileDelta(basis: Derived, head: Derived): FileDelta {
28
+ const keysByPath = (d: Derived): Map<string, string> => new Map(d.files.map(f => [f.path, f.key]))
29
+ const basisKeys = keysByPath(basis)
30
+ const headKeys = keysByPath(head)
31
+ const delta: FileDelta = { unchanged: [], changed: [], added: [], removed: [] }
32
+ for (const [path, headKey] of headKeys) {
33
+ const basisKey = basisKeys.get(path)
34
+ if (basisKey === undefined) {
35
+ delta.added.push(path)
36
+ } else if (basis.patches[basisKey] === head.patches[headKey]) {
37
+ delta.unchanged.push(path)
38
+ } else {
39
+ delta.changed.push(path)
40
+ }
41
+ }
42
+ for (const path of basisKeys.keys()) {
43
+ if (!headKeys.has(path)) {
44
+ delta.removed.push(path)
45
+ }
46
+ }
47
+ for (const list of Object.values(delta)) {
48
+ list.sort()
49
+ }
50
+ return delta
51
+ }
52
+
53
+ /**
54
+ * The basis canvas divided in two. A layer is carried whole when the head touches none of its
55
+ * files; otherwise the layer is re-judged, and only the files the head leaves alone keep their
56
+ * note, folds, and annotations. A point is carried when the file it sits in is untouched, which
57
+ * keeps its title and so its fingerprint, and with it any dismissal the reviewer made.
58
+ */
59
+ export function splitBasis(
60
+ artifact: ReviewArtifact,
61
+ delta: FileDelta
62
+ ): Omit<BasisSplit, 'canvasSha' | 'reviewJsonPath' | 'files'> {
63
+ const unchanged = new Set(delta.unchanged)
64
+ const layers: BasisSplitLayer[] = artifact.layers.map(layer => {
65
+ const carriedFiles = layer.files.filter(f => unchanged.has(f.path)).map(f => f.path)
66
+ const reJudgedFiles = layer.files.filter(f => !unchanged.has(f.path)).map(f => f.path)
67
+ return {
68
+ key: layer.key,
69
+ title: layer.title,
70
+ status: reJudgedFiles.length === 0 ? 'carried' : 're-judged',
71
+ carriedFiles,
72
+ reJudgedFiles,
73
+ }
74
+ })
75
+ const points: BasisSplitPoint[] = artifact.points.map(point => ({
76
+ kind: point.kind,
77
+ path: point.path,
78
+ title: point.title,
79
+ status: unchanged.has(point.path) ? 'carried' : 're-judged',
80
+ }))
81
+ return { layers, points }
82
+ }
83
+
84
+ /**
85
+ * The canvas an incremental run builds on: the newest one generated for a commit this head was
86
+ * built on. A canvas of a line of work the head no longer contains describes code that was
87
+ * abandoned, so it is never a basis, however recent it is.
88
+ */
89
+ export async function findBasisCanvas(
90
+ canvases: CanvasStore,
91
+ git: Git,
92
+ prNumber: number | undefined,
93
+ headSha: string
94
+ ): Promise<string | null> {
95
+ const index = await canvases.readIndex()
96
+ const candidates = Object.entries(index.canvases)
97
+ .filter(
98
+ ([sha, entry]) => sha !== headSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
99
+ )
100
+ .sort(([, a], [, b]) => (a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0))
101
+ for (const [sha] of candidates) {
102
+ if (await git.isAncestor(sha, headSha)) {
103
+ return sha
104
+ }
105
+ }
106
+ return null
107
+ }
@@ -29,6 +29,8 @@ export interface NormalizeInput {
29
29
  caps: TextCaps
30
30
  generatedAt: string
31
31
  generator: Generator
32
+ /** The canvas this one was generated from, when the run was incremental. */
33
+ basisCanvasSha?: string | undefined
32
34
  /** The globs that make a file a test; the project config's list, or the built-in one. */
33
35
  testPatterns?: readonly string[] | undefined
34
36
  }
@@ -76,16 +78,20 @@ function unionRisk(layers: readonly Layer[]): RiskTag[] {
76
78
  return out
77
79
  }
78
80
 
81
+ /**
82
+ * A layer's id is its own key, which the validator has already checked is unique here. A reviewed
83
+ * mark is keyed by it, so a regenerated canvas that reorders or renames its layers keeps the
84
+ * reviewer's progress pointing at the same concern; a position could not.
85
+ */
79
86
  function toLayer(
80
87
  layer: ModelLayer,
81
- index: number,
82
88
  highRisk: readonly HighRiskRule[],
83
89
  testPatterns: readonly string[]
84
90
  ): Layer {
85
91
  const { risk: _modelRisk, files, ...rest } = layer
86
92
  return {
87
93
  ...rest,
88
- id: `layer-${index + 1}`,
94
+ id: layer.key,
89
95
  risk: layerRisk(layer, highRisk),
90
96
  files: files.map(f => ({ ...f, isTest: isTestPath(f.path, testPatterns) })),
91
97
  }
@@ -167,12 +173,12 @@ function sortPoints(points: Unassigned[]): Point[] {
167
173
 
168
174
  export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArtifact {
169
175
  const testPatterns = input.testPatterns ?? DEFAULT_TEST_PATTERNS
170
- const layers = output.layers.map((l, i) => toLayer(l, i, input.highRisk, testPatterns))
176
+ const layers = output.layers.map(l => toLayer(l, input.highRisk, testPatterns))
171
177
  const points = [
172
178
  ...output.points.map(p => modelPoint(p, layers, input.files)),
173
179
  ...layers.flatMap(l => testPoints(l, input.files, input.caps.pointTitle)),
174
180
  ]
175
- return {
181
+ const artifact: ReviewArtifact = {
176
182
  version: 1,
177
183
  pr: input.pr,
178
184
  files: [...input.files],
@@ -184,6 +190,10 @@ export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArt
184
190
  generator: input.generator,
185
191
  source: 'local',
186
192
  }
193
+ if (input.basisCanvasSha !== undefined) {
194
+ artifact.basisCanvasSha = input.basisCanvasSha
195
+ }
196
+ return artifact
187
197
  }
188
198
 
189
199
  /**
@@ -3,6 +3,7 @@
3
3
  import { appendFile, readdir, rm } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import {
6
+ type BasisSplit,
6
7
  type GenerationContext,
7
8
  isLargePr,
8
9
  type PrepareTarget,
@@ -16,6 +17,8 @@ import { fetchPrRefs } from '../git/pr-refs.js'
16
17
  import { toPr } from '../host/pr.js'
17
18
  import type { AppContext } from '../server/context.js'
18
19
  import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
20
+ import type { Derived } from '../store/derived-store.js'
21
+ import { fileDelta, findBasisCanvas, splitBasis } from './incremental.js'
19
22
  import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
20
23
 
21
24
  export interface PrepareOptions {
@@ -131,6 +134,45 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
131
134
  }
132
135
  }
133
136
 
137
+ /**
138
+ * The basis canvas of this run, split into what carries and what is re-judged, or null when the
139
+ * canvas is generated from a blank page: `--force`, `canvas.incremental: false`, no canvas of an
140
+ * ancestor commit, or a basis whose canvas or diff this machine can no longer read.
141
+ *
142
+ * Only a pull request's canvases are owned by a number; a refs or local run considers the canvases
143
+ * that carry no number, the same ownership rule the store's own lookup uses.
144
+ */
145
+ async function resolveBasis(
146
+ ctx: AppContext,
147
+ target: PrepareTarget,
148
+ pr: Pr,
149
+ head: Derived
150
+ ): Promise<BasisSplit | null> {
151
+ const prNumber = target.kind === 'pr' ? target.number : undefined
152
+ const sha = await findBasisCanvas(ctx.canvases, ctx.git, prNumber, pr.headSha)
153
+ if (sha === null) {
154
+ return null
155
+ }
156
+ const [artifact, manifest] = await Promise.all([
157
+ ctx.canvases.readArtifact(sha),
158
+ ctx.canvases.readManifest(sha),
159
+ ])
160
+ if (artifact === null || manifest === null) {
161
+ return null
162
+ }
163
+ const basis = await ctx.derived.readOrBuild(sha, manifest.mergeBaseSha)
164
+ if (basis === null) {
165
+ return null
166
+ }
167
+ const files = fileDelta(basis, head)
168
+ return {
169
+ canvasSha: sha,
170
+ reviewJsonPath: path.join(ctx.canvases.canvasDir(sha), 'review.json'),
171
+ files,
172
+ ...splitBasis(artifact, files),
173
+ }
174
+ }
175
+
134
176
  /** The target with its base resolved, which is the form `context.json` records. */
135
177
  async function resolveTarget(ctx: AppContext, input: PrepareTargetInput): Promise<PrepareTarget> {
136
178
  if (input.kind !== 'local') {
@@ -216,6 +258,11 @@ export async function prepare(
216
258
  largePr: isLargePr({ files: derived.files.length, additions, deletions }),
217
259
  preparedAt: ctx.now().toISOString(),
218
260
  }
261
+ // `--force` means start over, so it never reads a basis, whatever the project config says.
262
+ const basis = opts.force || !config.canvas.incremental ? null : await resolveBasis(ctx, target, pr, derived)
263
+ if (basis !== null) {
264
+ context.basis = basis
265
+ }
219
266
  const sources =
220
267
  opts.promptSources ??
221
268
  (await loadPromptSources(undefined, {
@@ -1,7 +1,7 @@
1
1
  // Renders prompt.md from the selected generation template and the prepared context. The template carries
2
2
  // the prose; this module fills the `{{TOKENS}}` with data so a wording change never touches code.
3
3
  import { z } from 'zod'
4
- import { type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
4
+ import { type BasisSplit, type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
5
5
  import { type FileEntry, modelOutputSchema } from '../contract/review-artifact.js'
6
6
  import { labelPatch } from '../git/patch-lines.js'
7
7
  import { loadPromptFile, type ProjectPrompts } from '../prompt-files.js'
@@ -12,20 +12,48 @@ export { PROMPTS_DIR }
12
12
 
13
13
  export interface PromptSources {
14
14
  generation: Record<GenerationMode, string>
15
+ /** The same two modes, worded as an update of a basis canvas. Used when `ctx.basis` is set. */
16
+ incremental: Record<GenerationMode, string>
17
+ /**
18
+ * The judging rules of each mode: the half of the task that does not change between writing a
19
+ * canvas and updating one. Both task files of a mode end with it, so its wording has one home.
20
+ */
21
+ judging: Record<GenerationMode, string>
15
22
  format: string
16
23
  layeringGuidance: string
17
24
  qualityStandards: string
18
25
  }
19
26
 
20
27
  export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
21
- const [format, layeringGuidance, qualityStandards, strict, surfacing] = await Promise.all([
28
+ const [
29
+ format,
30
+ layeringGuidance,
31
+ qualityStandards,
32
+ strict,
33
+ surfacing,
34
+ strictInc,
35
+ surfacingInc,
36
+ judgingStrict,
37
+ judgingSurfacing,
38
+ ] = await Promise.all([
22
39
  loadPromptFile('generation-format.md', dir, project),
23
40
  loadPromptFile('layering-guidance.md', dir, project),
24
41
  loadPromptFile('quality-standards.md', dir, project),
25
42
  loadPromptFile('generation-strict.md', dir, project),
26
43
  loadPromptFile('generation-surfacing.md', dir, project),
44
+ loadPromptFile('generation-strict-incremental.md', dir, project),
45
+ loadPromptFile('generation-surfacing-incremental.md', dir, project),
46
+ loadPromptFile('judging-strict.md', dir, project),
47
+ loadPromptFile('judging-surfacing.md', dir, project),
27
48
  ])
28
- return { format, layeringGuidance, qualityStandards, generation: { strict, surfacing } }
49
+ return {
50
+ format,
51
+ layeringGuidance,
52
+ qualityStandards,
53
+ generation: { strict, surfacing },
54
+ incremental: { strict: strictInc, surfacing: surfacingInc },
55
+ judging: { strict: judgingStrict, surfacing: judgingSurfacing },
56
+ }
29
57
  }
30
58
 
31
59
  /** The line ranges of a hunk header; the trailing function context can hold backticks. */
@@ -197,6 +225,74 @@ export function schemaMarkdown(ctx: GenerationContext): string {
197
225
  return `\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\``
198
226
  }
199
227
 
228
+ function list(items: readonly string[], empty: string): string {
229
+ return items.length === 0 ? `_${empty}_` : items.map(i => `- \`${i}\``).join('\n')
230
+ }
231
+
232
+ function basisMarkdown(basis: BasisSplit | undefined): string {
233
+ if (basis === undefined) {
234
+ return ''
235
+ }
236
+ return [
237
+ `- Basis canvas: \`${basis.canvasSha}\``,
238
+ `- Its canvas file: \`${basis.reviewJsonPath}\` — read it for the wording you carry`,
239
+ ].join('\n')
240
+ }
241
+
242
+ function fileDeltaMarkdown(basis: BasisSplit | undefined): string {
243
+ if (basis === undefined) {
244
+ return ''
245
+ }
246
+ const f = basis.files
247
+ return [
248
+ `**Untouched** — the patch is byte-identical to the basis canvas's:\n\n${list(f.unchanged, 'none')}`,
249
+ `**Changed** — the patch differs, so every line number in it may have moved:\n\n${list(f.changed, 'none')}`,
250
+ `**New** — not in the basis canvas at all:\n\n${list(f.added, 'none')}`,
251
+ `**Gone** — in the basis canvas, not in this diff:\n\n${list(f.removed, 'none')}`,
252
+ ].join('\n\n')
253
+ }
254
+
255
+ /** The two lists the generator works from: what to copy across, and what to decide anew. */
256
+ function carriedMarkdown(basis: BasisSplit | undefined): string {
257
+ if (basis === undefined) {
258
+ return ''
259
+ }
260
+ const layers = basis.layers
261
+ .filter(l => l.status === 'carried')
262
+ .map(l => `- layer \`${l.key}\` — **${l.title}** (${l.carriedFiles.length} files, all untouched)`)
263
+ const files = basis.layers
264
+ .filter(l => l.status === 're-judged')
265
+ .flatMap(l => l.carriedFiles.map(p => `- \`${p}\`, from layer \`${l.key}\``))
266
+ const points = basis.points
267
+ .filter(p => p.status === 'carried')
268
+ .map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
269
+ return [
270
+ `**Whole layers** — copy the layer with its title, rationale, decisions, checkByHand, tests, files, notes, folds, and annotations:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
271
+ `**Single files of a re-judged layer** — the file is untouched, so its note, folds, and annotations still fit wherever you put the file:\n\n${files.length === 0 ? '_none_' : files.join('\n')}`,
272
+ `**Attention points** — repeat the kind, path, and title exactly, so the point keeps its identity and any dismissal the reviewer made:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
273
+ ].join('\n\n')
274
+ }
275
+
276
+ function reJudgedMarkdown(basis: BasisSplit | undefined): string {
277
+ if (basis === undefined) {
278
+ return ''
279
+ }
280
+ const layers = basis.layers
281
+ .filter(l => l.status === 're-judged')
282
+ .map(
283
+ l =>
284
+ `- layer \`${l.key}\` — **${l.title}**; touched: ${l.reJudgedFiles.map(p => `\`${p}\``).join(', ')}`
285
+ )
286
+ const points = basis.points
287
+ .filter(p => p.status === 're-judged')
288
+ .map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
289
+ return [
290
+ `**Layers** — the head touched at least one of their files, so decide the grouping, the prose, and the anchors again:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
291
+ `**Attention points** — the code under them moved; keep one only if you read the new code and it still holds:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
292
+ '**The summary and the pull-request-wide risk** are always written again: they describe the whole change set, which the new commits changed.',
293
+ ].join('\n\n')
294
+ }
295
+
200
296
  /** Selects one task and fills its data and format placeholders before the generator sees it. */
201
297
  export function renderPrompt(
202
298
  ctx: GenerationContext,
@@ -227,12 +323,23 @@ export function renderPrompt(
227
323
  TEST_PATTERNS: testPatternsMarkdown(ctx),
228
324
  SMALL_PR: smallPrMarkdown(ctx),
229
325
  MAX_REPAIR_ROUNDS: String(ctx.generation.maxRepairRounds),
326
+ BASIS: basisMarkdown(ctx.basis),
327
+ FILE_DELTA: fileDeltaMarkdown(ctx.basis),
328
+ CARRIED: carriedMarkdown(ctx.basis),
329
+ RE_JUDGED: reJudgedMarkdown(ctx.basis),
230
330
  }
231
- const template = sources.generation[ctx.generation.mode].replace('{{FORMAT}}', () => sources.format)
331
+ // A prepared basis picks the incremental wording: one prompt states one job, with no conditions.
332
+ // Both wordings end with the mode's judging rules, which are assembled first so the tokens inside
333
+ // them are filled by the one pass below.
334
+ const task = ctx.basis === undefined ? sources.generation : sources.incremental
335
+ const template = task[ctx.generation.mode]
336
+ .replace('{{JUDGING}}', () => sources.judging[ctx.generation.mode])
337
+ .replace('{{FORMAT}}', () => sources.format)
232
338
  return template.replace(/\{\{([A-Z_]+)\}\}/g, (_m, name: string) => {
233
339
  const value = tokens[name]
234
340
  if (value === undefined) {
235
- throw new Error(`generation-${ctx.generation.mode}.md uses an unknown token {{${name}}}`)
341
+ const suffix = ctx.basis === undefined ? '' : '-incremental'
342
+ throw new Error(`generation-${ctx.generation.mode}${suffix}.md uses an unknown token {{${name}}}`)
236
343
  }
237
344
  return value
238
345
  })
@@ -232,6 +232,7 @@ export async function publish(
232
232
  generatedAt: now,
233
233
  generator,
234
234
  testPatterns: context.tests.patterns,
235
+ basisCanvasSha: context.basis?.canvasSha,
235
236
  })
236
237
  const manifest = buildManifest(context, artifact, ctx.version)
237
238
  // A snapshot commit is on no branch, so it must never be offered as a pull request's canvas.