@erclx/canon 4.86.0 → 4.87.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The promotion of nine cited measurement folders out of `.canon/tmp/` into
3
- * `.canon/review/evidence/`, which `canon records push` already backs.
3
+ * `.canon/evidence/<nn>-<folder>/`, which `canon records push` already backs.
4
4
  *
5
5
  * The scratch root is the one record root a disk loss takes with it, and a
6
6
  * durable record naming a folder under it as its evidence is a citation into
@@ -26,6 +26,13 @@
26
26
  import { existsSync } from 'node:fs'
27
27
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
28
28
  import { dirname, join } from 'node:path'
29
+ import {
30
+ byFirstAppearance,
31
+ folderNames,
32
+ nextOrdinal,
33
+ numberedName,
34
+ slugOf,
35
+ } from '@/migrate/evidence-ordinal'
29
36
  import { presentFolders } from '@/records/backup'
30
37
  import { recordDir, SCRATCH } from '@/record-root'
31
38
 
@@ -59,16 +66,14 @@ export const PROMOTED_FOLDERS: readonly string[] = [
59
66
  'target-survey',
60
67
  ]
61
68
 
62
- const EVIDENCE_ROOT = ['review', 'evidence'] as const
63
-
64
69
  /** Where a promoted folder sits before the move. */
65
70
  export function sourcePath(root: string, folder: string): string {
66
71
  return recordDir(root, SCRATCH, folder)
67
72
  }
68
73
 
69
- /** Where it lands after, always under whichever root `review/` resolves at. */
70
- export function destinationPath(root: string, folder: string): string {
71
- return recordDir(root, ...EVIDENCE_ROOT, folder)
74
+ /** The folder a promoted one lands inside, under whichever root carries it. */
75
+ function evidenceDir(root: string): string {
76
+ return recordDir(root, 'evidence')
72
77
  }
73
78
 
74
79
  function escape(value: string): string {
@@ -100,14 +105,14 @@ function citationPattern(folder: string): RegExp {
100
105
 
101
106
  interface Rewrite {
102
107
  readonly pattern: RegExp
103
- readonly folder: string
108
+ readonly name: string
104
109
  }
105
110
 
106
- /** One rewrite per folder, paired with its citation pattern. */
107
- function buildRewrites(folders: readonly string[]): readonly Rewrite[] {
108
- return folders.map((folder) => ({
111
+ /** One rewrite per folder, from its scratch name to its numbered one. */
112
+ function buildRewrites(names: ReadonlyMap<string, string>): readonly Rewrite[] {
113
+ return [...names].map(([folder, name]) => ({
109
114
  pattern: citationPattern(folder),
110
- folder,
115
+ name,
111
116
  }))
112
117
  }
113
118
 
@@ -131,8 +136,8 @@ function isKept(lines: readonly string[], index: number): boolean {
131
136
 
132
137
  function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
133
138
  return rewrites.reduce(
134
- (current, { pattern, folder }) =>
135
- current.replace(pattern, `.canon/review/evidence/${folder}`),
139
+ (current, { pattern, name }) =>
140
+ current.replace(pattern, `.canon/evidence/${name}`),
136
141
  line,
137
142
  )
138
143
  }
@@ -144,7 +149,7 @@ interface RewriteOutcome {
144
149
 
145
150
  /**
146
151
  * Rewrites every unmarked citation of a folder `rewrites` covers into its
147
- * destination under `.canon/review/evidence/`, absolute regardless of how the
152
+ * destination under `.canon/evidence/`, absolute regardless of how the
148
153
  * source citation was spelled, counting each as it goes. A file naming no
149
154
  * such folder returns byte-identical with a count of zero, and a marked line
150
155
  * is returned unchanged and uncounted.
@@ -232,30 +237,55 @@ export interface ScratchEvidencePlan {
232
237
  }
233
238
 
234
239
  /**
235
- * Every promoted folder found on disk, with its destination, refusing a
236
- * folder whose destination is already occupied rather than merging into it.
240
+ * Every promoted folder found on disk, with its numbered destination, and the
241
+ * name each promoted folder's citations rewrite to.
242
+ *
243
+ * Folders on disk take ordinals in order of first appearance, continuing past
244
+ * the highest ordinal `evidence/` already holds. One whose slug `evidence/`
245
+ * already carries refuses rather than taking a second number, and its
246
+ * citations stay as they are. One already moved is not on disk under scratch,
247
+ * so its citations rewrite to the numbered name it landed at.
237
248
  */
238
249
  export function planFolderMoves(root: string): {
239
250
  moves: FolderMove[]
240
251
  collisions: string[]
252
+ names: Map<string, string>
241
253
  } {
254
+ const dir = evidenceDir(root)
255
+ const existing = folderNames(dir)
242
256
  const moves: FolderMove[] = []
243
257
  const collisions: string[] = []
258
+ const names = new Map<string, string>()
259
+ let next = nextOrdinal(existing)
260
+
261
+ const present = byFirstAppearance(
262
+ PROMOTED_FOLDERS.map((folder) => ({
263
+ name: folder,
264
+ dir: sourcePath(root, folder),
265
+ })).filter((folder) => existsSync(folder.dir)),
266
+ )
244
267
 
245
- for (const folder of PROMOTED_FOLDERS) {
246
- const from = sourcePath(root, folder)
247
- if (!existsSync(from)) continue
248
-
249
- const to = destinationPath(root, folder)
250
- if (existsSync(to)) {
251
- collisions.push(to)
268
+ for (const { name: folder, dir: from } of present) {
269
+ const landed = existing.find((entry) => slugOf(entry) === folder)
270
+ if (landed !== undefined) {
271
+ collisions.push(join(dir, landed))
252
272
  continue
253
273
  }
254
274
 
255
- moves.push({ folder, from, to })
275
+ const name = numberedName(next, folder)
276
+ next += 1
277
+ names.set(folder, name)
278
+ moves.push({ folder, from, to: join(dir, name) })
256
279
  }
257
280
 
258
- return { moves, collisions }
281
+ for (const folder of PROMOTED_FOLDERS) {
282
+ if (existsSync(sourcePath(root, folder))) continue
283
+
284
+ const landed = existing.find((entry) => slugOf(entry) === folder)
285
+ if (landed !== undefined) names.set(folder, landed)
286
+ }
287
+
288
+ return { moves, collisions, names }
259
289
  }
260
290
 
261
291
  /**
@@ -267,11 +297,8 @@ export function planScratchEvidence(
267
297
  root: string,
268
298
  sources: readonly ScratchEvidenceSource[],
269
299
  ): ScratchEvidencePlan {
270
- const { moves, collisions } = planFolderMoves(root)
271
- const folders = PROMOTED_FOLDERS.filter(
272
- (folder) => !collisions.includes(destinationPath(root, folder)),
273
- )
274
- const rewrites = buildRewrites(folders)
300
+ const { moves, collisions, names } = planFolderMoves(root)
301
+ const rewrites = buildRewrites(names)
275
302
  const entries: CitationEntry[] = []
276
303
 
277
304
  for (const source of sources) {
@@ -65,9 +65,12 @@ export const RECORD_ENTRIES: readonly string[] = [
65
65
  SCRATCH,
66
66
  'README.md',
67
67
  'diagrams',
68
+ 'evidence',
69
+ 'feedback',
68
70
  'groundwork',
69
71
  'intake',
70
72
  'memory',
73
+ 'picks',
71
74
  'plans',
72
75
  'proposals',
73
76
  'ready',
@@ -40,7 +40,7 @@ This check is one of the two the destination rule above scopes. The reader insid
40
40
 
41
41
  A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
42
42
 
43
- Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open.
43
+ Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/feedback/` names a folder the remote's reader cannot open.
44
44
 
45
45
  Under the tracked `canon/` and `.claude/` folders there is no hit, since `canon/context/governance/rules.md` resolves everywhere. `.canon/` carries no such carve-out: one ignore line covers the root whole, so every path beneath it is a hit regardless of which folder names it.
46
46
 
@@ -210,7 +210,7 @@ Without this skill, a session <observed failure>, <observed failure>.
210
210
  ### Output and tuning
211
211
 
212
212
  - Skill success lines emit the full relative path from the project root (`<dir>/<file>`) for any file written, updated, or deleted. A bare filename names a file the reader cannot open. The `## Output` section of the project's instruction file sets the form that path takes, so a skill body states which path is emitted and leaves the form to that section.
213
- - Before a skill writes anything, decide whether the output is a deliverable the project keeps or a toolkit session record. A deliverable lands among the project's own tracked files. A session record lands under `.canon/`, in the named subfolder for its kind (`tasks/`, `plans/`, `review/`, `memory/`, `groundwork/`, `intake/`, `proposals/`, `diagrams/`, `teach/`, `ready/`, or `walkthroughs/`, with `tmp/` for scratch nothing else claims), never in a folder the body invents. `canon/ARCHITECTURE.md`'s per-folder decisions are the precedent for which kind takes which folder.
213
+ - Before a skill writes anything, decide whether the output is a deliverable the project keeps or a toolkit session record. A deliverable lands among the project's own tracked files. A session record lands under `.canon/`, in the named subfolder for its kind (`tasks/`, `plans/`, `review/`, `memory/`, `groundwork/`, `intake/`, `proposals/`, `diagrams/`, `teach/`, `ready/`, `feedback/`, `picks/`, `evidence/`, `transcripts/`, or `walkthroughs/`, with `tmp/` for scratch nothing else claims), never in a folder the body invents. A `review/` writer names its file `<kind>-<slug>.md` and writes it flat, and an `evidence/` folder takes the next two-digit ordinal as `<nn>-<slug>/`, numbered in the order the folders first appeared. `canon/ARCHITECTURE.md`'s per-folder decisions are the precedent for which kind takes which folder.
214
214
  - Codify a skill's posted or generated output as a fenced template, and keep the body consistent with every capability the frontmatter description names.
215
215
  - When a skill gathers user input or pre-seeds a template, attach a concrete proposed default to every question, derived from project context. Accept "use defaults" as a bulk-confirm.
216
216
  - Separate correctness axes (routing, sourcing, escalation, decline) from shape axes (line count, formatting, variant sprawl) when tuning a skill. Tighten only on correctness regressions. Do not convert soft caps to hard caps for aesthetic drift when correctness passes.