@erclx/canon 4.67.0 → 4.69.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 (160) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/{claude-autoship → auto-ship}/REQUIREMENT.md +3 -3
  3. package/claude/skills/{claude-autoship → auto-ship}/SKILL.md +36 -36
  4. package/claude/skills/canon-cli/REQUIREMENT.md +2 -2
  5. package/claude/skills/canon-cli/SKILL.md +2 -2
  6. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +1 -1
  7. package/claude/skills/canon-feedback-triage/SKILL.md +3 -3
  8. package/claude/skills/canon-operator/REQUIREMENT.md +1 -1
  9. package/claude/skills/canon-operator/SKILL.md +4 -4
  10. package/claude/skills/canon-rollout/REQUIREMENT.md +6 -6
  11. package/claude/skills/canon-rollout/SKILL.md +7 -7
  12. package/claude/skills/{claude-design-extract → design-extract}/REQUIREMENT.md +4 -4
  13. package/claude/skills/{claude-design-extract → design-extract}/SKILL.md +1 -1
  14. package/claude/skills/{claude-docs → docs-fold}/REQUIREMENT.md +3 -3
  15. package/claude/skills/{claude-docs → docs-fold}/SKILL.md +14 -14
  16. package/claude/skills/{claude-docs → docs-fold}/references/anchor-sweep.md +1 -1
  17. package/claude/skills/{claude-docs → docs-fold}/references/wireframe-sweep.md +1 -1
  18. package/claude/skills/docs-sync/REQUIREMENT.md +3 -3
  19. package/claude/skills/docs-sync/SKILL.md +1 -1
  20. package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -4
  21. package/claude/skills/draft-and-pick/SKILL.md +6 -6
  22. package/claude/skills/draft-context/REQUIREMENT.md +2 -2
  23. package/claude/skills/draft-context/SKILL.md +3 -3
  24. package/claude/skills/{claude-diagram → draft-diagram}/REQUIREMENT.md +4 -4
  25. package/claude/skills/{claude-diagram → draft-diagram}/SKILL.md +3 -3
  26. package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
  27. package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
  28. package/claude/skills/draft-wireframes/SKILL.md +2 -2
  29. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  30. package/claude/skills/git-followup/SKILL.md +1 -1
  31. package/claude/skills/git-pr/SKILL.md +3 -3
  32. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  33. package/claude/skills/git-ship/SKILL.md +8 -8
  34. package/claude/skills/git-worktree/REQUIREMENT.md +2 -2
  35. package/claude/skills/git-worktree/SKILL.md +3 -3
  36. package/claude/skills/identity/REQUIREMENT.md +2 -2
  37. package/claude/skills/identity/SKILL.md +3 -3
  38. package/claude/skills/{claude-markdown-propose → markdown-propose}/REQUIREMENT.md +8 -8
  39. package/claude/skills/{claude-markdown-propose → markdown-propose}/SKILL.md +5 -5
  40. package/claude/skills/{claude-markdown-propose → markdown-propose}/references/format.md +1 -1
  41. package/claude/skills/{claude-memory-capture → memory-capture}/REQUIREMENT.md +5 -5
  42. package/claude/skills/{claude-memory-capture → memory-capture}/SKILL.md +13 -13
  43. package/claude/skills/{claude-memory-review → memory-review}/REQUIREMENT.md +4 -4
  44. package/claude/skills/{claude-memory-review → memory-review}/SKILL.md +10 -10
  45. package/claude/skills/migration-context/SKILL.md +1 -1
  46. package/claude/skills/migration-standards-drop/REQUIREMENT.md +1 -1
  47. package/claude/skills/migration-superseded/REQUIREMENT.md +1 -1
  48. package/claude/skills/{claude-feature → plan-feature}/REQUIREMENT.md +4 -4
  49. package/claude/skills/{claude-feature → plan-feature}/SKILL.md +4 -4
  50. package/claude/skills/{claude-groundwork → plan-groundwork}/REQUIREMENT.md +5 -5
  51. package/claude/skills/{claude-groundwork → plan-groundwork}/SKILL.md +8 -8
  52. package/claude/skills/{claude-intake → plan-intake}/REQUIREMENT.md +6 -6
  53. package/claude/skills/{claude-intake → plan-intake}/SKILL.md +10 -10
  54. package/claude/skills/{claude-intake-answer → plan-intake-answer}/REQUIREMENT.md +4 -4
  55. package/claude/skills/{claude-intake-answer → plan-intake-answer}/SKILL.md +5 -5
  56. package/claude/skills/{claude-address-review → review-address}/REQUIREMENT.md +5 -5
  57. package/claude/skills/{claude-address-review → review-address}/SKILL.md +6 -6
  58. package/claude/skills/{claude-address-review → review-address}/references/rebase-conflicts.md +1 -1
  59. package/claude/skills/{claude-review → review-branch}/REQUIREMENT.md +4 -4
  60. package/claude/skills/{claude-review → review-branch}/SKILL.md +4 -4
  61. package/claude/skills/{claude-pr-review → review-pr}/REQUIREMENT.md +5 -5
  62. package/claude/skills/{claude-pr-review → review-pr}/SKILL.md +11 -11
  63. package/claude/skills/{claude-orchestrate → role-orchestrator}/REQUIREMENT.md +5 -5
  64. package/claude/skills/{claude-orchestrate → role-orchestrator}/SKILL.md +20 -20
  65. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-dispatch.md +30 -30
  66. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-handoff.md +2 -2
  67. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-parked.md +8 -8
  68. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-poll.md +8 -8
  69. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-resume.md +1 -1
  70. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-sweep.md +1 -1
  71. package/claude/skills/{claude-orchestrate → role-orchestrator}/scripts/poll.sh +6 -6
  72. package/claude/skills/{claude-planner → role-planner}/REQUIREMENT.md +12 -12
  73. package/claude/skills/{claude-planner → role-planner}/SKILL.md +4 -4
  74. package/claude/skills/{claude-worker → role-worker}/REQUIREMENT.md +8 -8
  75. package/claude/skills/{claude-worker → role-worker}/SKILL.md +6 -6
  76. package/claude/skills/{claude-seed-sync → seed-sync}/REQUIREMENT.md +2 -2
  77. package/claude/skills/{claude-seed-sync → seed-sync}/SKILL.md +3 -3
  78. package/claude/skills/session-map/REQUIREMENT.md +2 -2
  79. package/claude/skills/session-map/SKILL.md +2 -2
  80. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  81. package/claude/skills/session-resume/SKILL.md +2 -2
  82. package/claude/skills/{claude-worktree → session-worktree}/REQUIREMENT.md +3 -3
  83. package/claude/skills/{claude-worktree → session-worktree}/SKILL.md +6 -6
  84. package/claude/skills/setup-plugins/references/plugin-catalog.md +1 -1
  85. package/claude/skills/{claude-standards-audit → standards-audit}/REQUIREMENT.md +2 -2
  86. package/claude/skills/{claude-standards-audit → standards-audit}/SKILL.md +2 -2
  87. package/claude/skills/systematic-debugging/REQUIREMENT.md +1 -1
  88. package/claude/skills/{claude-tasks → task-board}/REQUIREMENT.md +3 -3
  89. package/claude/skills/{claude-tasks → task-board}/SKILL.md +7 -7
  90. package/claude/skills/{claude-teach → teach-workspace}/REQUIREMENT.md +2 -2
  91. package/claude/skills/{claude-teach → teach-workspace}/SKILL.md +4 -4
  92. package/claude/skills/test-first/REQUIREMENT.md +2 -2
  93. package/claude/skills/{claude-ui-test → ui-test}/REQUIREMENT.md +3 -3
  94. package/claude/skills/{claude-ui-test → ui-test}/SKILL.md +3 -3
  95. package/claude/skills/{claude-ux-audit → ux-audit}/REQUIREMENT.md +6 -6
  96. package/claude/skills/{claude-ux-audit → ux-audit}/SKILL.md +5 -5
  97. package/claude/skills/{claude-ux-measure → ux-measure}/REQUIREMENT.md +5 -5
  98. package/claude/skills/{claude-ux-measure → ux-measure}/SKILL.md +5 -5
  99. package/docs/agents/commands.md +1 -0
  100. package/docs/agents/install-and-sync.md +1 -1
  101. package/docs/agents/key-changes.md +3 -3
  102. package/docs/agents/markdown-audit.md +1 -1
  103. package/docs/agents/restated.md +1 -1
  104. package/docs/agents/review-classification.md +1 -1
  105. package/docs/agents/sandbox.md +13 -10
  106. package/docs/agents/sessions.md +1 -1
  107. package/docs/agents/state-scoped-risk.md +1 -1
  108. package/docs/agents/targets.md +1 -1
  109. package/docs/agents/tasks.md +4 -4
  110. package/docs/agents/teach.md +1 -1
  111. package/docs/target-projects.md +10 -10
  112. package/docs/workflow/ai-workflow.md +84 -84
  113. package/docs/workflow/operating-model.md +17 -17
  114. package/docs/workflow/visual-design-workflow.md +6 -6
  115. package/governance/rules/core/045-memory.md +1 -1
  116. package/governance/rules/core/085-worktrees.md +1 -1
  117. package/package.json +1 -1
  118. package/scripts/core/regen-agent-fixture.sh +1 -1
  119. package/scripts/core/regen-hero.sh +7 -7
  120. package/scripts/lib/sandbox-dispatch.sh +8 -0
  121. package/snippets/claude/decision-memo.md +1 -1
  122. package/src/autoship/paths.ts +1 -1
  123. package/src/claude/cases/all.ts +2 -2
  124. package/src/claude/cases/{claude-workflow.ts → workflow.ts} +33 -33
  125. package/src/claude/plugin-update.ts +48 -0
  126. package/src/commands/claude.ts +281 -1
  127. package/src/commands/feedback.ts +15 -5
  128. package/src/commands/gate.ts +3 -1
  129. package/src/commands/sandbox.ts +13 -4
  130. package/src/commands/sync.ts +3 -3
  131. package/src/design/components.ts +12 -0
  132. package/src/design/tokens.ts +1 -1
  133. package/src/gate/measures.ts +47 -1
  134. package/src/gov/restated.ts +2 -2
  135. package/src/markdown/structure.ts +1 -1
  136. package/src/migrate/rename.ts +4 -3
  137. package/src/pr/bijection.ts +1 -1
  138. package/src/pr/paths.ts +2 -2
  139. package/src/sandbox/expect.ts +26 -1
  140. package/src/shipped/references.ts +1 -1
  141. package/src/sync/seeds-report.ts +1 -1
  142. package/src/targets/pulls.ts +2 -2
  143. package/src/tasks/answers.ts +1 -1
  144. package/src/tasks/archive.ts +4 -4
  145. package/src/tasks/record.ts +2 -2
  146. package/src/tasks/validate.ts +1 -1
  147. package/src/teach/nav.ts +97 -3
  148. package/standards/glossary.md +8 -0
  149. package/standards/groundwork.md +1 -1
  150. package/standards/snippets.md +1 -1
  151. package/standards/tasks.md +3 -3
  152. package/standards/teach.md +2 -2
  153. package/tooling/base/configs/.husky/post-merge +3 -3
  154. package/tooling/claude/reference.md +5 -5
  155. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  156. /package/claude/skills/{claude-memory-review → memory-review}/references/receipt-format.md +0 -0
  157. /package/claude/skills/{claude-orchestrate → role-orchestrator}/scripts/watch.sh +0 -0
  158. /package/claude/skills/{claude-teach → teach-workspace}/references/lesson-craft.md +0 -0
  159. /package/claude/skills/{claude-teach → teach-workspace}/references/pedagogy.md +0 -0
  160. /package/claude/skills/{claude-teach → teach-workspace}/references/promotion.md +0 -0
@@ -78,6 +78,7 @@ interface CheckOptions {
78
78
  readonly writes?: string
79
79
  readonly escapes?: string
80
80
  readonly escapesWatched?: boolean
81
+ readonly concurrentSessions?: string
81
82
  readonly json?: boolean
82
83
  readonly strict?: boolean
83
84
  }
@@ -349,7 +350,7 @@ function runCheck(
349
350
 
350
351
  const parsed = parseTarget(target)
351
352
  if (parsed === undefined) {
352
- logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
353
+ logError('Invalid target. Use <category>:<command>, e.g. claude:docs-fold.')
353
354
  outro()
354
355
  process.exitCode = 1
355
356
  return
@@ -376,6 +377,7 @@ function runCheck(
376
377
  options.escapes === undefined
377
378
  ? undefined
378
379
  : options.escapesWatched === true,
380
+ concurrentSessions: readPathList(options.concurrentSessions),
379
381
  envelope: readEnvelope(options.envelope),
380
382
  },
381
383
  )
@@ -414,7 +416,10 @@ export function register(program: Command): void {
414
416
  sandbox
415
417
  .command('check')
416
418
  .description('Check a provisioned sandbox against a scenario expectation')
417
- .argument('<target>', 'Scenario as <category>:<command>, e.g. claude:docs')
419
+ .argument(
420
+ '<target>',
421
+ 'Scenario as <category>:<command>, e.g. claude:docs-fold',
422
+ )
418
423
  .argument('[arm]', 'Named scenario arm, e.g. drift')
419
424
  .helpOption('-h, --help', 'Show this help message')
420
425
  .option('--envelope <file>', 'Run envelope JSON from claude -p')
@@ -427,6 +432,10 @@ export function register(program: Command): void {
427
432
  '--escapes-watched',
428
433
  'At least one watched root held a target this run, so a zero-escape file is a clean watch rather than one with nothing to watch',
429
434
  )
435
+ .option(
436
+ '--concurrent-sessions <file>',
437
+ 'Newline-delimited sessions live in the registry both before and after this run, a witness for an unbounded escape',
438
+ )
430
439
  .option('--json', 'Emit the verdict as JSON on stdout')
431
440
  .option('--strict', 'Exit non-zero when the arm declares no expectation')
432
441
  .addHelpText(
@@ -434,8 +443,8 @@ export function register(program: Command): void {
434
443
  [
435
444
  '',
436
445
  'Examples:',
437
- ' canon sandbox check claude:docs drift',
438
- ' canon sandbox check claude:docs drift --envelope run.json --json',
446
+ ' canon sandbox check claude:docs-fold drift',
447
+ ' canon sandbox check claude:docs-fold drift --envelope run.json --json',
439
448
  '',
440
449
  'Exit codes: 0 on pass or unchecked, 1 on failure.',
441
450
  'With --strict, unchecked exits 1 as well.',
@@ -265,7 +265,7 @@ function renderTooling(report: CheckReport): void {
265
265
  /**
266
266
  * Seeds print their own section because no sync command applies them. A `stale`
267
267
  * seed is safe to take whole and a `drifted` one holds edits, which is the split
268
- * `claude-seed-sync` reads to decide what needs a section-level merge.
268
+ * `seed-sync` reads to decide what needs a section-level merge.
269
269
  */
270
270
  function renderSeeds(report: CheckReport): void {
271
271
  const notable = report.seeds.entries.filter(
@@ -283,7 +283,7 @@ function renderSeeds(report: CheckReport): void {
283
283
  logWarn(`${entry.rel} (${entry.state})`)
284
284
  }
285
285
 
286
- logInfo('Run /canon:claude-seed-sync to reconcile these section by section.')
286
+ logInfo('Run /canon:seed-sync to reconcile these section by section.')
287
287
  }
288
288
 
289
289
  /**
@@ -399,7 +399,7 @@ async function runSync(target: string): Promise<number> {
399
399
 
400
400
  if (existsSync(join(resolved, '.claude'))) {
401
401
  process.stderr.write(
402
- `${GREY}Tip: run \`/claude-seed-sync\` to audit seed drift per section, preserving local customizations.${NC}\n`,
402
+ `${GREY}Tip: run \`/seed-sync\` to audit seed drift per section, preserving local customizations.${NC}\n`,
403
403
  )
404
404
  }
405
405
 
@@ -998,6 +998,18 @@ h2 .count {
998
998
  line-height: 1.55;
999
999
  }
1000
1000
 
1001
+ .gloss-group {
1002
+ margin: 1.1rem 0 0.4rem;
1003
+ font-family: var(--teach-sans);
1004
+ font-size: 0.8125rem;
1005
+ font-weight: 600;
1006
+ color: var(--color-muted);
1007
+ text-transform: uppercase;
1008
+ letter-spacing: 0.02em;
1009
+ }
1010
+
1011
+ .gloss-group:first-of-type { margin-top: 0; }
1012
+
1001
1013
  .gterm b { font-weight: 700; }
1002
1014
  /* The markdown source separates a term from its definition with a colon,
1003
1015
  and the house standard bans an em dash outright, so the view mirrors
@@ -354,7 +354,7 @@ export const TOKENS: DesignTokens = {
354
354
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
355
355
 
356
356
  iconography:
357
- "No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing. The same mark also ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift: `regen-hero.sh` derives one from the live SVG colored with whatever `--color-accent` (`#e0724b`) the fetched token CSS carries, `src/design/render.ts` carries the path data as a hardcoded literal colored via `colorValue('light-accent')` (`#a4471c`), since a data URI has no CSS context and that page renders on light chrome, and `claude-teach`'s `SKILL.md` names one in prose colored `rgb(224,114,75)`, the same value as the dark accent written as decimal rather than hex to clear the shipped-references gate's commit-sha check. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for.",
357
+ "No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing. The same mark also ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift: `regen-hero.sh` derives one from the live SVG colored with whatever `--color-accent` (`#e0724b`) the fetched token CSS carries, `src/design/render.ts` carries the path data as a hardcoded literal colored via `colorValue('light-accent')` (`#a4471c`), since a data URI has no CSS context and that page renders on light chrome, and `teach-workspace`'s `SKILL.md` names one in prose colored `rgb(224,114,75)`, the same value as the dark accent written as decimal rather than hex to clear the shipped-references gate's commit-sha check. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for.",
358
358
  }
359
359
 
360
360
  /** A role's value, or `undefined` where the record declares no such role. */
@@ -110,6 +110,17 @@ export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
110
110
  */
111
111
  export const SANDBOX_UNDECLARED_CEILING = 47
112
112
 
113
+ /**
114
+ * Skills the sandbox skill census reports `asserted`, taken from `canon
115
+ * sandbox coverage --skills` against a clean tree. A dropped pairing lowers
116
+ * this directly, unlike the ceiling above, which a rename can starve without
117
+ * moving: fourteen arms drifted off their skill's name in one branch and the
118
+ * scenario-level ceiling stayed green throughout, since it counts scenarios
119
+ * declaring an expectation rather than skills a scenario reaches. Lowering
120
+ * this floor is a deliberate edit that says which skill lost its arm and why.
121
+ */
122
+ export const SANDBOX_ASSERTED_FLOOR = 26
123
+
113
124
  /**
114
125
  * The retained counts the audit stage compares each run against. Spelled here
115
126
  * rather than derived, because this stage only ever names the file in a remedy
@@ -740,10 +751,45 @@ export const sandboxCoverage: Measure = async (ctx) => {
740
751
  }
741
752
  }
742
753
 
754
+ const scenarioEmission = info(
755
+ `${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
756
+ )
757
+
758
+ const skillsRun = await ctx.cli(['sandbox', 'coverage', '--skills', '--json'])
759
+
760
+ if (skillsRun.exitCode !== 0) {
761
+ return {
762
+ emissions: [scenarioEmission],
763
+ unmeasured: `The skill census did not report (exit ${skillsRun.exitCode}). It ships in the checkout beside the scenario report, so a run that does not report is a broken command rather than an absent census.`,
764
+ }
765
+ }
766
+
767
+ const skillsRecord = parseJson(skillsRun.stdout) as
768
+ | { asserted?: unknown; totalSkills?: unknown }
769
+ | undefined
770
+ const asserted = skillsRecord?.asserted
771
+ const totalSkills = skillsRecord?.totalSkills
772
+
773
+ if (typeof asserted !== 'number' || typeof totalSkills !== 'number') {
774
+ return {
775
+ emissions: [scenarioEmission],
776
+ failure:
777
+ 'The skill census carried no asserted total, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --skills --json.',
778
+ }
779
+ }
780
+
781
+ if (asserted < SANDBOX_ASSERTED_FLOOR) {
782
+ return {
783
+ emissions: [scenarioEmission],
784
+ failure: `${asserted} of ${totalSkills} skills asserted, under the floor of ${SANDBOX_ASSERTED_FLOOR}. A rename or a moved arm likely dropped a skill's pairing; repair the arm's filename against its skill and say which skill lost its arm, or lower SANDBOX_ASSERTED_FLOOR in src/gate/measures.ts.`,
785
+ }
786
+ }
787
+
743
788
  return {
744
789
  emissions: [
790
+ scenarioEmission,
745
791
  info(
746
- `${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
792
+ `${asserted} of ${totalSkills} skills asserted, against a floor of ${SANDBOX_ASSERTED_FLOOR}`,
747
793
  ),
748
794
  ],
749
795
  }
@@ -24,7 +24,7 @@ export const RULES_REL = join('governance', 'rules')
24
24
  /**
25
25
  * Path pairs whose duplication is deliberate and already recorded.
26
26
  *
27
- * The seed is authored from the always-loaded file and `claude-seed-sync`
27
+ * The seed is authored from the always-loaded file and `seed-sync`
28
28
  * exists to reconcile the two, so a bullet appearing in both is the design
29
29
  * rather than a defect. Excluding by pair rather than by content is what the
30
30
  * plan settled on: the duplication is a location fact this repository already
@@ -574,7 +574,7 @@ function authorityFor(
574
574
  if (candidate.kind === 'seed') {
575
575
  return {
576
576
  authority: 'claude-md',
577
- reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through claude-seed-sync`,
577
+ reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through seed-sync`,
578
578
  }
579
579
  }
580
580
 
@@ -26,7 +26,7 @@ const HEADING = /^#{1,6}\s/
26
26
  * run and a false one shortens every run around it until the measure stops
27
27
  * reporting, which is the dearer of the two.
28
28
  *
29
- * A colon ends most markers and not all of them. `claude-pr-review` writes
29
+ * A colon ends most markers and not all of them. `review-pr` writes
30
30
  * three colon-less ones into every body it posts and a bold path heading for
31
31
  * each file it reviews, so requiring the colon held a real seam out. Two
32
32
  * signals stand in where the colon is absent, because the shape a marker has to
@@ -46,8 +46,9 @@ export interface RenameRuleSpec {
46
46
  * A rename whose tokens are whole names wants this, and one whose tokens are
47
47
  * word stems cannot have it. `aitk` is a stem that legitimately carries a
48
48
  * suffix, as in `aitk-allow-superseded`, so requiring a boundary there would
49
- * leave every hyphenated form behind. A skill name is not a stem, and
50
- * `claude-worktree` inside `claude-worktrees` is a different subject: the
49
+ * leave every hyphenated form behind. A skill name is not a stem, and the
50
+ * spelling below is the pre-rename one on purpose (canon-keep-retired):
51
+ * `claude-worktree` inside `claude-worktrees` is a different subject, the
51
52
  * wiki page about the harness feature, which the rename must not move.
52
53
  */
53
54
  readonly wholeToken?: boolean
@@ -88,7 +89,7 @@ const NEVER_MATCHES = '(?!)'
88
89
  *
89
90
  * A plain word boundary rejects a following letter and accepts a following
90
91
  * hyphen, since `\b` reads a hyphen as the end of a word. That leaves
91
- * `claude-intake` matching inside `claude-intake-answer`, with the ordering of
92
+ * `plan-intake` matching inside `plan-intake-answer`, with the ordering of
92
93
  * the alternation the only thing standing between them. Naming the characters
93
94
  * that continue an identifier holds on both, so the ordering and the boundary
94
95
  * each cover what the other could miss, and a slash, a dot, or a backtick
@@ -99,7 +99,7 @@ function owesNoBullet(path: string): boolean {
99
99
  * names.
100
100
  *
101
101
  * An unanchored claim matches on a segment-anchored suffix, which is what lets
102
- * `claude-worker/SKILL.md` credit `claude/skills/claude-worker/SKILL.md`. That
102
+ * `role-worker/SKILL.md` credit `claude/skills/role-worker/SKILL.md`. That
103
103
  * asymmetry is deliberate: a partial spelling can confirm a changed file was
104
104
  * named and never accuse one of being absent, because nothing here separates a
105
105
  * path written short from a path written wrong.
package/src/pr/paths.ts CHANGED
@@ -20,7 +20,7 @@ export interface PathClaim {
20
20
  * True when the first segment names an entry the tree actually holds.
21
21
  *
22
22
  * An unanchored claim is a path written partially, such as
23
- * `claude-worker/SKILL.md` for a file under `claude/skills/`. It can confirm
23
+ * `role-worker/SKILL.md` for a file under `claude/skills/`. It can confirm
24
24
  * that a changed file was named and can never accuse one of being absent,
25
25
  * because the comparison has no way to tell a partial spelling from a
26
26
  * genuinely wrong one.
@@ -145,7 +145,7 @@ function maskSpans(text: string): string {
145
145
  * that came out. A path past the comma was left out of the claim set entirely
146
146
  * and fell to the unnamed direction, accepted here on the ground that the
147
147
  * direction reports without grading. It graded anyway, one consumer removed:
148
- * `claude-pr-review` reads `unnamed` as a question to put to the branch author,
148
+ * `review-pr` reads `unnamed` as a question to put to the branch author,
149
149
  * and on 2026-09-01 the question went to three pull requests over bullets that
150
150
  * had named the files all along, with `#1329` gaining bullets it did not need.
151
151
  *
@@ -78,6 +78,14 @@ export interface CheckInput {
78
78
  * nothing to watch, both of which produce the same empty `escapes` list.
79
79
  */
80
80
  readonly escapesWatched?: boolean
81
+ /**
82
+ * Names of sessions the client's own registry carried both before and after
83
+ * the run, so present rather than dispatched by it. Undefined when the
84
+ * caller supplied none, which is not the same as a run that had no witness:
85
+ * the registry carries no contract, so a client that stops writing a record
86
+ * per session makes this list empty regardless of who else was live.
87
+ */
88
+ readonly concurrentSessions?: readonly string[]
81
89
  readonly envelope?: RunEnvelope
82
90
  }
83
91
 
@@ -386,11 +394,19 @@ function checkWriteScope(
386
394
  * empty `escapes` list. `watched` is what tells them apart: a run that had
387
395
  * nothing to watch reports unmeasured rather than passing on a diff it never
388
396
  * had the target to take.
397
+ *
398
+ * A witnessed concurrent session never softens the verdict. `concurrentSessions`
399
+ * is evidence a reader can act on without a second lookup, appended to the
400
+ * message on an unbounded escape, never a reason to pass or skip one: a session
401
+ * merely alive throughout the run proves someone else was busy, not that a
402
+ * given file is theirs, and a real dispatch escape reads identically to an
403
+ * innocent sibling's write either way.
389
404
  */
390
405
  function checkEscapeScope(
391
406
  expectation: Expectation,
392
407
  escapes: readonly string[] | undefined,
393
408
  watched: boolean | undefined,
409
+ concurrentSessions: readonly string[] | undefined,
394
410
  ): KindOutcome {
395
411
  if (expectation.escapeScope === undefined) return { results: [], skipped: [] }
396
412
 
@@ -416,12 +432,20 @@ function checkEscapeScope(
416
432
  }
417
433
 
418
434
  const globs = expectation.escapeScope.map((glob) => new Bun.Glob(glob))
435
+ const witnessCount = concurrentSessions?.length ?? 0
436
+ const witnessSuffix =
437
+ witnessCount > 0
438
+ ? ` (${witnessCount} session${witnessCount === 1 ? '' : 's'} live throughout the run)`
439
+ : ''
419
440
 
420
441
  return {
421
442
  results: escapes.map((path) =>
422
443
  globs.some((glob) => glob.match(path))
423
444
  ? { ok: true, message: `declared escape: ${path}` }
424
- : { ok: false, message: `unbounded escape: ${path}` },
445
+ : {
446
+ ok: false,
447
+ message: `unbounded escape: ${path}${witnessSuffix}`,
448
+ },
425
449
  ),
426
450
  skipped: [],
427
451
  }
@@ -514,6 +538,7 @@ export function checkExpectation(
514
538
  expectation,
515
539
  input.escapes,
516
540
  input.escapesWatched,
541
+ input.concurrentSessions,
517
542
  )
518
543
  const reply = checkReply(expectation, input.envelope)
519
544
  const envelope = checkEnvelope(expectation, input.envelope)
@@ -61,7 +61,7 @@ export const REFERENCE_MARKER = 'canon-allow-reference'
61
61
  * The leading boundary is what excludes the repair form by construction rather
62
62
  * than by exemption, and that is load bearing. A lookbehind rejecting a word
63
63
  * character before `#` never matches `erclx/canon#1299`, so a qualified
64
- * reference passes with no marker, and `claude/skills/claude-worktree/SKILL.md`
64
+ * reference passes with no marker, and `claude/skills/session-worktree/SKILL.md`
65
65
  * already ships `anthropics/claude-code#58345` in exactly that form.
66
66
  *
67
67
  * The trailing boundary is what the first shape of this pattern lacked, and it
@@ -34,7 +34,7 @@ export interface SeedsReport {
34
34
  * Classifies every seed the toolkit ships against the target's copy, and never
35
35
  * returns a change. Seeds are copy-once files a project is expected to edit, so
36
36
  * the engine's copy path would overwrite `CLAUDE.md` wholesale. Reporting alone
37
- * is what lets `claude-seed-sync` merge one section at a time instead.
37
+ * is what lets `seed-sync` merge one section at a time instead.
38
38
  *
39
39
  * Attribution reuses the history reader rather than the engine's own recovery
40
40
  * pass, which is private and takes a `SyncAdapter` seeds have no way to supply.
@@ -7,8 +7,8 @@ const GH_TIMEOUT_MS = 30_000
7
7
  /**
8
8
  * The two headings a review pass posts under.
9
9
  *
10
- * Owned by `claude-pr-review`, which states the full set once, and pinned here
11
- * the way `claude-orchestrate/scripts/poll.sh` pins them. All three surfaces
10
+ * Owned by `review-pr`, which states the full set once, and pinned here
11
+ * the way `role-orchestrator/scripts/poll.sh` pins them. All three surfaces
12
12
  * ship separately, so a heading added in that skill goes stale here with
13
13
  * nothing comparing the copies.
14
14
  */
@@ -186,7 +186,7 @@ export function resolvePlanReference(
186
186
  }
187
187
 
188
188
  // An archived plan answers every question and would report as launchable, so
189
- // the name would clear a dispatch that `claude-autoship` Step 1 then refuses
189
+ // the name would clear a dispatch that `auto-ship` Step 1 then refuses
190
190
  // as already-shipped work. Catching it here is a step earlier than the worker.
191
191
  // Both roots, for the reason `resolveLivePlan` carries: the reference is a
192
192
  // string a caller wrote, and one spelling the root this tree has since left is
@@ -269,7 +269,7 @@ function isRowFor(line: string, target: string): boolean {
269
269
 
270
270
  /**
271
271
  * Resolves the `Plan:` target against the board and against the project root
272
- * both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`
272
+ * both, which is how `docs-fold` reads the same line. It accepts `../plans/x.md`
273
273
  * and `.canon/plans/x.md` as one file, so a gate reading only the first form
274
274
  * would pass the second and strand the plan this exists to protect.
275
275
  *
@@ -323,7 +323,7 @@ function atOneRoot(path: string, plans: string[], root: string): string {
323
323
 
324
324
  /**
325
325
  * Names the other live tasks whose `Plan:` line lands on the same file. This is
326
- * the rule `claude-docs` applies before it archives a plan, held here so one
326
+ * the rule `docs-fold` applies before it archives a plan, held here so one
327
327
  * question has one implementation: a plan another live task still cites is a
328
328
  * plan the sweep is correct to leave, and a guard that read the folder instead
329
329
  * refused every task sharing one plan and deadlocked the board against the
@@ -380,7 +380,7 @@ export type CitationOutcome = PlanCitations | ArchiveRefused
380
380
 
381
381
  /**
382
382
  * Answers where one task's plan sits and who else holds it, which is the whole
383
- * of the last-live-citation rule. `claude-docs` reads this rather than scanning
383
+ * of the last-live-citation rule. `docs-fold` reads this rather than scanning
384
384
  * the board itself, so the sweep that moves a plan and the gate that refuses a
385
385
  * task archive cannot drift into disagreeing about which plan is free.
386
386
  *
@@ -564,7 +564,7 @@ async function resolveStem(
564
564
 
565
565
  /**
566
566
  * Archives one task as a single unit: the move, the ordering-row removal, and
567
- * the index regen. The hook and `claude-tasks` both call this, so every gate
567
+ * the index regen. The hook and `task-board` both call this, so every gate
568
568
  * refuses rather than reports. A caller with nobody watching cannot act on a
569
569
  * warning, and two callers gating differently is the drift this exists to stop.
570
570
  */
@@ -311,7 +311,7 @@ export async function recordPullRequest(
311
311
 
312
312
  /**
313
313
  * Records a plan's path on the task it belongs to, as the task's `Plan:` line.
314
- * `claude-feature` runs this right after the plan file lands, resolving the
314
+ * `plan-feature` runs this right after the plan file lands, resolving the
315
315
  * reference the same two ways `canon tasks plan-answers` does, through
316
316
  * `planCandidates`, so a bare slug and a board-relative path both resolve.
317
317
  *
@@ -346,7 +346,7 @@ export async function recordPlan(
346
346
  }
347
347
 
348
348
  /**
349
- * Marks the named outcomes `[x]` in place. `claude-docs` runs this against a
349
+ * Marks the named outcomes `[x]` in place. `docs-fold` runs this against a
350
350
  * board it can read and cannot edit from a linked worktree, and the positions
351
351
  * come from the read it already made.
352
352
  */
@@ -557,7 +557,7 @@ async function listTaskStems(dir: string): Promise<string[]> {
557
557
 
558
558
  /**
559
559
  * Resolves a pointer against the board and against the project root both, the
560
- * way `claude-docs` reads the same line. A fragment is dropped first, since an
560
+ * way `docs-fold` reads the same line. A fragment is dropped first, since an
561
561
  * anchor is part of the link and never part of the path.
562
562
  */
563
563
  function resolves(target: string, dir: string, root: string): boolean {
package/src/teach/nav.ts CHANGED
@@ -109,6 +109,17 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
109
109
  var input = document.getElementById("gfilter");
110
110
  var list = document.getElementById("gloss");
111
111
  if (!input || !list) return;
112
+ function updateGroups() {
113
+ list.querySelectorAll(".gloss-group").forEach(function (heading) {
114
+ var el = heading.nextElementSibling;
115
+ var any = false;
116
+ while (el && !el.classList.contains("gloss-group")) {
117
+ if (el.style.display !== "none") any = true;
118
+ el = el.nextElementSibling;
119
+ }
120
+ heading.style.display = any ? "" : "none";
121
+ });
122
+ }
112
123
  input.addEventListener("input", function () {
113
124
  var q = input.value.toLowerCase();
114
125
  var n = 0;
@@ -118,6 +129,7 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
118
129
  if (match) n++;
119
130
  });
120
131
  list.classList.toggle("none", n === 0);
132
+ updateGroups();
121
133
  });
122
134
  var clear = list.querySelector(".clear");
123
135
  if (clear) {
@@ -457,8 +469,90 @@ function renderGlossaryEntry(entry: string): string {
457
469
  return `<div class="gterm"><b>${escapeHtml(term)}</b><span>${escapeHtml(definition)}</span></div>`
458
470
  }
459
471
 
460
- function renderGlossarySection(entries: readonly string[]): string {
461
- const rendered = entries.map(renderGlossaryEntry).join('')
472
+ const FIRST_SEEN_PATTERN = / First seen in (.+)\.$/
473
+
474
+ /**
475
+ * The lesson or reference page an entry's own "First seen in" sentence
476
+ * names, absent when the entry predates that citation convention.
477
+ */
478
+ function firstSeenFile(entry: string): string | undefined {
479
+ return FIRST_SEEN_PATTERN.exec(entry)?.[1]
480
+ }
481
+
482
+ /**
483
+ * `firstSeenFile` names a page free-form, per the `--first-seen` flag it
484
+ * comes from, so it may carry a directory prefix a lesson's own `file` does
485
+ * not. Comparing basenames is what keeps `lessons/0001-x.html` and
486
+ * `0001-x.html` resolving to the same lesson without a suffix match risking
487
+ * a false hit across two differently-prefixed filenames.
488
+ */
489
+ function matchingLesson(
490
+ file: string,
491
+ metas: readonly LessonMeta[],
492
+ ): LessonMeta | undefined {
493
+ const basename = file.split('/').pop()
494
+ return metas.find((meta) => meta.file === basename)
495
+ }
496
+
497
+ interface GlossaryGroup {
498
+ readonly heading: string
499
+ readonly entries: readonly string[]
500
+ }
501
+
502
+ const OTHER_TERMS_HEADING = 'Other terms'
503
+
504
+ /**
505
+ * Groups already-alphabetical glossary entries by the lesson their own
506
+ * "First seen in" sentence names, in lesson order. An entry naming a
507
+ * reference page instead, or carrying no citation at all, cannot be
508
+ * attributed to a lesson and trails in its own group, keeping the
509
+ * alphabetical order the source entries already carry.
510
+ */
511
+ function groupGlossaryEntries(
512
+ entries: readonly string[],
513
+ metas: readonly LessonMeta[],
514
+ ): readonly GlossaryGroup[] {
515
+ const byLesson = new Map<string, string[]>()
516
+ const other: string[] = []
517
+
518
+ for (const entry of entries) {
519
+ const file = firstSeenFile(entry)
520
+ const lesson = file ? matchingLesson(file, metas) : undefined
521
+
522
+ if (lesson) {
523
+ const list = byLesson.get(lesson.file) ?? []
524
+ list.push(entry)
525
+ byLesson.set(lesson.file, list)
526
+ } else {
527
+ other.push(entry)
528
+ }
529
+ }
530
+
531
+ const groups: GlossaryGroup[] = []
532
+ for (const meta of metas) {
533
+ const list = byLesson.get(meta.file)
534
+ if (list) groups.push({ heading: meta.title, entries: list })
535
+ }
536
+ if (other.length > 0) {
537
+ groups.push({ heading: OTHER_TERMS_HEADING, entries: other })
538
+ }
539
+
540
+ return groups
541
+ }
542
+
543
+ function renderGlossaryGroup(group: GlossaryGroup): string {
544
+ const entries = group.entries.map(renderGlossaryEntry).join('')
545
+
546
+ return `<h3 class="gloss-group">${escapeHtml(group.heading)}</h3>${entries}`
547
+ }
548
+
549
+ function renderGlossarySection(
550
+ entries: readonly string[],
551
+ metas: readonly LessonMeta[],
552
+ ): string {
553
+ const rendered = groupGlossaryEntries(entries, metas)
554
+ .map(renderGlossaryGroup)
555
+ .join('')
462
556
 
463
557
  return `<h2>Glossary <span class="count">${entries.length}</span></h2>
464
558
  <input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="term">
@@ -610,7 +704,7 @@ async function renderContentsPage(
610
704
  referenceRows
611
705
  ? `<h2>Reference pages</h2>\n<ul class="toc">${referenceRows}</ul>`
612
706
  : '',
613
- renderGlossarySection(detail.glossary),
707
+ renderGlossarySection(detail.glossary, metas),
614
708
  ]
615
709
  .filter((section) => section !== '')
616
710
  .join('\n\n')
@@ -58,6 +58,14 @@ A glossary failing these is non-conforming even when it satisfies every shape ru
58
58
  - Name each category so a reader picks it from the term alone. A category a reader cannot predict makes the grouping a second thing to search.
59
59
  - State a departure from any rule above in the file itself, naming what it departs from and why. A glossary serving no single body of material is the case that produces one, since a term drawn from everywhere has no first appearance to name.
60
60
 
61
+ ## Rendered grouping
62
+
63
+ Applies to a rendered glossary page, never to the source file above, which stays the flat alphabetical list the `## Grouping` rules above govern.
64
+
65
+ - Group a rendered glossary by the lesson its own "First seen in" citation names, ordered by lesson order, under a sub-heading naming the lesson's title rather than its filename.
66
+ - Trail with an "Other terms" group holding any entry the citation cannot attribute to a lesson, whether it names a reference page instead or carries no citation at all. Keep it in the alphabetical order the source file already carries.
67
+ - Keep the workspace-wide term filter matching against every group, and drop a group's own heading once filtering leaves nothing under it.
68
+
61
69
  ## Template
62
70
 
63
71
  ```markdown
@@ -156,7 +156,7 @@ A lean is weaker than the suggestion a plan file carries. It records the current
156
156
  ## Conventions
157
157
 
158
158
  - State a number with what it settles. The strongest sections are the ones where a measurement answers a named question and says so.
159
- - Route a finding that would change an existing standard or rule through `claude-intake`. Only a demonstrated failure changes one.
159
+ - Route a finding that would change an existing standard or rule through `plan-intake`. Only a demonstrated failure changes one.
160
160
  - Let the file count follow the number of genuinely separable questions, not the importance of the topic. A large topic with one question is a small folder.
161
161
 
162
162
  ## Anti-patterns
@@ -38,7 +38,7 @@ Overlapping a skill that does the same job is not disqualifying on its own. A sn
38
38
  ## Use patterns
39
39
 
40
40
  - Run-as-is: invoke and send immediately. The snippet is self-contained and needs no extra context.
41
- - Invoke-then-add-context: invoke the snippet, then append specifics in the same message (e.g. invoke `claude-feature`, then add the feature name or extra constraints)
41
+ - Invoke-then-add-context: invoke the snippet, then append specifics in the same message (e.g. invoke `plan-feature`, then add the feature name or extra constraints)
42
42
  - Invoke-on-history: invoke after a discussion. The snippet uses prior conversation as implicit context with no additional input needed (e.g. invoke `claude-figma` after discussing a design).
43
43
 
44
44
  ## Authoring
@@ -47,7 +47,7 @@ The handoff takes one file per session for the reason a task does. A single shar
47
47
 
48
48
  The catalog is the one reader that filters nothing, so it carries a row per sibling alongside the tasks. That is what a folder catalog is for, and the handoffs are what make it worth stating: a board accumulates one row per session that ever wrote one, with nothing pruning them. Anything reading the catalog as the backlog therefore does its own filtering, and a reader that takes every row as a task reports the handoffs as queued work.
49
49
 
50
- The `claude-tasks` skill creates and archives task files, and the archive carries the task's plan with it. `claude-docs` marks outcomes `[x]` in an existing file. Neither does the other's job.
50
+ The `task-board` skill creates and archives task files, and the archive carries the task's plan with it. `docs-fold` marks outcomes `[x]` in an existing file. Neither does the other's job.
51
51
 
52
52
  ## Ordering
53
53
 
@@ -219,9 +219,9 @@ A project that archived plans before the folder nested under `.canon/plans/` hol
219
219
 
220
220
  One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
221
221
 
222
- `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, mirroring how `canon tasks pull-request` writes its own. `claude-feature` calls it right after the plan file lands, when an existing task names the feature, so the line is a mechanical write rather than hand-edited markdown.
222
+ `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, mirroring how `canon tasks pull-request` writes its own. `plan-feature` calls it right after the plan file lands, when an existing task names the feature, so the line is a mechanical write rather than hand-edited markdown.
223
223
 
224
- `Groundwork:` points at `../groundwork/<slug>/`, the folder `claude-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
224
+ `Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
225
225
 
226
226
  `Intake:` points at `../intake/<slug>/`, the folder an intake pass fills. Use it rather than `Groundwork:`, because a groundwork track measures one question in depth while an intake dispositions many across a tree, and one key covering both loses which kind of pass produced the task. The line names the folder rather than an item inside it. A task routinely promotes several items at once, so an anchored line would name one and drop the rest, and the item numbers belong in that task's `## Findings`.
227
227
 
@@ -15,7 +15,7 @@ Governs a learning workspace under `.canon/teach/<nn>-<topic>/`: folder layout,
15
15
 
16
16
  Does not govern:
17
17
 
18
- - The frontmatter, entry shape, and ordering of the glossary the workspace holds: the `claude-teach` skill, which carries that reference
18
+ - The frontmatter, entry shape, and ordering of the glossary the workspace holds: the `teach-workspace` skill, which carries that reference
19
19
  - What a lesson teaches, how it sequences difficulty, and what makes one worth returning to, which belong to the surface driving the workspace
20
20
  - Where a durable page goes once it leaves the workspace, which belongs to the routing test the destination surface states
21
21
  - One question measured in depth before anyone can plan against it: `groundwork.md`
@@ -113,7 +113,7 @@ Take the date and the rung from what the surface driving the workspace reports r
113
113
 
114
114
  ## GLOSSARY.md
115
115
 
116
- Required in every workspace, holding one entry per term the subject defines. The glossary reference the `canon:claude-teach` skill carries fixes what an entry looks like, how the file orders and groups them, and which terms it carries, so this standard states only that the file exists and sits at the workspace root. That reference ships with the plugin rather than installing here, because a promoted glossary keeps its shape wherever it lands and no project folder covers every destination. Say so and stop rather than working the shape from memory when the project has no plugin to read it from.
116
+ Required in every workspace, holding one entry per term the subject defines. The glossary reference the `canon:teach-workspace` skill carries fixes what an entry looks like, how the file orders and groups them, and which terms it carries, so this standard states only that the file exists and sits at the workspace root. That reference ships with the plugin rather than installing here, because a promoted glossary keeps its shape wherever it lands and no project folder covers every destination. Say so and stop rather than working the shape from memory when the project has no plugin to read it from.
117
117
 
118
118
  Name the lesson or reference page a term first appears in as that reference requires. A workspace is the case it was written for, so a glossary here has a first appearance to name.
119
119