@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/feedback/github.ts
CHANGED
|
@@ -2,34 +2,73 @@ import { execa } from 'execa'
|
|
|
2
2
|
import { gitEnv } from '@/git/env'
|
|
3
3
|
import { PROJECT_ROOT } from '@/roots/project'
|
|
4
4
|
import {
|
|
5
|
+
buildCommentArgs,
|
|
5
6
|
buildIssueArgs,
|
|
7
|
+
buildIssueReadArgs,
|
|
6
8
|
failureDetail,
|
|
9
|
+
parseIssueRead,
|
|
7
10
|
type CreateIssueOptions,
|
|
8
11
|
type CreateIssueResult,
|
|
12
|
+
type IssueFailure,
|
|
13
|
+
type IssueRead,
|
|
9
14
|
} from '@/feedback/github-format'
|
|
10
15
|
|
|
11
16
|
const GH_TIMEOUT_MS = 30_000
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Runs `gh` and returns its trimmed stdout, or the failure the operator reads.
|
|
20
|
+
* See src/worktrees/reclaim.ts for why gh needs the stripped environment.
|
|
21
|
+
*/
|
|
22
|
+
async function runGh(
|
|
23
|
+
args: string[],
|
|
24
|
+
): Promise<{ ok: true; stdout: string } | IssueFailure> {
|
|
16
25
|
if (Bun.which('gh') === null) return { ok: false, reason: 'missing-binary' }
|
|
17
26
|
try {
|
|
18
|
-
|
|
19
|
-
const result = await execa('gh', buildIssueArgs(opts), {
|
|
27
|
+
const result = await execa('gh', args, {
|
|
20
28
|
cwd: PROJECT_ROOT,
|
|
21
29
|
timeout: GH_TIMEOUT_MS,
|
|
22
30
|
env: gitEnv(),
|
|
23
31
|
extendEnv: false,
|
|
24
32
|
})
|
|
25
|
-
|
|
26
|
-
if (url) return { ok: true, url }
|
|
27
|
-
return {
|
|
28
|
-
ok: false,
|
|
29
|
-
reason: 'command-failed',
|
|
30
|
-
detail: 'gh exited zero and printed no issue URL',
|
|
31
|
-
}
|
|
33
|
+
return { ok: true, stdout: result.stdout.trim() }
|
|
32
34
|
} catch (error) {
|
|
33
35
|
return { ok: false, reason: 'command-failed', detail: failureDetail(error) }
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
async function runForUrl(
|
|
40
|
+
args: string[],
|
|
41
|
+
emptyDetail: string,
|
|
42
|
+
): Promise<CreateIssueResult> {
|
|
43
|
+
const result = await runGh(args)
|
|
44
|
+
if (!result.ok) return result
|
|
45
|
+
if (result.stdout) return { ok: true, url: result.stdout }
|
|
46
|
+
return { ok: false, reason: 'command-failed', detail: emptyDetail }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createGithubIssue(
|
|
50
|
+
opts: CreateIssueOptions,
|
|
51
|
+
): Promise<CreateIssueResult> {
|
|
52
|
+
return runForUrl(
|
|
53
|
+
buildIssueArgs(opts),
|
|
54
|
+
'gh exited zero and printed no issue URL',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readFeedbackIssue(
|
|
59
|
+
issue: number,
|
|
60
|
+
): Promise<IssueRead | IssueFailure> {
|
|
61
|
+
const result = await runGh(buildIssueReadArgs(issue))
|
|
62
|
+
if (!result.ok) return result
|
|
63
|
+
return parseIssueRead(result.stdout)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function postIssueComment(
|
|
67
|
+
issue: number,
|
|
68
|
+
body: string,
|
|
69
|
+
): Promise<CreateIssueResult> {
|
|
70
|
+
return runForUrl(
|
|
71
|
+
buildCommentArgs(issue, body),
|
|
72
|
+
'gh exited zero and printed no comment URL',
|
|
73
|
+
)
|
|
74
|
+
}
|
package/src/markdown/colons.ts
CHANGED
|
@@ -22,8 +22,14 @@ const QUOTE_MARKER = /^(?:\s*>\s?)+/
|
|
|
22
22
|
/** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
|
|
23
23
|
const COLON = /:(?=\s+\S)/g
|
|
24
24
|
|
|
25
|
+
/** A colon followed by whitespace, which a masked code span leaves behind. */
|
|
26
|
+
const LABEL_COLON = /:\s/
|
|
27
|
+
|
|
25
28
|
const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
|
|
26
29
|
|
|
30
|
+
/** What may stand in a lead once its code spans and links are masked. */
|
|
31
|
+
const CONNECTIVE = /\b(?:and|or|to)\b|,/g
|
|
32
|
+
|
|
27
33
|
/** Blanks a span while holding its width, so a column stays where it was. */
|
|
28
34
|
function blank(match: string): string {
|
|
29
35
|
return ' '.repeat(match.length)
|
|
@@ -32,14 +38,74 @@ function blank(match: string): string {
|
|
|
32
38
|
/**
|
|
33
39
|
* Whether the text before a line's first colon is a label rather than a
|
|
34
40
|
* clause: the lead of a list item, or a prefix that is only emphasis and a
|
|
35
|
-
* masked code span.
|
|
41
|
+
* masked code span. A list item's lead also reads as a label at any length
|
|
42
|
+
* when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
|
|
43
|
+
* which is a file entry naming several paths.
|
|
36
44
|
*/
|
|
37
45
|
function isLabel(prefix: string, listItem: boolean): boolean {
|
|
38
46
|
if (BOLD_LABEL.test(prefix.trim())) return true
|
|
39
47
|
|
|
40
48
|
const words = prefix.replace(/[*_]/g, ' ').trim()
|
|
41
49
|
if (words === '') return true
|
|
42
|
-
|
|
50
|
+
if (!listItem) return false
|
|
51
|
+
if (words.replace(CONNECTIVE, ' ').trim() === '') return true
|
|
52
|
+
return words.split(/\s+/).length <= 4
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Entry {
|
|
56
|
+
readonly line: BodyLine
|
|
57
|
+
readonly masked: string
|
|
58
|
+
readonly start: number
|
|
59
|
+
/** Width of the quote marker and the item's own leading whitespace. */
|
|
60
|
+
readonly indent: number
|
|
61
|
+
readonly quote: string
|
|
62
|
+
readonly isItem: boolean
|
|
63
|
+
/** Matches at or past the content start, the only ones a line can report. */
|
|
64
|
+
readonly colons: readonly RegExpExecArray[]
|
|
65
|
+
/** A colon stands past the content start, even with only a masked span after it. */
|
|
66
|
+
readonly labelColon: boolean
|
|
67
|
+
/** Items sharing one list at one indent, shared by reference. */
|
|
68
|
+
group?: Entry[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Collects consecutive items at one indent into a list. A blank line, a
|
|
73
|
+
* heading, or a change of quote depth ends every open list, and a deeper child
|
|
74
|
+
* neither joins its parent's list nor ends it.
|
|
75
|
+
*/
|
|
76
|
+
function groupItems(entries: readonly Entry[]): void {
|
|
77
|
+
const open = new Map<number, Entry[]>()
|
|
78
|
+
let quote = ''
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.line.fenced) continue
|
|
82
|
+
|
|
83
|
+
const empty = entry.masked.slice(entry.quote.length).trim() === ''
|
|
84
|
+
if (entry.quote !== quote || empty || HEADING.test(entry.line.text)) {
|
|
85
|
+
open.clear()
|
|
86
|
+
}
|
|
87
|
+
quote = entry.quote
|
|
88
|
+
if (!entry.isItem) continue
|
|
89
|
+
|
|
90
|
+
for (const level of open.keys()) {
|
|
91
|
+
if (level > entry.indent) open.delete(level)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const group = open.get(entry.indent) ?? []
|
|
95
|
+
open.set(entry.indent, group)
|
|
96
|
+
group.push(entry)
|
|
97
|
+
entry.group = group
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether the item sits in a list of two or more where every item has a colon. */
|
|
102
|
+
function uniform(entry: Entry): boolean {
|
|
103
|
+
const group = entry.group
|
|
104
|
+
return (
|
|
105
|
+
group !== undefined &&
|
|
106
|
+
group.length >= 2 &&
|
|
107
|
+
group.every((item) => item.labelColon)
|
|
108
|
+
)
|
|
43
109
|
}
|
|
44
110
|
|
|
45
111
|
/**
|
|
@@ -47,30 +113,50 @@ function isLabel(prefix: string, listItem: boolean): boolean {
|
|
|
47
113
|
*
|
|
48
114
|
* A heading, a table row, a fence, a code span, a link, and a line-final colon
|
|
49
115
|
* are left alone, and so is the first colon of a list item or of a line opening
|
|
50
|
-
* on a bold or code label
|
|
51
|
-
*
|
|
52
|
-
* asked for.
|
|
116
|
+
* on a bold or code label, and of every item in a list where each one carries a
|
|
117
|
+
* colon. The measure is advisory, because a colon is sometimes right and the
|
|
118
|
+
* corpus count shows a gate would be a sweep nobody asked for.
|
|
53
119
|
*/
|
|
54
120
|
export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
for (const line of lines) {
|
|
58
|
-
if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
|
|
121
|
+
const entries: Entry[] = lines.map((line) => {
|
|
62
122
|
const masked = maskDisplayed(line.text.replace(LINK, blank))
|
|
63
123
|
const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
|
|
64
124
|
const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
|
|
65
125
|
const start = quote.length + marker.length
|
|
66
|
-
|
|
126
|
+
const skipped =
|
|
127
|
+
line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
line,
|
|
131
|
+
masked,
|
|
132
|
+
start,
|
|
133
|
+
indent: quote.length + marker.length - marker.trimStart().length,
|
|
134
|
+
quote: quote.replace(/\s/g, ''),
|
|
135
|
+
isItem: !skipped && marker !== '',
|
|
136
|
+
labelColon: !skipped && LABEL_COLON.test(masked.slice(start)),
|
|
137
|
+
colons: skipped
|
|
138
|
+
? []
|
|
139
|
+
: [...masked.matchAll(COLON)].filter((match) => match.index >= start),
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
groupItems(entries)
|
|
67
144
|
|
|
68
|
-
|
|
69
|
-
|
|
145
|
+
const found: ColonFinding[] = []
|
|
146
|
+
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const { line, masked, start } = entry
|
|
149
|
+
let labelled = false
|
|
70
150
|
|
|
151
|
+
for (const match of entry.colons) {
|
|
71
152
|
if (!labelled) {
|
|
72
153
|
labelled = true
|
|
73
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
isLabel(masked.slice(start, match.index), entry.isItem) ||
|
|
156
|
+
(entry.isItem && uniform(entry))
|
|
157
|
+
) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
74
160
|
}
|
|
75
161
|
|
|
76
162
|
found.push({
|
package/src/pr/carry.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DESIGN_FOLDER, isEvidencePath } from '@/pr/evidence'
|
|
2
|
+
import type { ChangedRead } from '@/pr/lift'
|
|
3
|
+
import { DEFAULT_PAINTED } from '@/pr/painted'
|
|
4
|
+
import { coversHead } from '@/pr/review-scope'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The globs no checklist box can observe: tests, stories, snapshots, and
|
|
8
|
+
* markdown where a project keeps docs. The rule lists what is inert rather than
|
|
9
|
+
* what is driven, so a kind nobody listed, such as a `.json` data file, fails
|
|
10
|
+
* closed. Markdown elsewhere, such as an Astro page or content entry, renders,
|
|
11
|
+
* so it is driven like `.mdx`.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_INERT: readonly string[] = [
|
|
14
|
+
...DEFAULT_PAINTED.unpainted,
|
|
15
|
+
'*.md',
|
|
16
|
+
'canon/**/*.md',
|
|
17
|
+
'.claude/**/*.md',
|
|
18
|
+
'.github/**/*.md',
|
|
19
|
+
'docs/**/*.md',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An evidence capture and a design record are inert too, the two paths
|
|
24
|
+
* `readUncaptured` skips, since a recapture or a design note changes no
|
|
25
|
+
* behavior a box drives.
|
|
26
|
+
*/
|
|
27
|
+
export function isInert(path: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
isEvidencePath(path) ||
|
|
30
|
+
path.startsWith(DESIGN_FOLDER) ||
|
|
31
|
+
DEFAULT_INERT.some((glob) => new Bun.Glob(glob).match(path))
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether a UI verdict at one commit still holds at a later one. */
|
|
36
|
+
export type Carry =
|
|
37
|
+
| { readonly kind: 'covered' }
|
|
38
|
+
| { readonly kind: 'carried'; readonly carriedFrom: string }
|
|
39
|
+
| { readonly kind: 'moved'; readonly drivenPaths: readonly string[] }
|
|
40
|
+
| { readonly kind: 'not-ancestor' }
|
|
41
|
+
| { readonly kind: 'failed' }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Carries a verdict driven at `from` to `to` only across a gap that changed
|
|
45
|
+
* nothing a box drives. A box drives behavior, which lives in any source file
|
|
46
|
+
* and not only in a painted one, so every path outside the inert set moves the
|
|
47
|
+
* verdict. A `from` that `to` does not descend from never carries.
|
|
48
|
+
*/
|
|
49
|
+
export async function readCarry(
|
|
50
|
+
readChanged: (from: string, to: string) => Promise<ChangedRead | undefined>,
|
|
51
|
+
from: string,
|
|
52
|
+
to: string,
|
|
53
|
+
): Promise<Carry> {
|
|
54
|
+
if (coversHead(from, to)) return { kind: 'covered' }
|
|
55
|
+
|
|
56
|
+
const changed = await readChanged(from, to)
|
|
57
|
+
if (changed === undefined) return { kind: 'failed' }
|
|
58
|
+
if (!changed.isAncestor) return { kind: 'not-ancestor' }
|
|
59
|
+
|
|
60
|
+
const drivenPaths = changed.paths.filter((path) => !isInert(path))
|
|
61
|
+
return drivenPaths.length > 0
|
|
62
|
+
? { kind: 'moved', drivenPaths }
|
|
63
|
+
: { kind: 'carried', carriedFrom: from }
|
|
64
|
+
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -607,7 +607,7 @@ export function readOwed(input: OwedInput): OwedStep[] {
|
|
|
607
607
|
* Design references are redrawn rather than captured, so recapturing them
|
|
608
608
|
* neither owes nor stands in for an evidence capture.
|
|
609
609
|
*/
|
|
610
|
-
const DESIGN_FOLDER = 'canon/design/'
|
|
610
|
+
export const DESIGN_FOLDER = 'canon/design/'
|
|
611
611
|
|
|
612
612
|
/**
|
|
613
613
|
* The painted paths a pull request changed while no evidence image did, in a
|
package/src/pr/lift.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
resolveUiScope,
|
|
7
7
|
type UiCounts,
|
|
8
8
|
} from '@/pr/review-scope'
|
|
9
|
+
import { readCarry } from '@/pr/carry'
|
|
9
10
|
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
10
11
|
|
|
11
12
|
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
@@ -49,6 +50,8 @@ export interface GateResult {
|
|
|
49
50
|
readonly carriedFrom?: string
|
|
50
51
|
/** The painted paths that kept a UI close from carrying forward. */
|
|
51
52
|
readonly paintedPaths?: readonly string[]
|
|
53
|
+
/** Every path a box can observe that kept a UI close from carrying forward. */
|
|
54
|
+
readonly drivenPaths?: readonly string[]
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/** How an earlier commit sits against the head, and what changed since. */
|
|
@@ -125,6 +128,7 @@ export interface LiftRecord {
|
|
|
125
128
|
readonly failed?: LiftRefusal
|
|
126
129
|
readonly carriedFrom?: string
|
|
127
130
|
readonly paintedPaths?: readonly string[]
|
|
131
|
+
readonly drivenPaths?: readonly string[]
|
|
128
132
|
readonly isLifted: boolean
|
|
129
133
|
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
130
134
|
readonly isOk: boolean
|
|
@@ -164,11 +168,13 @@ export function reviewGate(
|
|
|
164
168
|
function carried(gates: readonly GateResult[]): {
|
|
165
169
|
carriedFrom?: string
|
|
166
170
|
paintedPaths?: readonly string[]
|
|
171
|
+
drivenPaths?: readonly string[]
|
|
167
172
|
} {
|
|
168
173
|
const ui = gates.find((gate) => gate.gate === 'ui')
|
|
169
174
|
return {
|
|
170
175
|
...(ui?.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
|
|
171
176
|
...(ui?.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
|
|
177
|
+
...(ui?.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
|
|
172
178
|
}
|
|
173
179
|
}
|
|
174
180
|
|
|
@@ -189,10 +195,11 @@ function droveNothing(counts: UiCounts | undefined): boolean {
|
|
|
189
195
|
/**
|
|
190
196
|
* Binds only where the evidence record carries a checklist to drive.
|
|
191
197
|
*
|
|
192
|
-
* A close behind the head still holds
|
|
193
|
-
* no
|
|
194
|
-
*
|
|
195
|
-
*
|
|
198
|
+
* A close behind the head still holds across the gap `readCarry` carries,
|
|
199
|
+
* one changing no path a box can observe. A box drives behavior, so a push to
|
|
200
|
+
* a non-painted source file moves the verdict too, and `drivenPaths` names
|
|
201
|
+
* every such path while `paintedPaths` keeps the painted subset. A close that
|
|
202
|
+
* is not an ancestor, which a force-push or a rebase leaves, never carries.
|
|
196
203
|
*/
|
|
197
204
|
export async function uiGate(
|
|
198
205
|
api: Pick<LiftApi, 'readChanged'>,
|
|
@@ -211,17 +218,27 @@ export async function uiGate(
|
|
|
211
218
|
// Ahead of the head test so a close carried across commits is refused too.
|
|
212
219
|
// A verdict whose count line did not parse reads as unchecked and goes on.
|
|
213
220
|
if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
|
|
214
|
-
if (coversHead(scope.commit, head)) return pass('ui')
|
|
215
221
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
222
|
+
const carry = await readCarry(
|
|
223
|
+
(from, to) => api.readChanged(from, to),
|
|
224
|
+
scope.commit,
|
|
225
|
+
head,
|
|
226
|
+
)
|
|
227
|
+
if (carry.kind === 'covered') return pass('ui')
|
|
228
|
+
if (carry.kind === 'failed') return fail('ui', 'gh-failed')
|
|
229
|
+
if (carry.kind === 'not-ancestor') return fail('ui', 'ui-behind')
|
|
230
|
+
if (carry.kind === 'carried') {
|
|
231
|
+
return { ...pass('ui'), carriedFrom: carry.carriedFrom }
|
|
232
|
+
}
|
|
219
233
|
|
|
220
|
-
const paintedPaths =
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
const paintedPaths = carry.drivenPaths.filter((path) =>
|
|
235
|
+
isPainted(path, painted),
|
|
236
|
+
)
|
|
237
|
+
return {
|
|
238
|
+
...fail('ui', 'ui-behind'),
|
|
239
|
+
...(paintedPaths.length > 0 && { paintedPaths }),
|
|
240
|
+
drivenPaths: carry.drivenPaths,
|
|
223
241
|
}
|
|
224
|
-
return { ...pass('ui'), carriedFrom: scope.commit }
|
|
225
242
|
}
|
|
226
243
|
|
|
227
244
|
function standingGate(standing: Standing): GateResult {
|
package/src/pr/review-state.ts
CHANGED
|
@@ -47,6 +47,7 @@ export interface UiState extends UiScope, FamilyState {
|
|
|
47
47
|
readonly isBound: boolean
|
|
48
48
|
readonly carriedFrom?: string
|
|
49
49
|
readonly paintedPaths?: readonly string[]
|
|
50
|
+
readonly drivenPaths?: readonly string[]
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export interface ReviewStateRecord {
|
|
@@ -119,6 +120,7 @@ async function readAt(
|
|
|
119
120
|
isBound: hasChecklist,
|
|
120
121
|
...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
|
|
121
122
|
...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
|
|
123
|
+
...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
|
|
122
124
|
},
|
|
123
125
|
},
|
|
124
126
|
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -325,6 +325,57 @@ export function readPendingBranches(text: string): readonly string[] {
|
|
|
325
325
|
.filter((entry) => entry.length > 0)
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* A local session's full sessionId, or the `session_` id a cloud create reads
|
|
330
|
+
* out. Only the full form reaches `canon sessions export`, so a short prefix is
|
|
331
|
+
* refused rather than written.
|
|
332
|
+
*/
|
|
333
|
+
const SESSION_ID_SOURCE =
|
|
334
|
+
'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|session_[A-Za-z0-9]+'
|
|
335
|
+
|
|
336
|
+
export const SESSION_ID_PATTERN = new RegExp(`^(?:${SESSION_ID_SOURCE})$`)
|
|
337
|
+
|
|
338
|
+
export const SESSIONS_PREFIX = 'Sessions:'
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One entry is a name and its parenthesized id, and the parse keys on that
|
|
342
|
+
* trailing id, so a roster name carrying a comma or its own parentheses still
|
|
343
|
+
* reads as one entry.
|
|
344
|
+
*/
|
|
345
|
+
const SESSION_ENTRY_PATTERN = new RegExp(
|
|
346
|
+
`(.+?) \\((${SESSION_ID_SOURCE})\\)(?:, |$)`,
|
|
347
|
+
'y',
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
export interface SessionEntry {
|
|
351
|
+
readonly name: string
|
|
352
|
+
readonly sessionId: string
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reads the `Sessions:` line `canon tasks session` writes. A line that does not
|
|
357
|
+
* parse whole reads as naming none, the way `readPullRequest` reads a line
|
|
358
|
+
* carrying anything besides its list.
|
|
359
|
+
*/
|
|
360
|
+
export function readSessions(text: string): readonly SessionEntry[] {
|
|
361
|
+
const line = text
|
|
362
|
+
.split('\n')
|
|
363
|
+
.find((entry) => entry.startsWith(SESSIONS_PREFIX))
|
|
364
|
+
if (line === undefined) return []
|
|
365
|
+
|
|
366
|
+
const body = line.slice(SESSIONS_PREFIX.length).trim()
|
|
367
|
+
const entries: SessionEntry[] = []
|
|
368
|
+
const pattern = new RegExp(SESSION_ENTRY_PATTERN)
|
|
369
|
+
|
|
370
|
+
while (pattern.lastIndex < body.length) {
|
|
371
|
+
const match = pattern.exec(body)
|
|
372
|
+
if (!match) return []
|
|
373
|
+
entries.push({ name: match[1], sessionId: match[2] })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return entries
|
|
377
|
+
}
|
|
378
|
+
|
|
328
379
|
/** A markdown link on a `Plan:` line, its target captured. */
|
|
329
380
|
const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
|
|
330
381
|
|
|
@@ -514,7 +565,8 @@ export function declineLine(reason: string, by: string, date: string): string {
|
|
|
514
565
|
/**
|
|
515
566
|
* Lines a `Declined:` line anchors after, mirroring `record.ts`'s
|
|
516
567
|
* `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
|
|
517
|
-
* a pull request that never merged.
|
|
568
|
+
* a pull request that never merged. `Sessions:` joins them, since the sessions
|
|
569
|
+
* that worked a task sit above whatever ended it.
|
|
518
570
|
*/
|
|
519
571
|
const DECLINE_ANCHOR_PREFIXES = [
|
|
520
572
|
'Plan:',
|
|
@@ -522,6 +574,7 @@ const DECLINE_ANCHOR_PREFIXES = [
|
|
|
522
574
|
'Intake:',
|
|
523
575
|
'Issue:',
|
|
524
576
|
'Pull request:',
|
|
577
|
+
SESSIONS_PREFIX,
|
|
525
578
|
] as const
|
|
526
579
|
|
|
527
580
|
function lastAnchorLine(lines: readonly string[]): number | undefined {
|
package/src/tasks/record.ts
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
readPendingBranches,
|
|
16
16
|
readPlanTargets,
|
|
17
17
|
readPullRequest,
|
|
18
|
+
readSessions,
|
|
19
|
+
SESSION_ID_PATTERN,
|
|
20
|
+
SESSIONS_PREFIX,
|
|
18
21
|
tasksDir,
|
|
19
22
|
} from '@/tasks/archive'
|
|
20
23
|
import { checkWaiting, type LinkPromotion, promoteOnLink } from '@/tasks/board'
|
|
@@ -34,6 +37,9 @@ const ORIGIN_PREFIXES = ['Plan:', 'Groundwork:', 'Intake:', 'Issue:'] as const
|
|
|
34
37
|
*
|
|
35
38
|
* `several-plans` answers a `Plan:` line that already links more than one plan.
|
|
36
39
|
* `plan-link` cannot know which link to drop, so it writes nothing.
|
|
40
|
+
*
|
|
41
|
+
* `no-name` answers a session id the roster no longer lists, called with no
|
|
42
|
+
* name, since an id written alone reads as nobody to the next reader.
|
|
37
43
|
*/
|
|
38
44
|
export const RECORD_REFUSALS = [
|
|
39
45
|
'no-board',
|
|
@@ -44,6 +50,7 @@ export const RECORD_REFUSALS = [
|
|
|
44
50
|
'no-plan',
|
|
45
51
|
'several-plans',
|
|
46
52
|
'bad-input',
|
|
53
|
+
'no-name',
|
|
47
54
|
] as const
|
|
48
55
|
|
|
49
56
|
export type RecordRefusal = (typeof RECORD_REFUSALS)[number]
|
|
@@ -88,6 +95,18 @@ export interface PlanRecorded {
|
|
|
88
95
|
|
|
89
96
|
export type PlanOutcome = PlanRecorded | RecordRefused
|
|
90
97
|
|
|
98
|
+
export interface SessionRecorded {
|
|
99
|
+
readonly ok: true
|
|
100
|
+
readonly stem: string
|
|
101
|
+
readonly path: string
|
|
102
|
+
readonly sessionId: string
|
|
103
|
+
/** The name the line carries for the id, which a repeat call never renames. */
|
|
104
|
+
readonly name: string
|
|
105
|
+
readonly action: Exclude<LineAction, 'corrected'>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type SessionOutcome = SessionRecorded | RecordRefused
|
|
109
|
+
|
|
91
110
|
export interface OutcomesClosed {
|
|
92
111
|
readonly ok: true
|
|
93
112
|
readonly stem: string
|
|
@@ -228,6 +247,47 @@ export function clearPendingBranch(
|
|
|
228
247
|
return { text: lines.join('\n'), changed: true }
|
|
229
248
|
}
|
|
230
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Names a session on the `Sessions:` line, keyed on the id, so a dispatcher
|
|
252
|
+
* and `git-pr` recording the same worker write it once. The line sits below
|
|
253
|
+
* every other line a task carries above its outcomes, so a pull request
|
|
254
|
+
* recorded later lands above it.
|
|
255
|
+
*/
|
|
256
|
+
export function writeSessionsLine(
|
|
257
|
+
text: string,
|
|
258
|
+
name: string,
|
|
259
|
+
sessionId: string,
|
|
260
|
+
): { readonly text: string; readonly action: SessionRecorded['action'] } {
|
|
261
|
+
const entry = `${name} (${sessionId})`
|
|
262
|
+
const lines = text.split('\n')
|
|
263
|
+
const existing = lines.findIndex((line) => line.startsWith(SESSIONS_PREFIX))
|
|
264
|
+
|
|
265
|
+
if (existing !== -1) {
|
|
266
|
+
if (lines[existing].includes(`(${sessionId})`)) {
|
|
267
|
+
return { text, action: 'unchanged' }
|
|
268
|
+
}
|
|
269
|
+
lines[existing] = `${lines[existing].trimEnd()}, ${entry}`
|
|
270
|
+
return { text: lines.join('\n'), action: 'appended' }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const line = `${SESSIONS_PREFIX} ${entry}`
|
|
274
|
+
const anchor = lastOriginLine(lines, [
|
|
275
|
+
...ORIGIN_PREFIXES,
|
|
276
|
+
'Pull request:',
|
|
277
|
+
PENDING_PREFIX,
|
|
278
|
+
])
|
|
279
|
+
if (anchor !== undefined) {
|
|
280
|
+
lines.splice(anchor + 1, 0, line)
|
|
281
|
+
return { text: lines.join('\n'), action: 'added' }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const heading = lines.findIndex((entry) => entry.startsWith('# '))
|
|
285
|
+
if (heading === -1) return { text: `${line}\n${text}`, action: 'added' }
|
|
286
|
+
|
|
287
|
+
lines.splice(heading + 1, 0, '', line)
|
|
288
|
+
return { text: lines.join('\n'), action: 'added' }
|
|
289
|
+
}
|
|
290
|
+
|
|
231
291
|
export type PlanLineWrite =
|
|
232
292
|
| {
|
|
233
293
|
readonly ok: true
|
|
@@ -566,6 +626,64 @@ export async function recordPlan(
|
|
|
566
626
|
}
|
|
567
627
|
}
|
|
568
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Records a session that worked a task on its `Sessions:` line. The caller
|
|
631
|
+
* resolves `name` from the roster or takes it from `--name`, and an undefined
|
|
632
|
+
* name refuses rather than writing a bare id. The id is checked first, since a
|
|
633
|
+
* malformed one is a caller defect and every later check would hide it.
|
|
634
|
+
*/
|
|
635
|
+
export async function recordSession(
|
|
636
|
+
root: string,
|
|
637
|
+
selector: RecordSelector,
|
|
638
|
+
sessionId: string,
|
|
639
|
+
name: string | undefined,
|
|
640
|
+
): Promise<SessionOutcome> {
|
|
641
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
642
|
+
return refuse(
|
|
643
|
+
'bad-input',
|
|
644
|
+
`Not a full sessionId: ${sessionId}. Pass the whole id canon sessions list --json carries.`,
|
|
645
|
+
[sessionId],
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (name === undefined) {
|
|
650
|
+
return refuse(
|
|
651
|
+
'no-name',
|
|
652
|
+
`No live session carries ${sessionId}, so its name cannot be read. Pass --name.`,
|
|
653
|
+
[sessionId],
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const trimmed = name.trim()
|
|
658
|
+
if (trimmed.length === 0 || /[\r\n]/.test(trimmed)) {
|
|
659
|
+
return refuse('bad-input', 'A session name is one non-empty line.', [name])
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const opened = await openTask(root, selector)
|
|
663
|
+
if ('ok' in opened) return opened
|
|
664
|
+
|
|
665
|
+
const { stem, path } = opened
|
|
666
|
+
const written = writeSessionsLine(
|
|
667
|
+
await readFile(path, 'utf8'),
|
|
668
|
+
trimmed,
|
|
669
|
+
sessionId,
|
|
670
|
+
)
|
|
671
|
+
if (written.action !== 'unchanged') await writeFile(path, written.text)
|
|
672
|
+
|
|
673
|
+
const listed = readSessions(written.text).find(
|
|
674
|
+
(entry) => entry.sessionId === sessionId,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
ok: true,
|
|
679
|
+
stem,
|
|
680
|
+
path,
|
|
681
|
+
sessionId,
|
|
682
|
+
name: listed?.name ?? trimmed,
|
|
683
|
+
action: written.action,
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
569
687
|
/**
|
|
570
688
|
* Marks the named outcomes `[x]` in place. `task-complete` runs this against a
|
|
571
689
|
* board it can read and cannot edit from a linked worktree, and the positions
|