@erclx/canon 4.116.0 → 4.118.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 (77) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +12 -12
  4. package/claude/skills/canon-operator/SKILL.md +1 -1
  5. package/claude/skills/create-skill/REQUIREMENT.md +3 -0
  6. package/claude/skills/create-skill/SKILL.md +3 -1
  7. package/claude/skills/docs-fold/SKILL.md +1 -1
  8. package/claude/skills/draft-ready/references/assembly.md +8 -0
  9. package/claude/skills/memory-review/SKILL.md +4 -4
  10. package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
  11. package/claude/skills/plan-feature/SKILL.md +7 -0
  12. package/claude/skills/review-pr/REQUIREMENT.md +3 -0
  13. package/claude/skills/review-pr/SKILL.md +5 -3
  14. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
  15. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  16. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
  17. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
  19. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  20. package/claude/skills/role-worker/SKILL.md +2 -2
  21. package/claude/skills/session-compact/REQUIREMENT.md +2 -1
  22. package/claude/skills/session-compact/SKILL.md +5 -1
  23. package/claude/skills/target-check/references/domains.md +1 -1
  24. package/claude/skills/write-human/references/source-material.md +2 -2
  25. package/docs/agents/commands.md +1 -1
  26. package/docs/agents/context-audit-checks.md +3 -29
  27. package/docs/agents/context-audit.md +1 -1
  28. package/docs/agents/install-and-sync.md +24 -18
  29. package/docs/agents/markdown-audit.md +7 -3
  30. package/docs/agents/skills-audit.md +8 -1
  31. package/docs/target-projects.md +2 -2
  32. package/docs/workflow/ai-workflow.md +9 -9
  33. package/docs/workflow/operating-model.md +1 -1
  34. package/docs/workflow/visual-design-workflow.md +1 -1
  35. package/governance/rules/claude/510-context.md +0 -12
  36. package/governance/rules/core/005-behavior.md +1 -2
  37. package/governance/rules/core/015-output.md +1 -3
  38. package/governance/rules/core/035-tasks.md +1 -3
  39. package/governance/rules/core/045-memory.md +0 -1
  40. package/governance/rules/core/055-scratch.md +2 -4
  41. package/governance/rules/core/070-planning.md +1 -4
  42. package/governance/rules/core/087-git.md +2 -2
  43. package/governance/rules/core/090-code-comments.md +1 -1
  44. package/governance/rules/ui/440-surface-capture.md +2 -0
  45. package/governance/rules/ui/460-design-taste.md +0 -15
  46. package/package.json +1 -1
  47. package/scripts/core/check-capability-seeding.sh +4 -2
  48. package/src/claude/cases/misc.ts +5 -0
  49. package/src/claude/skills-audit.ts +59 -2
  50. package/src/commands/claude.ts +30 -0
  51. package/src/commands/context.ts +11 -111
  52. package/src/commands/markdown.ts +63 -2
  53. package/src/commands/sync.ts +2 -2
  54. package/src/context/audit.ts +9 -153
  55. package/src/gate/measures.ts +97 -0
  56. package/src/gate/stages.ts +21 -2
  57. package/src/gov/adapter.ts +8 -6
  58. package/src/markdown/ceiling.ts +70 -0
  59. package/src/markdown/structure.ts +22 -0
  60. package/src/records/validate.ts +5 -1
  61. package/src/sync/check.ts +7 -1
  62. package/src/sync/engine.ts +90 -10
  63. package/src/sync/stamp.ts +44 -0
  64. package/src/tooling/inject.ts +13 -5
  65. package/src/tooling/manifest.ts +9 -0
  66. package/src/tooling/scan.ts +23 -6
  67. package/src/tooling/subfolder.ts +3 -3
  68. package/standards/context.md +2 -2
  69. package/standards/markdown.md +2 -0
  70. package/standards/plan.md +24 -1
  71. package/standards/skill.md +1 -0
  72. package/tooling/base/reference.md +2 -2
  73. package/tooling/web/configs/e2e/screenshot.ts +43 -7
  74. package/tooling/web/reference.md +1 -1
  75. package/src/context/narration.ts +0 -99
  76. /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
  77. /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
@@ -12,7 +12,11 @@ import {
12
12
  isOverLength,
13
13
  measureArchitecture,
14
14
  } from '@/context/architecture'
15
+ import { auditSkills } from '@/claude/skills-audit'
15
16
  import { listRepositoryFiles } from '@/git-files'
17
+ import { ceilingFindings } from '@/markdown/ceiling'
18
+ import { resolveMarkdown } from '@/markdown/files'
19
+ import { CHECKPOINTS } from '@/markdown/structure'
16
20
  import {
17
21
  isShippedCorpus,
18
22
  REFERENCE_MARKER,
@@ -21,6 +25,7 @@ import {
21
25
  type ShippedReference,
22
26
  } from '@/shipped/references'
23
27
  import { surfaceDir } from '@/surface-root'
28
+ import { plural } from '@/ui'
24
29
  import {
25
30
  README_PARAPHRASE_MARKER,
26
31
  readmeCitationsIn,
@@ -273,6 +278,98 @@ export const architectureRecord: Measure = async (ctx) => {
273
278
  }
274
279
  }
275
280
 
281
+ /**
282
+ * Every skill body and reference against the no-dated-provenance rule, across
283
+ * both corpora `auditSkills` reads.
284
+ *
285
+ * Read in-process rather than through `claude skills audit`, whose exit code
286
+ * stays a missing requirement only, so a target running the verb is told about
287
+ * a date without failing on one. This repository holds the corpus at zero,
288
+ * which is what lets the same finding fail here.
289
+ */
290
+ export const skillProvenance: Measure = async (ctx) => {
291
+ const report = await auditSkills(ctx.root)
292
+ if (report.corpora.length === 0) {
293
+ return {
294
+ emissions: [],
295
+ unmeasured: 'no skill corpus resolved, so no body was read.',
296
+ }
297
+ }
298
+
299
+ const found = report.datedProvenance
300
+ if (found.length > 0) {
301
+ const where = found
302
+ .map((finding) => `${finding.rel} ${finding.detail}`)
303
+ .join(', ')
304
+ return {
305
+ emissions: [],
306
+ failure: `${plural(found.length, 'date')} in skill prose: ${where}. Move the incident to REQUIREMENT.md under Gap or leave it to git, or fence the date or put it in a code span when it is an example.`,
307
+ }
308
+ }
309
+
310
+ return {
311
+ emissions: [
312
+ info(`No dated provenance across ${plural(report.skills, 'skill')}`),
313
+ ],
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Every tracked markdown document against the whole-document ceiling.
319
+ *
320
+ * Report-only for now: a document past the ceiling warns and the stage still
321
+ * passes, since the corpus carried dozens past it when the stage landed. The
322
+ * flip to a failure is a separate change once the count reads zero, and it
323
+ * replaces the warn path rather than sitting beside it.
324
+ *
325
+ * Read in-process rather than through `canon markdown audit`, whose exit code
326
+ * stays bans and dead links only. The plan and groundwork skills run that audit
327
+ * on a gitignored record, and a gating length there would fail every long plan.
328
+ */
329
+ export const documentCeiling: Measure = async (ctx) => {
330
+ const scope = await resolveMarkdown(ctx.root, [])
331
+ if (scope.kind === 'unavailable') {
332
+ return {
333
+ emissions: [],
334
+ unmeasured: 'git could not list the tree, so no document was measured.',
335
+ }
336
+ }
337
+
338
+ const documents = scope.files.flatMap((rel) => {
339
+ try {
340
+ return [{ rel, source: readFileSync(join(ctx.root, rel), 'utf8') }]
341
+ } catch {
342
+ return []
343
+ }
344
+ })
345
+ const findings = ceilingFindings(documents)
346
+ const over = findings.filter((finding) => finding.exempt === null)
347
+ const exempt = findings.length - over.length
348
+ const ceiling = CHECKPOINTS.ceiling
349
+
350
+ if (over.length === 0) {
351
+ const exemptNote = exempt > 0 ? `, ${exempt} exempt` : ''
352
+ return {
353
+ emissions: [
354
+ info(
355
+ `No document past the ${ceiling}-line ceiling across ${plural(documents.length, 'markdown file')}${exemptNote}`,
356
+ ),
357
+ ],
358
+ }
359
+ }
360
+
361
+ return {
362
+ emissions: [
363
+ ...over.map((finding) =>
364
+ warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
365
+ ),
366
+ info(
367
+ `${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
368
+ ),
369
+ ],
370
+ }
371
+ }
372
+
276
373
  /**
277
374
  * A second run of the records move should rewrite nothing, and the count is
278
375
  * only knowable once the folders themselves have landed.
@@ -3,6 +3,7 @@ import {
3
3
  auditSet,
4
4
  captureStamps,
5
5
  clientCommandCitations,
6
+ documentCeiling,
6
7
  markdownBans,
7
8
  type Measure,
8
9
  pluginManifests,
@@ -12,6 +13,7 @@ import {
12
13
  seedStandards,
13
14
  rawFieldFileReference,
14
15
  shippedReferences,
16
+ skillProvenance,
15
17
  standardCriteria,
16
18
  unreferencedRules,
17
19
  visualPathGlobs,
@@ -333,9 +335,11 @@ export const STAGES: readonly Stage[] = [
333
335
  checks: [{ kind: 'measure', measure: unreferencedRules }],
334
336
  },
335
337
  {
336
- // Only the citation half of the audit gates. Length, depth, table, and
338
+ // Only the citation half of the audit gates. Its length, depth, table, and
337
339
  // index findings are judgment thresholds, and failing a push on one would
338
- // make the stage something to route around.
340
+ // make the stage something to route around. The one length reading that
341
+ // will gate is the whole-document ceiling, which the Document ceiling stage
342
+ // below owns rather than this audit.
339
343
  id: 'context-citations',
340
344
  label: 'Context citations',
341
345
  checks: [
@@ -355,6 +359,13 @@ export const STAGES: readonly Stage[] = [
355
359
  label: 'Architecture record',
356
360
  checks: [{ kind: 'measure', measure: architectureRecord }],
357
361
  },
362
+ {
363
+ // Unscoped, since any branch can grow any document. It warns and passes
364
+ // until the corpus sits under the ceiling, then flips to a failure.
365
+ id: 'document-ceiling',
366
+ label: 'Document ceiling',
367
+ checks: [{ kind: 'measure', measure: documentCeiling }],
368
+ },
358
369
  {
359
370
  // A rule citing a file that moved fails silently. The consumed-copy drift
360
371
  // stage passes an authored rule and its copy that are wrong together, and
@@ -458,6 +469,14 @@ export const STAGES: readonly Stage[] = [
458
469
  ],
459
470
  success: 'Skill requirements present',
460
471
  },
472
+ {
473
+ // The audit verb only reports a dated line, so a target is told without
474
+ // failing. This stage makes the same finding fail here, where the corpus is
475
+ // held at zero. Unscoped, since any branch can add a line to any body.
476
+ id: 'skill-provenance',
477
+ label: 'Skill provenance',
478
+ checks: [{ kind: 'measure', measure: skillProvenance }],
479
+ },
461
480
  {
462
481
  id: 'standard-criteria',
463
482
  label: 'Standard success criteria',
@@ -44,12 +44,13 @@ export function indexSourceRules(root: string): Map<string, string> {
44
44
  * path, so a rule that moved between subdirectories in the toolkit still
45
45
  * syncs into the subdirectory the target already uses.
46
46
  *
47
- * Declares no `projectSubdir`. That exemption existed to keep a project's
48
- * `.claude/rules/project/` folder out of the walk by location before any
49
- * name lookup ran, and `installedRoot` narrowing to `.claude/rules/canon/`
50
- * already leaves `project/` outside the walked root, so a second exemption
51
- * computing a stale `canon/project/` destination would be wrong rather than
52
- * merely redundant.
47
+ * Declares `ownsInstalledRoot` and no `projectSubdir`. A project keeps its own
48
+ * rules under `.claude/rules/project/`, which `installedRoot` narrowing to
49
+ * `.claude/rules/canon/` leaves outside the walk, so everything the walk does
50
+ * reach is the toolkit's. A rule there with no source is one the toolkit
51
+ * retired or renamed, and the sync deletes it rather than leaving it loaded.
52
+ * A `projectSubdir` would compute a stale `canon/project/` destination, which
53
+ * is wrong rather than merely redundant.
53
54
  */
54
55
  export function createGovAdapter(root: string): SyncAdapter {
55
56
  const index = indexSourceRules(root)
@@ -65,6 +66,7 @@ export function createGovAdapter(root: string): SyncAdapter {
65
66
  index.get(basename(file.path, '.md')),
66
67
  collectRetired: (target: string) => collectRetiredGov(target),
67
68
  collectMissing: (target: string) => collectMissingGov(root, target),
69
+ ownsInstalledRoot: true,
68
70
  stamp: { domain: 'governance', toolkitRoot: root },
69
71
  }
70
72
  }
@@ -0,0 +1,70 @@
1
+ import { basename } from 'node:path'
2
+ import { bodyLines } from '@/markdown/scan'
3
+ import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
4
+
5
+ /**
6
+ * The marker exempting one document from the ceiling, written as a whole line
7
+ * outside a fence and naming a reason.
8
+ *
9
+ * The whole line rather than anywhere on it, so a standard quoting the marker
10
+ * inside a code span to document it does not exempt itself. A marker with no
11
+ * reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
12
+ * gives: a bare token reads as a line that meant to say something and did not.
13
+ */
14
+ const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
15
+
16
+ /**
17
+ * A changelog is exempt by name because the release tool rewrites it and would
18
+ * not keep a marker in place. A committed list of exempt paths was the
19
+ * alternative, and it turns every rename into a second edit with nothing to
20
+ * check the list against the tree.
21
+ */
22
+ const CHANGELOG = 'CHANGELOG.md'
23
+ const CHANGELOG_REASON = 'CHANGELOG.md is written by the release tool'
24
+
25
+ export interface CeilingDocument {
26
+ readonly rel: string
27
+ readonly source: string
28
+ }
29
+
30
+ export interface CeilingFinding {
31
+ readonly rel: string
32
+ readonly renderedLines: number
33
+ /** The stated reason, or null where the document is not exempt. */
34
+ readonly exempt: string | null
35
+ }
36
+
37
+ /** The reason a document is exempt from the ceiling, or null where it is not. */
38
+ export function lengthExemption(rel: string, source: string): string | null {
39
+ if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
40
+
41
+ for (const line of bodyLines(source)) {
42
+ if (line.fenced) continue
43
+ const match = EXEMPT_MARKER.exec(line.text.trim())
44
+ if (match) return match[1]
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Every document past the ceiling, longest first, exempt ones included with
52
+ * their reason so a report can count them rather than hide them.
53
+ */
54
+ export function ceilingFindings(
55
+ documents: readonly CeilingDocument[],
56
+ ): CeilingFinding[] {
57
+ return documents
58
+ .map(({ rel, source }) => ({
59
+ rel,
60
+ renderedLines: documentHeight(source),
61
+ source,
62
+ }))
63
+ .filter(({ renderedLines }) => renderedLines > CHECKPOINTS.ceiling)
64
+ .map(({ rel, renderedLines, source }) => ({
65
+ rel,
66
+ renderedLines,
67
+ exempt: lengthExemption(rel, source),
68
+ }))
69
+ .sort((a, b) => b.renderedLines - a.renderedLines)
70
+ }
@@ -116,6 +116,7 @@ export const CHECKPOINTS = {
116
116
  cadence: 3,
117
117
  spread: 5,
118
118
  opener: 2,
119
+ ceiling: 300,
119
120
  } as const
120
121
 
121
122
  /**
@@ -167,6 +168,11 @@ export interface Checkpoints {
167
168
  readonly spread: number
168
169
  /** Times one opening word may open a sentence in a paragraph before it is a pattern. */
169
170
  readonly opener: number
171
+ /**
172
+ * Rendered lines a whole document may reach, read by the Document ceiling
173
+ * gate stage rather than by any exit code of the audit.
174
+ */
175
+ readonly ceiling: number
170
176
  }
171
177
 
172
178
  export interface BulletFinding {
@@ -247,6 +253,22 @@ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
247
253
  return Math.max(1, Math.ceil(visibleText(text).length / width))
248
254
  }
249
255
 
256
+ /**
257
+ * Height a whole source occupies once wrapped, frontmatter and fenced blocks
258
+ * included.
259
+ *
260
+ * Nothing is excluded because a session pays for every line it loads, which is
261
+ * what separates this from the depth measure. The context audit's length
262
+ * reading and the document ceiling both count through here, so the two report
263
+ * one unit.
264
+ */
265
+ export function documentHeight(source: string, width = RENDER_WIDTH): number {
266
+ return source
267
+ .replace(/\n$/, '')
268
+ .split('\n')
269
+ .reduce((sum, text) => sum + renderedHeight(text, width), 0)
270
+ }
271
+
250
272
  /**
251
273
  * Reports whether a run is the peer list the standard exempts.
252
274
  *
@@ -271,7 +271,9 @@ const PLAN_SECTIONS = [
271
271
  'Summary',
272
272
  'Constraints',
273
273
  'Files to touch',
274
+ 'Verification',
274
275
  'Risks',
276
+ 'Review focus',
275
277
  'Questions',
276
278
  ] as const
277
279
 
@@ -280,7 +282,9 @@ type PlanSection = (typeof PLAN_SECTIONS)[number]
280
282
  const PLAN_REQUIRED: readonly PlanSection[] = [
281
283
  'Summary',
282
284
  'Files to touch',
285
+ 'Verification',
283
286
  'Risks',
287
+ 'Review focus',
284
288
  'Questions',
285
289
  ]
286
290
 
@@ -293,7 +297,7 @@ const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
293
297
 
294
298
  /**
295
299
  * A section opens as a bold label or as an H2 and both count. The corpus writes
296
- * `Summary` as a heading and the other four as bold labels, and roughly a fifth
300
+ * `Summary` as a heading and the others as bold labels, and roughly a fifth
297
301
  * of it swaps one for the other. Reporting the variant would fail nearly every
298
302
  * plan present on the rule a reader is least served by, which is what teaches
299
303
  * them to skip the output.
package/src/sync/check.ts CHANGED
@@ -79,6 +79,7 @@ export interface StateCounts {
79
79
  readonly orphaned: number
80
80
  readonly stranded: number
81
81
  readonly missing: number
82
+ readonly retired: number
82
83
  }
83
84
 
84
85
  export interface DomainReport {
@@ -314,6 +315,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
314
315
  orphaned: count(entries, 'orphaned'),
315
316
  stranded: count(entries, 'stranded'),
316
317
  missing: count(entries, 'missing'),
318
+ retired: count(entries, 'retired'),
317
319
  }
318
320
  }
319
321
 
@@ -342,6 +344,9 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
342
344
  * sync that adds a rule silently changes what a project is governed by, and
343
345
  * nobody chose that, so gating CI on the count would pressure a target into
344
346
  * adopting a rule nobody picked.
347
+ *
348
+ * `retired` counts, since a sync deletes it mechanically. A target's CI goes
349
+ * red the day a release retires a rule it holds, and one sync clears it.
345
350
  */
346
351
  export function hasDrift(report: CheckReport): boolean {
347
352
  if (report.unmigrated.length > 0) return true
@@ -351,7 +356,8 @@ export function hasDrift(report: CheckReport): boolean {
351
356
  domain.counts.stale +
352
357
  domain.counts.customized +
353
358
  domain.counts.drifted +
354
- domain.counts.stranded >
359
+ domain.counts.stranded +
360
+ domain.counts.retired >
355
361
  0,
356
362
  )
357
363
  }
@@ -8,8 +8,11 @@ import {
8
8
  type DomainHashes,
9
9
  hashFile,
10
10
  readStamp,
11
+ type Stamp,
12
+ type StampDomain,
11
13
  type StampSource,
12
14
  stampedHashes,
15
+ stampedVersion,
13
16
  toStampKey,
14
17
  writeStamp,
15
18
  } from '@/sync/stamp'
@@ -26,6 +29,8 @@ import {
26
29
  palette,
27
30
  select,
28
31
  } from '@/ui'
32
+ import { compareVersions, parseVersion } from '@/version/compare'
33
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
29
34
 
30
35
  /**
31
36
  * One installed file in three path flavours: absolute, relative to the
@@ -58,10 +63,12 @@ export type SyncChange =
58
63
  * who moved it. `stale` and `customized` need the stamp to tell apart, so
59
64
  * `drifted` stays the verdict for a difference no stamp covers.
60
65
  *
61
- * `orphaned` and `stranded` both mean the walk found no source, and they are
62
- * separate because they need opposite treatment. A project-authored file is
63
- * orphaned and stays that way forever. A stamped file the toolkit no longer
64
- * installs to is stranded, which is a relocation waiting on a decision.
66
+ * `orphaned`, `retired`, and `stranded` all mean the walk found no source, and
67
+ * they are separate because they need different treatment. A project-authored
68
+ * file is orphaned and stays that way forever. A file under a root the toolkit
69
+ * owns outright is retired, since only the toolkit ever wrote there, and a
70
+ * sync deletes it. A stamped file the toolkit no longer installs to is
71
+ * stranded, which is a relocation waiting on a decision.
65
72
  *
66
73
  * `missing` is the one state the walk cannot produce on its own, since the
67
74
  * walk only iterates files that exist. It comes from `collectMissing`
@@ -73,6 +80,7 @@ export type EntryState =
73
80
  | 'customized'
74
81
  | 'drifted'
75
82
  | 'orphaned'
83
+ | 'retired'
76
84
  | 'stranded'
77
85
  | 'missing'
78
86
 
@@ -152,6 +160,14 @@ export interface SyncAdapter {
152
160
  * inference orphans a file sitting anywhere else.
153
161
  */
154
162
  readonly projectSubdir?: string
163
+ /**
164
+ * Every file under `installedRoot` is the toolkit's, so a walked file with
165
+ * no source is a leftover of a rule the toolkit retired or renamed rather
166
+ * than something a project wrote. Setting it turns that file into a
167
+ * `retired` entry and queues its delete, edited or not, whatever the stamp
168
+ * holds. Unset, the file stays `orphaned` and untouched.
169
+ */
170
+ readonly ownsInstalledRoot?: boolean
155
171
  /** Defaults to applying. */
156
172
  readonly nonInteractive?: NonInteractivePolicy
157
173
  /** Runs on a completed sync, including one with no changes. */
@@ -195,15 +211,18 @@ export function listInstalled(
195
211
 
196
212
  /**
197
213
  * Classifies every installed file against its source without writing anything.
198
- * A file with no source is left alone rather than deleted, which is what keeps
199
- * project-authored rules alive across a sync.
214
+ * A file with no source is deleted only when the adapter owns its installed
215
+ * root outright, and left alone otherwise, which is what keeps a
216
+ * project-authored file alive across a sync of a root the project shares.
200
217
  */
201
218
  export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
202
219
  const entries: ScanEntry[] = []
203
220
  const changes: SyncChange[] = []
204
221
  const unattributed: UnattributedFile[] = []
205
222
 
206
- const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
223
+ const stamp = readStamp(target)
224
+ const hashes = stampedHashes(stamp, adapter.stamp?.domain)
225
+ const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
207
226
  const walked = new Set<string>()
208
227
 
209
228
  for (const file of listInstalled(
@@ -222,7 +241,18 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
222
241
  const source = adapter.locateSource(file)
223
242
 
224
243
  if (source === undefined || !existsSync(source)) {
225
- entries.push(misplacedOrphan(adapter, target, file))
244
+ if (adapter.ownsInstalledRoot !== true) {
245
+ entries.push(misplacedOrphan(adapter, target, file))
246
+ } else if (newerInstall !== undefined) {
247
+ entries.push(heldForNewerInstall(file, newerInstall))
248
+ } else {
249
+ entries.push({
250
+ state: 'retired',
251
+ rel: file.rel,
252
+ notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
253
+ })
254
+ changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
255
+ }
226
256
  continue
227
257
  }
228
258
 
@@ -438,6 +468,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
438
468
  )
439
469
  else if (entry.state === 'customized')
440
470
  logWarn(`${entry.rel} (locally customized)`)
471
+ else if (entry.state === 'retired')
472
+ logWarn(entry.notice ?? `${entry.rel} (no longer shipped by the toolkit)`)
441
473
  else if (entry.state === 'stranded')
442
474
  logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
443
475
  else if (entry.state === 'missing')
@@ -528,6 +560,10 @@ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
528
560
  * Naming the destination is all this does. Moving the file rewrites a path the
529
561
  * project's own rules, skills, and docs may cite, so the sync leaves it where
530
562
  * it is.
563
+ *
564
+ * An adapter declaring `ownsInstalledRoot` never reaches this, since owning
565
+ * the root answers the question neither the name nor the stamp can, and its
566
+ * sourceless file is retired instead.
531
567
  */
532
568
  function misplacedOrphan(
533
569
  adapter: SyncAdapter,
@@ -549,6 +585,49 @@ function misplacedOrphan(
549
585
  }
550
586
  }
551
587
 
588
+ /**
589
+ * The release that last wrote this domain, when it is newer than the one
590
+ * running. A binary older than that release cannot tell a rule it retired
591
+ * from one added after it, so it holds every sourceless file rather than
592
+ * deleting rules the newer release installed. An unparseable side holds too,
593
+ * since deleting on a comparison that never ran is the failure this guards.
594
+ * A stamp with no version predates the field and is never newer.
595
+ */
596
+ function newerInstallingVersion(
597
+ stamp: Stamp | undefined,
598
+ domain: StampDomain | undefined,
599
+ ): { readonly stamped: string; readonly running: string } | undefined {
600
+ if (domain === undefined) return undefined
601
+
602
+ const stamped = stampedVersion(stamp, domain)
603
+ if (stamped === undefined) return undefined
604
+
605
+ const running = readInstalled().version ?? UNKNOWN_LABEL
606
+ const parsedStamped = parseVersion(stamped)
607
+ const parsedRunning = parseVersion(running)
608
+
609
+ if (
610
+ parsedStamped !== undefined &&
611
+ parsedRunning !== undefined &&
612
+ compareVersions(parsedStamped, parsedRunning) <= 0
613
+ ) {
614
+ return undefined
615
+ }
616
+
617
+ return { stamped, running }
618
+ }
619
+
620
+ function heldForNewerInstall(
621
+ file: InstalledFile,
622
+ versions: { readonly stamped: string; readonly running: string },
623
+ ): ScanEntry {
624
+ return {
625
+ state: 'orphaned',
626
+ rel: file.rel,
627
+ notice: `${file.rel} (installed by canon ${versions.stamped}, newer than this ${versions.running}. Upgrade canon to sync it.)`,
628
+ }
629
+ }
630
+
552
631
  function isInside(target: string, path: string): boolean {
553
632
  const rel = relative(target, path)
554
633
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
@@ -567,8 +646,9 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
567
646
  /**
568
647
  * Records what the toolkit placed, after the copies land, so a partial apply
569
648
  * that throws leaves the previous stamp rather than a claim the target does not
570
- * meet. A file with no source, or one orphaned by location, is
571
- * project-authored and stays out.
649
+ * meet. A file with no source stays out, whether it is project-authored or a
650
+ * retired leftover a declined sync did not delete, and so does one orphaned by
651
+ * location.
572
652
  *
573
653
  * Reads the installed tree rather than the caller's file list, so a partial
574
654
  * install still stamps the domain's whole installed set.
package/src/sync/stamp.ts CHANGED
@@ -5,6 +5,8 @@ import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
6
  import { resolveExisting } from '@/legacy-path'
7
7
  import { recordTarget } from '@/targets/registry'
8
+ import { compareVersions, parseVersion } from '@/version/compare'
9
+ import { readInstalled } from '@/version/installed'
8
10
 
9
11
  /**
10
12
  * Domains the stamp can record. Governance and design both attribute file by
@@ -38,6 +40,14 @@ export type DomainHashes = Readonly<Record<string, string>>
38
40
  export interface DomainStamp {
39
41
  /** Toolkit revision this domain last synced from. Absent outside a git clone. */
40
42
  readonly commit?: string
43
+ /**
44
+ * Newest canon release that has written this domain. A sync deletes an
45
+ * installed file its toolkit does not ship, and a binary older than the one
46
+ * that installed the file cannot tell a retired file from one added after
47
+ * its own release, so this is what it reads before deleting anything.
48
+ * Absent on a stamp written before the field existed.
49
+ */
50
+ readonly version?: string
41
51
  readonly syncedAt: string
42
52
  readonly files: DomainHashes
43
53
  /**
@@ -178,6 +188,14 @@ function readStampFile(path: string): Stamp | undefined {
178
188
  }
179
189
  }
180
190
 
191
+ /** The newest canon release that has written this domain, when one was recorded. */
192
+ export function stampedVersion(
193
+ stamp: Stamp | undefined,
194
+ domain: StampDomain,
195
+ ): string | undefined {
196
+ return stamp?.domains[domain]?.version
197
+ }
198
+
181
199
  /** The revision this domain last synced from, which bounds its upstream range. */
182
200
  export function stampedCommit(
183
201
  stamp: Stamp | undefined,
@@ -245,9 +263,11 @@ async function putDomain(
245
263
  const previous = readStamp(target)
246
264
  const previousRecord = previous?.domains[source.domain]
247
265
  const commit = await toolkitCommit(source.toolkitRoot)
266
+ const version = newerVersion(readInstalled().version, previousRecord?.version)
248
267
 
249
268
  const record: DomainStamp = {
250
269
  ...(commit === undefined ? {} : { commit }),
270
+ ...(version === undefined ? {} : { version }),
251
271
  syncedAt: now.toISOString(),
252
272
  files: payload.files ?? previousRecord?.files ?? {},
253
273
  ...resolveChainField(payload.chain, previousRecord?.chain),
@@ -304,6 +324,27 @@ async function readCommit(root: string): Promise<string | undefined> {
304
324
  : undefined
305
325
  }
306
326
 
327
+ /**
328
+ * Keeps the newer of the running release and the one already recorded, so an
329
+ * older binary syncing after a newer one cannot lower the mark and let a later
330
+ * run delete a rule the newer release installed. An unparseable side loses to
331
+ * a parseable one, and a recorded value survives an unreadable manifest.
332
+ */
333
+ function newerVersion(
334
+ running: string | undefined,
335
+ recorded: string | undefined,
336
+ ): string | undefined {
337
+ if (running === undefined) return recorded
338
+ if (recorded === undefined) return running
339
+
340
+ const parsedRunning = parseVersion(running)
341
+ const parsedRecorded = parseVersion(recorded)
342
+ if (parsedRecorded === undefined) return running
343
+ if (parsedRunning === undefined) return recorded
344
+
345
+ return compareVersions(parsedRecorded, parsedRunning) > 0 ? recorded : running
346
+ }
347
+
307
348
  /**
308
349
  * A write naming no chain keeps the domain's previous one rather than dropping
309
350
  * it, since `writeStamp` and `writeChainStamp` each touch one half of a
@@ -365,6 +406,9 @@ function isDomainStamp(value: unknown): value is DomainStamp {
365
406
  if (value.commit !== undefined && typeof value.commit !== 'string') {
366
407
  return false
367
408
  }
409
+ if (value.version !== undefined && typeof value.version !== 'string') {
410
+ return false
411
+ }
368
412
  if (value.chain !== undefined && !isStringArray(value.chain)) return false
369
413
 
370
414
  return Object.values(value.files).every((hash) => typeof hash === 'string')