@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +11 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +4 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-sweep.md +1 -1
- package/claude/skills/claude-orchestrate/scripts/poll.sh +11 -10
- package/claude/skills/claude-pr-review/SKILL.md +16 -16
- package/claude/skills/claude-tasks/SKILL.md +10 -1
- package/claude/skills/claude-teach/REQUIREMENT.md +2 -1
- package/claude/skills/claude-teach/SKILL.md +40 -9
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/docs/agents/commands.md +5 -0
- package/docs/agents/index.md +2 -1
- package/docs/agents/markdown-audit.md +21 -11
- package/docs/agents/tasks.md +8 -4
- package/docs/agents/teach.md +119 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/operating-model.md +9 -8
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/markdown.ts +44 -28
- package/src/commands/tasks.ts +6 -3
- package/src/commands/teach.ts +650 -0
- package/src/markdown/bans.ts +71 -210
- package/src/markdown/structure.ts +9 -71
- package/src/records/validate.ts +8 -7
- package/src/tasks/archive.ts +9 -4
- package/src/tasks/validate.ts +103 -3
- package/src/teach/workspace.ts +797 -0
- package/standards/prose.md +0 -2
- package/standards/tasks.md +51 -8
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +43 -47
|
@@ -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
|
+
}
|