@erclx/canon 5.47.0 → 5.48.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.
@@ -0,0 +1,175 @@
1
+ import type { ChecksGate } from '@/pr/lift'
2
+ import { readSection } from '@/pr/paths'
3
+ import { UI_PASS_MARKER } from '@/pr/tick'
4
+
5
+ /** Why the verb did not merge, in the order the refusals are read. */
6
+ export type MergeRefusal =
7
+ | 'pull-closed'
8
+ | 'pull-draft'
9
+ | 'missing-summary'
10
+ | 'missing-key-changes'
11
+ | 'ci-failing'
12
+ | 'ci-conflicted'
13
+ | 'ci-pending'
14
+ | 'head-moved'
15
+ | 'not-mergeable'
16
+ | 'gh-failed'
17
+
18
+ export interface MergePull {
19
+ readonly isOpen: boolean
20
+ readonly isDraft: boolean
21
+ readonly title: string
22
+ readonly body: string
23
+ /** The head the merge is pinned to, so a push after the read refuses. */
24
+ readonly head: string
25
+ }
26
+
27
+ export interface MergeRequest {
28
+ readonly title: string
29
+ readonly description: string
30
+ readonly sha: string
31
+ }
32
+
33
+ /** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
34
+ export type MergeOutcome =
35
+ | { readonly kind: 'merged'; readonly sha: string }
36
+ | { readonly kind: 'head-moved' }
37
+ | { readonly kind: 'not-mergeable' }
38
+ | { readonly kind: 'failed' }
39
+
40
+ /**
41
+ * Everything the verb reads or writes, injected so every refusal runs without
42
+ * a network. A read that failed returns undefined, and the verb refuses
43
+ * `gh-failed` rather than reading absence as a pass.
44
+ */
45
+ export interface MergeApi {
46
+ readPull(): Promise<MergePull | undefined>
47
+ readChecks(head: string): Promise<ChecksGate | undefined>
48
+ putMerge(request: MergeRequest): Promise<MergeOutcome>
49
+ }
50
+
51
+ export interface MergeOptions {
52
+ readonly number: number
53
+ readonly isDryRun: boolean
54
+ }
55
+
56
+ export interface MergeRecord {
57
+ readonly reason: 'merged' | 'dry-run' | MergeRefusal
58
+ readonly isOk: boolean
59
+ readonly number: number
60
+ readonly title?: string
61
+ readonly description?: string
62
+ /** The squash commit, present only on a merge. */
63
+ readonly sha?: string
64
+ }
65
+
66
+ export type Composed =
67
+ | { readonly kind: 'composed'; readonly description: string }
68
+ | {
69
+ readonly kind: 'refused'
70
+ readonly reason: 'missing-summary' | 'missing-key-changes'
71
+ }
72
+
73
+ /** A section's text with the UI pass line dropped, or undefined when nothing is left. */
74
+ function keptSection(body: string, title: string): string | undefined {
75
+ const text = readSection(body, title)
76
+ ?.split('\n')
77
+ .filter((line) => !line.includes(UI_PASS_MARKER))
78
+ .join('\n')
79
+ .trim()
80
+ return text === undefined || text === ''
81
+ ? undefined
82
+ : `## ${title}\n\n${text}`
83
+ }
84
+
85
+ /**
86
+ * The squash description: Summary, Key changes, and Technical context, in that
87
+ * order, under their headings. Testing, the reviewer requests, and the UI pass
88
+ * line describe the review rather than the change, so none reaches the trunk.
89
+ * Technical context is optional, and an empty one leaves no bare heading.
90
+ */
91
+ export function composeDescription(body: string): Composed {
92
+ const summary = keptSection(body, 'Summary')
93
+ if (summary === undefined)
94
+ return { kind: 'refused', reason: 'missing-summary' }
95
+ const keyChanges = keptSection(body, 'Key changes')
96
+ if (keyChanges === undefined) {
97
+ return { kind: 'refused', reason: 'missing-key-changes' }
98
+ }
99
+ const context = keptSection(body, 'Technical context')
100
+ return {
101
+ kind: 'composed',
102
+ description: [summary, keyChanges, context]
103
+ .filter((block) => block !== undefined)
104
+ .join('\n\n'),
105
+ }
106
+ }
107
+
108
+ /** The suffix the web merge adds and the trunk log carries. */
109
+ export function mergeTitle(title: string, number: number): string {
110
+ return `${title} (#${number})`
111
+ }
112
+
113
+ function refused(
114
+ reason: MergeRefusal,
115
+ number: number,
116
+ message: { title?: string; description?: string } = {},
117
+ ): MergeRecord {
118
+ return { reason, isOk: false, number, ...message }
119
+ }
120
+
121
+ /**
122
+ * Squash-merges one ready pull request with a message carrying only the
123
+ * change. Every refusal is read before the one write, and the write sends the
124
+ * head it read as `sha`, so GitHub refuses rather than merging a commit pushed
125
+ * after the checks were read. The verb reads the draft flag and not the review
126
+ * comments, since a ready pull request already passed the lift's gates.
127
+ */
128
+ export async function merge(
129
+ api: MergeApi,
130
+ opts: MergeOptions,
131
+ ): Promise<MergeRecord> {
132
+ const { number } = opts
133
+
134
+ const pull = await api.readPull()
135
+ if (pull === undefined) return refused('gh-failed', number)
136
+ if (!pull.isOpen) return refused('pull-closed', number)
137
+ if (pull.isDraft) return refused('pull-draft', number)
138
+
139
+ const title = mergeTitle(pull.title, number)
140
+ const composed = composeDescription(pull.body)
141
+ if (composed.kind === 'refused') {
142
+ return refused(composed.reason, number, { title })
143
+ }
144
+ const message = { title, description: composed.description }
145
+
146
+ const checks = await api.readChecks(pull.head)
147
+ if (checks === undefined) return refused('gh-failed', number, message)
148
+ if (checks.state === 'failing') return refused('ci-failing', number, message)
149
+ if (checks.state !== 'passing') {
150
+ return refused(
151
+ checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
152
+ number,
153
+ message,
154
+ )
155
+ }
156
+
157
+ if (opts.isDryRun)
158
+ return { reason: 'dry-run', isOk: true, number, ...message }
159
+
160
+ const outcome = await api.putMerge({ ...message, sha: pull.head })
161
+ if (outcome.kind === 'merged') {
162
+ return {
163
+ reason: 'merged',
164
+ isOk: true,
165
+ number,
166
+ ...message,
167
+ sha: outcome.sha,
168
+ }
169
+ }
170
+ return refused(
171
+ outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
172
+ number,
173
+ message,
174
+ )
175
+ }
package/src/pr/rest.ts CHANGED
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
13
13
  /** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
14
14
  export interface RestPull {
15
15
  readonly number?: number
16
+ readonly title?: string
16
17
  readonly body?: string | null
17
18
  readonly head?: { readonly ref?: string; readonly sha?: string }
18
19
  readonly base?: { readonly ref?: string }
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
136
136
 
137
137
  export type UiHeading = (typeof UI_HEADINGS)[number]
138
138
 
139
+ /** The four totals on a verdict's count line. */
140
+ export interface UiCounts {
141
+ readonly failed: number
142
+ readonly eyes: number
143
+ readonly passed: number
144
+ readonly notDriven: number
145
+ }
146
+
139
147
  export interface UiScope {
140
148
  readonly heading?: UiHeading
141
149
  readonly state: ReviewState
142
150
  /** The head the verdict covered, a full or abbreviated sha. */
143
151
  readonly commit?: string
152
+ /** The counts the verdict wrote, absent when its count line does not parse. */
153
+ readonly counts?: UiCounts
144
154
  /** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
145
155
  readonly source: ScopeSource
146
156
  }
@@ -152,6 +162,32 @@ export interface UiScope {
152
162
  */
153
163
  const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
154
164
 
165
+ /**
166
+ * The count line `review-ui`'s `post.md` fixes as the verdict's first line
167
+ * under the heading. It is model-written prose, so a body that drifts from the
168
+ * form reads as no counts and a caller treats that as unchecked.
169
+ */
170
+ const UI_COUNTS =
171
+ /^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
172
+
173
+ export function readUiCounts(body: string): UiCounts | undefined {
174
+ for (const raw of body.split('\n').slice(1, 6)) {
175
+ const match = UI_COUNTS.exec(raw.trim())
176
+ if (match === null) continue
177
+ const [, failed, eyes, passed, notDriven] = match.map(Number)
178
+ if (
179
+ failed === undefined ||
180
+ eyes === undefined ||
181
+ passed === undefined ||
182
+ notDriven === undefined
183
+ ) {
184
+ return undefined
185
+ }
186
+ return { failed, eyes, passed, notDriven }
187
+ }
188
+ return undefined
189
+ }
190
+
155
191
  function lastLine(body: string): string | undefined {
156
192
  const lines = body.split('\n')
157
193
 
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
185
221
  const line = lastLine(row.body ?? '')
186
222
  const match = line === undefined ? null : UI_MARKER.exec(line)
187
223
  const marked = match?.[1]
224
+ const counts = readUiCounts(row.body ?? '')
225
+ const counted = counts === undefined ? {} : { counts }
188
226
 
189
227
  if (marked !== undefined) {
190
- return { heading, state, commit: marked, source: 'marker' }
228
+ return { heading, state, commit: marked, ...counted, source: 'marker' }
191
229
  }
192
230
 
193
231
  const oid = row.commit?.oid
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
195
233
  heading,
196
234
  state,
197
235
  ...(oid === undefined || oid === '' ? {} : { commit: oid }),
236
+ ...counted,
198
237
  source: 'fallback',
199
238
  }
200
239
  }
package/src/pr/tick.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ChecklistBox,
2
3
  CHECKLIST_END,
3
4
  CHECKLIST_START,
4
5
  parseBoxLine,
@@ -7,7 +8,7 @@ import {
7
8
  writeBoxLine,
8
9
  } from '@/pr/evidence'
9
10
 
10
- const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
11
+ export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
11
12
  const TESTING_HEADING = /^## Testing\s*$/
12
13
  const SECTION_HEADING = /^## /
13
14
  const REVIEWER_HEADING = /^## For the reviewer\s*$/
@@ -48,6 +49,25 @@ export function uiPassLine(summary: UiPassSummary): string {
48
49
  return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
49
50
  }
50
51
 
52
+ /**
53
+ * The driven boxes ticked at the head. A tick counts as this pass's only when
54
+ * stamped at the head, since an unstamped one is the author's claim and an
55
+ * older stamp names another head. Taste boxes are left out, as the body line
56
+ * leaves them out.
57
+ */
58
+ export function countTicksAtHead(
59
+ boxes: readonly ChecklistBox[],
60
+ head: string,
61
+ ): number {
62
+ return boxes.filter(
63
+ (box) =>
64
+ !box.isTaste &&
65
+ box.isTicked &&
66
+ box.stamp !== undefined &&
67
+ head.startsWith(box.stamp),
68
+ ).length
69
+ }
70
+
51
71
  /** The short sha the marked line names, absent when the body carries no such line. */
52
72
  export function readUiPassHead(body: string): string | undefined {
53
73
  const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
@@ -115,6 +115,8 @@ export interface ArchiveSuccess {
115
115
  readonly ready: ReadyMove | undefined
116
116
  readonly closed: number
117
117
  readonly cut: number
118
+ /** Unticked `Operator check:` outcomes the archive left as they were. */
119
+ readonly operatorChecks: readonly string[]
118
120
  /** Every task file whose links to the moved task were rewritten. */
119
121
  readonly relinked: readonly InboundRelink[]
120
122
  /** Every task file the pass could not read or write, left as it was. */
@@ -159,6 +161,8 @@ export interface TaskOutcomes {
159
161
  readonly open: readonly string[]
160
162
  readonly closed: readonly string[]
161
163
  readonly cut: readonly string[]
164
+ /** Unticked outcomes only the operator can check, which hold nothing open. */
165
+ readonly operator: readonly string[]
162
166
  }
163
167
 
164
168
  export function tasksDir(root: string): string {
@@ -197,6 +201,9 @@ const BULLET_PATTERN = /^- (?:\[([ xX])\] ?)?(.*)$/
197
201
  */
198
202
  const STRUCK_BODY_PATTERN = /^~~.+~~/
199
203
 
204
+ /** The one spelling of an operator-owned outcome, anchored to the body's start. */
205
+ const OPERATOR_BODY_PATTERN = /^Operator check: ?\S/
206
+
200
207
  /**
201
208
  * Marks the lines sitting inside a fenced block, the fence delimiters included.
202
209
  * A checkbox in a sample a task displays is not an outcome the task claims, and
@@ -232,6 +239,7 @@ export function readOutcomes(text: string): TaskOutcomes {
232
239
  const open: string[] = []
233
240
  const closed: string[] = []
234
241
  const cut: string[] = []
242
+ const operator: string[] = []
235
243
  const lines = text.split('\n')
236
244
  const fenced = fenceMask(lines)
237
245
 
@@ -252,13 +260,17 @@ export function readOutcomes(text: string): TaskOutcomes {
252
260
  if (box === undefined) continue
253
261
 
254
262
  if (box === ' ') {
255
- open.push(body)
263
+ if (OPERATOR_BODY_PATTERN.test(body)) {
264
+ operator.push(body)
265
+ } else {
266
+ open.push(body)
267
+ }
256
268
  } else {
257
269
  closed.push(body)
258
270
  }
259
271
  }
260
272
 
261
- return { open, closed, cut }
273
+ return { open, closed, cut, operator }
262
274
  }
263
275
 
264
276
  /**
@@ -951,7 +963,7 @@ export async function archiveTask(
951
963
  )
952
964
  }
953
965
 
954
- const { open, closed, cut } = readOutcomes(text)
966
+ const { open, closed, cut, operator } = readOutcomes(text)
955
967
 
956
968
  if (open.length > 0) {
957
969
  return refuse(
@@ -961,7 +973,7 @@ export async function archiveTask(
961
973
  )
962
974
  }
963
975
 
964
- if (closed.length === 0 && cut.length === 0) {
976
+ if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
965
977
  return refuse(
966
978
  'no-outcomes',
967
979
  `${stem} carries no outcomes, so nothing marks it shipped.`,
@@ -1020,6 +1032,7 @@ export async function archiveTask(
1020
1032
  ready: ready || undefined,
1021
1033
  closed: closed.length,
1022
1034
  cut: cut.length,
1035
+ operatorChecks: operator,
1023
1036
  relinked,
1024
1037
  relinkFailed: failed,
1025
1038
  }
@@ -29,6 +29,8 @@ export interface Closure {
29
29
  readonly issues: readonly number[]
30
30
  readonly closing: boolean
31
31
  readonly reason: ClosingReason
32
+ /** Unticked operator checks, read once the outcome test runs. */
33
+ readonly operatorChecks?: readonly string[]
32
34
  }
33
35
 
34
36
  export interface TaskClosure extends Closure {
@@ -108,16 +110,18 @@ export function decideClosing(
108
110
  return { issues, closing: false, reason: 'pending-branch' }
109
111
  }
110
112
 
111
- const { open, closed, cut } = readOutcomes(text)
113
+ const { open, closed, cut, operator } = readOutcomes(text)
114
+ const operatorChecks = operator
115
+
112
116
  if (open.length > 0) {
113
- return { issues, closing: false, reason: 'open-outcomes' }
117
+ return { issues, closing: false, reason: 'open-outcomes', operatorChecks }
114
118
  }
115
119
 
116
- if (closed.length === 0 && cut.length === 0) {
117
- return { issues, closing: false, reason: 'no-outcomes' }
120
+ if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
121
+ return { issues, closing: false, reason: 'no-outcomes', operatorChecks }
118
122
  }
119
123
 
120
- return { issues, closing: true, reason: 'last-slice' }
124
+ return { issues, closing: true, reason: 'last-slice', operatorChecks }
121
125
  }
122
126
 
123
127
  /** Resolves the task through the same selector `pull-request` takes. */
@@ -1315,8 +1315,10 @@ async function checkCitedTask(
1315
1315
  }
1316
1316
 
1317
1317
  const text = await readFile(live, 'utf8')
1318
- const { open, closed } = readOutcomes(text)
1319
- if (open.length > 0 || closed.length === 0) return nothing()
1318
+ const { open, closed, operator } = readOutcomes(text)
1319
+ if (open.length > 0 || closed.length + operator.length === 0) {
1320
+ return nothing()
1321
+ }
1320
1322
 
1321
1323
  const pending = readPendingBranches(text)
1322
1324
  if (pending.length > 0) {
@@ -69,7 +69,9 @@ A project installed before the board became a folder still holds `.claude/TASKS.
69
69
  - Keep only what is project-specific in the project file, since project settings layer over user settings
70
70
  - Expect `permissions.deny` to hold `Agent` and `Task`, so no session spawns a subagent and parallel work runs as separate background sessions
71
71
  - Expect `autoMemoryEnabled` to be `false`. A person turns it back on for themselves in `settings.local.json`.
72
- - Run `canon migrate session-defaults` on a target seeded before those two defaults, since sync never rewrites an existing settings file
72
+ - Run `canon migrate session-defaults` on a target seeded before those two defaults or before the merge entries below, since sync never rewrites an existing settings file
73
+
74
+ The seed's deny list also holds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)`, so the operator alone merges. An existing target gets both from `canon migrate session-defaults --write`, and `canon claude sync` warns while either is absent. Neither covers more than the form an agent writes by default.
73
75
 
74
76
  ## User settings
75
77
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "autoMemoryEnabled": false,
3
3
  "permissions": {
4
- "deny": ["Agent", "Task"]
4
+ "deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
5
5
  },
6
6
  "hooks": {
7
7
  "PreToolUse": [