@erclx/canon 5.44.0 → 5.45.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 (126) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +0 -1
  4. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  5. package/claude/skills/pick-by-eye/REQUIREMENT.md +17 -67
  6. package/claude/skills/role-orchestrator/REQUIREMENT.md +16 -99
  7. package/claude/skills/role-planner/REQUIREMENT.md +15 -73
  8. package/claude/skills/role-worker/REQUIREMENT.md +13 -41
  9. package/claude/skills/target-setup/SKILL.md +2 -2
  10. package/claude/skills/target-setup/references/detect.md +0 -1
  11. package/claude/skills/target-setup/references/verify.md +2 -2
  12. package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
  13. package/claude/skills/teach-workspace/references/promotion.md +5 -5
  14. package/docs/agents/install-and-sync.md +2 -2
  15. package/docs/agents/output-shape.md +1 -2
  16. package/docs/target/migrations.md +18 -0
  17. package/docs/target/projects.md +1 -2
  18. package/docs/target/scenarios.md +4 -1
  19. package/docs/workflow/skill-map-on-request.md +1 -1
  20. package/governance/detect.toml +4 -0
  21. package/governance/ledger/lang/go.md +61 -0
  22. package/governance/ledger/lang/php.md +50 -0
  23. package/governance/ledger/lang/python.md +68 -0
  24. package/governance/ledger/lang/typescript.md +64 -0
  25. package/governance/ledger/lib/database.md +65 -0
  26. package/governance/ledger/lib/e2e-reliability.md +57 -0
  27. package/governance/ledger/lib/pydantic.md +61 -0
  28. package/governance/ledger/lib/security-server.md +62 -0
  29. package/governance/ledger/lib/security-web.md +51 -0
  30. package/governance/ledger/lib/tanstack-query.md +57 -0
  31. package/governance/ledger/lib/test-scope.md +43 -0
  32. package/governance/ledger/lib/testing-go.md +57 -0
  33. package/governance/ledger/lib/testing-php.md +51 -0
  34. package/governance/ledger/lib/testing-py.md +67 -0
  35. package/governance/ledger/lib/testing-ts.md +63 -0
  36. package/governance/ledger/lib/zod.md +47 -0
  37. package/governance/ledger/tooling/ci-workflow.md +33 -0
  38. package/governance/ledger/tooling/code-comments.md +48 -0
  39. package/governance/ledger/tooling/config-comments.md +27 -0
  40. package/governance/ledger/tooling/dependencies.md +38 -0
  41. package/governance/ledger/ui/a11y.md +55 -0
  42. package/governance/ledger/ui/copy.md +49 -0
  43. package/governance/ledger/ui/design-reference.md +42 -0
  44. package/governance/ledger/ui/design-taste.md +19 -0
  45. package/governance/ledger/ui/forms.md +71 -0
  46. package/governance/ledger/ui/interface-casing.md +47 -0
  47. package/governance/ledger/ui/keyboard.md +68 -0
  48. package/governance/ledger/ui/link-behavior.md +29 -0
  49. package/governance/ledger/ui/motion.md +33 -0
  50. package/governance/ledger/ui/surface-capture.md +69 -0
  51. package/governance/ledger/ui/ux-completeness.md +79 -0
  52. package/governance/rules/lang/go.md +7 -16
  53. package/governance/rules/lang/php.md +3 -3
  54. package/governance/rules/lang/python.md +9 -18
  55. package/governance/rules/lang/typescript.md +9 -19
  56. package/governance/rules/lib/database.md +5 -5
  57. package/governance/rules/lib/e2e-reliability.md +4 -6
  58. package/governance/rules/lib/pydantic.md +14 -16
  59. package/governance/rules/lib/security-server.md +4 -4
  60. package/governance/rules/lib/security-web.md +6 -10
  61. package/governance/rules/lib/tanstack-query.md +10 -12
  62. package/governance/rules/lib/test-scope.md +4 -4
  63. package/governance/rules/lib/testing-go.md +1 -2
  64. package/governance/rules/lib/testing-php.md +4 -5
  65. package/governance/rules/lib/testing-py.md +11 -19
  66. package/governance/rules/lib/testing-ts.md +7 -16
  67. package/governance/rules/lib/zod.md +6 -8
  68. package/governance/rules/tooling/code-comments.md +2 -4
  69. package/governance/rules/tooling/config-comments.md +1 -4
  70. package/governance/rules/tooling/dependencies.md +1 -1
  71. package/governance/rules/ui/a11y.md +16 -45
  72. package/governance/rules/ui/copy.md +6 -9
  73. package/governance/rules/ui/forms.md +13 -19
  74. package/governance/rules/ui/interface-casing.md +5 -7
  75. package/governance/rules/ui/keyboard.md +38 -0
  76. package/governance/rules/ui/link-behavior.md +3 -3
  77. package/governance/rules/ui/motion.md +3 -3
  78. package/governance/rules/ui/surface-capture.md +15 -27
  79. package/governance/rules/ui/ux-completeness.md +16 -39
  80. package/package.json +1 -1
  81. package/scripts/core/install-check.sh +1 -1
  82. package/src/cli/help.ts +0 -1
  83. package/src/cli.ts +0 -2
  84. package/src/commands/gov.ts +4 -0
  85. package/src/commands/init.ts +2 -2
  86. package/src/commands/tooling.ts +18 -5
  87. package/src/design/components.ts +1 -1
  88. package/src/gov/audit.ts +22 -2
  89. package/src/init/plan.ts +1 -8
  90. package/src/init/steps.ts +0 -8
  91. package/src/inventory/subjects.ts +1 -1
  92. package/src/markdown/scope.ts +4 -0
  93. package/src/migrate/scratch-evidence.ts +12 -4
  94. package/src/records/backup.ts +3 -3
  95. package/src/records/validate.ts +3 -3
  96. package/src/roots/record.ts +17 -8
  97. package/src/tooling/inject.ts +26 -7
  98. package/src/tooling/package.ts +32 -0
  99. package/src/tooling/read.ts +46 -3
  100. package/src/tooling/scan.ts +8 -2
  101. package/tooling/astro/reference.md +64 -93
  102. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +1 -1
  103. package/tooling/base/configs/scripts/verify.ts +1 -1
  104. package/tooling/base/reference.md +1 -9
  105. package/tooling/base/seeds/canon/context/{ci.md → development/ci.md} +1 -1
  106. package/tooling/base/seeds/canon/context/development/index.md +11 -0
  107. package/tooling/base/seeds/canon/context/{development.md → development/setup.md} +8 -12
  108. package/tooling/base/seeds/canon/context/index.md +1 -2
  109. package/tooling/claude/manifest.toml +1 -5
  110. package/tooling/claude/reference.md +36 -59
  111. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -7
  112. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -7
  113. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +3 -10
  114. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +2 -7
  115. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +2 -4
  116. package/tooling/nextjs/reference.md +0 -6
  117. package/tooling/php/reference.md +0 -4
  118. package/tooling/web/configs/.github/workflows/verify.yml +28 -0
  119. package/tooling/web/configs/.oxlintrc.json +7 -0
  120. package/tooling/web/configs/e2e/capture-types.ts +35 -0
  121. package/tooling/web/configs/e2e/screenshot.ts +23 -72
  122. package/tooling/web/manifest.toml +1 -1
  123. package/tooling/web/reference.md +77 -124
  124. package/tooling/web/seeds/e2e/capture/cases.ts +47 -0
  125. package/src/commands/wiki.ts +0 -100
  126. package/src/wiki/init.ts +0 -85
package/src/cli.ts CHANGED
@@ -7,7 +7,6 @@ import { register as sync } from '@/commands/sync'
7
7
  import { register as gov } from '@/commands/gov'
8
8
  import { register as tooling } from '@/commands/tooling'
9
9
  import { register as claude } from '@/commands/claude'
10
- import { register as wiki } from '@/commands/wiki'
11
10
  import { register as indexes } from '@/commands/indexes'
12
11
  import { register as docs } from '@/commands/docs'
13
12
  import { register as design } from '@/commands/design'
@@ -68,7 +67,6 @@ sync(program)
68
67
  gov(program)
69
68
  tooling(program)
70
69
  claude(program)
71
- wiki(program)
72
70
  indexes(program)
73
71
  docs(program)
74
72
  design(program)
@@ -37,6 +37,7 @@ import {
37
37
  hasFinding,
38
38
  PRACTICE_FOLDERS,
39
39
  readRuleAudit,
40
+ RULE_GATE_EXEMPT,
40
41
  RULE_GATED_FOLDERS,
41
42
  RULE_LIMITS,
42
43
  type RuleAuditReport,
@@ -631,6 +632,7 @@ export function register(program: Command): void {
631
632
  ' inside it.',
632
633
  '',
633
634
  `Gated folders: ${RULE_GATED_FOLDERS.length === 0 ? 'none yet' : RULE_GATED_FOLDERS.join(', ')}`,
635
+ `Exempt from the gate: ${RULE_GATE_EXEMPT.length === 0 ? 'none' : RULE_GATE_EXEMPT.join(', ')}`,
634
636
  '',
635
637
  'Exit codes:',
636
638
  ' 0 no finding, or under --gate no finding in a gated folder',
@@ -691,6 +693,7 @@ async function runAudit(
691
693
  root,
692
694
  limits: RULE_LIMITS,
693
695
  gated: RULE_GATED_FOLDERS,
696
+ exempt: RULE_GATE_EXEMPT,
694
697
  ...report,
695
698
  })}\n`,
696
699
  )
@@ -699,6 +702,7 @@ async function runAudit(
699
702
  return auditExit(report, {
700
703
  gate: opts.gate ?? false,
701
704
  gated: RULE_GATED_FOLDERS,
705
+ exempt: RULE_GATE_EXEMPT,
702
706
  })
703
707
  }
704
708
 
@@ -39,8 +39,8 @@ export function register(program: Command): void {
39
39
  '',
40
40
  'Runs `canon tooling sync base --write` first, which replaces every',
41
41
  'base golden config the target holds, then `canon claude init`,',
42
- '`canon gov install`, and `canon wiki init`. Refuses a target that is',
43
- 'not a directory or is the toolkit root.',
42
+ 'and `canon gov install`. Refuses a target that is not a directory or is',
43
+ 'the toolkit root.',
44
44
  '',
45
45
  'Exit codes:',
46
46
  ' 0 every domain installed, or the prompt was cancelled',
@@ -1,4 +1,5 @@
1
- import { resolve } from 'node:path'
1
+ import { existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
2
3
  import type { Command } from 'commander'
3
4
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
5
  import {
@@ -12,11 +13,16 @@ import { buildStackSummaries, describeStack } from '@/tooling/list'
12
13
  import {
13
14
  isStackExcluded,
14
15
  listStacks,
16
+ loadManifest,
15
17
  type Manifest,
16
18
  resolveChain,
17
19
  stackExists,
18
20
  } from '@/tooling/manifest'
19
- import { readReference, resolveReference } from '@/tooling/read'
21
+ import {
22
+ readReference,
23
+ renderStackSections,
24
+ resolveReference,
25
+ } from '@/tooling/read'
20
26
  import { scan, type ScanResult } from '@/tooling/scan'
21
27
  import { recordToolingChain } from '@/tooling/stamp'
22
28
  import { subfolderPath } from '@/tooling/subfolder'
@@ -302,6 +308,8 @@ function printReference(stack: string): number {
302
308
 
303
309
  logStep(resolved.source)
304
310
  process.stdout.write(readReference(resolved))
311
+ const manifest = loadManifest(resolved.root, stack)
312
+ if (manifest) process.stdout.write(renderStackSections(manifest))
305
313
  outro()
306
314
  return 0
307
315
  }
@@ -625,12 +633,16 @@ function report(result: ScanResult, target: string): void {
625
633
  if (entry.state === 'missing') logAdd(entry.rel)
626
634
  }
627
635
 
628
- if (result.hasPackageJson) {
636
+ if (result.newManifest) {
637
+ logStep('Scanning package.json')
638
+ logAdd('package.json')
639
+ reportPackage(result)
640
+ } else if (result.hasPackageJson) {
629
641
  reportPackage(result)
630
- } else {
642
+ } else if (existsSync(join(target, 'package.json'))) {
631
643
  logStep('Scanning package.json')
632
644
  logWarn(
633
- "Skipped scripts and deps: no package.json found (run 'bun init' to enable)",
645
+ `Skipped scripts and deps: package.json is not valid JSON (fix it in ${target}, then sync again)`,
634
646
  )
635
647
  }
636
648
 
@@ -688,6 +700,7 @@ function summarize(result: ScanResult): string {
688
700
  if (count > 0) parts.push(`${count} ${label}`)
689
701
  }
690
702
 
703
+ add(result.newManifest ? 1 : 0, 'package.json')
691
704
  add(
692
705
  result.configs.filter((entry) => entry.state !== 'matching').length,
693
706
  'configs',
@@ -520,7 +520,7 @@ html.sb-shut .pane { border-left: 0; }
520
520
  }
521
521
 
522
522
  /* A bar link is its own tap target rather than a line of text, so it carries
523
- the 24 pixel minimum the a11y rule sets. */
523
+ the 24 pixel minimum the keyboard rule sets. */
524
524
  .mast a,
525
525
  .mast summary {
526
526
  min-height: 1.6rem;
package/src/gov/audit.ts CHANGED
@@ -36,6 +36,21 @@ export const RULE_GATED_FOLDERS: readonly string[] = [
36
36
  join('governance', 'rules', 'framework') + sep,
37
37
  join('governance', 'rules', 'writing') + sep,
38
38
  join('internal', 'rules') + sep,
39
+ join('governance', 'rules', 'lang') + sep,
40
+ join('governance', 'rules', 'lib') + sep,
41
+ join('governance', 'rules', 'ui') + sep,
42
+ join('governance', 'rules', 'tooling') + sep,
43
+ ]
44
+
45
+ /**
46
+ * Rule files in a gated folder whose findings `--gate` still passes, one path
47
+ * each and never a folder, so a new rule beside one is gated the day it lands.
48
+ *
49
+ * `ui/canvas.md` carries a shape finding and no ledger, and v149.2 removes the
50
+ * rule, so this entry goes with it.
51
+ */
52
+ export const RULE_GATE_EXEMPT: readonly string[] = [
53
+ join('governance', 'rules', 'ui', 'canvas.md'),
39
54
  ]
40
55
 
41
56
  /** The folders whose rules claim outside practice, so each carries a ledger. */
@@ -429,13 +444,18 @@ export function hasFinding(reading: RuleReading): boolean {
429
444
  */
430
445
  export function auditExit(
431
446
  report: Extract<RuleAuditReport, { kind: 'measured' }>,
432
- options: { readonly gate: boolean; readonly gated: readonly string[] },
447
+ options: {
448
+ readonly gate: boolean
449
+ readonly gated: readonly string[]
450
+ readonly exempt?: readonly string[]
451
+ },
433
452
  ): number {
434
453
  const failing = report.rules.filter(
435
454
  (reading) =>
436
455
  hasFinding(reading) &&
437
456
  (!options.gate ||
438
- options.gated.some((prefix) => reading.file.startsWith(prefix))),
457
+ (options.gated.some((prefix) => reading.file.startsWith(prefix)) &&
458
+ !options.exempt?.includes(reading.file))),
439
459
  )
440
460
  return failing.length > 0 ? 2 : 0
441
461
  }
package/src/init/plan.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const SKIPPABLE_DOMAINS = ['wiki', 'governance', 'records'] as const
1
+ export const SKIPPABLE_DOMAINS = ['governance', 'records'] as const
2
2
 
3
3
  export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
4
4
 
@@ -97,13 +97,6 @@ export function planInit(flags: InitFlags): InitPlan {
97
97
  })
98
98
  }
99
99
 
100
- if (!flags.skip.skipped.has('wiki')) {
101
- preview.push({
102
- level: 'info',
103
- text: 'wiki (.claude/wiki/ with a stub index)',
104
- })
105
- }
106
-
107
100
  if (!flags.skip.skipped.has('records')) {
108
101
  preview.push({
109
102
  level: 'info',
package/src/init/steps.ts CHANGED
@@ -55,14 +55,6 @@ export function buildSteps(
55
55
  })
56
56
  }
57
57
 
58
- if (!flags.skip.skipped.has('wiki')) {
59
- steps.push({
60
- kind: 'run',
61
- label: 'Wiki',
62
- run: child(['wiki', 'init', resolved]),
63
- })
64
- }
65
-
66
58
  if (!flags.skip.skipped.has('records')) {
67
59
  // `skip` here means there is nothing to run non-interactively, not that
68
60
  // the caller opted out, unlike every other push of this kind above.
@@ -27,7 +27,7 @@ export interface Subject {
27
27
 
28
28
  /**
29
29
  * Reports what each element changes about itself when it takes focus, which is
30
- * the reading `governance/rules/ui/a11y.md` states three rules against and
30
+ * the reading `governance/rules/ui/keyboard.md` states three rules against and
31
31
  * nothing measures.
32
32
  *
33
33
  * The difference between rest and focus is the treatment, rather than the
@@ -114,6 +114,7 @@ export const GATED_PATHS: readonly string[] = [
114
114
  'claude/skills/docs-sync/references/readme.md',
115
115
  'claude/skills/document-health/SKILL.md',
116
116
  'claude/skills/document-health/references/axes.md',
117
+ 'claude/skills/pick-by-eye/REQUIREMENT.md',
117
118
  'claude/skills/pick-by-eye/SKILL.md',
118
119
  'claude/skills/pick-by-eye/references/live-arms.md',
119
120
  'claude/skills/write-doc/SKILL.md',
@@ -204,6 +205,7 @@ export const GATED_PATHS: readonly string[] = [
204
205
  'claude/skills/review-ui/references/hosted-preview.md',
205
206
  'claude/skills/review-ui/references/post.md',
206
207
  'claude/skills/review-ui/references/tick.md',
208
+ 'claude/skills/role-orchestrator/REQUIREMENT.md',
207
209
  'claude/skills/role-orchestrator/SKILL.md',
208
210
  'claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md',
209
211
  'claude/skills/role-orchestrator/references/orchestrator-dispatch.md',
@@ -221,8 +223,10 @@ export const GATED_PATHS: readonly string[] = [
221
223
  'claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md',
222
224
  'claude/skills/role-orchestrator/references/orchestrator-sweep.md',
223
225
  'claude/skills/role-orchestrator/references/orchestrator-watch.md',
226
+ 'claude/skills/role-planner/REQUIREMENT.md',
224
227
  'claude/skills/role-planner/SKILL.md',
225
228
  'claude/skills/role-reviewer/SKILL.md',
229
+ 'claude/skills/role-worker/REQUIREMENT.md',
226
230
  'claude/skills/role-worker/SKILL.md',
227
231
  'claude/skills/search-craft/SKILL.md',
228
232
  'claude/skills/search-craft/references/adopted.md',
@@ -34,7 +34,7 @@ import {
34
34
  slugOf,
35
35
  } from '@/migrate/evidence-ordinal'
36
36
  import { presentFolders } from '@/records/backup'
37
- import { recordDir, SCRATCH } from '@/roots/record'
37
+ import { LEGACY_SCRATCH_REL, recordDir, SCRATCH } from '@/roots/record'
38
38
 
39
39
  /**
40
40
  * Every folder this promotion moves, at the name it carries under scratch.
@@ -64,9 +64,17 @@ export const PROMOTED_FOLDERS: readonly string[] = [
64
64
  'target-survey',
65
65
  ]
66
66
 
67
- /** Where a promoted folder sits before the move. */
67
+ /**
68
+ * Where a promoted folder sits before the move: `.canon/tmp` when it holds the
69
+ * folder, so a target part-way through a move promotes the newer copy, and the
70
+ * retired scratch folder otherwise. The verb is a migration, so it reads an
71
+ * unmigrated target where it is.
72
+ */
68
73
  export function sourcePath(root: string, folder: string): string {
69
- return recordDir(root, SCRATCH, folder)
74
+ const current = recordDir(root, SCRATCH, folder)
75
+ const legacy = join(root, LEGACY_SCRATCH_REL, folder)
76
+
77
+ return !existsSync(current) && existsSync(legacy) ? legacy : current
70
78
  }
71
79
 
72
80
  /** The folder a promoted one lands inside, under whichever root carries it. */
@@ -85,7 +93,7 @@ function escape(value: string): string {
85
93
  * swallowing a sibling folder whose name extends it.
86
94
  */
87
95
  const OLD_PREFIXES = [
88
- '.claude/.tmp/',
96
+ '.claude/.tmp/', // canon-keep-record-root
89
97
  '.canon/tmp/',
90
98
  '../.tmp/',
91
99
  '../tmp/',
@@ -107,9 +107,9 @@ export const EXCLUDED_ENTRIES = [
107
107
  /**
108
108
  * Whether a top-level name stays out of every push, at either root.
109
109
  *
110
- * `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
111
- * the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
112
- * spelling only.
110
+ * `SCRATCH` is the retired `.tmp` spelling. It is a guard rather than a lookup,
111
+ * keeping a stray `.tmp` out of a push, and `EXCLUDED_ENTRIES` names the
112
+ * `.canon` spelling only.
113
113
  */
114
114
  function isExcluded(name: string): boolean {
115
115
  return (
@@ -228,9 +228,9 @@ const PLAN_REQUIRED: readonly PlanSection[] = [
228
228
  ]
229
229
 
230
230
  /**
231
- * The files whose edit changes what a person sees, the set the `ui/a11y` rule
232
- * globs plus the other markup and style extensions a plan names. A plan touching
233
- * one owes a `Design references` section, so a set too wide forces it on every
231
+ * The files whose edit changes what a person sees, the set the `ui/a11y` and
232
+ * `ui/keyboard` rules glob plus the other markup and style extensions a plan
233
+ * names. A plan touching one owes a `Design references` section, so a set too wide forces it on every
234
234
  * plan and one too narrow lets a UI plan skip it.
235
235
  */
236
236
  const RENDERED_SURFACE = /\.(?:tsx|jsx|astro|vue|svelte|html|css|scss)$/
@@ -29,17 +29,22 @@ export type RecordRoot = (typeof RECORD_ROOTS)[number]
29
29
  export const CREATION_ROOT: RecordRoot = '.canon'
30
30
 
31
31
  /**
32
- * The deletable scratch folder, named at the spelling `.claude/` gives it.
32
+ * The deletable scratch folder, named at the spelling `.claude/` gave it.
33
33
  *
34
- * It is the one folder whose name differs by root, and callers name it at the
35
- * old spelling because `spell` is the only place the variant is decided. Inside
36
- * a dotted root the leading dot hides nothing already hidden and costs a bare
37
- * `ls` that omits the folder, so the move dropped it. Every other record folder
38
- * keeps its name, `.records.git` included, where the dot marks the mechanism
39
- * apart from a payload rather than hiding it.
34
+ * Scratch resolves at `.canon/tmp` only. The `.claude/.tmp` spelling is a
35
+ * migration source, named through `LEGACY_SCRATCH_REL` by the verbs that move
36
+ * it, and no lookup falls back to it. Callers still name the folder at this
37
+ * spelling because `spell` is the only place the variant is decided. Inside a
38
+ * dotted root the leading dot hides nothing already hidden and costs a bare
39
+ * `ls` that omits the folder, so the move dropped it. Every other record
40
+ * folder keeps its name, `.records.git` included, where the dot marks the
41
+ * mechanism apart from a payload rather than hiding it.
40
42
  */
41
43
  export const SCRATCH = '.tmp'
42
44
 
45
+ /** The retired scratch location, read only as the source of a migration. */
46
+ export const LEGACY_SCRATCH_REL = '.claude/.tmp' // canon-keep-record-root
47
+
43
48
  /** The scratch folder's name under `.canon/`. */
44
49
  const CANON_SCRATCH = 'tmp'
45
50
 
@@ -109,6 +114,8 @@ export function spell(root: RecordRoot, folder: string): string {
109
114
  * records it belongs to rather than at the creation default.
110
115
  */
111
116
  function rootOf(root: string, folder: string): RecordRoot {
117
+ if (folder === SCRATCH) return CREATION_ROOT
118
+
112
119
  return (
113
120
  RECORD_ROOTS.find((candidate) =>
114
121
  existsSync(join(root, candidate, spell(candidate, folder))),
@@ -147,7 +154,9 @@ export function recordDirs(
147
154
  folder: string,
148
155
  ...rest: string[]
149
156
  ): string[] {
150
- return RECORD_ROOTS.map((candidate) =>
157
+ const candidates = folder === SCRATCH ? [CREATION_ROOT] : RECORD_ROOTS
158
+
159
+ return candidates.map((candidate) =>
151
160
  join(root, candidate, spell(candidate, folder), ...rest),
152
161
  )
153
162
  }
@@ -16,6 +16,8 @@ import {
16
16
  import {
17
17
  applyScripts,
18
18
  collectDeps,
19
+ declaresInstalls,
20
+ minimalPackage,
19
21
  readPackage,
20
22
  serializePackage,
21
23
  } from '@/tooling/package'
@@ -156,15 +158,22 @@ export async function pruneGitignore(
156
158
 
157
159
  /**
158
160
  * Installs missing dev dependencies, fills package scripts, and merges
159
- * gitignore entries. A target with no package.json gets the gitignore alone,
160
- * plus a warning naming every install it skipped, since seeding a package.json
161
- * would pick a package manager and a project name on the target's behalf.
161
+ * gitignore entries. A target with no package.json gets a minimal one first
162
+ * when the chain installs anything. A package.json that exists but cannot be
163
+ * parsed is never rewritten, and the install is skipped with a warning.
162
164
  */
163
165
  export async function injectManifest(
164
166
  chain: readonly Manifest[],
165
167
  target: string,
166
168
  ): Promise<void> {
167
169
  const packagePath = join(target, 'package.json')
170
+
171
+ if (!existsSync(packagePath) && declaresInstalls(chain)) {
172
+ logStep('Writing package.json')
173
+ await writeFile(packagePath, serializePackage(minimalPackage(target)))
174
+ logAdd('package.json')
175
+ }
176
+
168
177
  const pkg = readPackage(packagePath)
169
178
 
170
179
  if (pkg) {
@@ -192,14 +201,22 @@ export async function injectManifest(
192
201
 
193
202
  await injectGitignore(chain, target)
194
203
 
195
- if (!pkg) warnSkippedInstalls(chain, target)
204
+ if (!pkg) warnUnreadablePackage(chain, target)
196
205
  }
197
206
 
198
- function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
207
+ /**
208
+ * Only an existing package.json that fails to parse reaches here with installs
209
+ * pending, since an absent one was written above. It is left untouched.
210
+ */
211
+ function warnUnreadablePackage(
212
+ chain: readonly Manifest[],
213
+ target: string,
214
+ ): void {
215
+ if (!declaresInstalls(chain)) return
216
+
199
217
  const deps = collectDeps(chain, {}).map((dep) => dep.spec)
200
218
  const skipped = applyScripts({}, chain)
201
219
  const scripts = [...new Set([...skipped.added, ...skipped.overridden])]
202
- if (deps.length === 0 && scripts.length === 0) return
203
220
 
204
221
  logStep('Skipped installs')
205
222
  if (deps.length > 0) {
@@ -208,5 +225,7 @@ function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
208
225
  if (scripts.length > 0) {
209
226
  logWarn(`Scripts not added: ${scripts.join(', ')}`)
210
227
  }
211
- logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
228
+ logWarn(
229
+ `package.json is not valid JSON: fix it in ${target}, then sync again`,
230
+ )
212
231
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
2
3
  import type { Manifest } from '@/tooling/manifest'
3
4
 
4
5
  export interface PackageJson {
@@ -28,6 +29,37 @@ export function readPackage(path: string): PackageJson | undefined {
28
29
  }
29
30
  }
30
31
 
32
+ /**
33
+ * Whether the chain asks for any dev dependency or script, which is what makes
34
+ * a missing package.json worth writing. A stack that installs nothing, such as
35
+ * the one `canon claude` drives, leaves a project without a manifest alone.
36
+ */
37
+ export function declaresInstalls(chain: readonly Manifest[]): boolean {
38
+ if (collectDeps(chain, {}).length > 0) return true
39
+
40
+ const scripts = applyScripts({}, chain)
41
+ return scripts.added.length > 0 || scripts.overridden.length > 0
42
+ }
43
+
44
+ /**
45
+ * The manifest a sync writes into a folder that has none. The name is the
46
+ * folder's, reduced to the characters npm accepts, so it is never empty or
47
+ * invalid.
48
+ */
49
+ export function minimalPackage(target: string): PackageJson {
50
+ const name = basename(target)
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9-._]/g, '-')
53
+ .replace(/^[._]+/, '')
54
+ .slice(0, 214)
55
+
56
+ return {
57
+ name: name === '' ? 'project' : name,
58
+ version: '0.0.0',
59
+ type: 'module',
60
+ }
61
+ }
62
+
31
63
  export function serializePackage(pkg: PackageJson): string {
32
64
  return `${JSON.stringify(pkg, null, 2)}\n`
33
65
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { PROJECT_ROOT } from '@/roots/project'
4
+ import { configFiles, type Manifest, seedFiles } from '@/tooling/manifest'
4
5
 
5
6
  /**
6
7
  * Spells the package root in a report where every other root spells a
@@ -11,6 +12,8 @@ import { PROJECT_ROOT } from '@/roots/project'
11
12
  const PACKAGE_LABEL = '<canon>'
12
13
 
13
14
  export interface ReferenceRoot {
15
+ /** The root whose `tooling/` this searches, as the manifest loader takes it. */
16
+ readonly root: string
14
17
  /** Absolute `tooling/` directory to search. */
15
18
  readonly dir: string
16
19
  /** How a report spells a copy found under this root. */
@@ -19,6 +22,8 @@ export interface ReferenceRoot {
19
22
 
20
23
  export interface ResolvedReference {
21
24
  readonly path: string
25
+ /** The root that won, so the manifest is read from the same copy. */
26
+ readonly root: string
22
27
  /** The label of the root that won, joined to the stack and filename. */
23
28
  readonly source: string
24
29
  }
@@ -36,8 +41,9 @@ function toolingSourceDir(root: string): string {
36
41
  */
37
42
  export function referenceRoots(root: string): ReferenceRoot[] {
38
43
  return [
39
- { dir: toolingSourceDir(root), label: 'tooling' },
44
+ { root, dir: toolingSourceDir(root), label: 'tooling' },
40
45
  {
46
+ root: PROJECT_ROOT,
41
47
  dir: toolingSourceDir(PROJECT_ROOT),
42
48
  label: join(PACKAGE_LABEL, 'tooling'),
43
49
  },
@@ -49,10 +55,10 @@ export function resolveReference(
49
55
  root: string,
50
56
  stack: string,
51
57
  ): ResolvedReference | undefined {
52
- for (const { dir, label } of referenceRoots(root)) {
58
+ for (const { root: owner, dir, label } of referenceRoots(root)) {
53
59
  const path = join(dir, stack, 'reference.md')
54
60
  if (existsSync(path)) {
55
- return { path, source: join(label, stack, 'reference.md') }
61
+ return { path, root: owner, source: join(label, stack, 'reference.md') }
56
62
  }
57
63
  }
58
64
 
@@ -62,3 +68,40 @@ export function resolveReference(
62
68
  export function readReference(reference: ResolvedReference): string {
63
69
  return readFileSync(reference.path, 'utf8')
64
70
  }
71
+
72
+ function codeList(items: readonly string[]): string {
73
+ return items.map((item) => `- \`${item}\``).join('\n')
74
+ }
75
+
76
+ /**
77
+ * The sections the verb prints after a stack's reference, read from its
78
+ * manifest so no reference keeps a hand list that drifts from it. A stack lists
79
+ * its own files and groups alone, since a parent's print under the parent's
80
+ * reference, and a section with nothing to list prints no heading.
81
+ */
82
+ export function renderStackSections(manifest: Manifest): string {
83
+ const sections: string[] = []
84
+ const configs = configFiles(manifest)
85
+ const seeds = seedFiles(manifest)
86
+
87
+ if (configs.length > 0 || seeds.length > 0) {
88
+ const sync = ['## Sync']
89
+ if (configs.length > 0) {
90
+ sync.push('Sync keeps these current, from `configs/`:', codeList(configs))
91
+ }
92
+ if (seeds.length > 0) {
93
+ sync.push('Sync copies these once, from `seeds/`:', codeList(seeds))
94
+ }
95
+ sections.push(sync.join('\n\n'))
96
+ }
97
+
98
+ if (manifest.gitignore.length > 0) {
99
+ const groups = manifest.gitignore.map(
100
+ ({ header, entries }) =>
101
+ `- \`${header}\`: ${entries.map((entry) => `\`${entry}\``).join(', ')}`,
102
+ )
103
+ sections.push(`## Gitignore\n\n${groups.join('\n')}`)
104
+ }
105
+
106
+ return sections.map((section) => `\n${section}\n`).join('')
107
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  collectDeps,
14
14
  collectScripts,
15
+ declaresInstalls,
15
16
  type DepState,
16
17
  readPackage,
17
18
  type ScriptState,
@@ -48,6 +49,7 @@ export interface ScanResult {
48
49
  readonly deps: readonly DepState[]
49
50
  readonly gitignore: readonly EntryState[]
50
51
  readonly hasPackageJson: boolean
52
+ readonly newManifest: boolean
51
53
  readonly totalChanges: number
52
54
  }
53
55
 
@@ -128,8 +130,10 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
128
130
 
129
131
  const packagePath = join(target, 'package.json')
130
132
  const pkg = readPackage(packagePath)
131
- const scripts = pkg ? collectScripts(chain, pkg) : []
132
- const deps = pkg ? collectDeps(chain, pkg) : []
133
+ const newManifest = !existsSync(packagePath) && declaresInstalls(chain)
134
+ const basis = pkg ?? (newManifest ? {} : undefined)
135
+ const scripts = basis ? collectScripts(chain, basis) : []
136
+ const deps = basis ? collectDeps(chain, basis) : []
133
137
 
134
138
  const gitignorePath = join(target, '.gitignore')
135
139
  const gitignoreContent = existsSync(gitignorePath)
@@ -138,6 +142,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
138
142
  const gitignore = scanGitignore(chain, gitignoreContent)
139
143
 
140
144
  const totalChanges =
145
+ (newManifest ? 1 : 0) +
141
146
  configs.filter((entry) => entry.state !== 'matching').length +
142
147
  seeds.filter((entry) => entry.state === 'missing').length +
143
148
  scripts.filter((entry) => entry.state !== 'matching').length +
@@ -152,6 +157,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
152
157
  deps,
153
158
  gitignore,
154
159
  hasPackageJson: pkg !== undefined,
160
+ newManifest,
155
161
  totalChanges,
156
162
  }
157
163
  }