@erclx/canon 4.125.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/README.md CHANGED
@@ -96,7 +96,7 @@ Working with an agent rather than reading this yourself? It loads `CLAUDE.md` at
96
96
  - [AI workflow](docs/workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
97
97
  - [Operating model](docs/workflow/operating-model.md): orchestrator, planner, and worker roles for building across parallel sessions
98
98
  - [Visual design workflow](docs/workflow/visual-design-workflow.md): tiered guide for design and wireframe authoring
99
- - [Target projects](docs/target-projects.md): scaffold, add a domain later, sync upstream drift
99
+ - [Target projects](docs/target/target-projects.md): scaffold, add a domain later, sync upstream drift
100
100
  - [Agents](docs/agents/index.md): CLI flags, exit codes, and JSON output shapes
101
101
  - [Docs index](docs/index.md): every reference doc in this repo
102
102
 
@@ -152,7 +152,7 @@ canon init
152
152
 
153
153
  Pass `--stack <name>` for a framework stack, or `--skip governance` to leave rules out. A snippet resolves the same way, reached at its `@` reference through the plugin's live `claude/snippets` symlink rather than a copy. Run `canon tooling list --json` to see the catalog.
154
154
 
155
- For the full journey from scaffold through adding a domain later to syncing upstream drift, see [target projects](docs/target-projects.md).
155
+ For the full journey from scaffold through adding a domain later to syncing upstream drift, see [target projects](docs/target/target-projects.md).
156
156
 
157
157
  ### Internal narrative
158
158
 
@@ -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.125.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/docs/index.md CHANGED
@@ -7,14 +7,6 @@ subtitle: One-line reference for each doc in this folder.
7
7
 
8
8
  One-line reference for each doc in this folder.
9
9
 
10
- ## Agent surface
11
-
12
- - [Target migrations](target-migrations.md): One-time moves a project scaffolded under an older toolkit runs before its next sync
13
- - [Target projects](target-projects.md): Scaffold, add domains later, and sync upstream drift in a toolkit-managed project
14
- - [Target scenarios](target-scenarios.md): Worked setups for a markdown-heavy project, a web application, and a monorepo
15
- - [Target sync](target-sync.md): Check what drifted in a toolkit-managed project and pull it in, per domain or all at once
16
-
17
- ## Sub-catalogs
18
-
19
10
  - [Agents](agents/index.md): CLI catalog and invocation rules for agents, split by command domain. Start with overview.
11
+ - [Target](target/index.md): Scaffold, sync, and migrate a toolkit-managed project, with worked setups for three project shapes.
20
12
  - [Workflow](workflow/index.md): How a person and their agents run a feature, from the design tier through the parallel-session loop to the shell that launches them.
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Target
3
+ subtitle: Scaffold, sync, and migrate a toolkit-managed project, with worked setups for three project shapes.
4
+ category: Agent surface
5
+ ---
6
+
7
+ # Target
8
+
9
+ Scaffold, sync, and migrate a toolkit-managed project, with worked setups for three project shapes.
10
+
11
+ ## Agent surface
12
+
13
+ - [Target migrations](target-migrations.md): One-time moves a project scaffolded under an older toolkit runs before its next sync
14
+ - [Target projects](target-projects.md): Scaffold, add domains later, and sync upstream drift in a toolkit-managed project
15
+ - [Target scenarios](target-scenarios.md): Worked setups for a markdown-heavy project, a web application, and a monorepo
16
+ - [Target sync](target-sync.md): Check what drifted in a toolkit-managed project and pull it in, per domain or all at once
@@ -8,7 +8,7 @@ category: Agent surface
8
8
 
9
9
  How a project outside this repo consumes the toolkit across its lifecycle. Three phases: scaffold once, add a domain later when a new need appears, and sync when the upstream toolkit moves.
10
10
 
11
- This doc stays at the narrative layer. For command flags and JSON shapes, see [agents](agents/index.md). For per-domain mechanics, see each `canon/context/<domain>.md`.
11
+ This doc stays at the narrative layer. For command flags and JSON shapes, see [agents](../agents/index.md). For per-domain mechanics, see each `canon/context/<domain>.md`.
12
12
 
13
13
  ## Getting the skills
14
14
 
@@ -74,7 +74,7 @@ Scaffold installs tooling and seeds. It does not fill the planning docs or the d
74
74
  1. Fill `canon/REQUIREMENTS.md` and `canon/ARCHITECTURE.md`. The seed provides the files, the scope and decisions are yours to write.
75
75
  2. For a UI project, invoke `canon:design-extract` to draft `canon/DESIGN.md`. With no UI code yet it takes the greenfield path and proposes tokens from the requirements and a `## Personality` section. Skip for non-UI projects.
76
76
  3. Optionally invoke `canon:draft-diagram` to draft entries under `.canon/diagrams/` from the architecture and the requirements. One file per diagram kind, so a later refresh of one kind leaves the others untouched. It renders each diagram it writes to verify the layout, which downloads the Mermaid CLI on first use and takes about 15 seconds.
77
- 4. Start the feature loop. See [AI workflow](workflow/ai-workflow.md) for the per-feature sequence.
77
+ 4. Start the feature loop. See [AI workflow](../workflow/ai-workflow.md) for the per-feature sequence.
78
78
 
79
79
  A machine without a renderer still gets the diagrams and is told which check was skipped.
80
80
 
@@ -135,8 +135,8 @@ When the toolkit updates, a target pulls changes per domain. A project scaffolde
135
135
 
136
136
  ## Related
137
137
 
138
- - [agents](agents/index.md): CLI flags, exit codes, and JSON output shapes
138
+ - [agents](../agents/index.md): CLI flags, exit codes, and JSON output shapes
139
139
  - [target migrations](target-migrations.md), [target sync](target-sync.md), [target scenarios](target-scenarios.md): the one-time moves, the recurring sync, and worked setups
140
- - [AI workflow](workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
141
- - [tooling](../canon/context/tooling/index.md), [governance](../canon/context/governance/index.md), [claude plugin](../canon/context/claude-plugin/index.md), [indexes](../canon/context/indexes.md), [snippets](../canon/context/snippets.md), [standards](../canon/context/standards/index.md): per-domain mechanics
142
- - [sandbox](../canon/context/sandbox/index.md): scenario catalog for verifying domain flows
140
+ - [AI workflow](../workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
141
+ - [tooling](../../canon/context/tooling/index.md), [governance](../../canon/context/governance/index.md), [claude plugin](../../canon/context/claude-plugin/index.md), [indexes](../../canon/context/indexes.md), [snippets](../../canon/context/snippets.md), [standards](../../canon/context/standards/index.md): per-domain mechanics
142
+ - [sandbox](../../canon/context/sandbox/index.md): scenario catalog for verifying domain flows
@@ -84,7 +84,7 @@ Use a targeted entry point when only one surface moved upstream. Use the catch-a
84
84
 
85
85
  ## Verify a sync
86
86
 
87
- Before running a sync against a real project, run the relevant sandbox scenario. The sandbox provisions a representative project state and routes `SANDBOX_SCENARIO=sync` through the domain flow. See [sandbox](../canon/context/sandbox/index.md) for the scenario catalog and routing patterns.
87
+ Before running a sync against a real project, run the relevant sandbox scenario. The sandbox provisions a representative project state and routes `SANDBOX_SCENARIO=sync` through the domain flow. See [sandbox](../../canon/context/sandbox/index.md) for the scenario catalog and routing patterns.
88
88
 
89
89
  ## Running sync from an agent session
90
90
 
@@ -44,7 +44,7 @@ Run `canon init` to seed the `.claude/` directory, a root `CLAUDE.md` file, and
44
44
 
45
45
  ### Bootstrap a new project
46
46
 
47
- See [target projects](../target-projects.md) for the scaffold decision, core domains and skips, and the full lifecycle across scaffold, add-a-domain-later, and upstream sync.
47
+ See [target projects](../target/target-projects.md) for the scaffold decision, core domains and skips, and the full lifecycle across scaffold, add-a-domain-later, and upstream sync.
48
48
 
49
49
  ### New feature
50
50
 
@@ -6,7 +6,7 @@ category: Workflow
6
6
 
7
7
  # Skill map
8
8
 
9
- Groups run in the order a project meets them, so a reader at a known point scans to that group and reads across. The set reconciles the scenarios in [AI workflow](ai-workflow.md) with the lifecycle [target projects](../target-projects.md) describes, rather than inventing a third vocabulary beside those two, so a group name matches neither source exactly and every moment either one names has a group. Each row says when to reach for the skill. What it does is the skill's own description.
9
+ Groups run in the order a project meets them, so a reader at a known point scans to that group and reads across. The set reconciles the scenarios in [AI workflow](ai-workflow.md) with the lifecycle [target projects](../target/target-projects.md) describes, rather than inventing a third vocabulary beside those two, so a group name matches neither source exactly and every moment either one names has a group. Each row says when to reach for the skill. What it does is the skill's own description.
10
10
 
11
11
  This page is the corpus the coverage claim is measured against: every name `canon claude skills list --names` reports takes exactly one row here. A skill serving two moments sits at the earlier one, and a mention on any other page is prose rather than routing.
12
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.125.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()
@@ -312,10 +312,9 @@ export const skillProvenance: Measure = async (ctx) => {
312
312
  /**
313
313
  * Every tracked markdown document against the whole-document ceiling.
314
314
  *
315
- * Report-only for now: a document past the ceiling warns and the stage still
316
- * passes, since the corpus carried dozens past it when the stage landed. The
317
- * flip to a failure is a separate change once the count reads zero, and it
318
- * replaces the warn path rather than sitting beside it.
315
+ * A document past the ceiling fails the stage. The failure names every one with
316
+ * its height rather than the first, so a contributor sees the whole set in one
317
+ * pass, and an exempt document is counted and never named.
319
318
  *
320
319
  * Read in-process rather than through `canon markdown audit`, whose exit code
321
320
  * stays bans and dead links only. The plan and groundwork skills run that audit
@@ -353,15 +352,12 @@ export const documentCeiling: Measure = async (ctx) => {
353
352
  }
354
353
  }
355
354
 
355
+ const where = over
356
+ .map((finding) => `${finding.rel} ${finding.renderedLines} rendered lines`)
357
+ .join(', ')
356
358
  return {
357
- emissions: [
358
- ...over.map((finding) =>
359
- warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
360
- ),
361
- info(
362
- `${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
363
- ),
364
- ],
359
+ emissions: [],
360
+ failure: `${plural(over.length, 'document')} past the ${ceiling}-line ceiling: ${where}. Split each at a seam, or for a verbatim record add a \`<!-- canon-length-exempt: <reason> -->\` line naming why.`,
365
361
  }
366
362
  }
367
363
 
@@ -338,7 +338,7 @@ export const STAGES: readonly Stage[] = [
338
338
  // Only the citation half of the audit gates. Its length, depth, table, and
339
339
  // index findings are judgment thresholds, and failing a push on one would
340
340
  // make the stage something to route around. The one length reading that
341
- // will gate is the whole-document ceiling, which the Document ceiling stage
341
+ // gates is the whole-document ceiling, which the Document ceiling stage
342
342
  // below owns rather than this audit.
343
343
  id: 'context-citations',
344
344
  label: 'Context citations',
@@ -360,8 +360,8 @@ export const STAGES: readonly Stage[] = [
360
360
  checks: [{ kind: 'measure', measure: architectureRecord }],
361
361
  },
362
362
  {
363
- // Unscoped, since any branch can grow any document. It warns and passes
364
- // until the corpus sits under the ceiling, then flips to a failure.
363
+ // Unscoped, since any branch can grow any document. A document past the
364
+ // ceiling fails the push.
365
365
  id: 'document-ceiling',
366
366
  label: 'Document ceiling',
367
367
  checks: [{ kind: 'measure', measure: documentCeiling }],
package/src/init/steps.ts CHANGED
@@ -5,7 +5,7 @@ import type { DomainStep } from '@/init/run'
5
5
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
6
6
 
7
7
  /**
8
- * States the same fact as `docs/target-migrations.md`'s backup section in its
8
+ * States the same fact as `docs/target/target-migrations.md`'s backup section in its
9
9
  * own words. Keep the two in step by hand; nothing compares them.
10
10
  */
11
11
  const RECORDS_SETUP_NOTICE =
@@ -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