@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.
@@ -63,6 +63,7 @@ import {
63
63
  } from '@/pr/frames'
64
64
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
65
65
  import {
66
+ type ChecksGate,
66
67
  DEFAULT_WAIT_SECONDS,
67
68
  type EvidenceGate,
68
69
  type GitRunner,
@@ -73,6 +74,13 @@ import {
73
74
  readBaseStanding,
74
75
  readChangedPaths,
75
76
  } from '@/pr/lift'
77
+ import {
78
+ merge,
79
+ type MergeApi,
80
+ type MergeOutcome,
81
+ type MergeRefusal,
82
+ type MergeRequest,
83
+ } from '@/pr/merge'
76
84
  import { machineListenerReader } from '@/listeners/machine'
77
85
  import { loadPaintedPatterns } from '@/pr/painted'
78
86
  import {
@@ -93,7 +101,9 @@ import {
93
101
  servesChange,
94
102
  type WorkflowFile,
95
103
  } from '@/pr/preview'
104
+ import { coversHead, resolveUiScope } from '@/pr/review-scope'
96
105
  import {
106
+ countTicksAtHead,
97
107
  readUiPassHead,
98
108
  type TickRefusal,
99
109
  tickBoxes,
@@ -150,6 +160,10 @@ interface LiftCommandOptions extends ReadOptions {
150
160
  readonly wait?: string
151
161
  }
152
162
 
163
+ interface MergeCommandOptions extends ReadOptions {
164
+ readonly dryRun?: boolean
165
+ }
166
+
153
167
  interface ReviewStateOptions extends ReadOptions {
154
168
  readonly wait?: string
155
169
  }
@@ -165,6 +179,7 @@ interface TickOptions extends ReadOptions {
165
179
 
166
180
  type TickReason =
167
181
  | TickRefusal
182
+ | 'count-mismatch'
168
183
  | 'stale-head'
169
184
  | 'bad-boxes'
170
185
  | 'no-comment'
@@ -177,6 +192,8 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
177
192
  '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.',
178
193
  'bad-boxes':
179
194
  '--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
195
+ 'count-mismatch':
196
+ 'The newest UI verdict at this head passed a different number of boxes than the ticks this call would leave at the head. Nothing was written. With fewer ticks than passed, re-run once with every box that passed. With more, a box ticked earlier at this head has since failed, which only the operator can untick.',
180
197
  'no-comment': 'No comment on this pull request carries the evidence marker.',
181
198
  'no-checklist': 'The evidence comment carries no checklist to tick.',
182
199
  'no-box':
@@ -834,7 +851,9 @@ export function register(program: Command): void {
834
851
  ' on an earlier commit still holds when it is an ancestor of the',
835
852
  ' head and no painted path changed since, and the record names',
836
853
  ' it as `carriedFrom`. A painted change refuses `ui-behind` and',
837
- ' lists the paths as `paintedPaths`',
854
+ ' lists the paths as `paintedPaths`. A close whose count line',
855
+ ' shows boxes not driven and none passed, failed, or needing',
856
+ ' eyes refuses `ui-undriven`, carried or not',
838
857
  ' evidence the evidence check reads settled',
839
858
  ' standing the head is fresh against the base, not stale or conflicted',
840
859
  ' ci every check run on the tip passed, waited on up to --wait',
@@ -860,7 +879,7 @@ export function register(program: Command): void {
860
879
  ' lifted, already-ready, redrafted, clear (--check)',
861
880
  ' pull-closed, review-missing, review-open, review-unmarked,',
862
881
  ' review-behind, ui-missing, ui-open, ui-unmarked, ui-behind,',
863
- ' evidence-owed, standing-stale, standing-conflict, standing-unknown,',
882
+ ' ui-undriven, evidence-owed, standing-stale, standing-conflict, standing-unknown,',
864
883
  ' ci-pending (--check), ci-failing, ci-conflicted, ci-timeout,',
865
884
  ' head-moved, gh-failed',
866
885
  '',
@@ -881,6 +900,57 @@ export function register(program: Command): void {
881
900
  process.exitCode = await runLift(number, opts)
882
901
  })
883
902
 
903
+ pr.command('merge')
904
+ .description(
905
+ 'Squash-merge a ready pull request with a message carrying only its Summary, Key changes, and Technical context',
906
+ )
907
+ .argument('[number]', 'Pull request to merge, defaulting to this branch')
908
+ .helpOption('-h, --help', 'Show this help message')
909
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
910
+ .option('--json', 'Add a machine-readable record on stdout')
911
+ .option('--dry-run', 'Print the title and description and merge nothing')
912
+ .addHelpText(
913
+ 'after',
914
+ [
915
+ '',
916
+ 'The operator runs this verb, and no skill calls it. A permissions deny',
917
+ 'rule on `canon pr merge` and `gh pr merge` keeps an agent off it.',
918
+ '',
919
+ 'The title is the pull request title plus ` (#<number>)`. The',
920
+ "description is the body's `## Summary`, `## Key changes`, and",
921
+ '`## Technical context`, in that order under their headings, with the',
922
+ 'UI pass line dropped. Technical context is optional. Testing, the',
923
+ 'reviewer requests, and every other section stay out of the trunk.',
924
+ '',
925
+ 'Refusals are read before the one write, which sends the head it read',
926
+ 'as `sha`, so a push after the read refuses rather than merging. The',
927
+ 'verb reads the draft flag and not the reviews, since a ready pull',
928
+ 'request already passed `canon pr lift`. It does not wait on checks or',
929
+ 'delete the branch.',
930
+ '',
931
+ 'Reasons:',
932
+ ' merged, dry-run (--dry-run)',
933
+ ' pull-closed, pull-draft, missing-summary, missing-key-changes,',
934
+ ' ci-failing, ci-conflicted, ci-pending, head-moved, not-mergeable,',
935
+ ' gh-failed',
936
+ '',
937
+ 'The merge is the REST `PUT pulls/<number>/merge` with',
938
+ '`merge_method=squash`, never `gh pr merge`, which runs on GraphQL.',
939
+ '',
940
+ 'Exit codes:',
941
+ ' 0 merged, or every refusal clear under --dry-run',
942
+ ' 1 refused, with the reason in the JSON record',
943
+ '',
944
+ 'Examples:',
945
+ ' canon pr merge 1341 --dry-run',
946
+ ' canon pr merge 1341 --json',
947
+ '',
948
+ ].join('\n'),
949
+ )
950
+ .action(async (number: string | undefined, opts: MergeCommandOptions) => {
951
+ process.exitCode = await runMerge(number, opts)
952
+ })
953
+
884
954
  pr.command('preview')
885
955
  .description(
886
956
  "Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
@@ -1048,9 +1118,19 @@ export function register(program: Command): void {
1048
1118
  'driver can run is ticked, counts taste boxes apart, and is the only',
1049
1119
  'body text this verb writes, so hand edits elsewhere survive.',
1050
1120
  '',
1121
+ 'Before writing it reads the newest UI verdict. One that covers the tip',
1122
+ 'and carries a count line must match the ticks this call leaves at the',
1123
+ 'tip, so a re-drive passes every box that passed, since a new head',
1124
+ 'clears the older ticks. Any other verdict leaves the check unrun.',
1125
+ '',
1051
1126
  'Read `reason` on the JSON record:',
1052
1127
  ' ticked the boxes were ticked and the body line written, with the',
1053
- ' tip in `head`',
1128
+ ' tip in `head` and `countCheck` as matched or unchecked',
1129
+ ' count-mismatch the verdict passed a different number of boxes than',
1130
+ ' this call leaves ticked at the tip, nothing written,',
1131
+ ' `passed` and `ticked` carry the two counts. Fewer ticks',
1132
+ ' owe a re-run with every passed box, more ticks owe the',
1133
+ ' operator an untick of a box that has since failed',
1054
1134
  ' body-refused the comment is ticked but gh could not edit the body,',
1055
1135
  ' exit 1 with `commentTicked`. Re-run to retry the body.',
1056
1136
  ' stale-head --head is not the remote branch tip, nothing written',
@@ -2977,6 +3057,32 @@ async function runTick(
2977
3057
  const result = tickBoxes(marked.body, numbers.map(Number), reading.tip)
2978
3058
  if (result.kind === 'refused') return refuseTick(result.reason)
2979
3059
 
3060
+ // Read ahead of the write so a short list refuses before it leaves the
3061
+ // comment half ticked. A verdict that does not cover the tip, carries no
3062
+ // count line, or cannot be read leaves the check unrun and says so.
3063
+ const ticked = countTicksAtHead(
3064
+ readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
3065
+ reading.tip,
3066
+ )
3067
+ const reviews = await listReviews(root, String(identity.number))
3068
+ const verdict = resolveUiScope({
3069
+ reviews: reviews.kind === 'read' ? reviews.rows.map(reviewRowOf) : [],
3070
+ })
3071
+ const isCounted =
3072
+ verdict.counts !== undefined &&
3073
+ verdict.commit !== undefined &&
3074
+ coversHead(verdict.commit, reading.tip)
3075
+ if (isCounted && verdict.counts.passed !== ticked) {
3076
+ return refuseWith(
3077
+ 'count-mismatch',
3078
+ TICK_REFUSALS['count-mismatch'],
3079
+ emitJson,
3080
+ root,
3081
+ { passed: verdict.counts.passed, ticked },
3082
+ )
3083
+ }
3084
+ const countCheck = isCounted ? 'matched' : 'unchecked'
3085
+
2980
3086
  if (result.body !== marked.body) {
2981
3087
  const patched = await gh(root, [
2982
3088
  'api',
@@ -2995,14 +3101,7 @@ async function runTick(
2995
3101
  const driven = boxes.filter((box) => !box.isTaste)
2996
3102
  const summary: UiPassSummary = {
2997
3103
  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,
3104
+ passed: countTicksAtHead(boxes, reading.tip),
3006
3105
  total: driven.length,
3007
3106
  taste: boxes.length - driven.length,
3008
3107
  head: reading.tip,
@@ -3024,6 +3123,7 @@ async function runTick(
3024
3123
  reason: 'body-refused',
3025
3124
  message: TICK_REFUSALS['body-refused'],
3026
3125
  commentTicked: true,
3126
+ countCheck,
3027
3127
  head: reading.tip,
3028
3128
  boxes: numbers.map(Number),
3029
3129
  commentId,
@@ -3044,6 +3144,7 @@ async function runTick(
3044
3144
  root,
3045
3145
  number: identity.number,
3046
3146
  reason: 'ticked',
3147
+ countCheck,
3047
3148
  head: reading.tip,
3048
3149
  boxes: numbers.map(Number),
3049
3150
  commentId,
@@ -3443,6 +3544,8 @@ const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
3443
3544
  'The newest UI review carries no head marker, so the commit it covers is a GitHub stamp this gate does not trust.',
3444
3545
  'ui-behind':
3445
3546
  'The newest UI review closed on an earlier commit than the head.',
3547
+ 'ui-undriven':
3548
+ 'The newest UI review closed having driven no box, so it looked at nothing. Re-drive the checklist on an address that answers.',
3446
3549
  'evidence-owed':
3447
3550
  'The evidence check reads owed, so the ship chain left a step undone.',
3448
3551
  'standing-stale':
@@ -3564,6 +3667,34 @@ async function readEvidenceGate(
3564
3667
  }
3565
3668
  }
3566
3669
 
3670
+ async function readChecksGate(
3671
+ root: string,
3672
+ number: string,
3673
+ tip: string,
3674
+ ): Promise<ChecksGate | undefined> {
3675
+ const [listed, pulled] = await Promise.all([
3676
+ gh(root, [
3677
+ 'api',
3678
+ `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
3679
+ ]),
3680
+ gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
3681
+ ])
3682
+ if (listed === null) return undefined
3683
+ try {
3684
+ const mergeState =
3685
+ pulled === null
3686
+ ? undefined
3687
+ : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
3688
+ const reading = withMergeState(
3689
+ mergeState,
3690
+ collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
3691
+ )
3692
+ return { state: reading.state, isConflicted: reading.conflicted }
3693
+ } catch {
3694
+ return undefined
3695
+ }
3696
+ }
3697
+
3567
3698
  function liftApi(root: string, number: string, baseRef: string): LiftApi {
3568
3699
  const git = gitRunner(root)
3569
3700
 
@@ -3601,29 +3732,7 @@ function liftApi(root: string, number: string, baseRef: string): LiftApi {
3601
3732
  readEvidence: () => readEvidenceGate(root, number),
3602
3733
  readStanding: (head) =>
3603
3734
  readBaseStanding(git, Number(number), head, baseRef),
3604
- readChecks: async (tip) => {
3605
- const [listed, pulled] = await Promise.all([
3606
- gh(root, [
3607
- 'api',
3608
- `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
3609
- ]),
3610
- gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
3611
- ])
3612
- if (listed === null) return undefined
3613
- try {
3614
- const mergeState =
3615
- pulled === null
3616
- ? undefined
3617
- : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
3618
- const reading = withMergeState(
3619
- mergeState,
3620
- collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
3621
- )
3622
- return { state: reading.state, isConflicted: reading.conflicted }
3623
- } catch {
3624
- return undefined
3625
- }
3626
- },
3735
+ readChecks: (tip) => readChecksGate(root, number, tip),
3627
3736
  markReady: (nodeId) =>
3628
3737
  flipDraft(
3629
3738
  root,
@@ -3725,6 +3834,160 @@ async function runLift(
3725
3834
  return record.isOk ? 0 : 1
3726
3835
  }
3727
3836
 
3837
+ const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
3838
+ 'gh-missing': PULL_REFUSALS['gh-missing'],
3839
+ 'pull-closed': 'The pull request is closed, so there is nothing to merge.',
3840
+ 'pull-draft':
3841
+ 'The pull request is a draft, so its reviews have not lifted the mark.',
3842
+ 'missing-summary':
3843
+ 'The body carries no `## Summary` with text under it, so the description has no change to state.',
3844
+ 'missing-key-changes':
3845
+ 'The body carries no `## Key changes` with text under it, so the description has no change to state.',
3846
+ 'ci-failing': 'A check run on the head failed.',
3847
+ 'ci-conflicted': 'The branch conflicts with its base.',
3848
+ 'ci-pending':
3849
+ 'A check run on the head has yet to conclude. Run the verb again once it does.',
3850
+ 'head-moved':
3851
+ 'The head moved after the read, so GitHub refused rather than merging unread commits.',
3852
+ 'not-mergeable': 'GitHub answered that the pull request is not mergeable.',
3853
+ 'gh-failed':
3854
+ 'A read or the merge was refused, so nothing is reported as merged.',
3855
+ }
3856
+
3857
+ /**
3858
+ * The REST merge, never `gh pr merge`, which runs on the GraphQL a cloud
3859
+ * session's proxy refuses. gh reports the status only as the `(HTTP <code>)`
3860
+ * tail of its stderr, so 409 and 405 are read off it.
3861
+ */
3862
+ async function putSquashMerge(
3863
+ root: string,
3864
+ number: string,
3865
+ request: MergeRequest,
3866
+ ): Promise<MergeOutcome> {
3867
+ const result = await execa(
3868
+ 'gh',
3869
+ [
3870
+ 'api',
3871
+ '-X',
3872
+ 'PUT',
3873
+ `repos/{owner}/{repo}/pulls/${number}/merge`,
3874
+ '-f',
3875
+ `commit_title=${request.title}`,
3876
+ '-f',
3877
+ `commit_message=${request.description}`,
3878
+ '-f',
3879
+ `sha=${request.sha}`,
3880
+ '-f',
3881
+ 'merge_method=squash',
3882
+ ],
3883
+ {
3884
+ cwd: root,
3885
+ reject: false,
3886
+ timeout: GH_TIMEOUT_MS,
3887
+ env: gitEnv(),
3888
+ extendEnv: false,
3889
+ },
3890
+ )
3891
+ if (result.exitCode !== 0) {
3892
+ const status = /\(HTTP (\d{3})\)/.exec(String(result.stderr))?.[1]
3893
+ if (status === '409') return { kind: 'head-moved' }
3894
+ if (status === '405') return { kind: 'not-mergeable' }
3895
+ return { kind: 'failed' }
3896
+ }
3897
+ try {
3898
+ const body = JSON.parse(String(result.stdout)) as {
3899
+ merged?: unknown
3900
+ sha?: unknown
3901
+ }
3902
+ return body.merged === true && typeof body.sha === 'string'
3903
+ ? { kind: 'merged', sha: body.sha }
3904
+ : { kind: 'failed' }
3905
+ } catch {
3906
+ return { kind: 'failed' }
3907
+ }
3908
+ }
3909
+
3910
+ function mergeApi(root: string, number: string): MergeApi {
3911
+ return {
3912
+ readPull: async () => {
3913
+ const stdout = await gh(root, [
3914
+ 'api',
3915
+ `repos/{owner}/{repo}/pulls/${number}`,
3916
+ ])
3917
+ if (stdout === null) return undefined
3918
+ try {
3919
+ const row = JSON.parse(stdout) as RestPull
3920
+ if (row.head?.sha === undefined || typeof row.title !== 'string') {
3921
+ return undefined
3922
+ }
3923
+ return {
3924
+ isOpen: row.state === 'open',
3925
+ isDraft: row.draft === true,
3926
+ title: row.title,
3927
+ body: row.body ?? '',
3928
+ head: row.head.sha,
3929
+ }
3930
+ } catch {
3931
+ return undefined
3932
+ }
3933
+ },
3934
+ readChecks: (head) => readChecksGate(root, number, head),
3935
+ putMerge: (request) => putSquashMerge(root, number, request),
3936
+ }
3937
+ }
3938
+
3939
+ async function runMerge(
3940
+ number: string | undefined,
3941
+ opts: MergeCommandOptions,
3942
+ ): Promise<number> {
3943
+ const root = resolve(opts.root ?? process.cwd())
3944
+ const emitJson = opts.json ?? false
3945
+
3946
+ intro('canon pr merge')
3947
+
3948
+ if (Bun.which('gh') === null) {
3949
+ return refuseWith(
3950
+ 'gh-missing',
3951
+ MERGE_REFUSALS['gh-missing'],
3952
+ emitJson,
3953
+ root,
3954
+ )
3955
+ }
3956
+
3957
+ const resolved = await resolvePullNumber(root, number)
3958
+ if (resolved.kind === 'refused') {
3959
+ return refuseWith(
3960
+ resolved.reason,
3961
+ PULL_REFUSALS[resolved.reason],
3962
+ emitJson,
3963
+ root,
3964
+ )
3965
+ }
3966
+
3967
+ const record = await merge(mergeApi(root, resolved.number), {
3968
+ number: Number(resolved.number),
3969
+ isDryRun: opts.dryRun ?? false,
3970
+ })
3971
+
3972
+ logStep(record.reason)
3973
+ logInfo(
3974
+ record.reason === 'merged'
3975
+ ? `#${record.number} squash-merged as ${record.sha?.slice(0, 8)}`
3976
+ : record.reason === 'dry-run'
3977
+ ? `#${record.number} would merge with the message below`
3978
+ : MERGE_REFUSALS[record.reason],
3979
+ )
3980
+ outro()
3981
+
3982
+ if (emitJson) {
3983
+ process.stdout.write(`${JSON.stringify({ root, ...record })}\n`)
3984
+ } else if (record.reason === 'dry-run') {
3985
+ process.stdout.write(`${record.title}\n\n${record.description}\n`)
3986
+ }
3987
+
3988
+ return record.isOk ? 0 : 1
3989
+ }
3990
+
3728
3991
  /**
3729
3992
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
3730
3993
  * so an operator reading the terminal sees the reason rather than a command
@@ -3739,13 +4002,16 @@ function refuseWith(
3739
4002
  message: string,
3740
4003
  emitJson: boolean,
3741
4004
  root: string,
4005
+ extra: Record<string, unknown> = {},
3742
4006
  ): number {
3743
4007
  logStep('Refused')
3744
4008
  logWarn(message)
3745
4009
  outro()
3746
4010
 
3747
4011
  if (emitJson) {
3748
- process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
4012
+ process.stdout.write(
4013
+ `${JSON.stringify({ root, reason, message, ...extra })}\n`,
4014
+ )
3749
4015
  }
3750
4016
  return 1
3751
4017
  }
@@ -680,7 +680,9 @@ export function register(program: Command): void {
680
680
  ' the last listed, so a later number closes the issue',
681
681
  ' lookup-failed the pull request lookup failed while the task already',
682
682
  ' lists a pull request, so an earlier slice cannot be ruled out',
683
- ' open-outcomes an outcome is still open, so a later slice closes it',
683
+ ' open-outcomes an outcome is still open, so a later slice closes it.',
684
+ ' A `- [ ] Operator check: <outcome>` line holds nothing open and',
685
+ ' is listed under `operatorChecks`',
684
686
  ' pending-branch another branch is on `Pending branch:`',
685
687
  ' no-outcomes the task carries no outcomes',
686
688
  '',
@@ -1544,6 +1546,7 @@ function reportCloses(
1544
1546
  issues: outcome.issues,
1545
1547
  closing: outcome.closing,
1546
1548
  reason: outcome.reason,
1549
+ operatorChecks: outcome.operatorChecks ?? [],
1547
1550
  })}\n`,
1548
1551
  )
1549
1552
  return 0
@@ -1556,6 +1559,9 @@ function reportCloses(
1556
1559
  ? `${outcome.stem} names ${outcome.issues.map((n) => `#${n}`).join(', ')}, ${outcome.reason}`
1557
1560
  : `${outcome.stem} names no issue`,
1558
1561
  )
1562
+ for (const check of outcome.operatorChecks ?? []) {
1563
+ logInfo(`operator check: ${check}`)
1564
+ }
1559
1565
  outro()
1560
1566
 
1561
1567
  return 0
@@ -2199,6 +2205,7 @@ function report(
2199
2205
  logRelinked(outcome.relinked, outcome.relinkFailed, root)
2200
2206
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
2201
2207
  if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
2208
+ for (const check of outcome.operatorChecks) logInfo(check)
2202
2209
  outro()
2203
2210
 
2204
2211
  return 0
@@ -2289,6 +2296,7 @@ function recordFor(
2289
2296
  : null,
2290
2297
  closed: outcome.closed,
2291
2298
  cut: outcome.cut,
2299
+ operatorChecks: outcome.operatorChecks,
2292
2300
  relinked: relinkedFor(outcome.relinked, root),
2293
2301
  relinkFailed: relinkFailedFor(outcome.relinkFailed, root),
2294
2302
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Adds the session defaults the seeded `.claude/settings.json` carries to a
3
- * target that was seeded before them.
3
+ * target that was seeded before them: the subagent deny, the memory default, and
4
+ * the merge deny entries.
4
5
  *
5
6
  * `canon claude init` copies a seed only when the file is absent, so a target
6
7
  * holding its own settings never sees a later seed change. This verb adds each
@@ -21,6 +22,12 @@ import {
21
22
  /** Both names, since `Task` is the tool's name before Claude Code renamed it. */
22
23
  export const DENIED_TOOLS: readonly string[] = ['Agent', 'Task']
23
24
 
25
+ /** The merge entries the seeded deny list carries, so a session cannot merge. */
26
+ export const MERGE_DENIED: readonly string[] = [
27
+ 'Bash(canon pr merge:*)',
28
+ 'Bash(gh pr merge:*)',
29
+ ]
30
+
24
31
  export interface SessionDefaultsPlan {
25
32
  readonly keys: readonly SettingsKey[]
26
33
  readonly next: SettingsRecord
@@ -31,6 +38,8 @@ export interface SessionDefaultsPlan {
31
38
  * on purpose, which is how an operator opts back into subagents.
32
39
  */
33
40
  readonly predates: boolean
41
+ /** True when the deny list lacks any merge entry, whatever else it holds. */
42
+ readonly lacksMergeDeny: boolean
34
43
  }
35
44
 
36
45
  export type SessionDefaultsRead =
@@ -52,8 +61,8 @@ function isRecord(value: unknown): value is SettingsRecord {
52
61
  }
53
62
 
54
63
  /**
55
- * Appends the denied tools a list lacks, keeping the list's own order, so the
56
- * diff a person reads is the added entries alone.
64
+ * Appends the denied tools and merge entries a list lacks, keeping the list's
65
+ * own order, so the diff a person reads is the added entries alone.
57
66
  */
58
67
  export function planSessionDefaults(
59
68
  current: SettingsRecord,
@@ -62,7 +71,9 @@ export function planSessionDefaults(
62
71
  const permissions = isRecord(current.permissions) ? current.permissions : {}
63
72
  const deny = Array.isArray(permissions.deny) ? permissions.deny : []
64
73
 
65
- const missing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
74
+ const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
75
+ const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
76
+ const missing = [...subagentsMissing, ...mergeMissing]
66
77
  if (missing.length > 0) {
67
78
  next.permissions = { ...permissions, deny: [...deny, ...missing] }
68
79
  }
@@ -78,7 +89,11 @@ export function planSessionDefaults(
78
89
  keys,
79
90
  next,
80
91
  changed: keys.some((key) => key.changed),
81
- predates: memoryAbsent && missing.length === DENIED_TOOLS.length,
92
+ predates:
93
+ memoryAbsent &&
94
+ subagentsMissing.length === DENIED_TOOLS.length &&
95
+ mergeMissing.length === MERGE_DENIED.length,
96
+ lacksMergeDeny: mergeMissing.length > 0,
82
97
  }
83
98
  }
84
99
 
package/src/pr/lift.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type ReviewRow,
5
5
  resolveReviewScope,
6
6
  resolveUiScope,
7
+ type UiCounts,
7
8
  } from '@/pr/review-scope'
8
9
  import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
9
10
 
@@ -24,6 +25,7 @@ export type LiftRefusal =
24
25
  | 'ui-open'
25
26
  | 'ui-unmarked'
26
27
  | 'ui-behind'
28
+ | 'ui-undriven'
27
29
  | 'evidence-owed'
28
30
  | 'standing-stale'
29
31
  | 'standing-conflict'
@@ -170,6 +172,20 @@ function carried(gates: readonly GateResult[]): {
170
172
  }
171
173
  }
172
174
 
175
+ /**
176
+ * Whether a verdict's counts show boxes left undriven and none driven. An empty
177
+ * pass, with nothing not driven either, looked at no box because none was owed.
178
+ */
179
+ function droveNothing(counts: UiCounts | undefined): boolean {
180
+ return (
181
+ counts !== undefined &&
182
+ counts.failed === 0 &&
183
+ counts.eyes === 0 &&
184
+ counts.passed === 0 &&
185
+ counts.notDriven > 0
186
+ )
187
+ }
188
+
173
189
  /**
174
190
  * Binds only where the evidence record carries a checklist to drive.
175
191
  *
@@ -192,6 +208,9 @@ export async function uiGate(
192
208
  if (scope.state === 'open') return fail('ui', 'ui-open')
193
209
  if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
194
210
  if (scope.commit === undefined) return fail('ui', 'ui-behind')
211
+ // Ahead of the head test so a close carried across commits is refused too.
212
+ // A verdict whose count line did not parse reads as unchecked and goes on.
213
+ if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
195
214
  if (coversHead(scope.commit, head)) return pass('ui')
196
215
 
197
216
  const changed = await api.readChanged(scope.commit, head)