@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/references/task-number.md +2 -0
- package/claude/skills/task-board/references/archive.md +1 -0
- package/docs/agents/tasks-archive.md +1 -1
- package/docs/agents/tasks.md +4 -0
- package/package.json +1 -1
- package/src/commands/labels.ts +3 -3
- package/src/commands/tasks.ts +82 -2
- package/src/labels/format.ts +2 -2
- package/src/tasks/archive.ts +30 -0
- package/src/tasks/record.ts +106 -9
- package/standards/commit.md +1 -1
- package/standards/pr.md +1 -1
- package/standards/tasks.md +2 -2
|
@@ -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
|
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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
package/src/commands/labels.ts
CHANGED
|
@@ -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(
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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()
|
package/src/labels/format.ts
CHANGED
|
@@ -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
|
/**
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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) {
|
package/src/tasks/record.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/standards/commit.md
CHANGED
|
@@ -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
|
|
package/standards/tasks.md
CHANGED
|
@@ -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
|
|