@pmelab/gtd 64.0.0 → 64.1.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,607 @@
1
+ // Formats a workflow declares with `listFormat`/`sectionFormat`. Every rule
2
+ // here applies to declared formats only: the built-in `qa`/`review`/`findings`
3
+ // formats are a replay contract and never read through this walker.
4
+ import { openThreadsOf, threadFindings } from "./Footnotes.js"
5
+ import { freeFormFormat } from "./freeform.js"
6
+ import type { SteeringFinding, SteeringFormat } from "./SteeringFormat.js"
7
+
8
+ export type ListMarker = "-" | "*" | "+" | "1."
9
+
10
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
11
+
12
+ export interface ListFormatSpec {
13
+ /** The mode name a step reports and `gtd check` prints — lowercase letters, digits and `-`. */
14
+ readonly name: string
15
+ /** The heading the list lives under. Absent: the whole document is the list. */
16
+ readonly section?: {
17
+ /** A string matches the heading text case-insensitively; a RegExp needs `example`. */
18
+ readonly title: string | RegExp
19
+ /** The heading text `instructions` and `sample` show for a RegExp `title`. */
20
+ readonly example?: string
21
+ readonly level: HeadingLevel
22
+ /** Default true: a document without the section is a problem. */
23
+ readonly required?: boolean
24
+ }
25
+ /** Items start with an ID, then `separator`, then the text: parsed into `{ id, text }`. */
26
+ readonly item?: {
27
+ readonly id: RegExp
28
+ readonly separator: string
29
+ /** An ID `id` matches, shown by `instructions` and `sample`. */
30
+ readonly example: string
31
+ }
32
+ /** Default `["-"]`. `"1."` stands for any `1.`/`1)` numbered item. */
33
+ readonly markers?: readonly ListMarker[]
34
+ /** `"join"` (default): an indented continuation line joins its item. `"forbid"`: one line per item. */
35
+ readonly wrap?: "join" | "forbid"
36
+ /** `"forbid"` (default), `"strip"` (read and drop `[ ]`/`[x]`), or `"require"`. */
37
+ readonly checkbox?: "forbid" | "strip" | "require"
38
+ readonly ids?: { readonly unique?: boolean }
39
+ }
40
+
41
+ export interface SectionFormatSpec {
42
+ readonly name: string
43
+ /** The heading level every section uses. A shallower heading is a problem; deeper ones belong to a section's body. */
44
+ readonly level: HeadingLevel
45
+ /** `"unique"`: two sections may not share a title. Default `"any"`. */
46
+ readonly title?: "unique" | "any"
47
+ }
48
+
49
+ export interface ListItem {
50
+ /** 0-based line of the item's marker. */
51
+ readonly line: number
52
+ readonly text: string
53
+ /** Set when the format declares `item.id`. */
54
+ readonly id?: string
55
+ /** Set when the format reads checkboxes (`"strip"`/`"require"`). */
56
+ readonly checked?: boolean
57
+ }
58
+
59
+ export interface Section {
60
+ readonly line: number
61
+ readonly title: string
62
+ /** The section's text below its heading, trimmed. */
63
+ readonly body: string
64
+ }
65
+
66
+ interface DeclaredFormat<Parsed> extends SteeringFormat {
67
+ readonly name: string
68
+ /** JSON the engine hands `gtd check --spec`, so a validation script needs no workflow loaded. */
69
+ readonly spec: string
70
+ /** Prose describing the format, for an agent's prompt. */
71
+ readonly instructions: string
72
+ readonly parse: (content: string) => Parsed & { readonly problems: readonly SteeringFinding[] }
73
+ }
74
+
75
+ export type ListFormat = DeclaredFormat<{ readonly items: readonly ListItem[] }>
76
+ export type SectionFormat = DeclaredFormat<{ readonly sections: readonly Section[] }>
77
+
78
+ // --- the shared line walker -------------------------------------------------
79
+
80
+ const FENCE_RE = /^( *)(`{3,}|~{3,})/
81
+ const HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
82
+ const ITEM_RE = /^( *)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/
83
+ const CHECKBOX_RE = /^\[([ xX])\](?:[ \t]+|$)/
84
+
85
+ interface Line {
86
+ readonly index: number
87
+ readonly text: string
88
+ /** Inside a fenced code block, fence lines included. */
89
+ readonly fenced: boolean
90
+ readonly fenceIndent?: number
91
+ /** The line that opens a fence. */
92
+ readonly opens?: boolean
93
+ }
94
+
95
+ interface Walked {
96
+ readonly lines: readonly Line[]
97
+ readonly problems: readonly SteeringFinding[]
98
+ }
99
+
100
+ /** Marks fenced lines; an unclosed fence is a problem rather than swallowing the rest of the document. */
101
+ const walk = (content: string): Walked => {
102
+ const raw = content.split(/\r?\n/)
103
+ const lines: Line[] = []
104
+ const problems: SteeringFinding[] = []
105
+ let open:
106
+ | {
107
+ readonly char: string
108
+ readonly length: number
109
+ readonly line: number
110
+ readonly indent: number
111
+ }
112
+ | undefined
113
+ for (const [index, text] of raw.entries()) {
114
+ const fence = FENCE_RE.exec(text)
115
+ if (open === undefined) {
116
+ if (fence !== null) {
117
+ open = {
118
+ char: fence[2]![0]!,
119
+ length: fence[2]!.length,
120
+ line: index,
121
+ indent: fence[1]!.length,
122
+ }
123
+ lines.push({ index, text, fenced: true, fenceIndent: open.indent, opens: true })
124
+ } else lines.push({ index, text, fenced: false })
125
+ continue
126
+ }
127
+ lines.push({ index, text, fenced: true, fenceIndent: open.indent })
128
+ const closes =
129
+ fence !== null &&
130
+ fence[2]![0] === open.char &&
131
+ fence[2]!.length >= open.length &&
132
+ text.slice(fence[0].length).trim() === ""
133
+ if (closes) open = undefined
134
+ }
135
+ if (open !== undefined) problems.push({ line: open.line, message: "code fence is never closed" })
136
+ return { lines, problems }
137
+ }
138
+
139
+ interface Heading {
140
+ readonly line: number
141
+ readonly level: number
142
+ readonly title: string
143
+ }
144
+
145
+ const headingOf = (line: Line): Heading | undefined => {
146
+ if (line.fenced) return undefined
147
+ const match = HEADING_RE.exec(line.text)
148
+ return match === null
149
+ ? undefined
150
+ : { line: line.index, level: match[1]!.length, title: (match[2] ?? "").trim() }
151
+ }
152
+
153
+ const indentOf = (text: string): number => /^ */.exec(text)![0].length
154
+
155
+ // --- listFormat --------------------------------------------------------------
156
+
157
+ const markerKind = (marker: string): ListMarker =>
158
+ marker === "-" || marker === "*" || marker === "+" ? marker : "1."
159
+
160
+ const markerList = (markers: readonly ListMarker[]): string =>
161
+ markers.map((m) => `\`${m}\``).join(" or ")
162
+
163
+ const titleMatches = (spec: NonNullable<ListFormatSpec["section"]>, title: string): boolean =>
164
+ typeof spec.title === "string"
165
+ ? spec.title.trim().toLowerCase() === title.toLowerCase()
166
+ : spec.title.test(title)
167
+
168
+ const titleShown = (spec: NonNullable<ListFormatSpec["section"]>): string =>
169
+ typeof spec.title === "string" ? spec.title : spec.example!
170
+
171
+ interface Region {
172
+ readonly start: number
173
+ readonly end: number
174
+ }
175
+
176
+ /** The lines the list lives on, with the section-heading problems. */
177
+ const regionOf = (
178
+ spec: ListFormatSpec,
179
+ lines: readonly Line[],
180
+ ): { readonly region?: Region; readonly problems: readonly SteeringFinding[] } => {
181
+ const section = spec.section
182
+ if (section === undefined) return { region: { start: 0, end: lines.length }, problems: [] }
183
+ const problems: SteeringFinding[] = []
184
+ const headings = lines.flatMap((line) => headingOf(line) ?? [])
185
+ const matching = headings.filter((h) => titleMatches(section, h.title))
186
+ const wanted = "#".repeat(section.level)
187
+ for (const h of matching.filter((h) => h.level !== section.level))
188
+ problems.push({
189
+ line: h.line,
190
+ message: `"${h.title}" is a level-${h.level} heading — write it as \`${wanted} ${h.title}\``,
191
+ })
192
+ const atLevel = matching.filter((h) => h.level === section.level)
193
+ for (const h of atLevel.slice(1))
194
+ problems.push({
195
+ line: h.line,
196
+ message: `"${h.title}" repeats the section on line ${atLevel[0]!.line + 1} — keep one`,
197
+ })
198
+ const first = atLevel[0]
199
+ if (first === undefined) {
200
+ if (section.required !== false && matching.length === 0)
201
+ problems.push({ message: `missing the \`${wanted} ${titleShown(section)}\` section` })
202
+ return { problems }
203
+ }
204
+ const next = headings.find((h) => h.line > first.line && h.level <= section.level)
205
+ return { region: { start: first.line + 1, end: next?.line ?? lines.length }, problems }
206
+ }
207
+
208
+ interface Draft {
209
+ readonly line: number
210
+ parts: string[]
211
+ }
212
+
213
+ /** One pass over the region: items, their continuations, and every lint the format implies. */
214
+ // fallow-ignore-next-line complexity
215
+ const readItems = (
216
+ spec: ListFormatSpec,
217
+ lines: readonly Line[],
218
+ region: Region,
219
+ ): { readonly drafts: readonly Draft[]; readonly problems: readonly SteeringFinding[] } => {
220
+ const markers = spec.markers ?? ["-"]
221
+ const wrap = spec.wrap ?? "join"
222
+ const problems: SteeringFinding[] = []
223
+ const drafts: Draft[] = []
224
+ let firstKind: ListMarker | undefined
225
+ let current: Draft | undefined
226
+ let blank = false
227
+ let prose: number[] = []
228
+ const continueWith = (line: Line, text: string): void => {
229
+ if (wrap === "forbid") {
230
+ problems.push({
231
+ line: line.index,
232
+ message: "one line per item — this line continues the one above",
233
+ })
234
+ return
235
+ }
236
+ current!.parts.push(text.trim())
237
+ }
238
+ for (const line of lines.slice(region.start, region.end)) {
239
+ const text = line.text
240
+ if (line.fenced) {
241
+ // A fence indented under an item is that item's code; any other fence ends the item.
242
+ if (current !== undefined && (line.fenceIndent ?? 0) >= 2) {
243
+ blank = false
244
+ continue
245
+ }
246
+ if (line.opens === true && current !== undefined) {
247
+ prose.push(line.index)
248
+ current = undefined
249
+ } else if (line.opens !== true && ITEM_RE.test(text) && indentOf(text) < 2)
250
+ problems.push({
251
+ line: line.index,
252
+ message: "a list item inside a code fence is not read as an item",
253
+ })
254
+ blank = false
255
+ continue
256
+ }
257
+ if (text.trim() === "") {
258
+ blank = true
259
+ continue
260
+ }
261
+ const item = ITEM_RE.exec(text)
262
+ const indent = indentOf(text)
263
+ if (item !== null && indent < 2) {
264
+ for (const p of prose)
265
+ problems.push({
266
+ line: p,
267
+ message: "text between items — make it part of an item, or move it out of the list",
268
+ })
269
+ prose = []
270
+ const kind = markerKind(item[2]!)
271
+ if (!markers.includes(kind))
272
+ problems.push({
273
+ line: line.index,
274
+ message: `\`${item[2]}\` is not an allowed marker — use ${markerList(markers)}`,
275
+ })
276
+ else if (firstKind !== undefined && kind !== firstKind)
277
+ problems.push({
278
+ line: line.index,
279
+ message: `\`${item[2]}\` mixes markers in one list — the list started with \`${firstKind}\``,
280
+ })
281
+ firstKind ??= kind
282
+ current = { line: line.index, parts: [(item[3] ?? "").trim()] }
283
+ drafts.push(current)
284
+ blank = false
285
+ continue
286
+ }
287
+ if (current === undefined) {
288
+ blank = false
289
+ continue
290
+ }
291
+ if (item !== null) {
292
+ problems.push({
293
+ line: line.index,
294
+ message: "nested list — this format is flat, one level of items",
295
+ })
296
+ } else if (indent >= 2) {
297
+ continueWith(line, text)
298
+ } else if (!blank) {
299
+ problems.push({
300
+ line: line.index,
301
+ message:
302
+ "continuation line is not indented — indent it under its item, or separate it with a blank line",
303
+ })
304
+ continueWith(line, text)
305
+ } else {
306
+ prose.push(line.index)
307
+ current = undefined
308
+ }
309
+ blank = false
310
+ }
311
+ return { drafts, problems }
312
+ }
313
+
314
+ /** Strips a leading `[ ]`/`[x]`, reporting it where the format forbids or requires one. */
315
+ const readCheckbox = (
316
+ spec: ListFormatSpec,
317
+ draft: Draft,
318
+ problems: SteeringFinding[],
319
+ ): { readonly text: string; readonly checked?: boolean } => {
320
+ const checkbox = spec.checkbox ?? "forbid"
321
+ const text = draft.parts.join(" ").trim()
322
+ const box = CHECKBOX_RE.exec(text)
323
+ if (box === null) {
324
+ if (checkbox === "require")
325
+ problems.push({
326
+ line: draft.line,
327
+ message: "every item starts with a checkbox — write `- [ ] …`",
328
+ })
329
+ return checkbox === "forbid" ? { text } : { text, checked: false }
330
+ }
331
+ if (checkbox === "forbid")
332
+ problems.push({ line: draft.line, message: "no checkboxes in this list — drop the `[ ]`" })
333
+ const rest = text.slice(box[0].length).trim()
334
+ return checkbox === "forbid" ? { text: rest } : { text: rest, checked: box[1] !== " " }
335
+ }
336
+
337
+ const finishItem = (
338
+ spec: ListFormatSpec,
339
+ draft: Draft,
340
+ problems: SteeringFinding[],
341
+ ): ListItem | undefined => {
342
+ const { text, ...checked } = readCheckbox(spec, draft, problems)
343
+ const item = spec.item
344
+ if (text === "") {
345
+ problems.push({ line: draft.line, message: "empty item" })
346
+ return undefined
347
+ }
348
+ if (item === undefined) return { line: draft.line, text, ...checked }
349
+ const at = text.indexOf(item.separator)
350
+ const id = at < 0 ? "" : text.slice(0, at).trim()
351
+ if (at < 0 || !item.id.test(id)) {
352
+ problems.push({
353
+ line: draft.line,
354
+ message: `item has no ID — start it \`${item.example}${item.separator} …\``,
355
+ })
356
+ return undefined
357
+ }
358
+ const body = text.slice(at + item.separator.length).trim()
359
+ if (body === "") problems.push({ line: draft.line, message: `empty item — ${id} has no text` })
360
+ return { line: draft.line, text: body, id, ...checked }
361
+ }
362
+
363
+ const parseList = (spec: ListFormatSpec, content: string) => {
364
+ const walked = walk(content)
365
+ const { region, problems: sectionProblems } = regionOf(spec, walked.lines)
366
+ const problems: SteeringFinding[] = [...walked.problems, ...sectionProblems]
367
+ if (region === undefined) return { items: [], problems }
368
+ const read = readItems(spec, walked.lines, region)
369
+ problems.push(...read.problems)
370
+ const items = read.drafts.flatMap((draft) => finishItem(spec, draft, problems) ?? [])
371
+ if (spec.ids?.unique === true) {
372
+ const seen = new Map<string, number>()
373
+ for (const item of items) {
374
+ if (item.id === undefined) continue
375
+ const first = seen.get(item.id)
376
+ if (first === undefined) seen.set(item.id, item.line)
377
+ else
378
+ problems.push({
379
+ line: item.line,
380
+ message: `duplicate ID ${item.id} — first on line ${first + 1}`,
381
+ })
382
+ }
383
+ }
384
+ problems.sort((a, b) => (a.line ?? -1) - (b.line ?? -1))
385
+ return { items, problems }
386
+ }
387
+
388
+ const sectionSentence = (spec: ListFormatSpec): string => {
389
+ if (spec.section === undefined) return "The whole document is the list."
390
+ const heading = `${"#".repeat(spec.section.level)} ${titleShown(spec.section)}`
391
+ return `Write the list under one \`${heading}\` heading; it ends at the next heading of the same or a higher level.`
392
+ }
393
+
394
+ const CHECKBOX_SENTENCE = {
395
+ forbid: "Do not use checkboxes.",
396
+ strip: "",
397
+ require: "Start every item with a checkbox: `[ ]`, or `[x]` when done.",
398
+ } as const
399
+
400
+ const idSentence = (spec: ListFormatSpec): string => {
401
+ if (spec.item === undefined) return ""
402
+ const unique = spec.ids?.unique === true ? ", unique within the list" : ""
403
+ return `Start every item with its ID (like \`${spec.item.example}\`${unique}), then \`${spec.item.separator}\`, then the text.`
404
+ }
405
+
406
+ const listInstructions = (spec: ListFormatSpec): string =>
407
+ [
408
+ sectionSentence(spec),
409
+ `Write each item as a ${markerList(spec.markers ?? ["-"])} list item, all with the same marker.`,
410
+ CHECKBOX_SENTENCE[spec.checkbox ?? "forbid"],
411
+ idSentence(spec),
412
+ (spec.wrap ?? "join") === "join"
413
+ ? "Continue a long item on lines indented two spaces under it."
414
+ : "Keep every item on one line.",
415
+ "Keep the list flat: no nested items, and no text between items. Items inside a code fence are not read.",
416
+ ]
417
+ .filter((sentence) => sentence !== "")
418
+ .join(" ")
419
+
420
+ const listSample = (spec: ListFormatSpec): string => {
421
+ const marker = (spec.markers ?? ["-"])[0] === "1." ? "1." : (spec.markers ?? ["-"])[0]!
422
+ const box = spec.checkbox === "require" ? "[ ] " : ""
423
+ const id = spec.item !== undefined ? `${spec.item.example}${spec.item.separator} ` : ""
424
+ const item = `${marker} ${box}${id}Describe the item.\n`
425
+ return spec.section === undefined
426
+ ? item
427
+ : `${"#".repeat(spec.section.level)} ${titleShown(spec.section)}\n\n${item}`
428
+ }
429
+
430
+ // --- sectionFormat -----------------------------------------------------------
431
+
432
+ const bodyOf = (lines: readonly Line[], start: number, end: number): string =>
433
+ lines
434
+ .slice(start, end)
435
+ .map((l) => l.text)
436
+ .join("\n")
437
+ .trim()
438
+
439
+ /** The problem with heading `i` as a section, if any; `null` when it is not a section heading at all. */
440
+ const sectionHeadingProblem = (
441
+ spec: SectionFormatSpec,
442
+ headings: readonly Heading[],
443
+ i: number,
444
+ firstLine: ReadonlyMap<string, number>,
445
+ ): string | null | undefined => {
446
+ const h = headings[i]!
447
+ if (h.level < spec.level && headings.slice(0, i).some((p) => p.level === spec.level))
448
+ return `"${h.title}" is a level-${h.level} heading — sections are \`${"#".repeat(spec.level)}\``
449
+ if (h.level !== spec.level) return null
450
+ if (h.title === "") return "section heading has no title"
451
+ const first = firstLine.get(h.title.toLowerCase())
452
+ return spec.title === "unique" && first !== undefined
453
+ ? `"${h.title}" repeats the section on line ${first + 1} — titles are unique`
454
+ : undefined
455
+ }
456
+
457
+ const parseSections = (spec: SectionFormatSpec, content: string) => {
458
+ const walked = walk(content)
459
+ const problems: SteeringFinding[] = [...walked.problems]
460
+ const headings = walked.lines.flatMap((line) => headingOf(line) ?? [])
461
+ const sections: Section[] = []
462
+ const firstLine = new Map<string, number>()
463
+ for (const [i, h] of headings.entries()) {
464
+ const problem = sectionHeadingProblem(spec, headings, i, firstLine)
465
+ if (problem === null) continue
466
+ if (problem !== undefined) problems.push({ line: h.line, message: problem })
467
+ if (h.level !== spec.level || h.title === "") continue
468
+ if (!firstLine.has(h.title.toLowerCase())) firstLine.set(h.title.toLowerCase(), h.line)
469
+ const end =
470
+ headings.slice(i + 1).find((n) => n.level <= spec.level)?.line ?? walked.lines.length
471
+ sections.push({ line: h.line, title: h.title, body: bodyOf(walked.lines, h.line + 1, end) })
472
+ }
473
+ problems.sort((a, b) => (a.line ?? -1) - (b.line ?? -1))
474
+ return { sections, problems }
475
+ }
476
+
477
+ const sectionInstructions = (spec: SectionFormatSpec): string => {
478
+ const hashes = "#".repeat(spec.level)
479
+ return [
480
+ `Write each section under a \`${hashes} Title\` heading.`,
481
+ spec.title === "unique" ? "No two sections share a title." : "",
482
+ spec.level > 1
483
+ ? `Use deeper headings inside a section only; no heading above \`${hashes}\` after the first section.`
484
+ : "",
485
+ "Close every code fence.",
486
+ ]
487
+ .filter((s) => s !== "")
488
+ .join(" ")
489
+ }
490
+
491
+ // --- construction and the spec round-trip ------------------------------------
492
+
493
+ const NAME_RE = /^[a-z][a-z0-9-]*$/
494
+
495
+ type EncodedPattern = { readonly source: string; readonly flags: string }
496
+
497
+ // `g`/`y` make `test` stateful; a format matches each line afresh.
498
+ const pattern = (re: RegExp): RegExp => new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
499
+
500
+ const encode = (value: unknown): unknown =>
501
+ value instanceof RegExp
502
+ ? { source: value.source, flags: value.flags }
503
+ : Array.isArray(value)
504
+ ? value.map(encode)
505
+ : typeof value === "object" && value !== null
506
+ ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encode(v)]))
507
+ : value
508
+
509
+ const isPattern = (value: unknown): value is EncodedPattern =>
510
+ typeof value === "object" &&
511
+ value !== null &&
512
+ typeof (value as EncodedPattern).source === "string" &&
513
+ typeof (value as EncodedPattern).flags === "string"
514
+
515
+ const checkName = (name: unknown): string => {
516
+ if (typeof name !== "string" || !NAME_RE.test(name))
517
+ throw new Error(
518
+ `format name must be lowercase letters, digits and "-" — got ${JSON.stringify(name)}`,
519
+ )
520
+ return name
521
+ }
522
+
523
+ const withThreads = <P extends { readonly problems: readonly SteeringFinding[] }>(
524
+ name: string,
525
+ spec: unknown,
526
+ kind: "list" | "section",
527
+ parse: (content: string) => P,
528
+ instructions: string,
529
+ sample: string,
530
+ ): DeclaredFormat<Omit<P, "problems">> => ({
531
+ ...freeFormFormat,
532
+ openThreads: openThreadsOf,
533
+ name,
534
+ spec: JSON.stringify({ kind, ...(encode(spec) as object) }),
535
+ instructions,
536
+ sample,
537
+ parse,
538
+ validate: (content) => [...parse(content).problems, ...threadFindings(content)],
539
+ })
540
+
541
+ /** Declares a list format: `parse`, `validate`, `instructions` and `sample` all derive from `spec`. Usable as a step's `mode`. */
542
+ export const listFormat = (spec: ListFormatSpec): ListFormat => {
543
+ const name = checkName(spec.name)
544
+ const normalized: ListFormatSpec = {
545
+ ...spec,
546
+ ...(spec.section !== undefined && typeof spec.section.title !== "string"
547
+ ? { section: { ...spec.section, title: pattern(spec.section.title) } }
548
+ : {}),
549
+ ...(spec.item !== undefined ? { item: { ...spec.item, id: pattern(spec.item.id) } } : {}),
550
+ }
551
+ if ((normalized.markers ?? ["-"]).length === 0)
552
+ throw new Error(`format "${name}": markers is empty`)
553
+ if (normalized.item !== undefined && !normalized.item.id.test(normalized.item.example))
554
+ throw new Error(
555
+ `format "${name}": item.example "${normalized.item.example}" does not match item.id`,
556
+ )
557
+ const section = normalized.section
558
+ if (
559
+ section !== undefined &&
560
+ typeof section.title !== "string" &&
561
+ (section.example === undefined || !section.title.test(section.example))
562
+ )
563
+ throw new Error(`format "${name}": a RegExp section.title needs a section.example it matches`)
564
+ const format = withThreads(
565
+ name,
566
+ normalized,
567
+ "list",
568
+ (content) => parseList(normalized, content),
569
+ listInstructions(normalized),
570
+ listSample(normalized),
571
+ )
572
+ if (format.parse(format.sample).problems.length > 0)
573
+ throw new Error(`format "${name}": its own sample does not validate`)
574
+ return format
575
+ }
576
+
577
+ /** Declares a document of headed sections. Usable as a step's `mode`. */
578
+ export const sectionFormat = (spec: SectionFormatSpec): SectionFormat => {
579
+ const name = checkName(spec.name)
580
+ const hashes = "#".repeat(spec.level)
581
+ return withThreads(
582
+ name,
583
+ spec,
584
+ "section",
585
+ (content) => parseSections(spec, content),
586
+ sectionInstructions(spec),
587
+ `${hashes} First section\n\nWhat this section says.\n`,
588
+ )
589
+ }
590
+
591
+ const decodePatterns = (value: unknown, key?: string): unknown =>
592
+ (key === "title" || key === "id") && isPattern(value)
593
+ ? new RegExp(value.source, value.flags)
594
+ : Array.isArray(value)
595
+ ? value.map((v) => decodePatterns(v))
596
+ : typeof value === "object" && value !== null
597
+ ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decodePatterns(v, k)]))
598
+ : value
599
+
600
+ /** The format a declared format's `spec` describes — what `gtd check --spec` validates with. Throws on a spec no declaration produces. */
601
+ export const formatFromSpec = (spec: string): ListFormat | SectionFormat => {
602
+ const decoded = decodePatterns(JSON.parse(spec)) as { readonly kind?: unknown }
603
+ const { kind, ...rest } = decoded as { kind?: unknown } & Record<string, unknown>
604
+ if (kind === "list") return listFormat(rest as unknown as ListFormatSpec)
605
+ if (kind === "section") return sectionFormat(rest as unknown as SectionFormatSpec)
606
+ throw new Error(`not a declared format spec: ${JSON.stringify(kind)}`)
607
+ }
@@ -22,6 +22,9 @@ read them.
22
22
  `# Dropped as noise` heading being the noise; each holds exactly one
23
23
  `- [ ] fix` box. A ticked box fixes the finding; text below the box is the
24
24
  human's guidance, valid only under a ticked one.
25
+ - **Declared formats**: a workflow's own `listFormat`/`sectionFormat`. Their
26
+ rules are not this contract: they apply to declared formats only, never to the
27
+ formats above.
25
28
  - **Free-form**: any markdown, for a file with no mode or an unknown one. Only
26
29
  its threads are validated.
27
30
  - **Threads**, shared by all of them: a footnote whose definition is a list of
@@ -24,6 +24,18 @@ export type { InlineNode } from "./Inline.js"
24
24
 
25
25
  export { freeFormFormat } from "./freeform.js"
26
26
 
27
+ export {
28
+ listFormat,
29
+ sectionFormat,
30
+ type ListFormat,
31
+ type ListFormatSpec,
32
+ type ListItem,
33
+ type ListMarker,
34
+ type Section,
35
+ type SectionFormat,
36
+ type SectionFormatSpec,
37
+ } from "./Declared.js"
38
+
27
39
  export type {
28
40
  BlockListItem,
29
41
  SteeringAction,
@@ -9,3 +9,4 @@ export {
9
9
  unansweredQuestions,
10
10
  viewOf,
11
11
  } from "./registry.js"
12
+ export { formatFromSpec } from "./Declared.js"
@@ -32,6 +32,7 @@ import {
32
32
  import {
33
33
  BUILT_IN_MODE_NAMES,
34
34
  formatFinding,
35
+ formatFromSpec,
35
36
  steeringFormatFor,
36
37
  type SteeringFormat,
37
38
  } from "../steering/internal.js"
@@ -538,13 +539,14 @@ const recognizeModeContradictionCheck = (
538
539
 
539
540
  const GTD_CHECK_RE = /^gtd check (\S+) (.+)$/
540
541
 
541
- /** `gtd check <mode> <file>` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
542
+ /** `gtd check <mode> <file> [--spec <json>]` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
542
543
  const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
543
544
  const match = GTD_CHECK_RE.exec(block)
544
545
  if (!match) return undefined
545
546
  const mode = match[1]!
546
- const [file] = extractQuotedTokens(match[2]!)
547
- const format = steeringFormatFor(mode)
547
+ const [file, spec] = extractQuotedTokens(match[2]!)
548
+ const declared = / --spec '/.test(match[2]!)
549
+ const format = declared && spec !== undefined ? formatFromSpec(spec) : steeringFormatFor(mode)
548
550
  if (format === undefined || file === undefined) {
549
551
  const known = BUILT_IN_MODE_NAMES.join(", ")
550
552
  return { kind: "failed", error: `gtd check: unknown mode "${mode}" — known modes: ${known}` }