@erclx/canon 5.46.0 → 5.47.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.
Files changed (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/git-followup/SKILL.md +1 -0
  3. package/claude/skills/git-pr/REQUIREMENT.md +2 -2
  4. package/claude/skills/git-pr/SKILL.md +2 -1
  5. package/claude/skills/git-pr/references/pr.md +58 -7
  6. package/claude/skills/review-address/SKILL.md +18 -6
  7. package/claude/skills/review-address/references/post-review-findings.md +4 -1
  8. package/claude/skills/review-address/references/rebase-only.md +14 -2
  9. package/claude/skills/review-pr/SKILL.md +9 -2
  10. package/claude/skills/review-pr/references/body-variants.md +29 -8
  11. package/claude/skills/review-pr/references/post.md +29 -10
  12. package/claude/skills/review-ui/SKILL.md +1 -0
  13. package/claude/skills/review-ui/references/post.md +2 -0
  14. package/claude/skills/review-ui/references/tick.md +3 -0
  15. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  16. package/claude/skills/role-reviewer/SKILL.md +3 -0
  17. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  18. package/claude/skills/role-worker/SKILL.md +1 -0
  19. package/claude/skills/task-board/references/decline.md +2 -2
  20. package/claude/skills/task-complete/references/archive.md +3 -2
  21. package/claude/skills/ui-checklist/SKILL.md +3 -3
  22. package/docs/workflow/zshrc-aliases.md +3 -0
  23. package/package.json +1 -1
  24. package/scripts/core/bootstrap.sh +1 -0
  25. package/src/commands/labels.ts +66 -1
  26. package/src/commands/pr.ts +113 -4
  27. package/src/commands/sessions.ts +25 -11
  28. package/src/commands/tasks.ts +2 -0
  29. package/src/labels/density.ts +363 -0
  30. package/src/pr/evidence.ts +1 -1
  31. package/src/pr/rest.ts +2 -0
  32. package/src/pr/tick.ts +89 -0
  33. package/src/sessions/live.ts +26 -0
  34. package/src/sessions/resolve.ts +62 -11
  35. package/src/tasks/archive.ts +15 -4
  36. package/tooling/base/seeds/.github/pull_request_template.md +1 -1
@@ -17,6 +17,7 @@ A hand-written block without markers is detected through its `alias clp=` line,
17
17
  ```zsh
18
18
  # >>> canon aliases >>>
19
19
  TOOLKIT="/path/to/toolkit"
20
+ export CLAUDE_CODE_ENABLE_TODO_TOOLS=1
20
21
 
21
22
  alias cl='claude'
22
23
  alias clr='cl -r'
@@ -32,6 +33,8 @@ alias clpac='clpa --cwd .'
32
33
  # <<< canon aliases <<<
33
34
  ```
34
35
 
36
+ The export gives a session started from your terminal the task tools, which Claude Code reads before it starts, so workers and reviewers can show their checklist on `Ctrl+T`. A background session has them without it.
37
+
35
38
  To opt out, delete the block and run `bun install` and `bun link` yourself. Bash takes the block unchanged in `~/.bashrc`, pasted by hand. Bootstrap writes `~/.zshrc` alone.
36
39
 
37
40
  ## What each one does
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.46.0",
4
+ "version": "5.47.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -26,6 +26,7 @@ alias_block() {
26
26
  cat <<EOF
27
27
  $MARKER_OPEN
28
28
  TOOLKIT="$PROJECT_ROOT"
29
+ export CLAUDE_CODE_ENABLE_TODO_TOOLS=1
29
30
 
30
31
  alias cl='claude'
31
32
  alias clr='cl -r'
@@ -6,6 +6,12 @@ import { gitEnv } from '@/git/env'
6
6
  import { diffLabels } from '@/labels/apply'
7
7
  import { type LabelAuditRefusal, auditLabels } from '@/labels/audit'
8
8
  import { checkClosing } from '@/labels/closing'
9
+ import {
10
+ bodyDensity,
11
+ isPost,
12
+ POST_HEADINGS,
13
+ postDensity,
14
+ } from '@/labels/density'
9
15
  import { resolveScanInput } from '@/labels/event'
10
16
  import { checkTitleFormat, type TitleFormatIssue } from '@/labels/format'
11
17
  import { MAP_REL } from '@/labels/map'
@@ -241,7 +247,27 @@ export function register(program: Command): void {
241
247
  'It grades the body of an ordinary pull request against the closed',
242
248
  'section list: a level-two heading outside it, or a missing',
243
249
  '## Summary or ## Key changes, fails. A review, a release-please body,',
244
- 'and a scan carrying no title skip this check. It also fails a banned',
250
+ 'and a scan carrying no title skip this check.',
251
+ '',
252
+ 'It reports the density of that same body per section under `density`,',
253
+ 'without failing on it: a bullet past 200 characters, more than 5 plain',
254
+ 'bullets or 2 paragraphs in a row, a paragraph past 350 characters, any',
255
+ 'H3 or deeper heading, and Technical context mixing bullets with',
256
+ 'paragraphs. A bold label alone on a line splits a Key changes run, a',
257
+ 'bold-led paragraph starts a new Technical context run, and Testing',
258
+ 'takes the heading limit alone. Each finding carries its section, kind,',
259
+ 'line in the whole body, value, and limit.',
260
+ '',
261
+ 'A body the section check skips is graded as a post under the same',
262
+ '`density` key when its first level-two heading is one of',
263
+ `${POST_HEADINGS.map((heading) => `## ${heading}`).join(', ')}.`,
264
+ 'A post takes the same length and run limits, allows an H3, and',
265
+ 'measures a nested item as a bullet of its own. The footer, the',
266
+ 'marker, and a table never report, a block inside <details> takes the',
267
+ 'length limits alone, and a checklist box under ## What to look at',
268
+ 'takes no length limit. Post density never fails the scan.',
269
+ '',
270
+ 'It also fails a banned',
245
271
  'character, an em dash or a semicolon, in the title or body of a pull',
246
272
  'request or a review, outside a code span or a link.',
247
273
  '',
@@ -692,6 +718,44 @@ async function runScan(opts: ScanOptions): Promise<number> {
692
718
  }
693
719
  }
694
720
 
721
+ // Reported and never gated yet. New bodies come back clean under the
722
+ // shape in pr.md first, so an older open pull request fails no edit event.
723
+ // A post is never gated, since CI reads one only after it is published.
724
+ const post = !sectionsChecked && isPost(resolved.body)
725
+ const density = sectionsChecked
726
+ ? bodyDensity(resolved.body)
727
+ : post
728
+ ? postDensity(resolved.body)
729
+ : []
730
+
731
+ logStep(
732
+ !sectionsChecked && !post
733
+ ? 'Density not checked'
734
+ : density.length === 0
735
+ ? 'Clean'
736
+ : 'Density finding found',
737
+ )
738
+ if (!sectionsChecked && !post) {
739
+ logInfo(
740
+ 'only an ordinary pull request body or a post opening on a post heading is graded for density',
741
+ )
742
+ } else if (density.length === 0) {
743
+ logInfo('every section is within its length, run, and heading limits')
744
+ } else {
745
+ logWarn(
746
+ `${plural(density.length, 'density finding')} in the ${post ? 'post' : 'body'}. Regroup the section under bold labels or split the long item rather than cutting a path.`,
747
+ )
748
+ for (const finding of density) {
749
+ const measured =
750
+ finding.value === null
751
+ ? ''
752
+ : ` (${finding.value} over ${finding.limit})`
753
+ logWarn(
754
+ `line ${finding.line} in ${finding.section ?? 'the opening'}: ${finding.kind}${measured}`,
755
+ )
756
+ }
757
+ }
758
+
695
759
  const banFindings = [
696
760
  ...scanBans(
697
761
  [{ number: 1, text: resolved.title, fenced: false }],
@@ -781,6 +845,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
781
845
  sessionLinks: result.sessionLinks,
782
846
  titleFormatIssues,
783
847
  sections: { checked: sectionsChecked, ...sections },
848
+ density,
784
849
  bannedCharacters,
785
850
  missingClosures: closing.missing,
786
851
  strayClosures: closing.stray,
@@ -93,7 +93,13 @@ import {
93
93
  servesChange,
94
94
  type WorkflowFile,
95
95
  } from '@/pr/preview'
96
- import { type TickRefusal, tickBoxes } from '@/pr/tick'
96
+ import {
97
+ readUiPassHead,
98
+ type TickRefusal,
99
+ tickBoxes,
100
+ type UiPassSummary,
101
+ writeUiPassLine,
102
+ } from '@/pr/tick'
97
103
  import {
98
104
  commentRowOf,
99
105
  identityOf,
@@ -157,9 +163,16 @@ interface TickOptions extends ReadOptions {
157
163
  readonly head?: string
158
164
  }
159
165
 
160
- type TickReason = TickRefusal | 'stale-head' | 'bad-boxes' | 'no-comment'
166
+ type TickReason =
167
+ | TickRefusal
168
+ | 'stale-head'
169
+ | 'bad-boxes'
170
+ | 'no-comment'
171
+ | 'body-refused'
161
172
 
162
173
  const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
174
+ 'body-refused':
175
+ 'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
163
176
  'stale-head':
164
177
  'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
165
178
  'bad-boxes':
@@ -736,6 +749,11 @@ export function register(program: Command): void {
736
749
  'comment. Together with --checklist the link and the checklist land in',
737
750
  'one comment.',
738
751
  '',
752
+ "A render at a new head also resets the pull request body's UI pass",
753
+ 'line, the one `canon pr tick` writes under ## Testing, to unchecked',
754
+ 'naming that head. It touches no other body text, leaves a line already',
755
+ 'naming this head alone, and reports `uiPass` as reset or reset-refused.',
756
+ '',
739
757
  'Both reasons carry what the marked comment already posted, each field',
740
758
  'present only when that comment holds it:',
741
759
  ' preview the hosted **Preview:** address the comment carries',
@@ -1024,8 +1042,17 @@ export function register(program: Command): void {
1024
1042
  'it. A later render at another head clears a tick whose stamp names',
1025
1043
  'a different commit.',
1026
1044
  '',
1045
+ "It also keeps one marked line in the pull request body's ## Testing,",
1046
+ 'such as `- [x] UI pass, 17 of 17 boxes passed at <short sha>, 1 taste',
1047
+ 'box left to the operator`. The line is ticked only when every box a',
1048
+ 'driver can run is ticked, counts taste boxes apart, and is the only',
1049
+ 'body text this verb writes, so hand edits elsewhere survive.',
1050
+ '',
1027
1051
  'Read `reason` on the JSON record:',
1028
- ' ticked the boxes were ticked, with the tip in `head`',
1052
+ ' ticked the boxes were ticked and the body line written, with the',
1053
+ ' tip in `head`',
1054
+ ' body-refused the comment is ticked but gh could not edit the body,',
1055
+ ' exit 1 with `commentTicked`. Re-run to retry the body.',
1029
1056
  ' stale-head --head is not the remote branch tip, nothing written',
1030
1057
  ' taste-box a named box ends in (taste), nothing written',
1031
1058
  ' no-box a named number is past the checklist, nothing written',
@@ -2437,10 +2464,30 @@ async function runEvidence(
2437
2464
  return 1
2438
2465
  }
2439
2466
 
2467
+ // A UI pass line stamped at an earlier head describes a tree that is gone,
2468
+ // so a render at a new head puts it back to unchecked. A line already
2469
+ // naming this head is left alone, since each body edit reruns the label gate.
2470
+ const priorBody = identity.body ?? ''
2471
+ const stampedHead = readUiPassHead(priorBody)
2472
+ let uiPass: 'reset' | 'reset-refused' | undefined
2473
+ if (stampedHead !== undefined && !identity.head.startsWith(stampedHead)) {
2474
+ const reset = writeUiPassLine(priorBody, {
2475
+ kind: 'reset',
2476
+ head: identity.head,
2477
+ })
2478
+ uiPass = (await patchPullBody(root, identity.number, reset))
2479
+ ? 'reset'
2480
+ : 'reset-refused'
2481
+ }
2482
+
2440
2483
  logStep('Scope')
2441
2484
  logInfo(
2442
2485
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
2443
2486
  )
2487
+ if (uiPass === 'reset') logInfo('UI pass line reset to the new head')
2488
+ if (uiPass === 'reset-refused') {
2489
+ logWarn('gh could not edit the body, so its UI pass line names an old head')
2490
+ }
2444
2491
  if (sized.unread.length > 0) {
2445
2492
  logWarn(
2446
2493
  `width unread for ${plural(sized.unread.length, 'image')}, rendered at 1000: ${sized.unread.join(', ')}`,
@@ -2469,6 +2516,7 @@ async function runEvidence(
2469
2516
  body,
2470
2517
  states,
2471
2518
  ...(sized.unread.length > 0 && { widthUnread: sized.unread }),
2519
+ ...(uiPass !== undefined && { uiPass }),
2472
2520
  ...(commentId !== undefined && { commentId }),
2473
2521
  ...carried,
2474
2522
  })}\n`,
@@ -2941,9 +2989,53 @@ async function runTick(
2941
2989
  if (patched === null) return refuseTick('gh-failed')
2942
2990
  }
2943
2991
 
2992
+ const boxes = readChecklistBoxes(
2993
+ findEvidenceChecklist([{ body: result.body }]) ?? '',
2994
+ )
2995
+ const driven = boxes.filter((box) => !box.isTaste)
2996
+ const summary: UiPassSummary = {
2997
+ kind: 'passed',
2998
+ // A tick counts as this pass's only when stamped at the tip, since an
2999
+ // unstamped one is the author's claim and an older stamp names another head.
3000
+ passed: driven.filter(
3001
+ (box) =>
3002
+ box.isTicked &&
3003
+ box.stamp !== undefined &&
3004
+ reading.tip.startsWith(box.stamp),
3005
+ ).length,
3006
+ total: driven.length,
3007
+ taste: boxes.length - driven.length,
3008
+ head: reading.tip,
3009
+ }
3010
+ const priorBody = identity.body ?? ''
3011
+ const nextBody = writeUiPassLine(priorBody, summary)
3012
+ if (
3013
+ nextBody !== priorBody &&
3014
+ !(await patchPullBody(root, identity.number, nextBody))
3015
+ ) {
3016
+ logStep('Partly ticked')
3017
+ logWarn(TICK_REFUSALS['body-refused'])
3018
+ outro()
3019
+ if (emitJson) {
3020
+ process.stdout.write(
3021
+ `${JSON.stringify({
3022
+ root,
3023
+ number: identity.number,
3024
+ reason: 'body-refused',
3025
+ message: TICK_REFUSALS['body-refused'],
3026
+ commentTicked: true,
3027
+ head: reading.tip,
3028
+ boxes: numbers.map(Number),
3029
+ commentId,
3030
+ })}\n`,
3031
+ )
3032
+ }
3033
+ return 1
3034
+ }
3035
+
2944
3036
  logStep('Ticked')
2945
3037
  logInfo(
2946
- `${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}`,
3038
+ `${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}, UI pass line on the body`,
2947
3039
  )
2948
3040
  outro()
2949
3041
  if (emitJson) {
@@ -2961,6 +3053,23 @@ async function runTick(
2961
3053
  return 0
2962
3054
  }
2963
3055
 
3056
+ /** Replaces a pull request's body, which `gh` takes through the REST pulls endpoint a cloud proxy serves. */
3057
+ async function patchPullBody(
3058
+ root: string,
3059
+ number: number,
3060
+ body: string,
3061
+ ): Promise<boolean> {
3062
+ const patched = await gh(root, [
3063
+ 'api',
3064
+ '-X',
3065
+ 'PATCH',
3066
+ `repos/{owner}/{repo}/pulls/${number}`,
3067
+ '-f',
3068
+ `body=${body}`,
3069
+ ])
3070
+ return patched !== null
3071
+ }
3072
+
2964
3073
  /** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
2965
3074
  type ApiCall =
2966
3075
  | { readonly ok: true; readonly data: unknown }
@@ -3,6 +3,7 @@ import type { Command } from 'commander'
3
3
  import { checkClaim, type ClaimReport } from '@/sessions/claim'
4
4
  import {
5
5
  callerIdentity,
6
+ confidenceOf,
6
7
  repositoryOf,
7
8
  type ResolvedSession,
8
9
  resolveSessions,
@@ -134,15 +135,21 @@ export function register(program: Command): void {
134
135
  '',
135
136
  'The confidence field says how the liveness of a row was decided.',
136
137
  '"confirmed" matched the running process against the start time the',
137
- 'record stamped. "unverified" means only that the pid answers, which',
138
- 'cannot rule out a pid handed to an unrelated process, so a roster',
139
- 'reported that way is a candidate list rather than an identity.',
138
+ 'record stamped, and reads "via": "pid". "unverified" means only that the',
139
+ 'pid answers, which cannot rule out a pid handed to an unrelated',
140
+ 'process, so a roster reported that way is a candidate list rather than',
141
+ 'an identity. Where the start time cannot be read, a row whose messaging',
142
+ 'socket accepts a connection is confirmed instead and reads "via":',
143
+ '"listener". The probe connects and closes without writing. Each row',
144
+ 'carries its own "confidence", and the top-level field is the weakest of',
145
+ 'the rows listed, so --branch is not weakened by a row it dropped.',
140
146
  '',
141
147
  'A reader in another pid namespace, such as a sandboxed shell, cannot',
142
148
  'see the pid of a record stamped elsewhere. That row is judged on its',
143
149
  'messaging socket instead: it stays on the roster as "unverified" with',
144
150
  '"via": "socket", since a crashed session leaves its socket behind. A',
145
- 'foreign record whose socket is gone is left off the roster and',
151
+ 'foreign record whose socket accepts a connection is confirmed as',
152
+ '"listener". One whose socket is gone is left off the roster and',
146
153
  'counted in "foreignDead", so an empty roster beside a count above zero',
147
154
  'is one this reader could not check, not one whose sessions ended.',
148
155
  '',
@@ -471,7 +478,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
471
478
  : null
472
479
 
473
480
  intro('canon sessions list')
474
- reportConfidence(report)
481
+ reportConfidence(shown, report)
475
482
  reportSessions(
476
483
  shown,
477
484
  opts.branch,
@@ -486,7 +493,8 @@ async function runList(opts: ListCommandOptions): Promise<number> {
486
493
  process.stdout.write(
487
494
  `${JSON.stringify({
488
495
  dir: report.dir,
489
- confidence: report.confidence,
496
+ // Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
497
+ confidence: confidenceOf(shown),
490
498
  foreignDead: report.foreignDead,
491
499
  branch: opts.branch ?? null,
492
500
  repository,
@@ -528,6 +536,7 @@ function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
528
536
  * addresses a session directly and the second opens by asking to be corrected.
529
537
  */
530
538
  function reportConfidence(
539
+ shown: readonly ResolvedSession[],
531
540
  report: Extract<SessionReport, { kind: 'resolved' }>,
532
541
  ): void {
533
542
  logStep('Liveness')
@@ -535,23 +544,28 @@ function reportConfidence(
535
544
  // A roster of none decided nothing, so the confirmed line would claim a check
536
545
  // over rows that do not exist. The registry is never pruned and holds a record
537
546
  // per session ever run, which is what makes the empty result worth stating.
538
- if (report.sessions.length === 0) {
547
+ if (shown.length === 0) {
539
548
  logInfo(
540
- report.foreignDead > 0
549
+ report.foreignDead > 0 || report.sessions.length > 0
541
550
  ? 'No row to decide.'
542
551
  : 'No row to decide. Every record belongs to a session that has ended.',
543
552
  )
544
553
  return
545
554
  }
546
555
 
547
- if (report.confidence === 'confirmed') {
556
+ if (confidenceOf(shown) === 'confirmed') {
557
+ const listened = shown.filter(
558
+ (session) => session.via === 'listener',
559
+ ).length
548
560
  logInfo(
549
- 'Every row matched a running process against the start time its record stamped.',
561
+ listened === 0
562
+ ? 'Every row matched a running process against the start time its record stamped.'
563
+ : `Every row is confirmed, ${listened} of them because their messaging socket accepted a connection and the rest by start time.`,
550
564
  )
551
565
  return
552
566
  }
553
567
 
554
- if (report.sessions.some((session) => session.via === 'socket')) {
568
+ if (shown.some((session) => session.via === 'socket')) {
555
569
  logWarn(
556
570
  'This reader sits in another pid namespace than some records, so those rows rest on a messaging socket existing alone. A crashed session leaves its socket behind, so treat the mapping as inferred and open by asking to be corrected.',
557
571
  )
@@ -2195,6 +2195,7 @@ function report(
2195
2195
  logInfo('retargeted the Ready: line')
2196
2196
  }
2197
2197
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
2198
+ if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
2198
2199
  logRelinked(outcome.relinked, outcome.relinkFailed, root)
2199
2200
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
2200
2201
  if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
@@ -2277,6 +2278,7 @@ function recordFor(
2277
2278
  from: relative(root, outcome.from),
2278
2279
  to: relative(root, outcome.to),
2279
2280
  priorityRowRemoved: outcome.priorityRowRemoved,
2281
+ backlogRowRemoved: outcome.backlogRowRemoved,
2280
2282
  indexRegenerated: outcome.indexRegenerated,
2281
2283
  plans: planMovesFor(outcome.plans, root),
2282
2284
  ready: outcome.ready