@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 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. It also adds `.pr-review/settings.yml` to the
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 skill is installed. It prints a JSON report with a result for
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
- A global install reads `pr-review.config.yml` from the project root. Use its `prompts`
135
- map to replace individual templates with files you keep in the project's Git repository:
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
- To start from the installed templates (for an npm global install):
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 add entries only for the templates you want to override. Commit
152
- `pr-review.config.yml` and the referenced files together. Paths resolve from the project
153
- root, including when running from a subdirectory or using `--repo`. Absolute paths also
154
- work for personal templates shared across projects. Omitted entries use the installed
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
- For installation, the author/reviewer workflow, and basic settings, start with the
4
- [README](../README.md). This reference covers command options, configuration values, and behavior
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 uses relative symlinks on Linux and macOS and copies on Windows. Re-running it
105
- refreshes its own installation. A customized directory requires `--force` to replace it.
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` | Eight architecture groups | Suggested review groups; each entry has `id`, `title`, `description`, and optional `paths` patterns. The generator may split or reorder groups |
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 customization](../README.md#project-prompt-templates) for supported keys and setup |
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
- See the [README's settings overview](../README.md#user-local-preferences) for editing preferences
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` | Follow the [GitHub CLI setup](../README.md#install); check authentication in the same environment that runs the server |
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 the [chat setup](../README.md#optional-ai-chat-install), `chat.enabled`, and that the server can find the installed executables |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vintasoftware/pr-review-canvas",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Localhost PR review canvas: layered diffs, attention points, and GitHub comments for one PR",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- # layers-default.md: review-prompts/layers-default.md
14
+ # layering-guidance.md: review-prompts/layering-guidance.md
15
15
  # chat-seed.md: review-prompts/chat-seed.md
16
16
 
17
- # Default layer taxonomy, in review order. Ids are referenced by `defaultLayerId` in a canvas.
18
- layers:
19
- - id: contracts
20
- title: Contracts and schemas
21
- description: Types, schemas, and the shapes two modules must agree on.
22
- - id: data-access
23
- title: Data access
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
- The project's default layers, in review order, are the default taxonomy:
42
+ Project-configured layers (optional guidance):
43
43
 
44
- {{DEFAULT_LAYERS}}
44
+ {{CONFIGURED_LAYERS}}
45
45
 
46
- {{LAYERS_DEFAULT}}
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 default; add, split, or reorder
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.
@@ -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
  }
@@ -4,11 +4,9 @@ import type { Repo } from '../contract/review-artifact.js'
4
4
 
5
5
  export interface ParsedCanvasName {
6
6
  prNumber?: number
7
- sha7: string
7
+ shaPrefix: string
8
8
  }
9
9
 
10
- export const CANVAS_NAME_PREFIX = 'pr-review-canvas'
11
-
12
10
  /** Owner and repo names may hold characters a file name should not; runs of them become one dash. */
13
11
  function slugPart(value: string): string {
14
12
  return value
@@ -24,32 +22,34 @@ export function repoSlug(repo: Repo): string {
24
22
  export interface BuildNameOptions {
25
23
  repo: Repo
26
24
  headSha: string
25
+ generatedAt: string
27
26
  prNumber?: number | undefined
28
27
  }
29
28
 
30
- /** `pr-review-canvas-<owner>-<repo>-pr<n>-<sha7>.zip`, without `-pr<n>` before the PR exists. */
29
+ /** PR or ref, UTC generation time to seconds, commit prefix, repository, and canvas marker. */
31
30
  export function buildCanvasZipName(opts: BuildNameOptions): string {
32
- const pr = opts.prNumber === undefined ? '' : `-pr${opts.prNumber}`
33
- return `${CANVAS_NAME_PREFIX}-${repoSlug(opts.repo)}${pr}-${opts.headSha.slice(0, 7)}.zip`
31
+ const target = opts.prNumber === undefined ? 'ref' : `pr-${opts.prNumber}`
32
+ const timestamp = new Date(opts.generatedAt).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z'
33
+ return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
34
34
  }
35
35
 
36
- const TAIL_RE = /^(?:pr(\d+)-)?([0-9a-f]{7})\.zip$/
36
+ const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
37
37
 
38
38
  /**
39
39
  * Reads a file name as a canvas zip for `repo`. A name for another repository, or one that does
40
40
  * not follow the grammar, returns null: discovery uses this to skip attachments that are not ours.
41
41
  */
42
42
  export function parseCanvasZipName(filename: string, repo: Repo): ParsedCanvasName | null {
43
- const prefix = `${CANVAS_NAME_PREFIX}-${repoSlug(repo)}-`
43
+ const suffix = `-${repoSlug(repo)}-canvas.zip`
44
44
  const lower = filename.toLowerCase()
45
- if (!lower.startsWith(prefix)) {
45
+ if (!lower.endsWith(suffix)) {
46
46
  return null
47
47
  }
48
- const m = TAIL_RE.exec(lower.slice(prefix.length))
49
- const sha7 = m?.[2]
50
- if (sha7 === undefined) {
48
+ const m = NAME_RE.exec(lower.slice(0, -suffix.length))
49
+ const shaPrefix = m?.[2]
50
+ if (shaPrefix === undefined) {
51
51
  return null
52
52
  }
53
53
  const pr = m?.[1]
54
- return pr === undefined ? { sha7 } : { prNumber: Number(pr), sha7 }
54
+ return pr === undefined ? { shaPrefix } : { prNumber: Number(pr), shaPrefix }
55
55
  }
package/src/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, platform: process.platform }, rest, io)
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 sha7 = target.headSha.slice(0, 7)
97
+ const shaPrefix = target.headSha.slice(0, 8)
98
98
  const score = (c: AttachmentCandidate): number =>
99
- (c.parsed.sha7 === sha7 ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
99
+ (c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
100
100
  return [...candidates].sort(
101
101
  (a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
102
102
  )
@@ -261,7 +261,7 @@ async function tryCandidate(
261
261
  const shared = {
262
262
  url: candidate.url,
263
263
  name: candidate.name,
264
- matchesHead: candidate.parsed.sha7 === pr.headSha.slice(0, 7),
264
+ matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
265
265
  }
266
266
  const download = await downloadAttachment(ctx, candidate.url)
267
267
  if (!download.ok) {
@@ -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
- 'layers-default.md': z.string().min(1).optional(),
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: DEFAULT_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 ?? DEFAULT_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
  }
@@ -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,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(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
+ }
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.exists)
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`: 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 }
@@ -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,9 +64,9 @@ 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) => {
@@ -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 for the sake of the taxonomy.\n' +
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
- DEFAULT_LAYERS: defaultLayersMarkdown(ctx),
211
- LAYERS_DEFAULT: sources.layersDefault.trim(),
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
+ }
@@ -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 filename.toLowerCase().startsWith('pr-review-canvas-') ? null : 'that zip is not a review canvas export'
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
  /**
@@ -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>layers in the taxonomy: ${project.layers}</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.