@erclx/canon 5.42.0 → 5.44.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 (195) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +3 -3
  3. package/claude/skills/backlog-triage/references/apply.md +6 -6
  4. package/claude/skills/canon-cli/SKILL.md +4 -4
  5. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  6. package/claude/skills/canvas-board/SKILL.md +1 -1
  7. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  8. package/claude/skills/ci-workflow/SKILL.md +12 -2
  9. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  10. package/claude/skills/context-fold/references/context.md +1 -1
  11. package/claude/skills/create-rule/SKILL.md +1 -0
  12. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  13. package/claude/skills/create-skill/SKILL.md +2 -2
  14. package/claude/skills/create-skill/references/skill-requirement.md +68 -9
  15. package/claude/skills/create-skill/references/skill.md +1 -1
  16. package/claude/skills/decision-escalate/SKILL.md +1 -2
  17. package/claude/skills/design-identity/REQUIREMENT.md +40 -0
  18. package/claude/skills/design-identity/SKILL.md +138 -0
  19. package/claude/skills/{draft-identity → design-identity}/references/card-route.md +2 -2
  20. package/claude/skills/design-taste/REQUIREMENT.md +4 -4
  21. package/claude/skills/design-taste/SKILL.md +2 -2
  22. package/claude/skills/design-taste/references/kinds.md +1 -1
  23. package/claude/skills/design-taste/references/preflight.md +1 -1
  24. package/claude/skills/document-health/references/axes.md +2 -1
  25. package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -69
  26. package/claude/skills/draft-and-pick/SKILL.md +3 -153
  27. package/claude/skills/draft-doc/REQUIREMENT.md +9 -30
  28. package/claude/skills/draft-doc/SKILL.md +4 -76
  29. package/claude/skills/draft-figure/REQUIREMENT.md +8 -42
  30. package/claude/skills/draft-figure/SKILL.md +3 -77
  31. package/claude/skills/draft-identity/REQUIREMENT.md +8 -23
  32. package/claude/skills/draft-identity/SKILL.md +3 -130
  33. package/claude/skills/draft-ready/REQUIREMENT.md +8 -37
  34. package/claude/skills/draft-ready/SKILL.md +3 -128
  35. package/claude/skills/draft-screencast/REQUIREMENT.md +8 -23
  36. package/claude/skills/draft-screencast/SKILL.md +3 -62
  37. package/claude/skills/draft-slides/REQUIREMENT.md +8 -27
  38. package/claude/skills/draft-slides/SKILL.md +3 -72
  39. package/claude/skills/draw-figure/REQUIREMENT.md +59 -0
  40. package/claude/skills/draw-figure/SKILL.md +85 -0
  41. package/claude/skills/{draft-figure → draw-figure}/references/architecture-set.md +2 -2
  42. package/claude/skills/{draft-figure → draw-figure}/references/hand-drawn.md +2 -2
  43. package/claude/skills/{draft-figure → draw-figure}/references/mermaid-path.md +2 -2
  44. package/claude/skills/{draft-figure → draw-figure}/references/mermaid.md +1 -1
  45. package/claude/skills/git-commit/references/commit.md +1 -5
  46. package/claude/skills/git-pr/references/pr.md +1 -0
  47. package/claude/skills/make-deck/REQUIREMENT.md +44 -0
  48. package/claude/skills/make-deck/SKILL.md +80 -0
  49. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -1
  50. package/claude/skills/markdown-craft/SKILL.md +2 -2
  51. package/claude/skills/pick-by-eye/REQUIREMENT.md +86 -0
  52. package/claude/skills/pick-by-eye/SKILL.md +161 -0
  53. package/claude/skills/plan-feature/SKILL.md +3 -2
  54. package/claude/skills/plan-feature/references/plan.md +25 -11
  55. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  56. package/claude/skills/plan-intake/SKILL.md +1 -1
  57. package/claude/skills/plan-ready/REQUIREMENT.md +54 -0
  58. package/claude/skills/plan-ready/SKILL.md +136 -0
  59. package/claude/skills/{draft-ready → plan-ready}/references/assembly.md +1 -1
  60. package/claude/skills/{draft-ready → plan-ready}/references/ready.md +1 -1
  61. package/claude/skills/pr-evidence/references/evidence.md +1 -1
  62. package/claude/skills/record-screencast/REQUIREMENT.md +3 -3
  63. package/claude/skills/record-screencast/SKILL.md +2 -2
  64. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  65. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  66. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +5 -2
  67. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
  68. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  69. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +18 -13
  70. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  71. package/claude/skills/role-planner/SKILL.md +5 -4
  72. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  73. package/claude/skills/role-worker/SKILL.md +35 -2
  74. package/claude/skills/script-screencast/REQUIREMENT.md +40 -0
  75. package/claude/skills/script-screencast/SKILL.md +70 -0
  76. package/claude/skills/{draft-screencast → script-screencast}/references/beats-template.md +2 -2
  77. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  78. package/claude/skills/search-craft/SKILL.md +2 -2
  79. package/claude/skills/target-setup/SKILL.md +1 -0
  80. package/claude/skills/task-board/SKILL.md +11 -9
  81. package/claude/skills/task-board/references/board.md +15 -12
  82. package/claude/skills/teach-workspace/SKILL.md +1 -1
  83. package/claude/skills/teach-workspace/references/lesson-craft.md +2 -2
  84. package/claude/skills/ui-checklist/SKILL.md +2 -0
  85. package/claude/skills/write-doc/REQUIREMENT.md +46 -0
  86. package/claude/skills/write-doc/SKILL.md +83 -0
  87. package/claude/skills/{draft-doc → write-doc}/references/context.md +2 -2
  88. package/claude/skills/{draft-doc → write-doc}/references/docs.md +2 -2
  89. package/claude/skills/{draft-doc → write-doc}/references/readme.md +2 -2
  90. package/claude/skills/{draft-doc → write-doc}/references/wiki-kind.md +4 -4
  91. package/claude/skills/{draft-doc → write-doc}/references/wiki.md +1 -1
  92. package/docs/agents/audits.md +1 -0
  93. package/docs/agents/markdown-audit.md +4 -2
  94. package/docs/agents/migrate.md +13 -10
  95. package/docs/target/migrations.md +28 -4
  96. package/docs/target/projects.md +1 -1
  97. package/docs/workflow/parallel-features.md +3 -2
  98. package/docs/workflow/skill-map-on-request.md +18 -12
  99. package/docs/workflow/skill-map.md +13 -12
  100. package/docs/workflow/visual-design-workflow.md +2 -2
  101. package/governance/detect.toml +4 -0
  102. package/governance/ledger/framework/astro.md +73 -0
  103. package/governance/ledger/framework/fastapi.md +76 -0
  104. package/governance/ledger/framework/nextjs.md +86 -0
  105. package/governance/ledger/framework/react.md +83 -0
  106. package/governance/ledger/framework/shadcn.md +60 -0
  107. package/governance/ledger/framework/tailwind.md +55 -0
  108. package/governance/rules/canon/context.md +2 -1
  109. package/governance/rules/canon/plan.md +3 -5
  110. package/governance/rules/canon/scratch.md +3 -1
  111. package/governance/rules/canon/task-scope.md +2 -1
  112. package/governance/rules/canon/tasks.md +2 -1
  113. package/governance/rules/canon/worktrees.md +5 -2
  114. package/governance/rules/claude/output.md +7 -4
  115. package/governance/rules/claude/skill.md +6 -4
  116. package/governance/rules/framework/astro.md +12 -14
  117. package/governance/rules/framework/fastapi.md +20 -24
  118. package/governance/rules/framework/nextjs.md +19 -27
  119. package/governance/rules/framework/react.md +22 -33
  120. package/governance/rules/framework/shadcn.md +11 -15
  121. package/governance/rules/framework/tailwind.md +12 -14
  122. package/governance/rules/tooling/ci-workflow.md +6 -39
  123. package/governance/rules/ui/design-reference.md +37 -0
  124. package/governance/rules/writing/markdown.md +3 -2
  125. package/package.json +6 -6
  126. package/src/audits/catalog.ts +42 -0
  127. package/src/claude/cases/authoring.ts +15 -3
  128. package/src/claude/cases/misc.ts +12 -0
  129. package/src/claude/cases/workflow.ts +19 -1
  130. package/src/claude/settings.ts +8 -4
  131. package/src/claude/skills-audit.ts +113 -1
  132. package/src/commands/claude.ts +45 -12
  133. package/src/commands/demo.ts +1 -1
  134. package/src/commands/gov.ts +198 -0
  135. package/src/commands/markdown.ts +1 -1
  136. package/src/commands/migrate.ts +125 -2
  137. package/src/commands/pr.ts +34 -83
  138. package/src/commands/tasks.ts +383 -2
  139. package/src/commands/tooling.ts +3 -3
  140. package/src/commands/worktrees.ts +49 -0
  141. package/src/context/audit.ts +1 -1
  142. package/src/demo/beats.ts +1 -1
  143. package/src/gate/stages.ts +15 -0
  144. package/src/gov/audit.ts +441 -0
  145. package/src/listeners/belongs.ts +21 -0
  146. package/src/listeners/machine.ts +93 -0
  147. package/src/listeners/parse.ts +41 -0
  148. package/src/markdown/scope.ts +45 -35
  149. package/src/migrate/record-layout.ts +1 -1
  150. package/src/migrate/session-defaults.ts +138 -0
  151. package/src/migrate/skill-names.ts +68 -21
  152. package/src/pr/frames.ts +14 -3
  153. package/src/pr/local.ts +4 -69
  154. package/src/records/validate.ts +31 -0
  155. package/src/sync/check.ts +15 -0
  156. package/src/tasks/board.ts +854 -0
  157. package/src/tasks/record.ts +20 -1
  158. package/src/tasks/validate.ts +206 -9
  159. package/src/tooling/stamp.ts +56 -12
  160. package/src/worktrees/stop.ts +92 -0
  161. package/tooling/astro/configs/card-src/pages/og-card.astro +1 -1
  162. package/tooling/astro/reference.md +2 -2
  163. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  164. package/tooling/base/configs/commitlint.config.js +0 -20
  165. package/tooling/base/reference.md +1 -1
  166. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  167. package/tooling/claude/reference.md +13 -7
  168. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +65 -0
  169. package/tooling/claude/seeds/.claude/settings.json +8 -0
  170. package/tooling/claude/user/settings.template.json +0 -4
  171. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  172. package/tooling/public/configs/.github/dependabot.yml +12 -0
  173. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  174. package/tooling/public/manifest.toml +8 -0
  175. package/tooling/public/reference.md +53 -0
  176. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  177. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  178. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  179. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  180. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  181. package/tooling/public/seeds/LICENSE +21 -0
  182. package/tooling/public/seeds/SECURITY.md +13 -0
  183. package/tooling/public/seeds/release-please-config.json +12 -0
  184. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  185. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  186. package/tooling/web/configs/.github/workflows/verify.yml +15 -12
  187. /package/claude/skills/{draft-figure → draw-figure}/references/figures.md +0 -0
  188. /package/claude/skills/{draft-slides → make-deck}/references/deck.md +0 -0
  189. /package/claude/skills/{draft-and-pick → pick-by-eye}/references/live-arms.md +0 -0
  190. /package/claude/skills/{draft-doc → write-doc}/references/readme/agent-facing.md +0 -0
  191. /package/claude/skills/{draft-doc → write-doc}/references/readme/application.md +0 -0
  192. /package/claude/skills/{draft-doc → write-doc}/references/readme/cli.md +0 -0
  193. /package/claude/skills/{draft-doc → write-doc}/references/readme/library.md +0 -0
  194. /package/claude/skills/{draft-doc → write-doc}/references/readme/plugin.md +0 -0
  195. /package/claude/skills/{draft-doc → write-doc}/references/requirements.md +0 -0
@@ -0,0 +1,854 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { open, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { basename, join } from 'node:path'
4
+ import {
5
+ linkTo,
6
+ readPlanTargets,
7
+ removeBacklogRow,
8
+ resolveLivePlan,
9
+ tasksDir,
10
+ } from '@/tasks/archive'
11
+ import { readDeclarations } from '@/tasks/reach'
12
+ import {
13
+ BOARD_GROUPS,
14
+ type BoardGroup,
15
+ backlogPath,
16
+ type FindingKind,
17
+ groupRuleFindings,
18
+ orderingPath,
19
+ readBoard,
20
+ } from '@/tasks/validate'
21
+
22
+ /**
23
+ * The columns each group owns, fixed by the operator on 2026-10-18. Every
24
+ * cell but `Waiting on` is derived from the task and its plan, so the render
25
+ * is the only writer of the other three.
26
+ */
27
+ export const GROUP_COLUMNS: Readonly<Record<BoardGroup, readonly string[]>> = {
28
+ 'Run now': ['Task', 'Touches', 'Plan'],
29
+ 'Up next': ['Task', 'Touches', 'Waiting on'],
30
+ 'Needs a plan': ['Task', 'Waiting on'],
31
+ }
32
+
33
+ /** How many declared paths a `Touches` cell spells out before it counts the rest. */
34
+ const TOUCHES_SHOWN = 3
35
+
36
+ /**
37
+ * The widest a `Waiting on` cell may render. The live board's cells measured 14
38
+ * to 131 characters on 2026-10-18 with one table outlier set aside, and the
39
+ * cap sits where all but the longest fit.
40
+ */
41
+ export const WAITING_LIMIT = 120
42
+
43
+ /** How long a verb waits on another verb's lock before refusing as busy. */
44
+ const LOCK_WAIT_MS = 5_000
45
+
46
+ /** A lock older than this was left by a call that died, since a render takes well under a second. */
47
+ const LOCK_STALE_MS = 30_000
48
+
49
+ const LOCK_POLL_MS = 20
50
+
51
+ /** One row as the board holds it: the task it points at and the one typed cell. */
52
+ export interface PlacedRow {
53
+ readonly stem: string
54
+ /** The label the row's Task cell carried, used when the task file is gone. */
55
+ readonly label: string
56
+ readonly waiting: string | undefined
57
+ }
58
+
59
+ /**
60
+ * The board as a model. Rows are kept per group in order, and everything the
61
+ * model does not own, being the frontmatter and title above the first group,
62
+ * prose under a group heading, and any section that is not a group, is kept as
63
+ * written.
64
+ */
65
+ export interface Board {
66
+ readonly head: readonly string[]
67
+ readonly groups: Readonly<Record<BoardGroup, readonly PlacedRow[]>>
68
+ readonly notes: Readonly<Record<BoardGroup, readonly string[]>>
69
+ readonly trailing: readonly string[]
70
+ }
71
+
72
+ export type BoardParse =
73
+ | { readonly ok: true; readonly board: Board }
74
+ | { readonly ok: false; readonly line: string }
75
+
76
+ function emptyGroups<T>(): Record<BoardGroup, T[]> {
77
+ return { 'Run now': [], 'Up next': [], 'Needs a plan': [] }
78
+ }
79
+
80
+ function isGroup(heading: string): heading is BoardGroup {
81
+ return (BOARD_GROUPS as readonly string[]).includes(heading)
82
+ }
83
+
84
+ function splitCells(line: string): string[] {
85
+ return line
86
+ .trim()
87
+ .replace(/^\|/, '')
88
+ .replace(/(?<!\\)\|$/, '')
89
+ .split(/(?<!\\)\|/)
90
+ .map((cell) => cell.trim())
91
+ }
92
+
93
+ function isSeparator(cells: readonly string[]): boolean {
94
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell))
95
+ }
96
+
97
+ /** The task a Task cell links, a bare sibling filename the way every row spells one. */
98
+ function taskLink(
99
+ cell: string,
100
+ ): { readonly stem: string; readonly label: string } | undefined {
101
+ const match = /\[([^\]]*)\]\(([^)]+)\)/.exec(cell)
102
+ if (!match) return undefined
103
+
104
+ const target = match[2].trim().split('#')[0] ?? ''
105
+ if (target.includes('/') || !target.endsWith('.md')) return undefined
106
+
107
+ return { stem: target.slice(0, -'.md'.length), label: match[1].trim() }
108
+ }
109
+
110
+ /**
111
+ * Reads the `Waiting on` cell out of a row through the header above it, which
112
+ * still applies to a row stranded behind a closed table. A row one cell short
113
+ * of a header ending in `Waiting on` is read as missing its `Touches` cell, so
114
+ * its last cell stands in. Any other shape answers `undefined`, since guessing
115
+ * would write a path or a plan link into the blocker on the next render.
116
+ */
117
+ function waitingCell(
118
+ cells: readonly string[],
119
+ header: readonly string[] | undefined,
120
+ ): string | undefined {
121
+ if (!header) return undefined
122
+ const at = header.findIndex((cell) => cell.toLowerCase() === 'waiting on')
123
+ if (at === -1) return undefined
124
+
125
+ if (cells.length === header.length) return cells[at]
126
+
127
+ const missingTouches = header.some((cell) => cell.toLowerCase() === 'touches')
128
+ if (
129
+ missingTouches &&
130
+ at === header.length - 1 &&
131
+ cells.length === header.length - 1
132
+ ) {
133
+ return cells.at(-1)
134
+ }
135
+
136
+ return undefined
137
+ }
138
+
139
+ /**
140
+ * Parses `priority.md` into the model. The parse is lenient where `readBoard`
141
+ * is strict: a row after a closed table, under a header short a column, or
142
+ * past a blank line inside a table is still a row of the group it sits under,
143
+ * since the render exists to repair exactly those boards. It refuses a pipe line
144
+ * naming no task, which a render would drop, and a row whose blocker the header
145
+ * cannot place, which a render would rewrite to the wrong cell.
146
+ */
147
+ export function parseBoard(text: string): BoardParse {
148
+ const lines = text.split('\n')
149
+ const head: string[] = []
150
+ const groups = emptyGroups<PlacedRow>()
151
+ const notes = emptyGroups<string>()
152
+ const trailing: string[] = []
153
+
154
+ let section: BoardGroup | 'head' | 'other' = 'head'
155
+ let header: string[] | undefined
156
+
157
+ for (const line of lines) {
158
+ const heading = /^##\s+(.+?)\s*$/.exec(line)
159
+ if (heading) {
160
+ section = isGroup(heading[1]) ? heading[1] : 'other'
161
+ header = undefined
162
+ if (section === 'other') trailing.push(line)
163
+ continue
164
+ }
165
+
166
+ if (section === 'head') {
167
+ head.push(line)
168
+ continue
169
+ }
170
+
171
+ if (section === 'other') {
172
+ trailing.push(line)
173
+ continue
174
+ }
175
+
176
+ if (!line.trimStart().startsWith('|')) {
177
+ if (line.trim() !== '') notes[section].push(line)
178
+ continue
179
+ }
180
+
181
+ const cells = splitCells(line)
182
+ if (isSeparator(cells)) continue
183
+
184
+ const link = taskLink(cells[0] ?? '')
185
+ if (!link) {
186
+ if ((cells[0] ?? '').toLowerCase() === 'task') {
187
+ header = cells
188
+ continue
189
+ }
190
+ return { ok: false, line: line.trim() }
191
+ }
192
+
193
+ const owned = GROUP_COLUMNS[section].includes('Waiting on')
194
+ const waiting = owned ? waitingCell(cells, header) : undefined
195
+ if (owned && waiting === undefined) return { ok: false, line: line.trim() }
196
+
197
+ groups[section].push({ stem: link.stem, label: link.label, waiting })
198
+ }
199
+
200
+ return { ok: true, board: { head, groups, notes, trailing } }
201
+ }
202
+
203
+ /** The cells a row renders that the task and its plans decide. */
204
+ export interface DerivedCells {
205
+ readonly task: string
206
+ readonly touches: string
207
+ readonly plan: string
208
+ }
209
+
210
+ function titleOf(text: string): string | undefined {
211
+ const lines = text.split('\n')
212
+ let at = 0
213
+ if (lines[0]?.trim() === '---') {
214
+ const close = lines.findIndex(
215
+ (line, index) => index > 0 && line.trim() === '---',
216
+ )
217
+ at = close === -1 ? 0 : close + 1
218
+ }
219
+
220
+ const heading = lines.slice(at).find((line) => line.startsWith('# '))
221
+ return heading?.slice(2).trim()
222
+ }
223
+
224
+ function planLabel(path: string): string {
225
+ const stem = basename(path, '.md')
226
+ return stem.startsWith('feature-') ? stem.slice('feature-'.length) : stem
227
+ }
228
+
229
+ /**
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.
233
+ */
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(', ')
238
+
239
+ const lists = plans > 1 ? 'the plans list' : 'the plan lists'
240
+ return `${shown.join(', ')}, and ${rest} more files ${lists}`
241
+ }
242
+
243
+ /**
244
+ * The live plans a task's `Plan:` line resolves to, in line order and once
245
+ * each. An archived plan or a dead link drops out, so a cell never links a plan
246
+ * nobody can open and a Touches cell never counts files a shipped plan listed.
247
+ */
248
+ async function livePlans(text: string, root: string): Promise<string[]> {
249
+ const dir = tasksDir(root)
250
+ const paths = readPlanTargets(text)
251
+ .map((target) => resolveLivePlan(target.split('#')[0] ?? '', dir, root))
252
+ .filter((path): path is string => path !== undefined && existsSync(path))
253
+
254
+ return [...new Set(paths)]
255
+ }
256
+
257
+ /**
258
+ * Derives the Task, Touches, and Plan cells from the task file and the plans
259
+ * its `Plan:` line links. A task file that is gone keeps the label its row
260
+ * carried and derives nothing else, since `validate` reports the dead row and a
261
+ * render has no business dropping it.
262
+ */
263
+ export async function deriveCells(
264
+ root: string,
265
+ row: Pick<PlacedRow, 'stem' | 'label'>,
266
+ ): Promise<DerivedCells> {
267
+ const file = join(tasksDir(root), `${row.stem}.md`)
268
+ if (!existsSync(file)) {
269
+ return {
270
+ task: `[${row.label || row.stem}](${row.stem}.md)`,
271
+ touches: '',
272
+ plan: '',
273
+ }
274
+ }
275
+
276
+ const text = await readFile(file, 'utf8')
277
+ const plans = await livePlans(text, root)
278
+ const declared = await Promise.all(
279
+ plans.map(async (plan) => readDeclarations(await readFile(plan, 'utf8'))),
280
+ )
281
+ const dir = tasksDir(root)
282
+
283
+ return {
284
+ task: `[${titleOf(text) ?? (row.label || row.stem)}](${row.stem}.md)`,
285
+ touches: touchesCell([...new Set(declared.flat())], plans.length),
286
+ plan: plans
287
+ .map((plan) => `[${planLabel(plan)}](${linkTo(dir, plan)})`)
288
+ .join(', '),
289
+ }
290
+ }
291
+
292
+ function tableLine(cells: readonly string[]): string {
293
+ return `| ${cells.join(' | ')} |`
294
+ }
295
+
296
+ function cellFor(
297
+ column: string,
298
+ derived: DerivedCells,
299
+ row: PlacedRow,
300
+ ): string {
301
+ if (column === 'Task') return derived.task
302
+ if (column === 'Touches') return derived.touches
303
+ if (column === 'Plan') return derived.plan
304
+ return row.waiting ?? ''
305
+ }
306
+
307
+ /**
308
+ * Renders the whole board from the model, one clean table per group under its
309
+ * owned header, in the order `BOARD_GROUPS` fixes. A group with no rows keeps
310
+ * its heading and header so the next placement has a table to land in.
311
+ */
312
+ export async function renderBoard(root: string, board: Board): Promise<string> {
313
+ const head = [...board.head]
314
+ while (head.length > 0 && head.at(-1)?.trim() === '') head.pop()
315
+
316
+ const out = [...head]
317
+
318
+ for (const group of BOARD_GROUPS) {
319
+ const columns = GROUP_COLUMNS[group]
320
+ const rows = await Promise.all(
321
+ board.groups[group].map(async (row) => {
322
+ const derived = await deriveCells(root, row)
323
+ return tableLine(columns.map((column) => cellFor(column, derived, row)))
324
+ }),
325
+ )
326
+
327
+ out.push('', `## ${group}`, '')
328
+ if (board.notes[group].length > 0) out.push(...board.notes[group], '')
329
+ out.push(
330
+ tableLine(columns),
331
+ tableLine(columns.map((column) => '-'.repeat(column.length))),
332
+ ...rows,
333
+ )
334
+ }
335
+
336
+ const trailing = [...board.trailing]
337
+ while (trailing.length > 0 && trailing.at(-1)?.trim() === '') trailing.pop()
338
+ if (trailing.length > 0) out.push('', ...trailing)
339
+
340
+ return `${out.join('\n')}\n`
341
+ }
342
+
343
+ export const BOARD_REFUSALS = [
344
+ 'no-board',
345
+ 'no-ordering',
346
+ 'no-match',
347
+ 'no-row',
348
+ 'unreadable-row',
349
+ 'board-busy',
350
+ 'board-changed',
351
+ 'group-rule',
352
+ 'no-column',
353
+ 'top-group',
354
+ 'cell-long',
355
+ 'bad-input',
356
+ ] as const
357
+
358
+ export type BoardRefusal = (typeof BOARD_REFUSALS)[number]
359
+
360
+ export interface BoardRefused {
361
+ readonly ok: false
362
+ readonly reason: BoardRefusal
363
+ readonly message: string
364
+ readonly detail: readonly string[]
365
+ }
366
+
367
+ /** Where a row lands in its group. A named sibling must already sit in that group. */
368
+ export type Position =
369
+ | { readonly at: 'top' }
370
+ | { readonly at: 'bottom' }
371
+ | { readonly before: string }
372
+ | { readonly after: string }
373
+
374
+ export interface BoardWritten {
375
+ readonly ok: true
376
+ readonly stem: string
377
+ readonly group: BoardGroup
378
+ /** Where the row sat before the write, `backlog` for a backlog line, undefined for neither. */
379
+ readonly from: BoardGroup | 'backlog' | undefined
380
+ /** The row's 1-based position in its group after the write. */
381
+ readonly position: number
382
+ readonly path: string
383
+ /** Whether the call also removed the task's backlog line. */
384
+ readonly backlog: boolean
385
+ }
386
+
387
+ export type BoardOutcome = BoardWritten | BoardRefused
388
+
389
+ /** The `Waiting on` a row promoted out of `## Needs a plan` carries when the caller names none. */
390
+ export const PLANNED_WAITING = 'Planned, not dispatched yet'
391
+
392
+ /**
393
+ * The finding kinds each group's rule forbids, read off the same predicates
394
+ * `canon tasks validate` reports through `groupRuleFindings`.
395
+ */
396
+ const GROUP_RULES: Readonly<Record<BoardGroup, readonly FindingKind[]>> = {
397
+ 'Run now': ['plan-unstated', 'touches-unstated'],
398
+ 'Up next': ['plan-absent'],
399
+ 'Needs a plan': ['plan-parked'],
400
+ }
401
+
402
+ /** The group one step closer to `## Run now`, which is where `promote` sends a row. */
403
+ const PROMOTES_TO: Readonly<Record<BoardGroup, BoardGroup | undefined>> = {
404
+ 'Run now': undefined,
405
+ 'Up next': 'Run now',
406
+ 'Needs a plan': 'Up next',
407
+ }
408
+
409
+ function refuse(
410
+ reason: BoardRefusal,
411
+ message: string,
412
+ detail: readonly string[] = [],
413
+ ): BoardRefused {
414
+ return { ok: false, reason, message, detail }
415
+ }
416
+
417
+ /** The width a cell renders at, a link counting as its label alone. */
418
+ export function renderedLength(cell: string): number {
419
+ return cell.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').length
420
+ }
421
+
422
+ export function checkWaiting(text: string): BoardRefused | undefined {
423
+ if (text.trim() === '' || /[|\n]/.test(text)) {
424
+ return refuse(
425
+ 'bad-input',
426
+ 'A Waiting on cell is one line of text carrying no pipe.',
427
+ [text],
428
+ )
429
+ }
430
+
431
+ const width = renderedLength(text.trim())
432
+ if (width > WAITING_LIMIT) {
433
+ return refuse(
434
+ 'cell-long',
435
+ `The Waiting on cell renders ${width} characters, past the ${WAITING_LIMIT} a board cell holds. Name the blocker and drop the history.`,
436
+ [text],
437
+ )
438
+ }
439
+
440
+ return undefined
441
+ }
442
+
443
+ function findRow(
444
+ board: Board,
445
+ stem: string,
446
+ ): { readonly group: BoardGroup; readonly row: PlacedRow } | undefined {
447
+ for (const group of BOARD_GROUPS) {
448
+ const row = board.groups[group].find((entry) => entry.stem === stem)
449
+ if (row) return { group, row }
450
+ }
451
+
452
+ return undefined
453
+ }
454
+
455
+ function anchorOf(position: Position): string | undefined {
456
+ if ('before' in position) return position.before
457
+ if ('after' in position) return position.after
458
+ return undefined
459
+ }
460
+
461
+ /**
462
+ * Takes the row out of every group and puts it back at a position in one, so a
463
+ * row that sat anywhere lands once. A sibling the position names but the group
464
+ * does not hold is refused rather than read as the bottom.
465
+ */
466
+ function placed(
467
+ board: Board,
468
+ group: BoardGroup,
469
+ row: PlacedRow,
470
+ position: Position,
471
+ ): Board | BoardRefused {
472
+ const groups = emptyGroups<PlacedRow>()
473
+ for (const each of BOARD_GROUPS) {
474
+ groups[each] = board.groups[each].filter((entry) => entry.stem !== row.stem)
475
+ }
476
+
477
+ const rows = groups[group]
478
+ if ('at' in position) {
479
+ groups[group] = position.at === 'top' ? [row, ...rows] : [...rows, row]
480
+ return { ...board, groups }
481
+ }
482
+
483
+ const anchor = anchorOf(position) ?? ''
484
+ const at = rows.findIndex((entry) => entry.stem === anchor)
485
+ if (at === -1) {
486
+ return refuse(
487
+ 'bad-input',
488
+ `${anchor} has no row under ${group}, so ${row.stem} has nothing to sit beside.`,
489
+ [anchor],
490
+ )
491
+ }
492
+
493
+ const index = 'before' in position ? at : at + 1
494
+ groups[group] = [...rows.slice(0, index), row, ...rows.slice(index)]
495
+ return { ...board, groups }
496
+ }
497
+
498
+ interface Edited {
499
+ readonly board: Board
500
+ readonly group: BoardGroup
501
+ readonly from: BoardGroup | undefined
502
+ /** The group whose rule the write must pass, undefined when the row kept its group. */
503
+ readonly ruled: BoardGroup | undefined
504
+ }
505
+
506
+ /**
507
+ * Runs one verb as one read, one render, and one write of `priority.md`, so a
508
+ * call never leaves a partial table behind. A row changing group is checked
509
+ * against the target group's rule on the rendered board before anything is
510
+ * written, which reads the board exactly as `validate` will.
511
+ */
512
+ async function writeBoard(
513
+ root: string,
514
+ stem: string,
515
+ edit: (board: Board) => Edited | BoardRefused,
516
+ ): Promise<BoardOutcome> {
517
+ const dir = tasksDir(root)
518
+ if (!existsSync(dir)) return refuse('no-board', `No task board at ${dir}.`)
519
+
520
+ const path = orderingPath(root)
521
+ if (!existsSync(path)) {
522
+ return refuse('no-ordering', `No ordering file at ${path}.`)
523
+ }
524
+
525
+ const file = join(dir, `${stem}.md`)
526
+ if (!existsSync(file)) {
527
+ return refuse('no-match', `No task file at ${file}.`, [stem])
528
+ }
529
+
530
+ return withBoardLock(path, () => writeLocked(root, stem, path, edit))
531
+ }
532
+
533
+ /**
534
+ * Holds an exclusive lock file beside the board for one read, render, and
535
+ * write, so two verbs in two sessions cannot each replace the file with an
536
+ * edit that drops the other's. The render reads every task and plan, which is
537
+ * why the stale age sits well above any one call.
538
+ */
539
+ async function withBoardLock(
540
+ path: string,
541
+ run: () => Promise<BoardOutcome>,
542
+ ): Promise<BoardOutcome> {
543
+ const lock = `${path}.lock`
544
+ const deadline = Date.now() + LOCK_WAIT_MS
545
+
546
+ for (;;) {
547
+ try {
548
+ await (await open(lock, 'wx')).close()
549
+ break
550
+ } catch (error) {
551
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
552
+ const modified = (await stat(lock).catch(() => undefined))?.mtimeMs
553
+ if (modified !== undefined && Date.now() - modified > LOCK_STALE_MS) {
554
+ await rm(lock, { force: true })
555
+ continue
556
+ }
557
+ if (Date.now() >= deadline) {
558
+ return refuse(
559
+ 'board-busy',
560
+ `Another board verb holds ${lock}. Run this one again once it finishes.`,
561
+ )
562
+ }
563
+ await new Promise((settle) => setTimeout(settle, LOCK_POLL_MS))
564
+ }
565
+ }
566
+
567
+ try {
568
+ return await run()
569
+ } finally {
570
+ await rm(lock, { force: true })
571
+ }
572
+ }
573
+
574
+ async function writeLocked(
575
+ root: string,
576
+ stem: string,
577
+ path: string,
578
+ edit: (board: Board) => Edited | BoardRefused,
579
+ ): Promise<BoardOutcome> {
580
+ const before = await readFile(path, 'utf8')
581
+ const parse = parseBoard(before)
582
+ if (!parse.ok) {
583
+ return refuse(
584
+ 'unreadable-row',
585
+ `${path} holds a row naming no task or carrying a Waiting on cell its header cannot place, so a render would drop or rewrite it. Fix that row by hand first.`,
586
+ [parse.line],
587
+ )
588
+ }
589
+
590
+ const edited = edit(parse.board)
591
+ if ('ok' in edited) return edited
592
+
593
+ const text = await renderBoard(root, edited.board)
594
+
595
+ if (edited.ruled) {
596
+ const forbidden = GROUP_RULES[edited.ruled]
597
+ const broken = (await groupRuleFindings(readBoard(text).rows, root)).filter(
598
+ (finding) => finding.subject === stem && forbidden.includes(finding.kind),
599
+ )
600
+ if (broken.length > 0) {
601
+ return refuse(
602
+ 'group-rule',
603
+ `${stem} cannot sit under ${edited.ruled}. ${broken.map((finding) => `${finding.kind}: ${finding.message}`).join(' ')}`,
604
+ broken.map((finding) => finding.kind),
605
+ )
606
+ }
607
+ }
608
+
609
+ // The lock holds off other verbs and nothing else, so a hand edit landing
610
+ // while the render awaited its reads is caught by comparing the file again.
611
+ if ((await readFile(path, 'utf8')) !== before) {
612
+ return refuse(
613
+ 'board-changed',
614
+ `${path} changed while this call rendered it, so nothing was written. Run the verb again.`,
615
+ )
616
+ }
617
+
618
+ await writeFile(path, text)
619
+
620
+ return {
621
+ ok: true,
622
+ stem,
623
+ group: edited.group,
624
+ from: edited.from,
625
+ position:
626
+ edited.board.groups[edited.group].findIndex((row) => row.stem === stem) +
627
+ 1,
628
+ path,
629
+ backlog: false,
630
+ }
631
+ }
632
+
633
+ export interface PlaceOptions {
634
+ readonly waiting?: string
635
+ readonly position?: Position
636
+ }
637
+
638
+ /**
639
+ * Puts a task under a group from anywhere: another group, the backlog, or no
640
+ * surface at all. A backlog line is removed in the same call, since a task on
641
+ * both surfaces is the `row-duplicated` finding and a two-step move strands it
642
+ * between them.
643
+ */
644
+ export async function placeTask(
645
+ root: string,
646
+ stem: string,
647
+ group: BoardGroup,
648
+ options: PlaceOptions = {},
649
+ ): Promise<BoardOutcome> {
650
+ if (options.waiting !== undefined) {
651
+ const bad = checkWaiting(options.waiting)
652
+ if (bad) return bad
653
+ }
654
+
655
+ const written = await writeBoard(root, stem, (board) => {
656
+ const found = findRow(board, stem)
657
+ const owns = GROUP_COLUMNS[group].includes('Waiting on')
658
+ const waiting = owns
659
+ ? (options.waiting?.trim() ?? found?.row.waiting)
660
+ : undefined
661
+
662
+ if (owns && waiting === undefined) {
663
+ return refuse(
664
+ 'bad-input',
665
+ `${group} carries a Waiting on cell and ${stem} has none to keep. Pass --waiting.`,
666
+ )
667
+ }
668
+
669
+ const next = placed(
670
+ board,
671
+ group,
672
+ { stem, label: found?.row.label ?? stem, waiting },
673
+ options.position ?? { at: 'bottom' },
674
+ )
675
+ if ('ok' in next) return next
676
+
677
+ return {
678
+ board: next,
679
+ group,
680
+ from: found?.group,
681
+ ruled: found?.group === group ? undefined : group,
682
+ }
683
+ })
684
+ if (!written.ok) return written
685
+
686
+ const removed = await removeFromBacklog(root, stem)
687
+ return {
688
+ ...written,
689
+ from: written.from ?? (removed ? 'backlog' : undefined),
690
+ backlog: removed,
691
+ }
692
+ }
693
+
694
+ async function removeFromBacklog(root: string, stem: string): Promise<boolean> {
695
+ const path = backlogPath(root)
696
+ if (!existsSync(path)) return false
697
+
698
+ const { text, removed } = removeBacklogRow(await readFile(path, 'utf8'), stem)
699
+ if (removed) await writeFile(path, text)
700
+ return removed
701
+ }
702
+
703
+ export interface PromoteOptions {
704
+ readonly waiting?: string
705
+ }
706
+
707
+ /**
708
+ * Moves a row one group closer to `## Run now`, to the bottom of that group.
709
+ * Dispatch runs it once a launch succeeds, so `## Run now` reads as
710
+ * dispatched, and `plan-link` runs it on a `## Needs a plan` row once the plan
711
+ * is linked.
712
+ */
713
+ export async function promoteTask(
714
+ root: string,
715
+ stem: string,
716
+ options: PromoteOptions = {},
717
+ ): Promise<BoardOutcome> {
718
+ if (options.waiting !== undefined) {
719
+ const bad = checkWaiting(options.waiting)
720
+ if (bad) return bad
721
+ }
722
+
723
+ return writeBoard(root, stem, (board) => {
724
+ const found = findRow(board, stem)
725
+ if (!found) return refuse('no-row', `${stem} has no row on the board.`)
726
+
727
+ const target = PROMOTES_TO[found.group]
728
+ if (!target) {
729
+ return refuse(
730
+ 'top-group',
731
+ `${stem} already sits under Run now, the top group.`,
732
+ )
733
+ }
734
+
735
+ const waiting = GROUP_COLUMNS[target].includes('Waiting on')
736
+ ? (options.waiting?.trim() ?? PLANNED_WAITING)
737
+ : undefined
738
+ const next = placed(
739
+ board,
740
+ target,
741
+ { ...found.row, waiting },
742
+ {
743
+ at: 'bottom',
744
+ },
745
+ )
746
+ if ('ok' in next) return next
747
+
748
+ return { board: next, group: target, from: found.group, ruled: target }
749
+ })
750
+ }
751
+
752
+ /** What linking a plan did to the task's row, and why when it did nothing. */
753
+ export interface LinkPromotion {
754
+ readonly moved: boolean
755
+ /** The group the row sat in before, undefined when it has no row. */
756
+ readonly from: BoardGroup | undefined
757
+ readonly note: string
758
+ }
759
+
760
+ /**
761
+ * Promotes a task's row out of `## Needs a plan` once its plan is linked, and
762
+ * leaves every other row where it is. A row already under `## Up next` or
763
+ * `## Run now` is past the planning a link records, and a task with no row was
764
+ * never placed, which is the controller's call rather than the link's.
765
+ */
766
+ export async function promoteOnLink(
767
+ root: string,
768
+ stem: string,
769
+ options: PromoteOptions = {},
770
+ ): Promise<LinkPromotion> {
771
+ const path = orderingPath(root)
772
+ if (!existsSync(path)) {
773
+ return { moved: false, from: undefined, note: 'no ordering file' }
774
+ }
775
+
776
+ const parse = parseBoard(await readFile(path, 'utf8'))
777
+ const found = parse.ok ? findRow(parse.board, stem) : undefined
778
+ if (!found) {
779
+ return { moved: false, from: undefined, note: 'no row on the board' }
780
+ }
781
+
782
+ if (found.group !== 'Needs a plan') {
783
+ return {
784
+ moved: false,
785
+ from: found.group,
786
+ note: `already under ${found.group}`,
787
+ }
788
+ }
789
+
790
+ const outcome = await promoteTask(root, stem, options)
791
+ return outcome.ok
792
+ ? { moved: true, from: found.group, note: `moved to ${outcome.group}` }
793
+ : { moved: false, from: found.group, note: outcome.message }
794
+ }
795
+
796
+ /** Repositions a row inside the group it already sits in. */
797
+ export async function moveTask(
798
+ root: string,
799
+ stem: string,
800
+ position: Position,
801
+ ): Promise<BoardOutcome> {
802
+ if (anchorOf(position) === stem) {
803
+ return refuse('bad-input', `${stem} cannot sit beside itself.`, [stem])
804
+ }
805
+
806
+ return writeBoard(root, stem, (board) => {
807
+ const found = findRow(board, stem)
808
+ if (!found) return refuse('no-row', `${stem} has no row on the board.`)
809
+
810
+ const next = placed(board, found.group, found.row, position)
811
+ if ('ok' in next) return next
812
+
813
+ return {
814
+ board: next,
815
+ group: found.group,
816
+ from: found.group,
817
+ ruled: undefined,
818
+ }
819
+ })
820
+ }
821
+
822
+ /** Rewrites the `Waiting on` cell of a row in place. */
823
+ export async function blockTask(
824
+ root: string,
825
+ stem: string,
826
+ waiting: string,
827
+ ): Promise<BoardOutcome> {
828
+ const bad = checkWaiting(waiting)
829
+ if (bad) return bad
830
+
831
+ return writeBoard(root, stem, (board) => {
832
+ const found = findRow(board, stem)
833
+ if (!found) return refuse('no-row', `${stem} has no row on the board.`)
834
+
835
+ if (!GROUP_COLUMNS[found.group].includes('Waiting on')) {
836
+ return refuse(
837
+ 'no-column',
838
+ `${stem} sits under ${found.group}, which carries no Waiting on cell.`,
839
+ )
840
+ }
841
+
842
+ const groups = { ...board.groups }
843
+ groups[found.group] = board.groups[found.group].map((row) =>
844
+ row.stem === stem ? { ...row, waiting: waiting.trim() } : row,
845
+ )
846
+
847
+ return {
848
+ board: { ...board, groups },
849
+ group: found.group,
850
+ from: found.group,
851
+ ruled: undefined,
852
+ }
853
+ })
854
+ }