@erclx/canon 4.73.0 → 4.75.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 (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/create-skill/SKILL.md +0 -1
  3. package/claude/skills/docs-fold/SKILL.md +1 -1
  4. package/claude/skills/git-followup/SKILL.md +9 -5
  5. package/claude/skills/git-pr/SKILL.md +21 -0
  6. package/claude/skills/markdown-propose/REQUIREMENT.md +1 -1
  7. package/claude/skills/markdown-propose/SKILL.md +9 -7
  8. package/claude/skills/markdown-propose/references/format.md +5 -3
  9. package/claude/skills/plan-feature/SKILL.md +3 -3
  10. package/claude/skills/plan-groundwork/SKILL.md +6 -5
  11. package/claude/skills/plan-intake/SKILL.md +1 -1
  12. package/claude/skills/review-pr/SKILL.md +2 -2
  13. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -1
  15. package/claude/skills/role-orchestrator/scripts/poll.sh +16 -10
  16. package/claude/skills/task-board/SKILL.md +37 -3
  17. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -6
  18. package/docs/agents/commands.md +2 -0
  19. package/docs/agents/index.md +2 -1
  20. package/docs/agents/install-and-sync.md +7 -4
  21. package/docs/agents/pr-evidence.md +108 -0
  22. package/docs/agents/records.md +29 -9
  23. package/docs/agents/tasks.md +2 -0
  24. package/docs/target-projects.md +4 -0
  25. package/governance/rules/ui/440-surface-capture.md +1 -0
  26. package/package.json +1 -1
  27. package/src/commands/design.ts +10 -2
  28. package/src/commands/pr.ts +220 -0
  29. package/src/commands/records.ts +138 -0
  30. package/src/commands/transcripts.ts +20 -3
  31. package/src/design/base.css +59 -0
  32. package/src/design/components.ts +80 -50
  33. package/src/design/fonts.ts +21 -0
  34. package/src/init/plan.ts +8 -1
  35. package/src/init/steps.ts +17 -0
  36. package/src/intake/folder.ts +1 -1
  37. package/src/pr/evidence.ts +171 -0
  38. package/src/records/backup.ts +110 -38
  39. package/src/records/ordinal.ts +243 -0
  40. package/src/records/validate.ts +28 -0
  41. package/src/tasks/answers.ts +10 -1
  42. package/src/teach/fonts.ts +9 -11
  43. package/src/transcripts/fetch.ts +31 -1
  44. package/standards/architecture.md +1 -0
  45. package/standards/figures.md +51 -0
  46. package/standards/groundwork.md +2 -1
  47. package/standards/index.md +1 -0
  48. package/standards/intake.md +2 -1
  49. package/standards/plan.md +1 -0
  50. package/standards/skill.md +1 -1
  51. package/tooling/base/configs/.husky/post-merge +27 -0
  52. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +5 -4
@@ -0,0 +1,108 @@
1
+ ---
2
+ title: The pull request evidence comparison
3
+ description: What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, and why the comparison anchors at the merge base rather than the previous push
4
+ ---
5
+
6
+ # The pull request evidence comparison
7
+
8
+ `canon pr evidence` renders one comment naming every changed image under an
9
+ `evidence/` path segment, comparing each against the pull request's merge base
10
+ with the trunk. `git-pr` posts it when a pull request opens or is edited, and
11
+ `git-followup` posts it again after every later push, so a reviewer never has
12
+ to open Files Changed to see what a case looked like before and after.
13
+
14
+ ```bash
15
+ canon pr evidence
16
+ canon pr evidence 1341 --json
17
+ ```
18
+
19
+ ## Why the comparison anchors at the merge base
20
+
21
+ The comparison is always base against the current head, never the previous
22
+ push against the new one. A follow-up only ever moves the after side of the
23
+ comment, so what the comment claims can only grow to match what the branch
24
+ actually shows, and a reviewer who opens it midway through a review round
25
+ never reads a stale before image describing an intermediate commit nobody is
26
+ looking at anymore.
27
+
28
+ ## What counts as evidence
29
+
30
+ A changed path qualifies when one of its segments is literally `evidence` and
31
+ its filename carries an image extension (`png`, `jpg`, `jpeg`, `gif`, `webp`,
32
+ `avif`, `svg`). The second half of that test exists because an `evidence/`
33
+ folder holds whatever else a project keeps beside its captures. This
34
+ repository's own tree carries eight `.md` files, two `.sh` scripts, a `.tsv`,
35
+ a `.json`, and an `.html` file against a single `.png`, and every one of them
36
+ would render as a broken `![]()` embed without the extension filter.
37
+
38
+ ## What the record carries
39
+
40
+ The record groups every evidence path by state, the remainder of its
41
+ directory under the `evidence/` segment, and within a state by filename stem,
42
+ so a `before/hero.png` and an `after/hero.png` naming the same case group as
43
+ one entry rather than two unrelated files. Each entry carries whether it
44
+ existed at the base commit, which decides whether the comment shows a base
45
+ image or marks the case new.
46
+
47
+ `reason` on the record is what a caller branches on, not the exit code:
48
+
49
+ | Reason | What it means |
50
+ | -------------------- | ----------------------------------------------------------------------------- |
51
+ | `ok` | A body was rendered. `commentId` is set when a marked comment already exists. |
52
+ | `no-evidence` | Nothing in the diff carries an `evidence/` segment. An ordinary silent no-op. |
53
+ | `gh-missing` | `gh` is not on the path, so no pull request could be resolved. |
54
+ | `gh-failed` | `gh` could not answer for this repository or branch. |
55
+ | `no-branch` | The pull request carries no head branch name. |
56
+ | `no-object-head` | The pull request object reported no head commit. |
57
+ | `no-base` | No base resolves against the trunk. |
58
+ | `unreadable-tree` | git could not read the tree at the base commit. |
59
+ | `unreadable-changes` | git could not list what this branch changed. |
60
+
61
+ `no-evidence` is not a refusal a caller reports. A project on a stack that
62
+ carries no evidence path, such as `base` or `python`, hits this reason on
63
+ every pull request and posts nothing, which is the correct behavior rather
64
+ than a gap.
65
+
66
+ ## What `[number]` selects, and what it does not
67
+
68
+ Naming a number picks which pull request the rendered body claims to
69
+ describe: its head commit and, when one already exists, the marked comment to
70
+ edit in place. The base and the changed set are always read from the local
71
+ checkout's own history, `git diff` and `git ls-tree` against the merge base
72
+ with the trunk, rather than fetched for the named pull request over the API.
73
+ That is correct for `git-pr` and `git-followup`, which never pass a number
74
+ and always run from the worktree building the branch, so the local checkout
75
+ and the named pull request describe the same branch. Naming a number for a
76
+ pull request built somewhere else compares this checkout's own diff against a
77
+ head commit that describes a different one.
78
+
79
+ ## One comment, found by its own marker
80
+
81
+ Every body this verb renders ends with a trailing marker naming the head it
82
+ describes:
83
+
84
+ ```markdown
85
+ <!-- pr-evidence: head=<sha> -->
86
+ ```
87
+
88
+ `canon pr evidence` reads that marker back off every comment on the pull
89
+ request, the same way `canon pr review-state` reads its own marker off a
90
+ review body, and reports the REST id of whichever comment carries it as
91
+ `commentId`. A caller with no `commentId` posts a new comment. A caller
92
+ holding one edits that comment in place with a `PATCH` rather than posting a
93
+ second one.
94
+
95
+ Putting the lookup here, in the one place both `git-pr` and `git-followup`
96
+ call, is what keeps two close-out comments from landing beside each other the
97
+ way `review-pr` once posted, before that skill's own guard existed. It is
98
+ also why this verb renders the whole comment body rather than handing each
99
+ skill a record to format on its own: a fix to the table shape or the
100
+ collapsed-details wrapper lands once, not twice.
101
+
102
+ ## What a collapsed comment still leaves to GitHub
103
+
104
+ GitHub already draws its own before-and-after comparison on the Files Changed
105
+ tab for any tracked image that changed. This comment does not make that view
106
+ redundant. The diff view shows what moved between two commits a reader has
107
+ already opened, and the comment is what lets a reader see the same comparison
108
+ without opening it at all.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Records
3
- description: The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
3
+ description: The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, claiming the ordinal intake and groundwork share, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
4
4
  ---
5
5
 
6
6
  # Records
@@ -39,14 +39,14 @@ Nothing fires it automatically. The five record folders are gitignored, so the s
39
39
 
40
40
  ### What each kind checks
41
41
 
42
- | Kind | What it reports |
43
- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | `plans` | A filename that is not `feature-<slug>.md`, a missing `# Feature:` heading, a missing required section, a files-to-touch entry naming no file or saying nothing about one, and a question carrying no suggestion or no answer slot |
45
- | `groundwork` | A track with no `README.md` or no `01-current-state.md`, a file missing `title` or `description`, a `README.md` with no `date` as `YYYY-MM-DD`, an unnumbered file, and a track holding a decision without its handoff or the reverse |
46
- | `intake` | A dump with no `00-overview.md`, the same frontmatter and numbering checks, an item missing any of `Problem`, `Fix`, `Worth it`, or `You`, and an item carrying `Open` with no `Suggested` |
47
- | `memory` | A filename whose prefix names none of the four types, an entry missing `title`, `description`, or `category`, a `category` disagreeing with that prefix, a title repeating the filename, and a rule-bearing body missing a part |
48
- | `standards` | A standard missing `title` or `description`, an absent `## Scope` section, a scope section carrying no `Does not govern:` list, a statement anchoring nothing, and a filename naming no part of the path the statement governs |
49
- | `teach` | A workspace folder carrying no two-digit ordinal, an absent `MISSION.md`, `RESOURCES.md`, or `GLOSSARY.md`, a file missing `title` or `description`, a mission with no `date` as `YYYY-MM-DD` or no `## Success looks like` section, an unnumbered learning record, and a reference page opening with an ordinal |
42
+ | Kind | What it reports |
43
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | `plans` | A filename that is not `feature-<slug>.md`, a missing `# Feature:` heading, a missing required section, a files-to-touch entry naming no file or saying nothing about one, a question carrying no suggestion or no answer slot, and a batch still staged with a `**Batch N**` sub-heading inside the one file rather than split into its own plan |
45
+ | `groundwork` | A track with no `README.md` or no `01-current-state.md`, a file missing `title` or `description`, a `README.md` with no `date` as `YYYY-MM-DD`, an unnumbered file, and a track holding a decision without its handoff or the reverse |
46
+ | `intake` | A dump with no `00-overview.md`, the same frontmatter and numbering checks, an item missing any of `Problem`, `Fix`, `Worth it`, or `You`, and an item carrying `Open` with no `Suggested` |
47
+ | `memory` | A filename whose prefix names none of the four types, an entry missing `title`, `description`, or `category`, a `category` disagreeing with that prefix, a title repeating the filename, and a rule-bearing body missing a part |
48
+ | `standards` | A standard missing `title` or `description`, an absent `## Scope` section, a scope section carrying no `Does not govern:` list, a statement anchoring nothing, and a filename naming no part of the path the statement governs |
49
+ | `teach` | A workspace folder carrying no two-digit ordinal, an absent `MISSION.md`, `RESOURCES.md`, or `GLOSSARY.md`, a file missing `title` or `description`, a mission with no `date` as `YYYY-MM-DD` or no `## Success looks like` section, an unnumbered learning record, and a reference page opening with an ordinal |
50
50
 
51
51
  The half-closed track is the groundwork check a reader cannot run by eye. A folder holding `06` without `07` reads as closed to anyone scanning filenames while the file a returning session actually opens is absent.
52
52
 
@@ -104,6 +104,26 @@ A transform runs only where the missing value is recoverable from the file itsel
104
104
 
105
105
  Exit codes: `0` nothing carried a known transform, or `--write` repaired everything it found. `1` refused for a reason `validate` shares, every candidate it found failed to repair, or `--write` repaired only some of them. `2` a record carries a known transform and `--write` was not passed.
106
106
 
107
+ ## Ordinal
108
+
109
+ `canon records ordinal <kind> <slug>` reports the next ordinal `intake` and `groundwork` share, or claims it with `--claim`. The two kinds share one sequence, per `standards/intake.md` and `standards/groundwork.md`, so this reads both `.canon/intake/` and `.canon/groundwork/` regardless of which kind was asked for.
110
+
111
+ ```bash
112
+ canon records ordinal intake my-topic
113
+ canon records ordinal groundwork my-topic --claim
114
+ canon records ordinal groundwork my-topic --claim --json
115
+ ```
116
+
117
+ | Option | Behavior |
118
+ | --------------- | ------------------------------------------------------ |
119
+ | `--json` | Add a machine-readable record on stdout |
120
+ | `--claim` | Create the folder atomically instead of only reporting |
121
+ | `--root <path>` | Project root, defaulting to the main worktree |
122
+
123
+ Without `--claim` this only reports, so two sessions reading at once can still report the same number, which is what let two sessions open two different record folders under one ordinal on the same day. `--claim` closes that: it reserves the number at a path both an `intake` claim and a `groundwork` claim resolve to identically, whichever kind is asking, and only creates the kind's own `<nn>-<slug>/` folder once that reservation is won. A losing reservation is retried against a freshly read ordinal rather than reported as a collision, bounded to five attempts before refusing as `ordinal-contended`.
124
+
125
+ Exit codes: `0` reported the next ordinal, or `--claim` created the folder. `1` refused, `unknown-kind` when the argument names neither `intake` nor `groundwork`. `2` `--claim` lost every retry to a collision.
126
+
107
127
  ## Size
108
128
 
109
129
  `canon records size` reports what each record folder holds and how much of it is recent. It reads the ten backed folders named under Push and pull, plus the scratch folder, and it gates nothing.
@@ -141,6 +141,8 @@ A relative path resolves against the project root first and against `.canon/task
141
141
 
142
142
  `canon tasks plan-citations` reads a task's `Plan:` line against those same two bases in the opposite order, and tests that the target lands under the live plans folder, which this verb does not. Both answer the same file for every spelling a board writes. Liveness is a separate refusal here: a plan resolving inside `.canon/plans/archive/` returns `archived` rather than a launchable reading, since it answers every question and describes work that already shipped.
143
143
 
144
+ A plan still staging its batches with a `**Batch N**` sub-heading inside one file's `**Files to touch:**` reads `launchable: false` the same way, alongside the operator-call case. It reports as an entry in `open` labeled `Batch staging`, stating that the plan must split into one file per batch before it can dispatch, since the row waits on a split rather than on the operator.
145
+
144
146
  | Option | Effect |
145
147
  | --------------- | ------------------------------------------- |
146
148
  | `--json` | Emit a machine-readable record on stdout |
@@ -143,6 +143,10 @@ The first line takes the `.canon/` ignore entry, and the verb refuses until the
143
143
 
144
144
  Order matters between the first line and the two under it. The sync prunes the twelve old `.claude/` ignore entries down to the one `.canon/` line, which leaves every record still at the old root visible to git and therefore to the verb. The sweep passes over them on purpose, reporting a count of what it left alone rather than reading the memory pen and the groundwork trails as source.
145
145
 
146
+ ### Back records up off this machine, once
147
+
148
+ Moving records under `.canon/` relocates them, and relocating them is not the same as backing them up. Run `canon records push` once a private repository exists for them, and it prints the one-time setup command when it finds none, since the task board, the memory pen, and the groundwork trails otherwise live on one disk with nothing pushing them off it. `canon init` prints the same reminder as a notice-only step rather than trying to run this non-interactively, since the private repository does not exist yet at scaffold time.
149
+
146
150
  Read the `ok` field out of the `--json` record rather than the exit code. A shell profile that wraps `canon` in a function takes its status from whatever the function runs last, so an absent subcommand and a clean run can both exit 0, and a reader watching the exit alone concludes the move happened.
147
151
 
148
152
  A tracked file that names an old record path on purpose, such as prose dating a decision, keeps it by carrying `canon-keep-record-root` on that line or the nearest non-blank line above. The report pass prints every file it would rewrite, which is where to catch one before `--write` runs.
@@ -37,3 +37,4 @@ paths:
37
37
  - A project whose own established convention already commits that same comparison may route a flagged case's evidence there instead of a separate evidence folder. Only an established convention already committing the same comparison qualifies, not a preference for skipping a second folder.
38
38
  - Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
39
39
  - Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
40
+ - `git-pr` and `git-followup` maintain a comparison comment on the pull request automatically once a case is committed, so a reviewer never has to open Files Changed to see it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.73.0",
4
+ "version": "4.75.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -7,6 +7,7 @@ import {
7
7
  DESIGN_PROJECT_SUBDIR,
8
8
  } from '@/design/adapter'
9
9
  import { buildDesignCss } from '@/design/css'
10
+ import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
10
11
  import { renderDesignDoc } from '@/design/render'
11
12
  import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
12
13
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
@@ -80,9 +81,16 @@ export function register(program: Command): void {
80
81
  '--no-components',
81
82
  'Custom properties only, without the component rules',
82
83
  )
83
- .action((opts: { components: boolean }) => {
84
+ .option(
85
+ '--figures',
86
+ 'Embed the hand-drawn figure fonts (Virgil, Excalifont), which the unconditional component styling never carries on its own',
87
+ )
88
+ .action((opts: { components: boolean; figures?: boolean }) => {
84
89
  process.stdout.write(
85
- buildDesignCss(undefined, { components: opts.components }),
90
+ buildDesignCss(undefined, {
91
+ components: opts.components,
92
+ embedFonts: opts.figures ? HAND_DRAWN_FONT_FACES : undefined,
93
+ }),
86
94
  )
87
95
  })
88
96
 
@@ -20,6 +20,11 @@ import {
20
20
  treeRoots,
21
21
  } from '@/pr/bijection'
22
22
  import { type CheckRunListing, collapseChecks } from '@/pr/checks'
23
+ import {
24
+ findEvidenceCommentId,
25
+ groupEvidence,
26
+ renderEvidenceBody,
27
+ } from '@/pr/evidence'
23
28
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
24
29
  import { KEY_CHANGES } from '@/pr/paths'
25
30
  import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
@@ -81,6 +86,27 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
81
86
  'The reviews on this pull request could not be read. An empty answer here would report a reviewed pull request as never reviewed, which routes the next pass to the whole change, so nothing is reported.',
82
87
  }
83
88
 
89
+ /**
90
+ * Why `canon pr evidence` produced no comment body, past the ones
91
+ * `readIdentity` already owns (`gh-missing`, `gh-failed`, `no-branch`) and the
92
+ * one `identity.head` owns (`no-object-head`).
93
+ */
94
+ type EvidenceRefusal =
95
+ | 'gh-failed'
96
+ | 'no-base'
97
+ | 'unreadable-tree'
98
+ | 'unreadable-changes'
99
+
100
+ const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
101
+ 'gh-failed':
102
+ 'gh could not answer for this repository. Name the pull request number.',
103
+ 'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
104
+ 'unreadable-tree':
105
+ 'git could not read the tree at the base commit, so no path could be judged added or changed.',
106
+ 'unreadable-changes':
107
+ 'git could not list what this branch changed, so the set is unknown.',
108
+ }
109
+
84
110
  /** Why the read produced no comparison, ahead of the ones the compare owns. */
85
111
  type SourceRefusal =
86
112
  | 'gh-missing'
@@ -289,6 +315,46 @@ export function register(program: Command): void {
289
315
  .action(async (number: string | undefined, opts: ReadOptions) => {
290
316
  process.exitCode = await runReviewState(number, opts)
291
317
  })
318
+
319
+ pr.command('evidence')
320
+ .description(
321
+ "Render a before/after comparison for the pull request's changed evidence images",
322
+ )
323
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
324
+ .helpOption('-h, --help', 'Show this help message')
325
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
326
+ .option('--json', 'Add a machine-readable record on stdout')
327
+ .addHelpText(
328
+ 'after',
329
+ [
330
+ '',
331
+ 'Compares the merge base with the trunk against the current head, never',
332
+ 'the previous push against the new one, so the comment never claims more',
333
+ 'than the branch currently shows. A path counts as evidence when one of',
334
+ 'its segments is literally `evidence` and the filename carries an image',
335
+ 'extension (png, jpg, jpeg, gif, webp, avif, svg). A README, a capture',
336
+ 'script, or a raw data file kept beside the images is left out rather',
337
+ 'than rendered as a broken embed.',
338
+ '',
339
+ 'Read `reason` on the JSON record before posting anything:',
340
+ ' ok a body was rendered, with `commentId` set when a marked',
341
+ ' comment already exists and should be edited in place',
342
+ ' no-evidence nothing in the diff carries an evidence/ segment, which',
343
+ ' is an ordinary, silent no-op rather than a refusal',
344
+ '',
345
+ 'Exit codes:',
346
+ ' 0 read, whether it produced a body or reported no-evidence',
347
+ ' 1 refused, with the reason on stderr or in the JSON record',
348
+ '',
349
+ 'Examples:',
350
+ ' canon pr evidence --json',
351
+ ' canon pr evidence 1341 --json',
352
+ '',
353
+ ].join('\n'),
354
+ )
355
+ .action(async (number: string | undefined, opts: ReadOptions) => {
356
+ process.exitCode = await runEvidence(number, opts)
357
+ })
292
358
  }
293
359
 
294
360
  interface PullRequestRead {
@@ -1019,6 +1085,160 @@ async function runReviewState(
1019
1085
  return 0
1020
1086
  }
1021
1087
 
1088
+ /**
1089
+ * Every path `git ls-tree` reports for `ref`, or undefined when the read
1090
+ * failed. One call for the whole tree rather than one `cat-file -e` per
1091
+ * evidence path, since existence at base is checked once per changed path and
1092
+ * a batch read is one round trip instead of many.
1093
+ */
1094
+ async function listTreePaths(
1095
+ root: string,
1096
+ ref: string,
1097
+ ): Promise<Set<string> | undefined> {
1098
+ const result = await $`git -C ${root} ls-tree -r --name-only ${ref}`
1099
+ .env(gitEnv())
1100
+ .quiet()
1101
+ .nothrow()
1102
+ if (result.exitCode !== 0) return undefined
1103
+ return new Set(result.text().split('\n').filter(Boolean))
1104
+ }
1105
+
1106
+ async function runEvidence(
1107
+ number: string | undefined,
1108
+ opts: ReadOptions,
1109
+ ): Promise<number> {
1110
+ const root = resolve(opts.root ?? process.cwd())
1111
+ const emitJson = opts.json ?? false
1112
+
1113
+ intro('canon pr evidence')
1114
+
1115
+ const read = await readIdentity(root, number)
1116
+ if (read.kind === 'refused') {
1117
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
1118
+ }
1119
+
1120
+ const { identity } = read
1121
+ if (identity.head === undefined) {
1122
+ return refuseWith(
1123
+ 'no-object-head',
1124
+ PULL_REFUSALS['no-object-head'],
1125
+ emitJson,
1126
+ root,
1127
+ )
1128
+ }
1129
+
1130
+ const base = await resolveBaseRef(root)
1131
+ if (base === undefined) {
1132
+ return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1133
+ }
1134
+
1135
+ const [changed, baseTree] = await Promise.all([
1136
+ listChangedFiles(root, base),
1137
+ listTreePaths(root, base),
1138
+ ])
1139
+ if (changed === undefined) {
1140
+ return refuseWith(
1141
+ 'unreadable-changes',
1142
+ EVIDENCE_REFUSALS['unreadable-changes'],
1143
+ emitJson,
1144
+ root,
1145
+ )
1146
+ }
1147
+ if (baseTree === undefined) {
1148
+ return refuseWith(
1149
+ 'unreadable-tree',
1150
+ EVIDENCE_REFUSALS['unreadable-tree'],
1151
+ emitJson,
1152
+ root,
1153
+ )
1154
+ }
1155
+
1156
+ const grouped = await groupEvidence(changed, async (path) =>
1157
+ baseTree.has(path),
1158
+ )
1159
+
1160
+ if (grouped.kind === 'refused') {
1161
+ logStep('Skipped')
1162
+ logInfo(
1163
+ 'No changed path carries an evidence/ segment, so there is nothing to post.',
1164
+ )
1165
+ outro()
1166
+ if (emitJson) {
1167
+ process.stdout.write(
1168
+ `${JSON.stringify({
1169
+ root,
1170
+ ...(identity.number !== undefined && { number: identity.number }),
1171
+ reason: 'no-evidence',
1172
+ })}\n`,
1173
+ )
1174
+ }
1175
+ return 0
1176
+ }
1177
+
1178
+ const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1179
+ const repo =
1180
+ repoRow === null
1181
+ ? undefined
1182
+ : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1183
+
1184
+ if (repo === undefined) {
1185
+ return refuseWith(
1186
+ 'gh-failed',
1187
+ EVIDENCE_REFUSALS['gh-failed'],
1188
+ emitJson,
1189
+ root,
1190
+ )
1191
+ }
1192
+
1193
+ const body = renderEvidenceBody(grouped.states, repo, base, identity.head)
1194
+
1195
+ let commentId: number | undefined
1196
+ if (identity.number !== undefined) {
1197
+ const commentsRow = await gh(root, [
1198
+ 'pr',
1199
+ 'view',
1200
+ String(identity.number),
1201
+ '--json',
1202
+ 'comments',
1203
+ ])
1204
+ if (commentsRow !== null) {
1205
+ try {
1206
+ const parsed = JSON.parse(commentsRow) as {
1207
+ comments?: readonly { url?: string; body: string }[]
1208
+ }
1209
+ commentId = findEvidenceCommentId(parsed.comments ?? [])
1210
+ } catch {
1211
+ commentId = undefined
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ const caseCount = grouped.states.reduce((n, s) => n + s.items.length, 0)
1217
+
1218
+ logStep('Scope')
1219
+ logInfo(
1220
+ `${plural(caseCount, 'case')} across ${plural(grouped.states.length, 'state')}`,
1221
+ )
1222
+
1223
+ outro()
1224
+
1225
+ if (emitJson) {
1226
+ process.stdout.write(
1227
+ `${JSON.stringify({
1228
+ root,
1229
+ ...(identity.number !== undefined && { number: identity.number }),
1230
+ reason: 'ok',
1231
+ base,
1232
+ head: identity.head,
1233
+ body,
1234
+ ...(commentId !== undefined && { commentId }),
1235
+ })}\n`,
1236
+ )
1237
+ }
1238
+
1239
+ return 0
1240
+ }
1241
+
1022
1242
  /**
1023
1243
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
1024
1244
  * so an operator reading the terminal sees the reason rather than a command
@@ -3,6 +3,13 @@ import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
5
5
  import { migrateRecord } from '@/records/migrate'
6
+ import {
7
+ type ClaimOutcome,
8
+ claimOrdinal,
9
+ highestOrdinal,
10
+ isOrdinalKind,
11
+ ORDINAL_KINDS,
12
+ } from '@/records/ordinal'
6
13
  import {
7
14
  type FolderSize,
8
15
  formatBytes,
@@ -40,6 +47,9 @@ const EXIT_FINDINGS = 2
40
47
  /** Returned when a record carries a known transform and `--write` was not passed. */
41
48
  const EXIT_MIGRATABLE = 2
42
49
 
50
+ /** Returned when `--claim` loses every retry to a collision. */
51
+ const EXIT_CONTENDED = 2
52
+
43
53
  interface ValidateCommandOptions {
44
54
  readonly json?: boolean
45
55
  readonly root?: string
@@ -51,6 +61,10 @@ interface MigrateCommandOptions extends ValidateCommandOptions {
51
61
  readonly write?: boolean
52
62
  }
53
63
 
64
+ interface OrdinalCommandOptions extends ValidateCommandOptions {
65
+ readonly claim?: boolean
66
+ }
67
+
54
68
  export function register(program: Command): void {
55
69
  const records = program
56
70
  .command('records')
@@ -140,6 +154,49 @@ export function register(program: Command): void {
140
154
  process.exitCode = await runMigrate(kind, opts)
141
155
  })
142
156
 
157
+ records
158
+ .command('ordinal')
159
+ .description(
160
+ 'Report or claim the next ordinal shared by intake and groundwork folders',
161
+ )
162
+ .argument('<kind>', `Ordinal-bearing folder: ${ORDINAL_KINDS.join(', ')}`)
163
+ .argument('<slug>', 'The kebab-case slug the new folder will carry')
164
+ .helpOption('-h, --help', 'Show this help message')
165
+ .option('--json', 'Add a machine-readable record on stdout')
166
+ .option(
167
+ '--claim',
168
+ 'Create the folder atomically instead of only reporting the ordinal',
169
+ )
170
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
171
+ .addHelpText(
172
+ 'after',
173
+ [
174
+ '',
175
+ 'intake and groundwork folders share one ordinal sequence, so this reads',
176
+ 'both .canon/intake/ and .canon/groundwork/ regardless of which kind was',
177
+ 'asked for.',
178
+ '',
179
+ 'Exit codes:',
180
+ ' 0 reported the next ordinal, or --claim created the folder',
181
+ ' 1 refused, with the reason on stderr or in the JSON record',
182
+ ' 2 --claim lost every retry to a collision',
183
+ '',
184
+ 'Without --claim this only reports, so two sessions reading at once can',
185
+ 'still report the same number. --claim resolves that by creating the',
186
+ 'folder as part of the same act, retrying past a losing race rather than',
187
+ 'reporting one.',
188
+ '',
189
+ 'Examples:',
190
+ ' canon records ordinal intake my-topic',
191
+ ' canon records ordinal groundwork my-topic --claim',
192
+ ' canon records ordinal groundwork my-topic --claim --json',
193
+ '',
194
+ ].join('\n'),
195
+ )
196
+ .action(async (kind: string, slug: string, opts: OrdinalCommandOptions) => {
197
+ process.exitCode = await runOrdinal(kind, slug, opts)
198
+ })
199
+
143
200
  records
144
201
  .command('size')
145
202
  .description('Report what each record folder holds and how much is recent')
@@ -726,3 +783,84 @@ export function migrateExitCode(
726
783
  if (!write) return EXIT_MIGRATABLE
727
784
  return refused.length > 0 ? 1 : 0
728
785
  }
786
+
787
+ async function runOrdinal(
788
+ kind: string,
789
+ slug: string,
790
+ opts: OrdinalCommandOptions,
791
+ ): Promise<number> {
792
+ const emitJson = opts.json ?? false
793
+
794
+ if (!isOrdinalKind(kind)) {
795
+ return reportRefusal(
796
+ 'canon records ordinal',
797
+ {
798
+ reason: 'unknown-kind',
799
+ message: `Not an ordinal-bearing kind: ${kind}. Expected one of: ${ORDINAL_KINDS.join(', ')}.`,
800
+ },
801
+ emitJson,
802
+ )
803
+ }
804
+
805
+ const root = opts.root ?? (await mainWorktreeRoot())
806
+ const claim = opts.claim ?? false
807
+
808
+ if (!claim) {
809
+ const next = String((await highestOrdinal(root)) + 1).padStart(2, '0')
810
+
811
+ if (emitJson) {
812
+ process.stdout.write(
813
+ `${JSON.stringify({ ok: true, root, kind, slug, ordinal: next, claimed: false })}\n`,
814
+ )
815
+ } else {
816
+ intro('canon records ordinal')
817
+ logStep('Next')
818
+ logInfo(`${next}-${slug} (report only, pass --claim to create it)`)
819
+ outro()
820
+ }
821
+
822
+ return 0
823
+ }
824
+
825
+ return reportOrdinal(root, await claimOrdinal(root, kind, slug), emitJson)
826
+ }
827
+
828
+ function reportOrdinal(
829
+ root: string,
830
+ outcome: ClaimOutcome,
831
+ emitJson: boolean,
832
+ ): number {
833
+ if (!outcome.ok) {
834
+ if (emitJson) {
835
+ process.stderr.write(`${outcome.message}\n`)
836
+ process.stdout.write(
837
+ `${JSON.stringify({
838
+ ok: false,
839
+ reason: outcome.reason,
840
+ message: outcome.message,
841
+ lastOrdinal: outcome.lastOrdinal,
842
+ })}\n`,
843
+ )
844
+ return EXIT_CONTENDED
845
+ }
846
+
847
+ intro('canon records ordinal')
848
+ logStep('Refused')
849
+ logError(outcome.message)
850
+ outro()
851
+ return EXIT_CONTENDED
852
+ }
853
+
854
+ if (emitJson) {
855
+ process.stdout.write(
856
+ `${JSON.stringify({ root, ...outcome, claimed: true })}\n`,
857
+ )
858
+ } else {
859
+ intro('canon records ordinal')
860
+ logStep('Claimed')
861
+ logInfo(outcome.path)
862
+ outro()
863
+ }
864
+
865
+ return 0
866
+ }