@erclx/canon 5.43.0 → 5.45.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 (245) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/references/apply.md +6 -6
  4. package/claude/skills/canon-cli/SKILL.md +4 -5
  5. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  6. package/claude/skills/canvas-board/SKILL.md +1 -1
  7. package/claude/skills/context-fold/references/context.md +1 -1
  8. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  9. package/claude/skills/create-skill/SKILL.md +2 -2
  10. package/claude/skills/create-skill/references/skill-requirement.md +68 -9
  11. package/claude/skills/create-skill/references/skill.md +1 -1
  12. package/claude/skills/design-identity/REQUIREMENT.md +40 -0
  13. package/claude/skills/design-identity/SKILL.md +138 -0
  14. package/claude/skills/{draft-identity → design-identity}/references/card-route.md +2 -2
  15. package/claude/skills/design-taste/REQUIREMENT.md +6 -6
  16. package/claude/skills/design-taste/SKILL.md +2 -2
  17. package/claude/skills/design-taste/references/kinds.md +1 -1
  18. package/claude/skills/design-taste/references/preflight.md +1 -1
  19. package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -69
  20. package/claude/skills/draft-and-pick/SKILL.md +3 -153
  21. package/claude/skills/draft-doc/REQUIREMENT.md +9 -30
  22. package/claude/skills/draft-doc/SKILL.md +4 -76
  23. package/claude/skills/draft-figure/REQUIREMENT.md +8 -42
  24. package/claude/skills/draft-figure/SKILL.md +3 -77
  25. package/claude/skills/draft-identity/REQUIREMENT.md +8 -23
  26. package/claude/skills/draft-identity/SKILL.md +3 -130
  27. package/claude/skills/draft-ready/REQUIREMENT.md +8 -37
  28. package/claude/skills/draft-ready/SKILL.md +3 -128
  29. package/claude/skills/draft-screencast/REQUIREMENT.md +8 -23
  30. package/claude/skills/draft-screencast/SKILL.md +3 -62
  31. package/claude/skills/draft-slides/REQUIREMENT.md +8 -27
  32. package/claude/skills/draft-slides/SKILL.md +3 -72
  33. package/claude/skills/draw-figure/REQUIREMENT.md +59 -0
  34. package/claude/skills/draw-figure/SKILL.md +85 -0
  35. package/claude/skills/{draft-figure → draw-figure}/references/architecture-set.md +2 -2
  36. package/claude/skills/{draft-figure → draw-figure}/references/hand-drawn.md +2 -2
  37. package/claude/skills/{draft-figure → draw-figure}/references/mermaid-path.md +2 -2
  38. package/claude/skills/{draft-figure → draw-figure}/references/mermaid.md +1 -1
  39. package/claude/skills/make-deck/REQUIREMENT.md +44 -0
  40. package/claude/skills/make-deck/SKILL.md +80 -0
  41. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -1
  42. package/claude/skills/markdown-craft/SKILL.md +2 -2
  43. package/claude/skills/pick-by-eye/REQUIREMENT.md +36 -0
  44. package/claude/skills/pick-by-eye/SKILL.md +161 -0
  45. package/claude/skills/plan-feature/SKILL.md +2 -1
  46. package/claude/skills/plan-feature/references/plan.md +24 -10
  47. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  48. package/claude/skills/plan-intake/SKILL.md +1 -1
  49. package/claude/skills/plan-ready/REQUIREMENT.md +54 -0
  50. package/claude/skills/plan-ready/SKILL.md +136 -0
  51. package/claude/skills/{draft-ready → plan-ready}/references/assembly.md +1 -1
  52. package/claude/skills/{draft-ready → plan-ready}/references/ready.md +1 -1
  53. package/claude/skills/record-screencast/REQUIREMENT.md +3 -3
  54. package/claude/skills/record-screencast/SKILL.md +2 -2
  55. package/claude/skills/role-orchestrator/REQUIREMENT.md +16 -99
  56. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +5 -2
  57. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  58. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +18 -13
  59. package/claude/skills/role-planner/REQUIREMENT.md +15 -73
  60. package/claude/skills/role-planner/SKILL.md +2 -1
  61. package/claude/skills/role-worker/REQUIREMENT.md +13 -41
  62. package/claude/skills/role-worker/SKILL.md +9 -0
  63. package/claude/skills/script-screencast/REQUIREMENT.md +40 -0
  64. package/claude/skills/script-screencast/SKILL.md +70 -0
  65. package/claude/skills/{draft-screencast → script-screencast}/references/beats-template.md +2 -2
  66. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  67. package/claude/skills/search-craft/SKILL.md +1 -1
  68. package/claude/skills/target-setup/SKILL.md +3 -2
  69. package/claude/skills/target-setup/references/detect.md +0 -1
  70. package/claude/skills/target-setup/references/verify.md +2 -2
  71. package/claude/skills/task-board/SKILL.md +11 -9
  72. package/claude/skills/task-board/references/board.md +15 -12
  73. package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
  74. package/claude/skills/teach-workspace/SKILL.md +1 -1
  75. package/claude/skills/teach-workspace/references/lesson-craft.md +2 -2
  76. package/claude/skills/teach-workspace/references/promotion.md +5 -5
  77. package/claude/skills/ui-checklist/SKILL.md +2 -0
  78. package/claude/skills/write-doc/REQUIREMENT.md +46 -0
  79. package/claude/skills/write-doc/SKILL.md +83 -0
  80. package/claude/skills/{draft-doc → write-doc}/references/context.md +2 -2
  81. package/claude/skills/{draft-doc → write-doc}/references/docs.md +2 -2
  82. package/claude/skills/{draft-doc → write-doc}/references/readme.md +2 -2
  83. package/claude/skills/{draft-doc → write-doc}/references/wiki-kind.md +3 -3
  84. package/docs/agents/install-and-sync.md +2 -2
  85. package/docs/agents/markdown-audit.md +4 -2
  86. package/docs/agents/migrate.md +2 -2
  87. package/docs/agents/output-shape.md +1 -2
  88. package/docs/target/migrations.md +35 -4
  89. package/docs/target/projects.md +2 -3
  90. package/docs/target/scenarios.md +4 -1
  91. package/docs/workflow/parallel-features.md +3 -2
  92. package/docs/workflow/skill-map-on-request.md +19 -13
  93. package/docs/workflow/skill-map.md +13 -12
  94. package/docs/workflow/visual-design-workflow.md +2 -2
  95. package/governance/detect.toml +4 -0
  96. package/governance/ledger/framework/astro.md +73 -0
  97. package/governance/ledger/framework/fastapi.md +76 -0
  98. package/governance/ledger/framework/nextjs.md +86 -0
  99. package/governance/ledger/framework/react.md +83 -0
  100. package/governance/ledger/framework/shadcn.md +60 -0
  101. package/governance/ledger/framework/tailwind.md +55 -0
  102. package/governance/ledger/lang/go.md +61 -0
  103. package/governance/ledger/lang/php.md +50 -0
  104. package/governance/ledger/lang/python.md +68 -0
  105. package/governance/ledger/lang/typescript.md +64 -0
  106. package/governance/ledger/lib/database.md +65 -0
  107. package/governance/ledger/lib/e2e-reliability.md +57 -0
  108. package/governance/ledger/lib/pydantic.md +61 -0
  109. package/governance/ledger/lib/security-server.md +62 -0
  110. package/governance/ledger/lib/security-web.md +51 -0
  111. package/governance/ledger/lib/tanstack-query.md +57 -0
  112. package/governance/ledger/lib/test-scope.md +43 -0
  113. package/governance/ledger/lib/testing-go.md +57 -0
  114. package/governance/ledger/lib/testing-php.md +51 -0
  115. package/governance/ledger/lib/testing-py.md +67 -0
  116. package/governance/ledger/lib/testing-ts.md +63 -0
  117. package/governance/ledger/lib/zod.md +47 -0
  118. package/governance/ledger/tooling/ci-workflow.md +33 -0
  119. package/governance/ledger/tooling/code-comments.md +48 -0
  120. package/governance/ledger/tooling/config-comments.md +27 -0
  121. package/governance/ledger/tooling/dependencies.md +38 -0
  122. package/governance/ledger/ui/a11y.md +55 -0
  123. package/governance/ledger/ui/copy.md +49 -0
  124. package/governance/ledger/ui/design-reference.md +42 -0
  125. package/governance/ledger/ui/design-taste.md +19 -0
  126. package/governance/ledger/ui/forms.md +71 -0
  127. package/governance/ledger/ui/interface-casing.md +47 -0
  128. package/governance/ledger/ui/keyboard.md +68 -0
  129. package/governance/ledger/ui/link-behavior.md +29 -0
  130. package/governance/ledger/ui/motion.md +33 -0
  131. package/governance/ledger/ui/surface-capture.md +69 -0
  132. package/governance/ledger/ui/ux-completeness.md +79 -0
  133. package/governance/rules/framework/astro.md +12 -14
  134. package/governance/rules/framework/fastapi.md +20 -24
  135. package/governance/rules/framework/nextjs.md +19 -27
  136. package/governance/rules/framework/react.md +22 -33
  137. package/governance/rules/framework/shadcn.md +11 -15
  138. package/governance/rules/framework/tailwind.md +12 -14
  139. package/governance/rules/lang/go.md +7 -16
  140. package/governance/rules/lang/php.md +3 -3
  141. package/governance/rules/lang/python.md +9 -18
  142. package/governance/rules/lang/typescript.md +9 -19
  143. package/governance/rules/lib/database.md +5 -5
  144. package/governance/rules/lib/e2e-reliability.md +4 -6
  145. package/governance/rules/lib/pydantic.md +14 -16
  146. package/governance/rules/lib/security-server.md +4 -4
  147. package/governance/rules/lib/security-web.md +6 -10
  148. package/governance/rules/lib/tanstack-query.md +10 -12
  149. package/governance/rules/lib/test-scope.md +4 -4
  150. package/governance/rules/lib/testing-go.md +1 -2
  151. package/governance/rules/lib/testing-php.md +4 -5
  152. package/governance/rules/lib/testing-py.md +11 -19
  153. package/governance/rules/lib/testing-ts.md +7 -16
  154. package/governance/rules/lib/zod.md +6 -8
  155. package/governance/rules/tooling/code-comments.md +2 -4
  156. package/governance/rules/tooling/config-comments.md +1 -4
  157. package/governance/rules/tooling/dependencies.md +1 -1
  158. package/governance/rules/ui/a11y.md +16 -45
  159. package/governance/rules/ui/copy.md +6 -9
  160. package/governance/rules/ui/forms.md +13 -19
  161. package/governance/rules/ui/interface-casing.md +5 -7
  162. package/governance/rules/ui/keyboard.md +38 -0
  163. package/governance/rules/ui/link-behavior.md +3 -3
  164. package/governance/rules/ui/motion.md +3 -3
  165. package/governance/rules/ui/surface-capture.md +15 -27
  166. package/governance/rules/ui/ux-completeness.md +16 -39
  167. package/governance/rules/writing/markdown.md +1 -1
  168. package/package.json +1 -1
  169. package/scripts/core/install-check.sh +1 -1
  170. package/src/claude/cases/authoring.ts +15 -3
  171. package/src/claude/cases/misc.ts +12 -0
  172. package/src/claude/cases/workflow.ts +19 -1
  173. package/src/claude/skills-audit.ts +113 -1
  174. package/src/cli/help.ts +0 -1
  175. package/src/cli.ts +0 -2
  176. package/src/commands/claude.ts +34 -11
  177. package/src/commands/demo.ts +1 -1
  178. package/src/commands/gov.ts +4 -0
  179. package/src/commands/init.ts +2 -2
  180. package/src/commands/markdown.ts +1 -1
  181. package/src/commands/migrate.ts +4 -0
  182. package/src/commands/pr.ts +30 -6
  183. package/src/commands/tasks.ts +383 -2
  184. package/src/commands/tooling.ts +18 -5
  185. package/src/context/audit.ts +1 -1
  186. package/src/demo/beats.ts +1 -1
  187. package/src/design/components.ts +1 -1
  188. package/src/gov/audit.ts +25 -4
  189. package/src/init/plan.ts +1 -8
  190. package/src/init/steps.ts +0 -8
  191. package/src/inventory/subjects.ts +1 -1
  192. package/src/markdown/scope.ts +49 -35
  193. package/src/migrate/record-layout.ts +1 -1
  194. package/src/migrate/scratch-evidence.ts +12 -4
  195. package/src/migrate/skill-names.ts +68 -21
  196. package/src/pr/frames.ts +14 -3
  197. package/src/records/backup.ts +3 -3
  198. package/src/records/validate.ts +31 -0
  199. package/src/roots/record.ts +17 -8
  200. package/src/tasks/board.ts +854 -0
  201. package/src/tasks/record.ts +20 -1
  202. package/src/tasks/validate.ts +206 -9
  203. package/src/tooling/inject.ts +26 -7
  204. package/src/tooling/package.ts +32 -0
  205. package/src/tooling/read.ts +46 -3
  206. package/src/tooling/scan.ts +8 -2
  207. package/tooling/astro/configs/card-src/pages/og-card.astro +1 -1
  208. package/tooling/astro/reference.md +64 -93
  209. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +1 -1
  210. package/tooling/base/configs/scripts/verify.ts +1 -1
  211. package/tooling/base/reference.md +1 -9
  212. package/tooling/base/seeds/canon/context/{ci.md → development/ci.md} +1 -1
  213. package/tooling/base/seeds/canon/context/development/index.md +11 -0
  214. package/tooling/base/seeds/canon/context/{development.md → development/setup.md} +8 -12
  215. package/tooling/base/seeds/canon/context/index.md +1 -2
  216. package/tooling/claude/manifest.toml +1 -5
  217. package/tooling/claude/reference.md +36 -59
  218. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +65 -0
  219. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -7
  220. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -7
  221. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +3 -10
  222. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +2 -7
  223. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +2 -4
  224. package/tooling/claude/seeds/.claude/settings.json +4 -0
  225. package/tooling/nextjs/reference.md +0 -6
  226. package/tooling/php/reference.md +0 -4
  227. package/tooling/web/configs/.github/workflows/verify.yml +28 -0
  228. package/tooling/web/configs/.oxlintrc.json +7 -0
  229. package/tooling/web/configs/e2e/capture-types.ts +35 -0
  230. package/tooling/web/configs/e2e/screenshot.ts +23 -72
  231. package/tooling/web/manifest.toml +1 -1
  232. package/tooling/web/reference.md +77 -124
  233. package/tooling/web/seeds/e2e/capture/cases.ts +47 -0
  234. package/src/commands/wiki.ts +0 -100
  235. package/src/wiki/init.ts +0 -85
  236. /package/claude/skills/{draft-figure → draw-figure}/references/figures.md +0 -0
  237. /package/claude/skills/{draft-slides → make-deck}/references/deck.md +0 -0
  238. /package/claude/skills/{draft-and-pick → pick-by-eye}/references/live-arms.md +0 -0
  239. /package/claude/skills/{draft-doc → write-doc}/references/readme/agent-facing.md +0 -0
  240. /package/claude/skills/{draft-doc → write-doc}/references/readme/application.md +0 -0
  241. /package/claude/skills/{draft-doc → write-doc}/references/readme/cli.md +0 -0
  242. /package/claude/skills/{draft-doc → write-doc}/references/readme/library.md +0 -0
  243. /package/claude/skills/{draft-doc → write-doc}/references/readme/plugin.md +0 -0
  244. /package/claude/skills/{draft-doc → write-doc}/references/requirements.md +0 -0
  245. /package/claude/skills/{draft-doc → write-doc}/references/wiki.md +0 -0
@@ -16,6 +16,7 @@ import {
16
16
  readPullRequest,
17
17
  tasksDir,
18
18
  } from '@/tasks/archive'
19
+ import { checkWaiting, type LinkPromotion, promoteOnLink } from '@/tasks/board'
19
20
 
20
21
  /**
21
22
  * Lines a task carries above its outcomes naming where the work came from. The
@@ -79,6 +80,8 @@ export interface PlanRecorded {
79
80
  readonly action: LineAction
80
81
  /** The target the line named before a correction. Undefined unless it differed. */
81
82
  readonly replaced: string | undefined
83
+ /** Whether the link moved the task's row out of `## Needs a plan`. */
84
+ readonly board: LinkPromotion
82
85
  }
83
86
 
84
87
  export type PlanOutcome = PlanRecorded | RecordRefused
@@ -472,7 +475,13 @@ export async function recordPlan(
472
475
  stem: string,
473
476
  reference: string,
474
477
  add = false,
478
+ waiting?: string,
475
479
  ): Promise<PlanOutcome> {
480
+ if (waiting !== undefined) {
481
+ const bad = checkWaiting(waiting)
482
+ if (bad) return refuse('bad-input', bad.message, bad.detail)
483
+ }
484
+
476
485
  const opened = await openTask(root, { kind: 'stem', stem })
477
486
  if ('ok' in opened) return opened
478
487
 
@@ -500,7 +509,17 @@ export async function recordPlan(
500
509
  const { text, action, replaced } = written
501
510
  if (action !== 'unchanged') await writeFile(path, text)
502
511
 
503
- return { ok: true, stem: opened.stem, path, plan: target, action, replaced }
512
+ const board = await promoteOnLink(root, opened.stem, { waiting })
513
+
514
+ return {
515
+ ok: true,
516
+ stem: opened.stem,
517
+ path,
518
+ plan: target,
519
+ action,
520
+ replaced,
521
+ board,
522
+ }
504
523
  }
505
524
 
506
525
  /**
@@ -12,6 +12,12 @@ import {
12
12
  resolveLivePlan,
13
13
  tasksDir,
14
14
  } from '@/tasks/archive'
15
+ import {
16
+ deriveCells,
17
+ GROUP_COLUMNS,
18
+ renderedLength,
19
+ WAITING_LIMIT,
20
+ } from '@/tasks/board'
15
21
  import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
16
22
 
17
23
  const ORDERING_FILE = 'priority.md'
@@ -50,6 +56,9 @@ export const FINDING_KINDS = [
50
56
  'row-misordered',
51
57
  'row-unranked',
52
58
  'touches-unstated',
59
+ 'touches-stale',
60
+ 'columns-mismatched',
61
+ 'cell-long',
53
62
  'blocker-settled',
54
63
  'blocker-unresolved',
55
64
  'blocker-declined',
@@ -145,6 +154,8 @@ export interface BoardRow {
145
154
  readonly plan: string | undefined
146
155
  /** Absent when the group fixes no `Touches` column, empty when it read none. */
147
156
  readonly touches: readonly string[] | undefined
157
+ /** The `Touches` cell as written, which the stale check compares against the derivation. */
158
+ readonly touchesText: string | undefined
148
159
  /** Absent when the group fixes no `Waiting on` column, which is `## Run now`. */
149
160
  readonly waiting: string | undefined
150
161
  /** The ordinal phrase a `Waiting on` cell states about its own position, undefined when the cell carries none. */
@@ -271,8 +282,8 @@ function splitCells(line: string): string[] {
271
282
  return line
272
283
  .trim()
273
284
  .replace(/^\|/, '')
274
- .replace(/\|$/, '')
275
- .split('|')
285
+ .replace(/(?<!\\)\|$/, '')
286
+ .split(/(?<!\\)\|/)
276
287
  .map((cell) => cell.trim())
277
288
  }
278
289
 
@@ -288,6 +299,21 @@ function isGroup(heading: string): heading is BoardGroup {
288
299
  return (BOARD_GROUPS as readonly string[]).includes(heading)
289
300
  }
290
301
 
302
+ /**
303
+ * Whether a header is the set of columns its group owns. Order is left to the
304
+ * reader, which resolves every column by name, so only a missing or a foreign
305
+ * column is a finding.
306
+ */
307
+ function ownsColumns(group: BoardGroup, header: readonly string[]): boolean {
308
+ const owned = GROUP_COLUMNS[group].map((column) => column.toLowerCase())
309
+ const named = header.map((cell) => cell.toLowerCase())
310
+
311
+ return (
312
+ named.length === owned.length &&
313
+ owned.every((column) => named.includes(column))
314
+ )
315
+ }
316
+
291
317
  const ORDINAL_WORDS = [
292
318
  'first',
293
319
  'second',
@@ -460,6 +486,14 @@ export function readBoard(text: string): {
460
486
  }
461
487
 
462
488
  header = cells
489
+ if (!ownsColumns(group, cells)) {
490
+ findings.push({
491
+ kind: 'columns-mismatched',
492
+ group,
493
+ subject: line.trim(),
494
+ message: `is not the ${GROUP_COLUMNS[group].join(', ')} header ${group} owns.`,
495
+ })
496
+ }
463
497
  continue
464
498
  }
465
499
 
@@ -489,6 +523,7 @@ export function readBoard(text: string): {
489
523
  stem: target ? stemOf(target) : undefined,
490
524
  plan,
491
525
  touches: touchesAt >= 0 ? readPaths(cells[touchesAt] ?? '') : undefined,
526
+ touchesText: touchesAt >= 0 ? (cells[touchesAt] ?? '') : undefined,
492
527
  waiting,
493
528
  ordinal: waiting ? readOrdinal(waiting) : undefined,
494
529
  ranked: waiting ? readRank(waiting) : false,
@@ -1125,6 +1160,83 @@ function citedStem(cell: string): string | undefined {
1125
1160
  return target ? stemOf(target) : undefined
1126
1161
  }
1127
1162
 
1163
+ /** The task stems in each folder a bare label can resolve through, live first. */
1164
+ interface LabelFolders {
1165
+ readonly live: readonly string[]
1166
+ readonly archived: readonly string[]
1167
+ readonly declined: readonly string[]
1168
+ }
1169
+
1170
+ type LabelCitation =
1171
+ | { readonly kind: 'stems'; readonly stems: readonly string[] }
1172
+ | { readonly kind: 'ambiguous'; readonly label: string }
1173
+
1174
+ /**
1175
+ * Reads every bare `vNNN.N` phase label a `Waiting on` cell names outside a
1176
+ * link, each resolved to the one task it labels. The live folder answers first,
1177
+ * since a live task and a declined one can share a label and the live one is
1178
+ * what the row waits on. Two matches in the folder that answers leave the row
1179
+ * untested rather than guessing between them, and a label no folder holds is
1180
+ * read as prose rather than as a broken pointer, since a version number in a
1181
+ * sentence is not always a citation.
1182
+ */
1183
+ function bareLabels(cell: string, folders: LabelFolders): LabelCitation {
1184
+ const prose = cell.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
1185
+ const stems: string[] = []
1186
+
1187
+ for (const match of prose.matchAll(/\bv\d+\.\d+\b/g)) {
1188
+ const prefix = `${match[0]}-`
1189
+ for (const folder of [folders.live, folders.archived, folders.declined]) {
1190
+ const hits = folder.filter((stem) => stem.startsWith(prefix))
1191
+ if (hits.length > 1) return { kind: 'ambiguous', label: match[0] }
1192
+ if (hits.length === 1) {
1193
+ if (!stems.includes(hits[0])) stems.push(hits[0])
1194
+ break
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ return { kind: 'stems', stems }
1200
+ }
1201
+
1202
+ /**
1203
+ * Tests every task a cell cites by bare label, and reports the row only when
1204
+ * none of them is still open. A cell naming settled history beside its live
1205
+ * blocker would otherwise read as settled on the history alone.
1206
+ */
1207
+ async function checkCitedTasks(
1208
+ group: BoardGroup,
1209
+ subject: string,
1210
+ stems: readonly string[],
1211
+ root: string,
1212
+ trunk: TrunkReader,
1213
+ ): Promise<CitedResult> {
1214
+ const results = await Promise.all(
1215
+ stems.map((stem) => checkCitedTask(group, subject, stem, root, trunk)),
1216
+ )
1217
+ const open = results.some(
1218
+ (result) => result.findings.length === 0 && result.untested.length === 0,
1219
+ )
1220
+ if (open) return nothing()
1221
+
1222
+ return {
1223
+ findings: results.flatMap((result) => result.findings),
1224
+ untested: results.flatMap((result) => result.untested),
1225
+ }
1226
+ }
1227
+
1228
+ async function readLabelFolders(root: string): Promise<LabelFolders> {
1229
+ const read = async (dir: string): Promise<string[]> =>
1230
+ existsSync(dir) ? listTaskStems(dir) : []
1231
+ const [live, archived, declined] = await Promise.all([
1232
+ read(tasksDir(root)),
1233
+ read(archiveDir(root)),
1234
+ read(declinedDir(root)),
1235
+ ])
1236
+
1237
+ return { live, archived, declined }
1238
+ }
1239
+
1128
1240
  /** What one blocker citation produced, since a row can be neither settled nor open. */
1129
1241
  interface CitedResult {
1130
1242
  readonly findings: readonly Finding[]
@@ -1288,17 +1400,29 @@ async function checkParked(
1288
1400
  const findings: Finding[] = []
1289
1401
  const untested: Untested[] = []
1290
1402
  const running = rows.filter((row) => row.group === 'Run now')
1403
+ const folders = await readLabelFolders(root)
1291
1404
 
1292
1405
  for (const row of rows) {
1293
1406
  if (row.group === 'Run now') continue
1294
1407
 
1295
1408
  const subject = row.stem ?? row.label
1296
1409
  const cell = row.waiting ?? ''
1297
- const cited = citedStem(cell)
1410
+ const linked = citedStem(cell)
1411
+ const bare = linked ? undefined : bareLabels(cell, folders)
1412
+ const cited = linked ? [linked] : bare?.kind === 'stems' ? bare.stems : []
1298
1413
  const contested = readPaths(cell)
1299
1414
 
1300
- if (cited) {
1301
- const result = await checkCitedTask(
1415
+ if (bare?.kind === 'ambiguous') {
1416
+ untested.push({
1417
+ group: row.group,
1418
+ subject,
1419
+ message: `waits on ${bare.label}, which labels more than one task, so the blocker is not read.`,
1420
+ })
1421
+ continue
1422
+ }
1423
+
1424
+ if (cited.length > 0) {
1425
+ const result = await checkCitedTasks(
1302
1426
  row.group,
1303
1427
  subject,
1304
1428
  cited,
@@ -1324,7 +1448,7 @@ async function checkParked(
1324
1448
  })
1325
1449
  }
1326
1450
 
1327
- if (!cited && contested.length === 0) {
1451
+ if (cited.length === 0 && contested.length === 0) {
1328
1452
  untested.push({
1329
1453
  group: row.group,
1330
1454
  subject,
@@ -1337,6 +1461,79 @@ async function checkParked(
1337
1461
  return { findings, untested }
1338
1462
  }
1339
1463
 
1464
+ /**
1465
+ * Reports a `Waiting on` cell rendering past the cap. A verb refuses to write
1466
+ * one, so a cell this long is a hand edit, which is why it moves the exit code.
1467
+ */
1468
+ function checkCellWidth(rows: readonly BoardRow[]): Finding[] {
1469
+ return rows.flatMap((row) => {
1470
+ const width = row.waiting ? renderedLength(row.waiting) : 0
1471
+ if (width <= WAITING_LIMIT) return []
1472
+
1473
+ return [
1474
+ {
1475
+ kind: 'cell-long' as const,
1476
+ group: row.group,
1477
+ subject: subjectOf(row),
1478
+ message: `carries a Waiting on cell rendering ${width} characters, past the ${WAITING_LIMIT} a board cell holds.`,
1479
+ },
1480
+ ]
1481
+ })
1482
+ }
1483
+
1484
+ /**
1485
+ * Reports a `Touches` cell that differs from what the task's plans derive. A
1486
+ * verb writes the derivation on every call, so a difference is a hand edit or a
1487
+ * plan that changed since the last write. An empty derivation is left alone,
1488
+ * since a row with no live plan or no declared file is `plan-absent`,
1489
+ * `plan-unresolved`, or `touches-unstated` to report, not stale.
1490
+ */
1491
+ async function checkStaleTouches(
1492
+ rows: readonly BoardRow[],
1493
+ root: string,
1494
+ ): Promise<Finding[]> {
1495
+ const found = await Promise.all(
1496
+ rows.map(async (row): Promise<Finding | undefined> => {
1497
+ if (row.touchesText === undefined || !row.stem) return undefined
1498
+
1499
+ const { touches } = await deriveCells(root, {
1500
+ stem: row.stem,
1501
+ label: row.label,
1502
+ })
1503
+ if (touches === '' || touches === row.touchesText) return undefined
1504
+
1505
+ return {
1506
+ kind: 'touches-stale',
1507
+ group: row.group,
1508
+ subject: row.stem,
1509
+ message: `states ${row.touchesText || 'no file'}, and its plan derives ${touches}. A board verb rewrites the cell.`,
1510
+ }
1511
+ }),
1512
+ )
1513
+
1514
+ return found.filter((finding): finding is Finding => finding !== undefined)
1515
+ }
1516
+
1517
+ /**
1518
+ * The claims a group name makes about a row, being a stated plan and file set
1519
+ * under `## Run now`, a live plan under `## Up next`, and none under
1520
+ * `## Needs a plan`. The board verbs read these same findings off the board
1521
+ * they are about to write, so a placement and a validation cannot disagree on
1522
+ * what a group allows.
1523
+ */
1524
+ export async function groupRuleFindings(
1525
+ rows: readonly BoardRow[],
1526
+ root: string,
1527
+ ): Promise<Finding[]> {
1528
+ const dir = tasksDir(root)
1529
+
1530
+ return [
1531
+ ...checkPlans(rows, dir, root),
1532
+ ...(await checkGroupClaims(rows, dir, root)),
1533
+ ...checkTouches(rows),
1534
+ ]
1535
+ }
1536
+
1340
1537
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
1341
1538
  return { ok: false, reason, message }
1342
1539
  }
@@ -1397,10 +1594,10 @@ export async function validateBoard(
1397
1594
  const findings = [
1398
1595
  ...shapeFindings,
1399
1596
  ...checkMapping(rows, backlog, dir),
1400
- ...checkPlans(rows, dir, root),
1597
+ ...(await groupRuleFindings(rows, root)),
1401
1598
  ...(await checkPlanAgreement(rows, dir, root)),
1402
- ...(await checkGroupClaims(rows, dir, root)),
1403
- ...checkTouches(rows),
1599
+ ...(await checkStaleTouches(rows, root)),
1600
+ ...checkCellWidth(rows),
1404
1601
  ...checkOrdering(rows),
1405
1602
  ...parked.findings,
1406
1603
  ]
@@ -16,6 +16,8 @@ import {
16
16
  import {
17
17
  applyScripts,
18
18
  collectDeps,
19
+ declaresInstalls,
20
+ minimalPackage,
19
21
  readPackage,
20
22
  serializePackage,
21
23
  } from '@/tooling/package'
@@ -156,15 +158,22 @@ export async function pruneGitignore(
156
158
 
157
159
  /**
158
160
  * Installs missing dev dependencies, fills package scripts, and merges
159
- * gitignore entries. A target with no package.json gets the gitignore alone,
160
- * plus a warning naming every install it skipped, since seeding a package.json
161
- * would pick a package manager and a project name on the target's behalf.
161
+ * gitignore entries. A target with no package.json gets a minimal one first
162
+ * when the chain installs anything. A package.json that exists but cannot be
163
+ * parsed is never rewritten, and the install is skipped with a warning.
162
164
  */
163
165
  export async function injectManifest(
164
166
  chain: readonly Manifest[],
165
167
  target: string,
166
168
  ): Promise<void> {
167
169
  const packagePath = join(target, 'package.json')
170
+
171
+ if (!existsSync(packagePath) && declaresInstalls(chain)) {
172
+ logStep('Writing package.json')
173
+ await writeFile(packagePath, serializePackage(minimalPackage(target)))
174
+ logAdd('package.json')
175
+ }
176
+
168
177
  const pkg = readPackage(packagePath)
169
178
 
170
179
  if (pkg) {
@@ -192,14 +201,22 @@ export async function injectManifest(
192
201
 
193
202
  await injectGitignore(chain, target)
194
203
 
195
- if (!pkg) warnSkippedInstalls(chain, target)
204
+ if (!pkg) warnUnreadablePackage(chain, target)
196
205
  }
197
206
 
198
- function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
207
+ /**
208
+ * Only an existing package.json that fails to parse reaches here with installs
209
+ * pending, since an absent one was written above. It is left untouched.
210
+ */
211
+ function warnUnreadablePackage(
212
+ chain: readonly Manifest[],
213
+ target: string,
214
+ ): void {
215
+ if (!declaresInstalls(chain)) return
216
+
199
217
  const deps = collectDeps(chain, {}).map((dep) => dep.spec)
200
218
  const skipped = applyScripts({}, chain)
201
219
  const scripts = [...new Set([...skipped.added, ...skipped.overridden])]
202
- if (deps.length === 0 && scripts.length === 0) return
203
220
 
204
221
  logStep('Skipped installs')
205
222
  if (deps.length > 0) {
@@ -208,5 +225,7 @@ function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
208
225
  if (scripts.length > 0) {
209
226
  logWarn(`Scripts not added: ${scripts.join(', ')}`)
210
227
  }
211
- logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
228
+ logWarn(
229
+ `package.json is not valid JSON: fix it in ${target}, then sync again`,
230
+ )
212
231
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
2
3
  import type { Manifest } from '@/tooling/manifest'
3
4
 
4
5
  export interface PackageJson {
@@ -28,6 +29,37 @@ export function readPackage(path: string): PackageJson | undefined {
28
29
  }
29
30
  }
30
31
 
32
+ /**
33
+ * Whether the chain asks for any dev dependency or script, which is what makes
34
+ * a missing package.json worth writing. A stack that installs nothing, such as
35
+ * the one `canon claude` drives, leaves a project without a manifest alone.
36
+ */
37
+ export function declaresInstalls(chain: readonly Manifest[]): boolean {
38
+ if (collectDeps(chain, {}).length > 0) return true
39
+
40
+ const scripts = applyScripts({}, chain)
41
+ return scripts.added.length > 0 || scripts.overridden.length > 0
42
+ }
43
+
44
+ /**
45
+ * The manifest a sync writes into a folder that has none. The name is the
46
+ * folder's, reduced to the characters npm accepts, so it is never empty or
47
+ * invalid.
48
+ */
49
+ export function minimalPackage(target: string): PackageJson {
50
+ const name = basename(target)
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9-._]/g, '-')
53
+ .replace(/^[._]+/, '')
54
+ .slice(0, 214)
55
+
56
+ return {
57
+ name: name === '' ? 'project' : name,
58
+ version: '0.0.0',
59
+ type: 'module',
60
+ }
61
+ }
62
+
31
63
  export function serializePackage(pkg: PackageJson): string {
32
64
  return `${JSON.stringify(pkg, null, 2)}\n`
33
65
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { PROJECT_ROOT } from '@/roots/project'
4
+ import { configFiles, type Manifest, seedFiles } from '@/tooling/manifest'
4
5
 
5
6
  /**
6
7
  * Spells the package root in a report where every other root spells a
@@ -11,6 +12,8 @@ import { PROJECT_ROOT } from '@/roots/project'
11
12
  const PACKAGE_LABEL = '<canon>'
12
13
 
13
14
  export interface ReferenceRoot {
15
+ /** The root whose `tooling/` this searches, as the manifest loader takes it. */
16
+ readonly root: string
14
17
  /** Absolute `tooling/` directory to search. */
15
18
  readonly dir: string
16
19
  /** How a report spells a copy found under this root. */
@@ -19,6 +22,8 @@ export interface ReferenceRoot {
19
22
 
20
23
  export interface ResolvedReference {
21
24
  readonly path: string
25
+ /** The root that won, so the manifest is read from the same copy. */
26
+ readonly root: string
22
27
  /** The label of the root that won, joined to the stack and filename. */
23
28
  readonly source: string
24
29
  }
@@ -36,8 +41,9 @@ function toolingSourceDir(root: string): string {
36
41
  */
37
42
  export function referenceRoots(root: string): ReferenceRoot[] {
38
43
  return [
39
- { dir: toolingSourceDir(root), label: 'tooling' },
44
+ { root, dir: toolingSourceDir(root), label: 'tooling' },
40
45
  {
46
+ root: PROJECT_ROOT,
41
47
  dir: toolingSourceDir(PROJECT_ROOT),
42
48
  label: join(PACKAGE_LABEL, 'tooling'),
43
49
  },
@@ -49,10 +55,10 @@ export function resolveReference(
49
55
  root: string,
50
56
  stack: string,
51
57
  ): ResolvedReference | undefined {
52
- for (const { dir, label } of referenceRoots(root)) {
58
+ for (const { root: owner, dir, label } of referenceRoots(root)) {
53
59
  const path = join(dir, stack, 'reference.md')
54
60
  if (existsSync(path)) {
55
- return { path, source: join(label, stack, 'reference.md') }
61
+ return { path, root: owner, source: join(label, stack, 'reference.md') }
56
62
  }
57
63
  }
58
64
 
@@ -62,3 +68,40 @@ export function resolveReference(
62
68
  export function readReference(reference: ResolvedReference): string {
63
69
  return readFileSync(reference.path, 'utf8')
64
70
  }
71
+
72
+ function codeList(items: readonly string[]): string {
73
+ return items.map((item) => `- \`${item}\``).join('\n')
74
+ }
75
+
76
+ /**
77
+ * The sections the verb prints after a stack's reference, read from its
78
+ * manifest so no reference keeps a hand list that drifts from it. A stack lists
79
+ * its own files and groups alone, since a parent's print under the parent's
80
+ * reference, and a section with nothing to list prints no heading.
81
+ */
82
+ export function renderStackSections(manifest: Manifest): string {
83
+ const sections: string[] = []
84
+ const configs = configFiles(manifest)
85
+ const seeds = seedFiles(manifest)
86
+
87
+ if (configs.length > 0 || seeds.length > 0) {
88
+ const sync = ['## Sync']
89
+ if (configs.length > 0) {
90
+ sync.push('Sync keeps these current, from `configs/`:', codeList(configs))
91
+ }
92
+ if (seeds.length > 0) {
93
+ sync.push('Sync copies these once, from `seeds/`:', codeList(seeds))
94
+ }
95
+ sections.push(sync.join('\n\n'))
96
+ }
97
+
98
+ if (manifest.gitignore.length > 0) {
99
+ const groups = manifest.gitignore.map(
100
+ ({ header, entries }) =>
101
+ `- \`${header}\`: ${entries.map((entry) => `\`${entry}\``).join(', ')}`,
102
+ )
103
+ sections.push(`## Gitignore\n\n${groups.join('\n')}`)
104
+ }
105
+
106
+ return sections.map((section) => `\n${section}\n`).join('')
107
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  collectDeps,
14
14
  collectScripts,
15
+ declaresInstalls,
15
16
  type DepState,
16
17
  readPackage,
17
18
  type ScriptState,
@@ -48,6 +49,7 @@ export interface ScanResult {
48
49
  readonly deps: readonly DepState[]
49
50
  readonly gitignore: readonly EntryState[]
50
51
  readonly hasPackageJson: boolean
52
+ readonly newManifest: boolean
51
53
  readonly totalChanges: number
52
54
  }
53
55
 
@@ -128,8 +130,10 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
128
130
 
129
131
  const packagePath = join(target, 'package.json')
130
132
  const pkg = readPackage(packagePath)
131
- const scripts = pkg ? collectScripts(chain, pkg) : []
132
- const deps = pkg ? collectDeps(chain, pkg) : []
133
+ const newManifest = !existsSync(packagePath) && declaresInstalls(chain)
134
+ const basis = pkg ?? (newManifest ? {} : undefined)
135
+ const scripts = basis ? collectScripts(chain, basis) : []
136
+ const deps = basis ? collectDeps(chain, basis) : []
133
137
 
134
138
  const gitignorePath = join(target, '.gitignore')
135
139
  const gitignoreContent = existsSync(gitignorePath)
@@ -138,6 +142,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
138
142
  const gitignore = scanGitignore(chain, gitignoreContent)
139
143
 
140
144
  const totalChanges =
145
+ (newManifest ? 1 : 0) +
141
146
  configs.filter((entry) => entry.state !== 'matching').length +
142
147
  seeds.filter((entry) => entry.state === 'missing').length +
143
148
  scripts.filter((entry) => entry.state !== 'matching').length +
@@ -152,6 +157,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
152
157
  deps,
153
158
  gitignore,
154
159
  hasPackageJson: pkg !== undefined,
160
+ newManifest,
155
161
  totalChanges,
156
162
  }
157
163
  }
@@ -7,7 +7,7 @@
7
7
  * an edit to one string here and a re-capture, not another pass through the
8
8
  * pick loop with an operator in it.
9
9
  *
10
- * `canon:draft-identity` writes the picked composition into the `.card`
10
+ * `canon:design-identity` writes the picked composition into the `.card`
11
11
  * element below and leaves everything around it alone. What ships is a working
12
12
  * placeholder, so the route serves before any pick has been taken.
13
13
  *