@erclx/canon 4.26.1 → 4.26.2

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.26.1",
4
+ "version": "4.26.2",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -110,7 +110,7 @@ On success the record carries `from`, `to`, `priorityRowRemoved`, and `indexRege
110
110
 
111
111
  Each reason has one resolution and none of them is to archive around it:
112
112
 
113
- - `open-outcomes`: the named outcomes are unmarked or genuinely open. Run `claude-docs` when the work shipped and nothing marked it. Leave the task on the board when the outcome is real. Cut the outcomes first when the work is being abandoned, so the board records what was dropped.
113
+ - `open-outcomes`: the named outcomes are unmarked or genuinely open. Run `claude-docs` 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.
114
114
  - `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.
115
115
  - `no-match`: the stem or number names nothing on the board. Check the name against the listed stems.
116
116
  - `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.
@@ -38,7 +38,7 @@ Keying the query on a sha is necessary and not sufficient. The endpoint answered
38
38
 
39
39
  Both empty cases report `pending` instead: a tip carrying no run yet, and a listing whose rows all belong to some other commit. The record separates them, since `matched` counts the runs belonging to the tip and `foreign` counts the rest, and a caller that wants to tell "not started" from "still going" reads those two numbers rather than the state alone.
40
40
 
41
- `matched` counts runs and not distinct checks, which is where it parts company with the list `gh pr checks` prints. A workflow that fires twice on one commit lands two runs under one name, so a pull request whose body was edited after the push reads 3 against that command's 2. Measured on 2026-09-02 against a commit whose phase-label workflow ran on the push and again on a later body edit. Read a `matched` above the check count as that, rather than as the verb disagreeing with the command.
41
+ The verb keeps the newest run per check name rather than rolling every matched run into the state. A workflow that fires twice on one commit used to leave its first, stale run beside the rerun, where a failing one could outvote a passing rerun. Grouping the matched set by name and keeping the highest `id` per group removes that stale run before the rollup runs. `collapsed` on the record counts what the fold discards, so a caller reading it against `matched` sees the dedupe directly, instead of reading a `matched` above the check count as the verb disagreeing with `gh pr checks`.
42
42
 
43
43
  A listing carrying even one foreign run reports `pending` whatever the matching half says. A set that describes another commit says nothing about this one, so answering off the rows that happen to match would put a verdict on a set already known to be incomplete.
44
44
 
@@ -24,6 +24,8 @@ canon tasks archive --pull-request 673 --json
24
24
 
25
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`, or `bad-input`.
26
26
 
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
+
27
29
  The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
28
30
 
29
31
  A plan several tasks share stays where it is, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.26.1",
4
+ "version": "4.26.2",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -727,6 +727,11 @@ async function runChecks(
727
727
  `${plural(reading.foreign, 'further run')} belonging to another commit, so this listing does not describe the tip alone.`,
728
728
  )
729
729
  }
730
+ if (reading.collapsed > 0) {
731
+ logInfo(
732
+ `${plural(reading.collapsed, 'run')} folded into a newer run of the same check, so the count above includes a superseded run the state did not use.`,
733
+ )
734
+ }
730
735
 
731
736
  logStep(
732
737
  reading.state === 'passing'
@@ -825,6 +825,7 @@ function report(
825
825
  }
826
826
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
827
827
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
828
+ if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
828
829
  outro()
829
830
 
830
831
  return 0
@@ -856,5 +857,7 @@ function recordFor(
856
857
  to: relative(root, outcome.plan.to),
857
858
  }
858
859
  : null,
860
+ closed: outcome.closed,
861
+ cut: outcome.cut,
859
862
  }
860
863
  }
package/src/pr/checks.ts CHANGED
@@ -5,6 +5,8 @@ export interface RawCheckRun {
5
5
  readonly head_sha?: string
6
6
  readonly status?: string
7
7
  readonly conclusion?: string | null
8
+ readonly name?: string
9
+ readonly id?: number
8
10
  }
9
11
 
10
12
  /** What that endpoint returns, count and rows apart. */
@@ -23,6 +25,8 @@ export interface ChecksReading {
23
25
  readonly foreign: number
24
26
  /** What the endpoint said it holds, which can exceed the rows it returned. */
25
27
  readonly reported: number
28
+ /** Runs `matched` carried for a check name this reading already kept a newer run for. */
29
+ readonly collapsed: number
26
30
  }
27
31
 
28
32
  /**
@@ -43,6 +47,37 @@ function adapt(run: RawCheckRun): RawCheck {
43
47
  }
44
48
  }
45
49
 
50
+ /**
51
+ * Keeps the newest run per check name, so a re-triggered gate's stale run
52
+ * does not outvote its own rerun.
53
+ *
54
+ * A run with no name keys on its own position rather than joining an
55
+ * unnamed group, since nothing ties two nameless runs to the same check. A
56
+ * run carrying an `id` always outranks one without, and a tie on `id`,
57
+ * including a pair both missing one, keeps whichever run occurs later in
58
+ * the array.
59
+ */
60
+ function newestPerCheck(runs: readonly RawCheckRun[]): {
61
+ readonly kept: readonly RawCheckRun[]
62
+ readonly collapsed: number
63
+ } {
64
+ const bestByKey = new Map<string, RawCheckRun>()
65
+
66
+ runs.forEach((run, index) => {
67
+ const key = run.name !== undefined ? `name:${run.name}` : `pos:${index}`
68
+ const existing = bestByKey.get(key)
69
+ const keepCurrent =
70
+ existing === undefined ||
71
+ (existing.id === undefined && run.id === undefined) ||
72
+ (run.id !== undefined &&
73
+ (existing.id === undefined || run.id >= existing.id))
74
+ if (keepCurrent) bestByKey.set(key, run)
75
+ })
76
+
77
+ const kept = [...bestByKey.values()]
78
+ return { kept, collapsed: runs.length - kept.length }
79
+ }
80
+
46
81
  /**
47
82
  * Collapses a check-run listing to one word about one commit.
48
83
  *
@@ -55,6 +90,12 @@ function adapt(run: RawCheckRun): RawCheck {
55
90
  * The foreign test runs ahead of the collapse rather than after it. A listing
56
91
  * that describes another commit says nothing about this one, so answering off
57
92
  * the matching half would report a verdict on a set known to be incomplete.
93
+ *
94
+ * The matched set can still repeat a check name, since a re-triggered gate
95
+ * creates a second run rather than replacing the first. The final state
96
+ * rolls up from `newestPerCheck`'s kept rows rather than from `matched`
97
+ * directly, so a stale failing run left behind by a rerun no longer outvotes
98
+ * the passing run beside it.
58
99
  */
59
100
  export function collapseChecks(
60
101
  tip: string,
@@ -62,11 +103,13 @@ export function collapseChecks(
62
103
  ): ChecksReading {
63
104
  const runs = listing.check_runs ?? []
64
105
  const matched = runs.filter((run) => run.head_sha === tip)
106
+ const { kept, collapsed } = newestPerCheck(matched)
65
107
  const reading = {
66
108
  tip,
67
109
  matched: matched.length,
68
110
  foreign: runs.length - matched.length,
69
111
  reported: listing.total_count ?? runs.length,
112
+ collapsed,
70
113
  }
71
114
 
72
115
  if (reading.foreign > 0) return { ...reading, state: 'pending' }
@@ -79,5 +122,5 @@ export function collapseChecks(
79
122
 
80
123
  if (matched.length === 0) return { ...reading, state: 'pending' }
81
124
 
82
- return { ...reading, state: rollup(matched.map(adapt)) ?? 'pending' }
125
+ return { ...reading, state: rollup(kept.map(adapt)) ?? 'pending' }
83
126
  }
@@ -67,6 +67,8 @@ export interface ArchiveSuccess {
67
67
  readonly indexRegenerated: boolean
68
68
  /** Undefined when the task cited no live plan, or when another task still holds it. */
69
69
  readonly plan: PlanMove | undefined
70
+ readonly closed: number
71
+ readonly cut: number
70
72
  }
71
73
 
72
74
  export interface ArchiveRefused {
@@ -81,6 +83,7 @@ export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
81
83
  export interface TaskOutcomes {
82
84
  readonly open: readonly string[]
83
85
  readonly closed: readonly string[]
86
+ readonly cut: readonly string[]
84
87
  }
85
88
 
86
89
  export function tasksDir(root: string): string {
@@ -93,6 +96,28 @@ export function archiveDir(root: string): string {
93
96
 
94
97
  export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
95
98
 
99
+ /**
100
+ * A bullet with an optional checkbox, wider than `OUTCOME_PATTERN` so a cut
101
+ * line missing its checkbox still parses. `record.ts` positions outcomes off
102
+ * the narrower pattern and stays that way, so a cut line with no checkbox
103
+ * stays invisible to `canon tasks outcome` by design rather than by oversight.
104
+ *
105
+ * Unlike `OUTCOME_PATTERN`, this reaches a struck bullet under any heading,
106
+ * not only `## Outcomes`, since a checkbox is what confined the narrower
107
+ * pattern there and a cut line may carry none. No struck bullet sits outside
108
+ * `## Outcomes` across the live board or the archive as of this change, so
109
+ * the wider reach is not live, and a heading test is one to add if that stops
110
+ * holding.
111
+ */
112
+ const BULLET_PATTERN = /^- (?:\[([ xX])\] ?)?(.*)$/
113
+
114
+ /**
115
+ * A struck outcome body, whatever its checkbox holds. The test only checks
116
+ * the body's start, since the canonical form trails the struck text with why
117
+ * it was cut: `- ~~<outcome>~~ <why>` leaves `<why>` outside the `~~` pair.
118
+ */
119
+ const STRUCK_BODY_PATTERN = /^~~.+~~/
120
+
96
121
  /**
97
122
  * Marks the lines sitting inside a fenced block, the fence delimiters included.
98
123
  * A checkbox in a sample a task displays is not an outcome the task claims, and
@@ -112,31 +137,49 @@ export function fenceMask(lines: readonly string[]): boolean[] {
112
137
  }
113
138
 
114
139
  /**
115
- * Splits a task's outcome list by checkbox state. The board format puts every
116
- * outcome at the top level of `## Outcomes`, so an anchored match is enough and
117
- * no heading tracking is needed.
140
+ * Splits a task's outcome list by checkbox state, cut outcomes pulled out
141
+ * ahead of it. The board format puts every outcome at the top level of
142
+ * `## Outcomes`, so an anchored match is enough and no heading tracking is
143
+ * needed.
144
+ *
145
+ * A struck body reads as cut whatever its checkbox holds, since a dropped box
146
+ * and a retained one both appear in the tree and neither is the abandoned
147
+ * work's fault: `- ~~a thing~~` carries no box and would otherwise vanish
148
+ * from both arrays, `- [ ] ~~a thing~~` would otherwise read as open, and
149
+ * `- [x] ~~a thing~~` would otherwise read as shipped. Testing the body
150
+ * before the checkbox is what catches all three under one rule.
118
151
  */
119
152
  export function readOutcomes(text: string): TaskOutcomes {
120
153
  const open: string[] = []
121
154
  const closed: string[] = []
155
+ const cut: string[] = []
122
156
  const lines = text.split('\n')
123
157
  const fenced = fenceMask(lines)
124
158
 
125
159
  for (const [index, line] of lines.entries()) {
126
160
  if (fenced[index]) continue
127
161
 
128
- const match = OUTCOME_PATTERN.exec(line)
129
- if (!match) continue
162
+ const bullet = BULLET_PATTERN.exec(line)
163
+ if (!bullet) continue
164
+
165
+ const [, box, rawBody] = bullet
166
+ const body = rawBody.trim()
167
+
168
+ if (STRUCK_BODY_PATTERN.test(body)) {
169
+ cut.push(body)
170
+ continue
171
+ }
172
+
173
+ if (box === undefined) continue
130
174
 
131
- const [, box, body] = match
132
175
  if (box === ' ') {
133
- open.push(body.trim())
176
+ open.push(body)
134
177
  } else {
135
- closed.push(body.trim())
178
+ closed.push(body)
136
179
  }
137
180
  }
138
181
 
139
- return { open, closed }
182
+ return { open, closed, cut }
140
183
  }
141
184
 
142
185
  /**
@@ -492,17 +535,17 @@ export async function archiveTask(
492
535
  const from = join(dir, `${stem}.md`)
493
536
  const text = await readFile(from, 'utf8')
494
537
 
495
- const { open, closed } = readOutcomes(text)
538
+ const { open, closed, cut } = readOutcomes(text)
496
539
 
497
540
  if (open.length > 0) {
498
541
  return refuse(
499
542
  'open-outcomes',
500
- `${stem} has ${open.length} open outcome(s). Close them or cut them from the task, then archive.`,
543
+ `${stem} has ${open.length} open outcome(s). Close them, or cut one with \`- ~~<outcome>~~ <why>\`, then archive.`,
501
544
  open,
502
545
  )
503
546
  }
504
547
 
505
- if (closed.length === 0) {
548
+ if (closed.length === 0 && cut.length === 0) {
506
549
  return refuse(
507
550
  'no-outcomes',
508
551
  `${stem} carries no outcomes, so nothing marks it shipped.`,
@@ -538,6 +581,8 @@ export async function archiveTask(
538
581
  priorityRowRemoved,
539
582
  indexRegenerated: regen.action === 'written',
540
583
  plan,
584
+ closed: closed.length,
585
+ cut: cut.length,
541
586
  }
542
587
  }
543
588
 
@@ -45,13 +45,10 @@ A decision's reasoning stays correct while the numbers it cites move. The anchor
45
45
 
46
46
  ## Length
47
47
 
48
- Every session pays for this file before any work starts, so it carries a budget. The budget counts decisions rather than lines, because a bare line total is satisfied by merging paragraph pairs and the merged paragraphs then fail the weight checkpoint in `markdown.md`. A file over budget is carrying too many decisions, not decisions written too long.
48
+ Every session pays for this file before any work starts, so a heavy read is a real cost. Judge weight by reading the file rather than by counting it: a file that reads heavy is carrying too many decisions, not decisions written too long.
49
49
 
50
- - Budget six lines per decision entry, being the H3, two paragraphs, and the blank lines separating them.
51
- - Budget the frame outside `## Key technical decisions` separately, covering the H1, the overview, and the risks. State the number the project takes where it records the budget, since a frame carries no fixed structure to derive one from.
52
- - Read the ceiling as the frame plus six lines against the decision count, rather than as the total the two multiply out to.
53
- - Bring an over-budget file back by merging two decisions or retiring one, never by compressing a decision's prose.
54
- - Yield the budget to the paragraph weight checkpoint when the two disagree. A paragraph past the checkpoint is a defect no budget licenses.
50
+ - Bring a heavy file back by merging two decisions or retiring one, never by compressing a decision's prose.
51
+ - Yield to the paragraph weight checkpoint in `markdown.md`. A paragraph past the checkpoint is a defect no length guideline licenses.
55
52
 
56
53
  ## Template
57
54
 
@@ -255,4 +255,4 @@ Archiving a task archives its plan alongside it, when the closing task is that p
255
255
 
256
256
  One act rather than two is what makes the pair safe. The merge is the event that settles a plan, and a `post-merge` hook reaching the archive with nobody watching cannot act on a warning, so a second call after it would be a second failure point leaving the task archived and the plan live.
257
257
 
258
- A task with an open outcome stays on the board, and so does its plan. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file.
258
+ A task with an open outcome stays on the board, and so does its plan. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. Cutting means striking the outcome's body: `- ~~<outcome>~~ <why>`. `archiveTask` reads a struck body as cut whatever its checkbox holds, so a task carrying only cut outcomes still archives and a mixed task carries both counts on its success record.