@erclx/canon 4.9.0 → 4.9.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.9.0",
4
+ "version": "4.9.2",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -158,13 +158,18 @@ This chain owns the receipt's lifetime, which is what makes the Output block's c
158
158
 
159
159
  Invoke `canon:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, the CI watch, and the scoped memory review, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
160
160
 
161
- One thing this chain adds. Mark the pull request as a draft as soon as `git-ship`'s pull request step returns, ahead of its CI watch:
161
+ One thing this chain adds. Mark the pull request as a draft as soon as `git-ship`'s pull request step returns, ahead of its CI watch, then read the flag back:
162
162
 
163
163
  ```bash
164
- gh pr ready --undo
164
+ gh pr ready --undo <number>
165
+ gh pr view <number> --json isDraft
165
166
  ```
166
167
 
167
- Placement is the whole point of naming it. Marking after the watch leaves the pull request ready to merge for as long as CI runs, which is the window an unattended worker's branch is least supervised.
168
+ Name the number `git-ship`'s pull request step returned on both calls rather than leaving either to resolve by branch, since each matches a head ref and ignores state, so a reused branch name reaches a merged namesake. The read is where that costs the most, answering with the wrong record's flag, and the write above it errors rather than mutating one.
169
+
170
+ Report what the read returned rather than what the command printed, since the exit says the call ran and says nothing about the state. A `true` reports a draft. A `false` reports the pull request as opened ready and unsupervised, and the chain stops there. Never re-issue the undo on a disagreeing read, which fights whoever readied it instead of guarding anything.
171
+
172
+ Placement is why the call sits ahead of the watch rather than after it. Marking afterwards leaves the pull request unmarked for the whole CI run, which is the stretch an unattended worker's branch is least supervised. What the mark buys is a reader learning the pull request has had no review yet. It buys no bound on that stretch: readying a pull request to merge lifts the mark, GitHub requires it before a merge, and it is the operator's act to take.
168
173
 
169
174
  `git-ship` verifies again at its own gate, which repeats this chain's Step 3 on the run where nothing stopped. That cost is deliberate: four of the stop points in the table below hand the run straight back to that body, and a gate the chain skips for being redundant is a gate no resumed run ever meets.
170
175
 
@@ -173,16 +178,18 @@ Placement is the whole point of naming it. Marking after the watch leaves the pu
173
178
  Respond with up to five lines:
174
179
 
175
180
  ```plaintext
176
- ✅ Autoshipped (draft): <PR url>
181
+ ✅ Autoshipped (<state>): <PR url>
177
182
  <N minor findings kept in .canon/review/branch/review-<slug>.md>
178
183
  <N facts routed to context entries>
179
184
  <N memories captured in .canon/memory/>
180
185
  <Memory proposal at .canon/review/memory/memory-review-<slug>.md>
181
186
  ```
182
187
 
188
+ `<state>` is whatever the Step 7 read returned, being `draft` or `ready, unsupervised`, rather than the state the undo asked for. Writing the word `draft` there unconditionally is what this line used to do, and it named a state no step had read.
189
+
183
190
  Omit the second line if there were no minor findings, and the third if nothing routed. Omit the fourth and fifth if `claude-memory-capture` wrote no memory file this session, since an empty pen means no scoped review and no proposal. A run that routes every fact and writes none is the shape to expect, and it reports three lines.
184
191
 
185
- This block replaces the one `git-ship` closes on rather than following it. The two carry the same three trailing lines and differ on the two above them, since the first names the draft state and the second reports the minor findings Step 6 kept, neither of which that body has a counterpart for. Emitting both reports one run twice and buries the draft under a `✅ Shipped` that does not name it.
192
+ This block replaces the one `git-ship` closes on rather than following it. The two carry the same three trailing lines and differ on the two above them, since the first names the state the read returned and the second reports the minor findings Step 6 kept, neither of which that body has a counterpart for. Emitting both reports one run twice and buries the state under a `✅ Shipped` that does not name it.
186
193
 
187
194
  ## Failure recovery
188
195
 
@@ -119,7 +119,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
119
119
  - Resolve the target at the moment of sending with `canon sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 5 routes on the count and the confidence it answers with
120
120
  - Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
121
121
  - Name the skill for the reader to run rather than writing an invocation, which arrives as text
122
- - Read the pull request's own draft flag rather than the state a worker reports, since two reported a draft that read ready inside ninety seconds
122
+ - Read the pull request's own draft flag rather than the state a worker reports, since two reported a draft that read ready inside ninety seconds, and report what the read returned and when rather than the state alone. The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment, so a read taken between the pull request opening and that call sees a genuinely ready pull request about to become a draft. That is how a poll on `#1307` told an operator the opposite of what the worker had said.
123
123
 
124
124
  A session is reachable when it appears in a live listing, which reads what each session registered on disk rather than probing it, and a message carries plain text and no authority. When no live session holds the branch, report the invocation for the human, naming the branch, the pull request, and the skill to run, then stop. Retrying or waiting leaves the loop believing it is open while nothing acts on it. Every dispatch in the trial behind this step found a live session, so this branch stands on reasoning rather than on observation.
125
125
 
@@ -25,6 +25,7 @@ running and re-invoking it would restart the build.
25
25
  - Resolve `.canon/plans/`, `.canon/tasks/`, `.canon/review/`, and `.canon/memory/` at the main worktree root, never against the linked worktree this session builds in. Those folders are gitignored, so `git worktree add` never creates them and the copy beside the build is absent rather than empty.
26
26
  - Report a plan that fails to resolve as unreadable from here, naming the main-root path. Reporting the task as having no plan is true where this session stands and wrong about the world, and a reader with no second tree to check cannot separate the two.
27
27
  - Build the plan the launch named. Do not write a second one when the path fails to resolve, since a row that cites a plan already has one and drafting another produces two plans for one row.
28
+ - Report a draft flag reading ready once, and leave it cleared. The ship chain marks the pull request a draft and no step anywhere un-marks, so a flag reading ready afterwards was lifted by a person readying it to merge, which GitHub requires and is theirs to take. Re-drafting fights them, which four sessions did on 2026-08-31 on a belief no surface in the tree states.
28
29
 
29
30
  ## The board is read-only
30
31
 
@@ -142,7 +142,9 @@ Common patterns:
142
142
 
143
143
  `migrate rename` moves a project off the retired `aitk` name. It reports until `--write` is passed, and `--scope target` rewrites the toolkit-owned folders alone, reporting every other citation as one the project owns rather than editing prose somebody wrote. A project installing `canon` fresh never needs it.
144
144
 
145
- `migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first. A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. Running it twice rewrites nothing, which is the check that the exclusions and the markers fired.
145
+ `migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
146
+
147
+ A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
146
148
 
147
149
  ## Version skew
148
150
 
@@ -80,7 +80,7 @@ When features are independent, run them in parallel instead of sequentially. Use
80
80
  - Invoke `canon:claude-feature` in each session. Plans land at the main worktree root as `.canon/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
81
81
  - Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review/branch/review-<slug>.md`, `review/ui-checklist-<slug>.md`), so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
82
82
  - Ship each worktree separately with `canon:git-ship`
83
- - For full autonomy per worktree, invoke `canon:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
83
+ - For full autonomy per worktree, invoke `canon:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it and is the operator's act.
84
84
 
85
85
  To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `canon:claude-orchestrate`. It holds the cross-feature call, plans each feature itself or dispatches a cold planner under `canon:claude-planner` to write the plan, refills the ready queue so a free worker never waits, and reviews each worker's PR with `canon:claude-pr-review`, then tells the session holding that branch to run `canon:claude-address-review` whenever the pass posted a finding at any severity, which is the same threshold `canon:claude-pr-review` states and posts its open heading under. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
86
86
 
@@ -128,15 +128,17 @@ When the toolkit updates, target projects pull changes per domain. There is one
128
128
 
129
129
  Session records moved out of `.claude/` and into a root of their own. What is committed stays where it is, and everything gitignored, being the task board, the plans, the memory pen, the review reports, and the scratch folder, now lives under `.canon/`, which a single ignore entry covers.
130
130
 
131
- **`canon migrate records` is not in a published release yet.** It ships with the change that split the roots, so an installed binary answers `unknown command` until the next release carries it. Until then, run it out of a canon checkout against the project by path:
131
+ `4.7.0` carries `canon migrate records`, so run these three lines from inside the project. Run `canon upgrade` first regardless of what you hold, since the sweep learned to pass over the records themselves after that release and a `4.7.0` binary rewrites them.
132
132
 
133
133
  ```bash
134
134
  canon tooling sync claude . --write
135
- bun src/cli.ts migrate records --root /path/to/project --json
136
- bun src/cli.ts migrate records --root /path/to/project --write --json
135
+ canon migrate records --json
136
+ canon migrate records --write --json
137
137
  ```
138
138
 
139
- Once the release lands, the same three lines read `canon migrate records` from inside the project. Either way the first line takes the `.canon/` ignore entry, and the verb refuses until the project has it, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. The second reports the plan and the third applies it, moving the folders and repointing every tracked file that cites one.
139
+ The first line takes the `.canon/` ignore entry, and the verb refuses until the project has it, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. The second reports the plan and the third applies it, moving the folders and repointing every tracked file that cites one.
140
+
141
+ 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.
140
142
 
141
143
  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.
142
144
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.9.0",
4
+ "version": "4.9.2",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -6,6 +6,7 @@ import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
6
6
  import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
7
7
  import {
8
8
  ignoresDestination,
9
+ isRecordArtifact,
9
10
  planRecordsMove,
10
11
  type RecordsPlan,
11
12
  } from '@/migrate/records'
@@ -176,17 +177,24 @@ async function runRecords(opts: RecordsOptions): Promise<number> {
176
177
  return 1
177
178
  }
178
179
 
179
- const plan = planRecordsMove(root, await readSources(root, files))
180
+ // Filtered before the read rather than inside the planner, because the
181
+ // records are the largest thing in the tree and `readSources` awaits one file
182
+ // at a time. This repository's own record tree is 9,744 files at 83M, and the
183
+ // backup history under it is object files read whole and discarded as binary.
184
+ const toSweep = files.filter((path) => !isRecordArtifact(path))
185
+ const records = files.length - toSweep.length
186
+
187
+ const plan = planRecordsMove(root, await readSources(root, toSweep))
180
188
 
181
189
  // stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
182
190
  // where this command's report belongs and where a JSON record does not.
183
191
  if (opts.json) {
184
192
  process.stdout.write(
185
- `${JSON.stringify(toRecordsRecord(plan, opts.write))}\n`,
193
+ `${JSON.stringify(toRecordsRecord(plan, records, opts.write))}\n`,
186
194
  )
187
195
  }
188
196
 
189
- reportRecords(plan)
197
+ reportRecords(plan, records)
190
198
 
191
199
  if (plan.collisions.length > 0) {
192
200
  logError(
@@ -229,7 +237,7 @@ function readGitignore(root: string): string | undefined {
229
237
  }
230
238
  }
231
239
 
232
- function reportRecords(plan: RecordsPlan): void {
240
+ function reportRecords(plan: RecordsPlan, records: number): void {
233
241
  logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
234
242
  for (const move of plan.moves) logInfo(` ${move.from} -> ${move.to}`)
235
243
  logInfo(
@@ -237,6 +245,14 @@ function reportRecords(plan: RecordsPlan): void {
237
245
  )
238
246
  logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
239
247
 
248
+ // A count rather than a list, and its own line rather than a place in
249
+ // `excluded`. That field exists so a reader can go and check a handful by
250
+ // hand, and a record tree would bury them. The count is what answers the
251
+ // question a target actually has, which is where the rest of the files went.
252
+ if (records > 0) {
253
+ logInfo(`${plural(records, 'file')} under a record root, left alone.`)
254
+ }
255
+
240
256
  if (plan.excluded.length > 0) {
241
257
  logInfo(`${plural(plan.excluded.length, 'file')} excluded from the sweep.`)
242
258
  }
@@ -244,6 +260,7 @@ function reportRecords(plan: RecordsPlan): void {
244
260
 
245
261
  function toRecordsRecord(
246
262
  plan: RecordsPlan,
263
+ records: number,
247
264
  wrote: boolean | undefined,
248
265
  ): unknown {
249
266
  return {
@@ -255,6 +272,7 @@ function toRecordsRecord(
255
272
  rewritten: plan.rewritten,
256
273
  kept: plan.kept,
257
274
  excluded: plan.excluded.length,
275
+ records,
258
276
  paths: plan.entries.map((entry) => ({
259
277
  path: entry.path,
260
278
  rewritten: entry.rewritten,
@@ -294,6 +312,11 @@ export function register(program: Command): void {
294
312
  'A line carrying canon-keep-record-root, or the line below it, keeps the',
295
313
  'old root. Prose that dates a decision needs it; a live path does not.',
296
314
  '',
315
+ 'The records themselves are never swept. Everything under .canon/ and',
316
+ 'every .claude/ record folder is left alone and reported as a count, so',
317
+ 'a run after the ignore entries collapse touches the same files as one',
318
+ 'before it.',
319
+ '',
297
320
  'Examples:',
298
321
  ' canon migrate records',
299
322
  ' canon migrate records --write',
@@ -10,7 +10,12 @@
10
10
 
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
- import { type RecordRoot, RECORD_ENTRIES, spell } from '@/record-root'
13
+ import {
14
+ type RecordRoot,
15
+ RECORD_ENTRIES,
16
+ RECORD_ROOTS,
17
+ spell,
18
+ } from '@/record-root'
14
19
 
15
20
  /** The root the entries below leave, exported so the writer can prune it. */
16
21
  export const FROM_ROOT: RecordRoot = '.claude'
@@ -104,6 +109,66 @@ export function isExcludedPath(path: string): boolean {
104
109
  return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
105
110
  }
106
111
 
112
+ /**
113
+ * The roots holding nothing but records, so a path under one is a record
114
+ * whatever it is named.
115
+ *
116
+ * `.canon/` qualifies by construction. `.claude/ARCHITECTURE.md` fixes the rule
117
+ * that every gitignored session record moves there and nothing tracked ever
118
+ * lands there, which covers a record folder `RECORD_ENTRIES` has yet to learn
119
+ * about. The old root is the one that cannot take a whole-root reading, and it
120
+ * is derived by exclusion rather than named, so a third root added later reads
121
+ * as records-only unless someone says otherwise.
122
+ */
123
+ const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
124
+ (root) => root !== FROM_ROOT,
125
+ )
126
+
127
+ /**
128
+ * Every prefix under which a path is a record rather than a file to sweep.
129
+ *
130
+ * The asymmetry is the point. A whole-root prefix is correct for the new root
131
+ * and wrong for the old one, which is mixed: this repository tracks 163 files
132
+ * under `.claude/`, and a target's installed `.claude/rules/core/035-tasks.md`
133
+ * is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
134
+ * it silently. The old root is therefore entry-scoped, through `spell` so the
135
+ * one naming variant stays decided in `record-root.ts`.
136
+ *
137
+ * Joined with a literal separator rather than through `join`, the way
138
+ * `EXCLUDED_PREFIXES` already is. These are matched against what `git ls-files`
139
+ * returns, which is forward-slashed on every platform, where `join` would spell
140
+ * a backslash on Windows and match nothing.
141
+ */
142
+ const RECORD_PREFIXES: readonly string[] = [
143
+ ...RECORD_ONLY_ROOTS,
144
+ ...RECORD_ENTRIES.map((entry) => `${FROM_ROOT}/${spell(FROM_ROOT, entry)}`),
145
+ ]
146
+
147
+ /**
148
+ * Whether a path is a record artifact, which the sweep passes over entirely.
149
+ *
150
+ * Separate from `isExcludedPath`, which reports what it skips because a reader
151
+ * has to check those by hand. A record artifact is never something to check,
152
+ * and a target's record tree is large enough that reporting each one would bury
153
+ * the handful of exclusions that matter.
154
+ *
155
+ * A record folder becomes visible to the sweep at the moment `canon tooling
156
+ * sync claude` prunes the twelve old ignore entries down to one `.canon/` line,
157
+ * which is the step the documented first-run order puts immediately before this
158
+ * verb. Without this predicate the run that follows reads the memory pen and
159
+ * the groundwork trails as source and rewrites them.
160
+ *
161
+ * The three retired flat archives stay outside this, the way `CITATION` already
162
+ * leaves them alone: `.claude/plans-archive/x.md` does not start with
163
+ * `.claude/plans/`, and widening the entry list to catch it would change what
164
+ * `MOVED_ENTRIES` means for the folder half of the verb.
165
+ */
166
+ export function isRecordArtifact(path: string): boolean {
167
+ return RECORD_PREFIXES.some(
168
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
169
+ )
170
+ }
171
+
107
172
  /**
108
173
  * Marks a line naming the old root on purpose.
109
174
  *
@@ -244,6 +309,11 @@ export function planRecordsMove(
244
309
  let kept = 0
245
310
 
246
311
  for (const source of sources) {
312
+ // Silently, and ahead of the exclusion test. The command boundary filters
313
+ // these out before it reads them, so this is what keeps the pure function
314
+ // correct under a direct call rather than what the verb relies on.
315
+ if (isRecordArtifact(source.path)) continue
316
+
247
317
  if (isExcludedPath(source.path)) {
248
318
  // Only an excluded file that actually carries a citation is reported. The
249
319
  // predicate covers every test file in the tree, so counting them all would