@erclx/canon 5.78.0 → 5.80.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 (104) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/canon-cli/SKILL.md +13 -10
  12. package/claude/skills/design-pick/SKILL.md +8 -0
  13. package/claude/skills/design-pick/references/design-config.md +9 -6
  14. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  15. package/claude/skills/design-references/SKILL.md +120 -0
  16. package/claude/skills/design-references/references/films.md +90 -0
  17. package/claude/skills/design-references/references/sites.md +36 -0
  18. package/claude/skills/design-taste/SKILL.md +16 -4
  19. package/claude/skills/design-taste/references/adopted.md +5 -1
  20. package/claude/skills/design-taste/references/kinds.md +1 -0
  21. package/claude/skills/design-taste/references/preflight.md +27 -1
  22. package/claude/skills/design-taste/references/tells.md +2 -0
  23. package/claude/skills/git-followup/SKILL.md +1 -0
  24. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  25. package/claude/skills/git-pr/SKILL.md +2 -0
  26. package/claude/skills/git-pr/references/final-command.md +34 -2
  27. package/claude/skills/git-pr/references/pr.md +1 -1
  28. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  29. package/claude/skills/git-ship/SKILL.md +16 -11
  30. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  31. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  32. package/claude/skills/plan-feature/references/plan.md +2 -0
  33. package/claude/skills/review-address/SKILL.md +2 -1
  34. package/claude/skills/review-pr/references/key-changes.md +2 -1
  35. package/claude/skills/review-ui/references/driving.md +33 -1
  36. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  37. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  38. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  40. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  42. package/claude/skills/role-worker/SKILL.md +1 -1
  43. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  44. package/claude/skills/search-craft/SKILL.md +12 -1
  45. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  46. package/claude/skills/target-scaffold/SKILL.md +44 -0
  47. package/claude/skills/target-setup/SKILL.md +5 -1
  48. package/claude/skills/task-board/SKILL.md +1 -0
  49. package/claude/skills/task-board/references/tasks.md +1 -0
  50. package/claude/skills/ui-checklist/SKILL.md +5 -1
  51. package/claude/skills/ui-craft/SKILL.md +9 -9
  52. package/claude/skills/ui-craft/references/adopted.md +4 -2
  53. package/claude/skills/ui-craft/references/capture.md +8 -0
  54. package/claude/skills/ui-craft/references/controls.md +6 -0
  55. package/claude/skills/ui-craft/references/motion.md +6 -0
  56. package/claude/skills/ui-film/references/take-craft.md +2 -0
  57. package/docs/agents/output-shape.md +2 -1
  58. package/docs/target/projects.md +2 -2
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/parallel-features.md +2 -1
  61. package/docs/workflow/skill-map.md +7 -5
  62. package/docs/workflow/visual-design-workflow.md +4 -0
  63. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  64. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  65. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  66. package/governance/ledger/skills/design-taste/tells.md +22 -20
  67. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  69. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  70. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  71. package/package.json +3 -3
  72. package/src/capture/render.ts +154 -2
  73. package/src/capture/sections.ts +257 -0
  74. package/src/claude/cases/misc.ts +5 -0
  75. package/src/claude/cases/setup.ts +7 -0
  76. package/src/commands/capture.ts +149 -4
  77. package/src/commands/pr.ts +138 -15
  78. package/src/commands/sessions.ts +88 -6
  79. package/src/commands/tasks.ts +41 -2
  80. package/src/commands/tooling.ts +72 -0
  81. package/src/git/files.ts +5 -2
  82. package/src/indexes/frontmatter.ts +27 -6
  83. package/src/indexes/render.ts +21 -3
  84. package/src/markdown/colons.ts +14 -4
  85. package/src/markdown/heading-case.ts +9 -2
  86. package/src/pr/bijection.ts +8 -0
  87. package/src/pr/evidence.ts +38 -9
  88. package/src/pr/health.ts +63 -15
  89. package/src/pr/links.ts +160 -0
  90. package/src/pr/local.ts +17 -4
  91. package/src/pr/paths.ts +20 -2
  92. package/src/sessions/stop.ts +145 -0
  93. package/src/sessions/transfer/bundle.ts +1 -1
  94. package/src/take/encode.ts +16 -42
  95. package/src/take/frame-clock.ts +46 -0
  96. package/src/take/perform.ts +29 -4
  97. package/src/take/screencast.ts +114 -11
  98. package/src/tasks/holders.ts +198 -0
  99. package/src/tasks/validate.ts +148 -0
  100. package/src/tooling/list.ts +3 -0
  101. package/src/tooling/scaffold.ts +193 -0
  102. package/tooling/cloudflare/manifest.toml +1 -1
  103. package/tooling/cloudflare/reference.md +8 -0
  104. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -113,6 +113,7 @@ import {
113
113
  type LocalRefusal,
114
114
  type LocalRunner,
115
115
  } from '@/pr/local'
116
+ import { linkKeyChanges } from '@/pr/links'
116
117
  import { KEY_CHANGES } from '@/pr/paths'
117
118
  import {
118
119
  findDeployWorkflow,
@@ -161,6 +162,7 @@ const GH_TIMEOUT_MS = 30_000
161
162
  const UNNAMED_PRINT_LIMIT = 10
162
163
 
163
164
  interface KeyChangesOptions {
165
+ readonly link?: boolean
164
166
  readonly body?: string
165
167
  readonly base?: string
166
168
  readonly root?: string
@@ -279,7 +281,7 @@ const LOCAL_REFUSALS: Record<
279
281
  'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
280
282
  unhealthy:
281
283
  '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.',
282
- '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.`,
284
+ '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. health takes paths starting with /, and page-only takes folders relative to the repository.`,
283
285
  'no-listener-reader':
284
286
  'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
285
287
  'gh-failed':
@@ -503,13 +505,17 @@ export function register(program: Command): void {
503
505
  .helpOption('-h, --help', 'Show this help message')
504
506
  .option(
505
507
  '--body <path>',
506
- 'Read the body from a file rather than the API, ignoring any number',
508
+ 'Read the body from a file rather than the API, ignoring any number except the one --link needs',
507
509
  )
508
510
  .option(
509
511
  '--base <ref>',
510
512
  'Far side of the range when --body supplies the body',
511
513
  )
512
514
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
515
+ .option(
516
+ '--link',
517
+ `Print the body with each ## ${KEY_CHANGES} path linked to its diff, needing a number`,
518
+ )
513
519
  .option('--json', 'Add a machine-readable record on stdout')
514
520
  .addHelpText(
515
521
  'after',
@@ -538,6 +544,13 @@ export function register(program: Command): void {
538
544
  'points the change at a locative the path does not name. Read the bullet on',
539
545
  "the record's preview before filing an unmet path as a stale claim.",
540
546
  '',
547
+ '--link prints the body and nothing else, with every path the section',
548
+ 'names that the diff changes written as a link to its row in the Files',
549
+ 'tab. The anchor is `diff-` and the SHA-256 of the path. Pass the pull',
550
+ 'request number, since the link needs it. It also rewrites the anchor',
551
+ 'of a link opening another file, and a plain run reports that link as',
552
+ "mislinked when its label is a changed path and its anchor hashes another's.",
553
+ '',
541
554
  'Refusals, read off `reason`:',
542
555
  ' no-section the body carries no `## Key changes` heading, so it',
543
556
  ' claims nothing',
@@ -555,14 +568,18 @@ export function register(program: Command): void {
555
568
  ' gh-missing `gh` is not on the path, so no pull request could be',
556
569
  ' resolved',
557
570
  ' gh-failed `gh` could not answer for this repository or branch',
571
+ ' no-number --link was given with no pull request number',
572
+ ' no-repo-url --link could not read the repository address',
573
+ ' link-json --link was given with --json, though it prints the body',
558
574
  '',
559
575
  'Exit codes:',
560
- ' 0 every claimed path is in the diff',
576
+ ' 0 every claimed path is in the diff, and every link opens its own file',
561
577
  ' 1 refused, with the reason on stderr or in the JSON record',
562
- ' 2 at least one claimed path is absent from the diff',
578
+ ' 2 a claimed path is absent from the diff, or a link opens another file',
563
579
  '',
564
580
  'Examples:',
565
581
  ' canon pr key-changes',
582
+ ' canon pr key-changes 1265 --link --body .canon/tmp/body.md',
566
583
  ' canon pr key-changes 1265 --json',
567
584
  ' canon pr key-changes --body .canon/tmp/body.md --base origin/main',
568
585
  '',
@@ -1301,6 +1318,15 @@ export function register(program: Command): void {
1301
1318
  "refusal names the lowest page port's first failing path. An absent",
1302
1319
  'file or an empty list checks the page alone.',
1303
1320
  '',
1321
+ 'A page served by a static server answers none of those paths, so',
1322
+ 'page-only = ["web"] lists repository-relative folders whose',
1323
+ 'listeners skip them and are found on the page probe alone. The match',
1324
+ "reads the listener's working directory on whole path segments, so web",
1325
+ 'does not cover webapp, and a server started from the root with a',
1326
+ 'working-directory flag still takes the health check. Keep health',
1327
+ 'beside page-only until the binary a project runs carries this key,',
1328
+ 'since an older one refuses a file holding page-only alone.',
1329
+ '',
1304
1330
  'A process in a sibling worktree, in the main checkout seen from a',
1305
1331
  'linked worktree, or in a linked worktree seen from the main checkout',
1306
1332
  'is never reported, since its server shows another branch. Sockets are',
@@ -1326,8 +1352,9 @@ export function register(program: Command): void {
1326
1352
  ' unhealthy a page answered but a declared health path did',
1327
1353
  ' not, with `port`, `path`, and `status`, which',
1328
1354
  ' is null on a timeout',
1329
- ' invalid-config canon/config/local.toml is not valid TOML, or',
1330
- ' health is not a list of paths starting with /',
1355
+ ' invalid-config canon/config/local.toml is not valid TOML,',
1356
+ ' health is not a list of paths starting with /,',
1357
+ ' or page-only is not a list of relative folders',
1331
1358
  ' removed --remove dropped the line',
1332
1359
  ' no-comment --remove found no marked comment, a no-op',
1333
1360
  ' no-line --remove found no address line, a no-op',
@@ -1865,6 +1892,8 @@ async function runKeyChanges(
1865
1892
  const root = resolve(opts.root ?? process.cwd())
1866
1893
  const emitJson = opts.json ?? false
1867
1894
 
1895
+ if (opts.link === true) return runKeyChangesLink(number, opts, root)
1896
+
1868
1897
  intro('canon pr key-changes')
1869
1898
 
1870
1899
  const source =
@@ -1949,6 +1978,14 @@ async function runKeyChanges(
1949
1978
  for (const claim of report.unresolved) logInfo(claim.path)
1950
1979
  }
1951
1980
 
1981
+ if (report.mislinked.length > 0) {
1982
+ logStep('Mislinked')
1983
+ logWarn(
1984
+ `${plural(report.mislinked.length, 'link')} open another file's diff than the path they name. Re-run with --link to print the body with them corrected.`,
1985
+ )
1986
+ for (const link of report.mislinked) logWarn(link.path)
1987
+ }
1988
+
1952
1989
  outro()
1953
1990
 
1954
1991
  if (emitJson) {
@@ -1966,11 +2003,71 @@ async function runKeyChanges(
1966
2003
  incidental: report.incidental,
1967
2004
  unresolved: report.unresolved,
1968
2005
  evidenceUnread: report.evidenceUnread,
2006
+ mislinked: report.mislinked,
1969
2007
  })}\n`,
1970
2008
  )
1971
2009
  }
1972
2010
 
1973
- return report.unmet.length === 0 ? 0 : 2
2011
+ return report.unmet.length === 0 && report.mislinked.length === 0 ? 0 : 2
2012
+ }
2013
+
2014
+ /**
2015
+ * Prints the body with each changed path in `## Key changes` linked to its
2016
+ * diff, and nothing else, so stdout pipes straight into a body file.
2017
+ *
2018
+ * Skips the frame `intro` opens, which would land in the piped body. Refusals
2019
+ * keep the framed shape on stdout like every other mode.
2020
+ */
2021
+ async function runKeyChangesLink(
2022
+ number: string | undefined,
2023
+ opts: KeyChangesOptions,
2024
+ root: string,
2025
+ ): Promise<number> {
2026
+ const emitJson = opts.json ?? false
2027
+
2028
+ if (emitJson) {
2029
+ return refuseWith(
2030
+ 'link-json',
2031
+ '--link prints the body itself, so it takes no --json record.',
2032
+ emitJson,
2033
+ root,
2034
+ )
2035
+ }
2036
+
2037
+ const source =
2038
+ opts.body === undefined
2039
+ ? await readFromApi(root, number)
2040
+ : await readFromFile(root, process.cwd(), opts.body, opts.base)
2041
+ if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
2042
+
2043
+ const pull = number ?? source.source.number?.toString()
2044
+ if (pull === undefined) {
2045
+ return refuseWith(
2046
+ 'no-number',
2047
+ 'The link needs the pull request number, so pass it as the argument.',
2048
+ emitJson,
2049
+ root,
2050
+ )
2051
+ }
2052
+
2053
+ const url = await readRepoUrl(root)
2054
+ if (url === undefined) {
2055
+ return refuseWith(
2056
+ 'no-repo-url',
2057
+ 'gh could not read the repository address, so no link was built.',
2058
+ emitJson,
2059
+ root,
2060
+ )
2061
+ }
2062
+
2063
+ process.stdout.write(
2064
+ linkKeyChanges(
2065
+ source.source.body,
2066
+ source.source.changed,
2067
+ `${url}/pull/${pull}/files`,
2068
+ ),
2069
+ )
2070
+ return 0
1974
2071
  }
1975
2072
 
1976
2073
  type IdentityRead =
@@ -2494,14 +2591,18 @@ interface PullFiles {
2494
2591
  readonly present: ReadonlyMap<string, boolean>
2495
2592
  /** Each path the pull request removed, which a deploy's push filter still counts. */
2496
2593
  readonly removed: readonly string[]
2594
+ /** Each renamed path mapped to the path it sat at before the pull request. */
2595
+ readonly renamedFrom: ReadonlyMap<string, string>
2497
2596
  }
2498
2597
 
2499
2598
  /**
2500
2599
  * The paths a pull request changed, read through the paginated files endpoint
2501
2600
  * so the set is the same from any checkout and never capped at the first
2502
- * view. `renamed` and `copied` count as added, since the new path has no
2503
- * counterpart at the merge base. Returns undefined when the read fails, which
2504
- * refuses rather than rendering a short set.
2601
+ * view. `copied` counts as added, since a copy leaves its source in place and
2602
+ * has no single base. `renamed` counts as added in `present` too, so the
2603
+ * changed set stays the same, and `renamedFrom` carries the old path of each
2604
+ * rename for a reader that compares against the base. Returns undefined when
2605
+ * the read fails, which refuses rather than rendering a short set.
2505
2606
  */
2506
2607
  async function listPullFiles(
2507
2608
  cwd: string,
@@ -2512,18 +2613,22 @@ async function listPullFiles(
2512
2613
  '--paginate',
2513
2614
  `repos/{owner}/{repo}/pulls/${number}/files`,
2514
2615
  '--jq',
2515
- '.[] | [.status, .filename] | @tsv',
2616
+ '.[] | [.status, .filename, (.previous_filename // "")] | @tsv',
2516
2617
  ])
2517
2618
  if (stdout === null) return undefined
2518
2619
  const present = new Map<string, boolean>()
2519
2620
  const removed: string[] = []
2621
+ const renamedFrom = new Map<string, string>()
2520
2622
  for (const line of stdout.split('\n').filter(Boolean)) {
2521
- const [status, path] = line.split('\t')
2623
+ const [status, path, previous] = line.split('\t')
2522
2624
  if (status === undefined || path === undefined) continue
2523
2625
  if (status === 'removed') removed.push(path)
2524
2626
  else present.set(path, status !== 'modified' && status !== 'changed')
2627
+ if (status === 'renamed' && previous !== undefined && previous !== '') {
2628
+ renamedFrom.set(path, previous)
2629
+ }
2525
2630
  }
2526
- return { present, removed }
2631
+ return { present, removed, renamedFrom }
2527
2632
  }
2528
2633
 
2529
2634
  function everyChangedPath(files: PullFiles): string[] {
@@ -2671,13 +2776,14 @@ async function measureItem(
2671
2776
  head: string,
2672
2777
  ): Promise<MeasuredItem> {
2673
2778
  const headRead = await readImage(cwd, item.path, head)
2779
+ const basePath = item.basePath ?? item.path
2674
2780
  const baseRead =
2675
2781
  item.added || base === undefined
2676
2782
  ? undefined
2677
- : await readImage(cwd, item.path, base)
2783
+ : await readImage(cwd, basePath, base)
2678
2784
  const unread = [
2679
2785
  ...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
2680
- ...(baseRead?.kind === 'failed' ? [`base:${item.path}`] : []),
2786
+ ...(baseRead?.kind === 'failed' ? [`base:${basePath}`] : []),
2681
2787
  ]
2682
2788
  const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
2683
2789
  const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
@@ -2691,6 +2797,9 @@ async function measureItem(
2691
2797
  ...(headBytes !== undefined &&
2692
2798
  baseBytes !== undefined && {
2693
2799
  withinTolerance: isWithinTolerance(baseBytes, headBytes),
2800
+ ...(item.basePath !== undefined && {
2801
+ identical: Buffer.from(baseBytes).equals(headBytes),
2802
+ }),
2694
2803
  }),
2695
2804
  },
2696
2805
  unread,
@@ -2811,6 +2920,7 @@ async function runEvidence(
2811
2920
  const grouped = await groupEvidence(
2812
2921
  [...pullFiles.present.keys()],
2813
2922
  async (path) => pullFiles.present.get(path) === false,
2923
+ pullFiles.renamedFrom,
2814
2924
  )
2815
2925
  const uncaptured = readUncaptured(
2816
2926
  everyChangedPath(pullFiles),
@@ -3412,6 +3522,7 @@ async function runLocal(opts: LocalOptions): Promise<number> {
3412
3522
  worktree,
3413
3523
  machineLocalRunner(),
3414
3524
  declaration.kind === 'paths' ? declaration.paths : [],
3525
+ declaration.pageOnly,
3415
3526
  )
3416
3527
  if (result.kind === 'refused' && result.reason === 'unhealthy') {
3417
3528
  const { port, path, status } = result
@@ -4028,6 +4139,18 @@ async function readRepoName(root: string): Promise<string | undefined> {
4028
4139
  }
4029
4140
  }
4030
4141
 
4142
+ /** The repository's web address off the REST repository read, for the same reason as `readRepoName`. */
4143
+ async function readRepoUrl(root: string): Promise<string | undefined> {
4144
+ const row = await gh(root, ['api', 'repos/{owner}/{repo}'])
4145
+ if (row === null) return undefined
4146
+ try {
4147
+ const url = (JSON.parse(row) as { html_url?: unknown }).html_url
4148
+ return typeof url === 'string' && url !== '' ? url : undefined
4149
+ } catch {
4150
+ return undefined
4151
+ }
4152
+ }
4153
+
4031
4154
  async function runFrames(
4032
4155
  number: string | undefined,
4033
4156
  opts: FramesOptions,
@@ -11,6 +11,7 @@ import {
11
11
  type SessionReport,
12
12
  selfOf,
13
13
  } from '@/sessions/resolve'
14
+ import { readStop, type StopReading } from '@/sessions/stop'
14
15
  import {
15
16
  type Behind,
16
17
  exportSession,
@@ -32,8 +33,11 @@ interface ListCommandOptions {
32
33
  readonly branch?: string
33
34
  readonly repository?: string
34
35
  readonly self?: boolean
36
+ readonly stopped?: boolean
35
37
  }
36
38
 
39
+ type ListedSession = ResolvedSession & StopReading
40
+
37
41
  const REASONS: Record<string, string> = {
38
42
  'not-a-repository': 'working outside any git repository',
39
43
  'detached-head': 'detached HEAD, so the worktree holds no branch name',
@@ -67,6 +71,7 @@ export function register(program: Command): void {
67
71
  '--self',
68
72
  "Report the caller's own row, and refuse where the roster holds none",
69
73
  )
74
+ .option('--stopped', 'Report only the sessions a usage limit stopped')
70
75
  .addHelpText(
71
76
  'after',
72
77
  [
@@ -161,12 +166,30 @@ export function register(program: Command): void {
161
166
  'framed listing renders the same dwell beside the status, at the',
162
167
  'coarsest unit that keeps it a whole number.',
163
168
  '',
169
+ 'Each row also carries "stopped" and "stopResets", read from the tail of',
170
+ 'the session transcript. "stopped" is "usage-limit" when the last',
171
+ 'assistant entry is a limit stop, passing over a synthetic entry with no',
172
+ 'error, such as the one a client restart writes. "stopResets" holds the',
173
+ 'text the client wrote after "resets", or null when it wrote none.',
174
+ '"stopped" is "unknown" when the transcript could not be read or a',
175
+ 'limit-worded entry has another shape, so the row may be stopped, and',
176
+ 'null otherwise. A "busy" row reads',
177
+ 'null without opening its transcript, since a turn is running.',
178
+ '',
179
+ '--stopped keeps the "usage-limit" rows after --branch and --self apply.',
180
+ 'It drops "unknown" rows, and "stopsUnread" counts them before it does,',
181
+ 'so a count above zero means a row here may be stopped and is not listed.',
182
+ '"foreignDead" above zero leaves the same doubt about a session this',
183
+ 'reader could not check, and an empty result says so on stderr.',
184
+ 'An empty result exits 0. "claimed" still reads the whole roster.',
185
+ '',
164
186
  'Examples:',
165
187
  ' canon sessions list',
166
188
  ' canon sessions list --json',
167
189
  ' canon sessions list --branch feat/parser --json',
168
190
  ' canon sessions list --branch chore/agents --repository ../caret --json',
169
191
  ' canon sessions list --self --json',
192
+ ' canon sessions list --stopped --json',
170
193
  '',
171
194
  ].join('\n'),
172
195
  )
@@ -477,15 +500,37 @@ async function runList(opts: ListCommandOptions): Promise<number> {
477
500
  ? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
478
501
  : null
479
502
 
503
+ const read: readonly ListedSession[] = shown.map((session) => ({
504
+ ...session,
505
+ ...readStop(session),
506
+ }))
507
+ const stopsUnread = read.filter(
508
+ (session) => session.stopped === 'unknown',
509
+ ).length
510
+ const listed = opts.stopped
511
+ ? read.filter((session) => session.stopped === 'usage-limit')
512
+ : read
513
+
480
514
  intro('canon sessions list')
481
- reportConfidence(shown, report)
515
+ reportConfidence(listed, report)
482
516
  reportSessions(
483
- shown,
517
+ listed,
484
518
  opts.branch,
485
519
  repository,
486
520
  own !== null,
487
521
  report.foreignDead,
522
+ opts.stopped ? stopsUnread : null,
488
523
  )
524
+ if (opts.stopped && stopsUnread > 0) {
525
+ logWarn(
526
+ `${plural(stopsUnread, 'row')} could not be read and may be stopped, so this list can be short.`,
527
+ )
528
+ }
529
+ if (opts.stopped && own === null && report.foreignDead > 0) {
530
+ logWarn(
531
+ `${report.foreignDead} records from another pid namespace have no answering socket, so a stopped session may be missing from this list.`,
532
+ )
533
+ }
489
534
  if (claim) reportClaim(claim)
490
535
  outro()
491
536
 
@@ -494,8 +539,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
494
539
  `${JSON.stringify({
495
540
  dir: report.dir,
496
541
  // Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
497
- confidence: confidenceOf(shown),
542
+ confidence: confidenceOf(listed),
498
543
  foreignDead: report.foreignDead,
544
+ stopsUnread,
499
545
  branch: opts.branch ?? null,
500
546
  repository,
501
547
  worktree: claim?.worktree ?? null,
@@ -503,7 +549,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
503
549
  claimed: claim?.claimed ?? null,
504
550
  sessionsReadable: claim?.sessionsReadable ?? null,
505
551
  refsReadable: claim?.refsReadable ?? null,
506
- sessions: shown,
552
+ sessions: listed,
507
553
  })}\n`,
508
554
  )
509
555
  }
@@ -578,11 +624,12 @@ function reportConfidence(
578
624
  }
579
625
 
580
626
  function reportSessions(
581
- sessions: readonly ResolvedSession[],
627
+ sessions: readonly ListedSession[],
582
628
  branch: string | undefined,
583
629
  repository: string | null,
584
630
  scoped: boolean,
585
631
  foreignDead: number,
632
+ stopsUnread: number | null,
586
633
  ): void {
587
634
  logStep('Sessions')
588
635
 
@@ -596,6 +643,11 @@ function reportSessions(
596
643
  // was narrowed to one row before the branch filter ran. Reporting the wider
597
644
  // answer there would claim a reading this run never took.
598
645
  if (sessions.length === 0) {
646
+ if (stopsUnread !== null) {
647
+ logInfo(noneStopped(branch, scoped, foreignDead, stopsUnread))
648
+ return
649
+ }
650
+
599
651
  if (branch) {
600
652
  logInfo(
601
653
  scoped
@@ -630,12 +682,42 @@ function reportSessions(
630
682
  `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
631
683
  const dwell = formatDwell(session.statusDwellMs)
632
684
  const status = dwell ? `${session.status} ${dwell}` : session.status
633
- return `${session.name} ${status} ${held}\n ${session.cwd}`
685
+ return [
686
+ `${session.name} ${status} ${held}`,
687
+ ` ${session.cwd}`,
688
+ ...stopLine(session),
689
+ ].join('\n')
634
690
  })
635
691
  .join('\n'),
636
692
  )
637
693
  }
638
694
 
695
+ /**
696
+ * Words an empty `--stopped` result by what was read, since "no session is
697
+ * stopped" is a claim about the machine that a row this run could not read, or
698
+ * a record it could not check, leaves open.
699
+ */
700
+ function noneStopped(
701
+ branch: string | undefined,
702
+ scoped: boolean,
703
+ foreignDead: number,
704
+ stopsUnread: number,
705
+ ): string {
706
+ if (stopsUnread > 0 || (!scoped && foreignDead > 0)) {
707
+ return 'No session this reader could check is stopped.'
708
+ }
709
+ if (scoped) return 'This session is not stopped.'
710
+ return branch
711
+ ? `No session holding ${branch} is stopped.`
712
+ : 'No session is stopped.'
713
+ }
714
+
715
+ function stopLine(session: StopReading): string[] {
716
+ if (session.stopped === null) return []
717
+ const resets = session.stopResets ? `, resets ${session.stopResets}` : ''
718
+ return [` stopped: ${session.stopped}${resets}`]
719
+ }
720
+
639
721
  /**
640
722
  * Renders the dwell at the coarsest unit that keeps it a whole number, since a
641
723
  * reader scanning a roster wants an age at a glance rather than a millisecond
@@ -72,6 +72,7 @@ import {
72
72
  type ValidateOutcome,
73
73
  validateBoard,
74
74
  } from '@/tasks/validate'
75
+ import { liveReaders } from '@/tasks/holders'
75
76
  import {
76
77
  intro,
77
78
  logAdd,
@@ -110,6 +111,7 @@ interface DeclineCommandOptions {
110
111
  interface ValidateCommandOptions {
111
112
  readonly json?: boolean
112
113
  readonly root?: string
114
+ readonly live?: boolean
113
115
  }
114
116
 
115
117
  interface CitationsCommandOptions {
@@ -323,6 +325,10 @@ export function register(program: Command): void {
323
325
  .helpOption('-h, --help', 'Show this help message')
324
326
  .option('--json', 'Add a machine-readable record on stdout')
325
327
  .option('--root <path>', 'Board root, defaulting to the main worktree')
328
+ .option(
329
+ '--live',
330
+ 'Also read the session roster and open pull requests for held rows',
331
+ )
326
332
  .addHelpText(
327
333
  'after',
328
334
  [
@@ -338,6 +344,12 @@ export function register(program: Command): void {
338
344
  ` no Waiting on cell renders past ${WAITING_LIMIT} characters`,
339
345
  ' a blocker citing a bare vNNN.N label settles like a linked one',
340
346
  ' a file two Run now rows share is listed, never failed',
347
+ ' with --live, no Up next or Needs a plan row is held by a live session',
348
+ " or an open pull request on its plan's branch",
349
+ '',
350
+ 'The `live` field states each --live source as read, unread, or not-asked.',
351
+ 'A source that fails reads as unread and moves no exit code, so read that',
352
+ 'field rather than taking exit 0 as clean. A run without --live reads neither.',
341
353
  '',
342
354
  'Refusals, read off `reason`:',
343
355
  ' no-board the repository holds no `.canon/tasks/` board',
@@ -356,6 +368,7 @@ export function register(program: Command): void {
356
368
  'Examples:',
357
369
  ' canon tasks validate',
358
370
  ' canon tasks validate --json',
371
+ ' canon tasks validate --live --json',
359
372
  '',
360
373
  ].join('\n'),
361
374
  )
@@ -1911,7 +1924,10 @@ function reportNextLabel(
1911
1924
 
1912
1925
  async function runValidate(opts: ValidateCommandOptions): Promise<number> {
1913
1926
  const root = opts.root ?? (await mainWorktreeRoot())
1914
- const outcome = await validateBoard(root)
1927
+ const outcome = await validateBoard(
1928
+ root,
1929
+ opts.live ? { live: liveReaders(root) } : {},
1930
+ )
1915
1931
 
1916
1932
  return reportValidation(outcome, opts.json ?? false, root)
1917
1933
  }
@@ -2390,8 +2406,30 @@ function reportValidation(
2390
2406
  } else {
2391
2407
  for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
2392
2408
  }
2393
- outro()
2409
+
2410
+ if (outcome.live.sessions !== 'not-asked') {
2411
+ logStep('Held rows')
2412
+ if (
2413
+ outcome.live.sessions === 'read' &&
2414
+ outcome.live.pulls === 'read' &&
2415
+ !outcome.findings.some((finding) => finding.kind === 'row-held')
2416
+ ) {
2417
+ logInfo('no row outside run now is held by a session or a pull request')
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+ // Unread is a warning in both modes, since diagnostics reach stderr with --json too.
2423
+ if (outcome.live.sessions === 'unread') {
2424
+ logWarn('the session roster could not be read, so no session was checked')
2394
2425
  }
2426
+ if (outcome.live.pulls === 'unread') {
2427
+ logWarn(
2428
+ 'pull requests could not be read, so a hold may be missed or already merged',
2429
+ )
2430
+ }
2431
+
2432
+ if (!emitJson) outro()
2395
2433
 
2396
2434
  if (emitJson) {
2397
2435
  process.stdout.write(
@@ -2408,6 +2446,7 @@ function reportValidation(
2408
2446
  shared: outcome.shared,
2409
2447
  wide: outcome.wide,
2410
2448
  unplaced: outcome.unplaced,
2449
+ live: outcome.live,
2411
2450
  })}\n`,
2412
2451
  )
2413
2452
  }
@@ -37,6 +37,7 @@ import {
37
37
  import { scan, type ScanResult } from '@/tooling/scan'
38
38
  import { recordToolingChain } from '@/tooling/stamp'
39
39
  import { subfolderPath } from '@/tooling/subfolder'
40
+ import { scaffoldStack } from '@/tooling/scaffold'
40
41
  import { verifyStack } from '@/tooling/verify-stack'
41
42
  import {
42
43
  intro,
@@ -377,6 +378,77 @@ export function register(program: Command): void {
377
378
  })
378
379
  process.exitCode = outcome.exitCode
379
380
  })
381
+
382
+ tooling
383
+ .command('scaffold')
384
+ .description("Run a stack's starter command into an empty folder")
385
+ .argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
386
+ .argument('[target]', 'Target directory, created when missing', '.')
387
+ .helpOption('-h, --help', 'Show this help message')
388
+ .option('--json', 'Add a machine-readable record on stdout')
389
+ .addHelpText(
390
+ 'after',
391
+ [
392
+ '',
393
+ "Runs the stack's manifest scaffold in a scratch folder, with the",
394
+ "target's folder name as the project name, then moves the result into",
395
+ 'the target. A target holding anything but .git is refused, and a',
396
+ 'failed command moves nothing and leaves the scratch folder named.',
397
+ 'Run `canon init` or the target-setup skill afterward to sync tooling.',
398
+ '',
399
+ 'Exit codes:',
400
+ ' 0 scaffolded',
401
+ ' 1 refused: excluded, no-manifest, no-scaffold, not-empty, bad-name,',
402
+ ' scaffold-failed, or no-output',
403
+ '',
404
+ 'Examples:',
405
+ ' canon tooling scaffold vite-react',
406
+ ' canon tooling scaffold astro my-site --json',
407
+ '',
408
+ ].join('\n'),
409
+ )
410
+ .action(async (stack: string, target: string, opts: ListOptions) => {
411
+ process.exitCode = await runScaffold(stack, target, opts)
412
+ })
413
+ }
414
+
415
+ /**
416
+ * A `--json` run writes no frame, so a skill reads the record alone, and a
417
+ * refusal carries its reason on stdout beside the exit 1.
418
+ */
419
+ async function runScaffold(
420
+ stack: string,
421
+ target: string,
422
+ opts: ListOptions,
423
+ ): Promise<number> {
424
+ const json = opts.json === true
425
+ if (!json) intro(`canon tooling scaffold ${stack}`)
426
+
427
+ const result = await scaffoldStack({ root: PROJECT_ROOT, stack, target })
428
+
429
+ if (!result.ok) {
430
+ logError(result.message)
431
+ if (result.scratch !== undefined) {
432
+ if (result.output !== undefined && result.output !== '') {
433
+ process.stderr.write(
434
+ `${result.output.split('\n').slice(-20).join('\n')}\n`,
435
+ )
436
+ }
437
+ logInfo(`Kept ${result.scratch}`)
438
+ }
439
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`)
440
+ else outro()
441
+ return 1
442
+ }
443
+
444
+ if (json) {
445
+ process.stdout.write(`${JSON.stringify(result)}\n`)
446
+ return 0
447
+ }
448
+ logStep(`Scaffolded ${result.target}`)
449
+ for (const file of result.files) logAdd(file)
450
+ outroSuccess('Scaffolded', `Next: canon init ${target}`)
451
+ return 0
380
452
  }
381
453
 
382
454
  /**