@erclx/canon 5.47.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-pr/references/closing-issues.md +2 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +3 -3
- package/claude/skills/review-ui/references/post.md +14 -2
- package/claude/skills/review-ui/references/tick.md +4 -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/SKILL.md +1 -1
- 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 +2 -1
- package/docs/agents/migrate.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/package.json +1 -1
- package/src/commands/claude.ts +4 -0
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +301 -35
- package/src/commands/tasks.ts +9 -1
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/lift.ts +19 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +1 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +21 -1
- package/src/tasks/archive.ts +17 -4
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +4 -2
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/pr/merge.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ChecksGate } from '@/pr/lift'
|
|
2
|
+
import { readSection } from '@/pr/paths'
|
|
3
|
+
import { UI_PASS_MARKER } from '@/pr/tick'
|
|
4
|
+
|
|
5
|
+
/** Why the verb did not merge, in the order the refusals are read. */
|
|
6
|
+
export type MergeRefusal =
|
|
7
|
+
| 'pull-closed'
|
|
8
|
+
| 'pull-draft'
|
|
9
|
+
| 'missing-summary'
|
|
10
|
+
| 'missing-key-changes'
|
|
11
|
+
| 'ci-failing'
|
|
12
|
+
| 'ci-conflicted'
|
|
13
|
+
| 'ci-pending'
|
|
14
|
+
| 'head-moved'
|
|
15
|
+
| 'not-mergeable'
|
|
16
|
+
| 'gh-failed'
|
|
17
|
+
|
|
18
|
+
export interface MergePull {
|
|
19
|
+
readonly isOpen: boolean
|
|
20
|
+
readonly isDraft: boolean
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly body: string
|
|
23
|
+
/** The head the merge is pinned to, so a push after the read refuses. */
|
|
24
|
+
readonly head: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MergeRequest {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly description: string
|
|
30
|
+
readonly sha: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
|
|
34
|
+
export type MergeOutcome =
|
|
35
|
+
| { readonly kind: 'merged'; readonly sha: string }
|
|
36
|
+
| { readonly kind: 'head-moved' }
|
|
37
|
+
| { readonly kind: 'not-mergeable' }
|
|
38
|
+
| { readonly kind: 'failed' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Everything the verb reads or writes, injected so every refusal runs without
|
|
42
|
+
* a network. A read that failed returns undefined, and the verb refuses
|
|
43
|
+
* `gh-failed` rather than reading absence as a pass.
|
|
44
|
+
*/
|
|
45
|
+
export interface MergeApi {
|
|
46
|
+
readPull(): Promise<MergePull | undefined>
|
|
47
|
+
readChecks(head: string): Promise<ChecksGate | undefined>
|
|
48
|
+
putMerge(request: MergeRequest): Promise<MergeOutcome>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MergeOptions {
|
|
52
|
+
readonly number: number
|
|
53
|
+
readonly isDryRun: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MergeRecord {
|
|
57
|
+
readonly reason: 'merged' | 'dry-run' | MergeRefusal
|
|
58
|
+
readonly isOk: boolean
|
|
59
|
+
readonly number: number
|
|
60
|
+
readonly title?: string
|
|
61
|
+
readonly description?: string
|
|
62
|
+
/** The squash commit, present only on a merge. */
|
|
63
|
+
readonly sha?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Composed =
|
|
67
|
+
| { readonly kind: 'composed'; readonly description: string }
|
|
68
|
+
| {
|
|
69
|
+
readonly kind: 'refused'
|
|
70
|
+
readonly reason: 'missing-summary' | 'missing-key-changes'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A section's text with the UI pass line dropped, or undefined when nothing is left. */
|
|
74
|
+
function keptSection(body: string, title: string): string | undefined {
|
|
75
|
+
const text = readSection(body, title)
|
|
76
|
+
?.split('\n')
|
|
77
|
+
.filter((line) => !line.includes(UI_PASS_MARKER))
|
|
78
|
+
.join('\n')
|
|
79
|
+
.trim()
|
|
80
|
+
return text === undefined || text === ''
|
|
81
|
+
? undefined
|
|
82
|
+
: `## ${title}\n\n${text}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The squash description: Summary, Key changes, and Technical context, in that
|
|
87
|
+
* order, under their headings. Testing, the reviewer requests, and the UI pass
|
|
88
|
+
* line describe the review rather than the change, so none reaches the trunk.
|
|
89
|
+
* Technical context is optional, and an empty one leaves no bare heading.
|
|
90
|
+
*/
|
|
91
|
+
export function composeDescription(body: string): Composed {
|
|
92
|
+
const summary = keptSection(body, 'Summary')
|
|
93
|
+
if (summary === undefined)
|
|
94
|
+
return { kind: 'refused', reason: 'missing-summary' }
|
|
95
|
+
const keyChanges = keptSection(body, 'Key changes')
|
|
96
|
+
if (keyChanges === undefined) {
|
|
97
|
+
return { kind: 'refused', reason: 'missing-key-changes' }
|
|
98
|
+
}
|
|
99
|
+
const context = keptSection(body, 'Technical context')
|
|
100
|
+
return {
|
|
101
|
+
kind: 'composed',
|
|
102
|
+
description: [summary, keyChanges, context]
|
|
103
|
+
.filter((block) => block !== undefined)
|
|
104
|
+
.join('\n\n'),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The suffix the web merge adds and the trunk log carries. */
|
|
109
|
+
export function mergeTitle(title: string, number: number): string {
|
|
110
|
+
return `${title} (#${number})`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refused(
|
|
114
|
+
reason: MergeRefusal,
|
|
115
|
+
number: number,
|
|
116
|
+
message: { title?: string; description?: string } = {},
|
|
117
|
+
): MergeRecord {
|
|
118
|
+
return { reason, isOk: false, number, ...message }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Squash-merges one ready pull request with a message carrying only the
|
|
123
|
+
* change. Every refusal is read before the one write, and the write sends the
|
|
124
|
+
* head it read as `sha`, so GitHub refuses rather than merging a commit pushed
|
|
125
|
+
* after the checks were read. The verb reads the draft flag and not the review
|
|
126
|
+
* comments, since a ready pull request already passed the lift's gates.
|
|
127
|
+
*/
|
|
128
|
+
export async function merge(
|
|
129
|
+
api: MergeApi,
|
|
130
|
+
opts: MergeOptions,
|
|
131
|
+
): Promise<MergeRecord> {
|
|
132
|
+
const { number } = opts
|
|
133
|
+
|
|
134
|
+
const pull = await api.readPull()
|
|
135
|
+
if (pull === undefined) return refused('gh-failed', number)
|
|
136
|
+
if (!pull.isOpen) return refused('pull-closed', number)
|
|
137
|
+
if (pull.isDraft) return refused('pull-draft', number)
|
|
138
|
+
|
|
139
|
+
const title = mergeTitle(pull.title, number)
|
|
140
|
+
const composed = composeDescription(pull.body)
|
|
141
|
+
if (composed.kind === 'refused') {
|
|
142
|
+
return refused(composed.reason, number, { title })
|
|
143
|
+
}
|
|
144
|
+
const message = { title, description: composed.description }
|
|
145
|
+
|
|
146
|
+
const checks = await api.readChecks(pull.head)
|
|
147
|
+
if (checks === undefined) return refused('gh-failed', number, message)
|
|
148
|
+
if (checks.state === 'failing') return refused('ci-failing', number, message)
|
|
149
|
+
if (checks.state !== 'passing') {
|
|
150
|
+
return refused(
|
|
151
|
+
checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
|
|
152
|
+
number,
|
|
153
|
+
message,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (opts.isDryRun)
|
|
158
|
+
return { reason: 'dry-run', isOk: true, number, ...message }
|
|
159
|
+
|
|
160
|
+
const outcome = await api.putMerge({ ...message, sha: pull.head })
|
|
161
|
+
if (outcome.kind === 'merged') {
|
|
162
|
+
return {
|
|
163
|
+
reason: 'merged',
|
|
164
|
+
isOk: true,
|
|
165
|
+
number,
|
|
166
|
+
...message,
|
|
167
|
+
sha: outcome.sha,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return refused(
|
|
171
|
+
outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
|
|
172
|
+
number,
|
|
173
|
+
message,
|
|
174
|
+
)
|
|
175
|
+
}
|
package/src/pr/rest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
|
|
|
13
13
|
/** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
|
|
14
14
|
export interface RestPull {
|
|
15
15
|
readonly number?: number
|
|
16
|
+
readonly title?: string
|
|
16
17
|
readonly body?: string | null
|
|
17
18
|
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
19
|
readonly base?: { readonly ref?: string }
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
|
|
|
136
136
|
|
|
137
137
|
export type UiHeading = (typeof UI_HEADINGS)[number]
|
|
138
138
|
|
|
139
|
+
/** The four totals on a verdict's count line. */
|
|
140
|
+
export interface UiCounts {
|
|
141
|
+
readonly failed: number
|
|
142
|
+
readonly eyes: number
|
|
143
|
+
readonly passed: number
|
|
144
|
+
readonly notDriven: number
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
export interface UiScope {
|
|
140
148
|
readonly heading?: UiHeading
|
|
141
149
|
readonly state: ReviewState
|
|
142
150
|
/** The head the verdict covered, a full or abbreviated sha. */
|
|
143
151
|
readonly commit?: string
|
|
152
|
+
/** The counts the verdict wrote, absent when its count line does not parse. */
|
|
153
|
+
readonly counts?: UiCounts
|
|
144
154
|
/** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
|
|
145
155
|
readonly source: ScopeSource
|
|
146
156
|
}
|
|
@@ -152,6 +162,32 @@ export interface UiScope {
|
|
|
152
162
|
*/
|
|
153
163
|
const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
|
|
154
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The count line `review-ui`'s `post.md` fixes as the verdict's first line
|
|
167
|
+
* under the heading. It is model-written prose, so a body that drifts from the
|
|
168
|
+
* form reads as no counts and a caller treats that as unchecked.
|
|
169
|
+
*/
|
|
170
|
+
const UI_COUNTS =
|
|
171
|
+
/^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
|
|
172
|
+
|
|
173
|
+
export function readUiCounts(body: string): UiCounts | undefined {
|
|
174
|
+
for (const raw of body.split('\n').slice(1, 6)) {
|
|
175
|
+
const match = UI_COUNTS.exec(raw.trim())
|
|
176
|
+
if (match === null) continue
|
|
177
|
+
const [, failed, eyes, passed, notDriven] = match.map(Number)
|
|
178
|
+
if (
|
|
179
|
+
failed === undefined ||
|
|
180
|
+
eyes === undefined ||
|
|
181
|
+
passed === undefined ||
|
|
182
|
+
notDriven === undefined
|
|
183
|
+
) {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
return { failed, eyes, passed, notDriven }
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
function lastLine(body: string): string | undefined {
|
|
156
192
|
const lines = body.split('\n')
|
|
157
193
|
|
|
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
185
221
|
const line = lastLine(row.body ?? '')
|
|
186
222
|
const match = line === undefined ? null : UI_MARKER.exec(line)
|
|
187
223
|
const marked = match?.[1]
|
|
224
|
+
const counts = readUiCounts(row.body ?? '')
|
|
225
|
+
const counted = counts === undefined ? {} : { counts }
|
|
188
226
|
|
|
189
227
|
if (marked !== undefined) {
|
|
190
|
-
return { heading, state, commit: marked, source: 'marker' }
|
|
228
|
+
return { heading, state, commit: marked, ...counted, source: 'marker' }
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
const oid = row.commit?.oid
|
|
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
195
233
|
heading,
|
|
196
234
|
state,
|
|
197
235
|
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
236
|
+
...counted,
|
|
198
237
|
source: 'fallback',
|
|
199
238
|
}
|
|
200
239
|
}
|
package/src/pr/tick.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChecklistBox,
|
|
2
3
|
CHECKLIST_END,
|
|
3
4
|
CHECKLIST_START,
|
|
4
5
|
parseBoxLine,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
writeBoxLine,
|
|
8
9
|
} from '@/pr/evidence'
|
|
9
10
|
|
|
10
|
-
const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
11
|
+
export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
11
12
|
const TESTING_HEADING = /^## Testing\s*$/
|
|
12
13
|
const SECTION_HEADING = /^## /
|
|
13
14
|
const REVIEWER_HEADING = /^## For the reviewer\s*$/
|
|
@@ -48,6 +49,25 @@ export function uiPassLine(summary: UiPassSummary): string {
|
|
|
48
49
|
return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The driven boxes ticked at the head. A tick counts as this pass's only when
|
|
54
|
+
* stamped at the head, since an unstamped one is the author's claim and an
|
|
55
|
+
* older stamp names another head. Taste boxes are left out, as the body line
|
|
56
|
+
* leaves them out.
|
|
57
|
+
*/
|
|
58
|
+
export function countTicksAtHead(
|
|
59
|
+
boxes: readonly ChecklistBox[],
|
|
60
|
+
head: string,
|
|
61
|
+
): number {
|
|
62
|
+
return boxes.filter(
|
|
63
|
+
(box) =>
|
|
64
|
+
!box.isTaste &&
|
|
65
|
+
box.isTicked &&
|
|
66
|
+
box.stamp !== undefined &&
|
|
67
|
+
head.startsWith(box.stamp),
|
|
68
|
+
).length
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
/** The short sha the marked line names, absent when the body carries no such line. */
|
|
52
72
|
export function readUiPassHead(body: string): string | undefined {
|
|
53
73
|
const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
|
package/src/tasks/archive.ts
CHANGED
|
@@ -115,6 +115,8 @@ export interface ArchiveSuccess {
|
|
|
115
115
|
readonly ready: ReadyMove | undefined
|
|
116
116
|
readonly closed: number
|
|
117
117
|
readonly cut: number
|
|
118
|
+
/** Unticked `Operator check:` outcomes the archive left as they were. */
|
|
119
|
+
readonly operatorChecks: readonly string[]
|
|
118
120
|
/** Every task file whose links to the moved task were rewritten. */
|
|
119
121
|
readonly relinked: readonly InboundRelink[]
|
|
120
122
|
/** Every task file the pass could not read or write, left as it was. */
|
|
@@ -159,6 +161,8 @@ export interface TaskOutcomes {
|
|
|
159
161
|
readonly open: readonly string[]
|
|
160
162
|
readonly closed: readonly string[]
|
|
161
163
|
readonly cut: readonly string[]
|
|
164
|
+
/** Unticked outcomes only the operator can check, which hold nothing open. */
|
|
165
|
+
readonly operator: readonly string[]
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
export function tasksDir(root: string): string {
|
|
@@ -197,6 +201,9 @@ const BULLET_PATTERN = /^- (?:\[([ xX])\] ?)?(.*)$/
|
|
|
197
201
|
*/
|
|
198
202
|
const STRUCK_BODY_PATTERN = /^~~.+~~/
|
|
199
203
|
|
|
204
|
+
/** The one spelling of an operator-owned outcome, anchored to the body's start. */
|
|
205
|
+
const OPERATOR_BODY_PATTERN = /^Operator check: ?\S/
|
|
206
|
+
|
|
200
207
|
/**
|
|
201
208
|
* Marks the lines sitting inside a fenced block, the fence delimiters included.
|
|
202
209
|
* A checkbox in a sample a task displays is not an outcome the task claims, and
|
|
@@ -232,6 +239,7 @@ export function readOutcomes(text: string): TaskOutcomes {
|
|
|
232
239
|
const open: string[] = []
|
|
233
240
|
const closed: string[] = []
|
|
234
241
|
const cut: string[] = []
|
|
242
|
+
const operator: string[] = []
|
|
235
243
|
const lines = text.split('\n')
|
|
236
244
|
const fenced = fenceMask(lines)
|
|
237
245
|
|
|
@@ -252,13 +260,17 @@ export function readOutcomes(text: string): TaskOutcomes {
|
|
|
252
260
|
if (box === undefined) continue
|
|
253
261
|
|
|
254
262
|
if (box === ' ') {
|
|
255
|
-
|
|
263
|
+
if (OPERATOR_BODY_PATTERN.test(body)) {
|
|
264
|
+
operator.push(body)
|
|
265
|
+
} else {
|
|
266
|
+
open.push(body)
|
|
267
|
+
}
|
|
256
268
|
} else {
|
|
257
269
|
closed.push(body)
|
|
258
270
|
}
|
|
259
271
|
}
|
|
260
272
|
|
|
261
|
-
return { open, closed, cut }
|
|
273
|
+
return { open, closed, cut, operator }
|
|
262
274
|
}
|
|
263
275
|
|
|
264
276
|
/**
|
|
@@ -951,7 +963,7 @@ export async function archiveTask(
|
|
|
951
963
|
)
|
|
952
964
|
}
|
|
953
965
|
|
|
954
|
-
const { open, closed, cut } = readOutcomes(text)
|
|
966
|
+
const { open, closed, cut, operator } = readOutcomes(text)
|
|
955
967
|
|
|
956
968
|
if (open.length > 0) {
|
|
957
969
|
return refuse(
|
|
@@ -961,7 +973,7 @@ export async function archiveTask(
|
|
|
961
973
|
)
|
|
962
974
|
}
|
|
963
975
|
|
|
964
|
-
if (closed.length === 0 && cut.length === 0) {
|
|
976
|
+
if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
|
|
965
977
|
return refuse(
|
|
966
978
|
'no-outcomes',
|
|
967
979
|
`${stem} carries no outcomes, so nothing marks it shipped.`,
|
|
@@ -1020,6 +1032,7 @@ export async function archiveTask(
|
|
|
1020
1032
|
ready: ready || undefined,
|
|
1021
1033
|
closed: closed.length,
|
|
1022
1034
|
cut: cut.length,
|
|
1035
|
+
operatorChecks: operator,
|
|
1023
1036
|
relinked,
|
|
1024
1037
|
relinkFailed: failed,
|
|
1025
1038
|
}
|
package/src/tasks/closes.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface Closure {
|
|
|
29
29
|
readonly issues: readonly number[]
|
|
30
30
|
readonly closing: boolean
|
|
31
31
|
readonly reason: ClosingReason
|
|
32
|
+
/** Unticked operator checks, read once the outcome test runs. */
|
|
33
|
+
readonly operatorChecks?: readonly string[]
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface TaskClosure extends Closure {
|
|
@@ -108,16 +110,18 @@ export function decideClosing(
|
|
|
108
110
|
return { issues, closing: false, reason: 'pending-branch' }
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
const { open, closed, cut } = readOutcomes(text)
|
|
113
|
+
const { open, closed, cut, operator } = readOutcomes(text)
|
|
114
|
+
const operatorChecks = operator
|
|
115
|
+
|
|
112
116
|
if (open.length > 0) {
|
|
113
|
-
return { issues, closing: false, reason: 'open-outcomes' }
|
|
117
|
+
return { issues, closing: false, reason: 'open-outcomes', operatorChecks }
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
if (closed.length === 0 && cut.length === 0) {
|
|
117
|
-
return { issues, closing: false, reason: 'no-outcomes' }
|
|
120
|
+
if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
|
|
121
|
+
return { issues, closing: false, reason: 'no-outcomes', operatorChecks }
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
return { issues, closing: true, reason: 'last-slice' }
|
|
124
|
+
return { issues, closing: true, reason: 'last-slice', operatorChecks }
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
/** Resolves the task through the same selector `pull-request` takes. */
|
package/src/tasks/validate.ts
CHANGED
|
@@ -1315,8 +1315,10 @@ async function checkCitedTask(
|
|
|
1315
1315
|
}
|
|
1316
1316
|
|
|
1317
1317
|
const text = await readFile(live, 'utf8')
|
|
1318
|
-
const { open, closed } = readOutcomes(text)
|
|
1319
|
-
if (open.length > 0 || closed.length === 0)
|
|
1318
|
+
const { open, closed, operator } = readOutcomes(text)
|
|
1319
|
+
if (open.length > 0 || closed.length + operator.length === 0) {
|
|
1320
|
+
return nothing()
|
|
1321
|
+
}
|
|
1320
1322
|
|
|
1321
1323
|
const pending = readPendingBranches(text)
|
|
1322
1324
|
if (pending.length > 0) {
|
|
@@ -69,7 +69,9 @@ A project installed before the board became a folder still holds `.claude/TASKS.
|
|
|
69
69
|
- Keep only what is project-specific in the project file, since project settings layer over user settings
|
|
70
70
|
- Expect `permissions.deny` to hold `Agent` and `Task`, so no session spawns a subagent and parallel work runs as separate background sessions
|
|
71
71
|
- Expect `autoMemoryEnabled` to be `false`. A person turns it back on for themselves in `settings.local.json`.
|
|
72
|
-
- Run `canon migrate session-defaults` on a target seeded before those two defaults, since sync never rewrites an existing settings file
|
|
72
|
+
- Run `canon migrate session-defaults` on a target seeded before those two defaults or before the merge entries below, since sync never rewrites an existing settings file
|
|
73
|
+
|
|
74
|
+
The seed's deny list also holds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)`, so the operator alone merges. An existing target gets both from `canon migrate session-defaults --write`, and `canon claude sync` warns while either is absent. Neither covers more than the form an agent writes by default.
|
|
73
75
|
|
|
74
76
|
## User settings
|
|
75
77
|
|