@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.
@@ -1,233 +1,94 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { linesOutsideFences } from '@/markdown/scan'
3
- import { resolveStandard } from '@/standards/read'
1
+ import type { BanSets } from '@/markdown/scan'
4
2
 
5
3
  /**
6
- * Headings the two standards state their closed sets under.
4
+ * Single characters `markdown.md` bans under `## Punctuation`.
7
5
  *
8
- * Each standard says where its own bans sit and points at the other for the
9
- * rest, so discovery anchors on the heading rather than on a bullet's position.
10
- * A section renamed empties the set loudly at the report's legend, where a
11
- * position-based read would narrow the check and still print a count.
6
+ * The parenthetical-aside ban in that section is absent because it quotes a
7
+ * whole clause, and a literal match built from a clause reports the compliant
8
+ * text and reaches none of the violations.
12
9
  */
13
- export const WORD_BAN_HEADING = '## Language'
14
- export const CHARACTER_BAN_HEADING = '## Punctuation'
15
-
16
- /** Bullet lead-ins the two closed sets are stated behind. */
17
- const BAN_LEAD = '- Do not use '
18
- const SPELLING_LEAD = '- Use American English spelling'
19
-
20
- const BACKTICKED = /`([^`]+)`/g
21
- const WORD = /^[a-z]+$/
22
- const SUFFIX = /^-[a-z]+$/
23
-
24
- export interface BanReport {
25
- /** Single characters the mechanics standard bans outright. */
26
- readonly characters: readonly string[]
27
- /** Single lowercase words the prose standard bans outright. */
28
- readonly words: readonly string[]
29
- /** Spellings derived from the prose standard's own examples. */
30
- readonly spellings: readonly string[]
31
- /**
32
- * Paths of the standards read, in the order read. Repo-relative for a project
33
- * copy, and spelled under `<aitk>/` for the package copy, so a reader can
34
- * tell which root answered without joining a path that resolves nowhere.
35
- */
36
- readonly sources: readonly string[]
37
- /**
38
- * Standards that resolved under none of the roots. Absent is a distinct state
39
- * from empty: a scan with no terms finds nothing, and reporting that as a
40
- * clean file claims the prose passed when nothing was looked for.
41
- */
42
- readonly missing: readonly string[]
43
- }
44
-
45
- function section(markdown: string, heading: string): string[] {
46
- const lines = linesOutsideFences(markdown)
47
- const start = lines.findIndex((line) => line.trim() === heading)
48
- if (start === -1) return []
49
-
50
- const body = lines.slice(start + 1)
51
- const end = body.findIndex((line) => line.startsWith('## '))
52
-
53
- return end === -1 ? body : body.slice(0, end)
54
- }
55
-
56
- function backticked(line: string): string[] {
57
- return [...line.matchAll(BACKTICKED)].map((match) => match[1].trim())
58
- }
10
+ const CHARACTERS = ['—', ';'] as const
59
11
 
60
12
  /**
61
- * Pulls the single lowercase words out of the `Do not use` bullets.
13
+ * Single lowercase words `prose.md` bans under `## Language`.
62
14
  *
63
- * A multi-word term is left out rather than matched loosely. The standard bans
64
- * a pattern like `It's not X, it's Y` with a placeholder standing in for the
65
- * rest of the sentence, and no literal match reaches it, so the phrase bans
66
- * stay a reader's judgment and the report names them as unmeasured.
15
+ * A multi-word ban is absent by the same test the character set applies. The
16
+ * standard bans a pattern like `It's not X, it's Y` with a placeholder standing
17
+ * in for the rest of the sentence, so no literal match reaches it and the
18
+ * phrase bans stay a reader's judgment.
67
19
  */
68
- export function parseWordBans(markdown: string): string[] {
69
- const terms: string[] = []
70
-
71
- for (const line of section(markdown, WORD_BAN_HEADING)) {
72
- if (!line.startsWith(BAN_LEAD)) continue
73
- for (const term of backticked(line)) {
74
- if (WORD.test(term) && !terms.includes(term)) terms.push(term)
75
- }
76
- }
77
-
78
- return terms
79
- }
20
+ const WORDS = [
21
+ 'seamless',
22
+ 'robust',
23
+ 'powerful',
24
+ 'revolutionary',
25
+ 'enhanced',
26
+ 'allows',
27
+ 'leverage',
28
+ 'simply',
29
+ 'just',
30
+ 'easily',
31
+ 'quickly',
32
+ 'very',
33
+ 'really',
34
+ ] as const
80
35
 
81
36
  /**
82
- * Pulls the single non-alphanumeric characters out of the `Do not use` bullets.
37
+ * British spellings of the American examples `prose.md` lists.
83
38
  *
84
- * Width is what separates the two ban classes in that section. The character
85
- * bans are stated as one glyph each, and the parenthetical-aside ban quotes a
86
- * whole clause, which is a shape no literal match should be built from.
87
- */
88
- export function parseCharacterBans(markdown: string): string[] {
89
- const terms: string[] = []
90
-
91
- for (const line of section(markdown, CHARACTER_BAN_HEADING)) {
92
- if (!line.startsWith(BAN_LEAD)) continue
93
- for (const term of backticked(line)) {
94
- if (
95
- term.length === 1 &&
96
- !/[a-z0-9]/i.test(term) &&
97
- !terms.includes(term)
98
- ) {
99
- terms.push(term)
100
- }
101
- }
102
- }
103
-
104
- return terms
105
- }
106
-
107
- /**
108
- * Derives the banned spellings by applying the standard's own suffix rules to
109
- * the standard's own examples.
39
+ * The set is carried whole rather than derived from a suffix rule, because a
40
+ * suffix pattern run over prose produced 46 of the 58 false positives measured
41
+ * during intake: `exercises`, `promises`, and `revised` all end in `-ise` and
42
+ * none is a British spelling. Matching whole words reaches none of them.
110
43
  *
111
- * The spelling rule is stated as a preference with examples rather than as a
112
- * closed set, and a suffix pattern run over prose is what produced 46 of the
113
- * 58 false positives measured during intake: `exercises`, `promises`, and
114
- * `revised` all end in `-ise` and none is a British spelling. Transforming the
115
- * listed American words into their British forms yields a closed set that
116
- * matches whole words and reaches none of them, and an example added to the
117
- * standard extends the check without a code edit.
44
+ * `analyse` is absent because the standard's example is `analyze`, which ends
45
+ * in `-yze` rather than the `-ize` its rule states. The set records what the
46
+ * standard reaches rather than what a reader would extend it to, so adding the
47
+ * spelling here would widen the check past the prose it answers to.
118
48
  */
119
- export function parseSpellingBans(markdown: string): string[] {
120
- const line = section(markdown, WORD_BAN_HEADING).find((each) =>
121
- each.startsWith(SPELLING_LEAD),
122
- )
123
- if (!line) return []
124
-
125
- const terms = backticked(line)
126
- const suffixes = terms.filter((term) => SUFFIX.test(term))
127
- const examples = terms.filter((term) => WORD.test(term))
128
-
129
- // Stated as `preferred` over `banned`, so the run of suffix terms pairs off
130
- // in order. An odd count means the sentence was rewritten into a shape this
131
- // cannot read, and pairing what is there would invent a rule from half of it.
132
- if (suffixes.length === 0 || suffixes.length % 2 !== 0) return []
133
-
134
- const pairs: { preferred: string; banned: string }[] = []
135
- for (let index = 0; index < suffixes.length; index += 2) {
136
- pairs.push({
137
- preferred: suffixes[index].slice(1),
138
- banned: suffixes[index + 1].slice(1),
139
- })
140
- }
141
-
142
- const spellings: string[] = []
143
- for (const example of examples) {
144
- const pair = pairs.find((each) => example.endsWith(each.preferred))
145
- if (!pair) continue
146
-
147
- const banned = `${example.slice(0, -pair.preferred.length)}${pair.banned}`
148
- if (!spellings.includes(banned)) spellings.push(banned)
149
- }
150
-
151
- return spellings
152
- }
153
-
154
- export interface StandardText {
155
- /** Path of the copy read, so a report says which root won. */
156
- readonly source: string
157
- readonly text: string
158
- }
159
-
160
- export interface Standards {
161
- readonly markdown: StandardText | undefined
162
- readonly prose: StandardText | undefined
163
- /**
164
- * Standards that resolved under none of the roots. Absent is a distinct state
165
- * from empty: a scan with no terms finds nothing, and reporting that as a
166
- * clean file claims the prose passed when nothing was looked for.
167
- */
168
- readonly missing: readonly string[]
169
- }
170
-
171
- async function loadStandard(
172
- root: string,
173
- name: string,
174
- ): Promise<StandardText | undefined> {
175
- const resolved = resolveStandard(root, name)
176
- if (!resolved) return undefined
177
-
178
- return {
179
- source: resolved.source,
180
- text: await readFile(resolved.path, 'utf8'),
181
- }
182
- }
49
+ const SPELLINGS = [
50
+ 'organise',
51
+ 'summarise',
52
+ 'recognise',
53
+ 'behaviour',
54
+ 'colour',
55
+ 'centre',
56
+ ] as const
183
57
 
184
58
  /**
185
- * Finds both standards, so one read serves the ban sets and the checkpoints.
59
+ * The three closed sets the audit measures, owned here rather than harvested
60
+ * from the standards stating them.
61
+ *
62
+ * Parsing the prose was the alternative and it put a parser contract on two
63
+ * documents authored for people, which the prose standard had to carry a
64
+ * paragraph of its own to protect. The sets are a prior an author already
65
+ * knows rather than a filter: measured at `60fc97bf` on 2026-08-19, these 21
66
+ * terms ran across 483 markdown files for a clean exit, and every one of the
67
+ * 70 occurrences of a banned word sat inside the ban list itself or inside an
68
+ * example demonstrating the ban.
186
69
  *
187
- * The texts are returned rather than the parsed sets alone because
188
- * `structure.ts` reads its numbers out of the same `markdown.md`, and a second
189
- * loader would open the file twice and could resolve a different root than
190
- * this one did.
70
+ * The set is closed rather than extensible. Enumeration cannot close the gap
71
+ * it aims at, and each addition costs a false-positive class, since `just`,
72
+ * `allows`, and `very` have honest uses no literal match separates. A project
73
+ * wanting its own vocabulary is asking for a different feature than this one.
191
74
  */
192
- export async function loadStandards(root: string): Promise<Standards> {
193
- const [markdown, prose] = await Promise.all([
194
- loadStandard(root, 'markdown.md'),
195
- loadStandard(root, 'prose.md'),
196
- ])
197
-
198
- return {
199
- markdown,
200
- prose,
201
- missing: [
202
- markdown ? undefined : 'markdown.md',
203
- prose ? undefined : 'prose.md',
204
- ].filter((name): name is string => name !== undefined),
205
- }
75
+ export const BAN_SETS: BanSets = {
76
+ characters: CHARACTERS,
77
+ words: WORDS,
78
+ spellings: SPELLINGS,
206
79
  }
207
80
 
208
81
  /**
209
- * Reads both closed sets out of the standards stating them.
82
+ * Names any set that arrived empty, so a run measuring nothing says so.
210
83
  *
211
- * Holding the lists in code was the alternative and it puts the bans in two
212
- * places, where an author adding a banned word gets no enforcement until
213
- * someone edits TypeScript. The trade is a reader of prose that a reformat can
214
- * break, which `bans.test.ts` answers by asserting the parsed sets against the
215
- * shipped standards.
84
+ * Empty is not the same state as finding no hit. A scan with no terms reports
85
+ * a clean file having looked for nothing, which is the silence the audit
86
+ * refused to ship back when a standard could go missing. The sets ship with
87
+ * the package now, so the only way one empties is an edit to this file, and
88
+ * the guard is what keeps that edit loud rather than quiet.
216
89
  */
217
- export function banReport(standards: Standards): BanReport {
218
- const { markdown, prose } = standards
219
-
220
- return {
221
- characters: markdown ? parseCharacterBans(markdown.text) : [],
222
- words: prose ? parseWordBans(prose.text) : [],
223
- spellings: prose ? parseSpellingBans(prose.text) : [],
224
- sources: [markdown?.source, prose?.source].filter(
225
- (source): source is string => source !== undefined,
226
- ),
227
- missing: standards.missing,
228
- }
229
- }
230
-
231
- export async function loadBans(root: string): Promise<BanReport> {
232
- return banReport(await loadStandards(root))
90
+ export function emptyBanSets(sets: BanSets = BAN_SETS): string[] {
91
+ return (['characters', 'words', 'spellings'] as const).filter(
92
+ (name) => sets[name].length === 0,
93
+ )
233
94
  }
@@ -24,30 +24,17 @@ const BLOCKQUOTE = /^\s*>/
24
24
  */
25
25
  const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
26
26
 
27
- const NUMBER_WORDS: Record<string, number> = {
28
- one: 1,
29
- two: 2,
30
- three: 3,
31
- four: 4,
32
- five: 5,
33
- six: 6,
34
- seven: 7,
35
- eight: 8,
36
- nine: 9,
37
- ten: 10,
38
- }
39
-
40
27
  /**
41
- * Checkpoints as `standards/markdown.md` states them today.
28
+ * Checkpoints the structural measures run against.
42
29
  *
43
- * These are the fallback rather than the definition. Each is read out of the
44
- * standard per run, and this stands in for one the standard no longer states in
45
- * a shape the reader recognizes, so a rewording degrades a number rather than
46
- * the whole check. The report's legend names every checkpoint that fell back,
47
- * because a stale number quietly measuring the wrong thing is the failure mode
48
- * a silent default would ship.
30
+ * These are the definition rather than a fallback. Reading each number out of
31
+ * the sentence `standards/markdown.md` states it in was the alternative, and it
32
+ * put a parser contract on a document authored for people, where a rewording
33
+ * degraded a number and the report had to carry a legend saying which one. The
34
+ * standard still states every number for a reader, and moving one is an edit to
35
+ * both.
49
36
  */
50
- export const DEFAULT_CHECKPOINTS = {
37
+ export const CHECKPOINTS = {
51
38
  run: 40,
52
39
  peerBullet: 130,
53
40
  bullet: 400,
@@ -56,15 +43,13 @@ export const DEFAULT_CHECKPOINTS = {
56
43
  renderWidth: 80,
57
44
  } as const
58
45
 
59
- export type CheckpointName = keyof typeof DEFAULT_CHECKPOINTS
60
-
61
46
  /**
62
47
  * Columns a source line wraps at when rendered.
63
48
  *
64
49
  * Nothing in this repository sets a line width and entries are authored one
65
50
  * line per bullet, so the rendered width is the viewer's rather than the file's.
66
51
  */
67
- export const RENDER_WIDTH: number = DEFAULT_CHECKPOINTS.renderWidth
52
+ export const RENDER_WIDTH: number = CHECKPOINTS.renderWidth
68
53
 
69
54
  export interface Checkpoints {
70
55
  readonly run: number
@@ -73,8 +58,6 @@ export interface Checkpoints {
73
58
  readonly paragraph: number
74
59
  readonly sentences: number
75
60
  readonly renderWidth: number
76
- /** Names that fell back, so the report can say which number is not the standard's. */
77
- readonly fellBack: readonly CheckpointName[]
78
61
  }
79
62
 
80
63
  export interface BulletFinding {
@@ -99,51 +82,6 @@ export interface StructureReport {
99
82
  readonly heavyParagraphs: readonly ParagraphFinding[]
100
83
  }
101
84
 
102
- const PATTERNS: Record<CheckpointName, RegExp> = {
103
- run: /Past roughly (\d+) rendered lines/,
104
- peerBullet: /averaging under roughly (\d+) characters/,
105
- bullet: /Past roughly (\d+) characters in one top-level bullet/,
106
- paragraph: /Past roughly (\d+) characters in one paragraph/,
107
- sentences: /Keep paragraphs to ([a-z]+|\d+) sentences or fewer/,
108
- renderWidth: /wrapping each source line at (\d+) columns/,
109
- }
110
-
111
- /**
112
- * Reads each checkpoint out of the standard stating it.
113
- *
114
- * A number held in code is a second place the rule lives, and the two drift
115
- * silently because nothing compares them. What this buys is that raising a
116
- * checkpoint is an edit to the sentence a reader is pointed at, and what it
117
- * costs is a reader of prose, which is bounded by falling back per checkpoint
118
- * rather than per file.
119
- */
120
- export function parseCheckpoints(markdown: string): Checkpoints {
121
- const fellBack: CheckpointName[] = []
122
-
123
- const read = (name: CheckpointName): number => {
124
- const match = markdown.match(PATTERNS[name])
125
- const raw = match?.[1]
126
- const value = raw ? (NUMBER_WORDS[raw] ?? Number(raw)) : Number.NaN
127
-
128
- if (!Number.isFinite(value) || value <= 0) {
129
- fellBack.push(name)
130
- return DEFAULT_CHECKPOINTS[name]
131
- }
132
-
133
- return value
134
- }
135
-
136
- return {
137
- run: read('run'),
138
- peerBullet: read('peerBullet'),
139
- bullet: read('bullet'),
140
- paragraph: read('paragraph'),
141
- sentences: read('sentences'),
142
- renderWidth: read('renderWidth'),
143
- fellBack,
144
- }
145
- }
146
-
147
85
  /**
148
86
  * Height a source line occupies once wrapped.
149
87
  *
@@ -3,6 +3,14 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ TEACH_GLOSSARY,
8
+ TEACH_MISSION,
9
+ TEACH_RECORDS,
10
+ TEACH_REFERENCE,
11
+ TEACH_RESOURCES,
12
+ WORKSPACE_NAME,
13
+ } from '@/teach/workspace'
6
14
 
7
15
  export const RECORD_KINDS = [
8
16
  'plans',
@@ -568,14 +576,7 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
568
576
  return [...findings, ...perCluster.flat()]
569
577
  }
570
578
 
571
- const TEACH_MISSION = 'MISSION.md'
572
- const TEACH_RESOURCES = 'RESOURCES.md'
573
- const TEACH_GLOSSARY = 'GLOSSARY.md'
574
- const TEACH_REFERENCE = 'reference'
575
- const TEACH_RECORDS = 'learning-records'
576
-
577
579
  const TEACH_SUCCESS = /^##[ \t]+Success looks like[ \t]*$/
578
- const WORKSPACE_NAME = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*$/
579
580
  const NUMBERED_RECORD = /^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
580
581
  /**
581
582
  * A kebab slug that does not open with an ordinal. The lookahead rejects a
@@ -9,11 +9,16 @@ const PLANS_DIR = join('.claude', 'plans')
9
9
 
10
10
  /**
11
11
  * Siblings that sit on the board without being tasks: the generated index, the
12
- * hand-maintained ordering, and a handoff a session wrote before the file took
13
- * one name per session. `validate` reads the same list, so neither verb can
14
- * count a sibling as a task the other does not.
12
+ * hand-maintained ordering, the unordered backlog beside it, and a handoff a
13
+ * session wrote before the file took one name per session. `validate` reads the
14
+ * same list, so neither verb can count a sibling as a task the other does not.
15
15
  */
16
- export const RESERVED_STEMS = ['index', 'priority', 'session'] as const
16
+ export const RESERVED_STEMS = [
17
+ 'index',
18
+ 'priority',
19
+ 'backlog',
20
+ 'session',
21
+ ] as const
17
22
 
18
23
  /** The pre-compaction handoff takes one file per session, so its stems vary. */
19
24
  const SESSION_MAP_PREFIX = 'session-'
@@ -9,6 +9,7 @@ import {
9
9
  } from '@/tasks/archive'
10
10
 
11
11
  const ORDERING_FILE = 'priority.md'
12
+ const BACKLOG_FILE = 'backlog.md'
12
13
 
13
14
  /**
14
15
  * The readiness headings `.claude/standards/tasks.md` fixes. The names are the
@@ -60,6 +61,16 @@ export interface Untested {
60
61
  readonly message: string
61
62
  }
62
63
 
64
+ /**
65
+ * A backlog line, which carries a pointer and nothing else. The backlog is
66
+ * explicitly unordered, so a line has no position to read and no columns to
67
+ * resolve.
68
+ */
69
+ export interface BacklogRow {
70
+ readonly label: string
71
+ readonly stem: string | undefined
72
+ }
73
+
63
74
  export interface BoardRow {
64
75
  readonly group: BoardGroup
65
76
  readonly label: string
@@ -74,6 +85,7 @@ export interface BoardRow {
74
85
  export interface ValidateReport {
75
86
  readonly ok: true
76
87
  readonly rows: number
88
+ readonly backlog: number
77
89
  readonly tasks: number
78
90
  readonly findings: readonly Finding[]
79
91
  readonly untested: readonly Untested[]
@@ -91,6 +103,10 @@ export function orderingPath(root: string): string {
91
103
  return join(tasksDir(root), ORDERING_FILE)
92
104
  }
93
105
 
106
+ export function backlogPath(root: string): string {
107
+ return join(tasksDir(root), BACKLOG_FILE)
108
+ }
109
+
94
110
  /**
95
111
  * Pulls the target out of a markdown link, which is how both the `Task` and the
96
112
  * `Plan` column spell their pointer. A cell carrying prose instead of a link
@@ -225,6 +241,39 @@ export function readBoard(text: string): {
225
241
  return { rows, groups }
226
242
  }
227
243
 
244
+ /**
245
+ * Parses the backlog into one row per bullet carrying a link. The backlog is a
246
+ * flat list rather than a table, so there is no header to resolve and no group
247
+ * to sit under, and a bullet holding prose instead of a pointer is skipped.
248
+ *
249
+ * Skipping it rather than reporting it is what keeps the intro paragraph and
250
+ * any explanatory bullet out of the findings. The task that bullet meant to
251
+ * name is still accounted for, because a stem no line reaches is reported by
252
+ * `checkMapping` as carrying no row on either surface.
253
+ */
254
+ export function readBacklog(text: string): readonly BacklogRow[] {
255
+ const rows: BacklogRow[] = []
256
+
257
+ for (const line of text.split('\n')) {
258
+ if (!/^\s*[-*]\s/.test(line)) continue
259
+
260
+ const target = linkTarget(line)
261
+ if (!target) continue
262
+
263
+ // A pointer carrying a directory names something other than a sibling task,
264
+ // the way `citedStem` reads the same shape on the board.
265
+ const path = target.split('#')[0] ?? ''
266
+ if (path.includes('/')) continue
267
+
268
+ const stem = stemOf(path)
269
+ if (stem && isReservedStem(stem)) continue
270
+
271
+ rows.push({ label: linkText(line) || line.trim(), stem })
272
+ }
273
+
274
+ return rows
275
+ }
276
+
228
277
  async function listTaskStems(dir: string): Promise<string[]> {
229
278
  const entries = await readdir(dir)
230
279
 
@@ -247,13 +296,44 @@ function resolves(target: string, dir: string, root: string): boolean {
247
296
  return existsSync(resolve(dir, path)) || existsSync(resolve(root, path))
248
297
  }
249
298
 
299
+ /**
300
+ * Accounts every task file against both surfaces the board spans. A task sits
301
+ * on `priority.md` when it would plausibly be planned soon and on `backlog.md`
302
+ * otherwise, so a file reached by neither is the dropped one this reports and a
303
+ * file reached by both claims two contradictory things about itself.
304
+ */
250
305
  function checkMapping(
251
306
  rows: readonly BoardRow[],
307
+ backlog: readonly BacklogRow[],
252
308
  stems: readonly string[],
253
309
  dir: string,
254
310
  ): Finding[] {
255
311
  const findings: Finding[] = []
256
312
  const seen = new Map<string, number>()
313
+ const listed = new Set<string>()
314
+
315
+ for (const row of backlog) {
316
+ if (!row.stem) {
317
+ findings.push({
318
+ kind: 'task-unresolved',
319
+ group: undefined,
320
+ subject: row.label,
321
+ message: 'is a backlog line naming no task file.',
322
+ })
323
+ continue
324
+ }
325
+
326
+ listed.add(row.stem)
327
+
328
+ if (!existsSync(join(dir, `${row.stem}.md`))) {
329
+ findings.push({
330
+ kind: 'task-unresolved',
331
+ group: undefined,
332
+ subject: row.stem,
333
+ message: 'has a backlog line and no task file.',
334
+ })
335
+ }
336
+ }
257
337
 
258
338
  for (const row of rows) {
259
339
  if (!row.stem) {
@@ -287,15 +367,26 @@ function checkMapping(
287
367
  message: `carries ${count} rows. A task belongs to exactly one group.`,
288
368
  })
289
369
  }
370
+
371
+ if (listed.has(stem)) {
372
+ findings.push({
373
+ kind: 'row-duplicated',
374
+ group: undefined,
375
+ subject: stem,
376
+ message:
377
+ 'carries a row on the board and a line on the backlog. A task sits on one surface.',
378
+ })
379
+ }
290
380
  }
291
381
 
292
382
  for (const stem of stems) {
293
- if (!seen.has(stem)) {
383
+ if (!seen.has(stem) && !listed.has(stem)) {
294
384
  findings.push({
295
385
  kind: 'row-missing',
296
386
  group: undefined,
297
387
  subject: stem,
298
- message: 'is a task file with no row on the board.',
388
+ message:
389
+ 'is a task file with no row on the board and no line on the backlog.',
299
390
  })
300
391
  }
301
392
  }
@@ -547,11 +638,19 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
547
638
  )
548
639
  }
549
640
 
641
+ // An absent backlog reads as empty rather than refusing. A project that has
642
+ // never needed the second surface keeps every task on the board, which is the
643
+ // one-to-one mapping this check ran before the backlog existed.
644
+ const backlogFile = backlogPath(root)
645
+ const backlog = existsSync(backlogFile)
646
+ ? readBacklog(await readFile(backlogFile, 'utf8'))
647
+ : []
648
+
550
649
  const stems = await listTaskStems(dir)
551
650
  const parked = await checkParked(rows, root)
552
651
 
553
652
  const findings = [
554
- ...checkMapping(rows, stems, dir),
653
+ ...checkMapping(rows, backlog, stems, dir),
555
654
  ...checkPlans(rows, dir, root),
556
655
  ...checkCollisions(rows),
557
656
  ...parked.findings,
@@ -560,6 +659,7 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
560
659
  return {
561
660
  ok: true,
562
661
  rows: rows.length,
662
+ backlog: backlog.length,
563
663
  tasks: stems.length,
564
664
  findings,
565
665
  untested: parked.untested,