@erclx/canon 4.29.0 → 4.29.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.29.0",
4
+ "version": "4.29.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -249,8 +249,10 @@ over a plan nobody has written yet.
249
249
 
250
250
  What a planning dispatch owes instead is the reverse reading, because the plan
251
251
  it produces carries a constraint per track in flight and a row planned during a
252
- wave is planned against a tree that wave is changing. `claude-planner` states that read as a command over open pull
253
- requests, which is why the brief carries no branch list for it.
252
+ wave is planned against a tree that wave is changing. `claude-planner` composes
253
+ the session roster with the pull request list for that read rather than reading
254
+ pull requests alone, which is why the brief carries no branch list for it: the
255
+ planner takes this reading itself either way.
254
256
 
255
257
  One row per dispatch. A session reused across a batch pays the context load once
256
258
  and ages its picture of the tree while it works, which is what puts the in-flight
@@ -50,7 +50,7 @@ four carried at least one, caught only because the planner ran
50
50
  ## Must
51
51
 
52
52
  - Assert what a planning session is, what it may not write, and how long the role lasts, since `claude-feature` carries the procedure and no body carries the role
53
- - State the in-flight read as a command over open pull requests, and say that a branch and a worktree are not evidence, since a count of either reported merged work as live
53
+ - State the in-flight read as a command composing the session roster with open pull requests, and say that a branch and a worktree are not evidence, since a count of either reported merged work as live
54
54
  - Run that read once per task rather than once per batch, since a reused session ages its picture of the tree while it works
55
55
  - Name every read a plan needed and a launch string did not carry, the task file's findings and the source files among them, since a count quoted from a row was wrong or stale in ten places across four plans
56
56
  - Name the validate and audit calls the written plan passes through, since nothing else opens a plan and the audit caught ban hits in three of four trial plans
@@ -73,7 +73,7 @@ four carried at least one, caught only because the planner ran
73
73
  ## Guards
74
74
 
75
75
  - Task file does not resolve from where the session stands: report it unreadable and name the main-root path, rather than reporting the row as absent
76
- - A read of what is in flight returns nothing: report that no pull request is open rather than falling back to a branch or worktree count
76
+ - A read of what is in flight returns nothing: report that no pull request is open and no live session holds another branch, rather than falling back to a raw branch or worktree count
77
77
  - Launch named no controlling session and an operator is present: ask which row to address rather than inferring
78
78
  - Launch named no controlling session and nobody is present: infer from the sessions holding no feature branch, never from a name prefix, and say the addressee was inferred
79
79
 
@@ -39,8 +39,8 @@ Each item below is something a plan needed and a launch string did not carry.
39
39
 
40
40
  ## Read what is in flight rather than inferring it
41
41
 
42
- - Run `gh pr list --json number,headRefName`, then `git diff --name-only main...<headRefName>` per open pull request, and take every in-flight file set from that pair.
43
- - A branch is not evidence and neither is a worktree. This repository squash-merges and leaves both behind, so a count of either reports merged work as live. One trial named four branches as in flight and all four had merged.
42
+ - Run `canon sessions list --json`, take the `branch` field of every session whose `repository` matches this one and whose branch is neither null nor the trunk branch, union that with every `headRefName` from `gh pr list --json number,headRefName`, dedupe by branch name, then diff each with `git diff --name-only main...<branch>` for its file set. That resolves locally whether or not a pull request exists, since every track in this repository shares one git directory across its worktrees.
43
+ - A bare branch or worktree with neither a live session nor a pull request behind it is still not evidence, since this repository squash-merges and leaves both behind. The roster read is already filtered to live sessions, so what changed is the second source composed with it rather than a raw count. It still misses a live session that reached its worktree through the direct-path fallback rather than `EnterWorktree`, since that session's registered branch never moves off the trunk.
44
44
  - Run the read once per task rather than once per batch. A session planning several rows ages its picture of the tree while it works, and this read is what dates it.
45
45
  - Name each in-flight set as a constraint, say which act it forbids, and stamp the block with the commit the tree was read at, per Constraints in the plan standard. A bare path list leaves the worker guessing.
46
46
 
@@ -53,17 +53,17 @@ The cut used to decide whether a span was read at all, and the paths past the co
53
53
 
54
54
  A span anywhere in the bullet has to survive all of these:
55
55
 
56
- | Rule | What it keeps out |
57
- | ------------------------------------------------- | ------------------------------------------------------ |
58
- | No whitespace, `<`, `>`, `$`, `*`, `\|`, `?`, `^` | A backticked command, a placeholder, a glob, a pattern |
59
- | No `://` and no leading `/`, `~`, `@`, `#`, `!` | A URL, an absolute path, a module alias |
60
- | Contains `/` | A bare filename with no folder around it |
61
- | Not a dotted-decimal segment, or ends with `/` | A dotted number such as an address |
62
- | Not a single top-level folder | `src/`, which nobody claims to have rewritten whole |
63
- | A `file:line` span leads its bullet | A citation into a file the bullet is describing |
64
- | The region carries no no-change marker | A bullet recording a file it deliberately left alone |
65
-
66
- The last three rows earn their place from the corpus rather than from a rule. Every body that spelled a lone top-level folder was naming where something lives, and a line citation that follows another claim in the same bullet points into the file being described rather than naming a second one. A line citation leading its bullet stays a claim, which is how a body names the exact line it rewrote.
56
+ | Rule | What it keeps out |
57
+ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
58
+ | No whitespace, `<`, `>`, `$`, `*`, `\|`, `?`, `^` | A backticked command, a placeholder, a glob, a pattern |
59
+ | No `://` and no leading `/`, `~`, `@`, `#`, `!` | A URL, an absolute path, a module alias |
60
+ | Contains `/` | A bare filename with no folder around it |
61
+ | Not a dotted-decimal segment, or ends with `/` | A dotted number such as an address |
62
+ | Not a single top-level folder | `src/`, which nobody claims to have rewritten whole |
63
+ | A `file:line` span leads its bullet, or the bullet carries no bare line-only companion | A citation into a file the bullet already named, such as `verify.sh:634` beside `:642` |
64
+ | The region carries no no-change marker | A bullet recording a file it deliberately left alone |
65
+
66
+ The last three rows earn their place from the corpus rather than from a rule. Every body that spelled a lone top-level folder was naming where something lives, and a line citation that follows another claim in the same bullet points into the file being described only when a bare line-only span sits beside it, such as `:642` following `verify.sh:634`. With no such companion the citation names a second, genuinely distinct file and stays a claim. A line citation leading its bullet stays a claim either way, which is how a body names the exact line it rewrote.
67
67
 
68
68
  The no-change marker is the one rule the region cut cannot substitute for. A body writes "Leave `x` untouched, since the decision keeps it" to record a change it declined, and the path sits ahead of the first comma, so a stricter cut would not reach it and a looser one would find more. Since `keep` and `leave` each open a real claim often enough, the marker decides it rather than the leading verb. Three words carry the set: `untouched`, `unchanged`, and `as written`. `in place` was measured and dropped, because rewriting a file in place is an ordinary claim, and `no other line` was dropped because a correct bullet writes "as one insertion that touches no other line" about a change it did make. `alone` shipped in the set too, until review found every corpus occurrence sitting past the first comma, where the cut already excludes it, so the word voided no true claim there. Restrictive use is the more common one in this repository's own prose, and a comma-free bullet exposed it: "Move the threshold read into `src/gate/stages.ts` alone" asserts an edit, and the marker voided it while the word was still in the set. Over the 40-pull-request corpus the rule still voids no true claim.
69
69
 
@@ -77,7 +77,7 @@ start, and only a count of the defect's extent decides how big it is.
77
77
 
78
78
  One feature travels this path end to end.
79
79
 
80
- 1. The next feature is planned with `claude-feature`, writing a plan to `.canon/plans/`. The orchestrator runs it warm when the row turns on a contract other features consume or a shared wiring seam, and dispatches a planner under `claude-planner` otherwise. A cold planner measures the row against the tree rather than trusting what the row claims, and it reads what is in flight from open pull requests rather than from branches and worktrees, which this repository leaves behind after a squash merge.
80
+ 1. The next feature is planned with `claude-feature`, writing a plan to `.canon/plans/`. The orchestrator runs it warm when the row turns on a contract other features consume or a shared wiring seam, and dispatches a planner under `claude-planner` otherwise. A cold planner measures the row against the tree rather than trusting what the row claims, and it reads what is in flight by composing the live session roster with open pull requests. A bare branch or worktree is not evidence on its own, since this repository leaves both behind after a squash merge.
81
81
  2. Orchestrator checks the plan waits on nobody, checks the branch is unclaimed, and checks the plan's file set is disjoint from every track in flight, then dispatches a background worker with `claude --bg` against the plan, naming the branch and the model on the launch rather than leaving the worker to derive either. No count caps how many run at once. The branch travels as the argument to the worker's own worktree call, which is the one place the name is read rather than inferred. It falls back to naming the invocation for a human to run through `claude-worktree` and `claude-autoship` when the plan still waits on an answer only the operator can give, the check refuses, the sets overlap, or a stated reason serializes the plan behind a track already in flight. Either way, the worker enters its own worktree, builds, self-checks, opens a PR, and stops at the PR boundary.
82
82
  3. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
83
83
  4. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target then with `canon sessions list --branch` and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches too, since the grade runs low often enough that a floor at should-fix loses fixes a worker would have made. `claude-pr-review` states that threshold and the heading follows it, so an open heading is itself the signal to send.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.29.0",
4
+ "version": "4.29.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/pr/paths.ts CHANGED
@@ -54,9 +54,6 @@ export type KeyChangeRead =
54
54
  /** The longest bullet a claim carries forward, matching the citation sweep. */
55
55
  const PREVIEW_LIMIT = 200
56
56
 
57
- /** A backticked span, the only carrier this corpus writes a path in. */
58
- const BACKTICKED = /`([^`\n]+)`/g
59
-
60
57
  /** A list item at any indent, in either bullet spelling or as an ordinal. */
61
58
  const BULLET = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/
62
59
 
@@ -70,6 +67,15 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
70
67
  */
71
68
  const LINE_SUFFIX = /:\d+(?:-\d+)?$/
72
69
 
70
+ /**
71
+ * A span that carries a line suffix and nothing else, such as `:642` beside
72
+ * an earlier `verify.sh:634`. Its presence is what tells the citation guard
73
+ * below the bullet is describing lines inside one already-claimed file rather
74
+ * than naming a second file, since a genuinely distinct file never gets cited
75
+ * by a bare line number with nothing in front of the colon.
76
+ */
77
+ const BARE_LINE = /^:\d+(?:-\d+)?$/
78
+
73
79
  /**
74
80
  * A character that puts the span outside a path this comparison resolves.
75
81
  *
@@ -108,9 +114,73 @@ function isDottedNumber(span: string): boolean {
108
114
  return /^\d+(?:\.\d+)+$/.test(segment)
109
115
  }
110
116
 
117
+ /** One code span, paired by matching backtick-run length rather than count. */
118
+ interface BacktickSpan {
119
+ /** Index of the opening run's first backtick. */
120
+ readonly start: number
121
+ /** Index one past the closing run's last backtick. */
122
+ readonly end: number
123
+ /** The text between the two runs, delimiters excluded. */
124
+ readonly content: string
125
+ }
126
+
127
+ /**
128
+ * Every code span in a bullet, pairing a run of backticks only with the next
129
+ * run of the same length.
130
+ *
131
+ * A single-backtick regex reads a doubled delimiter, such as
132
+ * ``` ``git status`` ```, as two unrelated single backticks: it opens on the
133
+ * second backtick of the pair, closes on the first backtick of the closing
134
+ * pair, and leaves one backtick before and after the span unconsumed. Every
135
+ * scan after that treats a stray leftover backtick as an opener, which
136
+ * absorbs the next real span's opening delimiter as its closer and drops the
137
+ * path inside past it. Pairing by run length rather than by single backtick
138
+ * keeps a doubled delimiter closed by a doubled delimiter, so nothing after
139
+ * it loses its pairing.
140
+ *
141
+ * An opening run with no same-length run after it is not a delimiter, per
142
+ * CommonMark, so it is left as literal text and the scan resumes at the next
143
+ * run rather than backtracking into the unmatched one.
144
+ */
145
+ function findBacktickSpans(text: string): BacktickSpan[] {
146
+ const runs = [...text.matchAll(/`+/g)].map((match) => ({
147
+ start: match.index ?? 0,
148
+ length: match[0].length,
149
+ }))
150
+
151
+ const spans: BacktickSpan[] = []
152
+ let i = 0
153
+ while (i < runs.length) {
154
+ const open = runs[i]
155
+ const closeIndex = runs.findIndex(
156
+ (run, index) => index > i && run.length === open.length,
157
+ )
158
+ if (closeIndex === -1) {
159
+ i += 1
160
+ continue
161
+ }
162
+ const close = runs[closeIndex]
163
+ spans.push({
164
+ start: open.start,
165
+ end: close.start + close.length,
166
+ content: text.slice(open.start + open.length, close.start),
167
+ })
168
+ i = closeIndex + 1
169
+ }
170
+
171
+ return spans
172
+ }
173
+
111
174
  /** Blanks every backticked span so a cue search never fires inside one. */
112
175
  function maskSpans(text: string): string {
113
- return text.replace(/`[^`\n]*`/g, (span) => ' '.repeat(span.length))
176
+ let out = text
177
+ for (const span of findBacktickSpans(text)) {
178
+ out =
179
+ out.slice(0, span.start) +
180
+ ' '.repeat(span.end - span.start) +
181
+ out.slice(span.end)
182
+ }
183
+ return out
114
184
  }
115
185
 
116
186
  /**
@@ -329,10 +399,20 @@ export function extractKeyChangePaths(
329
399
  if (disclaimsChange(trimmed)) continue
330
400
 
331
401
  const accusesBefore = claimRegion(trimmed).length
402
+ const spans = findBacktickSpans(trimmed)
403
+ // Whether a `file:line` span citing an already-claimed file is a second
404
+ // claim or a citation into the file just claimed turns on this: a bullet
405
+ // that also carries a bare `:642`-shaped span alongside it is describing
406
+ // two lines in one file, where a bullet with no such companion is naming
407
+ // a second, genuinely distinct file. Computed once, up front, so the
408
+ // guard below reads it rather than scanning again for every span.
409
+ const hasBareLineCompanion = spans.some((span) =>
410
+ BARE_LINE.test(span.content),
411
+ )
332
412
  let claimed = false
333
413
 
334
- for (const match of trimmed.matchAll(BACKTICKED)) {
335
- const span = match[1] ?? ''
414
+ for (const match of spans) {
415
+ const span = match.content
336
416
  const bare = span.replace(LINE_SUFFIX, '')
337
417
 
338
418
  // A `file:line` span following another claim in the same bullet is a
@@ -342,7 +422,7 @@ export function extractKeyChangePaths(
342
422
  // its bullet it is an ordinary claim, which is how a body names the exact
343
423
  // line it rewrote.
344
424
  const cited = bare !== span
345
- if (cited && claimed) continue
425
+ if (cited && claimed && hasBareLineCompanion) continue
346
426
 
347
427
  const resolved = resolveSpan(bare)
348
428
  if (resolved === undefined) continue
@@ -357,7 +437,7 @@ export function extractKeyChangePaths(
357
437
  path: resolved.path,
358
438
  directory: resolved.directory,
359
439
  anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
360
- leading: (match.index ?? 0) < accusesBefore,
440
+ leading: match.start < accusesBefore,
361
441
  span,
362
442
  bullet: index + 1,
363
443
  preview,