@erclx/canon 5.70.0 → 5.72.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/CHANGELOG.md +24 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +27 -2
- package/claude/skills/canon-feedback/references/template.md +22 -0
- package/claude/skills/git-pr/references/task-number.md +13 -0
- package/claude/skills/markdown-craft/references/markdown.md +1 -0
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +4 -3
- package/claude/skills/plan-intake-answer/SKILL.md +35 -2
- package/claude/skills/review-ui/SKILL.md +1 -1
- package/claude/skills/review-ui/references/tick.md +17 -5
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
- package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +1 -1
- package/claude/skills/role-reviewer/SKILL.md +4 -0
- package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
- package/claude/skills/task-board/references/tasks.md +4 -8
- package/claude/skills/ui-film/references/pr-clip.md +26 -2
- package/package.json +1 -1
- package/src/commands/feedback.ts +83 -3
- package/src/commands/markdown.ts +6 -1
- package/src/commands/pr.ts +66 -21
- package/src/commands/tasks.ts +133 -0
- package/src/feedback/body.ts +50 -0
- package/src/feedback/github-format.ts +77 -0
- package/src/feedback/github.ts +51 -12
- package/src/markdown/colons.ts +102 -16
- package/src/pr/carry.ts +64 -0
- package/src/pr/evidence.ts +1 -1
- package/src/pr/lift.ts +29 -12
- package/src/pr/review-state.ts +2 -0
- package/src/tasks/archive.ts +54 -1
- package/src/tasks/record.ts +118 -0
- package/src/tasks/validate.ts +37 -0
package/src/commands/feedback.ts
CHANGED
|
@@ -2,11 +2,20 @@ import { Option, type Command } from 'commander'
|
|
|
2
2
|
import {
|
|
3
3
|
deriveDomainLabels,
|
|
4
4
|
deriveTitle,
|
|
5
|
+
followUpProblem,
|
|
5
6
|
missingField,
|
|
6
7
|
missingFieldMessage,
|
|
7
8
|
} from '@/feedback/body'
|
|
8
|
-
import {
|
|
9
|
-
|
|
9
|
+
import {
|
|
10
|
+
createGithubIssue,
|
|
11
|
+
postIssueComment,
|
|
12
|
+
readFeedbackIssue,
|
|
13
|
+
} from '@/feedback/github'
|
|
14
|
+
import {
|
|
15
|
+
commentFailureMessage,
|
|
16
|
+
issueFailureMessage,
|
|
17
|
+
issueReadFailureMessage,
|
|
18
|
+
} from '@/feedback/github-format'
|
|
10
19
|
import { frameError, frameSuccess } from '@/cli/ui'
|
|
11
20
|
|
|
12
21
|
function readStdin(): Promise<string> {
|
|
@@ -20,6 +29,61 @@ function readStdin(): Promise<string> {
|
|
|
20
29
|
})
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Triage lists open `feedback` issues only, so a comment anywhere else is
|
|
34
|
+
* never read. Both checks run before the post, since a public comment cannot
|
|
35
|
+
* be taken back by a later refusal.
|
|
36
|
+
*/
|
|
37
|
+
async function postFollowUp(issue: number, body: string): Promise<void> {
|
|
38
|
+
const problem = followUpProblem(body)
|
|
39
|
+
if (problem) {
|
|
40
|
+
frameError(problem)
|
|
41
|
+
process.exitCode = 1
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// An absent gh can post nothing, so the block goes out for a hand paste. A
|
|
46
|
+
// failed read is a refusal to repair, since the number may name no issue.
|
|
47
|
+
const read = await readFeedbackIssue(issue)
|
|
48
|
+
if (!read.ok && read.reason === 'missing-binary') {
|
|
49
|
+
frameError(commentFailureMessage(read, issue))
|
|
50
|
+
process.stdout.write(`${body}\n`)
|
|
51
|
+
process.exitCode = 1
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (!read.ok) {
|
|
55
|
+
frameError(issueReadFailureMessage(read, issue))
|
|
56
|
+
process.exitCode = 1
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (read.state !== 'OPEN') {
|
|
60
|
+
frameError(
|
|
61
|
+
`Issue #${issue} is ${read.state.toLowerCase()}, and triage reads open feedback issues only. File a new report instead.`,
|
|
62
|
+
)
|
|
63
|
+
process.exitCode = 1
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (!read.labels.includes('feedback')) {
|
|
67
|
+
frameError(
|
|
68
|
+
`Issue #${issue} carries no feedback label, so it is not a toolkit feedback report. Check the number and re-run.`,
|
|
69
|
+
)
|
|
70
|
+
process.exitCode = 1
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await postIssueComment(issue, body)
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
frameSuccess('canon feedback', result.url)
|
|
77
|
+
process.stdout.write(`${result.url}\n`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
frameError(commentFailureMessage(result, issue))
|
|
81
|
+
process.stdout.write(`${body}\n`)
|
|
82
|
+
process.exitCode = 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ISSUE_NUMBER = /^[1-9]\d*$/
|
|
86
|
+
|
|
23
87
|
export function register(program: Command): void {
|
|
24
88
|
program
|
|
25
89
|
.command('feedback')
|
|
@@ -27,10 +91,21 @@ export function register(program: Command): void {
|
|
|
27
91
|
.description(
|
|
28
92
|
'Open a GitHub issue on the toolkit repo from a feedback block on stdin',
|
|
29
93
|
)
|
|
94
|
+
.option(
|
|
95
|
+
'--on <issue>',
|
|
96
|
+
'Post a follow-up block as a comment on an open feedback issue instead',
|
|
97
|
+
)
|
|
30
98
|
// A cached plugin still pipes `--github`, and commander rejects an unknown
|
|
31
99
|
// option, so the flag stays accepted and hidden for one release.
|
|
32
100
|
.addOption(new Option('--github').hideHelp())
|
|
33
|
-
.action(async () => {
|
|
101
|
+
.action(async (opts: { on?: string }) => {
|
|
102
|
+
if (opts.on !== undefined && !ISSUE_NUMBER.test(opts.on)) {
|
|
103
|
+
frameError(
|
|
104
|
+
`--on takes an issue number, such as --on 2801, not "${opts.on}".`,
|
|
105
|
+
)
|
|
106
|
+
process.exitCode = 1
|
|
107
|
+
return
|
|
108
|
+
}
|
|
34
109
|
if (process.stdin.isTTY) {
|
|
35
110
|
frameError(
|
|
36
111
|
'No feedback on stdin. Pipe a markdown block: pbpaste | canon feedback',
|
|
@@ -45,6 +120,11 @@ export function register(program: Command): void {
|
|
|
45
120
|
return
|
|
46
121
|
}
|
|
47
122
|
|
|
123
|
+
if (opts.on !== undefined) {
|
|
124
|
+
await postFollowUp(Number(opts.on), body)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
48
128
|
// A report missing its surface is no more useful in the tracker than
|
|
49
129
|
// anywhere else, so validation runs before any `gh` call.
|
|
50
130
|
const absent = missingField(body)
|
package/src/commands/markdown.ts
CHANGED
|
@@ -167,7 +167,9 @@ export function register(program: Command): void {
|
|
|
167
167
|
'',
|
|
168
168
|
'A mid-sentence colon and a heading with a capital after its first word',
|
|
169
169
|
'report the same way. A proper noun is the one case the second cannot',
|
|
170
|
-
'tell from title case, and the first is sometimes right.',
|
|
170
|
+
'tell from title case, and the first is sometimes right. A list whose',
|
|
171
|
+
'every item opens on a lead and a colon reads each lead as a label, and so',
|
|
172
|
+
'does a lead made only of code spans, links, commas, and and, or, to.',
|
|
171
173
|
'',
|
|
172
174
|
'Length reports a whole document past the ceiling and never exits 2.',
|
|
173
175
|
'The Document ceiling gate stage owns that verdict, since this audit also',
|
|
@@ -842,6 +844,9 @@ function reportColons(reports: readonly FileReport[]): void {
|
|
|
842
844
|
logInfo(
|
|
843
845
|
'A colon followed by more text in a prose line, outside a label, a code span, and a link.',
|
|
844
846
|
)
|
|
847
|
+
logInfo(
|
|
848
|
+
'A list where every item opens on a lead and a colon reads each lead as a label.',
|
|
849
|
+
)
|
|
845
850
|
logInfo(
|
|
846
851
|
'A heading, a table row, a fenced block, and a line-final colon are excluded. This never fails a run.',
|
|
847
852
|
)
|
package/src/commands/pr.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
} from '@/git/status'
|
|
99
99
|
import { machineListenerReader } from '@/listeners/machine'
|
|
100
100
|
import { loadPaintedPatterns } from '@/pr/painted'
|
|
101
|
+
import { readCarry } from '@/pr/carry'
|
|
101
102
|
import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
|
|
102
103
|
import {
|
|
103
104
|
dropAddressLine,
|
|
@@ -228,6 +229,7 @@ type TickReason =
|
|
|
228
229
|
| TickRefusal
|
|
229
230
|
| 'count-mismatch'
|
|
230
231
|
| 'stale-head'
|
|
232
|
+
| 'redrive-owed'
|
|
231
233
|
| 'bad-boxes'
|
|
232
234
|
| 'no-comment'
|
|
233
235
|
| 'body-refused'
|
|
@@ -236,7 +238,9 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
|
|
|
236
238
|
'body-refused':
|
|
237
239
|
'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
|
|
238
240
|
'stale-head':
|
|
239
|
-
'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
|
|
241
|
+
'The head named is not the branch tip on the remote, and the tip does not descend from it or the gap could not be read, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
|
|
242
|
+
'redrive-owed':
|
|
243
|
+
'A push since the driven head changed a path a box can observe, so the pass does not carry to the tip. Nothing was written. Re-drive the checklist at the tip rather than retrying the tick there.',
|
|
240
244
|
'bad-boxes':
|
|
241
245
|
'--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
|
|
242
246
|
'count-mismatch':
|
|
@@ -700,8 +704,9 @@ export function register(program: Command): void {
|
|
|
700
704
|
'',
|
|
701
705
|
'`ui` also carries the newest `## UI review` pass (`heading`, `state`,',
|
|
702
706
|
'`commit`, `source`), `isBound`, which is whether the evidence comment',
|
|
703
|
-
'carries a checklist, and `carriedFrom`
|
|
704
|
-
'
|
|
707
|
+
'carries a checklist, and `carriedFrom` when the lift carried a close',
|
|
708
|
+
'forward, or `drivenPaths` and the painted subset `paintedPaths` when it',
|
|
709
|
+
'refused to. An unbound family reads covered.',
|
|
705
710
|
'',
|
|
706
711
|
'Both families post as pull request reviews, so this reads',
|
|
707
712
|
'`pulls/<n>/reviews` alone, as the lift does. Issue comments are not',
|
|
@@ -944,11 +949,15 @@ export function register(program: Command): void {
|
|
|
944
949
|
' ui the newest `## UI review` is closed at the head, bound only',
|
|
945
950
|
' where `canon pr evidence --check` carries a checklist. A close',
|
|
946
951
|
' on an earlier commit still holds when it is an ancestor of the',
|
|
947
|
-
' head and
|
|
948
|
-
'
|
|
949
|
-
'
|
|
950
|
-
'
|
|
951
|
-
'
|
|
952
|
+
' head and every path changed since is one no box can observe,',
|
|
953
|
+
' being tests, stories, snapshots, markdown in the root and in',
|
|
954
|
+
' `canon/`, `.claude/`, `.github/`, and `docs/`, evidence captures,',
|
|
955
|
+
' and `canon/design/`, and the record names it as `carriedFrom`.',
|
|
956
|
+
' Any other change refuses `ui-behind`, listing the paths as',
|
|
957
|
+
' `drivenPaths` and the painted subset as `paintedPaths`, since',
|
|
958
|
+
' a box drives behavior a `.ts` file holds too. A close whose',
|
|
959
|
+
' count line shows boxes not driven and none passed, failed,',
|
|
960
|
+
' or needing eyes refuses `ui-undriven`, carried or not',
|
|
952
961
|
' evidence the evidence check reads settled',
|
|
953
962
|
' standing the head is fresh against the base, not stale or conflicted',
|
|
954
963
|
' ci every check run on the tip passed, waited on up to --wait',
|
|
@@ -1295,14 +1304,23 @@ export function register(program: Command): void {
|
|
|
1295
1304
|
'driver can run is ticked, counts taste boxes apart, and is the only',
|
|
1296
1305
|
'body text this verb writes, so hand edits elsewhere survive.',
|
|
1297
1306
|
'',
|
|
1298
|
-
'
|
|
1299
|
-
'
|
|
1300
|
-
'
|
|
1301
|
-
'
|
|
1307
|
+
'A --head behind the remote tip carries to the tip when every path',
|
|
1308
|
+
'changed since is one no box can observe, being tests, stories,',
|
|
1309
|
+
'snapshots, markdown in the root and in `canon/`, `.claude/`,',
|
|
1310
|
+
'`.github/`, and `docs/`, evidence captures, and `canon/design/`. The',
|
|
1311
|
+
'boxes are then stamped at the tip and the record names `carriedFrom`.',
|
|
1312
|
+
'Any other changed path, markdown elsewhere included, owes a re-drive.',
|
|
1313
|
+
'',
|
|
1314
|
+
'Before writing it reads the newest UI verdict. One that covers the tip,',
|
|
1315
|
+
'or the driven head on a carry, and carries a count line must match the',
|
|
1316
|
+
'ticks this call leaves at the tip, so a re-drive passes every box that',
|
|
1317
|
+
'passed, since a new head clears the older ticks. Any other verdict',
|
|
1318
|
+
'leaves the check unrun.',
|
|
1302
1319
|
'',
|
|
1303
1320
|
'Read `reason` on the JSON record:',
|
|
1304
1321
|
' ticked the boxes were ticked and the body line written, with the',
|
|
1305
|
-
' tip in `head
|
|
1322
|
+
' tip in `head`, `countCheck` as matched or unchecked, and',
|
|
1323
|
+
' `carriedFrom` when --head was carried to the tip',
|
|
1306
1324
|
' count-mismatch the verdict passed a different number of boxes than',
|
|
1307
1325
|
' this call leaves ticked at the tip, nothing written,',
|
|
1308
1326
|
' `passed` and `ticked` carry the two counts. Fewer ticks',
|
|
@@ -1310,7 +1328,10 @@ export function register(program: Command): void {
|
|
|
1310
1328
|
' operator an untick of a box that has since failed',
|
|
1311
1329
|
' body-refused the comment is ticked but gh could not edit the body,',
|
|
1312
1330
|
' exit 1 with `commentTicked`. Re-run to retry the body.',
|
|
1313
|
-
'
|
|
1331
|
+
' redrive-owed a push since --head changed a path a box can observe,',
|
|
1332
|
+
' nothing written, with `driven`, `head`, and `drivenPaths`',
|
|
1333
|
+
' stale-head --head is behind the tip with no readable linear gap,',
|
|
1334
|
+
' as a force-push or a rebase leaves, nothing written',
|
|
1314
1335
|
' taste-box a named box ends in (taste), nothing written',
|
|
1315
1336
|
' no-box a named number is past the checklist, nothing written',
|
|
1316
1337
|
' no-checklist the evidence comment carries no checklist',
|
|
@@ -3475,9 +3496,9 @@ const POSITIVE_WHOLE = /^[1-9]\d*$/
|
|
|
3475
3496
|
const MIN_TICK_HEAD_LENGTH = 7
|
|
3476
3497
|
|
|
3477
3498
|
/**
|
|
3478
|
-
* Ticks the named boxes on the marked comment. It refuses a head
|
|
3479
|
-
*
|
|
3480
|
-
* rather than as prose a driver can talk itself out of.
|
|
3499
|
+
* Ticks the named boxes on the marked comment. It refuses a head the tip moved
|
|
3500
|
+
* past in a way a box can observe, and a taste box, before writing, so both
|
|
3501
|
+
* rules hold as checks rather than as prose a driver can talk itself out of.
|
|
3481
3502
|
*/
|
|
3482
3503
|
async function runTick(
|
|
3483
3504
|
number: string | undefined,
|
|
@@ -3518,7 +3539,26 @@ async function runTick(
|
|
|
3518
3539
|
root,
|
|
3519
3540
|
)
|
|
3520
3541
|
}
|
|
3521
|
-
|
|
3542
|
+
const carry = await readCarry(
|
|
3543
|
+
(from, to) => readChangedPaths(gitRunner(root), from, to),
|
|
3544
|
+
head,
|
|
3545
|
+
reading.tip,
|
|
3546
|
+
)
|
|
3547
|
+
if (carry.kind === 'moved') {
|
|
3548
|
+
return refuseWith(
|
|
3549
|
+
'redrive-owed',
|
|
3550
|
+
TICK_REFUSALS['redrive-owed'],
|
|
3551
|
+
emitJson,
|
|
3552
|
+
root,
|
|
3553
|
+
{ driven: head, head: reading.tip, drivenPaths: carry.drivenPaths },
|
|
3554
|
+
)
|
|
3555
|
+
}
|
|
3556
|
+
// A gap that could not be read refuses as a rewrite does, which is what the
|
|
3557
|
+
// verb answered for every moved tip before the carry.
|
|
3558
|
+
if (carry.kind === 'not-ancestor' || carry.kind === 'failed') {
|
|
3559
|
+
return refuseTick('stale-head')
|
|
3560
|
+
}
|
|
3561
|
+
const carriedFrom = carry.kind === 'carried' ? carry.carriedFrom : undefined
|
|
3522
3562
|
|
|
3523
3563
|
const comments = await listComments(root, String(identity.number))
|
|
3524
3564
|
if (comments === undefined) return refuseTick('gh-failed')
|
|
@@ -3534,8 +3574,9 @@ async function runTick(
|
|
|
3534
3574
|
if (result.kind === 'refused') return refuseTick(result.reason)
|
|
3535
3575
|
|
|
3536
3576
|
// Read ahead of the write so a short list refuses before it leaves the
|
|
3537
|
-
// comment half ticked. A verdict that
|
|
3538
|
-
// count line, or cannot be read leaves the check
|
|
3577
|
+
// comment half ticked. A verdict that covers neither the tip nor the head a
|
|
3578
|
+
// carry came from, carries no count line, or cannot be read leaves the check
|
|
3579
|
+
// unrun and says so.
|
|
3539
3580
|
const ticked = countTicksAtHead(
|
|
3540
3581
|
readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
|
|
3541
3582
|
reading.tip,
|
|
@@ -3547,7 +3588,10 @@ async function runTick(
|
|
|
3547
3588
|
const isCounted =
|
|
3548
3589
|
verdict.counts !== undefined &&
|
|
3549
3590
|
verdict.commit !== undefined &&
|
|
3550
|
-
coversHead(verdict.commit, reading.tip)
|
|
3591
|
+
(coversHead(verdict.commit, reading.tip) ||
|
|
3592
|
+
(carriedFrom !== undefined &&
|
|
3593
|
+
(coversHead(verdict.commit, carriedFrom) ||
|
|
3594
|
+
coversHead(carriedFrom, verdict.commit))))
|
|
3551
3595
|
if (isCounted && verdict.counts.passed !== ticked) {
|
|
3552
3596
|
return refuseWith(
|
|
3553
3597
|
'count-mismatch',
|
|
@@ -3622,6 +3666,7 @@ async function runTick(
|
|
|
3622
3666
|
reason: 'ticked',
|
|
3623
3667
|
countCheck,
|
|
3624
3668
|
head: reading.tip,
|
|
3669
|
+
...(carriedFrom !== undefined && { carriedFrom }),
|
|
3625
3670
|
boxes: numbers.map(Number),
|
|
3626
3671
|
commentId,
|
|
3627
3672
|
})}\n`,
|
package/src/commands/tasks.ts
CHANGED
|
@@ -57,7 +57,10 @@ import {
|
|
|
57
57
|
type RecordRefused,
|
|
58
58
|
type RecordSelector,
|
|
59
59
|
recordPullRequest,
|
|
60
|
+
recordSession,
|
|
61
|
+
type SessionOutcome,
|
|
60
62
|
} from '@/tasks/record'
|
|
63
|
+
import { resolveSessions } from '@/sessions/resolve'
|
|
61
64
|
import {
|
|
62
65
|
type BoardGroup,
|
|
63
66
|
type Finding,
|
|
@@ -142,6 +145,13 @@ interface PullRequestCommandOptions {
|
|
|
142
145
|
readonly root?: string
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
interface SessionCommandOptions {
|
|
149
|
+
readonly json?: boolean
|
|
150
|
+
readonly plan?: string
|
|
151
|
+
readonly name?: string
|
|
152
|
+
readonly root?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
145
155
|
interface PlanLinkCommandOptions {
|
|
146
156
|
readonly add?: boolean
|
|
147
157
|
readonly waiting?: string
|
|
@@ -617,6 +627,63 @@ export function register(program: Command): void {
|
|
|
617
627
|
},
|
|
618
628
|
)
|
|
619
629
|
|
|
630
|
+
tasks
|
|
631
|
+
.command('session')
|
|
632
|
+
.description('Record a session that worked a task on its Sessions: line')
|
|
633
|
+
.argument(
|
|
634
|
+
'<sessionId>',
|
|
635
|
+
'Full sessionId, as canon sessions list --json carries it',
|
|
636
|
+
)
|
|
637
|
+
.argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
638
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
639
|
+
.option('--plan <slug>', 'Select the task whose Plan line names this plan')
|
|
640
|
+
.option(
|
|
641
|
+
'--name <name>',
|
|
642
|
+
'Name to record, read off the live roster when omitted',
|
|
643
|
+
)
|
|
644
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
645
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
646
|
+
.addHelpText(
|
|
647
|
+
'after',
|
|
648
|
+
[
|
|
649
|
+
'',
|
|
650
|
+
'Refusals, read off `reason`:',
|
|
651
|
+
' no-board the repository holds no `.canon/tasks/` board',
|
|
652
|
+
' no-match no task matches the selector',
|
|
653
|
+
' ambiguous more than one task matches the selector',
|
|
654
|
+
' bad-input an id that is not a full sessionId, or an empty name',
|
|
655
|
+
' no-name the roster lists no live session with the id, and no',
|
|
656
|
+
' --name was passed',
|
|
657
|
+
'',
|
|
658
|
+
'Exit codes:',
|
|
659
|
+
' 0 the session was added, appended, or already listed',
|
|
660
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
661
|
+
'',
|
|
662
|
+
'It writes Sessions: <name> (<sessionId>) below the origin, Pull',
|
|
663
|
+
'request:, and Pending branch: lines, and appends to the line when one',
|
|
664
|
+
'exists. The id is the key, so a session already listed is reported',
|
|
665
|
+
'unchanged under the name it was first recorded with. A cloud',
|
|
666
|
+
'session_ id is accepted, and takes --name since the roster never',
|
|
667
|
+
'lists it. Safe from a linked worktree, since it resolves the board',
|
|
668
|
+
'root in-process.',
|
|
669
|
+
'',
|
|
670
|
+
'Examples:',
|
|
671
|
+
' canon tasks session 0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0 v28.1-trigger-escalation',
|
|
672
|
+
' canon tasks session 0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0 --plan task-sessions-line --json',
|
|
673
|
+
' canon tasks session session_01AbC v28.1-trigger-escalation --name cloud-canon-a',
|
|
674
|
+
'',
|
|
675
|
+
].join('\n'),
|
|
676
|
+
)
|
|
677
|
+
.action(
|
|
678
|
+
async (
|
|
679
|
+
sessionId: string,
|
|
680
|
+
task: string | undefined,
|
|
681
|
+
opts: SessionCommandOptions,
|
|
682
|
+
) => {
|
|
683
|
+
process.exitCode = await runSession(sessionId, task, opts)
|
|
684
|
+
},
|
|
685
|
+
)
|
|
686
|
+
|
|
620
687
|
tasks
|
|
621
688
|
.command('plan-link')
|
|
622
689
|
.description("Write or correct a task's Plan: line to point at a plan")
|
|
@@ -1259,6 +1326,72 @@ async function runPullRequest(
|
|
|
1259
1326
|
return reportPullRequest(outcome, emitJson, root)
|
|
1260
1327
|
}
|
|
1261
1328
|
|
|
1329
|
+
async function runSession(
|
|
1330
|
+
sessionId: string,
|
|
1331
|
+
task: string | undefined,
|
|
1332
|
+
opts: SessionCommandOptions,
|
|
1333
|
+
): Promise<number> {
|
|
1334
|
+
const emitJson = opts.json ?? false
|
|
1335
|
+
const selector = selectorFor(task, opts.plan)
|
|
1336
|
+
|
|
1337
|
+
if ('ok' in selector) {
|
|
1338
|
+
return reportRecord(
|
|
1339
|
+
'canon tasks session',
|
|
1340
|
+
selector,
|
|
1341
|
+
emitJson,
|
|
1342
|
+
process.cwd(),
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1347
|
+
const name = opts.name ?? (await rosterName(sessionId))
|
|
1348
|
+
const outcome = await recordSession(root, selector, sessionId, name)
|
|
1349
|
+
|
|
1350
|
+
return reportSession(outcome, emitJson, root)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** The live roster's name for an id, or undefined once the session has exited. */
|
|
1354
|
+
async function rosterName(sessionId: string): Promise<string | undefined> {
|
|
1355
|
+
const report = await resolveSessions()
|
|
1356
|
+
if (report.kind === 'absent') return undefined
|
|
1357
|
+
|
|
1358
|
+
return report.sessions.find((session) => session.sessionId === sessionId)
|
|
1359
|
+
?.name
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function reportSession(
|
|
1363
|
+
outcome: SessionOutcome,
|
|
1364
|
+
emitJson: boolean,
|
|
1365
|
+
root: string,
|
|
1366
|
+
): number {
|
|
1367
|
+
if (!outcome.ok) {
|
|
1368
|
+
return reportRecord('canon tasks session', outcome, emitJson, root)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (emitJson) {
|
|
1372
|
+
process.stdout.write(
|
|
1373
|
+
`${JSON.stringify({
|
|
1374
|
+
ok: true,
|
|
1375
|
+
root,
|
|
1376
|
+
task: outcome.stem,
|
|
1377
|
+
path: relative(root, outcome.path),
|
|
1378
|
+
sessionId: outcome.sessionId,
|
|
1379
|
+
name: outcome.name,
|
|
1380
|
+
action: outcome.action,
|
|
1381
|
+
})}\n`,
|
|
1382
|
+
)
|
|
1383
|
+
return 0
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
intro('canon tasks session')
|
|
1387
|
+
logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
|
|
1388
|
+
logInfo(`${outcome.stem} names ${outcome.name} (${outcome.sessionId})`)
|
|
1389
|
+
if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
|
|
1390
|
+
outro()
|
|
1391
|
+
|
|
1392
|
+
return 0
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1262
1395
|
const RENAME_PATTERN = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\//
|
|
1263
1396
|
|
|
1264
1397
|
/**
|
package/src/feedback/body.ts
CHANGED
|
@@ -94,6 +94,56 @@ export function missingFieldMessage(field: string): string {
|
|
|
94
94
|
return `Feedback report is missing its "### ${field}" section. Every report needs ${REQUIRED_FIELDS.map((name) => `### ${name}`).join(', ')}.`
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The fields a follow-up comment carries. A follow-up reports what happened
|
|
99
|
+
* after filing, so it names no surface and proposes no fix of its own. It says
|
|
100
|
+
* whether the body's fix still stands, since triage reads the body as the
|
|
101
|
+
* request and a silent overturn leaves it acting on the old one.
|
|
102
|
+
*/
|
|
103
|
+
export const FOLLOW_UP_FIELDS = [
|
|
104
|
+
'Observed',
|
|
105
|
+
'Changes the fix',
|
|
106
|
+
'Links',
|
|
107
|
+
] as const
|
|
108
|
+
|
|
109
|
+
const YES_OR_NO = /^(?:yes|no)\b/i
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A URL, or an issue reference such as `#2801`, `frameboard#156`, or
|
|
113
|
+
* `erclx/canon#2801`. A bare name is what the hand-written follow-up carried,
|
|
114
|
+
* and nothing a reader can open from it.
|
|
115
|
+
*/
|
|
116
|
+
const REFERENCE = /https?:\/\/\S+|(?:^|[\s(])(?:[\w.-]+\/)?[\w.-]*#\d+\b/
|
|
117
|
+
|
|
118
|
+
export function missingFollowUpField(body: string): string | undefined {
|
|
119
|
+
const sections = parseSections(body)
|
|
120
|
+
for (const field of FOLLOW_UP_FIELDS) {
|
|
121
|
+
const value = sections.get(field.toLowerCase())
|
|
122
|
+
if (!value) return field
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The sentence a refused follow-up reads, or `undefined` when it carries every
|
|
129
|
+
* field in a checkable shape. One problem at a time, as `missingField` does.
|
|
130
|
+
*/
|
|
131
|
+
export function followUpProblem(body: string): string | undefined {
|
|
132
|
+
const absent = missingFollowUpField(body)
|
|
133
|
+
if (absent) {
|
|
134
|
+
return `Follow-up is missing its "### ${absent}" section. Every follow-up needs ${FOLLOW_UP_FIELDS.map((name) => `### ${name}`).join(', ')}.`
|
|
135
|
+
}
|
|
136
|
+
const changes = readField(body, 'Changes the fix') ?? ''
|
|
137
|
+
if (!YES_OR_NO.test(changes)) {
|
|
138
|
+
return 'Follow-up "### Changes the fix" must open with yes or no, so triage can tell whether the issue body still holds.'
|
|
139
|
+
}
|
|
140
|
+
const links = readField(body, 'Links') ?? ''
|
|
141
|
+
if (!REFERENCE.test(links)) {
|
|
142
|
+
return 'Follow-up "### Links" carries no URL or issue reference. Write each as a URL or as owner/repo#number.'
|
|
143
|
+
}
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
97
147
|
function surfaceField(body: string): string | undefined {
|
|
98
148
|
return readField(body, 'Surface')?.split('\n')[0]?.trim()
|
|
99
149
|
}
|
|
@@ -37,6 +37,61 @@ export function buildIssueArgs(opts: CreateIssueOptions): string[] {
|
|
|
37
37
|
return args
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function buildCommentArgs(issue: number, body: string): string[] {
|
|
41
|
+
return ['issue', 'comment', String(issue), '--body', body, '--repo', REPO]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildIssueReadArgs(issue: number): string[] {
|
|
45
|
+
return [
|
|
46
|
+
'issue',
|
|
47
|
+
'view',
|
|
48
|
+
String(issue),
|
|
49
|
+
'--json',
|
|
50
|
+
'state,labels',
|
|
51
|
+
'--repo',
|
|
52
|
+
REPO,
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IssueRead {
|
|
57
|
+
readonly ok: true
|
|
58
|
+
readonly state: string
|
|
59
|
+
readonly labels: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads the `gh issue view --json state,labels` record. A record of any other
|
|
64
|
+
* shape is a failed read rather than an issue with no labels, since the caller
|
|
65
|
+
* refuses on a missing label and that refusal would then name the wrong cause.
|
|
66
|
+
*/
|
|
67
|
+
export function parseIssueRead(stdout: string): IssueRead | IssueFailure {
|
|
68
|
+
let record: unknown
|
|
69
|
+
try {
|
|
70
|
+
record = JSON.parse(stdout)
|
|
71
|
+
} catch {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: 'command-failed',
|
|
75
|
+
detail: 'gh printed no JSON issue record',
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const { state, labels } = (record ?? {}) as {
|
|
79
|
+
state?: unknown
|
|
80
|
+
labels?: unknown
|
|
81
|
+
}
|
|
82
|
+
if (typeof state !== 'string' || !Array.isArray(labels)) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: 'command-failed',
|
|
86
|
+
detail: 'gh printed an issue record with no state or labels',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const names = labels
|
|
90
|
+
.map((label: unknown) => (label as { name?: unknown } | null)?.name)
|
|
91
|
+
.filter((name): name is string => typeof name === 'string')
|
|
92
|
+
return { ok: true, state, labels: names }
|
|
93
|
+
}
|
|
94
|
+
|
|
40
95
|
/**
|
|
41
96
|
* Collapses to one line. Both callers write the detail inside a framed error or
|
|
42
97
|
* a colored warning, and a newline in the middle of either breaks the frame and
|
|
@@ -75,3 +130,25 @@ export function issueFailureMessage(failure: IssueFailure): string {
|
|
|
75
130
|
}
|
|
76
131
|
return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}. File it manually at ${ISSUE_URL} instead.`
|
|
77
132
|
}
|
|
133
|
+
|
|
134
|
+
export function commentFailureMessage(
|
|
135
|
+
failure: IssueFailure,
|
|
136
|
+
issue: number,
|
|
137
|
+
): string {
|
|
138
|
+
const target = `https://github.com/${REPO}/issues/${issue}`
|
|
139
|
+
if (failure.reason === 'missing-binary') {
|
|
140
|
+
return `gh is not installed, so no follow-up was posted. Install gh, or comment at ${target}`
|
|
141
|
+
}
|
|
142
|
+
return `gh could not post the follow-up: ${failure.detail ?? 'no diagnostic on stderr'}. Comment manually at ${target} instead.`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A read that `gh` ran and failed is most often a number naming no issue, so
|
|
147
|
+
* the sentence asks for the number rather than pointing a paste at it.
|
|
148
|
+
*/
|
|
149
|
+
export function issueReadFailureMessage(
|
|
150
|
+
failure: IssueFailure,
|
|
151
|
+
issue: number,
|
|
152
|
+
): string {
|
|
153
|
+
return `gh could not read issue #${issue}: ${failure.detail ?? 'no diagnostic on stderr'}. Check the number and re-run.`
|
|
154
|
+
}
|