@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,120 @@
1
+ import path from 'node:path'
2
+ import type { CanvasRelation } from '../contract/api.js'
3
+ import {
4
+ type CanvasIndex,
5
+ CanvasIndexSchema,
6
+ type CanvasManifest,
7
+ CanvasManifestSchema,
8
+ } from '../contract/canvas-manifest.js'
9
+ import { type ReviewArtifact, ReviewArtifactSchema } from '../contract/review-artifact.js'
10
+ import type { Git } from '../git/git.js'
11
+ import { readJson, readText, writeJsonAtomic } from './atomic-json.js'
12
+
13
+ export type CanvasLookup =
14
+ | { status: 'ready'; headSha: string }
15
+ | { status: 'stale'; headSha: string; relation: CanvasRelation; commitsBehind?: number }
16
+ | { status: 'missing' }
17
+
18
+ export interface CanvasStore {
19
+ readonly root: string
20
+ canvasDir(headSha: string): string
21
+ readIndex(): Promise<CanvasIndex>
22
+ /** True when a review.json exists for the sha, whatever its format. */
23
+ exists(headSha: string): Promise<boolean>
24
+ readArtifact(headSha: string): Promise<ReviewArtifact | null>
25
+ readManifest(headSha: string): Promise<CanvasManifest | null>
26
+ /** Writes review.json + manifest.json atomically and records the canvas in index.json. */
27
+ write(headSha: string, artifact: ReviewArtifact, manifest: CanvasManifest, prNumber?: number): Promise<void>
28
+ /**
29
+ * The best canvas for this PR: the one written for its current head, else the newest one the
30
+ * head was built on top of, else one from a branch the head no longer contains.
31
+ */
32
+ findForPr(prNumber: number, currentHeadSha: string): Promise<CanvasLookup>
33
+ /** Records the PR number on a canvas that was exported before the pull request existed. */
34
+ attachPrNumber(headSha: string, prNumber: number): Promise<void>
35
+ }
36
+
37
+ const SHA_RE = /^[0-9a-f]{40}$/
38
+
39
+ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
40
+ const indexFile = path.join(repoRoot, 'index.json')
41
+ const canvasDir = (headSha: string): string => {
42
+ if (!SHA_RE.test(headSha)) {
43
+ throw new Error(`not a commit sha: ${headSha}`)
44
+ }
45
+ return path.join(repoRoot, 'canvases', headSha)
46
+ }
47
+ const readIndex = async (): Promise<CanvasIndex> => (await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
48
+
49
+ return {
50
+ root: repoRoot,
51
+ canvasDir,
52
+ readIndex,
53
+ exists: async headSha => (await readText(path.join(canvasDir(headSha), 'review.json'))) !== null,
54
+ readArtifact: headSha => readJson(path.join(canvasDir(headSha), 'review.json'), ReviewArtifactSchema),
55
+ readManifest: headSha => readJson(path.join(canvasDir(headSha), 'manifest.json'), CanvasManifestSchema),
56
+ write: async (headSha, artifact, manifest, prNumber) => {
57
+ const dir = canvasDir(headSha)
58
+ await writeJsonAtomic(path.join(dir, 'review.json'), artifact)
59
+ await writeJsonAtomic(path.join(dir, 'manifest.json'), manifest)
60
+ const index = await readIndex()
61
+ const entry: CanvasIndex['canvases'][string] = { generatedAt: artifact.generatedAt, source: artifact.source }
62
+ const number = prNumber ?? manifest.prNumber
63
+ if (number !== undefined) {
64
+ entry.prNumber = number
65
+ }
66
+ if (artifact.importedAt !== undefined) {
67
+ entry.importedAt = artifact.importedAt
68
+ }
69
+ index.canvases[headSha] = entry
70
+ await writeJsonAtomic(indexFile, index)
71
+ },
72
+ findForPr: async (prNumber, currentHeadSha) => {
73
+ const index = await readIndex()
74
+ if (index.canvases[currentHeadSha] !== undefined) {
75
+ return { status: 'ready', headSha: currentHeadSha }
76
+ }
77
+ // A canvas belongs to this PR when it was exported for it, or before the PR existed.
78
+ const candidates = Object.entries(index.canvases).filter(
79
+ ([sha, entry]) => sha !== currentHeadSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
80
+ )
81
+ const ranked: Array<{ headSha: string; generatedAt: string; relation: CanvasRelation; commitsBehind?: number }> =
82
+ []
83
+ for (const [sha, entry] of candidates) {
84
+ if (await git.isAncestor(sha, currentHeadSha)) {
85
+ ranked.push({
86
+ headSha: sha,
87
+ generatedAt: entry.generatedAt,
88
+ relation: 'ancestor',
89
+ commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
90
+ })
91
+ } else {
92
+ ranked.push({ headSha: sha, generatedAt: entry.generatedAt, relation: 'unrelated' })
93
+ }
94
+ }
95
+ // Commits the head was built on come first; a force-pushed-away canvas is the last resort.
96
+ ranked.sort((a, b) => {
97
+ if (a.relation !== b.relation) {
98
+ return a.relation === 'ancestor' ? -1 : 1
99
+ }
100
+ return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
101
+ })
102
+ const best = ranked[0]
103
+ if (best === undefined) {
104
+ return { status: 'missing' }
105
+ }
106
+ return best.commitsBehind === undefined
107
+ ? { status: 'stale', headSha: best.headSha, relation: best.relation }
108
+ : { status: 'stale', headSha: best.headSha, relation: best.relation, commitsBehind: best.commitsBehind }
109
+ },
110
+ attachPrNumber: async (headSha, prNumber) => {
111
+ const index = await readIndex()
112
+ const entry = index.canvases[headSha]
113
+ if (entry === undefined || entry.prNumber === prNumber) {
114
+ return
115
+ }
116
+ index.canvases[headSha] = { ...entry, prNumber }
117
+ await writeJsonAtomic(indexFile, index)
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,29 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { Repo } from '../contract/review-artifact.js'
4
+ import { readText } from './atomic-json.js'
5
+
6
+ /**
7
+ * `.pr-review/` next to the git common dir, so every worktree of one clone shares canvases, PR
8
+ * state, and settings. The common dir of a worktree is `<main checkout>/.git`, and its parent
9
+ * is the main checkout root. An explicit override wins.
10
+ */
11
+ export function resolveDataDir(opts: { override?: string | undefined; commonDir: string }): string {
12
+ if (opts.override !== undefined && opts.override !== '') {
13
+ return path.resolve(opts.override)
14
+ }
15
+ return path.join(path.dirname(path.resolve(opts.commonDir)), '.pr-review')
16
+ }
17
+
18
+ /** Creates the directory and the `.gitignore` with `*` so it never gets committed. */
19
+ export async function ensureDataDir(dataDir: string): Promise<void> {
20
+ await mkdir(dataDir, { recursive: true })
21
+ const ignore = path.join(dataDir, '.gitignore')
22
+ if ((await readText(ignore)) === null) {
23
+ await writeFile(ignore, '*\n', 'utf8')
24
+ }
25
+ }
26
+
27
+ export function repoDir(dataDir: string, repo: Repo): string {
28
+ return path.join(dataDir, 'repos', `${repo.owner}__${repo.name}`)
29
+ }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path'
2
+ import { z } from 'zod'
3
+ import { type FileEntry, FileEntrySchema } from '../contract/review-artifact.js'
4
+ import { collectDiffs, toFileEntry, toPatchMap } from '../git/diff-collector.js'
5
+ import type { Git } from '../git/git.js'
6
+ import { materialize } from '../git/materialize.js'
7
+ import { labelPatch } from '../git/patch-lines.js'
8
+ import { readJson, readText, writeJsonAtomic, writeTextAtomic } from './atomic-json.js'
9
+ import type { CanvasStore } from './canvas-store.js'
10
+
11
+ export interface Derived {
12
+ files: FileEntry[]
13
+ patches: Record<string, string>
14
+ }
15
+
16
+ const DerivedMetaSchema = z.object({ headSha: z.string(), mergeBaseSha: z.string(), builtAt: z.string() })
17
+ const FilesSchema = z.array(FileEntrySchema)
18
+ const PatchesSchema = z.record(z.string(), z.string())
19
+
20
+ export interface DerivedStore {
21
+ derivedDir(headSha: string): string
22
+ /** Both commits are local, so `ensure` can rebuild. */
23
+ derivable(headSha: string, mergeBaseSha: string): Promise<boolean>
24
+ /** Reads `derived/` when it matches the commits, otherwise rebuilds it from local git. */
25
+ ensure(headSha: string, mergeBaseSha: string): Promise<Derived>
26
+ read(headSha: string): Promise<Derived | null>
27
+ /** Lines `from..to` (1-based, inclusive) of a materialized file, or null when not materialized. */
28
+ readLines(
29
+ headSha: string,
30
+ side: 'head' | 'base',
31
+ filePath: string,
32
+ from: number,
33
+ to: number
34
+ ): Promise<string[] | null>
35
+ }
36
+
37
+ export function createDerivedStore(canvases: CanvasStore, git: Git, now: () => Date): DerivedStore {
38
+ const derivedDir = (headSha: string): string => path.join(canvases.canvasDir(headSha), 'derived')
39
+
40
+ const read = async (headSha: string): Promise<Derived | null> => {
41
+ const dir = derivedDir(headSha)
42
+ const files = await readJson(path.join(dir, 'files.json'), FilesSchema)
43
+ const patches = await readJson(path.join(dir, 'patches.json'), PatchesSchema)
44
+ if (files === null || patches === null) {
45
+ return null
46
+ }
47
+ return { files, patches }
48
+ }
49
+
50
+ return {
51
+ derivedDir,
52
+ derivable: async (headSha, mergeBaseSha) =>
53
+ (await git.commitExists(headSha)) && (await git.commitExists(mergeBaseSha)),
54
+ ensure: async (headSha, mergeBaseSha) => {
55
+ const dir = derivedDir(headSha)
56
+ const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
57
+ if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
58
+ const cached = await read(headSha)
59
+ if (cached !== null) {
60
+ return cached
61
+ }
62
+ }
63
+ const collected = await collectDiffs(git, mergeBaseSha, headSha)
64
+ const files = collected.map(toFileEntry)
65
+ const patches = toPatchMap(collected)
66
+ await writeJsonAtomic(path.join(dir, 'files.json'), files)
67
+ await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
68
+ for (const f of collected) {
69
+ await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
70
+ }
71
+ await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
72
+ await writeJsonAtomic(path.join(dir, 'meta.json'), { headSha, mergeBaseSha, builtAt: now().toISOString() })
73
+ return { files, patches }
74
+ },
75
+ read,
76
+ readLines: async (headSha, side, filePath, from, to) => {
77
+ const root = path.join(derivedDir(headSha), side)
78
+ const full = path.resolve(root, filePath)
79
+ if (!full.startsWith(root + path.sep)) {
80
+ return null
81
+ }
82
+ const text = await readText(full)
83
+ if (text === null) {
84
+ return null
85
+ }
86
+ const lines = text.split('\n')
87
+ if (lines[lines.length - 1] === '') {
88
+ lines.pop()
89
+ }
90
+ return lines.slice(Math.max(from, 1) - 1, Math.max(to, 0))
91
+ },
92
+ }
93
+ }
@@ -0,0 +1,69 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { type CommentsPayload, CommentsPayloadSchema } from '../contract/comments.js'
4
+ import { type DiscoveryCache, DiscoveryCacheSchema } from '../contract/discovery.js'
5
+ import { type Pr, PrSchema } from '../contract/review-artifact.js'
6
+ import { readJson, readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
7
+
8
+ /** PR-keyed files: the cached PR meta and the last comments payload. */
9
+ export interface PrStore {
10
+ prDir(number: number): string
11
+ readPr(number: number): Promise<Pr | null>
12
+ writePr(pr: Pr): Promise<void>
13
+ readComments(number: number): Promise<CommentsPayload | null>
14
+ writeComments(number: number, comments: CommentsPayload): Promise<void>
15
+ /** The last attachment scan, so an unchanged PR is not scanned again. */
16
+ readDiscovery(number: number): Promise<DiscoveryCache | null>
17
+ writeDiscovery(number: number, discovery: DiscoveryCache): Promise<void>
18
+ /** PR numbers with a cached pr.json, newest first by file mtime. */
19
+ listRecent(limit: number): Promise<Array<{ number: number; title: string; updatedAt: string }>>
20
+ }
21
+
22
+ export function createPrStore(repoRoot: string): PrStore {
23
+ const prDir = (number: number): string => {
24
+ if (!Number.isInteger(number) || number <= 0) {
25
+ throw new Error(`not a pull request number: ${number}`)
26
+ }
27
+ return path.join(repoRoot, 'prs', String(number))
28
+ }
29
+ return {
30
+ prDir,
31
+ readPr: number => readJson(path.join(prDir(number), 'pr.json'), PrSchema),
32
+ writePr: async pr => {
33
+ if (pr.number === null) {
34
+ throw new Error('cannot cache a pre-PR change set under prs/')
35
+ }
36
+ await writeJsonAtomic(path.join(prDir(pr.number), 'pr.json'), pr)
37
+ },
38
+ readComments: number => readJson(path.join(prDir(number), 'comments.json'), CommentsPayloadSchema),
39
+ writeComments: (number, comments) => writeJsonAtomic(path.join(prDir(number), 'comments.json'), comments),
40
+ readDiscovery: number =>
41
+ readJsonOrDefault(path.join(prDir(number), 'discovery.json'), DiscoveryCacheSchema, () => null),
42
+ writeDiscovery: (number, discovery) => writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
43
+ listRecent: async limit => {
44
+ let names: string[]
45
+ try {
46
+ names = await readdir(path.join(repoRoot, 'prs'))
47
+ } catch {
48
+ return []
49
+ }
50
+ const rows: Array<{ number: number; title: string; updatedAt: string; mtime: number }> = []
51
+ for (const name of names) {
52
+ const number = Number(name)
53
+ if (!Number.isInteger(number) || number <= 0) {
54
+ continue
55
+ }
56
+ const file = path.join(prDir(number), 'pr.json')
57
+ // A directory without pr.json, or one written by an older tool version, is skipped.
58
+ const pr = await readJsonOrDefault(file, PrSchema, () => null)
59
+ if (pr === null) {
60
+ continue
61
+ }
62
+ const s = await stat(file)
63
+ rows.push({ number, title: pr.title, updatedAt: s.mtime.toISOString(), mtime: s.mtimeMs })
64
+ }
65
+ rows.sort((a, b) => b.mtime - a.mtime)
66
+ return rows.slice(0, limit).map(({ number, title, updatedAt }) => ({ number, title, updatedAt }))
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,152 @@
1
+ import path from 'node:path'
2
+ import { isMap, parseDocument } from 'yaml'
3
+ import { DEFAULT_SETTINGS, type Settings, type SettingsInput, SettingsSchema } from '../contract/settings.js'
4
+ import { readText, writeTextAtomic } from './atomic-json.js'
5
+
6
+ export const SETTINGS_FILE = 'settings.yml'
7
+
8
+ /** Written once, the first time the file is needed, so the user can edit it by hand too. */
9
+ export const SETTINGS_TEMPLATE = `# Personal pr-review settings. Gitignored: this file is yours, not the project's.
10
+ # Project-wide settings (layers, caps, chat.enabled) live in pr-review.config.yml at the repo root.
11
+ version: 1
12
+
13
+ # The look of the page: terminal or github.
14
+ skin: terminal
15
+
16
+ # Light or dark: auto follows the operating system, light and dark pin one.
17
+ theme: auto
18
+
19
+ # Which agent answers in the AI Chat pane: claude or codex.
20
+ agent: claude
21
+
22
+ # Model id for that agent, or null for the agent's own default.
23
+ model: null
24
+
25
+ # How long one chat turn may take, in seconds.
26
+ chatTimeoutSec: 600
27
+
28
+ # Cap on agent turns per message, or null for the agent's own default.
29
+ maxTurns: null
30
+ `
31
+
32
+ export interface SettingsStore {
33
+ /** The file's path, shown in the settings dialog. */
34
+ file: string
35
+ /** The saved settings, or the defaults when the file is missing or unreadable. */
36
+ read(): Promise<Settings>
37
+ /**
38
+ * Writes the changed fields back. Comments and key order in the file survive, because the
39
+ * document is edited rather than re-serialized from a plain object.
40
+ */
41
+ write(input: SettingsInput): Promise<Settings>
42
+ /** Writes the commented template when the file does not exist yet. */
43
+ ensureFile(): Promise<boolean>
44
+ }
45
+
46
+ export function createSettingsStore(dataDir: string): SettingsStore {
47
+ const file = path.join(dataDir, SETTINGS_FILE)
48
+
49
+ const read = async (): Promise<Settings> => {
50
+ const text = await readText(file)
51
+ if (text === null) {
52
+ return DEFAULT_SETTINGS
53
+ }
54
+ return parseSettings(text)
55
+ }
56
+
57
+ /**
58
+ * Every write runs after the one before it, so two that arrive together both land and the
59
+ * first run's template never lands on top of a save.
60
+ */
61
+ let chain: Promise<unknown> = Promise.resolve()
62
+ const queued = <T>(task: () => Promise<T>): Promise<T> => {
63
+ const chained = chain.then(task, task)
64
+ chain = chained.catch(() => undefined)
65
+ return chained
66
+ }
67
+
68
+ const write = async (input: SettingsInput): Promise<Settings> => {
69
+ const current = (await readText(file)) ?? SETTINGS_TEMPLATE
70
+ const next = applySettings(current, input)
71
+ await writeTextAtomic(file, next.text)
72
+ return next.settings
73
+ }
74
+
75
+ const ensureFile = async (): Promise<boolean> => {
76
+ if ((await readText(file)) !== null) {
77
+ return false
78
+ }
79
+ await writeTextAtomic(file, SETTINGS_TEMPLATE)
80
+ return true
81
+ }
82
+
83
+ return {
84
+ file,
85
+ read,
86
+ ensureFile: () => queued(ensureFile),
87
+ write: input => queued(() => write(input)),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The settings a file holds. A file that is not YAML, or whose values do not fit, falls back to
93
+ * the defaults rather than failing the page: the dialog can then write a good file over it.
94
+ */
95
+ export function parseSettings(text: string): Settings {
96
+ let raw: unknown
97
+ try {
98
+ raw = parseDocument(text).toJS() as unknown
99
+ } catch {
100
+ return DEFAULT_SETTINGS
101
+ }
102
+ if (typeof raw !== 'object' || raw === null) {
103
+ return DEFAULT_SETTINGS
104
+ }
105
+ const merged = { ...DEFAULT_SETTINGS, ...raw, version: 1 }
106
+ const parsed = SettingsSchema.safeParse(merged)
107
+ return parsed.success ? parsed.data : DEFAULT_SETTINGS
108
+ }
109
+
110
+ /** The new file text and the settings it holds, given the old text and the changed fields. */
111
+ export function applySettings(text: string, input: SettingsInput): { text: string; settings: Settings } {
112
+ const wanted = { ...parseSettings(text), ...stripUndefined(input) }
113
+ const parsed = SettingsSchema.safeParse({ ...wanted, version: 1 })
114
+ const settings = parsed.success ? parsed.data : DEFAULT_SETTINGS
115
+ let doc = parseDocument(text)
116
+ // Only a mapping can hold these keys; a file that is a list or a scalar starts over from the
117
+ // template, which is also what a file that does not parse gets.
118
+ if (doc.errors.length > 0 || !isMap(doc.contents)) {
119
+ doc = parseDocument(SETTINGS_TEMPLATE)
120
+ }
121
+ doc.set('version', 1)
122
+ doc.set('skin', settings.skin)
123
+ doc.set('theme', settings.theme)
124
+ doc.set('agent', settings.agent)
125
+ doc.set('model', settings.model)
126
+ doc.set('chatTimeoutSec', settings.chatTimeoutSec)
127
+ doc.set('maxTurns', settings.maxTurns)
128
+ return { text: String(doc), settings }
129
+ }
130
+
131
+ function stripUndefined(input: SettingsInput): Partial<Settings> {
132
+ const out: Partial<Settings> = {}
133
+ if (input.skin !== undefined) {
134
+ out.skin = input.skin
135
+ }
136
+ if (input.theme !== undefined) {
137
+ out.theme = input.theme
138
+ }
139
+ if (input.agent !== undefined) {
140
+ out.agent = input.agent
141
+ }
142
+ if (input.model !== undefined) {
143
+ out.model = input.model === '' ? null : input.model
144
+ }
145
+ if (input.chatTimeoutSec !== undefined) {
146
+ out.chatTimeoutSec = input.chatTimeoutSec
147
+ }
148
+ if (input.maxTurns !== undefined) {
149
+ out.maxTurns = input.maxTurns
150
+ }
151
+ return out
152
+ }
@@ -0,0 +1,121 @@
1
+ import path from 'node:path'
2
+ import { emptyState, type PrState, PrStateSchema } from '../contract/state.js'
3
+ import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
4
+ import type { PrStore } from './pr-store.js'
5
+
6
+ /** What the canvas posted to GitHub, so the sign-off body can list it. */
7
+ export interface PostedEntry {
8
+ commentId: number
9
+ pointFingerprint?: string
10
+ }
11
+
12
+ /** Per-PR local state: reviewed cards, dismissed points, hidden threads, posted comments. */
13
+ export interface StateStore {
14
+ /** Defaults when the file is missing or does not match the schema (an older tool version wrote it). */
15
+ read(number: number): Promise<PrState>
16
+ /**
17
+ * Read, change, write. Calls for the same PR run one after another, so two requests that
18
+ * arrive together both land instead of one overwriting the other.
19
+ */
20
+ update(number: number, mutate: (state: PrState) => PrState): Promise<PrState>
21
+ /**
22
+ * Marks one layer or file. `headSha` is the commit the page was showing: when it differs from
23
+ * the one the marks describe, the old marks are dropped, because they were about other code.
24
+ */
25
+ setReviewed(number: number, id: string, reviewed: boolean, headSha?: string): Promise<PrState>
26
+ setDismissed(number: number, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
27
+ setThreadHidden(number: number, rootCommentId: number, hidden: boolean): Promise<PrState>
28
+ addPosted(number: number, entry: PostedEntry): Promise<PrState>
29
+ }
30
+
31
+ /** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
32
+ const REVIEWED_ID_RE = /^layer:[A-Za-z0-9_-]+(?:\/file:[A-Za-z0-9_]+)?$/
33
+
34
+ export function isReviewedId(id: string): boolean {
35
+ return REVIEWED_ID_RE.test(id)
36
+ }
37
+
38
+ /** `layer:<id>` for both a layer id and one of its file ids. */
39
+ function layerOf(id: string): string {
40
+ const at = id.indexOf('/file:')
41
+ return at === -1 ? id : id.slice(0, at)
42
+ }
43
+
44
+ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
45
+ const file = (number: number): string => path.join(prs.prDir(number), 'state.json')
46
+ const read = (number: number): Promise<PrState> =>
47
+ readJsonOrDefault(file(number), PrStateSchema, () => emptyState(now().toISOString()))
48
+
49
+ /** One chain per PR number; each update waits for the one before it. */
50
+ const chains = new Map<number, Promise<unknown>>()
51
+
52
+ const update = (number: number, mutate: (state: PrState) => PrState): Promise<PrState> => {
53
+ const run = async (): Promise<PrState> => {
54
+ const current = await read(number)
55
+ // Every write counts up, so the page can tell which of two answers was written later.
56
+ const next = { ...mutate(current), rev: (current.rev ?? 0) + 1, updatedAt: now().toISOString() }
57
+ await writeJsonAtomic(file(number), next)
58
+ return next
59
+ }
60
+ const chained = (chains.get(number) ?? Promise.resolve()).then(run, run)
61
+ // The chain only orders the calls, so a failed update never blocks the next one.
62
+ chains.set(
63
+ number,
64
+ chained.catch(() => undefined)
65
+ )
66
+ return chained
67
+ }
68
+
69
+ return {
70
+ read,
71
+ update,
72
+ setReviewed: (number, id, reviewed, headSha) =>
73
+ update(number, state => {
74
+ const sameHead =
75
+ headSha === undefined || state.reviewedHeadSha === undefined || state.reviewedHeadSha === headSha
76
+ const next = sameHead ? { ...state.reviewed } : {}
77
+ if (reviewed) {
78
+ next[id] = true
79
+ } else {
80
+ for (const key of Object.keys(next)) {
81
+ // Reopening a layer reopens its files, and reopening a file reopens its layer:
82
+ // both readings of "reviewed" have to agree.
83
+ if (key === id || key.startsWith(`${id}/`) || key === layerOf(id)) {
84
+ delete next[key]
85
+ }
86
+ }
87
+ }
88
+ return headSha === undefined
89
+ ? { ...state, reviewed: next }
90
+ : { ...state, reviewed: next, reviewedHeadSha: headSha }
91
+ }),
92
+ setDismissed: (number, fingerprint, dismissed, reason) =>
93
+ update(number, state => {
94
+ const next = { ...state.dismissed }
95
+ if (dismissed) {
96
+ next[fingerprint] = reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
97
+ } else {
98
+ delete next[fingerprint]
99
+ }
100
+ return { ...state, dismissed: next }
101
+ }),
102
+ setThreadHidden: (number, rootCommentId, hidden) =>
103
+ update(number, state => {
104
+ const next = { ...state.hiddenThreads }
105
+ if (hidden) {
106
+ next[String(rootCommentId)] = { at: now().toISOString() }
107
+ } else {
108
+ delete next[String(rootCommentId)]
109
+ }
110
+ return { ...state, hiddenThreads: next }
111
+ }),
112
+ addPosted: (number, entry) =>
113
+ update(number, state => {
114
+ if (state.posted.some(p => p.commentId === entry.commentId)) {
115
+ return state
116
+ }
117
+ const posted = { ...entry, at: now().toISOString() }
118
+ return { ...state, posted: [...state.posted, posted] }
119
+ }),
120
+ }
121
+ }