@erclx/aitk 0.100.0 → 0.102.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.
@@ -0,0 +1,797 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join, relative } from 'node:path'
4
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
6
+
7
+ export const TEACH_REFUSALS = [
8
+ 'no-teach',
9
+ 'no-workspace',
10
+ 'ambiguous',
11
+ 'exists',
12
+ 'no-file',
13
+ 'no-section',
14
+ 'listed',
15
+ 'defined',
16
+ 'bad-input',
17
+ ] as const
18
+
19
+ export type TeachRefusal = (typeof TEACH_REFUSALS)[number]
20
+
21
+ export interface TeachRefused {
22
+ readonly ok: false
23
+ readonly reason: TeachRefusal
24
+ readonly message: string
25
+ readonly detail: readonly string[]
26
+ }
27
+
28
+ /** The files and folders `standards/teach.md` fixes for a workspace. */
29
+ export const TEACH_MISSION = 'MISSION.md'
30
+ export const TEACH_RESOURCES = 'RESOURCES.md'
31
+ export const TEACH_GLOSSARY = 'GLOSSARY.md'
32
+ export const TEACH_REFERENCE = 'reference'
33
+ export const TEACH_RECORDS = 'learning-records'
34
+ export const TEACH_LESSONS = 'lessons'
35
+
36
+ /**
37
+ * A workspace folder as the standard names it, capturing the ordinal and the
38
+ * topic separately so a listing sorts by the first and a selector matches the
39
+ * second.
40
+ */
41
+ export const WORKSPACE_NAME = /^(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/
42
+
43
+ const TOPIC_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
44
+
45
+ const READ_HEADING = '## Read'
46
+ const LEADS_HEADING = '## Leads'
47
+
48
+ /**
49
+ * The line a scaffolded section carries until something real lands in it. An
50
+ * empty section reads as one someone forgot to write, and the insert verbs drop
51
+ * this line as the first real entry arrives.
52
+ */
53
+ const PLACEHOLDER = '- None yet.'
54
+
55
+ /** Two digits on the folder, per the standard, because a person opens few. */
56
+ const ORDINAL_WIDTH = 2
57
+
58
+ const DATE_LENGTH = 'YYYY-MM-DD'.length
59
+
60
+ export interface WorkspaceSummary {
61
+ readonly slug: string
62
+ /** `NaN` when the folder name carries no ordinal, which a listing reports. */
63
+ readonly ordinal: number
64
+ readonly topic: string
65
+ /** Relative to the root, so a caller prints a path a reader can open. */
66
+ readonly path: string
67
+ readonly title: string | undefined
68
+ readonly opened: string | undefined
69
+ readonly lessons: number
70
+ readonly records: number
71
+ readonly reference: number
72
+ readonly terms: number
73
+ /** Required files the workspace does not carry, by the standard's layout. */
74
+ readonly missing: readonly string[]
75
+ }
76
+
77
+ export interface WorkspaceDetail extends WorkspaceSummary {
78
+ readonly lessonFiles: readonly string[]
79
+ readonly recordFiles: readonly string[]
80
+ readonly referenceFiles: readonly string[]
81
+ readonly glossary: readonly string[]
82
+ }
83
+
84
+ export interface WorkspacesListed {
85
+ readonly ok: true
86
+ readonly workspaces: readonly WorkspaceSummary[]
87
+ /** The ordinal an open would take, so no caller derives one by hand. */
88
+ readonly next: string
89
+ }
90
+
91
+ export interface WorkspaceRead {
92
+ readonly ok: true
93
+ readonly workspace: WorkspaceDetail
94
+ }
95
+
96
+ export interface WorkspaceOpened {
97
+ readonly ok: true
98
+ readonly slug: string
99
+ readonly path: string
100
+ readonly created: readonly string[]
101
+ }
102
+
103
+ export interface SourcesWritten {
104
+ readonly ok: true
105
+ readonly slug: string
106
+ readonly path: string
107
+ readonly read: readonly Source[]
108
+ readonly leads: readonly Source[]
109
+ }
110
+
111
+ export interface TermsWritten {
112
+ readonly ok: true
113
+ readonly slug: string
114
+ readonly path: string
115
+ readonly defined: readonly Term[]
116
+ }
117
+
118
+ export type ListOutcome = WorkspacesListed | TeachRefused
119
+ export type ReadOutcome = WorkspaceRead | TeachRefused
120
+ export type OpenOutcome = WorkspaceOpened | TeachRefused
121
+ export type SourceOutcome = SourcesWritten | TeachRefused
122
+ export type TermOutcome = TermsWritten | TeachRefused
123
+
124
+ export interface Source {
125
+ readonly title: string
126
+ readonly url: string
127
+ }
128
+
129
+ export interface Term {
130
+ readonly term: string
131
+ readonly definition: string
132
+ }
133
+
134
+ export interface OpenRequest {
135
+ readonly topic: string
136
+ readonly subject: string
137
+ readonly startingPoint: string
138
+ readonly success: readonly string[]
139
+ readonly outOfScope: readonly string[]
140
+ readonly title?: string
141
+ readonly date?: string
142
+ }
143
+
144
+ function refuse(
145
+ reason: TeachRefusal,
146
+ message: string,
147
+ detail: readonly string[] = [],
148
+ ): TeachRefused {
149
+ return { ok: false, reason, message, detail }
150
+ }
151
+
152
+ /**
153
+ * Every workspace sits under the main worktree root rather than under the
154
+ * checkout the caller stands in. Resolving that root belongs to the caller, so
155
+ * this takes one and never reads the working directory.
156
+ */
157
+ export function teachDir(root: string): string {
158
+ return join(root, '.claude', 'teach')
159
+ }
160
+
161
+ async function listSlugs(dir: string): Promise<string[]> {
162
+ const entries = await readdir(dir, { withFileTypes: true })
163
+
164
+ return entries
165
+ .filter((entry) => entry.isDirectory())
166
+ .map((entry) => entry.name)
167
+ .sort()
168
+ }
169
+
170
+ async function filesIn(
171
+ dir: string,
172
+ folder: string,
173
+ suffix?: string,
174
+ ): Promise<string[]> {
175
+ const path = join(dir, folder)
176
+ if (!existsSync(path)) return []
177
+
178
+ const entries = await readdir(path, { withFileTypes: true })
179
+
180
+ return entries
181
+ .filter(
182
+ (entry) => entry.isFile() && (!suffix || entry.name.endsWith(suffix)),
183
+ )
184
+ .map((entry) => entry.name)
185
+ .sort()
186
+ }
187
+
188
+ function isEntry(line: string): boolean {
189
+ const trimmed = line.trim()
190
+ return trimmed.startsWith('- ') && trimmed !== PLACEHOLDER
191
+ }
192
+
193
+ /** One bullet per term the subject defines, which is what a glossary holds. */
194
+ function glossaryTerms(text: string): string[] {
195
+ return unfenced(text)
196
+ .map((line) => line.text)
197
+ .filter(isEntry)
198
+ .map((line) => line.trim().slice('- '.length))
199
+ }
200
+
201
+ /** The ordinal a folder name carries, or `NaN` when it carries none. */
202
+ function ordinalOf(slug: string): number {
203
+ const match = WORKSPACE_NAME.exec(slug)
204
+ return match ? Number(match[1]) : Number.NaN
205
+ }
206
+
207
+ async function summarize(
208
+ root: string,
209
+ dir: string,
210
+ slug: string,
211
+ ): Promise<WorkspaceDetail> {
212
+ const match = WORKSPACE_NAME.exec(slug)
213
+ const missionPath = join(dir, TEACH_MISSION)
214
+ const frontmatter = existsSync(missionPath)
215
+ ? parseFrontmatter(await readFile(missionPath, 'utf8'))
216
+ : undefined
217
+
218
+ const glossaryPath = join(dir, TEACH_GLOSSARY)
219
+ const glossary = existsSync(glossaryPath)
220
+ ? glossaryTerms(await readFile(glossaryPath, 'utf8'))
221
+ : []
222
+
223
+ const [lessonFiles, recordFiles, referenceFiles] = await Promise.all([
224
+ filesIn(dir, TEACH_LESSONS),
225
+ filesIn(dir, TEACH_RECORDS, '.md'),
226
+ filesIn(dir, TEACH_REFERENCE, '.md'),
227
+ ])
228
+
229
+ return {
230
+ slug,
231
+ ordinal: match ? Number(match[1]) : Number.NaN,
232
+ topic: match ? match[2] : slug,
233
+ path: relative(root, dir),
234
+ title: readField(frontmatter, 'title'),
235
+ opened: readField(frontmatter, 'date'),
236
+ lessons: lessonFiles.length,
237
+ records: recordFiles.length,
238
+ reference: referenceFiles.length,
239
+ terms: glossary.length,
240
+ missing: [TEACH_MISSION, TEACH_RESOURCES, TEACH_GLOSSARY].filter(
241
+ (file) => !existsSync(join(dir, file)),
242
+ ),
243
+ lessonFiles,
244
+ recordFiles,
245
+ referenceFiles,
246
+ glossary,
247
+ }
248
+ }
249
+
250
+ function byOrdinal(left: WorkspaceSummary, right: WorkspaceSummary): number {
251
+ if (Number.isNaN(left.ordinal)) return Number.isNaN(right.ordinal) ? 0 : 1
252
+ if (Number.isNaN(right.ordinal)) return -1
253
+ return left.ordinal - right.ordinal
254
+ }
255
+
256
+ /**
257
+ * Reads the ordinals off the folder names rather than off summaries, so opening
258
+ * a workspace costs one directory listing instead of a read of every file in
259
+ * every workspace already there.
260
+ */
261
+ function nextOrdinal(slugs: readonly string[]): string {
262
+ const highest = slugs
263
+ .map(ordinalOf)
264
+ .filter((ordinal) => !Number.isNaN(ordinal))
265
+ .reduce((carry, ordinal) => Math.max(carry, ordinal), 0)
266
+
267
+ return String(highest + 1).padStart(ORDINAL_WIDTH, '0')
268
+ }
269
+
270
+ /**
271
+ * Every workspace under the root, with the ordinal a new one would take.
272
+ *
273
+ * A folder failing the name pattern is listed rather than dropped, since
274
+ * dropping it hides the one workspace a session most needs to see. Its ordinal
275
+ * reads as absent, it sorts last, and it never moves the next number.
276
+ */
277
+ export async function listWorkspaces(root: string): Promise<ListOutcome> {
278
+ const dir = teachDir(root)
279
+
280
+ if (!existsSync(dir)) {
281
+ return refuse('no-teach', `No teach folder at ${relative(root, dir)}.`)
282
+ }
283
+
284
+ const slugs = await listSlugs(dir)
285
+
286
+ const workspaces = await Promise.all(
287
+ slugs.map((slug) => summarize(root, join(dir, slug), slug)),
288
+ )
289
+
290
+ return {
291
+ ok: true,
292
+ workspaces: [...workspaces].sort(byOrdinal),
293
+ next: nextOrdinal(slugs),
294
+ }
295
+ }
296
+
297
+ /**
298
+ * The workspace a selector names, matched on the folder name or on the topic
299
+ * behind the ordinal. Two topics matching is a refusal rather than a pick,
300
+ * because the caller meant one of them and no verb here can say which.
301
+ */
302
+ async function findWorkspace(
303
+ root: string,
304
+ selector: string,
305
+ ): Promise<{ slug: string; dir: string } | TeachRefused> {
306
+ const dir = teachDir(root)
307
+
308
+ if (!existsSync(dir)) {
309
+ return refuse('no-teach', `No teach folder at ${relative(root, dir)}.`)
310
+ }
311
+
312
+ const slugs = await listSlugs(dir)
313
+ const matched = slugs.filter(
314
+ (slug) => slug === selector || WORKSPACE_NAME.exec(slug)?.[2] === selector,
315
+ )
316
+
317
+ if (matched.length === 0) {
318
+ return refuse('no-workspace', `No workspace named ${selector}.`, slugs)
319
+ }
320
+
321
+ if (matched.length > 1) {
322
+ return refuse(
323
+ 'ambiguous',
324
+ `${selector} names ${matched.length} workspaces.`,
325
+ matched,
326
+ )
327
+ }
328
+
329
+ return { slug: matched[0], dir: join(dir, matched[0]) }
330
+ }
331
+
332
+ /** One workspace with the filenames behind each count. */
333
+ export async function readWorkspace(
334
+ root: string,
335
+ selector: string,
336
+ ): Promise<ReadOutcome> {
337
+ const found = await findWorkspace(root, selector)
338
+ if ('ok' in found) return found
339
+
340
+ return { ok: true, workspace: await summarize(root, found.dir, found.slug) }
341
+ }
342
+
343
+ function titleFor(topic: string, given: string | undefined): string {
344
+ if (given) return given
345
+
346
+ const words = topic.split('-')
347
+ return [
348
+ words[0].charAt(0).toUpperCase() + words[0].slice(1),
349
+ ...words.slice(1),
350
+ ].join(' ')
351
+ }
352
+
353
+ function missionText(
354
+ request: OpenRequest,
355
+ title: string,
356
+ date: string,
357
+ ): string {
358
+ const outOfScope =
359
+ request.outOfScope.length > 0
360
+ ? request.outOfScope
361
+ : ['Nothing has been ruled out yet.']
362
+
363
+ return [
364
+ '---',
365
+ `title: ${title}`,
366
+ `description: ${request.subject}`,
367
+ `date: ${date}`,
368
+ '---',
369
+ '',
370
+ `# ${title}`,
371
+ '',
372
+ request.subject,
373
+ '',
374
+ '## Starting point',
375
+ '',
376
+ request.startingPoint,
377
+ '',
378
+ '## Success looks like',
379
+ '',
380
+ ...request.success.map((line) => `- ${line}`),
381
+ '',
382
+ '## Out of scope',
383
+ '',
384
+ ...outOfScope.map((line) => `- ${line}`),
385
+ '',
386
+ ].join('\n')
387
+ }
388
+
389
+ function resourcesText(title: string): string {
390
+ return [
391
+ '---',
392
+ `title: Sources for ${title}`,
393
+ `description: Sources read for ${title}, and leads found but not opened`,
394
+ '---',
395
+ '',
396
+ `# Sources for ${title}`,
397
+ '',
398
+ 'Sources that stand behind this workspace, kept apart from leads nobody opened.',
399
+ '',
400
+ READ_HEADING,
401
+ '',
402
+ PLACEHOLDER,
403
+ '',
404
+ LEADS_HEADING,
405
+ '',
406
+ PLACEHOLDER,
407
+ '',
408
+ ].join('\n')
409
+ }
410
+
411
+ function glossaryText(title: string): string {
412
+ return [
413
+ '---',
414
+ `title: Glossary for ${title}`,
415
+ `description: Terms ${title} defines, one entry each`,
416
+ '---',
417
+ '',
418
+ `# Glossary for ${title}`,
419
+ '',
420
+ 'One entry per term the subject defines, sorted alphabetically.',
421
+ '',
422
+ PLACEHOLDER,
423
+ '',
424
+ ].join('\n')
425
+ }
426
+
427
+ /** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
428
+ function today(): string {
429
+ return new Date().toISOString().slice(0, DATE_LENGTH)
430
+ }
431
+
432
+ /**
433
+ * Creates a workspace at the next ordinal with all three required files.
434
+ *
435
+ * The ordinal, the folder name, and every path are derived here rather than by
436
+ * the caller. A caller standing in a linked worktree cannot reach this root
437
+ * through its file-editing tools, so a path it composed by hand is one nothing
438
+ * checks before the write lands somewhere else.
439
+ */
440
+ export async function openWorkspace(
441
+ root: string,
442
+ request: OpenRequest,
443
+ ): Promise<OpenOutcome> {
444
+ if (!TOPIC_SLUG.test(request.topic)) {
445
+ return refuse('bad-input', `Not a kebab-case topic: ${request.topic}.`, [
446
+ request.topic,
447
+ ])
448
+ }
449
+
450
+ if (request.success.length === 0) {
451
+ return refuse(
452
+ 'bad-input',
453
+ 'A mission needs at least one success line. Pass --success <line>.',
454
+ )
455
+ }
456
+
457
+ const dir = teachDir(root)
458
+ await mkdir(dir, { recursive: true })
459
+
460
+ const slugs = await listSlugs(dir)
461
+ const existing = slugs.find(
462
+ (slug) => WORKSPACE_NAME.exec(slug)?.[2] === request.topic,
463
+ )
464
+
465
+ if (existing) {
466
+ return refuse(
467
+ 'exists',
468
+ `${existing} already covers ${request.topic}. Resume it rather than opening a second.`,
469
+ [existing],
470
+ )
471
+ }
472
+
473
+ const slug = `${nextOrdinal(slugs)}-${request.topic}`
474
+ const folder = join(dir, slug)
475
+ const title = titleFor(request.topic, request.title)
476
+
477
+ await mkdir(folder, { recursive: true })
478
+
479
+ const files: ReadonlyArray<readonly [string, string]> = [
480
+ [TEACH_MISSION, missionText(request, title, request.date ?? today())],
481
+ [TEACH_RESOURCES, resourcesText(title)],
482
+ [TEACH_GLOSSARY, glossaryText(title)],
483
+ ]
484
+
485
+ for (const [name, text] of files) {
486
+ await writeFile(join(folder, name), text)
487
+ }
488
+
489
+ return {
490
+ ok: true,
491
+ slug,
492
+ path: relative(root, folder),
493
+ created: files.map(([name]) => join(relative(root, folder), name)),
494
+ }
495
+ }
496
+
497
+ interface Range {
498
+ readonly start: number
499
+ readonly end: number
500
+ }
501
+
502
+ /**
503
+ * Body lines outside every fence, each still carrying the source line it came
504
+ * from. `bodyLines` numbers from one past the frontmatter it drops, so `number
505
+ * - 1` addresses the same line in the array a caller splits itself.
506
+ */
507
+ function unfenced(text: string): BodyLine[] {
508
+ return bodyLines(text).filter((line) => !line.fenced)
509
+ }
510
+
511
+ /**
512
+ * The half-open source range a heading owns, ending at the next heading of the
513
+ * same level or above. A heading quoted inside a fenced example selects nothing,
514
+ * since the scan never sees it.
515
+ */
516
+ function sectionRange(
517
+ lines: readonly BodyLine[],
518
+ heading: string,
519
+ total: number,
520
+ ): Range | undefined {
521
+ let start: number | undefined
522
+
523
+ for (const line of lines) {
524
+ if (start === undefined) {
525
+ if (line.text.trim() === heading) start = line.number
526
+ continue
527
+ }
528
+
529
+ if (/^##?[ \t]/.test(line.text)) return { start, end: line.number - 1 }
530
+ }
531
+
532
+ return start === undefined ? undefined : { start, end: total }
533
+ }
534
+
535
+ /**
536
+ * The half-open source range holding a bullet list, from its first entry to the
537
+ * last line of its last entry. A list carrying only the placeholder yields that
538
+ * line's range, so the first real entry replaces it rather than landing beside
539
+ * it.
540
+ *
541
+ * An indented non-blank line extends the range because that is how markdown
542
+ * wraps an entry too long for one line. An unindented one does not, so a
543
+ * paragraph written under a list stays outside and is neither sorted nor moved.
544
+ */
545
+ function bulletRange(
546
+ lines: readonly BodyLine[],
547
+ within?: Range,
548
+ ): Range | undefined {
549
+ let first: number | undefined
550
+ let last: number | undefined
551
+
552
+ for (const line of lines) {
553
+ const index = line.number - 1
554
+
555
+ if (within && (index < within.start || index >= within.end)) continue
556
+
557
+ if (line.text.trim().startsWith('- ')) {
558
+ first ??= index
559
+ last = index
560
+ continue
561
+ }
562
+
563
+ const wraps = first !== undefined && /^\s+\S/.test(line.text)
564
+ if (wraps) last = index
565
+ }
566
+
567
+ return first === undefined || last === undefined
568
+ ? undefined
569
+ : { start: first, end: last + 1 }
570
+ }
571
+
572
+ /**
573
+ * The bullet blocks in a run of lines, each a bullet with the continuation lines
574
+ * wrapped under it.
575
+ *
576
+ * Splitting on the bullet marker rather than keeping the lines that are bullets
577
+ * is what holds a wrapped entry together. A filter over lines keeps the first
578
+ * line of one and silently drops the rest, which loses half of every entry an
579
+ * author wrapped at the margin.
580
+ */
581
+ function bulletBlocks(lines: readonly string[]): string[][] {
582
+ const blocks: string[][] = []
583
+
584
+ for (const line of lines) {
585
+ if (line.trim().startsWith('- ')) {
586
+ blocks.push([line])
587
+ continue
588
+ }
589
+
590
+ blocks.at(-1)?.push(line)
591
+ }
592
+
593
+ return blocks.filter((block) => block[0].trim() !== PLACEHOLDER)
594
+ }
595
+
596
+ /**
597
+ * Places entries in a line range, dropping the scaffolded placeholder as the
598
+ * first real entry lands. `sorted` merges alphabetically and anything else
599
+ * appends, which is the split between a glossary and a source list.
600
+ */
601
+ function placeEntries(
602
+ lines: readonly string[],
603
+ range: Range,
604
+ entries: readonly string[],
605
+ sorted: boolean,
606
+ ): string {
607
+ const kept = bulletBlocks(lines.slice(range.start, range.end))
608
+ const added = entries.map((entry) => [entry])
609
+
610
+ const placed = sorted
611
+ ? [...kept, ...added].sort((left, right) =>
612
+ left[0].toLowerCase().localeCompare(right[0].toLowerCase()),
613
+ )
614
+ : [...kept, ...added]
615
+
616
+ return [
617
+ ...lines.slice(0, range.start),
618
+ ...placed.flat(),
619
+ ...lines.slice(range.end),
620
+ ].join('\n')
621
+ }
622
+
623
+ /** Appends entries under a heading, keeping the blank lines around the list. */
624
+ function insertUnderHeading(
625
+ text: string,
626
+ heading: string,
627
+ entries: readonly string[],
628
+ ): string | undefined {
629
+ const lines = text.split('\n')
630
+ const body = unfenced(text)
631
+ const section = sectionRange(body, heading, lines.length)
632
+ if (!section) return undefined
633
+
634
+ const bullets = bulletRange(body, section)
635
+
636
+ if (!bullets) {
637
+ return [
638
+ ...lines.slice(0, section.start),
639
+ '',
640
+ ...entries,
641
+ '',
642
+ ...lines.slice(section.end),
643
+ ].join('\n')
644
+ }
645
+
646
+ return placeEntries(lines, bullets, entries, false)
647
+ }
648
+
649
+ async function openFile(
650
+ dir: string,
651
+ slug: string,
652
+ name: string,
653
+ ): Promise<string | TeachRefused> {
654
+ const path = join(dir, name)
655
+
656
+ if (!existsSync(path)) {
657
+ return refuse('no-file', `${slug} carries no ${name}.`)
658
+ }
659
+
660
+ return path
661
+ }
662
+
663
+ /**
664
+ * Appends sources to `RESOURCES.md`, keeping what was read apart from what was
665
+ * only found.
666
+ *
667
+ * A URL already listed under either heading is refused rather than repeated,
668
+ * since a second entry for one source splits what rests on it across two lines.
669
+ */
670
+ export async function recordSources(
671
+ root: string,
672
+ selector: string,
673
+ read: readonly Source[],
674
+ leads: readonly Source[],
675
+ ): Promise<SourceOutcome> {
676
+ const found = await findWorkspace(root, selector)
677
+ if ('ok' in found) return found
678
+
679
+ const path = await openFile(found.dir, found.slug, TEACH_RESOURCES)
680
+ if (typeof path !== 'string') return path
681
+
682
+ let text = await readFile(path, 'utf8')
683
+ const listed = unfenced(text)
684
+ .map((line) => line.text)
685
+ .filter(isEntry)
686
+
687
+ const repeated = [...read, ...leads].filter((source) =>
688
+ listed.some((line) => line.includes(`(${source.url})`)),
689
+ )
690
+
691
+ if (repeated.length > 0) {
692
+ return refuse(
693
+ 'listed',
694
+ `${TEACH_RESOURCES} already lists ${repeated.map((source) => source.url).join(', ')}.`,
695
+ repeated.map((source) => source.url),
696
+ )
697
+ }
698
+
699
+ for (const [heading, sources] of [
700
+ [READ_HEADING, read],
701
+ [LEADS_HEADING, leads],
702
+ ] as const) {
703
+ if (sources.length === 0) continue
704
+
705
+ const written = insertUnderHeading(
706
+ text,
707
+ heading,
708
+ sources.map((source) => `- [${source.title}](${source.url})`),
709
+ )
710
+
711
+ if (written === undefined) {
712
+ return refuse(
713
+ 'no-section',
714
+ `${TEACH_RESOURCES} carries no ${heading} section to write into.`,
715
+ [heading],
716
+ )
717
+ }
718
+
719
+ text = written
720
+ }
721
+
722
+ await writeFile(path, text)
723
+
724
+ return { ok: true, slug: found.slug, path, read, leads }
725
+ }
726
+
727
+ /**
728
+ * The entry shape the standard fixes, which leads with the bolded term.
729
+ *
730
+ * The definition is terminated before the citation is appended, since a caller
731
+ * passing a bare phrase would otherwise run it into the sentence naming where
732
+ * the term first appears.
733
+ */
734
+ function termEntry(term: Term, firstSeen: string | undefined): string {
735
+ const definition = /[.!?]$/.test(term.definition)
736
+ ? term.definition
737
+ : `${term.definition}.`
738
+
739
+ const where = firstSeen ? ` First seen in ${firstSeen}.` : ''
740
+
741
+ return `- **${term.term}**: ${definition}${where}`
742
+ }
743
+
744
+ /** The term a glossary entry defines, read back out of its bolded span. */
745
+ function definedTerm(entry: string): string {
746
+ return entry.replace(/^\*\*(.+?)\*\*.*$/s, '$1').toLowerCase()
747
+ }
748
+
749
+ /**
750
+ * Adds terms to `GLOSSARY.md`, alphabetically, in one read and one write.
751
+ *
752
+ * A term already defined is refused rather than replaced. A definition the
753
+ * subject has moved under is a revision of the entry rather than a second one,
754
+ * and no verb here can tell those two apart from the command line.
755
+ */
756
+ export async function defineTerms(
757
+ root: string,
758
+ selector: string,
759
+ terms: readonly Term[],
760
+ firstSeen: string | undefined,
761
+ ): Promise<TermOutcome> {
762
+ const found = await findWorkspace(root, selector)
763
+ if ('ok' in found) return found
764
+
765
+ const path = await openFile(found.dir, found.slug, TEACH_GLOSSARY)
766
+ if (typeof path !== 'string') return path
767
+
768
+ const text = await readFile(path, 'utf8')
769
+ const existing = glossaryTerms(text).map(definedTerm)
770
+
771
+ const defined = terms.filter((term) =>
772
+ existing.includes(term.term.toLowerCase()),
773
+ )
774
+
775
+ if (defined.length > 0) {
776
+ return refuse(
777
+ 'defined',
778
+ `${TEACH_GLOSSARY} already defines ${defined.map((term) => term.term).join(', ')}.`,
779
+ defined.map((term) => term.term),
780
+ )
781
+ }
782
+
783
+ const entries = terms.map((term) => termEntry(term, firstSeen))
784
+ const lines = text.split('\n')
785
+ const bullets = bulletRange(unfenced(text))
786
+
787
+ // A glossary carries no heading over its list, so the entries land in the
788
+ // bullet range itself. A file holding none yet is appended to, which covers a
789
+ // glossary written by hand rather than scaffolded here.
790
+ const written = bullets
791
+ ? placeEntries(lines, bullets, entries, true)
792
+ : `${text.trimEnd()}\n\n${entries.join('\n')}\n`
793
+
794
+ await writeFile(path, written)
795
+
796
+ return { ok: true, slug: found.slug, path, defined: terms }
797
+ }