@erclx/canon 4.18.0 → 4.19.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.18.0",
4
+ "version": "4.19.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -11,7 +11,7 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
11
11
  ## Guards
12
12
 
13
13
  - All `.canon/plans/` and `.canon/review/` reads resolve at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does.
14
- - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` Every later step keys its output on this slug, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
14
+ - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` This slug is provisional. It is superseded once `claude-worktree` runs, whether at Step 0 or before this chain began. Every later step keys its output on the slug that run resolves, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
15
15
  - Resolve `<plan>` in Step 1, ahead of any other read.
16
16
  - If the working tree has uncommitted changes unrelated to the plan, stop: `❌ Uncommitted changes outside the plan. Commit or stash before autoshipping.`
17
17
 
@@ -61,7 +61,7 @@ What it leaves unmeasured is a prefix reaching no path, so a row left behind by
61
61
 
62
62
  ## Matching
63
63
 
64
- - Take the changed set from `git diff --name-only <base> HEAD`, resolved against the same base as the diff the body is written from
64
+ - Take the changed set from `git diff --name-only <base>...HEAD`, resolved against the same base as the diff the body is written from. The three dots hold whether `<base>` arrives already resolved to a merge base or as a bare ref name, where the two-dot form is correct only in the first case and hands the branch somebody else's merged paths in the second
65
65
  - A path earns a label when the path starts with one of that label's prefixes
66
66
  - Collect the distinct labels across the whole set, ordered as the map declares them, so two runs over one branch produce one string
67
67
  - Pass the result as a single comma-separated value. An empty result runs no labelling step.
@@ -68,7 +68,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
68
68
  | `canon secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
69
69
  | `canon deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
70
70
  | `canon labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
71
- | `canon labels scan` | Fail a pull request whose title or body names the board, by a phase label, a label a code span quotes, or a gitignored record path (`--event`, `--json`) |
71
+ | `canon labels scan` | Fail a pull request whose title or body carries a phase label, a label a code span quotes, a gitignored record path, or a session link (`--event`, `--json`) |
72
72
  | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
73
73
  | `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
74
74
  | `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
@@ -18,12 +18,14 @@ The positional is the pull request to read, defaulting to the one open on this b
18
18
  | Option | Behavior |
19
19
  | --------------- | ------------------------------------------------------------------ |
20
20
  | `--body <path>` | Read the body from a file, taking the changed set from git instead |
21
- | `--base <ref>` | Far side of the range when `--body` supplies the body |
21
+ | `--base <ref>` | Ref the range runs back to when `--body` supplies the body |
22
22
  | `--root <path>` | Repository to read, defaulting to the cwd |
23
23
  | `--json` | Add a machine-readable record on stdout, keeping the frame |
24
24
 
25
25
  `--body` decides where both halves come from, so a number passed beside it is never read. The body comes off disk and the changed set from the local range, which is the shape a fixture and a body still being drafted both need.
26
26
 
27
+ `--base` names the far side of that range and never the commit the comparison runs against directly. The reader resolves the merge base between `HEAD` and the ref, so `--base origin/main` measures what the branch wrote rather than what the trunk gained while the branch was open. Passing the trunk as it stands today is therefore safe on a branch of any age, and a ref sharing no history with `HEAD` refuses as `bad-base`.
28
+
27
29
  Without `--body` the body, the file list, and the head commit come back from one `gh pr view` call, because the three have to describe the same commit and reading them apart leaves a window where a push between them compares a body against another head's files.
28
30
 
29
31
  That view caps its file list at 100 rows and says nothing about having done so, which was measured against `#1250`: the pull request carries 101 files and the view reports 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
@@ -14,11 +14,13 @@ canon labels audit --base origin/main
14
14
  canon labels audit src/cli.ts docs/index.md --json
15
15
  ```
16
16
 
17
- | Option | Behavior |
18
- | --------------- | ----------------------------------------------------------------- |
19
- | `--base <ref>` | Far side of the range, defaulting to the merge base against trunk |
20
- | `--root <path>` | Repository to read, defaulting to the current directory |
21
- | `--json` | Add a machine-readable record on stdout, keeping the frame |
17
+ | Option | Behavior |
18
+ | --------------- | ---------------------------------------------------------- |
19
+ | `--base <ref>` | Ref the range runs back to, defaulting to the trunk |
20
+ | `--root <path>` | Repository to read, defaulting to the current directory |
21
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
22
+
23
+ `--base` names the far side of the range rather than the commit the diff runs against. The audit resolves the merge base between `HEAD` and the ref before reading anything, so `--base origin/main` on a branch the trunk has moved past still earns labels for the paths that branch wrote. Reading the ref literally is what once handed a long-open branch the labels for somebody else's merge, and it named those paths as uncovered surfaces on top.
22
24
 
23
25
  Positional paths replace the range entirely. A caller holding a changed set already passes it rather than paying for a second git read, and the record then omits `base` to say the range was never resolved.
24
26
 
@@ -13,11 +13,11 @@ canon gov test-order --base origin/main --json
13
13
  canon gov test-order --root ../my-app
14
14
  ```
15
15
 
16
- | Option | Behavior |
17
- | --------------- | ----------------------------------------------------------------- |
18
- | `--base <ref>` | Far side of the range, defaulting to the merge base against trunk |
19
- | `--root <path>` | Repository to read, defaulting to the current directory |
20
- | `--json` | Add a machine-readable record on stdout, keeping the frame |
16
+ | Option | Behavior |
17
+ | --------------- | ---------------------------------------------------------- |
18
+ | `--base <ref>` | Ref the range runs back to, defaulting to the trunk |
19
+ | `--root <path>` | Repository to read, defaulting to the current directory |
20
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
21
21
 
22
22
  Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode. A consumer reading stdout sees pure data, and an operator reading the terminal sees why a run refused rather than a command that appeared to do nothing.
23
23
 
@@ -27,6 +27,8 @@ The default range is the branch against the trunk, resolved as the merge base ag
27
27
 
28
28
  Reading all history on every run measures work nobody is reviewing and buries the finding that matters under the ones already merged. `--base` widens or narrows it when a reader wants a different window.
29
29
 
30
+ A ref passed there is resolved the same way, through its merge base with `HEAD`, so it names the far side of the range and not the commit the range starts from. `--base origin/main` therefore reports the same window whether the trunk has moved since the branch left or not. Naming a commit the branch already carries still measures from that commit, since the merge base of `HEAD` and an ancestor is the ancestor, and a ref sharing no history with `HEAD` refuses as `bad-base`.
31
+
30
32
  Only history is read. A file sitting in the working tree and in no commit is invisible here, which is the point: the verification run sees a tree at one moment, and the ordering exists nowhere but history.
31
33
 
32
34
  ## How a pair is decided
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.18.0",
4
+ "version": "4.19.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -90,7 +90,7 @@ export function register(program: Command): void {
90
90
  labels
91
91
  .command('scan')
92
92
  .description(
93
- 'Fail a pull request whose title or body carries a phase label or a board identifier',
93
+ 'Fail a pull request whose title or body carries a phase label, a board identifier, or a session link',
94
94
  )
95
95
  .helpOption('-h, --help', 'Show this help message')
96
96
  .option(
@@ -118,10 +118,16 @@ export function register(program: Command): void {
118
118
  'copy of. A path under a tracked folder is left alone, so a rule or a',
119
119
  'skill any clone resolves is not reported.',
120
120
  '',
121
+ 'It reports a session link as the third category, being a link to one',
122
+ 'Claude Code session, which the harness appends to text it tells a',
123
+ 'session to publish. That link resolves for the one account holding the',
124
+ 'session and for no other reader, which no clone repairs, so it is read',
125
+ 'on a release pull request too, where the board identifier is not.',
126
+ '',
121
127
  'Exit codes:',
122
- ' 0 no phase label and no board identifier found',
128
+ ' 0 no phase label, no board identifier, and no session link found',
123
129
  ' 1 refused, with the reason on stderr or in the JSON record',
124
- ' 2 the title or body carries a phase label or a board identifier',
130
+ ' 2 the title or body carries one of the three',
125
131
  '',
126
132
  'Examples:',
127
133
  ' canon labels scan --event "$GITHUB_EVENT_PATH"',
@@ -363,6 +369,16 @@ async function runScan(opts: ScanOptions): Promise<number> {
363
369
  for (const reference of result.boardReferences) logWarn(reference)
364
370
  }
365
371
 
372
+ logStep(result.sessionLinks.length === 0 ? 'Clean' : 'Session link found')
373
+ if (result.sessionLinks.length === 0) {
374
+ logInfo('no session link in the title or body')
375
+ } else {
376
+ logWarn(
377
+ `${plural(result.sessionLinks.length, 'session link')} in the title or body. Delete every hit rather than rewriting it, since a session resolves for the one account that started it and for no other reader.`,
378
+ )
379
+ for (const link of result.sessionLinks) logWarn(link)
380
+ }
381
+
366
382
  outro()
367
383
 
368
384
  if (emitJson) {
@@ -372,11 +388,14 @@ async function runScan(opts: ScanOptions): Promise<number> {
372
388
  phaseLabels: result.phaseLabels,
373
389
  semverTags: result.semverTags,
374
390
  boardReferences: result.boardReferences,
391
+ sessionLinks: result.sessionLinks,
375
392
  })}\n`,
376
393
  )
377
394
  }
378
395
 
379
- return result.phaseLabels.length === 0 && result.boardReferences.length === 0
396
+ return result.phaseLabels.length === 0 &&
397
+ result.boardReferences.length === 0 &&
398
+ result.sessionLinks.length === 0
380
399
  ? 0
381
400
  : 2
382
401
  }
@@ -66,7 +66,7 @@ const REFUSALS: Record<Refusal, string> = {
66
66
  'git could not list this repository, so no path could be judged whole rather than partial.',
67
67
  'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
68
68
  'bad-base':
69
- 'The ref passed to --base resolves to no commit here. Pass one this tree carries.',
69
+ 'The ref passed to --base shares no history with HEAD here, either because it resolves to no commit or because it sits on an unrelated root. Pass a ref this branch was taken from.',
70
70
  'unreadable-changes':
71
71
  'git could not list what this branch changed, so the set is unknown.',
72
72
  'no-section': `This body carries no ## ${KEY_CHANGES} section, so it claims nothing to compare.`,
package/src/git-files.ts CHANGED
@@ -18,24 +18,28 @@ async function git(
18
18
  }
19
19
 
20
20
  /**
21
- * The far side of a branch range: the ref a caller named, or the merge base
22
- * against the first trunk this repository carries.
21
+ * The far side of a branch range: the merge base against the ref a caller
22
+ * named, or against the first trunk this repository carries.
23
23
  *
24
- * A named ref that resolves to nothing refuses rather than falling back, since
25
- * measuring the trunk range instead would answer a question nobody asked.
24
+ * A named ref names the far side rather than the comparison point, so it
25
+ * resolves through the merge base exactly as the trunk below does. Reading it
26
+ * literally is what let a trunk moving under an open branch report every path
27
+ * merged in between as one the branch had written. A caller naming an ancestor
28
+ * still gets that commit back, since the merge base of `HEAD` and an ancestor
29
+ * is the ancestor.
30
+ *
31
+ * A named ref that produces no merge base refuses rather than falling back,
32
+ * since measuring the trunk range instead would answer a question nobody
33
+ * asked. That covers a ref resolving to nothing and a ref sharing no history
34
+ * with `HEAD` alike, which no caller can separate.
26
35
  */
27
36
  export async function resolveBaseRef(
28
37
  root: string,
29
38
  ref?: string,
30
39
  ): Promise<string | undefined> {
31
40
  if (ref !== undefined) {
32
- const resolved = await git(root, [
33
- 'rev-parse',
34
- '--verify',
35
- '--quiet',
36
- `${ref}^{commit}`,
37
- ])
38
- return resolved === undefined || resolved === '' ? undefined : resolved
41
+ const merged = await git(root, ['merge-base', 'HEAD', ref])
42
+ return merged === undefined || merged === '' ? undefined : merged
39
43
  }
40
44
 
41
45
  for (const trunk of TRUNK_REFS) {
@@ -335,11 +335,14 @@ export function readTestOrder(
335
335
  }
336
336
 
337
337
  /**
338
- * The far side of the range. A ref the caller named has to resolve, since
339
- * falling back to the trunk there would measure a range nobody asked for. With
340
- * no ref named, the merge base against the trunk scopes the run to the branch,
341
- * and a repository carrying no trunk falls back to the root commit rather than
342
- * refusing.
338
+ * The far side of the range. A ref the caller named resolves through the merge
339
+ * base against `head`, matching the no-ref branch below rather than taking the
340
+ * ref as the comparison point, so a trunk that has moved under the branch does
341
+ * not pull other people's merged commits into the range. A ref producing no
342
+ * merge base has to refuse, since falling back to the trunk there would measure
343
+ * a range nobody asked for. With no ref named, the merge base against the trunk
344
+ * scopes the run to the branch, and a repository carrying no trunk falls back
345
+ * to the root commit rather than refusing.
343
346
  */
344
347
  function resolveBase(
345
348
  root: string,
@@ -347,15 +350,15 @@ function resolveBase(
347
350
  head: string,
348
351
  ): string | { kind: 'unreadable'; reason: TestOrderRefusal; message: string } {
349
352
  if (ref !== undefined) {
350
- const resolved = revParse(root, ref)
351
- if (resolved === undefined) {
353
+ const merged = git(root, ['merge-base', head, ref])
354
+ if (merged === undefined || merged === '') {
352
355
  return {
353
356
  kind: 'unreadable',
354
357
  reason: 'bad-base',
355
- message: `Ref ${ref} resolves to no commit in ${root}. Pass a commit this tree carries.`,
358
+ message: `Ref ${ref} shares no history with HEAD in ${root}. Pass a ref this branch was taken from.`,
356
359
  }
357
360
  }
358
- return resolved
361
+ return merged
359
362
  }
360
363
 
361
364
  for (const trunk of TRUNK_REFS) {
@@ -10,7 +10,7 @@ import { type MapRefusal, readLabelMap } from '@/labels/map'
10
10
  * could not get, which is a broken invocation rather than a project that
11
11
  * declared nothing.
12
12
  *
13
- * A named ref that will not resolve is its own reason. Folding it into
13
+ * A named ref producing no merge base is its own reason. Folding it into
14
14
  * `no-base` sends the caller who already passed `--base` a message telling
15
15
  * them to pass `--base`.
16
16
  */
@@ -31,7 +31,11 @@ export type LabelAudit =
31
31
  | { readonly kind: 'refused'; readonly reason: LabelAuditRefusal }
32
32
 
33
33
  export interface LabelAuditOptions {
34
- /** Far side of the range, defaulting to the merge base against the trunk. */
34
+ /**
35
+ * Ref naming the far side of the range, which resolves through its merge
36
+ * base with `HEAD` rather than being compared against literally. Defaults to
37
+ * the trunk.
38
+ */
35
39
  readonly base?: string
36
40
  /** A changed set the caller already holds, which skips git entirely. */
37
41
  readonly paths?: readonly string[]
@@ -34,6 +34,19 @@ export interface PhaseScanResult {
34
34
  * nor the gitignored folder a path names.
35
35
  */
36
36
  readonly boardReferences: readonly string[]
37
+ /**
38
+ * A link to one Claude Code session, which the harness appends to text it
39
+ * tells a session to publish.
40
+ *
41
+ * It sits beside the board references rather than inside them because the
42
+ * two are unresolvable for different reasons. A record path fails for a
43
+ * reader holding no copy of this checkout, and a session link fails for
44
+ * everyone outside the one account that holds the session, which no clone
45
+ * and no checkout repairs. The report line for a board reference names a
46
+ * record path and a quoted label, so a session link folded in would be
47
+ * reported under a sentence that does not describe it.
48
+ */
49
+ readonly sessionLinks: readonly string[]
37
50
  }
38
51
 
39
52
  const VERSION_TOKEN = /\bv\d+(?:\.\d+){1,2}\b/g
@@ -98,6 +111,26 @@ const RECORD_PATH = new RegExp(
98
111
  'g',
99
112
  )
100
113
 
114
+ /**
115
+ * A link to one Claude Code session, matched on the host and the path segment
116
+ * rather than on the identifier alphabet.
117
+ *
118
+ * The two instances on the trunk carry a 24-character identifier after
119
+ * `session_`, and reading that shape into the pattern would empty this check
120
+ * the moment the harness changed it, with nothing left to report and no
121
+ * failure to notice. The host and the path segment are what the harness has to
122
+ * keep for the link to resolve at all. A host change still gets past this and
123
+ * nothing detects that.
124
+ *
125
+ * The scheme is optional because it is incidental to the two parts being
126
+ * matched, so a link written without it is the same unresolvable reference.
127
+ * The tail runs to the first whitespace or closing delimiter, which is how a
128
+ * record path is read, so a report names the whole link rather than the prefix
129
+ * that matched.
130
+ */
131
+ const SESSION_LINK =
132
+ /(?<![\w./-])(?:https?:\/\/)?claude\.ai\/code\/session_[^\s`)\]]+/g
133
+
101
134
  /**
102
135
  * The head branch release-please opens every release pull request under.
103
136
  *
@@ -180,6 +213,16 @@ function recordPaths(text: string): string[] {
180
213
  ]
181
214
  }
182
215
 
216
+ function sessionLinks(text: string): string[] {
217
+ return [
218
+ ...new Set(
219
+ (text.match(SESSION_LINK) ?? []).map((link) =>
220
+ link.replace(TRAILING_PUNCTUATION, ''),
221
+ ),
222
+ ),
223
+ ]
224
+ }
225
+
183
226
  /**
184
227
  * Reads a title and a body for version-shaped tokens and sorts every one
185
228
  * found into the namespace this pull request is allowed to carry.
@@ -197,11 +240,22 @@ function recordPaths(text: string): string[] {
197
240
  * body without passing the gate on its own. That its author has nothing to
198
241
  * rewrite is true as well and is the weaker half, since it would leave the
199
242
  * reference standing and unresolvable.
243
+ *
244
+ * A session link is reported on both paths, the release one included. Nothing
245
+ * appends one to a release body, which release-please generates with no
246
+ * session in the loop, so populating the field there costs nothing on every
247
+ * run this repository has seen. What it buys is that the coverage argument
248
+ * above never has to hold for this category: that argument reasons from every
249
+ * commit in the generated history having passed this gate, and a link the gate
250
+ * did not yet scan for would reach a release body under it. Reading the whole
251
+ * body on both paths leaves no hole if the premise ever slips.
200
252
  */
201
253
  export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
202
254
  const source = `${input.title}\n${input.body}`
255
+ const outsideFences = linesOutsideFences(source).join('\n')
203
256
  const tokens = versionTokens(readable(source))
204
257
  const cutsRelease = isReleasePullRequest(input)
258
+ const links = sessionLinks(outsideFences)
205
259
 
206
260
  if (cutsRelease) {
207
261
  return {
@@ -209,6 +263,7 @@ export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
209
263
  phaseLabels: [],
210
264
  semverTags: tokens,
211
265
  boardReferences: [],
266
+ sessionLinks: links,
212
267
  }
213
268
  }
214
269
 
@@ -222,9 +277,7 @@ export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
222
277
  cutsRelease,
223
278
  phaseLabels: tokens,
224
279
  semverTags: [],
225
- boardReferences: [
226
- ...quoted,
227
- ...recordPaths(linesOutsideFences(source).join('\n')),
228
- ],
280
+ boardReferences: [...quoted, ...recordPaths(outsideFences)],
281
+ sessionLinks: links,
229
282
  }
230
283
  }
package/standards/pr.md CHANGED
@@ -14,7 +14,7 @@ Does not govern:
14
14
  - Commit subject format, which shares the title form: `commit.md`
15
15
  - Branch naming: `branch.md`
16
16
  - Whether a phase label or a semver tag may appear in a title or body: `versioning.md`
17
- - Whether a quoted label or a gitignored record path may appear in a title or body, which `canon labels scan` fails on: `publish.md`
17
+ - Whether a quoted label, a gitignored record path, or a link to one Claude Code session may appear in a title or body, which `canon labels scan` fails on: `publish.md`
18
18
  - Voice, rhythm, and sentence construction in pull request prose: the `write-human` skill
19
19
  - Punctuation, formatting, and banned words in pull request prose: `markdown.md`
20
20
 
@@ -46,6 +46,7 @@ Does not govern:
46
46
  - Use checkboxes, never prose. See Testing discipline for which box gets ticked.
47
47
  - `## For the reviewer` (optional): what the reviewing session should confirm, one bullet per request
48
48
  - Visuals: include only when they clarify architecture, UI, or complex logic flows
49
+ - The list is closed. A body carries the sections above and nothing after the last of them, which covers a trailer a harness appends once the body is composed. `publish.md` states the rule, and `canon labels scan` fails on the one such trailer measured so far.
49
50
 
50
51
  ## Testing discipline
51
52
 
@@ -46,6 +46,16 @@ Rewrite a hit to name what the reader can reach rather than deleting it. A row's
46
46
 
47
47
  `canon labels scan` runs this check and the phase-label one over a pull request title and body. It reads that pair alone, so every other channel is the author's own scan.
48
48
 
49
+ ## Session links
50
+
51
+ A link to one Claude Code session names the session that wrote the text rather than the change the text describes. It enters because the harness tells the composing session to end its published text with one, so the author writes it deliberately and it reads as required rather than as a slip. That is what carries it past a scan the same author runs, and it is why a rule stating the ban does not reach the case: the session is following an instruction it was given, and reads its own compliance as correct.
52
+
53
+ This check depends on the reader holding the account rather than the checkout, which is narrower than the dependency the destination rule above scopes. A reader inside this checkout opens a record path and still cannot open a session, since the session resolves for the one account that started it and for nobody else. The scoping still sits at the invocation site, because `canon labels scan` reads a pull request title and body and reaches no other text.
54
+
55
+ Delete a hit rather than rewriting it. The other two checks replace a hit with what the reader can reach instead, and a session has no such form, so what stands in its place is the body already written above it.
56
+
57
+ Removing the link before a merge does not substitute for never writing it. One branch removed it, read the live body back clean, and the squashed commit landed carrying it anyway off an earlier snapshot, which is permanent on the trunk.
58
+
49
59
  ## Cross-reference form
50
60
 
51
61
  A number referring to a pull request or an issue takes the form its destination renders. Write it bare where the destination auto-links it, and in backticks where it does not. Both spellings are correct, each in one place, so a reference moved from one destination to the other is rewritten rather than copied.