@erclx/canon 5.47.0 → 5.49.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 (67) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/git-pr/references/closing-issues.md +2 -0
  4. package/claude/skills/plan-feature/SKILL.md +18 -15
  5. package/claude/skills/plan-feature/references/batches.md +5 -5
  6. package/claude/skills/plan-feature/references/plan.md +82 -33
  7. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  8. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  9. package/claude/skills/plan-intake/SKILL.md +8 -0
  10. package/claude/skills/plan-intake/references/intake.md +9 -1
  11. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  12. package/claude/skills/plan-ready/SKILL.md +3 -3
  13. package/claude/skills/plan-ready/references/assembly.md +7 -7
  14. package/claude/skills/plan-ready/references/ready.md +8 -6
  15. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  16. package/claude/skills/review-ui/SKILL.md +7 -14
  17. package/claude/skills/review-ui/references/close-out.md +1 -1
  18. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  19. package/claude/skills/review-ui/references/post.md +36 -3
  20. package/claude/skills/review-ui/references/tick.md +4 -1
  21. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  22. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  30. package/claude/skills/role-planner/SKILL.md +1 -1
  31. package/claude/skills/role-reviewer/SKILL.md +1 -1
  32. package/claude/skills/role-worker/SKILL.md +4 -2
  33. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  34. package/claude/skills/session-map/references/session.md +1 -1
  35. package/claude/skills/task-board/references/board.md +2 -2
  36. package/claude/skills/task-board/references/tasks.md +5 -3
  37. package/claude/skills/task-complete/SKILL.md +1 -0
  38. package/claude/skills/task-complete/references/archive.md +2 -1
  39. package/docs/agents/migrate.md +2 -2
  40. package/docs/agents/records.md +1 -1
  41. package/docs/target/migrations.md +4 -2
  42. package/docs/workflow/operating-model.md +2 -2
  43. package/docs/workflow/parallel-features.md +2 -2
  44. package/package.json +1 -1
  45. package/src/commands/claude.ts +4 -0
  46. package/src/commands/migrate.ts +5 -4
  47. package/src/commands/pr.ts +712 -58
  48. package/src/commands/records.ts +45 -8
  49. package/src/commands/tasks.ts +21 -1
  50. package/src/hooks/post-merge.ts +11 -0
  51. package/src/migrate/session-defaults.ts +20 -5
  52. package/src/pr/drive-copy.ts +218 -0
  53. package/src/pr/lift.ts +19 -0
  54. package/src/pr/local.ts +26 -0
  55. package/src/pr/merge.ts +175 -0
  56. package/src/pr/rest.ts +3 -0
  57. package/src/pr/review-scope.ts +40 -1
  58. package/src/pr/tick.ts +21 -1
  59. package/src/records/structure.ts +147 -0
  60. package/src/records/validate.ts +113 -12
  61. package/src/tasks/archive.ts +154 -8
  62. package/src/tasks/board.ts +68 -13
  63. package/src/tasks/closes.ts +9 -5
  64. package/src/tasks/validate.ts +43 -4
  65. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  66. package/tooling/claude/reference.md +3 -1
  67. package/tooling/claude/seeds/.claude/settings.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
- import { readFile, realpath } from 'node:fs/promises'
2
+ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
5
5
  import type { Command } from 'commander'
@@ -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,10 +74,27 @@ 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'
84
+ import { listWorktrees } from '@/git/worktree'
85
+ import {
86
+ branchSlug,
87
+ buildDriveCopy,
88
+ changedSince,
89
+ loadPreviewConfig,
90
+ readWorktreeStatus,
91
+ type StatusEntry,
92
+ } from '@/pr/drive-copy'
76
93
  import { machineListenerReader } from '@/listeners/machine'
77
94
  import { loadPaintedPatterns } from '@/pr/painted'
78
95
  import {
79
96
  dropAddressLine,
97
+ maskLocalAddress,
80
98
  findLocalServer,
81
99
  type LocalRefusal,
82
100
  type LocalRunner,
@@ -93,7 +111,9 @@ import {
93
111
  servesChange,
94
112
  type WorkflowFile,
95
113
  } from '@/pr/preview'
114
+ import { coversHead, resolveUiScope } from '@/pr/review-scope'
96
115
  import {
116
+ countTicksAtHead,
97
117
  readUiPassHead,
98
118
  type TickRefusal,
99
119
  tickBoxes,
@@ -150,6 +170,10 @@ interface LiftCommandOptions extends ReadOptions {
150
170
  readonly wait?: string
151
171
  }
152
172
 
173
+ interface MergeCommandOptions extends ReadOptions {
174
+ readonly dryRun?: boolean
175
+ }
176
+
153
177
  interface ReviewStateOptions extends ReadOptions {
154
178
  readonly wait?: string
155
179
  }
@@ -163,8 +187,32 @@ interface TickOptions extends ReadOptions {
163
187
  readonly head?: string
164
188
  }
165
189
 
190
+ interface DriveCopyOptions extends ReadOptions {
191
+ readonly baseline?: boolean
192
+ readonly check?: boolean
193
+ }
194
+
195
+ type DriveCopyRefusal =
196
+ | 'no-worktree'
197
+ | 'no-branch'
198
+ | 'no-baseline'
199
+ | 'unreadable-tree'
200
+ | 'bad-flags'
201
+
202
+ const DRIVE_COPY_REFUSALS: Record<DriveCopyRefusal, string> = {
203
+ 'no-worktree':
204
+ 'No local worktree holds the pull request branch, so there is no tree to copy from or check.',
205
+ 'no-branch':
206
+ 'The checkout holds no branch, so no copy root could be named. Pass the pull request number.',
207
+ 'no-baseline':
208
+ 'No baseline was recorded for this pull request. Run --baseline before --check.',
209
+ 'unreadable-tree': 'git could not read the worktree status.',
210
+ 'bad-flags': '--baseline and --check cannot be combined.',
211
+ }
212
+
166
213
  type TickReason =
167
214
  | TickRefusal
215
+ | 'count-mismatch'
168
216
  | 'stale-head'
169
217
  | 'bad-boxes'
170
218
  | 'no-comment'
@@ -177,6 +225,8 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
177
225
  '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
226
  'bad-boxes':
179
227
  '--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.',
228
+ 'count-mismatch':
229
+ '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
230
  'no-comment': 'No comment on this pull request carries the evidence marker.',
181
231
  'no-checklist': 'The evidence comment carries no checklist to tick.',
182
232
  'no-box':
@@ -834,7 +884,9 @@ export function register(program: Command): void {
834
884
  ' on an earlier commit still holds when it is an ancestor of the',
835
885
  ' head and no painted path changed since, and the record names',
836
886
  ' it as `carriedFrom`. A painted change refuses `ui-behind` and',
837
- ' lists the paths as `paintedPaths`',
887
+ ' lists the paths as `paintedPaths`. A close whose count line',
888
+ ' shows boxes not driven and none passed, failed, or needing',
889
+ ' eyes refuses `ui-undriven`, carried or not',
838
890
  ' evidence the evidence check reads settled',
839
891
  ' standing the head is fresh against the base, not stale or conflicted',
840
892
  ' ci every check run on the tip passed, waited on up to --wait',
@@ -860,7 +912,7 @@ export function register(program: Command): void {
860
912
  ' lifted, already-ready, redrafted, clear (--check)',
861
913
  ' pull-closed, review-missing, review-open, review-unmarked,',
862
914
  ' review-behind, ui-missing, ui-open, ui-unmarked, ui-behind,',
863
- ' evidence-owed, standing-stale, standing-conflict, standing-unknown,',
915
+ ' ui-undriven, evidence-owed, standing-stale, standing-conflict, standing-unknown,',
864
916
  ' ci-pending (--check), ci-failing, ci-conflicted, ci-timeout,',
865
917
  ' head-moved, gh-failed',
866
918
  '',
@@ -881,6 +933,57 @@ export function register(program: Command): void {
881
933
  process.exitCode = await runLift(number, opts)
882
934
  })
883
935
 
936
+ pr.command('merge')
937
+ .description(
938
+ 'Squash-merge a ready pull request with a message carrying only its Summary, Key changes, and Technical context',
939
+ )
940
+ .argument('[number]', 'Pull request to merge, defaulting to this branch')
941
+ .helpOption('-h, --help', 'Show this help message')
942
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
943
+ .option('--json', 'Add a machine-readable record on stdout')
944
+ .option('--dry-run', 'Print the title and description and merge nothing')
945
+ .addHelpText(
946
+ 'after',
947
+ [
948
+ '',
949
+ 'The operator runs this verb, and no skill calls it. A permissions deny',
950
+ 'rule on `canon pr merge` and `gh pr merge` keeps an agent off it.',
951
+ '',
952
+ 'The title is the pull request title plus ` (#<number>)`. The',
953
+ "description is the body's `## Summary`, `## Key changes`, and",
954
+ '`## Technical context`, in that order under their headings, with the',
955
+ 'UI pass line dropped. Technical context is optional. Testing, the',
956
+ 'reviewer requests, and every other section stay out of the trunk.',
957
+ '',
958
+ 'Refusals are read before the one write, which sends the head it read',
959
+ 'as `sha`, so a push after the read refuses rather than merging. The',
960
+ 'verb reads the draft flag and not the reviews, since a ready pull',
961
+ 'request already passed `canon pr lift`. It does not wait on checks or',
962
+ 'delete the branch.',
963
+ '',
964
+ 'Reasons:',
965
+ ' merged, dry-run (--dry-run)',
966
+ ' pull-closed, pull-draft, missing-summary, missing-key-changes,',
967
+ ' ci-failing, ci-conflicted, ci-pending, head-moved, not-mergeable,',
968
+ ' gh-failed',
969
+ '',
970
+ 'The merge is the REST `PUT pulls/<number>/merge` with',
971
+ '`merge_method=squash`, never `gh pr merge`, which runs on GraphQL.',
972
+ '',
973
+ 'Exit codes:',
974
+ ' 0 merged, or every refusal clear under --dry-run',
975
+ ' 1 refused, with the reason in the JSON record',
976
+ '',
977
+ 'Examples:',
978
+ ' canon pr merge 1341 --dry-run',
979
+ ' canon pr merge 1341 --json',
980
+ '',
981
+ ].join('\n'),
982
+ )
983
+ .action(async (number: string | undefined, opts: MergeCommandOptions) => {
984
+ process.exitCode = await runMerge(number, opts)
985
+ })
986
+
884
987
  pr.command('preview')
885
988
  .description(
886
989
  "Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
@@ -967,7 +1070,10 @@ export function register(program: Command): void {
967
1070
  .helpOption('-h, --help', 'Show this help message')
968
1071
  .option('--root <path>', 'Worktree to read, defaulting to the cwd')
969
1072
  .option('--json', 'Add a machine-readable record on stdout')
970
- .option('--remove', "Drop the evidence comment's preview address line")
1073
+ .option(
1074
+ '--remove',
1075
+ "Drop the evidence comment's preview address line and mask local addresses in UI reviews",
1076
+ )
971
1077
  .addHelpText(
972
1078
  'after',
973
1079
  [
@@ -990,6 +1096,11 @@ export function register(program: Command): void {
990
1096
  'and drops the hosted link too because the same close deletes the',
991
1097
  "branch's preview deployments.",
992
1098
  '',
1099
+ 'It also rewrites the loopback address on the Driven at line of every',
1100
+ 'review carrying the review-ui marker to "a local preview", keeping',
1101
+ 'the head and the renderer. A hosted address and a code review are',
1102
+ 'left alone, and `reviews` on the JSON record counts the rewrites.',
1103
+ '',
993
1104
  'Read `reason` on the JSON record:',
994
1105
  ' ok a page answered, with its address in `url`',
995
1106
  ' no-server nothing inside this worktree served a page',
@@ -1000,7 +1111,8 @@ export function register(program: Command): void {
1000
1111
  '',
1001
1112
  'Refusals, read off `reason`:',
1002
1113
  ' gh-missing `--remove` needs `gh` and it is not on the path',
1003
- ' gh-failed `--remove` could not read or edit the comment',
1114
+ ' gh-failed `--remove` could not read the thread, or edit the',
1115
+ ' comment or a review, after trying every edit',
1004
1116
  '',
1005
1117
  'Exit codes:',
1006
1118
  ' 0 a server was found, or --remove finished, including a no-op',
@@ -1048,9 +1160,19 @@ export function register(program: Command): void {
1048
1160
  'driver can run is ticked, counts taste boxes apart, and is the only',
1049
1161
  'body text this verb writes, so hand edits elsewhere survive.',
1050
1162
  '',
1163
+ 'Before writing it reads the newest UI verdict. One that covers the tip',
1164
+ 'and carries a count line must match the ticks this call leaves at the',
1165
+ 'tip, so a re-drive passes every box that passed, since a new head',
1166
+ 'clears the older ticks. Any other verdict leaves the check unrun.',
1167
+ '',
1051
1168
  'Read `reason` on the JSON record:',
1052
1169
  ' ticked the boxes were ticked and the body line written, with the',
1053
- ' tip in `head`',
1170
+ ' tip in `head` and `countCheck` as matched or unchecked',
1171
+ ' count-mismatch the verdict passed a different number of boxes than',
1172
+ ' this call leaves ticked at the tip, nothing written,',
1173
+ ' `passed` and `ticked` carry the two counts. Fewer ticks',
1174
+ ' owe a re-run with every passed box, more ticks owe the',
1175
+ ' operator an untick of a box that has since failed',
1054
1176
  ' body-refused the comment is ticked but gh could not edit the body,',
1055
1177
  ' exit 1 with `commentTicked`. Re-run to retry the body.',
1056
1178
  ' stale-head --head is not the remote branch tip, nothing written',
@@ -1073,6 +1195,83 @@ export function register(program: Command): void {
1073
1195
  process.exitCode = await runTick(number, opts)
1074
1196
  })
1075
1197
 
1198
+ pr.command('drive-copy')
1199
+ .description(
1200
+ "Copy the folders a running preview saves into, so a UI pass drives a throwaway copy, and check the worker's worktree gained no change",
1201
+ )
1202
+ .argument(
1203
+ '[number]',
1204
+ 'Pull request whose worktree to read, defaulting to the checkout at --root',
1205
+ )
1206
+ .helpOption('-h, --help', 'Show this help message')
1207
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
1208
+ .option('--json', 'Add a machine-readable record on stdout')
1209
+ .option(
1210
+ '--baseline',
1211
+ "Rebuild the copy, then record the worktree's git status",
1212
+ )
1213
+ .option(
1214
+ '--check',
1215
+ 'Report each path changed since the baseline, copying nothing',
1216
+ )
1217
+ .addHelpText(
1218
+ 'after',
1219
+ [
1220
+ '',
1221
+ 'A project names what its preview writes in',
1222
+ '`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
1223
+ 'Each `writes` path is relative to the worktree, and `env`, defaulting',
1224
+ 'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
1225
+ 'root. The server resolves each declared path under that root.',
1226
+ '',
1227
+ 'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
1228
+ 'worktree root, where `<slug>` is the branch with a leading type',
1229
+ 'segment dropped and each `/` made `-`. With a number the branch is the',
1230
+ "pull request's head, and without one it is the checkout's, so the",
1231
+ 'worker starting its server before a pull request exists and the',
1232
+ 'reviewer naming the number land on one copy. Every run but --check',
1233
+ "rebuilds it from the worktree, dropping the last pass's drives.",
1234
+ '',
1235
+ '--baseline records `git status` for the worktree under',
1236
+ '`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
1237
+ 'whose status or content changed since, an untracked file included.',
1238
+ 'Both run with no config, since a project that saves drives and never',
1239
+ 'declared it is the case the check exists for. An edit the worker makes',
1240
+ 'during the pass reads as changed too.',
1241
+ '',
1242
+ 'Read `reason` on the JSON record:',
1243
+ ' copied the copy was rebuilt, with `copyRoot`, `env`, `copied`, and',
1244
+ ' `missing` for a declared path the worktree does not hold',
1245
+ ' no-config the project declares no preview, so nothing was copied',
1246
+ ' clean --check found no change since the baseline',
1247
+ ' changed --check found changes, listed in `paths`, exit 1',
1248
+ '',
1249
+ 'Refusals, read off `reason`:',
1250
+ ' no-worktree no local worktree holds the branch, as for a cloud',
1251
+ " worker's, so there is no tree to check",
1252
+ ' no-branch the checkout holds no branch',
1253
+ ' no-baseline --check ran before any --baseline for this number',
1254
+ ' bad-config preview.json does not parse or lacks a writes list',
1255
+ ' unsafe-path a writes path is absolute or climbs with `..`',
1256
+ ' unreadable-tree git could not read the worktree status',
1257
+ ' bad-flags --baseline and --check were combined',
1258
+ ' gh-failed, ambiguous-pull, gh-missing',
1259
+ '',
1260
+ 'Exit codes:',
1261
+ ' 0 copied, no-config, or clean',
1262
+ ' 1 changed, or refused, with the reason in the JSON record',
1263
+ '',
1264
+ 'Examples:',
1265
+ ' canon pr drive-copy --json',
1266
+ ' canon pr drive-copy 1341 --baseline --json',
1267
+ ' canon pr drive-copy 1341 --check --json',
1268
+ '',
1269
+ ].join('\n'),
1270
+ )
1271
+ .action(async (number: string | undefined, opts: DriveCopyOptions) => {
1272
+ process.exitCode = await runDriveCopy(number, opts)
1273
+ })
1274
+
1076
1275
  pr.command('frames')
1077
1276
  .description(
1078
1277
  `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
@@ -1653,7 +1852,7 @@ async function listReviews(cwd: string, number: string) {
1653
1852
  return listPages<RestReview>(
1654
1853
  cwd,
1655
1854
  `repos/{owner}/{repo}/pulls/${number}/reviews`,
1656
- 'body, commit_id, submitted_at',
1855
+ 'id, body, commit_id, submitted_at',
1657
1856
  )
1658
1857
  }
1659
1858
 
@@ -2842,9 +3041,9 @@ async function runLocal(opts: LocalOptions): Promise<number> {
2842
3041
  }
2843
3042
 
2844
3043
  /**
2845
- * Drops the address line on the marked comment. The verb writes here rather
2846
- * than handing a body back, because its caller is a close workflow with no
2847
- * session to post one.
3044
+ * Drops the address line on the marked comment and masks the loopback address
3045
+ * on every UI review. The verb writes here rather than handing a body back,
3046
+ * because its caller is a close workflow with no session to post one.
2848
3047
  */
2849
3048
  async function runLocalRemove(
2850
3049
  number: string | undefined,
@@ -2871,32 +3070,79 @@ async function runLocalRemove(
2871
3070
  root,
2872
3071
  )
2873
3072
  }
2874
- const comments = await listComments(root, resolved.number)
2875
- if (comments === undefined) {
3073
+ const [comments, reviews] = await Promise.all([
3074
+ listComments(root, resolved.number),
3075
+ listReviews(root, resolved.number),
3076
+ ])
3077
+ if (comments === undefined && reviews.kind === 'refused') {
2876
3078
  return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
2877
3079
  }
2878
3080
 
2879
- const finish = (reason: string, message: string): number => {
2880
- logStep(reason === 'removed' ? 'Removed' : 'Skipped')
2881
- logInfo(message)
2882
- outro()
2883
- if (emitJson) {
2884
- process.stdout.write(`${JSON.stringify({ root, reason })}\n`)
2885
- }
2886
- return 0
3081
+ // A refused read on one side still lets the other side's edits land, and
3082
+ // counts as a failure beside them, so one unread listing never strands both.
3083
+ const [evidence, masked] = await Promise.all([
3084
+ comments === undefined
3085
+ ? Promise.resolve<EvidenceRemoval>({ kind: 'failed' })
3086
+ : removeEvidenceAddress(root, comments),
3087
+ reviews.kind === 'refused'
3088
+ ? Promise.resolve({ rewritten: 0, failed: true })
3089
+ : maskUiReviews(root, resolved.number, reviews.rows),
3090
+ ])
3091
+ if (evidence.kind === 'failed' || masked.failed) {
3092
+ return refuseWith(
3093
+ 'gh-failed',
3094
+ LOCAL_REFUSALS['gh-failed'],
3095
+ emitJson,
3096
+ root,
3097
+ {
3098
+ reviews: masked.rewritten,
3099
+ },
3100
+ )
2887
3101
  }
2888
3102
 
3103
+ logStep(evidence.reason === 'removed' ? 'Removed' : 'Skipped')
3104
+ logInfo(evidence.message)
3105
+ logInfo(`${masked.rewritten} UI review addresses masked`)
3106
+ outro()
3107
+ if (emitJson) {
3108
+ process.stdout.write(
3109
+ `${JSON.stringify({ root, reason: evidence.reason, reviews: masked.rewritten })}\n`,
3110
+ )
3111
+ }
3112
+ return 0
3113
+ }
3114
+
3115
+ type EvidenceRemoval =
3116
+ | {
3117
+ readonly kind: 'done'
3118
+ readonly reason: 'removed' | 'no-comment' | 'no-line'
3119
+ readonly message: string
3120
+ }
3121
+ | { readonly kind: 'failed' }
3122
+
3123
+ async function removeEvidenceAddress(
3124
+ root: string,
3125
+ comments: readonly { url?: string; body: string }[],
3126
+ ): Promise<EvidenceRemoval> {
2889
3127
  const commentId = findEvidenceCommentId(comments)
2890
3128
  const marked = comments.find((comment) =>
2891
3129
  comment.url?.endsWith(`#issuecomment-${commentId}`),
2892
3130
  )
2893
3131
  if (commentId === undefined || marked === undefined) {
2894
- return finish('no-comment', 'No comment carries the evidence marker.')
3132
+ return {
3133
+ kind: 'done',
3134
+ reason: 'no-comment',
3135
+ message: 'No comment carries the evidence marker.',
3136
+ }
2895
3137
  }
2896
3138
 
2897
3139
  const dropped = dropAddressLine(marked.body)
2898
3140
  if (dropped === undefined) {
2899
- return finish('no-line', 'The evidence comment carries no address line.')
3141
+ return {
3142
+ kind: 'done',
3143
+ reason: 'no-line',
3144
+ message: 'The evidence comment carries no address line.',
3145
+ }
2900
3146
  }
2901
3147
 
2902
3148
  const patched = await gh(root, [
@@ -2907,11 +3153,43 @@ async function runLocalRemove(
2907
3153
  '-f',
2908
3154
  `body=${dropped}`,
2909
3155
  ])
2910
- if (patched === null) {
2911
- return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
2912
- }
3156
+ return patched === null
3157
+ ? { kind: 'failed' }
3158
+ : { kind: 'done', reason: 'removed', message: `comment ${commentId}` }
3159
+ }
3160
+
3161
+ const UI_REVIEW_MARKER = '<!-- review-ui: head='
2913
3162
 
2914
- return finish('removed', `comment ${commentId}`)
3163
+ /**
3164
+ * Masks the loopback address on each review `review-ui` posted, selected by
3165
+ * its marker so a code review quoting a local URL is never edited. Every
3166
+ * update is tried before a refusal is reported, so one refused review leaves
3167
+ * the rest masked.
3168
+ */
3169
+ async function maskUiReviews(
3170
+ root: string,
3171
+ number: string,
3172
+ reviews: readonly RestReview[],
3173
+ ): Promise<{ readonly rewritten: number; readonly failed: boolean }> {
3174
+ const updates = reviews.flatMap((review) => {
3175
+ const body = review.body ?? ''
3176
+ if (review.id === undefined || !body.includes(UI_REVIEW_MARKER)) return []
3177
+ const masked = maskLocalAddress(body)
3178
+ if (masked === undefined) return []
3179
+ return [
3180
+ gh(root, [
3181
+ 'api',
3182
+ '-X',
3183
+ 'PUT',
3184
+ `repos/{owner}/{repo}/pulls/${number}/reviews/${review.id}`,
3185
+ '-f',
3186
+ `body=${masked}`,
3187
+ ]),
3188
+ ]
3189
+ })
3190
+ const results = await Promise.all(updates)
3191
+ const rewritten = results.filter((stdout) => stdout !== null).length
3192
+ return { rewritten, failed: rewritten < results.length }
2915
3193
  }
2916
3194
 
2917
3195
  const POSITIVE_WHOLE = /^[1-9]\d*$/
@@ -2977,6 +3255,32 @@ async function runTick(
2977
3255
  const result = tickBoxes(marked.body, numbers.map(Number), reading.tip)
2978
3256
  if (result.kind === 'refused') return refuseTick(result.reason)
2979
3257
 
3258
+ // Read ahead of the write so a short list refuses before it leaves the
3259
+ // comment half ticked. A verdict that does not cover the tip, carries no
3260
+ // count line, or cannot be read leaves the check unrun and says so.
3261
+ const ticked = countTicksAtHead(
3262
+ readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
3263
+ reading.tip,
3264
+ )
3265
+ const reviews = await listReviews(root, String(identity.number))
3266
+ const verdict = resolveUiScope({
3267
+ reviews: reviews.kind === 'read' ? reviews.rows.map(reviewRowOf) : [],
3268
+ })
3269
+ const isCounted =
3270
+ verdict.counts !== undefined &&
3271
+ verdict.commit !== undefined &&
3272
+ coversHead(verdict.commit, reading.tip)
3273
+ if (isCounted && verdict.counts.passed !== ticked) {
3274
+ return refuseWith(
3275
+ 'count-mismatch',
3276
+ TICK_REFUSALS['count-mismatch'],
3277
+ emitJson,
3278
+ root,
3279
+ { passed: verdict.counts.passed, ticked },
3280
+ )
3281
+ }
3282
+ const countCheck = isCounted ? 'matched' : 'unchecked'
3283
+
2980
3284
  if (result.body !== marked.body) {
2981
3285
  const patched = await gh(root, [
2982
3286
  'api',
@@ -2995,14 +3299,7 @@ async function runTick(
2995
3299
  const driven = boxes.filter((box) => !box.isTaste)
2996
3300
  const summary: UiPassSummary = {
2997
3301
  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,
3302
+ passed: countTicksAtHead(boxes, reading.tip),
3006
3303
  total: driven.length,
3007
3304
  taste: boxes.length - driven.length,
3008
3305
  head: reading.tip,
@@ -3024,6 +3321,7 @@ async function runTick(
3024
3321
  reason: 'body-refused',
3025
3322
  message: TICK_REFUSALS['body-refused'],
3026
3323
  commentTicked: true,
3324
+ countCheck,
3027
3325
  head: reading.tip,
3028
3326
  boxes: numbers.map(Number),
3029
3327
  commentId,
@@ -3044,6 +3342,7 @@ async function runTick(
3044
3342
  root,
3045
3343
  number: identity.number,
3046
3344
  reason: 'ticked',
3345
+ countCheck,
3047
3346
  head: reading.tip,
3048
3347
  boxes: numbers.map(Number),
3049
3348
  commentId,
@@ -3443,6 +3742,8 @@ const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
3443
3742
  'The newest UI review carries no head marker, so the commit it covers is a GitHub stamp this gate does not trust.',
3444
3743
  'ui-behind':
3445
3744
  'The newest UI review closed on an earlier commit than the head.',
3745
+ 'ui-undriven':
3746
+ 'The newest UI review closed having driven no box, so it looked at nothing. Re-drive the checklist on an address that answers.',
3446
3747
  'evidence-owed':
3447
3748
  'The evidence check reads owed, so the ship chain left a step undone.',
3448
3749
  'standing-stale':
@@ -3564,6 +3865,34 @@ async function readEvidenceGate(
3564
3865
  }
3565
3866
  }
3566
3867
 
3868
+ async function readChecksGate(
3869
+ root: string,
3870
+ number: string,
3871
+ tip: string,
3872
+ ): Promise<ChecksGate | undefined> {
3873
+ const [listed, pulled] = await Promise.all([
3874
+ gh(root, [
3875
+ 'api',
3876
+ `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
3877
+ ]),
3878
+ gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
3879
+ ])
3880
+ if (listed === null) return undefined
3881
+ try {
3882
+ const mergeState =
3883
+ pulled === null
3884
+ ? undefined
3885
+ : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
3886
+ const reading = withMergeState(
3887
+ mergeState,
3888
+ collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
3889
+ )
3890
+ return { state: reading.state, isConflicted: reading.conflicted }
3891
+ } catch {
3892
+ return undefined
3893
+ }
3894
+ }
3895
+
3567
3896
  function liftApi(root: string, number: string, baseRef: string): LiftApi {
3568
3897
  const git = gitRunner(root)
3569
3898
 
@@ -3601,29 +3930,7 @@ function liftApi(root: string, number: string, baseRef: string): LiftApi {
3601
3930
  readEvidence: () => readEvidenceGate(root, number),
3602
3931
  readStanding: (head) =>
3603
3932
  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
- },
3933
+ readChecks: (tip) => readChecksGate(root, number, tip),
3627
3934
  markReady: (nodeId) =>
3628
3935
  flipDraft(
3629
3936
  root,
@@ -3725,6 +4032,160 @@ async function runLift(
3725
4032
  return record.isOk ? 0 : 1
3726
4033
  }
3727
4034
 
4035
+ const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
4036
+ 'gh-missing': PULL_REFUSALS['gh-missing'],
4037
+ 'pull-closed': 'The pull request is closed, so there is nothing to merge.',
4038
+ 'pull-draft':
4039
+ 'The pull request is a draft, so its reviews have not lifted the mark.',
4040
+ 'missing-summary':
4041
+ 'The body carries no `## Summary` with text under it, so the description has no change to state.',
4042
+ 'missing-key-changes':
4043
+ 'The body carries no `## Key changes` with text under it, so the description has no change to state.',
4044
+ 'ci-failing': 'A check run on the head failed.',
4045
+ 'ci-conflicted': 'The branch conflicts with its base.',
4046
+ 'ci-pending':
4047
+ 'A check run on the head has yet to conclude. Run the verb again once it does.',
4048
+ 'head-moved':
4049
+ 'The head moved after the read, so GitHub refused rather than merging unread commits.',
4050
+ 'not-mergeable': 'GitHub answered that the pull request is not mergeable.',
4051
+ 'gh-failed':
4052
+ 'A read or the merge was refused, so nothing is reported as merged.',
4053
+ }
4054
+
4055
+ /**
4056
+ * The REST merge, never `gh pr merge`, which runs on the GraphQL a cloud
4057
+ * session's proxy refuses. gh reports the status only as the `(HTTP <code>)`
4058
+ * tail of its stderr, so 409 and 405 are read off it.
4059
+ */
4060
+ async function putSquashMerge(
4061
+ root: string,
4062
+ number: string,
4063
+ request: MergeRequest,
4064
+ ): Promise<MergeOutcome> {
4065
+ const result = await execa(
4066
+ 'gh',
4067
+ [
4068
+ 'api',
4069
+ '-X',
4070
+ 'PUT',
4071
+ `repos/{owner}/{repo}/pulls/${number}/merge`,
4072
+ '-f',
4073
+ `commit_title=${request.title}`,
4074
+ '-f',
4075
+ `commit_message=${request.description}`,
4076
+ '-f',
4077
+ `sha=${request.sha}`,
4078
+ '-f',
4079
+ 'merge_method=squash',
4080
+ ],
4081
+ {
4082
+ cwd: root,
4083
+ reject: false,
4084
+ timeout: GH_TIMEOUT_MS,
4085
+ env: gitEnv(),
4086
+ extendEnv: false,
4087
+ },
4088
+ )
4089
+ if (result.exitCode !== 0) {
4090
+ const status = /\(HTTP (\d{3})\)/.exec(String(result.stderr))?.[1]
4091
+ if (status === '409') return { kind: 'head-moved' }
4092
+ if (status === '405') return { kind: 'not-mergeable' }
4093
+ return { kind: 'failed' }
4094
+ }
4095
+ try {
4096
+ const body = JSON.parse(String(result.stdout)) as {
4097
+ merged?: unknown
4098
+ sha?: unknown
4099
+ }
4100
+ return body.merged === true && typeof body.sha === 'string'
4101
+ ? { kind: 'merged', sha: body.sha }
4102
+ : { kind: 'failed' }
4103
+ } catch {
4104
+ return { kind: 'failed' }
4105
+ }
4106
+ }
4107
+
4108
+ function mergeApi(root: string, number: string): MergeApi {
4109
+ return {
4110
+ readPull: async () => {
4111
+ const stdout = await gh(root, [
4112
+ 'api',
4113
+ `repos/{owner}/{repo}/pulls/${number}`,
4114
+ ])
4115
+ if (stdout === null) return undefined
4116
+ try {
4117
+ const row = JSON.parse(stdout) as RestPull
4118
+ if (row.head?.sha === undefined || typeof row.title !== 'string') {
4119
+ return undefined
4120
+ }
4121
+ return {
4122
+ isOpen: row.state === 'open',
4123
+ isDraft: row.draft === true,
4124
+ title: row.title,
4125
+ body: row.body ?? '',
4126
+ head: row.head.sha,
4127
+ }
4128
+ } catch {
4129
+ return undefined
4130
+ }
4131
+ },
4132
+ readChecks: (head) => readChecksGate(root, number, head),
4133
+ putMerge: (request) => putSquashMerge(root, number, request),
4134
+ }
4135
+ }
4136
+
4137
+ async function runMerge(
4138
+ number: string | undefined,
4139
+ opts: MergeCommandOptions,
4140
+ ): Promise<number> {
4141
+ const root = resolve(opts.root ?? process.cwd())
4142
+ const emitJson = opts.json ?? false
4143
+
4144
+ intro('canon pr merge')
4145
+
4146
+ if (Bun.which('gh') === null) {
4147
+ return refuseWith(
4148
+ 'gh-missing',
4149
+ MERGE_REFUSALS['gh-missing'],
4150
+ emitJson,
4151
+ root,
4152
+ )
4153
+ }
4154
+
4155
+ const resolved = await resolvePullNumber(root, number)
4156
+ if (resolved.kind === 'refused') {
4157
+ return refuseWith(
4158
+ resolved.reason,
4159
+ PULL_REFUSALS[resolved.reason],
4160
+ emitJson,
4161
+ root,
4162
+ )
4163
+ }
4164
+
4165
+ const record = await merge(mergeApi(root, resolved.number), {
4166
+ number: Number(resolved.number),
4167
+ isDryRun: opts.dryRun ?? false,
4168
+ })
4169
+
4170
+ logStep(record.reason)
4171
+ logInfo(
4172
+ record.reason === 'merged'
4173
+ ? `#${record.number} squash-merged as ${record.sha?.slice(0, 8)}`
4174
+ : record.reason === 'dry-run'
4175
+ ? `#${record.number} would merge with the message below`
4176
+ : MERGE_REFUSALS[record.reason],
4177
+ )
4178
+ outro()
4179
+
4180
+ if (emitJson) {
4181
+ process.stdout.write(`${JSON.stringify({ root, ...record })}\n`)
4182
+ } else if (record.reason === 'dry-run') {
4183
+ process.stdout.write(`${record.title}\n\n${record.description}\n`)
4184
+ }
4185
+
4186
+ return record.isOk ? 0 : 1
4187
+ }
4188
+
3728
4189
  /**
3729
4190
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
3730
4191
  * so an operator reading the terminal sees the reason rather than a command
@@ -3734,18 +4195,211 @@ function refuse(reason: Refusal, emitJson: boolean, root: string): number {
3734
4195
  return refuseWith(reason, REFUSALS[reason], emitJson, root)
3735
4196
  }
3736
4197
 
4198
+ /** The scratch folder `review-ui` writes under, at the main worktree root. */
4199
+ const REVIEW_UI_SCRATCH = join('.canon', 'tmp', 'pr', 'review-ui')
4200
+
4201
+ type DriveTarget =
4202
+ | {
4203
+ readonly kind: 'found'
4204
+ readonly main: string
4205
+ readonly worktree: string
4206
+ readonly branch: string
4207
+ readonly number: string | undefined
4208
+ }
4209
+ | {
4210
+ readonly kind: 'refused'
4211
+ readonly reason: DriveCopyRefusal | PullRefusal
4212
+ }
4213
+
4214
+ /**
4215
+ * The worker's worktree and its branch. A named pull request resolves through
4216
+ * its head ref, since a reviewer runs at the main root on the trunk. With no
4217
+ * number the checkout at `root` answers, and a number is looked up only when
4218
+ * the mode needs one, so a worker can build the copy before its pull request
4219
+ * exists.
4220
+ */
4221
+ async function resolveDriveTarget(
4222
+ root: string,
4223
+ number: string | undefined,
4224
+ needsNumber: boolean,
4225
+ ): Promise<DriveTarget> {
4226
+ const worktrees = await listWorktrees(root)
4227
+ const main = worktrees[0]?.path ?? root
4228
+
4229
+ if (number === undefined) {
4230
+ const toplevel = await resolveWorktreeRoot(root)
4231
+ const realPaths = await Promise.all(
4232
+ worktrees.map((candidate) =>
4233
+ realpath(candidate.path).catch(() => candidate.path),
4234
+ ),
4235
+ )
4236
+ const entry = worktrees[realPaths.indexOf(toplevel)]
4237
+ const branch = entry?.branch ?? null
4238
+ if (branch === null) return { kind: 'refused', reason: 'no-branch' }
4239
+ if (!needsNumber) {
4240
+ return { kind: 'found', main, worktree: toplevel, branch, number }
4241
+ }
4242
+ if (Bun.which('gh') === null) {
4243
+ return { kind: 'refused', reason: 'gh-missing' }
4244
+ }
4245
+ const resolved = await resolvePullNumber(root, undefined)
4246
+ if (resolved.kind === 'refused') return resolved
4247
+ return {
4248
+ kind: 'found',
4249
+ main,
4250
+ worktree: toplevel,
4251
+ branch,
4252
+ number: resolved.number,
4253
+ }
4254
+ }
4255
+
4256
+ if (Bun.which('gh') === null) return { kind: 'refused', reason: 'gh-missing' }
4257
+ const read = await readPull(root, number)
4258
+ if (read.kind === 'refused') return read
4259
+ const headRef = read.row.head?.ref
4260
+ if (headRef === undefined || headRef === '') {
4261
+ return { kind: 'refused', reason: 'no-worktree' }
4262
+ }
4263
+ const entry = worktrees.find((candidate) => candidate.branch === headRef)
4264
+ if (entry === undefined) return { kind: 'refused', reason: 'no-worktree' }
4265
+ return {
4266
+ kind: 'found',
4267
+ main,
4268
+ worktree: entry.path,
4269
+ branch: headRef,
4270
+ number,
4271
+ }
4272
+ }
4273
+
4274
+ async function runDriveCopy(
4275
+ number: string | undefined,
4276
+ opts: DriveCopyOptions,
4277
+ ): Promise<number> {
4278
+ const root = resolve(opts.root ?? process.cwd())
4279
+ const emitJson = opts.json ?? false
4280
+ const baseline = opts.baseline ?? false
4281
+ const check = opts.check ?? false
4282
+
4283
+ intro('canon pr drive-copy')
4284
+
4285
+ const refuse = (
4286
+ reason: DriveCopyRefusal | PullRefusal | string,
4287
+ message: string,
4288
+ ) => refuseWith(reason, message, emitJson, root)
4289
+
4290
+ if (baseline && check) {
4291
+ return refuse('bad-flags', DRIVE_COPY_REFUSALS['bad-flags'])
4292
+ }
4293
+
4294
+ const target = await resolveDriveTarget(root, number, baseline || check)
4295
+ if (target.kind === 'refused') {
4296
+ const reason = target.reason
4297
+ return refuse(
4298
+ reason,
4299
+ reason in DRIVE_COPY_REFUSALS
4300
+ ? DRIVE_COPY_REFUSALS[reason as DriveCopyRefusal]
4301
+ : PULL_REFUSALS[reason as PullRefusal],
4302
+ )
4303
+ }
4304
+
4305
+ const scratch = join(target.main, REVIEW_UI_SCRATCH)
4306
+ const baselinePath =
4307
+ target.number === undefined
4308
+ ? undefined
4309
+ : join(scratch, target.number, 'drive-baseline.json')
4310
+ const emit = (record: Record<string, unknown>) => {
4311
+ if (emitJson) {
4312
+ process.stdout.write(
4313
+ `${JSON.stringify({ root, worktree: target.worktree, branch: target.branch, ...record })}\n`,
4314
+ )
4315
+ }
4316
+ }
4317
+
4318
+ if (check && baselinePath !== undefined) {
4319
+ let prior: readonly StatusEntry[]
4320
+ try {
4321
+ prior = JSON.parse(await readFile(baselinePath, 'utf8'))
4322
+ } catch {
4323
+ return refuse('no-baseline', DRIVE_COPY_REFUSALS['no-baseline'])
4324
+ }
4325
+ const current = await readWorktreeStatus(target.worktree)
4326
+ if (current === undefined) {
4327
+ return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
4328
+ }
4329
+ const paths = changedSince(prior, current)
4330
+ if (paths.length === 0) {
4331
+ logStep('Clean')
4332
+ logInfo(`No change in ${target.worktree} since the baseline.`)
4333
+ outro()
4334
+ emit({ reason: 'clean', paths })
4335
+ return 0
4336
+ }
4337
+ logStep('Changed')
4338
+ for (const path of paths) logWarn(path)
4339
+ outro()
4340
+ emit({ reason: 'changed', paths })
4341
+ return 1
4342
+ }
4343
+
4344
+ const config = loadPreviewConfig(target.worktree)
4345
+ if (config.kind === 'refused') return refuse(config.reason, config.detail)
4346
+
4347
+ let copy: Record<string, unknown>
4348
+ if (config.kind === 'absent') {
4349
+ logStep('No config')
4350
+ logInfo('The project declares no preview writes, so nothing was copied.')
4351
+ copy = { reason: 'no-config' }
4352
+ } else {
4353
+ const copyRoot = join(scratch, branchSlug(target.branch), 'board')
4354
+ const { copied, missing } = buildDriveCopy(
4355
+ target.worktree,
4356
+ copyRoot,
4357
+ config.config.writes,
4358
+ )
4359
+ logStep('Copied')
4360
+ logInfo(`${config.config.env}=${copyRoot}`)
4361
+ for (const path of missing) logWarn(`${path} is not in the worktree`)
4362
+ copy = {
4363
+ reason: 'copied',
4364
+ copyRoot,
4365
+ env: config.config.env,
4366
+ copied,
4367
+ missing,
4368
+ }
4369
+ }
4370
+
4371
+ if (baseline && baselinePath !== undefined) {
4372
+ const status = await readWorktreeStatus(target.worktree)
4373
+ if (status === undefined) {
4374
+ return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
4375
+ }
4376
+ await mkdir(join(baselinePath, '..'), { recursive: true })
4377
+ await writeFile(baselinePath, `${JSON.stringify(status)}\n`)
4378
+ logStep('Baseline')
4379
+ logInfo(`Recorded ${plural(status.length, 'status line')}.`)
4380
+ copy = { ...copy, baseline: baselinePath }
4381
+ }
4382
+
4383
+ outro()
4384
+ emit(copy)
4385
+ return 0
4386
+ }
4387
+
3737
4388
  function refuseWith(
3738
4389
  reason: string,
3739
4390
  message: string,
3740
4391
  emitJson: boolean,
3741
4392
  root: string,
4393
+ extra: Record<string, unknown> = {},
3742
4394
  ): number {
3743
4395
  logStep('Refused')
3744
4396
  logWarn(message)
3745
4397
  outro()
3746
4398
 
3747
4399
  if (emitJson) {
3748
- process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
4400
+ process.stdout.write(
4401
+ `${JSON.stringify({ root, reason, message, ...extra })}\n`,
4402
+ )
3749
4403
  }
3750
4404
  return 1
3751
4405
  }