@vintasoftware/pr-review-canvas 0.1.0 → 0.2.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.
- package/README.md +21 -25
- package/docs/reference.md +55 -11
- package/package.json +1 -1
- package/pr-review.config.example.yml +8 -27
- package/prompts/generation-format.md +4 -4
- package/prompts/layering-guidance.md +18 -0
- package/src/canvas/export.ts +1 -1
- package/src/canvas/name.ts +13 -13
- package/src/cli.ts +4 -11
- package/src/commands.ts +0 -2
- package/src/github/attachments.ts +3 -3
- package/src/project-config.ts +3 -46
- package/src/review/doctor.ts +30 -9
- package/src/review/install-skill.ts +24 -28
- package/src/review/prompt.ts +9 -9
- package/src/review/skill-content.ts +20 -0
- package/static/js/empty-state.js +1 -1
- package/static/js/settings.js +1 -1
- package/prompts/layers-default.md +0 -13
package/README.md
CHANGED
|
@@ -43,8 +43,6 @@ Install the command globally once, for use in any project:
|
|
|
43
43
|
npm install -g @vintasoftware/pr-review-canvas
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
The npm installation command will be available after the first release.
|
|
47
|
-
|
|
48
46
|
## Set up a project
|
|
49
47
|
|
|
50
48
|
```bash
|
|
@@ -54,15 +52,19 @@ pr-review doctor --all-checks
|
|
|
54
52
|
```
|
|
55
53
|
|
|
56
54
|
`install-skill` sets up **both Claude Code and Codex** in one command: `.claude/skills/pr-review-canvas`
|
|
57
|
-
and `.agents/skills/pr-review-canvas`, respectively.
|
|
55
|
+
and `.agents/skills/pr-review-canvas`, respectively. These are portable copies you can commit to Git.
|
|
56
|
+
Re-run `pr-review install-skill` after upgrading the CLI to refresh them. It also adds `.pr-review/settings.yml` to the
|
|
58
57
|
project's `.gitignore`. Restart your coding agent if the skill
|
|
59
58
|
does not appear. Repeat this setup for each project you want to review.
|
|
60
59
|
|
|
61
60
|
`doctor` checks Git, your GitHub remote, the GitHub CLI and its login, write access to the local
|
|
62
|
-
canvas directory, and whether the
|
|
61
|
+
canvas directory, and whether installed skills match the current package. It prints a JSON report with a result for
|
|
63
62
|
each check and suggested fixes for failures. `doctor --all-checks` also checks that `acpx` runs and
|
|
64
63
|
reports its version. Exit code `0` means all checks passed.
|
|
65
64
|
|
|
65
|
+
`serve` automatically runs the skill check and warns on stderr if a skill is missing, outdated,
|
|
66
|
+
or modified. The warning includes the reinstall command and does not block startup.
|
|
67
|
+
|
|
66
68
|
### Optional: AI Chat install
|
|
67
69
|
|
|
68
70
|
To ask questions about a PR inside the canvas, install `acpx` globally:
|
|
@@ -131,8 +133,8 @@ Canvas generation follows the [skill's model rules](skills/pr-review-canvas/SKIL
|
|
|
131
133
|
|
|
132
134
|
### Project prompt templates
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
Customize generation and AI Chat prompts with the `prompts` map in your project's
|
|
137
|
+
`pr-review.config.yml`:
|
|
136
138
|
|
|
137
139
|
```yaml
|
|
138
140
|
prompts:
|
|
@@ -141,35 +143,24 @@ prompts:
|
|
|
141
143
|
chat-seed.md: review-prompts/chat-seed.md
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
Copy the installed templates to start editing (for an npm global install):
|
|
145
147
|
|
|
146
148
|
```bash
|
|
147
149
|
mkdir -p review-prompts
|
|
148
150
|
cp "$(npm root -g)/@vintasoftware/pr-review-canvas/prompts/"*.md review-prompts/
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
Edit the copies and
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
package's defaults; a configured file that cannot be read causes an error.
|
|
156
|
-
|
|
157
|
-
The six supported keys are `generation-format.md` (schema and output rules),
|
|
158
|
-
`generation-strict.md` and `generation-surfacing.md` (mode wrappers),
|
|
159
|
-
`quality-standards.md` (bundled code standards), `layers-default.md` (taxonomy prose),
|
|
160
|
-
and `chat-seed.md` (the opening AI Chat instructions). `generation.mode` still selects
|
|
161
|
-
the wrapper. The rulebook still takes precedence over code standards, and configured
|
|
162
|
-
layers and caps still supply the template data.
|
|
163
|
-
|
|
164
|
-
Each file replaces a whole template. Preserve its `{{TOKENS}}`, including `{{FORMAT}}`
|
|
165
|
-
in generation wrappers, to keep the generated context and output contract. Unknown
|
|
166
|
-
generation tokens fail rendering. Chat leaves unknown tokens as written. Prompt edits
|
|
167
|
-
do not change the output schema or validation rules enforced by the tool. Overrides
|
|
168
|
-
remain yours across tool upgrades; compare them with new bundled templates when upgrading.
|
|
153
|
+
Edit the copies and configure only the templates you want to replace. Paths are relative
|
|
154
|
+
to the project root. Omitted entries use the bundled defaults. Keep each template's
|
|
155
|
+
`{{TOKENS}}`, including `{{FORMAT}}` in generation wrappers. Commit the config and
|
|
156
|
+
referenced files together.
|
|
169
157
|
|
|
170
158
|
Run `prepare` again to apply generation edits (use `--force` for an existing canvas).
|
|
171
159
|
Restart the server after changing the config; chat template edits apply to new threads.
|
|
172
160
|
|
|
161
|
+
See the [prompt template reference](docs/reference.md#prompt-templates) for supported keys,
|
|
162
|
+
path rules, validation, and upgrades.
|
|
163
|
+
|
|
173
164
|
## Contributing
|
|
174
165
|
|
|
175
166
|
In a clone of this tool, use pnpm for the shared lockfile and development checks:
|
|
@@ -182,6 +173,11 @@ corepack pnpm verify
|
|
|
182
173
|
corepack pnpm start --repo /path/to/your-project
|
|
183
174
|
```
|
|
184
175
|
|
|
176
|
+
Run the full `pnpm verify` before pushing. Keep branch coverage at least 96% when adding or
|
|
177
|
+
changing behavior, leaving a margin above CI's 95% minimum. Cover meaningful failure and boundary
|
|
178
|
+
cases rather than lowering thresholds. Each CI job uploads `coverage-node-<version>` with branch
|
|
179
|
+
locations and a summary; locally, these reports are in `coverage/` after `pnpm coverage`.
|
|
180
|
+
|
|
185
181
|
Run `pr-review --help` for CLI commands. Local data goes in the project's `.pr-review/`
|
|
186
182
|
directory; keep it out of Git.
|
|
187
183
|
|
package/docs/reference.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# CLI and configuration reference
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[README](../README.md).
|
|
5
|
-
that matters when customizing or troubleshooting a review.
|
|
3
|
+
This reference covers command options, configuration, and troubleshooting for local PR reviews.
|
|
4
|
+
For setup and the basic review workflow, see the [README](../README.md).
|
|
6
5
|
|
|
7
6
|
- [CLI options](#cli-options)
|
|
8
7
|
- [Project config](#project-config)
|
|
@@ -75,6 +74,10 @@ pr-review import <zip> [--pr <n>] [--force]
|
|
|
75
74
|
the archive; it does not select the commit.
|
|
76
75
|
- `--out` defaults to the data directory's `exports/` folder. Supply an existing directory to
|
|
77
76
|
keep the generated filename, or a full `.zip` file path to choose a name.
|
|
77
|
+
- Generated names follow `pr-<number>-<YYYYMMDDTHHmmssZ>-<sha8>-<owner>-<repo>-canvas.zip`,
|
|
78
|
+
for example `pr-42-20260910T110000Z-aaaaaaaa-acme-widgets-canvas.zip`. The timestamp is the
|
|
79
|
+
canvas generation time in UTC, to seconds, so exports sort chronologically within each PR.
|
|
80
|
+
Before a PR exists, `ref-` replaces `pr-<number>-`. Re-exporting the same canvas keeps its name.
|
|
78
81
|
- Export returns `status`, `path`, `name`, `headSha`, and `prNumber` when supplied or stored.
|
|
79
82
|
- `import --pr` compares the imported canvas with that PR's current head. Without it, import does
|
|
80
83
|
not check against a live PR.
|
|
@@ -101,8 +104,19 @@ Relative custom paths resolve from the command's working directory. For example:
|
|
|
101
104
|
pr-review install-skill --codex-dir ~/.codex/skills
|
|
102
105
|
```
|
|
103
106
|
|
|
104
|
-
Installation
|
|
105
|
-
|
|
107
|
+
Installation copies the bundled skill on every platform. The copies and their `.pr-review-install`
|
|
108
|
+
marker files can be committed to Git. Re-running the command refreshes managed copies and replaces
|
|
109
|
+
legacy symlinks. An unmanaged directory requires `--force` to replace it.
|
|
110
|
+
|
|
111
|
+
Each installed `SKILL.md` records `metadata.body-sha256` in its YAML frontmatter. The SHA-256 hash
|
|
112
|
+
covers the body after the closing frontmatter delimiter, with CRLF normalized to LF. `doctor`
|
|
113
|
+
compares the recorded hash and actual body against the skill bundled with the running CLI. Any
|
|
114
|
+
outdated or modified copy in `.claude/skills` or `.agents/skills` fails the skill check, even if the
|
|
115
|
+
other copy is current. Refresh copies with `pr-review install-skill` (repeat any custom directory
|
|
116
|
+
flags used during installation). Automatic discovery checks the two default directories.
|
|
117
|
+
|
|
118
|
+
`serve` runs this skill check automatically and prints failures with a repair hint to stderr.
|
|
119
|
+
Warnings do not prevent the server from starting. Use `doctor --all-checks` for full diagnostics.
|
|
106
120
|
The `.gitignore` update always applies to the selected repository root, even with custom skill
|
|
107
121
|
directories.
|
|
108
122
|
|
|
@@ -138,7 +152,7 @@ within one path segment.
|
|
|
138
152
|
|---|---|---|
|
|
139
153
|
| `version` | `1` | The only supported configuration version |
|
|
140
154
|
| `rulebook` | Unset | Path to a Markdown file of project code standards, resolved from the repository root; these standards take precedence over bundled standards |
|
|
141
|
-
| `layers` |
|
|
155
|
+
| `layers` | `[]` | Optional review guidance; each entry has `id`, `title`, `description`, and optional `paths` patterns. The agent may combine, split, or reorder groups. When omitted or empty, it chooses semantic sections from the change |
|
|
142
156
|
| `highRisk` | `[]` | Entries with a `pattern` glob and `label`; matching changes receive risk labels and cannot go in the Other layer |
|
|
143
157
|
| `generation.mode` | `strict` | See [generation modes](#generation-modes) |
|
|
144
158
|
| `generation.maxRepairRounds` | `3` | Failed validation rounds allowed by the generation skill |
|
|
@@ -147,7 +161,7 @@ within one path segment.
|
|
|
147
161
|
| `generation.caps` | See below | Overrides individual text limits |
|
|
148
162
|
| `tests.patterns` | `['**/*.test.*', '**/*.spec.*', '**/__tests__/**']` | Paths treated as tests for review ordering and labels |
|
|
149
163
|
| `chat.enabled` | `true` | Set to `false` to disable AI Chat |
|
|
150
|
-
| `prompts` | Bundled templates | See [prompt
|
|
164
|
+
| `prompts` | Bundled templates | See [prompt templates](#prompt-templates) for supported keys and behavior |
|
|
151
165
|
|
|
152
166
|
Generation's numeric options and text caps must be positive integers. An empty `layers` list
|
|
153
167
|
provides no suggested groups; an empty `tests.patterns` list recognizes no files as tests.
|
|
@@ -164,6 +178,37 @@ not change an already prepared generation or an existing canvas.
|
|
|
164
178
|
Both modes read the project rulebook and use the same validation and review controls. The mode
|
|
165
179
|
affects canvas generation; chat answers the reviewer's selected question.
|
|
166
180
|
|
|
181
|
+
### Prompt templates
|
|
182
|
+
|
|
183
|
+
The `prompts` map in `pr-review.config.yml` accepts these keys:
|
|
184
|
+
|
|
185
|
+
| Key | Purpose |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `generation-format.md` | Schema and output rules |
|
|
188
|
+
| `generation-strict.md` | Instructions for strict mode |
|
|
189
|
+
| `generation-surfacing.md` | Instructions for surfacing mode |
|
|
190
|
+
| `quality-standards.md` | Bundled code standards |
|
|
191
|
+
| `layering-guidance.md` | Guidance for grouping related changes |
|
|
192
|
+
| `chat-seed.md` | Opening AI Chat instructions |
|
|
193
|
+
|
|
194
|
+
Each configured file replaces a whole template. Paths resolve from the project root,
|
|
195
|
+
including when running from a subdirectory or using `--repo`. Absolute paths work for
|
|
196
|
+
personal templates shared across projects. Omitted entries use the installed package's
|
|
197
|
+
defaults. A configured file that cannot be read causes an error.
|
|
198
|
+
|
|
199
|
+
`generation.mode` selects the generation wrapper. The project rulebook takes precedence
|
|
200
|
+
over code standards. Configured layers and caps supply data to the templates.
|
|
201
|
+
|
|
202
|
+
Preserve `{{TOKENS}}`, including `{{FORMAT}}` in generation wrappers, so generated prompts
|
|
203
|
+
include the context and output requirements. Unknown generation tokens fail rendering;
|
|
204
|
+
chat leaves unknown tokens as written. Prompt edits do not change the output schema or
|
|
205
|
+
validation rules enforced by the tool.
|
|
206
|
+
|
|
207
|
+
Run `prepare` again to apply generation edits (use `--force` for an existing canvas).
|
|
208
|
+
Restart the server after changing the config; chat template edits apply to new threads.
|
|
209
|
+
Custom templates persist across tool upgrades. Compare them with the new bundled templates
|
|
210
|
+
when upgrading.
|
|
211
|
+
|
|
167
212
|
### Text limits
|
|
168
213
|
|
|
169
214
|
Set any of these keys under `generation.caps`. Prose limits count visible characters, so Markdown
|
|
@@ -200,8 +245,7 @@ placed in Other while its source is in a regular layer.
|
|
|
200
245
|
|
|
201
246
|
## Local settings and storage
|
|
202
247
|
|
|
203
|
-
|
|
204
|
-
through the UI. These are the file keys and accepted values:
|
|
248
|
+
The data directory's `settings.yml` accepts these keys and values:
|
|
205
249
|
|
|
206
250
|
| Key | Default | Accepted values |
|
|
207
251
|
|---|---|---|
|
|
@@ -304,7 +348,7 @@ sandbox for the agent. Its access also depends on the agent's own permissions. D
|
|
|
304
348
|
|---|---|
|
|
305
349
|
| `NOT_A_REPO` | Run inside a Git clone or pass `--repo <dir>` |
|
|
306
350
|
| `NO_ORIGIN` | Check that `origin` points to a repository on github.com |
|
|
307
|
-
| `GH_MISSING` / `GH_UNAUTHENTICATED` |
|
|
351
|
+
| `GH_MISSING` / `GH_UNAUTHENTICATED` | Install [GitHub CLI](https://cli.github.com), run `gh auth login`, and check authentication in the same environment that runs the server |
|
|
308
352
|
| `GITHUB_API_ERROR` | Read the underlying error for permissions, rate limits, connectivity, or GitHub service problems |
|
|
309
353
|
| `PR_NOT_FOUND` | Check the PR number, repository, and your access |
|
|
310
354
|
| `CANVAS_NOT_FOUND` | Generate or import a canvas for the requested commit |
|
|
@@ -316,7 +360,7 @@ sandbox for the agent. Its access also depends on the agent's own permissions. D
|
|
|
316
360
|
| `SKILL_DIR_EXISTS` | The destination contains a customized directory; preserve it elsewhere before replacing it with `--force` |
|
|
317
361
|
| `CHAT_BUSY` | Wait for the running reply or press **stop** |
|
|
318
362
|
| `AGENT_AUTH_REQUIRED` | Sign in through the selected agent's CLI, then retry |
|
|
319
|
-
| `AGENT_MISSING` or missing chat pane | Check
|
|
363
|
+
| `AGENT_MISSING` or missing chat pane | Check `chat.enabled` and confirm the server can find `acpx` and the selected agent; run `pr-review doctor --all-checks` |
|
|
320
364
|
| `AGENT_INCOMPLETE` | Retry the message or increase the chat timeout |
|
|
321
365
|
| `COMMENT_FORBIDDEN` | Check the GitHub account's repository access and token permissions |
|
|
322
366
|
| `COMMENT_LINE_NOT_IN_DIFF` | Choose a line shown in the current diff |
|
package/package.json
CHANGED
|
@@ -11,35 +11,16 @@ version: 1
|
|
|
11
11
|
# generation-strict.md: review-prompts/generation-strict.md
|
|
12
12
|
# generation-surfacing.md: review-prompts/generation-surfacing.md
|
|
13
13
|
# quality-standards.md: review-prompts/quality-standards.md
|
|
14
|
-
#
|
|
14
|
+
# layering-guidance.md: review-prompts/layering-guidance.md
|
|
15
15
|
# chat-seed.md: review-prompts/chat-seed.md
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
layers:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
description: Queries, repositories, migrations, storage.
|
|
25
|
-
- id: mappers
|
|
26
|
-
title: Mappers and DTOs
|
|
27
|
-
description: Code that turns records into view models and back.
|
|
28
|
-
- id: hooks-state
|
|
29
|
-
title: Hooks and state
|
|
30
|
-
description: Hooks, query definitions, stores, state machines.
|
|
31
|
-
- id: views
|
|
32
|
-
title: Views
|
|
33
|
-
description: Components, screens, styles, copy.
|
|
34
|
-
- id: routes-wiring
|
|
35
|
-
title: Routes and wiring
|
|
36
|
-
description: Route files, providers, shells, entry points.
|
|
37
|
-
- id: policy-config
|
|
38
|
-
title: Policy and config
|
|
39
|
-
description: Access policies, environment variables, feature flags, CI, deployment, docs.
|
|
40
|
-
- id: mechanical
|
|
41
|
-
title: Mechanical changes
|
|
42
|
-
description: Renames, lockfiles, generated files, formatting, moved code.
|
|
17
|
+
# Optional layer guidance. Omit or leave empty for agent-chosen semantic sections.
|
|
18
|
+
layers: []
|
|
19
|
+
# layers:
|
|
20
|
+
# - id: checkout
|
|
21
|
+
# title: Checkout
|
|
22
|
+
# description: Cart totals, payment processing, and order creation.
|
|
23
|
+
# paths: ['src/checkout/**']
|
|
43
24
|
|
|
44
25
|
# Blast radius tags. Glob patterns against changed paths; the label shows in the header and the rail.
|
|
45
26
|
highRisk:
|
|
@@ -39,11 +39,11 @@ hunk in that file's patch.
|
|
|
39
39
|
|
|
40
40
|
## Layering rules
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Project-configured layers (optional guidance):
|
|
43
43
|
|
|
44
|
-
{{
|
|
44
|
+
{{CONFIGURED_LAYERS}}
|
|
45
45
|
|
|
46
|
-
{{
|
|
46
|
+
{{LAYERING_GUIDANCE}}
|
|
47
47
|
|
|
48
48
|
- **Every hunk id must appear in exactly one layer.** The validator rejects an unassigned or
|
|
49
49
|
duplicated hunk. A file's hunks may be spread over several layers.
|
|
@@ -55,7 +55,7 @@ The project's default layers, in review order, are the default taxonomy:
|
|
|
55
55
|
- Test files go at the **end** of the layer whose code they cover, never in a layer of their own,
|
|
56
56
|
and into Other only together with the code they cover. This project counts a file as a test when
|
|
57
57
|
its path matches one of: {{TEST_PATTERNS}}.
|
|
58
|
-
- No empty layers. Set `defaultLayerId` when a layer derives from a
|
|
58
|
+
- No empty layers. Set `defaultLayerId` only when a layer derives from a configured layer; add, split, or reorder
|
|
59
59
|
layers when the {{TARGET_WORD}} reads better that way. Two to eight layers is typical for a
|
|
60
60
|
{{TARGET_WORD}} of any size; see the size note below for a small one.
|
|
61
61
|
- Each layer's `key` is a short lowercase slug (`auth-session`); links use it.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Choose semantic sections from the actual change: group hunks that explain one behavior, concern,
|
|
2
|
+
or decision, even when they span files or directories. Give each layer a specific title that tells
|
|
3
|
+
the reviewer what to understand.
|
|
4
|
+
|
|
5
|
+
When the project config lists layers, use their descriptions, path hints, and order as guidance.
|
|
6
|
+
Adapt, combine, split, or reorder them to fit the change. When no layers are configured, choose
|
|
7
|
+
sections yourself without assuming an architecture or a fixed list of categories.
|
|
8
|
+
|
|
9
|
+
Order sections by reviewer value. Lead with the main behavior changes, algorithms, state
|
|
10
|
+
transitions, or API changes. Follow with the integration and wiring that support them. Put
|
|
11
|
+
mechanical or low-importance hunks in Other at the end, subject to the eligibility rules below.
|
|
12
|
+
Bring a prerequisite earlier when it helps explain the main change. These priorities guide the
|
|
13
|
+
reading order; choose the number and names of sections to suit this diff.
|
|
14
|
+
|
|
15
|
+
Pair each test with its subject. A test file sits at the end of the layer whose code it covers,
|
|
16
|
+
never in a layer of its own. Match by stem: `lab-mapper.test.ts` covers `lab-mapper.ts`, and a
|
|
17
|
+
file under `__tests__/` covers the file with the same name one directory up. A test whose subject
|
|
18
|
+
is in Other may sit in Other; a test whose subject is in a real layer must sit in that layer.
|
package/src/canvas/export.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function buildCanvasZipFor(
|
|
|
37
37
|
const number = prNumber ?? stored.prNumber
|
|
38
38
|
const manifest: CanvasManifest = number === undefined ? stored : { ...stored, prNumber: number }
|
|
39
39
|
const zip: CanvasZip = {
|
|
40
|
-
name: buildCanvasZipName({ repo: manifest.repo, headSha, prNumber: number }),
|
|
40
|
+
name: buildCanvasZipName({ repo: manifest.repo, headSha, prNumber: number, generatedAt: manifest.generatedAt }),
|
|
41
41
|
bytes: buildCanvasZip(manifest, artifact),
|
|
42
42
|
headSha,
|
|
43
43
|
}
|
package/src/canvas/name.ts
CHANGED
|
@@ -4,11 +4,9 @@ import type { Repo } from '../contract/review-artifact.js'
|
|
|
4
4
|
|
|
5
5
|
export interface ParsedCanvasName {
|
|
6
6
|
prNumber?: number
|
|
7
|
-
|
|
7
|
+
shaPrefix: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export const CANVAS_NAME_PREFIX = 'pr-review-canvas'
|
|
11
|
-
|
|
12
10
|
/** Owner and repo names may hold characters a file name should not; runs of them become one dash. */
|
|
13
11
|
function slugPart(value: string): string {
|
|
14
12
|
return value
|
|
@@ -24,32 +22,34 @@ export function repoSlug(repo: Repo): string {
|
|
|
24
22
|
export interface BuildNameOptions {
|
|
25
23
|
repo: Repo
|
|
26
24
|
headSha: string
|
|
25
|
+
generatedAt: string
|
|
27
26
|
prNumber?: number | undefined
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/**
|
|
29
|
+
/** PR or ref, UTC generation time to seconds, commit prefix, repository, and canvas marker. */
|
|
31
30
|
export function buildCanvasZipName(opts: BuildNameOptions): string {
|
|
32
|
-
const
|
|
33
|
-
|
|
31
|
+
const target = opts.prNumber === undefined ? 'ref' : `pr-${opts.prNumber}`
|
|
32
|
+
const timestamp = new Date(opts.generatedAt).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z'
|
|
33
|
+
return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Reads a file name as a canvas zip for `repo`. A name for another repository, or one that does
|
|
40
40
|
* not follow the grammar, returns null: discovery uses this to skip attachments that are not ours.
|
|
41
41
|
*/
|
|
42
42
|
export function parseCanvasZipName(filename: string, repo: Repo): ParsedCanvasName | null {
|
|
43
|
-
const
|
|
43
|
+
const suffix = `-${repoSlug(repo)}-canvas.zip`
|
|
44
44
|
const lower = filename.toLowerCase()
|
|
45
|
-
if (!lower.
|
|
45
|
+
if (!lower.endsWith(suffix)) {
|
|
46
46
|
return null
|
|
47
47
|
}
|
|
48
|
-
const m =
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
48
|
+
const m = NAME_RE.exec(lower.slice(0, -suffix.length))
|
|
49
|
+
const shaPrefix = m?.[2]
|
|
50
|
+
if (shaPrefix === undefined) {
|
|
51
51
|
return null
|
|
52
52
|
}
|
|
53
53
|
const pr = m?.[1]
|
|
54
|
-
return pr === undefined ? {
|
|
54
|
+
return pr === undefined ? { shaPrefix } : { prNumber: Number(pr), shaPrefix }
|
|
55
55
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import { parseArgs } from 'node:util'
|
|
4
3
|
import { createAgentRunner } from './acpx/acpx.js'
|
|
@@ -21,6 +20,7 @@ import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-art
|
|
|
21
20
|
import { createGit } from './git/git.js'
|
|
22
21
|
import { createGitHubClient } from './github/gh.js'
|
|
23
22
|
import { loadProjectConfig } from './project-config.js'
|
|
23
|
+
import { checkSkill } from './review/doctor.js'
|
|
24
24
|
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
25
25
|
import { startServer } from './server/node-server.js'
|
|
26
26
|
import { readJson } from './store/atomic-json.js'
|
|
@@ -109,6 +109,8 @@ async function serve(argv: string[]): Promise<number> {
|
|
|
109
109
|
agent: values.agent,
|
|
110
110
|
model: values.model,
|
|
111
111
|
})
|
|
112
|
+
const skill = await checkSkill(ctx.config.repoRoot)
|
|
113
|
+
if (!skill.ok) io.stderr(`pr-review doctor: ${skill.detail}. ${skill.hint ?? ''}`)
|
|
112
114
|
startServer(ctx, line => process.stderr.write(`${line}\n`))
|
|
113
115
|
return EXIT.ok
|
|
114
116
|
}
|
|
@@ -125,15 +127,6 @@ async function doctorCommand(argv: string[]): Promise<number> {
|
|
|
125
127
|
version: readPackageVersion(),
|
|
126
128
|
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
127
129
|
dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
|
|
128
|
-
exists: async file => {
|
|
129
|
-
try {
|
|
130
|
-
// A read, so a dangling symlink and a directory both answer no.
|
|
131
|
-
await readFile(file)
|
|
132
|
-
return true
|
|
133
|
-
} catch {
|
|
134
|
-
return false
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
130
|
},
|
|
138
131
|
rest,
|
|
139
132
|
io
|
|
@@ -144,7 +137,7 @@ async function installSkillCommand(argv: string[]): Promise<number> {
|
|
|
144
137
|
const { repo, rest } = splitCommonFlags(argv)
|
|
145
138
|
const cwd = process.cwd()
|
|
146
139
|
const repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
|
|
147
|
-
return runInstallSkill({ repoRoot, cwd
|
|
140
|
+
return runInstallSkill({ repoRoot, cwd }, rest, io)
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
export async function main(argv: string[]): Promise<number> {
|
package/src/commands.ts
CHANGED
|
@@ -306,7 +306,6 @@ export async function runDoctor(deps: DoctorDeps, argv: string[], io: CliIo): Pr
|
|
|
306
306
|
export interface InstallSkillEnv {
|
|
307
307
|
repoRoot: string
|
|
308
308
|
cwd: string
|
|
309
|
-
platform: NodeJS.Platform
|
|
310
309
|
}
|
|
311
310
|
|
|
312
311
|
/** `install-skill [--claude-dir <dir>] [--codex-dir <dir>] [--force]`, both dirs under the repo root by default. */
|
|
@@ -319,7 +318,6 @@ export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io:
|
|
|
319
318
|
const resolve = (flag: string | undefined, fallback: string): string =>
|
|
320
319
|
flag === undefined ? path.join(env.repoRoot, fallback) : path.resolve(env.cwd, flag)
|
|
321
320
|
const result = await installSkill({
|
|
322
|
-
platform: env.platform,
|
|
323
321
|
force: values.force === true,
|
|
324
322
|
targets: [
|
|
325
323
|
{ kind: 'claude', dir: resolve(values['claude-dir'], CLAUDE_SKILLS_DIR) },
|
|
@@ -94,9 +94,9 @@ export function rankCandidates(
|
|
|
94
94
|
candidates: AttachmentCandidate[],
|
|
95
95
|
target: { headSha: string; prNumber: number }
|
|
96
96
|
): AttachmentCandidate[] {
|
|
97
|
-
const
|
|
97
|
+
const shaPrefix = target.headSha.slice(0, 8)
|
|
98
98
|
const score = (c: AttachmentCandidate): number =>
|
|
99
|
-
(c.parsed.
|
|
99
|
+
(c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
|
|
100
100
|
return [...candidates].sort(
|
|
101
101
|
(a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
|
|
102
102
|
)
|
|
@@ -261,7 +261,7 @@ async function tryCandidate(
|
|
|
261
261
|
const shared = {
|
|
262
262
|
url: candidate.url,
|
|
263
263
|
name: candidate.name,
|
|
264
|
-
matchesHead: candidate.parsed.
|
|
264
|
+
matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
|
|
265
265
|
}
|
|
266
266
|
const download = await downloadAttachment(ctx, candidate.url)
|
|
267
267
|
if (!download.ok) {
|
package/src/project-config.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const PromptOverridesSchema = z.object({
|
|
|
41
41
|
'generation-strict.md': z.string().min(1).optional(),
|
|
42
42
|
'generation-surfacing.md': z.string().min(1).optional(),
|
|
43
43
|
'quality-standards.md': z.string().min(1).optional(),
|
|
44
|
-
'
|
|
44
|
+
'layering-guidance.md': z.string().min(1).optional(),
|
|
45
45
|
'chat-seed.md': z.string().min(1).optional(),
|
|
46
46
|
}).strict()
|
|
47
47
|
export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
|
|
@@ -86,49 +86,9 @@ const PartialProjectConfigSchema = z.object({
|
|
|
86
86
|
chat: z.object({ enabled: z.boolean().optional() }).optional(),
|
|
87
87
|
})
|
|
88
88
|
|
|
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
89
|
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
130
90
|
version: 1,
|
|
131
|
-
layers:
|
|
91
|
+
layers: [],
|
|
132
92
|
highRisk: [],
|
|
133
93
|
generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
|
|
134
94
|
tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
|
|
@@ -167,7 +127,7 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
167
127
|
}
|
|
168
128
|
const config: ProjectConfig = {
|
|
169
129
|
version: 1,
|
|
170
|
-
layers: user.layers ??
|
|
130
|
+
layers: user.layers ?? [],
|
|
171
131
|
highRisk: user.highRisk ?? [],
|
|
172
132
|
generation,
|
|
173
133
|
tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
|
|
@@ -180,9 +140,6 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
180
140
|
config.prompts = user.prompts
|
|
181
141
|
}
|
|
182
142
|
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
143
|
if (config.tests.patterns.length === 0) {
|
|
187
144
|
warnings.push(`${PROJECT_CONFIG_FILE}: "tests.patterns" is empty, so no file counts as a test`)
|
|
188
145
|
}
|
package/src/review/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
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,19 +56,40 @@ 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(
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
70
88
|
}
|
|
71
89
|
}
|
|
90
|
+
if (stale.length > 0) {
|
|
91
|
+
return { ok: false, detail: `outdated or modified skill: ${stale.join(', ')}`, hint: 'run `pr-review install-skill`' }
|
|
92
|
+
}
|
|
72
93
|
if (found.length === 0) {
|
|
73
94
|
return {
|
|
74
95
|
ok: false,
|
|
@@ -130,7 +151,7 @@ export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: b
|
|
|
130
151
|
dataDir = { ok: false, detail: message(err), hint: 'pass --data-dir <dir>' }
|
|
131
152
|
}
|
|
132
153
|
|
|
133
|
-
const skill = await checkSkill(repoRoot, deps.
|
|
154
|
+
const skill = await checkSkill(repoRoot, deps.readSkill)
|
|
134
155
|
const checks: DoctorReport['checks'] = { git, origin, gh, ghAuth, dataDir, skill }
|
|
135
156
|
if (options.allChecks) {
|
|
136
157
|
checks.acpx = await checkAcpx(deps)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// `pr-review install-skill`:
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
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 = '
|
|
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
|
-
/**
|
|
55
|
-
async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link'
|
|
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'
|
|
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
|
-
|
|
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 (
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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,
|
|
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 }
|
package/src/review/prompt.ts
CHANGED
|
@@ -13,19 +13,19 @@ export { PROMPTS_DIR }
|
|
|
13
13
|
export interface PromptSources {
|
|
14
14
|
generation: Record<GenerationMode, string>
|
|
15
15
|
format: string
|
|
16
|
-
|
|
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,
|
|
21
|
+
const [format, layeringGuidance, qualityStandards, strict, surfacing] = await Promise.all([
|
|
22
22
|
loadPromptFile('generation-format.md', dir, project),
|
|
23
|
-
loadPromptFile('
|
|
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,
|
|
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,9 +64,9 @@ function inlineDiffs(files: readonly FileEntry[], patches: Record<string, string
|
|
|
64
64
|
.join('\n\n')
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function
|
|
67
|
+
function configuredLayersMarkdown(ctx: GenerationContext): string {
|
|
68
68
|
if (ctx.defaultLayers.length === 0) {
|
|
69
|
-
return '
|
|
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) => {
|
|
@@ -164,7 +164,7 @@ function smallPrMarkdown(ctx: GenerationContext): string {
|
|
|
164
164
|
}
|
|
165
165
|
return (
|
|
166
166
|
`**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
|
|
167
|
+
'- Use one layer unless the concerns truly differ; do not split merely to fill suggested groups.\n' +
|
|
168
168
|
'- Annotate only where the diff does not speak for itself; zero annotations is a fine answer.\n' +
|
|
169
169
|
'- Keep the summary self-contained: state the behavior change and the one relationship or decision worth understanding.'
|
|
170
170
|
)
|
|
@@ -207,8 +207,8 @@ export function renderPrompt(ctx: GenerationContext, patches: Record<string, str
|
|
|
207
207
|
MODEL_PATH: ctx.paths.model,
|
|
208
208
|
MANIFEST: manifestMarkdown(ctx.files),
|
|
209
209
|
DIFFS: diffsMarkdown(ctx, patches),
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
CONFIGURED_LAYERS: configuredLayersMarkdown(ctx),
|
|
211
|
+
LAYERING_GUIDANCE: sources.layeringGuidance.trim(),
|
|
212
212
|
CAPS: capsMarkdown(ctx),
|
|
213
213
|
MAX_POINTS: String(ctx.limits.maxPoints),
|
|
214
214
|
MAX_DIAGRAMS: String(ctx.limits.maxDiagramsPerLayer),
|
|
@@ -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
|
+
}
|
package/static/js/empty-state.js
CHANGED
|
@@ -50,7 +50,7 @@ export function validateCanvasFilename(filename) {
|
|
|
50
50
|
if (!filename.toLowerCase().endsWith('.zip')) {
|
|
51
51
|
return 'that is not a zip file'
|
|
52
52
|
}
|
|
53
|
-
return
|
|
53
|
+
return /^(?:pr-[1-9]\d*|ref)-.+-canvas\.zip$/i.test(filename) ? null : 'that zip is not a review canvas export'
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
package/static/js/settings.js
CHANGED
|
@@ -66,7 +66,7 @@ export function settingsDialogHtml(data, agents) {
|
|
|
66
66
|
'<div class="panel-ro"><h3>Project config (read-only)</h3>' +
|
|
67
67
|
`<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
|
|
68
68
|
`<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
|
|
69
|
-
`<li>
|
|
69
|
+
`<li>configured layer suggestions: ${project.layers}</li>` +
|
|
70
70
|
`<li>high-risk patterns: ${project.highRisk}</li>` +
|
|
71
71
|
`<li>max repair rounds: ${project.maxRepairRounds}</li>` +
|
|
72
72
|
`<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Order the layers so each one depends only on the layers above it. A wrong shape in a contract
|
|
2
|
-
invalidates every judgment about the code that consumes it, so contracts and schemas come first;
|
|
3
|
-
policy and config come late because you can only judge an access policy or an env var after you
|
|
4
|
-
know what the code reads. The configured default layers already follow this order. Keep it unless
|
|
5
|
-
the pull request reads better another way, and say why in the rationale when you reorder.
|
|
6
|
-
|
|
7
|
-
Pair each test with its subject. A test file sits at the end of the layer whose code it covers,
|
|
8
|
-
never in a layer of its own. Match by stem: `lab-mapper.test.ts` covers `lab-mapper.ts`, and a
|
|
9
|
-
file under `__tests__/` covers the file with the same name one directory up. A test whose subject
|
|
10
|
-
is in Other may sit in Other; a test whose subject is in a real layer must sit in that layer.
|
|
11
|
-
|
|
12
|
-
Group by feature only when the pull request is wide. One feature area means the flat order above.
|
|
13
|
-
Two or more areas mean one layer per area and stage, still in dependency order.
|