@erclx/canon 5.47.0 → 5.49.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +18 -15
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +7 -14
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +36 -3
- package/claude/skills/review-ui/references/tick.md +4 -1
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -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-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +5 -3
- 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/agents/records.md +1 -1
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/parallel-features.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 +712 -58
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +21 -1
- package/src/hooks/post-merge.ts +11 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/lift.ts +19 -0
- package/src/pr/local.ts +26 -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 +21 -1
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +154 -8
- package/src/tasks/board.ts +68 -13
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +43 -4
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/tasks/archive.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { execa } from 'execa'
|
|
2
3
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
4
6
|
import { regenOne } from '@/indexes/regen'
|
|
7
|
+
import { rerenderRow } from '@/tasks/board'
|
|
8
|
+
import { planBranch } from '@/tasks/branch'
|
|
5
9
|
import { isUnder } from '@/tasks/paths'
|
|
6
10
|
import { recordDir, recordDirs } from '@/roots/record'
|
|
7
11
|
|
|
@@ -115,6 +119,8 @@ export interface ArchiveSuccess {
|
|
|
115
119
|
readonly ready: ReadyMove | undefined
|
|
116
120
|
readonly closed: number
|
|
117
121
|
readonly cut: number
|
|
122
|
+
/** Unticked `Operator check:` outcomes the archive left as they were. */
|
|
123
|
+
readonly operatorChecks: readonly string[]
|
|
118
124
|
/** Every task file whose links to the moved task were rewritten. */
|
|
119
125
|
readonly relinked: readonly InboundRelink[]
|
|
120
126
|
/** Every task file the pass could not read or write, left as it was. */
|
|
@@ -126,8 +132,25 @@ export interface ArchiveRefused {
|
|
|
126
132
|
readonly reason: ArchiveRefusal
|
|
127
133
|
readonly message: string
|
|
128
134
|
readonly detail: readonly string[]
|
|
135
|
+
/**
|
|
136
|
+
* The slice plan a merged pull request settled while its task stayed open,
|
|
137
|
+
* moved into the archive. Set only on `earlier-slice` and `open-outcomes`.
|
|
138
|
+
*/
|
|
139
|
+
readonly slice?: PlanMove
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Answers the head branch a pull request merged from, or undefined when it
|
|
144
|
+
* cannot be read. Tests supply one rather than reaching for `gh`.
|
|
145
|
+
*/
|
|
146
|
+
export type HeadReader = (pullRequest: number) => Promise<string | undefined>
|
|
147
|
+
|
|
148
|
+
export interface ArchiveOptions {
|
|
149
|
+
readonly head?: HeadReader
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const GH_TIMEOUT_MS = 15_000
|
|
153
|
+
|
|
131
154
|
export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
|
|
132
155
|
|
|
133
156
|
export interface DeclineSuccess {
|
|
@@ -159,6 +182,8 @@ export interface TaskOutcomes {
|
|
|
159
182
|
readonly open: readonly string[]
|
|
160
183
|
readonly closed: readonly string[]
|
|
161
184
|
readonly cut: readonly string[]
|
|
185
|
+
/** Unticked outcomes only the operator can check, which hold nothing open. */
|
|
186
|
+
readonly operator: readonly string[]
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
export function tasksDir(root: string): string {
|
|
@@ -197,6 +222,9 @@ const BULLET_PATTERN = /^- (?:\[([ xX])\] ?)?(.*)$/
|
|
|
197
222
|
*/
|
|
198
223
|
const STRUCK_BODY_PATTERN = /^~~.+~~/
|
|
199
224
|
|
|
225
|
+
/** The one spelling of an operator-owned outcome, anchored to the body's start. */
|
|
226
|
+
const OPERATOR_BODY_PATTERN = /^Operator check: ?\S/
|
|
227
|
+
|
|
200
228
|
/**
|
|
201
229
|
* Marks the lines sitting inside a fenced block, the fence delimiters included.
|
|
202
230
|
* A checkbox in a sample a task displays is not an outcome the task claims, and
|
|
@@ -232,6 +260,7 @@ export function readOutcomes(text: string): TaskOutcomes {
|
|
|
232
260
|
const open: string[] = []
|
|
233
261
|
const closed: string[] = []
|
|
234
262
|
const cut: string[] = []
|
|
263
|
+
const operator: string[] = []
|
|
235
264
|
const lines = text.split('\n')
|
|
236
265
|
const fenced = fenceMask(lines)
|
|
237
266
|
|
|
@@ -252,13 +281,17 @@ export function readOutcomes(text: string): TaskOutcomes {
|
|
|
252
281
|
if (box === undefined) continue
|
|
253
282
|
|
|
254
283
|
if (box === ' ') {
|
|
255
|
-
|
|
284
|
+
if (OPERATOR_BODY_PATTERN.test(body)) {
|
|
285
|
+
operator.push(body)
|
|
286
|
+
} else {
|
|
287
|
+
open.push(body)
|
|
288
|
+
}
|
|
256
289
|
} else {
|
|
257
290
|
closed.push(body)
|
|
258
291
|
}
|
|
259
292
|
}
|
|
260
293
|
|
|
261
|
-
return { open, closed, cut }
|
|
294
|
+
return { open, closed, cut, operator }
|
|
262
295
|
}
|
|
263
296
|
|
|
264
297
|
/**
|
|
@@ -913,6 +946,7 @@ async function resolveStem(
|
|
|
913
946
|
export async function archiveTask(
|
|
914
947
|
root: string,
|
|
915
948
|
selector: TaskSelector,
|
|
949
|
+
options: ArchiveOptions = {},
|
|
916
950
|
): Promise<ArchiveOutcome> {
|
|
917
951
|
const dir = tasksDir(root)
|
|
918
952
|
|
|
@@ -932,9 +966,13 @@ export async function archiveTask(
|
|
|
932
966
|
// while the last slice is still open. Only the last number closes the task.
|
|
933
967
|
const last = readPullRequest(text).at(-1)
|
|
934
968
|
if (selector.kind === 'pull-request' && last !== selector.number) {
|
|
935
|
-
return
|
|
936
|
-
|
|
937
|
-
|
|
969
|
+
return withMergedSlice(
|
|
970
|
+
refuse(
|
|
971
|
+
'earlier-slice',
|
|
972
|
+
`${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
|
|
973
|
+
),
|
|
974
|
+
{ dir, root, stem, text, number: selector.number },
|
|
975
|
+
options.head ?? ghHeadReader(root),
|
|
938
976
|
)
|
|
939
977
|
}
|
|
940
978
|
|
|
@@ -951,17 +989,24 @@ export async function archiveTask(
|
|
|
951
989
|
)
|
|
952
990
|
}
|
|
953
991
|
|
|
954
|
-
const { open, closed, cut } = readOutcomes(text)
|
|
992
|
+
const { open, closed, cut, operator } = readOutcomes(text)
|
|
955
993
|
|
|
956
994
|
if (open.length > 0) {
|
|
957
|
-
|
|
995
|
+
const refused = refuse(
|
|
958
996
|
'open-outcomes',
|
|
959
997
|
`${stem} has ${open.length} open outcome(s). Close them, or cut one with \`- ~~<outcome>~~ <why>\`, then archive.`,
|
|
960
998
|
open,
|
|
961
999
|
)
|
|
1000
|
+
return selector.kind === 'pull-request'
|
|
1001
|
+
? withMergedSlice(
|
|
1002
|
+
refused,
|
|
1003
|
+
{ dir, root, stem, text, number: selector.number },
|
|
1004
|
+
options.head ?? ghHeadReader(root),
|
|
1005
|
+
)
|
|
1006
|
+
: refused
|
|
962
1007
|
}
|
|
963
1008
|
|
|
964
|
-
if (closed.length === 0 && cut.length === 0) {
|
|
1009
|
+
if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
|
|
965
1010
|
return refuse(
|
|
966
1011
|
'no-outcomes',
|
|
967
1012
|
`${stem} carries no outcomes, so nothing marks it shipped.`,
|
|
@@ -1020,11 +1065,112 @@ export async function archiveTask(
|
|
|
1020
1065
|
ready: ready || undefined,
|
|
1021
1066
|
closed: closed.length,
|
|
1022
1067
|
cut: cut.length,
|
|
1068
|
+
operatorChecks: operator,
|
|
1023
1069
|
relinked,
|
|
1024
1070
|
relinkFailed: failed,
|
|
1025
1071
|
}
|
|
1026
1072
|
}
|
|
1027
1073
|
|
|
1074
|
+
interface MergedSlice {
|
|
1075
|
+
readonly dir: string
|
|
1076
|
+
readonly root: string
|
|
1077
|
+
readonly stem: string
|
|
1078
|
+
readonly text: string
|
|
1079
|
+
readonly number: number
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Moves the slice plan a merged pull request built into the archive while its
|
|
1084
|
+
* task stays open, and retargets the task's `Plan:` line at it, so the board
|
|
1085
|
+
* row drops the slice through `livePlans`. The refusal still answers, since
|
|
1086
|
+
* the task did not close.
|
|
1087
|
+
*
|
|
1088
|
+
* Only a task citing more than one live plan has a slice to drop. The plan is
|
|
1089
|
+
* the one whose derived branch is the pull request's head, so a plan a worker
|
|
1090
|
+
* renamed off its branch matches nothing and moves nothing. A plan another
|
|
1091
|
+
* live task cites stays, for the reason `plansToArchive` gives, and an
|
|
1092
|
+
* unreadable head leaves everything as it was.
|
|
1093
|
+
*/
|
|
1094
|
+
async function withMergedSlice(
|
|
1095
|
+
refused: ArchiveRefused,
|
|
1096
|
+
merged: MergedSlice,
|
|
1097
|
+
head: HeadReader,
|
|
1098
|
+
): Promise<ArchiveRefused> {
|
|
1099
|
+
const { dir, root, stem, text } = merged
|
|
1100
|
+
const live = readPlanTargets(text).flatMap((target) => {
|
|
1101
|
+
const path = resolveLivePlan(target, dir, root)
|
|
1102
|
+
return path && existsSync(path) ? [{ target, path }] : []
|
|
1103
|
+
})
|
|
1104
|
+
if (new Set(live.map((entry) => entry.path)).size < 2) return refused
|
|
1105
|
+
|
|
1106
|
+
const branch = await head(merged.number)
|
|
1107
|
+
if (branch === undefined) return refused
|
|
1108
|
+
|
|
1109
|
+
const slice = live.find((entry) => {
|
|
1110
|
+
const derived = planBranch(root, entry.path)
|
|
1111
|
+
return derived.ok && derived.branch === branch
|
|
1112
|
+
})
|
|
1113
|
+
if (!slice) return refused
|
|
1114
|
+
|
|
1115
|
+
const shared = await otherTasksCitingPlan(dir, root, slice.path, stem)
|
|
1116
|
+
if (shared.length > 0) return refused
|
|
1117
|
+
|
|
1118
|
+
const move = {
|
|
1119
|
+
from: slice.path,
|
|
1120
|
+
to: join(recordDir(root, PLANS, ARCHIVE), basename(slice.path)),
|
|
1121
|
+
}
|
|
1122
|
+
if (existsSync(move.to)) return refused
|
|
1123
|
+
|
|
1124
|
+
await movePlans([move])
|
|
1125
|
+
const targets = live.filter((entry) => entry.path === slice.path)
|
|
1126
|
+
await writeFile(
|
|
1127
|
+
join(dir, `${stem}.md`),
|
|
1128
|
+
retargetPlanLine(
|
|
1129
|
+
text,
|
|
1130
|
+
new Map(targets.map((entry) => [entry.target, linkTo(dir, move.to)])),
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
// The closing path clears its row, and this one keeps it, so the row is
|
|
1134
|
+
// rendered again to drop the slice from its Plan and Touches cells. A row
|
|
1135
|
+
// the board cannot write, being absent or busy, stays for `validate` to report.
|
|
1136
|
+
await rerenderRow(root, stem)
|
|
1137
|
+
|
|
1138
|
+
return { ...refused, slice: move }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Reads a pull request's head branch through `gh`, in the repository `root`
|
|
1143
|
+
* resolves to. No `gh` on the path, or a failed read, answers undefined, so a
|
|
1144
|
+
* machine without it keeps a slice plan live rather than guessing.
|
|
1145
|
+
*/
|
|
1146
|
+
export function ghHeadReader(root: string): HeadReader {
|
|
1147
|
+
return async (pullRequest) => {
|
|
1148
|
+
if (Bun.which('gh') === null) return undefined
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
// `gh` resolves its repository through git, so the hook's resolution
|
|
1152
|
+
// variables would point it elsewhere. `reclaim.ts` strips them the same way.
|
|
1153
|
+
const result = await execa(
|
|
1154
|
+
'gh',
|
|
1155
|
+
[
|
|
1156
|
+
'pr',
|
|
1157
|
+
'view',
|
|
1158
|
+
String(pullRequest),
|
|
1159
|
+
'--json',
|
|
1160
|
+
'headRefName',
|
|
1161
|
+
'--jq',
|
|
1162
|
+
'.headRefName',
|
|
1163
|
+
],
|
|
1164
|
+
{ cwd: root, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
|
|
1165
|
+
)
|
|
1166
|
+
const branch = result.stdout.trim()
|
|
1167
|
+
return branch.length > 0 ? branch : undefined
|
|
1168
|
+
} catch {
|
|
1169
|
+
return undefined
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1028
1174
|
interface PlansToArchive {
|
|
1029
1175
|
readonly moves: readonly PlanMove[]
|
|
1030
1176
|
/** Each moved target as the line wrote it, mapped to its link from `taskDir`. */
|
package/src/tasks/board.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
BOARD_GROUPS,
|
|
14
14
|
type BoardGroup,
|
|
15
15
|
backlogPath,
|
|
16
|
+
declaredRows,
|
|
16
17
|
type FindingKind,
|
|
17
18
|
groupRuleFindings,
|
|
18
19
|
orderingPath,
|
|
@@ -30,8 +31,15 @@ export const GROUP_COLUMNS: Readonly<Record<BoardGroup, readonly string[]>> = {
|
|
|
30
31
|
'Needs a plan': ['Task', 'Waiting on'],
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* How many folders a `Touches` cell names. A backticked path never wraps, so
|
|
36
|
+
* three of them pushed a table past an editor preview, and two short folders
|
|
37
|
+
* fit beside the count.
|
|
38
|
+
*/
|
|
39
|
+
const TOUCHES_FOLDERS = 2
|
|
40
|
+
|
|
41
|
+
/** How deep a `Touches` folder reads, as in `src/tasks/` or `claude/skills/`. */
|
|
42
|
+
const FOLDER_DEPTH = 2
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
45
|
* The widest a `Waiting on` cell may render. The live board's cells measured 14
|
|
@@ -205,6 +213,8 @@ export interface DerivedCells {
|
|
|
205
213
|
readonly task: string
|
|
206
214
|
readonly touches: string
|
|
207
215
|
readonly plan: string
|
|
216
|
+
/** The declared paths the Touches cell counts, which the overlap checks read in its place. */
|
|
217
|
+
readonly paths: readonly string[]
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
function titleOf(text: string): string | undefined {
|
|
@@ -227,17 +237,41 @@ function planLabel(path: string): string {
|
|
|
227
237
|
}
|
|
228
238
|
|
|
229
239
|
/**
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
* the plural.
|
|
240
|
+
* The folder a path sits under, `FOLDER_DEPTH` segments deep at most and with a
|
|
241
|
+
* trailing slash, or undefined for a file at the root.
|
|
233
242
|
*/
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
function folderOf(path: string): string | undefined {
|
|
244
|
+
const segments = path.split('/').slice(0, -1).slice(0, FOLDER_DEPTH)
|
|
245
|
+
return segments.length === 0 ? undefined : `${segments.join('/')}/`
|
|
246
|
+
}
|
|
238
247
|
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
/**
|
|
249
|
+
* A count of the declared paths and the folders holding most of them, at most
|
|
250
|
+
* `TOUCHES_FOLDERS`, with ties going to the folder declared first. The file
|
|
251
|
+
* list lives in the plan the row links, so the cell only says where a row
|
|
252
|
+
* works. Root files count and name no folder.
|
|
253
|
+
*/
|
|
254
|
+
export function touchesCell(paths: readonly string[]): string {
|
|
255
|
+
if (paths.length === 0) return ''
|
|
256
|
+
|
|
257
|
+
const counts = new Map<string, number>()
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
const folder = folderOf(path)
|
|
260
|
+
if (folder !== undefined) counts.set(folder, (counts.get(folder) ?? 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const folders = [...counts.entries()]
|
|
264
|
+
.sort((left, right) => right[1] - left[1])
|
|
265
|
+
.map(([folder]) => folder)
|
|
266
|
+
const shown = folders
|
|
267
|
+
.slice(0, TOUCHES_FOLDERS)
|
|
268
|
+
.map((folder) => `\`${folder}\``)
|
|
269
|
+
const files = paths.length === 1 ? '1 file' : `${paths.length} files`
|
|
270
|
+
if (shown.length === 0) return files
|
|
271
|
+
|
|
272
|
+
const rest = folders.length - shown.length
|
|
273
|
+
const tail = rest === 0 ? '' : `, and ${rest} more`
|
|
274
|
+
return `${files} under ${shown.join(', ')}${tail}`
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
/**
|
|
@@ -270,6 +304,7 @@ export async function deriveCells(
|
|
|
270
304
|
task: `[${row.label || row.stem}](${row.stem}.md)`,
|
|
271
305
|
touches: '',
|
|
272
306
|
plan: '',
|
|
307
|
+
paths: [],
|
|
273
308
|
}
|
|
274
309
|
}
|
|
275
310
|
|
|
@@ -279,13 +314,15 @@ export async function deriveCells(
|
|
|
279
314
|
plans.map(async (plan) => readDeclarations(await readFile(plan, 'utf8'))),
|
|
280
315
|
)
|
|
281
316
|
const dir = tasksDir(root)
|
|
317
|
+
const paths = [...new Set(declared.flat())]
|
|
282
318
|
|
|
283
319
|
return {
|
|
284
320
|
task: `[${titleOf(text) ?? (row.label || row.stem)}](${row.stem}.md)`,
|
|
285
|
-
touches: touchesCell(
|
|
321
|
+
touches: touchesCell(paths),
|
|
286
322
|
plan: plans
|
|
287
323
|
.map((plan) => `[${planLabel(plan)}](${linkTo(dir, plan)})`)
|
|
288
324
|
.join(', '),
|
|
325
|
+
paths,
|
|
289
326
|
}
|
|
290
327
|
}
|
|
291
328
|
|
|
@@ -594,7 +631,8 @@ async function writeLocked(
|
|
|
594
631
|
|
|
595
632
|
if (edited.ruled) {
|
|
596
633
|
const forbidden = GROUP_RULES[edited.ruled]
|
|
597
|
-
const
|
|
634
|
+
const rows = await declaredRows(readBoard(text).rows, root)
|
|
635
|
+
const broken = (await groupRuleFindings(rows, root)).filter(
|
|
598
636
|
(finding) => finding.subject === stem && forbidden.includes(finding.kind),
|
|
599
637
|
)
|
|
600
638
|
if (broken.length > 0) {
|
|
@@ -793,6 +831,23 @@ export async function promoteOnLink(
|
|
|
793
831
|
: { moved: false, from: found.group, note: outcome.message }
|
|
794
832
|
}
|
|
795
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Rewrites the board with a task's row where it sits, so its derived cells
|
|
836
|
+
* catch up with a plan that moved under it. A merged slice archives its plan
|
|
837
|
+
* from the post-merge hook, where no other board verb runs.
|
|
838
|
+
*/
|
|
839
|
+
export async function rerenderRow(
|
|
840
|
+
root: string,
|
|
841
|
+
stem: string,
|
|
842
|
+
): Promise<BoardOutcome> {
|
|
843
|
+
return writeBoard(root, stem, (board) => {
|
|
844
|
+
const found = findRow(board, stem)
|
|
845
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
846
|
+
|
|
847
|
+
return { board, group: found.group, from: found.group, ruled: undefined }
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
796
851
|
/** Repositions a row inside the group it already sits in. */
|
|
797
852
|
export async function moveTask(
|
|
798
853
|
root: string,
|
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
|
@@ -23,6 +23,9 @@ import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
|
23
23
|
const ORDERING_FILE = 'priority.md'
|
|
24
24
|
const BACKLOG_FILE = 'backlog.md'
|
|
25
25
|
|
|
26
|
+
/** The opening `touchesCell` writes, as in `12 files under` or a bare `2 files`. */
|
|
27
|
+
const DERIVED_COUNT = /^\d+ files?(?: under |$)/
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
30
|
* The readiness headings `claude/skills/task-board/references/board.md` fixes. The names are the
|
|
28
31
|
* contract rather than a suggestion, so a board grouping under names of its own
|
|
@@ -152,7 +155,11 @@ export interface BoardRow {
|
|
|
152
155
|
readonly label: string
|
|
153
156
|
readonly stem: string | undefined
|
|
154
157
|
readonly plan: string | undefined
|
|
155
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Absent when the group fixes no `Touches` column, empty when it read none.
|
|
160
|
+
* `readBoard` reads the written cell, and `declaredRows` replaces it with the
|
|
161
|
+
* paths the task's plans declare.
|
|
162
|
+
*/
|
|
156
163
|
readonly touches: readonly string[] | undefined
|
|
157
164
|
/** The `Touches` cell as written, which the stale check compares against the derivation. */
|
|
158
165
|
readonly touchesText: string | undefined
|
|
@@ -235,8 +242,14 @@ function stemOf(target: string): string | undefined {
|
|
|
235
242
|
* An extension opens with a letter, which is what separates `commands.md` from
|
|
236
243
|
* a task version like `v40.2`. Reading the version as a path would collide two
|
|
237
244
|
* rows that merely cite the same task.
|
|
245
|
+
*
|
|
246
|
+
* A cell a board verb derived opens with its file count, and its folders say
|
|
247
|
+
* where the row works rather than claiming every file under them, so it yields
|
|
248
|
+
* nothing. Its reader takes the declared paths from the plans instead.
|
|
238
249
|
*/
|
|
239
250
|
export function readPaths(cell: string): string[] {
|
|
251
|
+
if (DERIVED_COUNT.test(cell.trim())) return []
|
|
252
|
+
|
|
240
253
|
const spans = cell.match(/`[^`]+`/g) ?? []
|
|
241
254
|
const paths = spans
|
|
242
255
|
.map((span) => span.slice(1, -1).trim())
|
|
@@ -1315,8 +1328,10 @@ async function checkCitedTask(
|
|
|
1315
1328
|
}
|
|
1316
1329
|
|
|
1317
1330
|
const text = await readFile(live, 'utf8')
|
|
1318
|
-
const { open, closed } = readOutcomes(text)
|
|
1319
|
-
if (open.length > 0 || closed.length === 0)
|
|
1331
|
+
const { open, closed, operator } = readOutcomes(text)
|
|
1332
|
+
if (open.length > 0 || closed.length + operator.length === 0) {
|
|
1333
|
+
return nothing()
|
|
1334
|
+
}
|
|
1320
1335
|
|
|
1321
1336
|
const pending = readPendingBranches(text)
|
|
1322
1337
|
if (pending.length > 0) {
|
|
@@ -1514,6 +1529,29 @@ async function checkStaleTouches(
|
|
|
1514
1529
|
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
1515
1530
|
}
|
|
1516
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* Gives each row the paths its task's live plans declare in place of the ones
|
|
1534
|
+
* read off its `Touches` cell, which states a count and folders rather than
|
|
1535
|
+
* files. A row whose task is gone, or whose plans declare nothing, keeps what
|
|
1536
|
+
* its cell named, so a hand-written cell still feeds the overlap checks.
|
|
1537
|
+
*/
|
|
1538
|
+
export async function declaredRows(
|
|
1539
|
+
rows: readonly BoardRow[],
|
|
1540
|
+
root: string,
|
|
1541
|
+
): Promise<BoardRow[]> {
|
|
1542
|
+
return Promise.all(
|
|
1543
|
+
rows.map(async (row) => {
|
|
1544
|
+
if (row.touches === undefined || !row.stem) return row
|
|
1545
|
+
|
|
1546
|
+
const { paths } = await deriveCells(root, {
|
|
1547
|
+
stem: row.stem,
|
|
1548
|
+
label: row.label,
|
|
1549
|
+
})
|
|
1550
|
+
return paths.length === 0 ? row : { ...row, touches: paths }
|
|
1551
|
+
}),
|
|
1552
|
+
)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1517
1555
|
/**
|
|
1518
1556
|
* The claims a group name makes about a row, being a stated plan and file set
|
|
1519
1557
|
* under `## Run now`, a live plan under `## Up next`, and none under
|
|
@@ -1564,10 +1602,11 @@ export async function validateBoard(
|
|
|
1564
1602
|
}
|
|
1565
1603
|
|
|
1566
1604
|
const {
|
|
1567
|
-
rows,
|
|
1605
|
+
rows: written,
|
|
1568
1606
|
groups,
|
|
1569
1607
|
findings: shapeFindings,
|
|
1570
1608
|
} = readBoard(await readFile(ordering, 'utf8'))
|
|
1609
|
+
const rows = await declaredRows(written, root)
|
|
1571
1610
|
|
|
1572
1611
|
if (groups.length === 0) {
|
|
1573
1612
|
return refuse(
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
bun-version: latest
|
|
26
26
|
|
|
27
|
-
- name: Drop the preview line
|
|
27
|
+
- name: Drop the evidence preview line and mask UI review addresses
|
|
28
28
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
29
29
|
env:
|
|
30
30
|
GH_TOKEN: ${{ github.token }}
|
|
@@ -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
|
|