@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
@@ -110,9 +110,12 @@ export async function fetchComments(
110
110
  fetchAllPages(gh, `${base}/issues/${number}/comments`),
111
111
  ])
112
112
  const reviews = (await fetchAllPages(gh, `${base}/pulls/${number}/reviews`)).flatMap(raw => {
113
- const review = GhIssueCommentSchema.omit({ created_at: true }).extend({
114
- submitted_at: z.string().nullable().optional(), state: z.string(),
115
- }).parse(raw)
113
+ const review = GhIssueCommentSchema.omit({ created_at: true })
114
+ .extend({
115
+ submitted_at: z.string().nullable().optional(),
116
+ state: z.string(),
117
+ })
118
+ .parse(raw)
116
119
  if (!review.submitted_at || review.state === 'PENDING') return []
117
120
  return [{ ...mapIssueComment({ ...review, created_at: review.submitted_at }), state: review.state }]
118
121
  })
package/src/github/gh.ts CHANGED
@@ -140,14 +140,24 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
140
140
  }
141
141
  const r = await exec(args)
142
142
  if (r.code !== 0) {
143
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
143
+ throw new GitHubApiError(
144
+ path,
145
+ r.missingBinary ? 'gh: command not found' : r.stderr,
146
+ r.code,
147
+ r.missingBinary
148
+ )
144
149
  }
145
150
  return JSON.parse(r.stdout) as unknown
146
151
  },
147
152
  apiWithHeaders: async path => {
148
153
  const r = await exec(['api', '-i', '--method', 'GET', path])
149
154
  if (r.code !== 0) {
150
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
155
+ throw new GitHubApiError(
156
+ path,
157
+ r.missingBinary ? 'gh: command not found' : r.stderr,
158
+ r.code,
159
+ r.missingBinary
160
+ )
151
161
  }
152
162
  return parseIncludedResponse(r.stdout)
153
163
  },
@@ -155,7 +165,12 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
155
165
  // The payload goes over stdin, so no comment text ever appears in an argument list.
156
166
  const r = await exec(['api', '--method', 'POST', path, '--input', '-'], { input: JSON.stringify(body) })
157
167
  if (r.code !== 0) {
158
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
168
+ throw new GitHubApiError(
169
+ path,
170
+ r.missingBinary ? 'gh: command not found' : r.stderr,
171
+ r.code,
172
+ r.missingBinary
173
+ )
159
174
  }
160
175
  return JSON.parse(r.stdout) as unknown
161
176
  },
@@ -81,7 +81,10 @@ export function commentRequest(
81
81
  return { path: `${base}/pulls/${number}/comments`, body }
82
82
  }
83
83
  case 'reply':
84
- return { path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`, body: { body: input.body } }
84
+ return {
85
+ path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`,
86
+ body: { body: input.body },
87
+ }
85
88
  case 'issue':
86
89
  return { path: `${base}/issues/${number}/comments`, body: { body: input.body } }
87
90
  }
package/src/github/pr.ts CHANGED
@@ -100,7 +100,10 @@ export function prBaseRef(number: number): string {
100
100
  * it is local once the base was fetched). Today's base tip would contain the PR and give an
101
101
  * empty diff.
102
102
  */
103
- export async function fetchPrRefs(git: Git, meta: PrMeta): Promise<{ headSha: string; mergeBaseSha: string }> {
103
+ export async function fetchPrRefs(
104
+ git: Git,
105
+ meta: PrMeta
106
+ ): Promise<{ headSha: string; mergeBaseSha: string }> {
104
107
  await git.fetch('origin', [
105
108
  `+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
106
109
  `+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
@@ -33,7 +33,11 @@ export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: In
33
33
  * The ids of every review comment that sits in a resolved thread. REST has no resolved flag,
34
34
  * so this one GraphQL query is joined to the REST comments by id.
35
35
  */
36
- export async function fetchResolvedCommentIds(gh: GitHubClient, repo: Repo, number: number): Promise<Set<number>> {
36
+ export async function fetchResolvedCommentIds(
37
+ gh: GitHubClient,
38
+ repo: Repo,
39
+ number: number
40
+ ): Promise<Set<number>> {
37
41
  const resolved = new Set<number>()
38
42
  let after: string | null = null
39
43
  for (;;) {
@@ -36,14 +36,16 @@ const capsShape = {
36
36
  diagram: cap(),
37
37
  } satisfies Record<keyof TextCaps, z.ZodOptional<z.ZodNumber>>
38
38
 
39
- export const PromptOverridesSchema = z.object({
40
- 'generation-format.md': z.string().min(1).optional(),
41
- 'generation-strict.md': z.string().min(1).optional(),
42
- 'generation-surfacing.md': z.string().min(1).optional(),
43
- 'quality-standards.md': z.string().min(1).optional(),
44
- 'layers-default.md': z.string().min(1).optional(),
45
- 'chat-seed.md': z.string().min(1).optional(),
46
- }).strict()
39
+ export const PromptOverridesSchema = z
40
+ .object({
41
+ 'generation-format.md': z.string().min(1).optional(),
42
+ 'generation-strict.md': z.string().min(1).optional(),
43
+ 'generation-surfacing.md': z.string().min(1).optional(),
44
+ 'quality-standards.md': z.string().min(1).optional(),
45
+ 'layering-guidance.md': z.string().min(1).optional(),
46
+ 'chat-seed.md': z.string().min(1).optional(),
47
+ })
48
+ .strict()
47
49
  export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
48
50
 
49
51
  export const ProjectConfigSchema = z.object({
@@ -86,49 +88,9 @@ const PartialProjectConfigSchema = z.object({
86
88
  chat: z.object({ enabled: z.boolean().optional() }).optional(),
87
89
  })
88
90
 
89
- /** The 8 architecture groups, in review order. A project file replaces the list. */
90
- export const DEFAULT_LAYERS: DefaultLayer[] = [
91
- {
92
- id: 'contracts',
93
- title: 'Contracts and schemas',
94
- description: 'Types, Zod schemas, FHIR profiles, and the shapes two packages must agree on.',
95
- },
96
- {
97
- id: 'data-access',
98
- title: 'Data access',
99
- description: 'Server functions, FHIR client calls, repositories, migrations, and storage.',
100
- },
101
- {
102
- id: 'mappers',
103
- title: 'Mappers and DTOs',
104
- description: 'Code that turns resources or rows into view models and back.',
105
- },
106
- {
107
- id: 'hooks-state',
108
- title: 'Hooks and state',
109
- description: 'React hooks, query definitions, stores, and client-side state machines.',
110
- },
111
- { id: 'views', title: 'Views', description: 'Components, screens, styles, and copy.' },
112
- {
113
- id: 'routes-wiring',
114
- title: 'Routes and wiring',
115
- description: 'Route files, providers, app shells, dependency wiring, and entry points.',
116
- },
117
- {
118
- id: 'policy-config',
119
- title: 'Policy and config',
120
- description: 'Access policies, environment variables, feature flags, CI, and deployment config.',
121
- },
122
- {
123
- id: 'mechanical',
124
- title: 'Mechanical changes',
125
- description: 'Renames, lockfiles, generated files, formatting, and moved code.',
126
- },
127
- ]
128
-
129
91
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
130
92
  version: 1,
131
- layers: DEFAULT_LAYERS,
93
+ layers: [],
132
94
  highRisk: [],
133
95
  generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
134
96
  tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
@@ -159,7 +121,8 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
159
121
  const generation: ProjectConfig['generation'] = {
160
122
  mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
161
123
  maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
162
- inlineDiffMaxLines: user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
124
+ inlineDiffMaxLines:
125
+ user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
163
126
  smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
164
127
  }
165
128
  if (user.generation?.caps !== undefined) {
@@ -167,7 +130,7 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
167
130
  }
168
131
  const config: ProjectConfig = {
169
132
  version: 1,
170
- layers: user.layers ?? DEFAULT_LAYERS,
133
+ layers: user.layers ?? [],
171
134
  highRisk: user.highRisk ?? [],
172
135
  generation,
173
136
  tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
@@ -180,9 +143,6 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
180
143
  config.prompts = user.prompts
181
144
  }
182
145
  const warnings: string[] = []
183
- if (config.layers.length === 0) {
184
- warnings.push(`${PROJECT_CONFIG_FILE}: "layers" is empty, the model gets no default taxonomy`)
185
- }
186
146
  if (config.tests.patterns.length === 0) {
187
147
  warnings.push(`${PROJECT_CONFIG_FILE}: "tests.patterns" is empty, so no file counts as a test`)
188
148
  }
@@ -15,9 +15,10 @@ export async function loadPromptFile(
15
15
  project?: ProjectPrompts
16
16
  ): Promise<string> {
17
17
  const override = project?.overrides?.[name]
18
- const file = project !== undefined && override !== undefined
19
- ? path.resolve(project.repoRoot, override)
20
- : path.join(dir, name)
18
+ const file =
19
+ project !== undefined && override !== undefined
20
+ ? path.resolve(project.repoRoot, override)
21
+ : path.join(dir, name)
21
22
  try {
22
23
  return await readFile(file, 'utf8')
23
24
  } catch (cause) {
@@ -1,13 +1,14 @@
1
1
  // `pr-review doctor`: one pass over everything the tool needs before it can serve a review, as
2
2
  // one JSON line. It reports instead of throwing, so a broken setup still answers.
3
3
  import { randomBytes } from 'node:crypto'
4
- import { rm, writeFile } from 'node:fs/promises'
4
+ import { readFile, rm, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { parseGithubRemote } from '../config.js'
7
7
  import type { Git } from '../git/git.js'
8
8
  import type { GitHubClient } from '../github/gh.js'
9
9
  import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
10
- import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME } from './install-skill.js'
10
+ import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME, SKILL_SOURCE_DIR } from './install-skill.js'
11
+ import { skillContent } from './skill-content.js'
11
12
 
12
13
  export const DOCTOR_CHECKS = ['git', 'origin', 'gh', 'ghAuth', 'dataDir', 'skill'] as const
13
14
  export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number]
@@ -31,8 +32,7 @@ export interface DoctorDeps {
31
32
  acpxVersion: () => Promise<string | null>
32
33
  /** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
33
34
  dataDirOverride?: string | undefined
34
- /** Answers whether a file can be read; the skill check asks for the SKILL.md inside. */
35
- exists: (file: string) => Promise<boolean>
35
+ readSkill?: (file: string) => Promise<string | null>
36
36
  }
37
37
 
38
38
  function message(err: unknown): string {
@@ -56,17 +56,42 @@ async function checkDataDir(dir: string): Promise<DoctorCheck> {
56
56
  }
57
57
 
58
58
  /** The skill the generation flow needs, in either harness's directory. */
59
- async function checkSkill(repoRoot: string | null, exists: DoctorDeps['exists']): Promise<DoctorCheck> {
59
+ export async function checkSkill(
60
+ repoRoot: string | null,
61
+ readSkill: NonNullable<DoctorDeps['readSkill']> = file => readFile(file, 'utf8')
62
+ ): Promise<DoctorCheck> {
60
63
  if (repoRoot === null) {
61
64
  return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
62
65
  }
63
66
  const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
64
67
  const found: string[] = []
68
+ const stale: string[] = []
69
+ let expected: string
70
+ try {
71
+ expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
72
+ } catch (err) {
73
+ return { ok: false, detail: message(err), hint: 'reinstall the pr-review package' }
74
+ }
65
75
  for (const target of targets) {
66
- // The file the harness reads, not the directory: an empty directory, a dangling link, and
67
- // an unreadable file all install nothing.
68
- if (await exists(path.join(target, 'SKILL.md'))) {
76
+ try {
77
+ const text = await readSkill(path.join(target, 'SKILL.md'))
78
+ if (text === null) continue
69
79
  found.push(path.relative(repoRoot, target))
80
+ const { hash, frontmatter } = skillContent(text)
81
+ if (hash !== expected || frontmatter.getIn(['metadata', 'body-sha256']) !== expected) {
82
+ stale.push(path.relative(repoRoot, target))
83
+ }
84
+ } catch (err) {
85
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
86
+ stale.push(`${path.relative(repoRoot, target)}: ${message(err)}`)
87
+ }
88
+ }
89
+ }
90
+ if (stale.length > 0) {
91
+ return {
92
+ ok: false,
93
+ detail: `outdated or modified skill: ${stale.join(', ')}`,
94
+ hint: 'run `pr-review install-skill`',
70
95
  }
71
96
  }
72
97
  if (found.length === 0) {
@@ -92,7 +117,10 @@ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
92
117
  }
93
118
 
94
119
  /** Runs core checks and, with allChecks, checks acpx for AI Chat. */
95
- export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: boolean } = {}): Promise<DoctorReport> {
120
+ export async function runDoctorChecks(
121
+ deps: DoctorDeps,
122
+ options: { allChecks?: boolean } = {}
123
+ ): Promise<DoctorReport> {
96
124
  let repoRoot: string | null = null
97
125
  let git: DoctorCheck
98
126
  try {
@@ -130,7 +158,7 @@ export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: b
130
158
  dataDir = { ok: false, detail: message(err), hint: 'pass --data-dir <dir>' }
131
159
  }
132
160
 
133
- const skill = await checkSkill(repoRoot, deps.exists)
161
+ const skill = await checkSkill(repoRoot, deps.readSkill)
134
162
  const checks: DoctorReport['checks'] = { git, origin, gh, ghAuth, dataDir, skill }
135
163
  if (options.allChecks) {
136
164
  checks.acpx = await checkAcpx(deps)
@@ -1,9 +1,10 @@
1
- // `pr-review install-skill`: link the bundled skill into the host repo's skill directories, so
1
+ // `pr-review install-skill`: copy the bundled skill into the host repo's skill directories, so
2
2
  // Claude Code (`.claude/skills`) and Codex (`.agents/skills`) both see `/pr-review-canvas`.
3
- import { appendFile, cp, lstat, mkdir, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { appendFile, cp, lstat, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { PACKAGE_ROOT } from '../server/context.js'
6
6
  import { readText } from '../store/atomic-json.js'
7
+ import { stampSkill } from './skill-content.js'
7
8
 
8
9
  export const SKILL_NAME = 'pr-review-canvas'
9
10
  export const SKILL_SOURCE_DIR = path.join(PACKAGE_ROOT, 'skills', SKILL_NAME)
@@ -27,39 +28,38 @@ export interface InstallSkillOptions {
27
28
  /** Absolute skills directories to install into; each gets `<dir>/pr-review-canvas`. */
28
29
  targets: Array<{ kind: 'claude' | 'codex'; dir: string }>
29
30
  source?: string
30
- /** Windows copies; everything else links. */
31
- platform?: NodeJS.Platform
32
31
  /** Replace a real directory that already sits at the target. */
33
32
  force?: boolean
34
33
  }
35
34
 
36
- /** A real directory (a customized copy of the skill) sits where the link would go. */
35
+ /** Preserve directories that were not created by the installer unless forced. */
37
36
  export class SkillDirExistsError extends Error {
38
37
  readonly path: string
39
38
 
40
39
  constructor(target: string) {
41
- super(`${target} is a directory, not a link to the bundled skill`)
40
+ super(`${target} is a directory, not a managed copy of the bundled skill`)
42
41
  this.name = 'SkillDirExistsError'
43
42
  this.path = target
44
43
  }
45
44
  }
46
45
 
47
- export type InstallStatus = 'linked' | 'copied' | 'exists' | 'replaced'
46
+ export type InstallStatus = 'copied'
48
47
 
49
48
  export interface InstallSkillResult {
50
49
  skill: string
51
50
  targets: Array<{ kind: 'claude' | 'codex'; path: string; status: InstallStatus }>
52
51
  }
53
52
 
54
- /** What sits at the target now: nothing, a symlink (with its target), or something else. */
55
- async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link'; to: string } | { kind: 'other' }> {
53
+ /** Inspect the entry without following a possibly dangling symlink. */
54
+ async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link' } | { kind: 'other' }> {
56
55
  let stats: Awaited<ReturnType<typeof lstat>>
57
56
  try {
58
57
  stats = await lstat(target)
59
- } catch {
58
+ } catch (err) {
59
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
60
60
  return { kind: 'none' }
61
61
  }
62
- return stats.isSymbolicLink() ? { kind: 'link', to: await readlink(target) } : { kind: 'other' }
62
+ return stats.isSymbolicLink() ? { kind: 'link' } : { kind: 'other' }
63
63
  }
64
64
 
65
65
  /** A copy carries a marker file, so a later run can tell it from a hand-made directory. */
@@ -68,7 +68,7 @@ export const COPY_MARKER = '.pr-review-install'
68
68
  async function installOne(
69
69
  source: string,
70
70
  dir: string,
71
- copy: boolean,
71
+ content: string,
72
72
  force: boolean
73
73
  ): Promise<{ path: string; status: InstallStatus }> {
74
74
  await mkdir(dir, { recursive: true })
@@ -78,29 +78,25 @@ async function installOne(
78
78
  if (current.kind === 'other' && !force && (await inspect(path.join(target, COPY_MARKER))).kind === 'none') {
79
79
  throw new SkillDirExistsError(target)
80
80
  }
81
- if (copy) {
82
- await rm(target, { recursive: true, force: true })
83
- await cp(source, target, { recursive: true })
84
- await writeFile(path.join(target, COPY_MARKER), `copied from ${source}\n`, 'utf8')
85
- return { path: target, status: 'copied' }
81
+ if (current.kind === 'other') {
82
+ const realTarget = await realpath(target)
83
+ if (source === realTarget || source.startsWith(`${realTarget}${path.sep}`)) {
84
+ throw new Error('Cannot install a skill over its source directory')
85
+ }
86
86
  }
87
- const relative = path.relative(realDir, source)
88
- if (current.kind === 'link' && current.to === relative) {
89
- return { path: target, status: 'exists' }
90
- }
91
- if (current.kind !== 'none') {
92
- await rm(target, { recursive: true, force: true })
93
- }
94
- await symlink(relative, target, 'dir')
95
- return { path: target, status: current.kind === 'none' ? 'linked' : 'replaced' }
87
+ await rm(target, { recursive: true, force: true })
88
+ await cp(source, target, { recursive: true, dereference: true })
89
+ await writeFile(path.join(target, 'SKILL.md'), content, 'utf8')
90
+ await writeFile(path.join(target, COPY_MARKER), 'pr-review managed skill copy\n', 'utf8')
91
+ return { path: target, status: 'copied' }
96
92
  }
97
93
 
98
94
  export async function installSkill(opts: InstallSkillOptions): Promise<InstallSkillResult> {
99
95
  const source = await realpath(opts.source ?? SKILL_SOURCE_DIR)
100
- const copy = (opts.platform ?? process.platform) === 'win32'
96
+ const content = stampSkill(await readFile(path.join(source, 'SKILL.md'), 'utf8'))
101
97
  const targets: InstallSkillResult['targets'] = []
102
98
  for (const t of opts.targets) {
103
- const done = await installOne(source, t.dir, copy, opts.force === true)
99
+ const done = await installOne(source, t.dir, content, opts.force === true)
104
100
  targets.push({ kind: t.kind, ...done })
105
101
  }
106
102
  return { skill: SKILL_NAME, targets }
@@ -195,7 +195,9 @@ export function artifactToModelOutput(artifact: ReviewArtifact): ModelOutput {
195
195
  summary: artifact.summary,
196
196
  layers: artifact.layers.map(layer => {
197
197
  const { id: _id, risk, files, ...rest } = layer
198
- const modelRisk = risk.filter(r => r.source === 'model').map(r => ({ label: r.label, reason: r.reason ?? '' }))
198
+ const modelRisk = risk
199
+ .filter(r => r.source === 'model')
200
+ .map(r => ({ label: r.label, reason: r.reason ?? '' }))
199
201
  const out: ModelLayer = { ...rest, files: files.map(({ isTest: _isTest, ...f }) => f) }
200
202
  if (modelRisk.length > 0) {
201
203
  out.risk = modelRisk
@@ -37,7 +37,12 @@ async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['l
37
37
  }
38
38
 
39
39
  /** A change set before a PR exists: the refs must already be in the clone. */
40
- async function resolveRefs(ctx: AppContext, base: string, head: string, log: PrepareOptions['log']): Promise<Pr> {
40
+ async function resolveRefs(
41
+ ctx: AppContext,
42
+ base: string,
43
+ head: string,
44
+ log: PrepareOptions['log']
45
+ ): Promise<Pr> {
41
46
  log('fetch-refs')
42
47
  const headSha = await ctx.git.revParse(head)
43
48
  const mergeBaseSha = await ctx.git.mergeBase(base, headSha)
@@ -93,7 +98,11 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
93
98
  }
94
99
  }
95
100
 
96
- export async function prepare(ctx: AppContext, target: PrepareTarget, opts: PrepareOptions): Promise<PrepareResult> {
101
+ export async function prepare(
102
+ ctx: AppContext,
103
+ target: PrepareTarget,
104
+ opts: PrepareOptions
105
+ ): Promise<PrepareResult> {
97
106
  const pr =
98
107
  target.kind === 'pr'
99
108
  ? await resolvePr(ctx, target.number, opts.log)
@@ -116,7 +125,8 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
116
125
  const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
117
126
  const additions = derived.files.reduce((n, f) => n + f.additions, 0)
118
127
  const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
119
- const fullPr: Pr = target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
128
+ const fullPr: Pr =
129
+ target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
120
130
  const derivedDir = ctx.derived.derivedDir(pr.headSha)
121
131
  const config = ctx.projectConfig.config
122
132
 
@@ -152,14 +162,20 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
152
162
  largePr: isLargePr({ files: derived.files.length, additions, deletions }),
153
163
  preparedAt: ctx.now().toISOString(),
154
164
  }
155
- const sources = opts.promptSources ?? (await loadPromptSources(undefined, {
156
- repoRoot: ctx.config.repoRoot,
157
- overrides: ctx.projectConfig.config.prompts,
158
- }))
165
+ const sources =
166
+ opts.promptSources ??
167
+ (await loadPromptSources(undefined, {
168
+ repoRoot: ctx.config.repoRoot,
169
+ overrides: ctx.projectConfig.config.prompts,
170
+ }))
159
171
  await clearCanvasDir(canvasDir)
160
172
  await writeTextAtomic(promptPath, renderPrompt(context, derived.patches, sources))
161
173
  await writeJsonAtomic(contextPath, context)
162
174
  // The log keeps the whole history; publish counts attempts from this line on.
163
- await appendFile(path.join(canvasDir, 'publish.log'), `${context.preparedAt} prepared ${pr.headSha}\n`, 'utf8')
175
+ await appendFile(
176
+ path.join(canvasDir, 'publish.log'),
177
+ `${context.preparedAt} prepared ${pr.headSha}\n`,
178
+ 'utf8'
179
+ )
164
180
  return { ...result, status: 'prepared' }
165
181
  }
@@ -13,19 +13,19 @@ export { PROMPTS_DIR }
13
13
  export interface PromptSources {
14
14
  generation: Record<GenerationMode, string>
15
15
  format: string
16
- layersDefault: string
16
+ layeringGuidance: string
17
17
  qualityStandards: string
18
18
  }
19
19
 
20
20
  export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
21
- const [format, layersDefault, qualityStandards, strict, surfacing] = await Promise.all([
21
+ const [format, layeringGuidance, qualityStandards, strict, surfacing] = await Promise.all([
22
22
  loadPromptFile('generation-format.md', dir, project),
23
- loadPromptFile('layers-default.md', dir, project),
23
+ loadPromptFile('layering-guidance.md', dir, project),
24
24
  loadPromptFile('quality-standards.md', dir, project),
25
25
  loadPromptFile('generation-strict.md', dir, project),
26
26
  loadPromptFile('generation-surfacing.md', dir, project),
27
27
  ])
28
- return { format, layersDefault, qualityStandards, generation: { strict, surfacing } }
28
+ return { format, layeringGuidance, qualityStandards, generation: { strict, surfacing } }
29
29
  }
30
30
 
31
31
  /** The line ranges of a hunk header; the trailing function context can hold backticks. */
@@ -64,14 +64,16 @@ function inlineDiffs(files: readonly FileEntry[], patches: Record<string, string
64
64
  .join('\n\n')
65
65
  }
66
66
 
67
- function defaultLayersMarkdown(ctx: GenerationContext): string {
67
+ function configuredLayersMarkdown(ctx: GenerationContext): string {
68
68
  if (ctx.defaultLayers.length === 0) {
69
- return '_The project config lists no default layers; choose the layers yourself._'
69
+ return '_No layers are configured; divide the change into semantic sections based on its behavior and concerns._'
70
70
  }
71
71
  return ctx.defaultLayers
72
72
  .map((l, i) => {
73
73
  const paths =
74
- l.paths === undefined || l.paths.length === 0 ? '' : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
74
+ l.paths === undefined || l.paths.length === 0
75
+ ? ''
76
+ : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
75
77
  return `${i + 1}. \`${l.id}\` **${l.title}** — ${l.description}${paths}`
76
78
  })
77
79
  .join('\n')
@@ -164,7 +166,7 @@ function smallPrMarkdown(ctx: GenerationContext): string {
164
166
  }
165
167
  return (
166
168
  `**Small change set.** This ${ctx.target.kind === 'pr' ? 'pull request' : 'change set'} has ${hunks} hunks, at most ${limit}, so:\n\n` +
167
- '- Use one layer unless the concerns truly differ; do not split for the sake of the taxonomy.\n' +
169
+ '- Use one layer unless the concerns truly differ; do not split merely to fill suggested groups.\n' +
168
170
  '- Annotate only where the diff does not speak for itself; zero annotations is a fine answer.\n' +
169
171
  '- Keep the summary self-contained: state the behavior change and the one relationship or decision worth understanding.'
170
172
  )
@@ -196,7 +198,11 @@ export function schemaMarkdown(ctx: GenerationContext): string {
196
198
  }
197
199
 
198
200
  /** Selects one task and fills its data and format placeholders before the generator sees it. */
199
- export function renderPrompt(ctx: GenerationContext, patches: Record<string, string>, sources: PromptSources): string {
201
+ export function renderPrompt(
202
+ ctx: GenerationContext,
203
+ patches: Record<string, string>,
204
+ sources: PromptSources
205
+ ): string {
200
206
  const tokens: Record<string, string> = {
201
207
  TARGET_WORD: ctx.target.kind === 'pr' ? 'pull request' : 'change set',
202
208
  META: metaMarkdown(ctx),
@@ -207,8 +213,8 @@ export function renderPrompt(ctx: GenerationContext, patches: Record<string, str
207
213
  MODEL_PATH: ctx.paths.model,
208
214
  MANIFEST: manifestMarkdown(ctx.files),
209
215
  DIFFS: diffsMarkdown(ctx, patches),
210
- DEFAULT_LAYERS: defaultLayersMarkdown(ctx),
211
- LAYERS_DEFAULT: sources.layersDefault.trim(),
216
+ CONFIGURED_LAYERS: configuredLayersMarkdown(ctx),
217
+ LAYERING_GUIDANCE: sources.layeringGuidance.trim(),
212
218
  CAPS: capsMarkdown(ctx),
213
219
  MAX_POINTS: String(ctx.limits.maxPoints),
214
220
  MAX_DIAGRAMS: String(ctx.limits.maxDiagramsPerLayer),
@@ -75,7 +75,11 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
75
75
  const file = path.join(canvasDir, 'model.json')
76
76
  const text = await readText(file)
77
77
  if (text === null) {
78
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'write the model output there and publish again')
78
+ throw new PublishError(
79
+ 'NOT_FOUND',
80
+ `${file} does not exist`,
81
+ 'write the model output there and publish again'
82
+ )
79
83
  }
80
84
  return parseModelText(text, 'model.json')
81
85
  }
@@ -141,7 +145,11 @@ export async function validationInput(
141
145
  }
142
146
  }
143
147
 
144
- export function buildManifest(context: GenerationContext, artifact: ReviewArtifact, version: string): CanvasManifest {
148
+ export function buildManifest(
149
+ context: GenerationContext,
150
+ artifact: ReviewArtifact,
151
+ version: string
152
+ ): CanvasManifest {
145
153
  const manifest: CanvasManifest = {
146
154
  formatVersion: 1,
147
155
  tool: { name: 'pr-review', version },
@@ -159,7 +167,11 @@ export function buildManifest(context: GenerationContext, artifact: ReviewArtifa
159
167
  return manifest
160
168
  }
161
169
 
162
- export async function publish(ctx: AppContext, canvasDir: string, opts: PublishOptions): Promise<PublishResult> {
170
+ export async function publish(
171
+ ctx: AppContext,
172
+ canvasDir: string,
173
+ opts: PublishOptions
174
+ ): Promise<PublishResult> {
163
175
  const context = await readContext(canvasDir)
164
176
  if (!opts.allowStale) {
165
177
  const head = await currentHead(ctx, context)
@@ -0,0 +1,20 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { parseDocument } from 'yaml'
3
+
4
+ /** Normalize checkout line endings so Git's CRLF conversion does not stale a copy. */
5
+ export function skillContent(text: string) {
6
+ const normalized = text.replace(/\r\n/g, '\n')
7
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized)
8
+ if (!match) throw new Error('SKILL.md is missing YAML frontmatter')
9
+ const frontmatter = parseDocument(match[1]!)
10
+ if (frontmatter.errors.length) throw new Error('SKILL.md has invalid YAML frontmatter')
11
+ const body = normalized.slice(match[0].length)
12
+ const hash = createHash('sha256').update(body).digest('hex')
13
+ return { frontmatter, body, hash }
14
+ }
15
+
16
+ export function stampSkill(text: string): string {
17
+ const { frontmatter, body, hash } = skillContent(text)
18
+ frontmatter.setIn(['metadata', 'body-sha256'], hash)
19
+ return `---\n${frontmatter.toString()}---\n${body}`
20
+ }