@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.
Files changed (67) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/git-pr/references/closing-issues.md +2 -0
  4. package/claude/skills/plan-feature/SKILL.md +18 -15
  5. package/claude/skills/plan-feature/references/batches.md +5 -5
  6. package/claude/skills/plan-feature/references/plan.md +82 -33
  7. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  8. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  9. package/claude/skills/plan-intake/SKILL.md +8 -0
  10. package/claude/skills/plan-intake/references/intake.md +9 -1
  11. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  12. package/claude/skills/plan-ready/SKILL.md +3 -3
  13. package/claude/skills/plan-ready/references/assembly.md +7 -7
  14. package/claude/skills/plan-ready/references/ready.md +8 -6
  15. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  16. package/claude/skills/review-ui/SKILL.md +7 -14
  17. package/claude/skills/review-ui/references/close-out.md +1 -1
  18. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  19. package/claude/skills/review-ui/references/post.md +36 -3
  20. package/claude/skills/review-ui/references/tick.md +4 -1
  21. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  22. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  30. package/claude/skills/role-planner/SKILL.md +1 -1
  31. package/claude/skills/role-reviewer/SKILL.md +1 -1
  32. package/claude/skills/role-worker/SKILL.md +4 -2
  33. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  34. package/claude/skills/session-map/references/session.md +1 -1
  35. package/claude/skills/task-board/references/board.md +2 -2
  36. package/claude/skills/task-board/references/tasks.md +5 -3
  37. package/claude/skills/task-complete/SKILL.md +1 -0
  38. package/claude/skills/task-complete/references/archive.md +2 -1
  39. package/docs/agents/migrate.md +2 -2
  40. package/docs/agents/records.md +1 -1
  41. package/docs/target/migrations.md +4 -2
  42. package/docs/workflow/operating-model.md +2 -2
  43. package/docs/workflow/parallel-features.md +2 -2
  44. package/package.json +1 -1
  45. package/src/commands/claude.ts +4 -0
  46. package/src/commands/migrate.ts +5 -4
  47. package/src/commands/pr.ts +712 -58
  48. package/src/commands/records.ts +45 -8
  49. package/src/commands/tasks.ts +21 -1
  50. package/src/hooks/post-merge.ts +11 -0
  51. package/src/migrate/session-defaults.ts +20 -5
  52. package/src/pr/drive-copy.ts +218 -0
  53. package/src/pr/lift.ts +19 -0
  54. package/src/pr/local.ts +26 -0
  55. package/src/pr/merge.ts +175 -0
  56. package/src/pr/rest.ts +3 -0
  57. package/src/pr/review-scope.ts +40 -1
  58. package/src/pr/tick.ts +21 -1
  59. package/src/records/structure.ts +147 -0
  60. package/src/records/validate.ts +113 -12
  61. package/src/tasks/archive.ts +154 -8
  62. package/src/tasks/board.ts +68 -13
  63. package/src/tasks/closes.ts +9 -5
  64. package/src/tasks/validate.ts +43 -4
  65. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  66. package/tooling/claude/reference.md +3 -1
  67. package/tooling/claude/seeds/.claude/settings.json +1 -1
@@ -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
- open.push(body)
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 refuse(
936
- 'earlier-slice',
937
- `${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
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
- return refuse(
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`. */
@@ -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
- /** How many declared paths a `Touches` cell spells out before it counts the rest. */
34
- const TOUCHES_SHOWN = 3
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 first `TOUCHES_SHOWN` declared paths, then a count of the rest. A task
231
- * sliced over several plans reads their union, so the tail names the plans in
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
- export function touchesCell(paths: readonly string[], plans: number): string {
235
- const shown = paths.slice(0, TOUCHES_SHOWN).map((path) => `\`${path}\``)
236
- const rest = paths.length - shown.length
237
- if (rest === 0) return shown.join(', ')
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
- const lists = plans > 1 ? 'the plans list' : 'the plan lists'
240
- return `${shown.join(', ')}, and ${rest} more files ${lists}`
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([...new Set(declared.flat())], plans.length),
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 broken = (await groupRuleFindings(readBoard(text).rows, root)).filter(
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,
@@ -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. */
@@ -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
- /** Absent when the group fixes no `Touches` column, empty when it read none. */
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) return nothing()
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 from the evidence comment
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "autoMemoryEnabled": false,
3
3
  "permissions": {
4
- "deny": ["Agent", "Task"]
4
+ "deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
5
5
  },
6
6
  "hooks": {
7
7
  "PreToolUse": [