@erclx/canon 4.126.0 → 4.127.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.126.0",
4
+ "version": "4.127.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -21,6 +21,8 @@ The verb resolves the board at the main worktree root in-process, adds `Pull req
21
21
 
22
22
  The correction reaches only a line that does not read as a list of `#NNN` entries. A line that does takes the new number appended rather than replaced, reported as `appended`, so a task shipped in slices keeps every pull request.
23
23
 
24
+ The same write drops this branch from the task's `Pending branch:` line, which `canon tasks outcome` wrote when the branch ticked an outcome, so this step is what lets the merge-time archive close the task.
25
+
24
26
  Skip this silently when the record is `ok: false` and `reason` is `no-board`, `no-match`, or `ambiguous`. Those are the three cases a guessed write would compound: no board, no task naming the plan, or more than one. One pull request names one task, and a wrong match archives the wrong task unattended once the branch merges. Report any other refusal rather than swallowing it.
25
27
 
26
28
  The number is what lets the merge close the task. Every merge on `main` is a squash carrying it in the subject, so the number survives where a branch name does not, and `post-merge` reads it back to call `canon tasks archive`. Writing it here rather than at worktree time is what makes it a pull request number rather than a branch the squash discards.
@@ -42,6 +42,7 @@ Each reason has one resolution and none of them is to archive around it:
42
42
  - `open-outcomes`: the named outcomes are unmarked or genuinely open. Run `context-fold` when the work shipped and nothing marked it. Leave the task on the board when the outcome is real. When the work is being abandoned, cut it by striking the body, `- ~~<outcome>~~ <why>`, whatever the checkbox holds, so the board records what was dropped rather than meeting this refusal a second time.
43
43
  - `ambiguous`: two tasks name one pull request, which is the misfile `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` rules out. Resolve the citation by hand, since no sweep repairs it.
44
44
  - `earlier-slice`: the number is listed on the task but is not the last, so a later slice is still to merge. Leave the task on the board. Its last slice's merge archives it.
45
+ - `pending-branch`: a branch named in `detail` ticked an outcome before its own pull request number was recorded. Leave the task live, since that branch's merge archives it once `git-pr` records the number. When the operator decides a named branch was abandoned, archive by stem, which is the one case for it.
45
46
  - `no-match`: the stem or number names nothing on the board. Check the name against the listed stems.
46
47
  - `bad-input`: the command line was wrong rather than the board. Read the message, fix the arguments, and run it again. Nothing on the board needs repair, which is what separates this from the two above.
47
48
 
@@ -22,7 +22,7 @@ canon tasks archive --pull-request 673 --json
22
22
  | `--json` | Emit a machine-readable record on stdout |
23
23
  | `--root <path>` | Board root, defaulting to the main worktree |
24
24
 
25
- Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `earlier-slice`, or `bad-input`. `earlier-slice` fires when `--pull-request` names a number that is not the last on the task's `Pull request:` line, since outcomes are ticked at ship time and only the last slice's merge closes the task.
25
+ Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `earlier-slice`, `pending-branch`, or `bad-input`. `earlier-slice` fires when `--pull-request` names a number that is not the last on the task's `Pull request:` line, since outcomes are ticked at ship time and only the last slice's merge closes the task. `pending-branch` fires when `--pull-request` meets a `Pending branch:` line, since a branch that ticked an outcome before `git-pr` recorded its number means the last number listed is not yet the last slice. The `detail` array names each pending branch. Selecting by stem skips both checks.
26
26
 
27
27
  An outcome whose body is struck reads as cut rather than open or closed, whatever its checkbox holds: `- ~~<outcome>~~ <why>`. A task carrying only cut outcomes archives, since the gate refuses `no-outcomes` only when both the closed and the cut counts are zero. The success record carries `closed` and `cut` as counts, so a reader tells a shipped task from an abandoned one without opening the file.
28
28
 
@@ -60,6 +60,8 @@ canon tasks pull-request 673 --plan worktree-scratch-routing --json
60
60
 
61
61
  A plan is matched on the token both spellings share, so `worktree-scratch-routing`, `feature-worktree-scratch-routing`, and `.canon/plans/feature-worktree-scratch-routing.md` all select the same task. The `action` field reports `added`, `appended`, `corrected`, or `unchanged`, which makes a rerun against the same number safe.
62
62
 
63
+ Run from a branch other than `main` or `master`, the verb also drops that branch from the task's `Pending branch:` line, along with every name its reflog says it was renamed from, and removes the line once it is empty. The renamed names matter because `git-ship` renames a nonconforming branch after `context-fold` wrote the marker. It does so on an `unchanged` run too, so a rerun repairs a marker left behind. The `pending` field lists the branches still on the line after the write.
64
+
63
65
  Exit codes: `0` recorded, `1` refused. The `reason` field carries `no-board`, `no-match`, or `ambiguous`. `git-pr` skips silently on those three, because each is a case where a guessed write would archive the wrong task once the branch merges.
64
66
 
65
67
  A malformed argument refuses as `bad-input` instead, which sits outside that set on purpose. `git-pr` derives the number by slicing whatever `gh pr create` printed, so a non-numeric value is reachable, and folding it into the swallowed set would lose the number with nothing reporting it.
@@ -82,6 +84,8 @@ canon tasks outcome --plan worktree-scratch-routing --close 2 --json
82
84
 
83
85
  An outcome already closed comes back under `alreadyClosed` rather than refusing, so a rerun against the same positions changes nothing. A position past the end of the list refuses as `out-of-range`, since a caller counting wrong should hear about it rather than mark a neighbor.
84
86
 
87
+ A close that ticks at least one box from a branch other than `main` or `master` names that branch on a `Pending branch:` line, read from `git branch --show-current` in the working directory. The line holds the task against `canon tasks archive --pull-request` until `canon tasks pull-request` records that branch's own number. A detached HEAD, a trunk checkout, or a failed git read writes no line and refuses nothing. The `pending` field lists the branches on the line after the write.
88
+
85
89
  Positions skip fenced blocks. A checkbox inside a sample a task displays is not an outcome the task claims, and counting one would shift every position after it. `canon tasks archive` reads the list through the same walker, so the two verbs cannot disagree about which checkboxes are outcomes.
86
90
 
87
91
  Exit codes: `0` closed, `1` refused. The `reason` field adds `no-outcomes`, `out-of-range`, and `bad-input` to the three above.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.126.0",
4
+ "version": "4.127.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -25,7 +25,7 @@ interface ScanOptions {
25
25
 
26
26
  /** What a reader does about each way `checkTitleFormat` graded a title as broken. */
27
27
  const TITLE_FORMAT_MESSAGES: Record<TitleFormatIssue, string> = {
28
- structure: 'does not match <type>(<scope>): <subject>',
28
+ structure: 'does not match <type>(<scope>)[!]: <subject>',
29
29
  'casing-type': 'the type is not lowercase',
30
30
  'casing-scope': 'the scope is not lowercase',
31
31
  'casing-subject': 'the first word of the subject is not lowercase',
@@ -153,7 +153,7 @@ export function register(program: Command): void {
153
153
  'forcing a new dependency.',
154
154
  '',
155
155
  "It also grades the title alone against standards/pr.md's ## Title",
156
- 'section: the `<type>(<scope>): <subject>` structure, lowercase casing',
156
+ 'section: the `<type>(<scope>)[!]: <subject>` structure, lowercase casing',
157
157
  'for the type, the scope, and the first subject word, and a 72-',
158
158
  'character length cap. A scan given no title, whether from a review',
159
159
  'or from a bare --body or --body-file invocation, skips this check',
@@ -374,7 +374,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
374
374
  logInfo('the scan carries no title, so there is no format to grade')
375
375
  } else if (titleFormat.conforms) {
376
376
  logInfo(
377
- 'the title matches <type>(<scope>): <subject> and its casing and length rules',
377
+ 'the title matches <type>(<scope>)[!]: <subject> and its casing and length rules',
378
378
  )
379
379
  } else {
380
380
  logWarn(
@@ -64,6 +64,8 @@ const EXIT_FINDINGS = 2
64
64
  /** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
65
65
  const GIT_TIMEOUT_MS = 10_000
66
66
 
67
+ const TRUNK_BRANCHES: readonly string[] = ['main', 'master']
68
+
67
69
  interface ArchiveCommandOptions {
68
70
  readonly json?: boolean
69
71
  readonly pullRequest?: string
@@ -164,6 +166,10 @@ export function register(program: Command): void {
164
166
  'folder that does not resolve, or a destination already taken, moves',
165
167
  'nothing and refuses nothing.',
166
168
  '',
169
+ 'With --pull-request, it refuses earlier-slice when a later number is',
170
+ 'listed, and pending-branch while a Pending branch: line names a branch',
171
+ 'whose own pull request is not recorded yet. A stem skips both.',
172
+ '',
167
173
  'Exit codes:',
168
174
  ' 0 the task was archived',
169
175
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -420,6 +426,10 @@ export function register(program: Command): void {
420
426
  'list is replaced. The board is shared scratch, so a linked worktree',
421
427
  'records against the same board.',
422
428
  '',
429
+ 'It also drops the current branch, and each name it was renamed from,',
430
+ 'from the Pending branch: line, even when the number was already',
431
+ 'listed, and removes the line once empty.',
432
+ '',
423
433
  'Examples:',
424
434
  ' canon tasks pull-request 673 v28.1-trigger-escalation',
425
435
  ' canon tasks pull-request 673 --plan worktree-scratch-routing --json',
@@ -497,6 +507,11 @@ export function register(program: Command): void {
497
507
  'An outcome already closed is reported rather than refused, so a rerun',
498
508
  'against the same positions is safe.',
499
509
  '',
510
+ 'Run from a branch other than main or master, a close that ticks a new',
511
+ 'box names that branch on a Pending branch: line, which holds the task',
512
+ 'against a merge-time archive until canon tasks pull-request records',
513
+ "the branch's own number.",
514
+ '',
500
515
  'Examples:',
501
516
  ' canon tasks outcome v28.1-trigger-escalation --close 1 --close 3',
502
517
  ' canon tasks outcome --plan worktree-scratch-routing --close 2 --json',
@@ -644,11 +659,63 @@ async function runPullRequest(
644
659
  }
645
660
 
646
661
  const root = opts.root ?? (await mainWorktreeRoot())
647
- const outcome = await recordPullRequest(root, selector, Number(number))
662
+ const branch = await workingBranch()
663
+ const outcome = await recordPullRequest(
664
+ root,
665
+ selector,
666
+ Number(number),
667
+ branch ? [branch, ...(await formerNames(branch))] : [],
668
+ )
648
669
 
649
670
  return reportPullRequest(outcome, emitJson, root)
650
671
  }
651
672
 
673
+ const RENAME_PATTERN = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\//
674
+
675
+ /**
676
+ * Names the branch carried before each `git branch -m`, read off its reflog,
677
+ * which moves with a rename and records every earlier one. A marker written
678
+ * under an old name is otherwise one nothing ever clears.
679
+ */
680
+ async function formerNames(branch: string): Promise<readonly string[]> {
681
+ const result = await execa(
682
+ 'git',
683
+ [
684
+ '-C',
685
+ process.cwd(),
686
+ 'reflog',
687
+ 'show',
688
+ '--format=%gs',
689
+ `refs/heads/${branch}`,
690
+ ],
691
+ { reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
692
+ )
693
+ if (result.exitCode !== 0) return []
694
+
695
+ return result.stdout
696
+ .split('\n')
697
+ .map((line) => RENAME_PATTERN.exec(line)?.[1])
698
+ .filter((name): name is string => name !== undefined)
699
+ }
700
+
701
+ /**
702
+ * The branch checked out where the command runs, which is where the work is,
703
+ * rather than the board root's. Undefined on trunk, a detached HEAD, or a
704
+ * failed read, where no slice is in flight to hold the task open.
705
+ */
706
+ async function workingBranch(): Promise<string | undefined> {
707
+ const result = await execa(
708
+ 'git',
709
+ ['-C', process.cwd(), 'branch', '--show-current'],
710
+ { reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
711
+ )
712
+
713
+ const branch = result.exitCode === 0 ? result.stdout.trim() : ''
714
+ if (branch.length === 0 || TRUNK_BRANCHES.includes(branch)) return undefined
715
+
716
+ return branch
717
+ }
718
+
652
719
  async function runPlanLink(
653
720
  task: string,
654
721
  plan: string,
@@ -710,7 +777,12 @@ async function runOutcome(
710
777
  }
711
778
 
712
779
  const root = opts.root ?? (await mainWorktreeRoot())
713
- const outcome = await closeOutcomes(root, selector, raw.map(Number))
780
+ const outcome = await closeOutcomes(
781
+ root,
782
+ selector,
783
+ raw.map(Number),
784
+ await workingBranch(),
785
+ )
714
786
 
715
787
  return reportOutcome(outcome, emitJson, root)
716
788
  }
@@ -765,6 +837,7 @@ function reportPullRequest(
765
837
  path: relative(root, outcome.path),
766
838
  pullRequest: outcome.number,
767
839
  action: outcome.action,
840
+ pending: outcome.pending,
768
841
  })}\n`,
769
842
  )
770
843
  return 0
@@ -773,6 +846,9 @@ function reportPullRequest(
773
846
  intro('canon tasks pull-request')
774
847
  logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
775
848
  logInfo(`${outcome.stem} names pull request #${outcome.number}`)
849
+ if (outcome.pending.length > 0) {
850
+ logInfo(`still pending on ${outcome.pending.join(', ')}`)
851
+ }
776
852
  if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
777
853
  outro()
778
854
 
@@ -831,6 +907,7 @@ function reportOutcome(
831
907
  path: relative(root, outcome.path),
832
908
  closed: outcome.closed,
833
909
  alreadyClosed: outcome.alreadyClosed,
910
+ pending: outcome.pending,
834
911
  })}\n`,
835
912
  )
836
913
  return 0
@@ -841,6 +918,9 @@ function reportOutcome(
841
918
 
842
919
  for (const closed of outcome.closed) logAdd(closed)
843
920
  for (const already of outcome.alreadyClosed) logInfo(`${already} (already)`)
921
+ if (outcome.pending.length > 0) {
922
+ logInfo(`pending on ${outcome.pending.join(', ')}`)
923
+ }
844
924
 
845
925
  if (outcome.closed.length > 0) logInfo(relative(root, outcome.path))
846
926
  outro()
@@ -22,8 +22,8 @@ const TITLE_MAX_LENGTH = 72
22
22
 
23
23
  // Casing-agnostic on purpose. Casing is graded separately once the shape
24
24
  // matches, so `type` and `scope` accept either case here and `structure`
25
- // reports only a title with no `<type>(<scope>): <subject>` shape at all.
26
- const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\): (.+)$/
25
+ // reports only a title with no `<type>(<scope>)[!]: <subject>` shape at all.
26
+ const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\)!?: (.+)$/
27
27
  const LEADING_LETTERS = /^[A-Za-z]+/
28
28
 
29
29
  /**
@@ -47,6 +47,7 @@ export const ARCHIVE_REFUSALS = [
47
47
  'no-outcomes',
48
48
  'open-outcomes',
49
49
  'earlier-slice',
50
+ 'pending-branch',
50
51
  'bad-input',
51
52
  ] as const
52
53
 
@@ -251,6 +252,22 @@ export function readPullRequest(text: string): readonly number[] {
251
252
  return match[1].split(',').map((entry) => Number(entry.trim().slice(1)))
252
253
  }
253
254
 
255
+ /**
256
+ * Reads the `Pending branch:` line `canon tasks outcome` writes when a branch
257
+ * ticks an outcome ahead of its own pull request number reaching the task.
258
+ * `canon tasks pull-request` clears each branch as it records, so a name left
259
+ * here is a slice whose number the `Pull request:` line cannot list yet.
260
+ */
261
+ export function readPendingBranches(text: string): readonly string[] {
262
+ const match = /^Pending branch:(.*)$/m.exec(text)
263
+ if (!match) return []
264
+
265
+ return match[1]
266
+ .split(',')
267
+ .map((entry) => entry.trim())
268
+ .filter((entry) => entry.length > 0)
269
+ }
270
+
254
271
  /** A markdown link on a `Plan:` line, its target captured. */
255
272
  const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
256
273
 
@@ -860,6 +877,19 @@ export async function archiveTask(
860
877
  )
861
878
  }
862
879
 
880
+ // A branch ticks its outcome before `git-pr` appends its number, so in that
881
+ // window the number above reads as last while it is not. The stem selector
882
+ // skips this check the way it skips the one above, since a hand archive is
883
+ // the route out of a pending branch that was abandoned.
884
+ const pending = readPendingBranches(text)
885
+ if (selector.kind === 'pull-request' && pending.length > 0) {
886
+ return refuse(
887
+ 'pending-branch',
888
+ `${stem} still waits on ${pending.join(', ')}, which closed an outcome before its own pull request was recorded, so the task closes when that pull request merges.`,
889
+ pending,
890
+ )
891
+ }
892
+
863
893
  const { open, closed, cut } = readOutcomes(text)
864
894
 
865
895
  if (open.length > 0) {
@@ -10,6 +10,7 @@ import {
10
10
  OUTCOME_PATTERN,
11
11
  planLine,
12
12
  planLineIndex,
13
+ readPendingBranches,
13
14
  readPlanTargets,
14
15
  readPullRequest,
15
16
  tasksDir,
@@ -63,6 +64,8 @@ export interface PullRequestRecorded {
63
64
  readonly path: string
64
65
  readonly number: number
65
66
  readonly action: LineAction
67
+ /** Branches still on the `Pending branch:` line after this write. */
68
+ readonly pending: readonly string[]
66
69
  }
67
70
 
68
71
  export type PullRequestOutcome = PullRequestRecorded | RecordRefused
@@ -85,6 +88,8 @@ export interface OutcomesClosed {
85
88
  readonly path: string
86
89
  readonly closed: readonly string[]
87
90
  readonly alreadyClosed: readonly string[]
91
+ /** Branches on the `Pending branch:` line after this write. */
92
+ readonly pending: readonly string[]
88
93
  }
89
94
 
90
95
  export type CloseOutcome = OutcomesClosed | RecordRefused
@@ -146,16 +151,77 @@ export function writePullRequestLine(
146
151
  return { text: lines.join('\n'), action: 'added' }
147
152
  }
148
153
 
149
- function lastOriginLine(lines: readonly string[]): number | undefined {
154
+ function lastOriginLine(
155
+ lines: readonly string[],
156
+ prefixes: readonly string[] = ORIGIN_PREFIXES,
157
+ ): number | undefined {
150
158
  let found: number | undefined
151
159
 
152
160
  for (const [index, line] of lines.entries()) {
153
- if (ORIGIN_PREFIXES.some((prefix) => line.startsWith(prefix))) found = index
161
+ if (prefixes.some((prefix) => line.startsWith(prefix))) found = index
154
162
  }
155
163
 
156
164
  return found
157
165
  }
158
166
 
167
+ const PENDING_PREFIX = 'Pending branch:'
168
+
169
+ /**
170
+ * Names a branch on the `Pending branch:` line, which holds the task open
171
+ * against a merge-time archive until that branch's own number is recorded. The
172
+ * line sits under `Pull request:` when one exists, since a later slice ticks
173
+ * after an earlier slice's number is already listed.
174
+ */
175
+ export function writePendingBranch(
176
+ text: string,
177
+ branch: string,
178
+ ): { readonly text: string; readonly changed: boolean } {
179
+ const lines = text.split('\n')
180
+ const existing = lines.findIndex((entry) => entry.startsWith(PENDING_PREFIX))
181
+
182
+ if (existing !== -1) {
183
+ const pending = readPendingBranches(lines[existing])
184
+ if (pending.includes(branch)) return { text, changed: false }
185
+ lines[existing] = `${PENDING_PREFIX} ${[...pending, branch].join(', ')}`
186
+ return { text: lines.join('\n'), changed: true }
187
+ }
188
+
189
+ const line = `${PENDING_PREFIX} ${branch}`
190
+ const anchor = lastOriginLine(lines, [...ORIGIN_PREFIXES, 'Pull request:'])
191
+ if (anchor !== undefined) {
192
+ lines.splice(anchor + 1, 0, line)
193
+ return { text: lines.join('\n'), changed: true }
194
+ }
195
+
196
+ const heading = lines.findIndex((entry) => entry.startsWith('# '))
197
+ if (heading === -1) return { text: `${line}\n${text}`, changed: true }
198
+
199
+ lines.splice(heading + 1, 0, '', line)
200
+ return { text: lines.join('\n'), changed: true }
201
+ }
202
+
203
+ /** Drops a branch from the `Pending branch:` line, and the line once it is empty. */
204
+ export function clearPendingBranch(
205
+ text: string,
206
+ branch: string,
207
+ ): { readonly text: string; readonly changed: boolean } {
208
+ const lines = text.split('\n')
209
+ const existing = lines.findIndex((entry) => entry.startsWith(PENDING_PREFIX))
210
+ if (existing === -1) return { text, changed: false }
211
+
212
+ const pending = readPendingBranches(lines[existing])
213
+ if (!pending.includes(branch)) return { text, changed: false }
214
+
215
+ const rest = pending.filter((entry) => entry !== branch)
216
+ if (rest.length > 0) {
217
+ lines[existing] = `${PENDING_PREFIX} ${rest.join(', ')}`
218
+ } else {
219
+ lines.splice(existing, 1)
220
+ }
221
+
222
+ return { text: lines.join('\n'), changed: true }
223
+ }
224
+
159
225
  export type PlanLineWrite =
160
226
  | {
161
227
  readonly ok: true
@@ -330,24 +396,43 @@ async function openTask(
330
396
  * this from a linked worktree, where an in-place edit through the file-editing
331
397
  * tools is refused and a shell stream editor is banned, so the write has to
332
398
  * resolve the board root in-process.
399
+ *
400
+ * `branches` names the recording branch and every name it carried before a
401
+ * rename, since `git-ship` renames a branch after `context-fold` has already
402
+ * written the marker under the old name. Each comes off the `Pending branch:`
403
+ * line even when the number was already listed, so a rerun repairs a stale
404
+ * marker.
333
405
  */
334
406
  export async function recordPullRequest(
335
407
  root: string,
336
408
  selector: RecordSelector,
337
409
  number: number,
410
+ branches: readonly string[] = [],
338
411
  ): Promise<PullRequestOutcome> {
339
412
  const opened = await openTask(root, selector)
340
413
  if ('ok' in opened) return opened
341
414
 
342
415
  const { stem, path } = opened
343
- const { text, action } = writePullRequestLine(
344
- await readFile(path, 'utf8'),
345
- number,
346
- )
416
+ const numbered = writePullRequestLine(await readFile(path, 'utf8'), number)
417
+ let text = numbered.text
418
+ let isCleared = false
419
+
420
+ for (const branch of branches) {
421
+ const cleared = clearPendingBranch(text, branch)
422
+ text = cleared.text
423
+ isCleared ||= cleared.changed
424
+ }
347
425
 
348
- if (action !== 'unchanged') await writeFile(path, text)
426
+ if (numbered.action !== 'unchanged' || isCleared) await writeFile(path, text)
349
427
 
350
- return { ok: true, stem, path, number, action }
428
+ return {
429
+ ok: true,
430
+ stem,
431
+ path,
432
+ number,
433
+ action: numbered.action,
434
+ pending: readPendingBranches(text),
435
+ }
351
436
  }
352
437
 
353
438
  /**
@@ -399,11 +484,17 @@ export async function recordPlan(
399
484
  * Marks the named outcomes `[x]` in place. `context-fold` runs this against a
400
485
  * board it can read and cannot edit from a linked worktree, and the positions
401
486
  * come from the read it already made.
487
+ *
488
+ * A `branch` that ticks at least one new box goes on the `Pending branch:`
489
+ * line, so the merge of an earlier slice cannot archive the task in the window
490
+ * before this branch's own number is recorded. A rerun that ticks nothing adds
491
+ * no marker, since its branch has already recorded or is already listed.
402
492
  */
403
493
  export async function closeOutcomes(
404
494
  root: string,
405
495
  selector: RecordSelector,
406
496
  positions: readonly number[],
497
+ branch?: string,
407
498
  ): Promise<CloseOutcome> {
408
499
  const opened = await openTask(root, selector)
409
500
  if ('ok' in opened) return opened
@@ -427,7 +518,12 @@ export async function closeOutcomes(
427
518
  )
428
519
  }
429
520
 
430
- if (result.closed.length > 0) await writeFile(path, result.text)
521
+ const text =
522
+ result.closed.length > 0 && branch
523
+ ? writePendingBranch(result.text, branch).text
524
+ : result.text
525
+
526
+ if (result.closed.length > 0) await writeFile(path, text)
431
527
 
432
528
  return {
433
529
  ok: true,
@@ -435,5 +531,6 @@ export async function closeOutcomes(
435
531
  path,
436
532
  closed: result.closed,
437
533
  alreadyClosed: result.alreadyClosed,
534
+ pending: readPendingBranches(text),
438
535
  }
439
536
  }
@@ -17,7 +17,7 @@ Does not govern:
17
17
 
18
18
  ## Format
19
19
 
20
- - Structure: `<type>(<scope>): <subject>`
20
+ - Structure: `<type>(<scope>): <subject>`, with `!` before the colon for a breaking change
21
21
  - Casing: lowercase for `<type>`, `<scope>`, and first word of `<subject>`
22
22
  - Subject: 72 characters maximum, no trailing period
23
23
 
package/standards/pr.md CHANGED
@@ -20,7 +20,7 @@ Does not govern:
20
20
 
21
21
  ## Title
22
22
 
23
- - Format: `<type>(<scope>): <subject>`
23
+ - Format: `<type>(<scope>): <subject>`, with `!` before the colon for a breaking change
24
24
  - Casing: lowercase for `<type>`, `<scope>`, and first word of `<subject>`
25
25
  - Length: 72 characters maximum
26
26
 
@@ -72,7 +72,7 @@ Add no third heading. Status stays inline on an outcome rather than becoming an
72
72
 
73
73
  Size the outcomes so one pull request closes all of them, and split the task before handing it off rather than after. A task closes whole or not at all, so outcomes spanning two pull requests leave the board showing in-progress work no branch carries.
74
74
 
75
- A sliced task is the one allowed exception: a task the operator decided to ship across several pull requests on purpose, one slice each. Its `Pull request:` line lists every slice, so the board records which merged work covered which outcomes, and it closes whole when the last slice's merge finds every outcome ticked.
75
+ A sliced task is the one allowed exception: a task the operator decided to ship across several pull requests on purpose, one slice each. Its `Pull request:` line lists every slice, so the board records which merged work covered which outcomes, and it closes whole when the last slice's merge finds every outcome ticked and no slice left pending.
76
76
 
77
77
  Prefix the H1 with the `vX.Y:` phase label, then a short title whose form depends on the task type:
78
78
 
@@ -134,7 +134,7 @@ One plan per task. A plan cited by two tasks is a misfile rather than a shape to
134
134
 
135
135
  `Pull request:` records which pull requests carry the task's work, each as a bare `#NNN` the way `Issue:` does. It lists every pull request that shipped part of the task, oldest first and separated by commas, as in `Pull request: #NNN, #NNN`, and a task shipped whole lists one. It is not an origin, so a task without one is well-formed. `git-pr` appends to it when a pull request opens.
136
136
 
137
- The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving.
137
+ The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving. `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number, since `canon tasks outcome` writes it and `canon tasks pull-request` clears it.
138
138
 
139
139
  ## What goes in
140
140