@erclx/canon 5.48.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 (47) 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/plan-feature/SKILL.md +18 -15
  4. package/claude/skills/plan-feature/references/batches.md +5 -5
  5. package/claude/skills/plan-feature/references/plan.md +82 -33
  6. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  7. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  8. package/claude/skills/plan-intake/SKILL.md +8 -0
  9. package/claude/skills/plan-intake/references/intake.md +9 -1
  10. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  11. package/claude/skills/plan-ready/SKILL.md +3 -3
  12. package/claude/skills/plan-ready/references/assembly.md +7 -7
  13. package/claude/skills/plan-ready/references/ready.md +8 -6
  14. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  15. package/claude/skills/review-ui/SKILL.md +4 -11
  16. package/claude/skills/review-ui/references/close-out.md +1 -1
  17. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  18. package/claude/skills/review-ui/references/post.md +24 -3
  19. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  20. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  22. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  23. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  25. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +1 -1
  27. package/claude/skills/role-worker/SKILL.md +4 -2
  28. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  29. package/claude/skills/session-map/references/session.md +1 -1
  30. package/claude/skills/task-board/references/board.md +2 -2
  31. package/claude/skills/task-board/references/tasks.md +1 -1
  32. package/docs/agents/records.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -2
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +411 -23
  36. package/src/commands/records.ts +45 -8
  37. package/src/commands/tasks.ts +12 -0
  38. package/src/hooks/post-merge.ts +11 -0
  39. package/src/pr/drive-copy.ts +218 -0
  40. package/src/pr/local.ts +26 -0
  41. package/src/pr/rest.ts +2 -0
  42. package/src/records/structure.ts +147 -0
  43. package/src/records/validate.ts +113 -12
  44. package/src/tasks/archive.ts +137 -4
  45. package/src/tasks/board.ts +68 -13
  46. package/src/tasks/validate.ts +39 -2
  47. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +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
 
@@ -128,8 +132,25 @@ export interface ArchiveRefused {
128
132
  readonly reason: ArchiveRefusal
129
133
  readonly message: string
130
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
131
140
  }
132
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
+
133
154
  export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
134
155
 
135
156
  export interface DeclineSuccess {
@@ -925,6 +946,7 @@ async function resolveStem(
925
946
  export async function archiveTask(
926
947
  root: string,
927
948
  selector: TaskSelector,
949
+ options: ArchiveOptions = {},
928
950
  ): Promise<ArchiveOutcome> {
929
951
  const dir = tasksDir(root)
930
952
 
@@ -944,9 +966,13 @@ export async function archiveTask(
944
966
  // while the last slice is still open. Only the last number closes the task.
945
967
  const last = readPullRequest(text).at(-1)
946
968
  if (selector.kind === 'pull-request' && last !== selector.number) {
947
- return refuse(
948
- 'earlier-slice',
949
- `${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),
950
976
  )
951
977
  }
952
978
 
@@ -966,11 +992,18 @@ export async function archiveTask(
966
992
  const { open, closed, cut, operator } = readOutcomes(text)
967
993
 
968
994
  if (open.length > 0) {
969
- return refuse(
995
+ const refused = refuse(
970
996
  'open-outcomes',
971
997
  `${stem} has ${open.length} open outcome(s). Close them, or cut one with \`- ~~<outcome>~~ <why>\`, then archive.`,
972
998
  open,
973
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
974
1007
  }
975
1008
 
976
1009
  if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
@@ -1038,6 +1071,106 @@ export async function archiveTask(
1038
1071
  }
1039
1072
  }
1040
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
+
1041
1174
  interface PlansToArchive {
1042
1175
  readonly moves: readonly PlanMove[]
1043
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,
@@ -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())
@@ -1516,6 +1529,29 @@ async function checkStaleTouches(
1516
1529
  return found.filter((finding): finding is Finding => finding !== undefined)
1517
1530
  }
1518
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
+
1519
1555
  /**
1520
1556
  * The claims a group name makes about a row, being a stated plan and file set
1521
1557
  * under `## Run now`, a live plan under `## Up next`, and none under
@@ -1566,10 +1602,11 @@ export async function validateBoard(
1566
1602
  }
1567
1603
 
1568
1604
  const {
1569
- rows,
1605
+ rows: written,
1570
1606
  groups,
1571
1607
  findings: shapeFindings,
1572
1608
  } = readBoard(await readFile(ordering, 'utf8'))
1609
+ const rows = await declaredRows(written, root)
1573
1610
 
1574
1611
  if (groups.length === 0) {
1575
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 }}