@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-docs/SKILL.md +2 -2
- package/claude/skills/claude-feature/SKILL.md +1 -1
- package/claude/skills/claude-markdown-propose/SKILL.md +1 -1
- package/claude/skills/claude-memory-capture/SKILL.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/claude/skills/claude-tasks/SKILL.md +1 -1
- package/claude/skills/claude-ui-test/SKILL.md +1 -1
- package/claude/skills/claude-ux-audit/SKILL.md +1 -1
- package/claude/skills/claude-ux-measure/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/session-resume/SKILL.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/package.json +1 -1
- package/src/claude/skills-headings.ts +70 -0
- package/src/commands/labels.ts +25 -4
- package/src/labels/phase.ts +142 -7
- package/standards/pr.md +1 -0
- package/standards/publish.md +13 -1
|
@@ -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.
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
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.
|
|
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`.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
+
}
|
package/src/commands/labels.ts
CHANGED
|
@@ -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
|
|
379
|
+
return result.phaseLabels.length === 0 && result.boardReferences.length === 0
|
|
380
|
+
? 0
|
|
381
|
+
: 2
|
|
361
382
|
}
|
package/src/labels/phase.ts
CHANGED
|
@@ -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)
|
|
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
|
|
89
|
-
const tokens =
|
|
202
|
+
const source = `${input.title}\n${input.body}`
|
|
203
|
+
const tokens = versionTokens(readable(source))
|
|
90
204
|
const cutsRelease = isReleasePullRequest(input)
|
|
91
205
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
package/standards/publish.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|