@erclx/canon 4.15.0 → 4.16.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.
@@ -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.15.0",
4
+ "version": "4.16.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -10,7 +10,7 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
10
10
 
11
11
  ## Guards
12
12
 
13
- - All `.canon/plans/` and `.canon/review/` reads resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
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
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.
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.`
@@ -49,7 +49,7 @@ Read these in parallel from the current worktree root (`pwd`), not the main work
49
49
  - `.claude/DESIGN.md`
50
50
  - `.claude/wireframes/index.md` and every `.claude/wireframes/<surface>.md`
51
51
 
52
- Read the task board from the main worktree root instead, per Worktrees in `CLAUDE.md`. It is gitignored scratch and never commits with the branch:
52
+ Read the task board from the main worktree root instead, resolving that root the way `claude-worktree` does. It is gitignored scratch and never commits with the branch:
53
53
 
54
54
  - `.canon/tasks/index.md` first, then the task files this session touched. That narrow read serves the marking step, which is the only step here that opens a task file.
55
55
 
@@ -210,7 +210,7 @@ Report a block left unfolded rather than dropping it:
210
210
 
211
211
  ## Step 9: sweep consumed receipts
212
212
 
213
- Sweep the review and memory receipts this session consumed. Resolve all paths at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
213
+ Sweep the review and memory receipts this session consumed. Resolve all paths at the main worktree root, not the current worktree, the way `claude-worktree` does.
214
214
 
215
215
  Every delete below is a shell operation, so send each as a plain single `Bash` command rather than joining two with `&&`, which is refused as compound from a linked worktree.
216
216
 
@@ -81,7 +81,7 @@ Omit empty sections. Do not print `None identified.` in chat.
81
81
 
82
82
  ### Full mode
83
83
 
84
- Derive a 2-to-4-word kebab-case slug from the feature description. Write the full plan to `.canon/plans/feature-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist.
84
+ Derive a 2-to-4-word kebab-case slug from the feature description. Write the full plan to `.canon/plans/feature-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist.
85
85
 
86
86
  From a linked worktree the file-editing tools refuse that path, so the plan goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
87
87
 
@@ -25,7 +25,7 @@ Derive `<slug>` from the concern and the surface, kebab-case, naming the subject
25
25
  - No matching folder, or the operator names a concern and a surface: **Propose**.
26
26
  - A matching folder exists and the operator says apply, ship, or commit the answers: **Apply**.
27
27
 
28
- All `.canon/proposals/` reads and writes resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
28
+ All `.canon/proposals/` reads and writes resolve at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does.
29
29
 
30
30
  ## Write scope
31
31
 
@@ -13,7 +13,7 @@ The filename and its type prefix, the frontmatter, the body shape each type carr
13
13
 
14
14
  ## Guards
15
15
 
16
- - All `.canon/memory/` reads and writes resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
16
+ - All `.canon/memory/` reads and writes resolve at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does.
17
17
  - From a linked worktree the file-editing tools refuse every path below, so each write in this skill goes out through `Bash` as a plain single command. A memory entry holds one fact and this session has read it, so an update rewrites the whole file with a heredoc rather than editing a line inside it.
18
18
  - If `.canon/memory/` does not exist at the main worktree root, create it, along with an `index.md` carrying `title` and `subtitle` frontmatter. `canon claude init` seeds both, and a project predating that seed has neither. Regeneration errors without the index, so the first write into a bare folder would report a frontmatter failure against a file that is fine.
19
19
  - If the session produced no user corrections, confirmations, or context disclosures worth persisting, stop: `✅ Nothing worth capturing.`
@@ -21,7 +21,7 @@ If the user re-pings the skill with no new phrase and a receipt exists, default
21
21
 
22
22
  ## Guards
23
23
 
24
- - All `.canon/memory/` reads, edits, and archive moves resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
24
+ - All `.canon/memory/` reads, edits, and archive moves resolve at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does.
25
25
  - If no `.canon/memory/` directory exists at the main worktree root, stop: `❌ No .canon/memory/ directory found.`
26
26
  - If `.canon/memory/` contains no `*.md` entries other than `index.md`, stop: `✅ No memory entries to review.`
27
27
  - Cleanup is exempt from the two stops above. It works on receipts in `.canon/review/`, and a drained pen is the normal state once Apply has run, so a pen-shaped stop would strand the receipt it exists to delete.
@@ -23,7 +23,7 @@ session's review are different passes, and how a feature is sized.
23
23
 
24
24
  ## On invocation
25
25
 
26
- Read the board in parallel, resolving the paths at the main worktree root per Worktrees in `CLAUDE.md`:
26
+ Read the board in parallel, resolving the paths at the main worktree root the way `claude-worktree` does:
27
27
 
28
28
  - `.canon/tasks/priority.md`: execution order and what each task is waiting on
29
29
  - `.canon/tasks/backlog.md`: what is not being scheduled, when the file exists
@@ -80,7 +80,7 @@ claude --bg --model <model> -n "worker-<project>-<slug>" "Run /canon:claude-work
80
80
 
81
81
  The prefix reads `worker-` because that is the role it marks. It read `orchestrator-` until 2026-08-31, and no controlling session ever carried it, so a worker filtering the roster for that string found a sibling or itself on every row. Nothing matches the prefix programmatically, which is what kept the rename down to three strings.
82
82
 
83
- `<project>` is the basename of the main worktree root, not of wherever the dispatcher happens to be running. Resolve the main root first, the way `claude-worktree` Step 1 does, since a bare `git rev-parse --show-toplevel` inside a linked worktree returns the worktree path rather than the project's. See Worktrees in `CLAUDE.md`.
83
+ `<project>` is the basename of the main worktree root, not of wherever the dispatcher happens to be running. Resolve the main root first, the way `claude-worktree` Step 1 does, since a bare `git rev-parse --show-toplevel` inside a linked worktree returns the worktree path rather than the project's.
84
84
 
85
85
  `claude agents` lists every session on the machine with no path column and no per-project filter, so `<project>` in the name is the only thing left telling two fleets apart, and a session named off the worktree path instead would carry the branch folder rather than the project. Two projects each dispatching a bare `worker-page-driver` used to read as one row in that view.
86
86
 
@@ -15,7 +15,7 @@ The sweep's own question stays distinct from this one. It asks which parked row
15
15
 
16
16
  ## Scope
17
17
 
18
- Every row under `## Up next` and `## Needs a plan` in `.canon/tasks/priority.md`. A `## Run now` row carries no blocker by definition, so the pass skips it. Resolve the board and each task file at the main worktree root, per Worktrees in `CLAUDE.md`.
18
+ Every row under `## Up next` and `## Needs a plan` in `.canon/tasks/priority.md`. A `## Run now` row carries no blocker by definition, so the pass skips it. Resolve the board and each task file at the main worktree root, the way `claude-worktree` does.
19
19
 
20
20
  Take the rows in board order and finish one before opening the next. Clearing a row changes what the next row collides with, so a pass that measures every row first and writes afterwards writes against a board it has already invalidated.
21
21
 
@@ -36,7 +36,7 @@ Read these in parallel from the project root, skipping any that do not exist:
36
36
  - `CLAUDE.md`: project type, conventions, and commands
37
37
  - `.claude/REQUIREMENTS.md`: feature scope and non-goals
38
38
  - `.claude/ARCHITECTURE.md`: technical design decisions
39
- - `.canon/tasks/priority.md`: where this feature sits on the board and what each neighboring row waits on. Resolve this one at the main worktree root per Worktrees in `CLAUDE.md`, since the board is gitignored and a linked worktree holds no copy of it
39
+ - `.canon/tasks/priority.md`: where this feature sits on the board and what each neighboring row waits on. Resolve this one at the main worktree root the way `claude-worktree` does, since the board is gitignored and a linked worktree holds no copy of it
40
40
  - `.canon/plans/feature-<slug>.md` for the branch, when present: the intent the PR should satisfy
41
41
 
42
42
  Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
@@ -120,7 +120,7 @@ If nothing is wrong, use: `✅ No findings.`
120
120
 
121
121
  Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
122
122
 
123
- Write the full report directly to `.canon/review/branch/review-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist. Always overwrite.
123
+ Write the full report directly to `.canon/review/branch/review-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist. Always overwrite.
124
124
 
125
125
  From a linked worktree the file-editing tools refuse that path, so the report goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
126
126
 
@@ -12,7 +12,7 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` before writing any file. It
12
12
  ## Guards
13
13
 
14
14
  - Resolve the board at the main worktree root, not `pwd`. Run `git worktree list --porcelain | grep -m 1 '^worktree ' | cut -d' ' -f2-`, falling back to `pwd` outside a git repo. Every read and write below resolves against that root. The board is gitignored scratch shared across worktrees, so a linked worktree writing to its own `pwd` creates a second board nothing else reads.
15
- - From a linked worktree the file-editing tools refuse that root, so a new task file goes out through `Bash` as a plain single command carrying a heredoc. Archiving already runs through `canon tasks archive`, which resolves the root in-process. Marking an outcome shipped is `claude-docs` and runs through `canon tasks outcome`. See Worktrees in `CLAUDE.md`.
15
+ - From a linked worktree the file-editing tools refuse that root, so a new task file goes out through `Bash` as a plain single command carrying a heredoc. Archiving already runs through `canon tasks archive`, which resolves the root in-process. Marking an outcome shipped is `claude-docs` and runs through `canon tasks outcome`. Resolve that root the way `claude-worktree` does.
16
16
  - If `.canon/tasks/` does not exist at that root, stop: `❌ No .canon/tasks/ board. Run canon claude init to set it up.`
17
17
  - Route on the request rather than on a flag. Creating names work that does not exist yet, archiving names a task file already on the board. If the request fits neither, stop: `❌ Ambiguous. Say whether to create a task or archive one.`
18
18
  - Never hand-edit `.canon/tasks/index.md`. A hook regenerates it from sibling frontmatter after a write. Do not run the regen command directly, except after a shell write from a linked worktree: the hook matches `Write|Edit|MultiEdit` and nothing fires on `Bash`, so that one case regenerates explicitly with `canon indexes regen --no-stage --root <main-root> <main-root>/.canon/tasks/index.md`.
@@ -59,7 +59,7 @@ If all changes are automatable, skip the manual checklist:
59
59
 
60
60
  Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
61
61
 
62
- When a manual checklist is produced, write it directly to `.canon/review/ui-checklist-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist. Always overwrite.
62
+ When a manual checklist is produced, write it directly to `.canon/review/ui-checklist-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist. Always overwrite.
63
63
 
64
64
  From a linked worktree the file-editing tools refuse that path, so the checklist goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
65
65
 
@@ -59,7 +59,7 @@ If nothing is wrong, use: `✅ No observations.`
59
59
 
60
60
  Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
61
61
 
62
- Write the full report directly to `.canon/review/ux-audit-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist. Always overwrite.
62
+ Write the full report directly to `.canon/review/ux-audit-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist. Always overwrite.
63
63
 
64
64
  From a linked worktree the file-editing tools refuse that path, so the report goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
65
65
 
@@ -106,7 +106,7 @@ Report the reading and stop there. A remedy for a poor verdict is a change with
106
106
 
107
107
  Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
108
108
 
109
- Write the full reading directly to `.canon/review/ux-measure-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist. Always overwrite.
109
+ Write the full reading directly to `.canon/review/ux-measure-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist. Always overwrite.
110
110
 
111
111
  From a linked worktree the file-editing tools refuse that path, so the reading goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
112
112
 
@@ -23,7 +23,7 @@ Run in parallel:
23
23
  - `git branch --show-current 2>/dev/null || echo ""`
24
24
  - `git config --get core.bare 2>/dev/null || echo false`
25
25
 
26
- Plans always live at the main root, never inside a linked worktree. See Worktrees in `CLAUDE.md`.
26
+ Plans always live at the main root, never inside a linked worktree.
27
27
 
28
28
  `EnterWorktree` writes `core.bare = true` into the shared config, and nothing restores it, so the repository can already be broken before this session arrives. Repair it before entering when the value is `true` and `<main-root>/.git` is a directory:
29
29
 
@@ -145,7 +145,7 @@ canon tasks pull-request <number> --plan feature-<slug> --json
145
145
 
146
146
  The slug is a guess at which plan this branch carries rather than a fact about the task, which is why the verb re-checks it against the board and refuses instead of writing on a near miss. A branch whose slug names no plan file falls to the silent skip below, the same as one whose plan no task cites.
147
147
 
148
- The verb resolves the board at the main worktree root in-process, adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already carries, and corrects the number in place when the line exists. This is the route because the write is an edit inside an existing file, which the file-editing tools refuse from a linked worktree and which no shell stream editor may make. See Worktrees in `CLAUDE.md`.
148
+ The verb resolves the board at the main worktree root in-process, adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already carries, and corrects the number in place when the line exists. This is the route because the write is an edit inside an existing file, which the file-editing tools refuse from a linked worktree and which no shell stream editor may make. That root is the one `claude-worktree` resolves on entry.
149
149
 
150
150
  Skip this silently when the record is `ok: false` and `reason` is `no-board`, `no-match`, or `ambiguous`. Those are the three cases a guessed write would compound: no board, no task naming the plan, or more than one. One task, one pull request, and a wrong match archives the wrong task unattended once the branch merges. Report any other refusal rather than swallowing it.
151
151
 
@@ -7,7 +7,7 @@ description: Resumes a previous session by reading the handoff it left behind, t
7
7
 
8
8
  ## Step 1: read tracked work
9
9
 
10
- Resolve `.canon/plans/`, `.canon/memory/`, and `.canon/tasks/` at the main worktree root per Worktrees in `CLAUDE.md`.
10
+ Resolve `.canon/plans/`, `.canon/memory/`, and `.canon/tasks/` at the main worktree root the way `claude-worktree` does.
11
11
 
12
12
  Read these in parallel, skipping any that do not exist:
13
13
 
@@ -40,7 +40,7 @@ End with one line: `Start with: <first Up next item>` and note whether it has a
40
40
 
41
41
  When the board is empty and a handoff was found, name what the handoff leaves open instead: `Start with: <the open thread the handoff names>`. A recommendation slot filled with nothing reads as a failed run, and a handoff on an empty board is the shape a session leaves when it was reasoning rather than shipping.
42
42
 
43
- Do not offer to remove entries. A completed task is archived out of `.canon/tasks/` when work ships. The git log is the authoritative record of shipped work. Plan files are archived per the plan lifecycle rule in `CLAUDE.md`.
43
+ Do not offer to remove entries. A completed task is archived out of `.canon/tasks/` when work ships. The git log is the authoritative record of shipped work. Plan files are archived per the lifecycle rule in `${CLAUDE_SKILL_DIR}/../../standards/plan.md`.
44
44
 
45
45
  Memory is updated only when a recorded fact becomes wrong, never on resume. A domain fact reaches a session through `.claude/context/`, which `claude-memory-capture` routes to and the three-tier model loads on demand, so the memory folder read here is the residue no context entry owns.
46
46
 
@@ -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 carries a phase label, sorting a release pull request's tokens as semver rather than as a leak (`--event`, `--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`) |
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`) |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.15.0",
4
+ "version": "4.16.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The citation shape that sends a reader to a named section of the consuming
3
+ * project's own `CLAUDE.md`.
4
+ *
5
+ * A shipped body reaches a target through the plugin cache, and no `CLAUDE.md`
6
+ * in this repository or in any known target carries the section these lines
7
+ * named. The pointer resolves to nothing, which a session answers by guessing
8
+ * the rule or dropping it, so the shape is banned rather than repaired one
9
+ * body at a time.
10
+ *
11
+ * It is a prose pattern rather than a resolution, because no check can read a
12
+ * target's own root file. A `See` or a `per`, then anything up to a period,
13
+ * then ` in ` and a backticked `CLAUDE.md`. The two verbs are what separate a
14
+ * citation from a mention: dropping them returns five extra lines that name
15
+ * the file without pointing into it, and the period is what stops the match at
16
+ * a sentence boundary so a body discussing the file two sentences earlier does
17
+ * not fail.
18
+ *
19
+ * The period is the only boundary, and excluding a backtick alongside it was
20
+ * the first shape. It reads as a tighter bound and is a hole, because
21
+ * `markdown.md` has a body backtick a named thing, so the natural spelling of
22
+ * the citation is `` See the `Parallel sessions` heading in `CLAUDE.md` `` and
23
+ * a class stopping at the first backtick never reaches the file. That heading
24
+ * is real in this repository's own root file. Both classes returned the same
25
+ * twenty lines over `claude/skills/` at the stamp, so the backtick exclusion
26
+ * was carrying nothing and hiding the spelling an author would reach for.
27
+ *
28
+ * Both verbs match in either case. The measured corpus spelled them `See` and
29
+ * `per` and the insensitive form returned the identical twenty hits with no
30
+ * false positive, so it costs nothing and catches a sentence-initial `Per`.
31
+ *
32
+ * A later body phrasing a legitimate reference this way fails the check and
33
+ * changes its phrasing. There is no exemption marker, since the point is that
34
+ * the target does not resolve for the reader who holds the body.
35
+ */
36
+ const HEADING_CITATION = /\b(?:see|per)\b[^.]* in `CLAUDE\.md`/i
37
+
38
+ export interface HeadingCitation {
39
+ readonly file: string
40
+ /** One-based, matching the `file:line` form a reader clicks. */
41
+ readonly line: number
42
+ /** The whole line, so a report names what has to be rephrased. */
43
+ readonly text: string
44
+ }
45
+
46
+ /**
47
+ * Every line in one body carrying the banned citation.
48
+ *
49
+ * The unit is the line rather than the match, since a report names a line for
50
+ * rephrasing and two hits on one line would arrive as identical entries.
51
+ *
52
+ * The corpus walk is deliberately absent. Keeping the shape separate from the
53
+ * tree read is what lets the pattern be tested against a string rather than
54
+ * against a fixture, the way `citationsIn` splits the same seam in
55
+ * `skills-reach.ts`.
56
+ */
57
+ export function headingCitationsIn(
58
+ file: string,
59
+ text: string,
60
+ ): HeadingCitation[] {
61
+ const citations: HeadingCitation[] = []
62
+
63
+ for (const [index, line] of text.split('\n').entries()) {
64
+ if (!HEADING_CITATION.test(line)) continue
65
+
66
+ citations.push({ file, line: index + 1, text: line })
67
+ }
68
+
69
+ return citations
70
+ }
@@ -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',
93
+ 'Fail a pull request whose title or body carries a phase label or a board identifier',
94
94
  )
95
95
  .helpOption('-h, --help', 'Show this help message')
96
96
  .option(
@@ -112,10 +112,16 @@ export function register(program: Command): void {
112
112
  'other pull request may carry neither, so any token found there is a',
113
113
  'leaked phase label.',
114
114
  '',
115
+ 'It reports a board identifier beside that, being text naming the task',
116
+ 'board rather than the change: a version token a code span quotes, and a',
117
+ 'path under a record root, both of which a reader on the remote holds no',
118
+ 'copy of. A path under a tracked folder is left alone, so a rule or a',
119
+ 'skill any clone resolves is not reported.',
120
+ '',
115
121
  'Exit codes:',
116
- ' 0 no phase label found',
122
+ ' 0 no phase label and no board identifier found',
117
123
  ' 1 refused, with the reason on stderr or in the JSON record',
118
- ' 2 the title or body carries a phase label',
124
+ ' 2 the title or body carries a phase label or a board identifier',
119
125
  '',
120
126
  'Examples:',
121
127
  ' canon labels scan --event "$GITHUB_EVENT_PATH"',
@@ -345,6 +351,18 @@ async function runScan(opts: ScanOptions): Promise<number> {
345
351
  for (const label of result.phaseLabels) logWarn(label)
346
352
  }
347
353
 
354
+ logStep(
355
+ result.boardReferences.length === 0 ? 'Clean' : 'Board identifier found',
356
+ )
357
+ if (result.boardReferences.length === 0) {
358
+ logInfo('no quoted label or record path in the title or body')
359
+ } else {
360
+ logWarn(
361
+ `${plural(result.boardReferences.length, 'board identifier')} in the title or body. Name what a reader on the remote can open, since a record path is gitignored there and a quoted label reads as one only from the board.`,
362
+ )
363
+ for (const reference of result.boardReferences) logWarn(reference)
364
+ }
365
+
348
366
  outro()
349
367
 
350
368
  if (emitJson) {
@@ -353,9 +371,12 @@ async function runScan(opts: ScanOptions): Promise<number> {
353
371
  cutsRelease: result.cutsRelease,
354
372
  phaseLabels: result.phaseLabels,
355
373
  semverTags: result.semverTags,
374
+ boardReferences: result.boardReferences,
356
375
  })}\n`,
357
376
  )
358
377
  }
359
378
 
360
- return result.phaseLabels.length === 0 ? 0 : 2
379
+ return result.phaseLabels.length === 0 && result.boardReferences.length === 0
380
+ ? 0
381
+ : 2
361
382
  }
@@ -1,4 +1,5 @@
1
1
  import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
2
+ import { RECORD_ENTRIES, RECORD_ROOTS, spell } from '@/record-root'
2
3
 
3
4
  /**
4
5
  * The two version namespaces `standards/versioning.md` keeps apart, and why a
@@ -22,10 +23,81 @@ export interface PhaseScanResult {
22
23
  readonly cutsRelease: boolean
23
24
  readonly phaseLabels: readonly string[]
24
25
  readonly semverTags: readonly string[]
26
+ /**
27
+ * Text naming the board rather than the change: a version token a code span
28
+ * quotes, and a path under a record root.
29
+ *
30
+ * It sits beside the two namespaces rather than inside either, because a
31
+ * record path is version-shaped in neither and a quoted token is one the
32
+ * reading below has already declined to sort. Both are one defect at the
33
+ * destination, which is a reader on a remote holding neither the task board
34
+ * nor the gitignored folder a path names.
35
+ */
36
+ readonly boardReferences: readonly string[]
25
37
  }
26
38
 
27
39
  const VERSION_TOKEN = /\bv\d+(?:\.\d+){1,2}\b/g
28
40
 
41
+ /**
42
+ * A code span holding a version token and nothing else.
43
+ *
44
+ * The closing delimiter refers back to the opening one, so a span opened on two
45
+ * backticks closes on two, which is the rule `maskCodeSpans` reads a span by.
46
+ * Content is the token alone rather than a token found inside longer content,
47
+ * which is the whole of what separates a quoted phase label from `#1208`.
48
+ */
49
+ const VERSION_SPAN = /(`+)(v\d+(?:\.\d+){1,2})\1/g
50
+
51
+ /** Sentence punctuation a path picks up at the end of a clause. */
52
+ const TRAILING_PUNCTUATION = /[.,;]+$/
53
+
54
+ /** Escapes a literal so it can sit inside a constructed pattern. */
55
+ function escapeLiteral(text: string): string {
56
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
+ }
58
+
59
+ /**
60
+ * What is ignored under a root beyond the entries the record move relocated.
61
+ *
62
+ * `RECORD_ENTRIES` answers which folders that move carried across, and this
63
+ * check asks which paths a reader on a remote cannot open. The two questions
64
+ * differ by exactly one entry: the worktrees folder is ignored and stays out of
65
+ * that list deliberately, since the harness creates a worktree there and
66
+ * requires its target to sit there, so adding it upstream would tell the
67
+ * migration to relocate a folder the harness pins.
68
+ *
69
+ * It is also the entry a worker announcement names most often, which is what
70
+ * makes the gap a live class rather than a theoretical one.
71
+ */
72
+ const IGNORED_BEYOND_RECORDS: readonly string[] = ['worktrees']
73
+
74
+ /**
75
+ * A path a reader on a remote cannot open, which is a root plus one of the
76
+ * entries that root ignores rather than the root alone.
77
+ *
78
+ * `.claude/` is tracked and holds `rules`, `skills`, `hooks`, and `context`, so
79
+ * a rule path resolves in any clone and is not a board reference. The scratch
80
+ * folder goes through `spell` because it is the one entry whose name differs by
81
+ * root. Reading the roots and the relocated entries from `src/record-root.ts`
82
+ * is what makes a folder added there matched here without an edit, and the list
83
+ * above is what covers the one thing that module deliberately does not carry.
84
+ *
85
+ * The tail runs to the first whitespace or closing delimiter, so a report names
86
+ * the whole path an author has to remove rather than the prefix that matched.
87
+ */
88
+ const RECORD_PATH = new RegExp(
89
+ `(?<![\\w./-])(?:${RECORD_ROOTS.map(
90
+ (root) =>
91
+ `${escapeLiteral(root)}/(?:${[
92
+ ...RECORD_ENTRIES,
93
+ ...IGNORED_BEYOND_RECORDS,
94
+ ]
95
+ .map((entry) => escapeLiteral(spell(root, entry)))
96
+ .join('|')})`,
97
+ ).join('|')})(?![\\w-])[^\\s\`)\\]]*`,
98
+ 'g',
99
+ )
100
+
29
101
  /**
30
102
  * The head branch release-please opens every release pull request under.
31
103
  *
@@ -53,6 +125,15 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
53
125
  )
54
126
  }
55
127
 
128
+ /** Replaces a version span's delimiters with spaces, holding the line's width. */
129
+ function unquoteVersionSpans(line: string): string {
130
+ return line.replace(
131
+ VERSION_SPAN,
132
+ (_span, ticks: string, token: string) =>
133
+ `${' '.repeat(ticks.length)}${token}${' '.repeat(ticks.length)}`,
134
+ )
135
+ }
136
+
56
137
  /**
57
138
  * Drops a fenced block outright and blanks a code span inside what remains,
58
139
  * so a token quoted rather than written is read the way a reader reads it:
@@ -69,9 +150,34 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
69
150
  * compare link's URL, and masking it would empty `semverTags` on the one
70
151
  * pull request this check exists to pass, trading the corpus's one code-span
71
152
  * leak for a hole in every release.
153
+ *
154
+ * `keepVersionSpans` widens that reading for the board-reference pass alone. A
155
+ * span whose whole content is a version token survives it, which is the shape
156
+ * the leak reached the remote through, and longer content stays blanked, which
157
+ * is what holds `#1208` closed. Two passes over one source rather than one pass
158
+ * sorting its own output, because a token is a board reference by virtue of the
159
+ * span it came out of and nothing downstream of the match can see that.
72
160
  */
73
- function readable(text: string): string {
74
- return linesOutsideFences(text).map(maskCodeSpans).join('\n')
161
+ function readable(text: string, keepVersionSpans = false): string {
162
+ return linesOutsideFences(text)
163
+ .map((line) =>
164
+ maskCodeSpans(keepVersionSpans ? unquoteVersionSpans(line) : line),
165
+ )
166
+ .join('\n')
167
+ }
168
+
169
+ function versionTokens(text: string): string[] {
170
+ return [...new Set(text.match(VERSION_TOKEN) ?? [])]
171
+ }
172
+
173
+ function recordPaths(text: string): string[] {
174
+ return [
175
+ ...new Set(
176
+ (text.match(RECORD_PATH) ?? []).map((path) =>
177
+ path.replace(TRAILING_PUNCTUATION, ''),
178
+ ),
179
+ ),
180
+ ]
75
181
  }
76
182
 
77
183
  /**
@@ -83,13 +189,42 @@ function readable(text: string): string {
83
189
  * legitimately carries, and every other pull request's tokens are read as
84
190
  * leaked phase labels, which is what `standards/versioning.md` names the
85
191
  * defect this exists to catch.
192
+ *
193
+ * A release pull request reports no board reference either, and the ground is
194
+ * coverage rather than exemption. Release-please generates that body from
195
+ * merged history, and every commit in that history came through a pull request
196
+ * this same check already scanned, so a board reference cannot reach a release
197
+ * body without passing the gate on its own. That its author has nothing to
198
+ * rewrite is true as well and is the weaker half, since it would leave the
199
+ * reference standing and unresolvable.
86
200
  */
87
201
  export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
88
- const text = readable(`${input.title}\n${input.body}`)
89
- const tokens = [...new Set(text.match(VERSION_TOKEN) ?? [])]
202
+ const source = `${input.title}\n${input.body}`
203
+ const tokens = versionTokens(readable(source))
90
204
  const cutsRelease = isReleasePullRequest(input)
91
205
 
92
- return cutsRelease
93
- ? { cutsRelease, phaseLabels: [], semverTags: tokens }
94
- : { cutsRelease, phaseLabels: tokens, semverTags: [] }
206
+ if (cutsRelease) {
207
+ return {
208
+ cutsRelease,
209
+ phaseLabels: [],
210
+ semverTags: tokens,
211
+ boardReferences: [],
212
+ }
213
+ }
214
+
215
+ // Dropped where the same token is also written bare, since the phase-label
216
+ // half already names it and reporting it twice asks for one removal twice.
217
+ const quoted = versionTokens(readable(source, true)).filter(
218
+ (token) => !tokens.includes(token),
219
+ )
220
+
221
+ return {
222
+ cutsRelease,
223
+ phaseLabels: tokens,
224
+ semverTags: [],
225
+ boardReferences: [
226
+ ...quoted,
227
+ ...recordPaths(linesOutsideFences(source).join('\n')),
228
+ ],
229
+ }
95
230
  }
package/standards/pr.md CHANGED
@@ -14,6 +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
18
  - Voice, rhythm, and sentence construction in pull request prose: the `write-human` skill
18
19
  - Punctuation, formatting, and banned words in pull request prose: `markdown.md`
19
20
 
@@ -22,6 +22,8 @@ Wherever text leaves through a channel no automated check covers, the author is
22
22
 
23
23
  Run the scan as an explicit step against the finished text. Having read the underlying rules before drafting does not cover it, because the check has to happen after the text exists.
24
24
 
25
+ Scope every check below by destination. Text published to a remote takes all of them. Text staying in the repository takes the character bans alone, since those hold wherever the text lands, and skips whichever check depends on the reader holding something only this checkout carries. The two checks below that depend on it say so under their own headings.
26
+
25
27
  ## Banned characters
26
28
 
27
29
  `markdown.md` holds the character bans and the banned words alike. Read it at scan time rather than working the sets from memory, then scan the drafted text and rewrite each occurrence.
@@ -32,7 +34,17 @@ Restructure the sentence rather than substituting the character. A semicolon swa
32
34
 
33
35
  `versioning.md` holds the label rule and the table of surfaces. Read it at scan time rather than working the format from memory.
34
36
 
35
- Scope this check by destination. Text published to a remote takes it. Text scanned on its way into the repository, where the reader has the task board, skips it.
37
+ This check is one of the two the destination rule above scopes. The reader inside the repository has the task board and the reader on a remote does not.
38
+
39
+ ## Board identifiers
40
+
41
+ A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
42
+
43
+ Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open. Under a tracked folder there is no hit, since `.claude/rules/core/005-behavior.md` resolves everywhere.
44
+
45
+ Rewrite a hit to name what the reader can reach rather than deleting it. A row's subject stated plainly replaces its label, and what a record folder holds, said in a sentence, replaces its path.
46
+
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.
36
48
 
37
49
  ## Cross-reference form
38
50