@erclx/canon 5.65.0 → 5.67.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 (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -74,6 +74,7 @@ import {
74
74
  readBaseStanding,
75
75
  readChangedPaths,
76
76
  } from '@/pr/lift'
77
+ import { close, type CloseApi, type CloseRefusal } from '@/pr/close'
77
78
  import {
78
79
  merge,
79
80
  type MergeApi,
@@ -82,16 +83,15 @@ import {
82
83
  type MergeRequest,
83
84
  } from '@/pr/merge'
84
85
  import { listWorktrees } from '@/git/worktree'
86
+ import { branchSlug, buildDriveCopy, loadPreviewConfig } from '@/pr/drive-copy'
85
87
  import {
86
- branchSlug,
87
- buildDriveCopy,
88
88
  changedSince,
89
- loadPreviewConfig,
90
89
  readWorktreeStatus,
91
90
  type StatusEntry,
92
- } from '@/pr/drive-copy'
91
+ } from '@/git/status'
93
92
  import { machineListenerReader } from '@/listeners/machine'
94
93
  import { loadPaintedPatterns } from '@/pr/painted'
94
+ import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
95
95
  import {
96
96
  dropAddressLine,
97
97
  maskLocalAddress,
@@ -174,6 +174,12 @@ interface MergeCommandOptions extends ReadOptions {
174
174
  readonly dryRun?: boolean
175
175
  }
176
176
 
177
+ interface CloseCommandOptions extends ReadOptions {
178
+ readonly reasonFile?: string
179
+ readonly head?: string
180
+ readonly keepBranch?: boolean
181
+ }
182
+
177
183
  interface ReviewStateOptions extends ReadOptions {
178
184
  readonly wait?: string
179
185
  }
@@ -244,9 +250,15 @@ const AMBIGUOUS_PULL =
244
250
  /** How long the probe waits on a listener before reading it as not a server. */
245
251
  const LOCAL_PROBE_TIMEOUT_MS = 2_000
246
252
 
247
- const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
253
+ const LOCAL_REFUSALS: Record<
254
+ LocalRefusal | 'invalid-config' | 'gh-failed',
255
+ string
256
+ > = {
248
257
  'no-server':
249
258
  'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
259
+ unhealthy:
260
+ 'A page answered inside this worktree, but a health path it declares failed. Start what the page reaches and re-run to get a link.',
261
+ 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run.`,
250
262
  'no-listener-reader':
251
263
  'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
252
264
  'gh-failed':
@@ -991,6 +1003,60 @@ export function register(program: Command): void {
991
1003
  process.exitCode = await runMerge(number, opts)
992
1004
  })
993
1005
 
1006
+ pr.command('close')
1007
+ .description(
1008
+ 'Close a pull request unmerged, posting the reason first and deleting its head branch',
1009
+ )
1010
+ .argument('<number>', 'Pull request to close')
1011
+ .helpOption('-h, --help', 'Show this help message')
1012
+ .option('--reason-file <path>', 'Closing comment to post, required')
1013
+ .option(
1014
+ '--head <branch>',
1015
+ 'Head branch the pull request must carry, defaulting to the current branch',
1016
+ )
1017
+ .option('--keep-branch', 'Leave the head branch in place')
1018
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
1019
+ .option('--json', 'Add a machine-readable record on stdout')
1020
+ .addHelpText(
1021
+ 'after',
1022
+ [
1023
+ '',
1024
+ 'The reason is required, so a pull request this verb closes always says',
1025
+ 'why. It is checked before anything is read: banned characters, phase',
1026
+ 'labels, board references, session links, and density measured on the',
1027
+ 'reason itself. A finding refuses reason-unpublishable with the findings',
1028
+ 'in the record.',
1029
+ '',
1030
+ 'Then the pull request is read and must be open with the head --head',
1031
+ 'names. The comment posts first, then the close, then the branch',
1032
+ 'delete, so a failure part way leaves a comment on an open pull request',
1033
+ 'rather than a close with no reason. A failed delete never fails the',
1034
+ 'close, and a head in another repository is never deleted.',
1035
+ '',
1036
+ 'Record: number, commentUrl, branch (deleted, kept, fork, delete-failed),',
1037
+ 'findings on a reason-unpublishable refusal',
1038
+ '',
1039
+ 'Reasons:',
1040
+ ' closed',
1041
+ ' no-reason, reason-unreadable, reason-unpublishable, no-head,',
1042
+ ' gh-failed, pull-closed, head-mismatch, comment-failed, close-failed',
1043
+ '',
1044
+ 'Every call is REST, never `gh pr close`, which runs on GraphQL.',
1045
+ '',
1046
+ 'Exit codes:',
1047
+ ' 0 closed',
1048
+ ' 1 refused, with the reason in the JSON record',
1049
+ '',
1050
+ 'Examples:',
1051
+ ' canon pr close 1341 --reason-file reason.md --json',
1052
+ ' canon pr close 1341 --reason-file reason.md --head feat/old --keep-branch',
1053
+ '',
1054
+ ].join('\n'),
1055
+ )
1056
+ .action(async (number: string, opts: CloseCommandOptions) => {
1057
+ process.exitCode = await runClose(number, opts)
1058
+ })
1059
+
994
1060
  pr.command('preview')
995
1061
  .description(
996
1062
  "Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
@@ -1090,6 +1156,13 @@ export function register(program: Command): void {
1090
1156
  'port serving an HTML page is reported, which skips a test runner or a',
1091
1157
  'reload socket answering in the same tree.',
1092
1158
  '',
1159
+ 'A project can declare paths the page must also serve, in',
1160
+ 'canon/config/local.toml as health = ["/api/health"]. Each is requested',
1161
+ "on the page's own port, redirects followed, and only a final 2xx",
1162
+ 'passes. A page failing one is skipped for the next page port, and the',
1163
+ "refusal names the lowest page port's first failing path. An absent",
1164
+ 'file or an empty list checks the page alone.',
1165
+ '',
1093
1166
  'A process in a sibling worktree, in the main checkout seen from a',
1094
1167
  'linked worktree, or in a linked worktree seen from the main checkout',
1095
1168
  'is never reported, since its server shows another branch. Sockets are',
@@ -1112,6 +1185,11 @@ export function register(program: Command): void {
1112
1185
  ' ok a page answered, with its address in `url`',
1113
1186
  ' no-server nothing inside this worktree served a page',
1114
1187
  ' no-listener-reader neither lsof nor /proc is available',
1188
+ ' unhealthy a page answered but a declared health path did',
1189
+ ' not, with `port`, `path`, and `status`, which',
1190
+ ' is null on a timeout',
1191
+ ' invalid-config canon/config/local.toml is not valid TOML, or',
1192
+ ' health is not a list of paths starting with /',
1115
1193
  ' removed --remove dropped the line',
1116
1194
  ' no-comment --remove found no marked comment, a no-op',
1117
1195
  ' no-line --remove found no address line, a no-op',
@@ -1226,18 +1304,28 @@ export function register(program: Command): void {
1226
1304
  [
1227
1305
  '',
1228
1306
  'A project names what its preview writes in',
1229
- '`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
1307
+ '`canon/config/preview.json` as',
1308
+ '`{ "writes": [path], "keep": [path], "env": "<NAME>" }`.',
1230
1309
  'Each `writes` path is relative to the worktree, and `env`, defaulting',
1231
1310
  'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
1232
1311
  'root. The server resolves each declared path under that root.',
1233
1312
  '',
1313
+ 'Each `keep` path sits below a `writes` path, never equal to one, and',
1314
+ 'names state the running server owns there, such as a run file. A',
1315
+ "rebuild leaves it in the copy and never copies the worktree's own, so",
1316
+ 'list it for a server that writes under a declared path. The key is',
1317
+ 'optional.',
1318
+ '',
1234
1319
  'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
1235
1320
  'worktree root, where `<slug>` is the branch with a leading type',
1236
1321
  'segment dropped and each `/` made `-`. With a number the branch is the',
1237
1322
  "pull request's head, and without one it is the checkout's, so the",
1238
1323
  'worker starting its server before a pull request exists and the',
1239
1324
  'reviewer naming the number land on one copy. Every run but --check',
1240
- "rebuilds it from the worktree, dropping the last pass's drives.",
1325
+ "resets each declared path from the worktree, dropping the last pass's",
1326
+ 'drives, and leaves the rest of the copy root and each `keep` path as',
1327
+ 'they are. A page added only to the copy, inside a declared path, is',
1328
+ 'wiped.',
1241
1329
  '',
1242
1330
  '--baseline records `git status` for the worktree under',
1243
1331
  '`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
@@ -3005,6 +3093,19 @@ function machineLocalRunner(): LocalRunner {
3005
3093
  return false
3006
3094
  }
3007
3095
  },
3096
+ // The final status after redirects, so a login bounce ending on a 3xx or
3097
+ // a 4xx fails rather than reading as the backend answering.
3098
+ async check(port, path) {
3099
+ try {
3100
+ const response = await fetch(`http://localhost:${port}${path}`, {
3101
+ signal: AbortSignal.timeout(LOCAL_PROBE_TIMEOUT_MS),
3102
+ })
3103
+ await response.body?.cancel()
3104
+ return response.status
3105
+ } catch {
3106
+ return null
3107
+ }
3108
+ },
3008
3109
  }
3009
3110
  }
3010
3111
 
@@ -3025,7 +3126,35 @@ async function runLocal(opts: LocalOptions): Promise<number> {
3025
3126
  intro('canon pr local')
3026
3127
 
3027
3128
  const worktree = await resolveWorktreeRoot(root)
3028
- const result = await findLocalServer(worktree, machineLocalRunner())
3129
+ const declaration = readHealthPaths(worktree)
3130
+ if (declaration.kind === 'invalid') {
3131
+ return refuseWith(
3132
+ 'invalid-config',
3133
+ `${declaration.reason}. ${LOCAL_REFUSALS['invalid-config']}`,
3134
+ emitJson,
3135
+ root,
3136
+ )
3137
+ }
3138
+
3139
+ const result = await findLocalServer(
3140
+ worktree,
3141
+ machineLocalRunner(),
3142
+ declaration.kind === 'paths' ? declaration.paths : [],
3143
+ )
3144
+ if (result.kind === 'refused' && result.reason === 'unhealthy') {
3145
+ const { port, path, status } = result
3146
+ const answer =
3147
+ status === null
3148
+ ? `gave no answer within ${LOCAL_PROBE_TIMEOUT_MS / 1000} seconds`
3149
+ : `answered ${status}`
3150
+ return refuseWith(
3151
+ 'unhealthy',
3152
+ `${LOCAL_REFUSALS.unhealthy} ${path} on http://localhost:${port} ${answer}.`,
3153
+ emitJson,
3154
+ root,
3155
+ { port, path, status },
3156
+ )
3157
+ }
3029
3158
  if (result.kind === 'refused') {
3030
3159
  return refuseWith(
3031
3160
  result.reason,
@@ -4364,6 +4493,7 @@ async function runDriveCopy(
4364
4493
  target.worktree,
4365
4494
  copyRoot,
4366
4495
  config.config.writes,
4496
+ config.config.keep,
4367
4497
  )
4368
4498
  logStep('Copied')
4369
4499
  logInfo(`${config.config.env}=${copyRoot}`)
@@ -4394,6 +4524,146 @@ async function runDriveCopy(
4394
4524
  return 0
4395
4525
  }
4396
4526
 
4527
+ type CloseCommandRefusal = 'reason-unreadable' | 'no-head'
4528
+
4529
+ const CLOSE_REFUSALS: Record<CloseRefusal | CloseCommandRefusal, string> = {
4530
+ 'no-reason':
4531
+ 'No closing reason was given. Write it to a file and pass --reason-file.',
4532
+ 'reason-unreadable': 'The --reason-file could not be read.',
4533
+ 'reason-unpublishable':
4534
+ 'The reason carries text a reader on the remote cannot use or that breaks the density limits. The findings are in the record.',
4535
+ 'no-head':
4536
+ 'This checkout is on no branch, so pass --head with the branch the pull request carries.',
4537
+ 'gh-failed': 'gh could not read the pull request, so nothing was written.',
4538
+ 'pull-closed': 'The pull request is already closed.',
4539
+ 'head-mismatch':
4540
+ 'The pull request carries another head branch than the one expected. Nothing was written to it.',
4541
+ 'comment-failed':
4542
+ 'The closing comment did not post, so the pull request was left open.',
4543
+ 'close-failed':
4544
+ 'The comment posted but the close was refused, so the pull request is still open. Close it by hand or re-run.',
4545
+ }
4546
+
4547
+ function closeApi(root: string, number: string): CloseApi {
4548
+ return {
4549
+ readPull: async () => {
4550
+ const stdout = await gh(root, [
4551
+ 'api',
4552
+ `repos/{owner}/{repo}/pulls/${number}`,
4553
+ ])
4554
+ if (stdout === null) return undefined
4555
+ try {
4556
+ const row = JSON.parse(stdout) as RestPull
4557
+ if (typeof row.head?.ref !== 'string') return undefined
4558
+ const headRepo = row.head.repo?.full_name
4559
+ return {
4560
+ isOpen: row.state === 'open',
4561
+ branch: row.head.ref,
4562
+ isFork:
4563
+ headRepo === undefined || headRepo !== row.base?.repo?.full_name,
4564
+ }
4565
+ } catch {
4566
+ return undefined
4567
+ }
4568
+ },
4569
+ postComment: async (body) => {
4570
+ const url = await gh(root, [
4571
+ 'api',
4572
+ '-X',
4573
+ 'POST',
4574
+ `repos/{owner}/{repo}/issues/${number}/comments`,
4575
+ '-f',
4576
+ `body=${body}`,
4577
+ '--jq',
4578
+ '.html_url',
4579
+ ])
4580
+ return url === null || url.trim() === '' ? undefined : url.trim()
4581
+ },
4582
+ patchClosed: async () =>
4583
+ (await gh(root, [
4584
+ 'api',
4585
+ '-X',
4586
+ 'PATCH',
4587
+ `repos/{owner}/{repo}/pulls/${number}`,
4588
+ '-f',
4589
+ 'state=closed',
4590
+ '--silent',
4591
+ ])) !== null,
4592
+ deleteBranch: async (branch) =>
4593
+ (await gh(root, [
4594
+ 'api',
4595
+ '-X',
4596
+ 'DELETE',
4597
+ `repos/{owner}/{repo}/git/refs/heads/${encodeURIComponent(branch)}`,
4598
+ '--silent',
4599
+ ])) !== null,
4600
+ }
4601
+ }
4602
+
4603
+ async function runClose(
4604
+ number: string,
4605
+ opts: CloseCommandOptions,
4606
+ ): Promise<number> {
4607
+ const root = resolve(opts.root ?? process.cwd())
4608
+ const emitJson = opts.json ?? false
4609
+
4610
+ intro('canon pr close')
4611
+
4612
+ const refuseClose = (
4613
+ reason: CloseRefusal | CloseCommandRefusal,
4614
+ extra: Record<string, unknown> = {},
4615
+ ): number =>
4616
+ refuseWith(reason, CLOSE_REFUSALS[reason], emitJson, root, {
4617
+ number: Number(number),
4618
+ ...extra,
4619
+ })
4620
+
4621
+ if (opts.reasonFile === undefined) return refuseClose('no-reason')
4622
+ let reason: string
4623
+ try {
4624
+ reason = await readFile(resolve(root, opts.reasonFile), 'utf8')
4625
+ } catch {
4626
+ logWarn(`Could not read ${opts.reasonFile}`)
4627
+ return refuseClose('reason-unreadable')
4628
+ }
4629
+
4630
+ let head = opts.head
4631
+ if (head === undefined) {
4632
+ const branch = await $`git -C ${root} symbolic-ref --short -q HEAD`
4633
+ .env(gitEnv())
4634
+ .quiet()
4635
+ .nothrow()
4636
+ head = branch.exitCode === 0 ? branch.text().trim() : ''
4637
+ if (head === '') return refuseClose('no-head')
4638
+ }
4639
+
4640
+ const record = await close(closeApi(root, number), {
4641
+ number: Number(number),
4642
+ reason,
4643
+ head,
4644
+ isKeepBranch: opts.keepBranch ?? false,
4645
+ })
4646
+
4647
+ if (!record.isOk) {
4648
+ return refuseClose(record.reason as CloseRefusal, {
4649
+ ...(record.commentUrl !== undefined && { commentUrl: record.commentUrl }),
4650
+ ...(record.findings !== undefined && { findings: record.findings }),
4651
+ })
4652
+ }
4653
+
4654
+ logStep('Closed')
4655
+ logInfo(`#${record.number} closed, branch ${record.branch}`)
4656
+ if (record.branch === 'delete-failed') {
4657
+ logWarn(`The branch ${head} could not be deleted. Delete it by hand.`)
4658
+ }
4659
+ outro()
4660
+
4661
+ if (emitJson) {
4662
+ process.stdout.write(`${JSON.stringify({ root, ...record })}\n`)
4663
+ }
4664
+ return 0
4665
+ }
4666
+
4397
4667
  function refuseWith(
4398
4668
  reason: string,
4399
4669
  message: string,
@@ -34,6 +34,7 @@ import {
34
34
  type StructureFinding,
35
35
  validateRecords,
36
36
  } from '@/records/validate'
37
+ import type { SessionMapLength } from '@/records/session-map'
37
38
  import {
38
39
  intro,
39
40
  logError,
@@ -92,7 +93,7 @@ export function register(program: Command): void {
92
93
  .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
93
94
  .argument(
94
95
  '[record]',
95
- 'One plan stem or folder slug, narrowing both findings and structure to it',
96
+ 'One plan stem, folder slug, or session map stem, narrowing the report to it',
96
97
  )
97
98
  .helpOption('-h, --help', 'Show this help message')
98
99
  .option('--json', 'Add a machine-readable record on stdout')
@@ -105,6 +106,7 @@ export function register(program: Command): void {
105
106
  ' plans filename, required sections, and the suggested-and-answer contract',
106
107
  ' groundwork README and current-state files, numbering, dating, and a half-closed track',
107
108
  ' intake overview file, numbering, dating, and the four bullets every item carries',
109
+ " sessions each session map's words against its cap, under `length` and never in the exit",
108
110
  '',
109
111
  'Structure, under `structure` and never in the exit:',
110
112
  " S1 to S5 from the plan standard, read on plans, a track's README and",
@@ -128,6 +130,7 @@ export function register(program: Command): void {
128
130
  ' canon records validate plans',
129
131
  ' canon records validate plans feature-records-by-heading',
130
132
  ' canon records validate intake --json',
133
+ ' canon records validate sessions session-orchestrator',
131
134
  '',
132
135
  ].join('\n'),
133
136
  )
@@ -828,6 +831,7 @@ function report(
828
831
  records: outcome.records,
829
832
  findings: outcome.findings,
830
833
  structure: outcome.structure,
834
+ ...(outcome.length && { length: outcome.length }),
831
835
  })}\n`,
832
836
  )
833
837
  } else {
@@ -846,6 +850,14 @@ function report(
846
850
  logStep('Structure, reported and never in the exit')
847
851
  for (const entry of outcome.structure) logInfo(describeStructure(entry))
848
852
  }
853
+
854
+ if (outcome.length) {
855
+ logStep('Length, reported and never in the exit')
856
+ for (const entry of outcome.length) {
857
+ const log = entry.over ? logWarn : logInfo
858
+ log(describeLength(entry))
859
+ }
860
+ }
849
861
  outro()
850
862
  }
851
863
 
@@ -857,6 +869,11 @@ function describe(found: Finding): string {
857
869
  return `${scope}${found.subject} ${found.message}`
858
870
  }
859
871
 
872
+ function describeLength(entry: SessionMapLength): string {
873
+ const verdict = entry.over ? 'over the cap' : 'within the cap'
874
+ return `${entry.record}: ${entry.words} of ${entry.cap} words, ${verdict}`
875
+ }
876
+
860
877
  function describeStructure(entry: StructureFinding): string {
861
878
  const scope = entry.record === entry.subject ? '' : `${entry.record}/`
862
879
  return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`