@erclx/canon 4.26.0 → 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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-tasks/SKILL.md +1 -1
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/tasks.md +2 -0
- package/package.json +1 -1
- package/src/commands/pr.ts +13 -2
- package/src/commands/tasks.ts +3 -0
- package/src/pr/checks.ts +44 -1
- package/src/tasks/archive.ts +57 -12
- package/standards/architecture.md +3 -6
- package/standards/tasks.md +1 -1
|
@@ -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.
|
|
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.
|
|
@@ -22,7 +22,7 @@ The positional is the pull request to read, defaulting to the one open on this b
|
|
|
22
22
|
| `--root <path>` | Repository to read, defaulting to the cwd |
|
|
23
23
|
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
24
24
|
|
|
25
|
-
`--body` decides where both halves come from, so a number passed beside it is never read. The body comes off disk and the changed set from the local range, which is the shape a fixture and a body still being drafted both need.
|
|
25
|
+
`--body` decides where both halves come from, so a number passed beside it is never read. The body comes off disk and the changed set from the local range, which is the shape a fixture and a body still being drafted both need. A relative `--body` path resolves against the working directory the command runs from, never against `--root`. `--base` and the changed-file read still resolve against `--root`.
|
|
26
26
|
|
|
27
27
|
`--base` names the far side of that range and never the commit the comparison runs against directly. The reader resolves the merge base between `HEAD` and the ref, so `--base origin/main` measures what the branch wrote rather than what the trunk gained while the branch was open. Passing the trunk as it stands today is therefore safe on a branch of any age, and a ref sharing no history with `HEAD` refuses as `bad-base`.
|
|
28
28
|
|
package/docs/agents/pr-reads.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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
package/src/commands/pr.ts
CHANGED
|
@@ -349,15 +349,21 @@ async function listFilesByPage(
|
|
|
349
349
|
/**
|
|
350
350
|
* Reads the body off disk and the changed set from git, which is the shape a
|
|
351
351
|
* fixture and a body still being drafted both need.
|
|
352
|
+
*
|
|
353
|
+
* `cwd` and `root` diverge when a caller passes `--root` to read a pull
|
|
354
|
+
* request against a worktree other than the one they are standing in. The
|
|
355
|
+
* body path is resolved against `cwd`, since it is correct from where the
|
|
356
|
+
* caller stands regardless of which tree `--root` names.
|
|
352
357
|
*/
|
|
353
358
|
async function readFromFile(
|
|
354
359
|
root: string,
|
|
360
|
+
cwd: string,
|
|
355
361
|
path: string,
|
|
356
362
|
base: string | undefined,
|
|
357
363
|
): Promise<SourceRead> {
|
|
358
364
|
let body: string
|
|
359
365
|
try {
|
|
360
|
-
body = await readFile(resolve(
|
|
366
|
+
body = await readFile(resolve(cwd, path), 'utf8')
|
|
361
367
|
} catch {
|
|
362
368
|
return { kind: 'refused', reason: 'unreadable-body' }
|
|
363
369
|
}
|
|
@@ -403,7 +409,7 @@ async function runKeyChanges(
|
|
|
403
409
|
const source =
|
|
404
410
|
opts.body === undefined
|
|
405
411
|
? await readFromApi(root, number)
|
|
406
|
-
: await readFromFile(root, opts.body, opts.base)
|
|
412
|
+
: await readFromFile(root, process.cwd(), opts.body, opts.base)
|
|
407
413
|
|
|
408
414
|
if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
|
|
409
415
|
|
|
@@ -721,6 +727,11 @@ async function runChecks(
|
|
|
721
727
|
`${plural(reading.foreign, 'further run')} belonging to another commit, so this listing does not describe the tip alone.`,
|
|
722
728
|
)
|
|
723
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
|
+
}
|
|
724
735
|
|
|
725
736
|
logStep(
|
|
726
737
|
reading.state === 'passing'
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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(
|
|
125
|
+
return { ...reading, state: rollup(kept.map(adapt)) ?? 'pending' }
|
|
83
126
|
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
116
|
-
*
|
|
117
|
-
* no heading tracking is
|
|
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
|
|
129
|
-
if (!
|
|
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
|
|
176
|
+
open.push(body)
|
|
134
177
|
} else {
|
|
135
|
-
closed.push(body
|
|
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
|
|
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
|
|
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
|
-
-
|
|
51
|
-
-
|
|
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
|
|
package/standards/tasks.md
CHANGED
|
@@ -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.
|