@erclx/canon 5.46.0 → 5.48.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +2 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/git-pr/references/pr.md +58 -7
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -3
- package/claude/skills/review-ui/references/post.md +16 -2
- package/claude/skills/review-ui/references/tick.md +7 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +4 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-board/references/tasks.md +4 -2
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +5 -3
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/docs/agents/migrate.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/commands/claude.ts +4 -0
- package/src/commands/labels.ts +66 -1
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +405 -30
- package/src/commands/sessions.ts +25 -11
- package/src/commands/tasks.ts +11 -1
- package/src/labels/density.ts +363 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/evidence.ts +1 -1
- package/src/pr/lift.ts +19 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +109 -0
- package/src/sessions/live.ts +26 -0
- package/src/sessions/resolve.ts +62 -11
- package/src/tasks/archive.ts +32 -8
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +4 -2
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/commands/tasks.ts
CHANGED
|
@@ -680,7 +680,9 @@ export function register(program: Command): void {
|
|
|
680
680
|
' the last listed, so a later number closes the issue',
|
|
681
681
|
' lookup-failed the pull request lookup failed while the task already',
|
|
682
682
|
' lists a pull request, so an earlier slice cannot be ruled out',
|
|
683
|
-
' open-outcomes an outcome is still open, so a later slice closes it',
|
|
683
|
+
' open-outcomes an outcome is still open, so a later slice closes it.',
|
|
684
|
+
' A `- [ ] Operator check: <outcome>` line holds nothing open and',
|
|
685
|
+
' is listed under `operatorChecks`',
|
|
684
686
|
' pending-branch another branch is on `Pending branch:`',
|
|
685
687
|
' no-outcomes the task carries no outcomes',
|
|
686
688
|
'',
|
|
@@ -1544,6 +1546,7 @@ function reportCloses(
|
|
|
1544
1546
|
issues: outcome.issues,
|
|
1545
1547
|
closing: outcome.closing,
|
|
1546
1548
|
reason: outcome.reason,
|
|
1549
|
+
operatorChecks: outcome.operatorChecks ?? [],
|
|
1547
1550
|
})}\n`,
|
|
1548
1551
|
)
|
|
1549
1552
|
return 0
|
|
@@ -1556,6 +1559,9 @@ function reportCloses(
|
|
|
1556
1559
|
? `${outcome.stem} names ${outcome.issues.map((n) => `#${n}`).join(', ')}, ${outcome.reason}`
|
|
1557
1560
|
: `${outcome.stem} names no issue`,
|
|
1558
1561
|
)
|
|
1562
|
+
for (const check of outcome.operatorChecks ?? []) {
|
|
1563
|
+
logInfo(`operator check: ${check}`)
|
|
1564
|
+
}
|
|
1559
1565
|
outro()
|
|
1560
1566
|
|
|
1561
1567
|
return 0
|
|
@@ -2195,9 +2201,11 @@ function report(
|
|
|
2195
2201
|
logInfo('retargeted the Ready: line')
|
|
2196
2202
|
}
|
|
2197
2203
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
2204
|
+
if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
|
|
2198
2205
|
logRelinked(outcome.relinked, outcome.relinkFailed, root)
|
|
2199
2206
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
2200
2207
|
if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
|
|
2208
|
+
for (const check of outcome.operatorChecks) logInfo(check)
|
|
2201
2209
|
outro()
|
|
2202
2210
|
|
|
2203
2211
|
return 0
|
|
@@ -2277,6 +2285,7 @@ function recordFor(
|
|
|
2277
2285
|
from: relative(root, outcome.from),
|
|
2278
2286
|
to: relative(root, outcome.to),
|
|
2279
2287
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
2288
|
+
backlogRowRemoved: outcome.backlogRowRemoved,
|
|
2280
2289
|
indexRegenerated: outcome.indexRegenerated,
|
|
2281
2290
|
plans: planMovesFor(outcome.plans, root),
|
|
2282
2291
|
ready: outcome.ready
|
|
@@ -2287,6 +2296,7 @@ function recordFor(
|
|
|
2287
2296
|
: null,
|
|
2288
2297
|
closed: outcome.closed,
|
|
2289
2298
|
cut: outcome.cut,
|
|
2299
|
+
operatorChecks: outcome.operatorChecks,
|
|
2290
2300
|
relinked: relinkedFor(outcome.relinked, root),
|
|
2291
2301
|
relinkFailed: relinkFailedFor(outcome.relinkFailed, root),
|
|
2292
2302
|
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DensityFinding,
|
|
3
|
+
type DensityKind,
|
|
4
|
+
densityFindings,
|
|
5
|
+
} from '@/markdown/density'
|
|
6
|
+
import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
|
|
7
|
+
import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The four counted limits a pull request section carries. The heading limits
|
|
11
|
+
* of `densityFindings()` are left out, since a body takes no subheading at all
|
|
12
|
+
* and `subheading` reports that once.
|
|
13
|
+
*/
|
|
14
|
+
const COUNTED = [
|
|
15
|
+
'paragraph-length',
|
|
16
|
+
'paragraph-run',
|
|
17
|
+
'bullet-length',
|
|
18
|
+
'bullet-run',
|
|
19
|
+
] as const satisfies readonly DensityKind[]
|
|
20
|
+
|
|
21
|
+
type CountedKind = (typeof COUNTED)[number]
|
|
22
|
+
|
|
23
|
+
export type BodyDensityKind = CountedKind | 'subheading' | 'mixed-form'
|
|
24
|
+
|
|
25
|
+
function isCounted(kind: DensityKind): kind is CountedKind {
|
|
26
|
+
return (COUNTED as readonly DensityKind[]).includes(kind)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BodyDensityFinding {
|
|
30
|
+
/** The H2 the finding sits under, without its hashes, or null before the first. */
|
|
31
|
+
readonly section: string | null
|
|
32
|
+
readonly kind: BodyDensityKind
|
|
33
|
+
/** The line in the whole body, not in its section. */
|
|
34
|
+
readonly line: number
|
|
35
|
+
readonly value: number | null
|
|
36
|
+
readonly limit: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const H2 = /^##\s+(.*?)\s*$/
|
|
40
|
+
const SUBHEADING = /^(#{3,6})\s/
|
|
41
|
+
const BOLD_ONLY = /^(\*\*|__)[^*_].*\1\s*$/
|
|
42
|
+
const BOLD_LED = /^(\*\*|__)\S/
|
|
43
|
+
|
|
44
|
+
interface Section {
|
|
45
|
+
readonly name: string | null
|
|
46
|
+
readonly lines: readonly BodyLine[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every density breach in a pull request body, graded per H2 section after
|
|
51
|
+
* the shape `claude/skills/git-pr/references/pr.md` states.
|
|
52
|
+
*
|
|
53
|
+
* `densityFindings()` stays the docs measure, so the PR exemptions live here
|
|
54
|
+
* as splits around it rather than as options on it. Testing takes no counted
|
|
55
|
+
* limit, since its checkboxes count as a bullet run. Key changes splits at
|
|
56
|
+
* each bold label alone on its own block, whatever its length, and Technical context
|
|
57
|
+
* at each bold-led paragraph, so a lead-in starts a new run. Technical
|
|
58
|
+
* context also reports mixing bullets with paragraphs.
|
|
59
|
+
*/
|
|
60
|
+
export function bodyDensity(body: string): BodyDensityFinding[] {
|
|
61
|
+
const found: BodyDensityFinding[] = []
|
|
62
|
+
|
|
63
|
+
for (const section of sectionsOf(bodyLines(body))) {
|
|
64
|
+
found.push(...subheadings(section))
|
|
65
|
+
if (section.name === 'Testing') continue
|
|
66
|
+
|
|
67
|
+
const chunks =
|
|
68
|
+
section.name === 'Key changes'
|
|
69
|
+
? splitAt(section.lines, opensBlock(section.lines, BOLD_ONLY), true)
|
|
70
|
+
: section.name === 'Technical context'
|
|
71
|
+
? splitAt(section.lines, opensBlock(section.lines, BOLD_LED), false)
|
|
72
|
+
: [section.lines]
|
|
73
|
+
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
for (const finding of densityFindings(chunk)) {
|
|
76
|
+
const { kind } = finding
|
|
77
|
+
if (!isCounted(kind)) continue
|
|
78
|
+
found.push({ section: section.name, ...finding, kind })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (section.name === 'Technical context') {
|
|
83
|
+
const mixed = mixedForm(section.lines)
|
|
84
|
+
if (mixed !== undefined) {
|
|
85
|
+
found.push({
|
|
86
|
+
section: section.name,
|
|
87
|
+
kind: 'mixed-form',
|
|
88
|
+
line: mixed,
|
|
89
|
+
value: null,
|
|
90
|
+
limit: null,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return found.sort((a, b) => a.line - b.line)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The opening headings of the posts a canon skill writes to a pull request,
|
|
101
|
+
* after `.canon/groundwork/170-github-comment-density/04-post-catalog.md`. A
|
|
102
|
+
* body opening on any other heading is not a post this reader grades.
|
|
103
|
+
*/
|
|
104
|
+
export const POST_HEADINGS = [
|
|
105
|
+
'Evidence',
|
|
106
|
+
'Review',
|
|
107
|
+
'Review closed',
|
|
108
|
+
'UI review',
|
|
109
|
+
'UI review closed',
|
|
110
|
+
'Review response',
|
|
111
|
+
'Post-review findings',
|
|
112
|
+
'Rebase',
|
|
113
|
+
] as const
|
|
114
|
+
|
|
115
|
+
export type PostDensityKind = CountedKind | 'heading-depth'
|
|
116
|
+
|
|
117
|
+
export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
|
|
118
|
+
readonly kind: PostDensityKind
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
|
|
122
|
+
const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
|
|
123
|
+
const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
|
|
124
|
+
const FOOTER = /^🤖/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every density breach in a post a skill writes to a pull request, keyed on
|
|
128
|
+
* its opening H2, or none when that heading is not in `POST_HEADINGS`.
|
|
129
|
+
*
|
|
130
|
+
* A post allows H3s, which the UI review groups its verdicts under, so only a
|
|
131
|
+
* heading past H3 reports. The `🤖` footer and the marker never report, and a
|
|
132
|
+
* table never does. Inside `<details>` a block takes the length limits alone,
|
|
133
|
+
* since a reader opens it on purpose. A bold-led paragraph starts a new
|
|
134
|
+
* paragraph run, and a checklist box under `What to look at` takes no length
|
|
135
|
+
* limit, since a driver needs its route, action, and result.
|
|
136
|
+
*
|
|
137
|
+
* A nested item is measured as a bullet of its own, which `densityFindings()`
|
|
138
|
+
* leaves out of its parent, so a finding's subject and its evidence never sum.
|
|
139
|
+
*/
|
|
140
|
+
export function postDensity(body: string): PostDensityFinding[] {
|
|
141
|
+
const lines = bodyLines(body).map((line) =>
|
|
142
|
+
!line.fenced && FOOTER.test(line.text) ? { ...line, text: '' } : line,
|
|
143
|
+
)
|
|
144
|
+
if (!opensOnPostHeading(lines)) return []
|
|
145
|
+
|
|
146
|
+
const found: PostDensityFinding[] = []
|
|
147
|
+
const text = new Map(lines.map((line) => [line.number, line.text]))
|
|
148
|
+
|
|
149
|
+
for (const section of sectionsOf(lines)) {
|
|
150
|
+
const { outside, inside } = foldedApart(section.lines)
|
|
151
|
+
const measured = [
|
|
152
|
+
...splitAt(outside, opensBlock(outside, BOLD_LED), false).flatMap(
|
|
153
|
+
(chunk) => densityFindings(chunk),
|
|
154
|
+
),
|
|
155
|
+
...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
|
|
156
|
+
...nestedLengths(section.lines),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
for (const finding of measured) {
|
|
160
|
+
const { kind } = finding
|
|
161
|
+
if (!isCounted(kind) && kind !== 'heading-depth') continue
|
|
162
|
+
if (
|
|
163
|
+
section.name === 'What to look at' &&
|
|
164
|
+
kind === 'bullet-length' &&
|
|
165
|
+
CHECKBOX.test(text.get(finding.line) ?? '')
|
|
166
|
+
) {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
found.push({ section: section.name, ...finding, kind })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return found.sort((a, b) => a.line - b.line)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether the body's first H2 outside a fence is one of `POST_HEADINGS`. */
|
|
177
|
+
export function isPost(body: string): boolean {
|
|
178
|
+
return opensOnPostHeading(bodyLines(body))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function opensOnPostHeading(lines: readonly BodyLine[]): boolean {
|
|
182
|
+
const opening = lines.find((line) => !line.fenced && H2.test(line.text))
|
|
183
|
+
const heading = opening && H2.exec(opening.text)?.[1]
|
|
184
|
+
return (POST_HEADINGS as readonly string[]).includes(heading ?? '')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parts a section into the lines outside `<details>` and the lines inside it.
|
|
189
|
+
* The tags stay outside, where each breaks a paragraph run as raw HTML does.
|
|
190
|
+
*/
|
|
191
|
+
function foldedApart(lines: readonly BodyLine[]): {
|
|
192
|
+
outside: BodyLine[]
|
|
193
|
+
inside: BodyLine[]
|
|
194
|
+
} {
|
|
195
|
+
const outside: BodyLine[] = []
|
|
196
|
+
const inside: BodyLine[] = []
|
|
197
|
+
let depth = 0
|
|
198
|
+
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
const tag = line.fenced ? '' : line.text.trim()
|
|
201
|
+
if (tag.startsWith('<details')) {
|
|
202
|
+
depth++
|
|
203
|
+
outside.push(line)
|
|
204
|
+
} else if (tag.startsWith('</details')) {
|
|
205
|
+
depth = Math.max(0, depth - 1)
|
|
206
|
+
outside.push(line)
|
|
207
|
+
} else if (depth > 0) {
|
|
208
|
+
inside.push(line)
|
|
209
|
+
} else {
|
|
210
|
+
outside.push(line)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { outside, inside }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A `bullet-length` finding for each nested item past the bullet limit, its
|
|
219
|
+
* length taken over the item and the lines indented under it that open no
|
|
220
|
+
* item of their own.
|
|
221
|
+
*/
|
|
222
|
+
function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
|
|
223
|
+
const found: DensityFinding[] = []
|
|
224
|
+
|
|
225
|
+
for (const [index, line] of lines.entries()) {
|
|
226
|
+
const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
|
|
227
|
+
if (!match) continue
|
|
228
|
+
|
|
229
|
+
const indent = match[1].length
|
|
230
|
+
const parts = [match[2].trim()]
|
|
231
|
+
for (const next of lines.slice(index + 1)) {
|
|
232
|
+
const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
|
|
233
|
+
if (next.fenced || next.text.trim() === '' || lead <= indent) break
|
|
234
|
+
if (LIST_ITEM.test(next.text)) break
|
|
235
|
+
parts.push(next.text.trim())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const length = visibleText(parts.join(' ')).length
|
|
239
|
+
if (length > CHECKPOINTS.bullet) {
|
|
240
|
+
found.push({
|
|
241
|
+
kind: 'bullet-length',
|
|
242
|
+
line: line.number,
|
|
243
|
+
value: length,
|
|
244
|
+
limit: CHECKPOINTS.bullet,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return found
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sectionsOf(lines: readonly BodyLine[]): Section[] {
|
|
253
|
+
const sections: Section[] = []
|
|
254
|
+
let current: { name: string | null; lines: BodyLine[] } = {
|
|
255
|
+
name: null,
|
|
256
|
+
lines: [],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
const heading = line.fenced ? null : H2.exec(line.text)
|
|
261
|
+
if (heading) {
|
|
262
|
+
sections.push(current)
|
|
263
|
+
current = { name: heading[1], lines: [] }
|
|
264
|
+
} else {
|
|
265
|
+
current.lines.push(line)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
sections.push(current)
|
|
269
|
+
|
|
270
|
+
return sections.filter((section) => section.lines.length > 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function subheadings(section: Section): BodyDensityFinding[] {
|
|
274
|
+
return section.lines.flatMap((line) => {
|
|
275
|
+
const match = line.fenced ? null : SUBHEADING.exec(line.text)
|
|
276
|
+
if (!match) return []
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
section: section.name,
|
|
280
|
+
kind: 'subheading' as const,
|
|
281
|
+
line: line.number,
|
|
282
|
+
value: match[1].length,
|
|
283
|
+
limit: 2,
|
|
284
|
+
},
|
|
285
|
+
]
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Cuts a section into chunks at each line the test picks, never inside a
|
|
291
|
+
* fence. `drop` leaves the picked line out of both chunks, as a bold label
|
|
292
|
+
* that only names the group below it.
|
|
293
|
+
*/
|
|
294
|
+
function splitAt(
|
|
295
|
+
lines: readonly BodyLine[],
|
|
296
|
+
breaks: (line: BodyLine, index: number) => boolean,
|
|
297
|
+
drop: boolean,
|
|
298
|
+
): BodyLine[][] {
|
|
299
|
+
const chunks: BodyLine[][] = [[]]
|
|
300
|
+
|
|
301
|
+
for (const [index, line] of lines.entries()) {
|
|
302
|
+
if (!line.fenced && breaks(line, index)) {
|
|
303
|
+
chunks.push(drop ? [] : [line])
|
|
304
|
+
} else {
|
|
305
|
+
chunks[chunks.length - 1].push(line)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return chunks
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Picks a line matching the pattern that opens a block, after a blank line or
|
|
314
|
+
* at the section start. A line directly under a bullet or a paragraph renders
|
|
315
|
+
* as its lazy continuation, so it breaks nothing.
|
|
316
|
+
*/
|
|
317
|
+
function opensBlock(
|
|
318
|
+
lines: readonly BodyLine[],
|
|
319
|
+
pattern: RegExp,
|
|
320
|
+
): (line: BodyLine, index: number) => boolean {
|
|
321
|
+
return (line, index) =>
|
|
322
|
+
pattern.test(line.text) &&
|
|
323
|
+
(index === 0 || lines[index - 1].text.trim() === '')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The line of the first block of whichever form appears second, when a
|
|
328
|
+
* section carries both bullets and paragraphs, else undefined.
|
|
329
|
+
*
|
|
330
|
+
* A column-zero line directly under an item with no blank between is a lazy
|
|
331
|
+
* continuation of that item, not a paragraph.
|
|
332
|
+
*/
|
|
333
|
+
function mixedForm(lines: readonly BodyLine[]): number | undefined {
|
|
334
|
+
let firstItem: number | undefined
|
|
335
|
+
let firstParagraph: number | undefined
|
|
336
|
+
let previous: 'blank' | 'item' | 'paragraph' | 'other' = 'blank'
|
|
337
|
+
|
|
338
|
+
for (const line of lines) {
|
|
339
|
+
const text = line.text
|
|
340
|
+
if (line.fenced) {
|
|
341
|
+
previous = 'other'
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
if (text.trim() === '') {
|
|
345
|
+
previous = 'blank'
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
if (/^\s/.test(text)) continue
|
|
349
|
+
|
|
350
|
+
if (LIST_ITEM.test(text)) {
|
|
351
|
+
firstItem ??= line.number
|
|
352
|
+
previous = 'item'
|
|
353
|
+
} else if (/^(#|\||>|<)/.test(text)) {
|
|
354
|
+
previous = 'other'
|
|
355
|
+
} else if (previous === 'blank' || previous === 'other') {
|
|
356
|
+
firstParagraph ??= line.number
|
|
357
|
+
previous = 'paragraph'
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (firstItem === undefined || firstParagraph === undefined) return undefined
|
|
362
|
+
return Math.max(firstItem, firstParagraph)
|
|
363
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Adds the session defaults the seeded `.claude/settings.json` carries to a
|
|
3
|
-
* target that was seeded before them
|
|
3
|
+
* target that was seeded before them: the subagent deny, the memory default, and
|
|
4
|
+
* the merge deny entries.
|
|
4
5
|
*
|
|
5
6
|
* `canon claude init` copies a seed only when the file is absent, so a target
|
|
6
7
|
* holding its own settings never sees a later seed change. This verb adds each
|
|
@@ -21,6 +22,12 @@ import {
|
|
|
21
22
|
/** Both names, since `Task` is the tool's name before Claude Code renamed it. */
|
|
22
23
|
export const DENIED_TOOLS: readonly string[] = ['Agent', 'Task']
|
|
23
24
|
|
|
25
|
+
/** The merge entries the seeded deny list carries, so a session cannot merge. */
|
|
26
|
+
export const MERGE_DENIED: readonly string[] = [
|
|
27
|
+
'Bash(canon pr merge:*)',
|
|
28
|
+
'Bash(gh pr merge:*)',
|
|
29
|
+
]
|
|
30
|
+
|
|
24
31
|
export interface SessionDefaultsPlan {
|
|
25
32
|
readonly keys: readonly SettingsKey[]
|
|
26
33
|
readonly next: SettingsRecord
|
|
@@ -31,6 +38,8 @@ export interface SessionDefaultsPlan {
|
|
|
31
38
|
* on purpose, which is how an operator opts back into subagents.
|
|
32
39
|
*/
|
|
33
40
|
readonly predates: boolean
|
|
41
|
+
/** True when the deny list lacks any merge entry, whatever else it holds. */
|
|
42
|
+
readonly lacksMergeDeny: boolean
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export type SessionDefaultsRead =
|
|
@@ -52,8 +61,8 @@ function isRecord(value: unknown): value is SettingsRecord {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/**
|
|
55
|
-
* Appends the denied tools a list lacks, keeping the list's
|
|
56
|
-
* diff a person reads is the added entries alone.
|
|
64
|
+
* Appends the denied tools and merge entries a list lacks, keeping the list's
|
|
65
|
+
* own order, so the diff a person reads is the added entries alone.
|
|
57
66
|
*/
|
|
58
67
|
export function planSessionDefaults(
|
|
59
68
|
current: SettingsRecord,
|
|
@@ -62,7 +71,9 @@ export function planSessionDefaults(
|
|
|
62
71
|
const permissions = isRecord(current.permissions) ? current.permissions : {}
|
|
63
72
|
const deny = Array.isArray(permissions.deny) ? permissions.deny : []
|
|
64
73
|
|
|
65
|
-
const
|
|
74
|
+
const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
|
|
75
|
+
const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
|
|
76
|
+
const missing = [...subagentsMissing, ...mergeMissing]
|
|
66
77
|
if (missing.length > 0) {
|
|
67
78
|
next.permissions = { ...permissions, deny: [...deny, ...missing] }
|
|
68
79
|
}
|
|
@@ -78,7 +89,11 @@ export function planSessionDefaults(
|
|
|
78
89
|
keys,
|
|
79
90
|
next,
|
|
80
91
|
changed: keys.some((key) => key.changed),
|
|
81
|
-
predates:
|
|
92
|
+
predates:
|
|
93
|
+
memoryAbsent &&
|
|
94
|
+
subagentsMissing.length === DENIED_TOOLS.length &&
|
|
95
|
+
mergeMissing.length === MERGE_DENIED.length,
|
|
96
|
+
lacksMergeDeny: mergeMissing.length > 0,
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
package/src/pr/evidence.ts
CHANGED
|
@@ -5,7 +5,7 @@ const EVIDENCE_SEGMENT = 'evidence'
|
|
|
5
5
|
const EVIDENCE_OPEN_LIMIT = 6
|
|
6
6
|
|
|
7
7
|
/** Length of the commit names the visible line shows, the width `git` abbreviates to. */
|
|
8
|
-
const SHORT_SHA_LENGTH = 7
|
|
8
|
+
export const SHORT_SHA_LENGTH = 7
|
|
9
9
|
|
|
10
10
|
/** Prefix of the trailing marker this module writes and reads back. */
|
|
11
11
|
const MARKER_PREFIX = '<!-- pr-evidence:'
|
package/src/pr/lift.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ReviewRow,
|
|
5
5
|
resolveReviewScope,
|
|
6
6
|
resolveUiScope,
|
|
7
|
+
type UiCounts,
|
|
7
8
|
} from '@/pr/review-scope'
|
|
8
9
|
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
9
10
|
|
|
@@ -24,6 +25,7 @@ export type LiftRefusal =
|
|
|
24
25
|
| 'ui-open'
|
|
25
26
|
| 'ui-unmarked'
|
|
26
27
|
| 'ui-behind'
|
|
28
|
+
| 'ui-undriven'
|
|
27
29
|
| 'evidence-owed'
|
|
28
30
|
| 'standing-stale'
|
|
29
31
|
| 'standing-conflict'
|
|
@@ -170,6 +172,20 @@ function carried(gates: readonly GateResult[]): {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Whether a verdict's counts show boxes left undriven and none driven. An empty
|
|
177
|
+
* pass, with nothing not driven either, looked at no box because none was owed.
|
|
178
|
+
*/
|
|
179
|
+
function droveNothing(counts: UiCounts | undefined): boolean {
|
|
180
|
+
return (
|
|
181
|
+
counts !== undefined &&
|
|
182
|
+
counts.failed === 0 &&
|
|
183
|
+
counts.eyes === 0 &&
|
|
184
|
+
counts.passed === 0 &&
|
|
185
|
+
counts.notDriven > 0
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
/**
|
|
174
190
|
* Binds only where the evidence record carries a checklist to drive.
|
|
175
191
|
*
|
|
@@ -192,6 +208,9 @@ export async function uiGate(
|
|
|
192
208
|
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
193
209
|
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
194
210
|
if (scope.commit === undefined) return fail('ui', 'ui-behind')
|
|
211
|
+
// Ahead of the head test so a close carried across commits is refused too.
|
|
212
|
+
// A verdict whose count line did not parse reads as unchecked and goes on.
|
|
213
|
+
if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
|
|
195
214
|
if (coversHead(scope.commit, head)) return pass('ui')
|
|
196
215
|
|
|
197
216
|
const changed = await api.readChanged(scope.commit, head)
|