@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +3 -3
- package/claude/skills/review-ui/references/post.md +14 -2
- package/claude/skills/review-ui/references/tick.md +4 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/task-board/references/tasks.md +4 -2
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +2 -1
- package/docs/agents/migrate.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/package.json +1 -1
- package/src/commands/claude.ts +4 -0
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +301 -35
- package/src/commands/tasks.ts +9 -1
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/lift.ts +19 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +1 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +21 -1
- package/src/tasks/archive.ts +17 -4
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +4 -2
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/commands/pr.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
4012
|
+
process.stdout.write(
|
|
4013
|
+
`${JSON.stringify({ root, reason, message, ...extra })}\n`,
|
|
4014
|
+
)
|
|
3749
4015
|
}
|
|
3750
4016
|
return 1
|
|
3751
4017
|
}
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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)
|