@erclx/aitk 0.101.0 → 0.103.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,14 +1,19 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { type BanReport, banReport, loadStandards } from '@/markdown/bans'
4
+ import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
5
5
  import { resolveMarkdown } from '@/markdown/files'
6
6
  import { isGating } from '@/markdown/gate'
7
- import { type BanFinding, bodyLines, scanBans } from '@/markdown/scan'
8
7
  import {
8
+ type BanFinding,
9
+ type BanSets,
10
+ bodyLines,
11
+ scanBans,
12
+ } from '@/markdown/scan'
13
+ import {
14
+ CHECKPOINTS,
9
15
  type Checkpoints,
10
16
  measureStructure,
11
- parseCheckpoints,
12
17
  type StructureReport,
13
18
  } from '@/markdown/structure'
14
19
  import {
@@ -21,8 +26,19 @@ import {
21
26
  plural,
22
27
  } from '@/ui'
23
28
 
29
+ const EXIT_REFUSED = 1
24
30
  const EXIT_GATE = 2
25
31
 
32
+ /**
33
+ * A set shipped empty, so the run measured a corpus against nothing.
34
+ *
35
+ * Distinct from `EXIT_REFUSED` because the two want different responses. A
36
+ * refusal means no corpus was built and a push stage is right to skip, while an
37
+ * empty set means the corpus was walked and nothing was looked for, which is a
38
+ * defect in the build and has to fail.
39
+ */
40
+ const EXIT_UNUSABLE = 3
41
+
26
42
  interface AuditCommandOptions {
27
43
  readonly json?: boolean
28
44
  }
@@ -58,6 +74,7 @@ export function register(program: Command): void {
58
74
  ' 0 the audit completed with no gating finding',
59
75
  ' 1 refused, with the reason on stderr',
60
76
  ' 2 a banned character, word, or spelling is present',
77
+ ' 3 a shipped ban set is empty, so the run measured nothing',
61
78
  '',
62
79
  'A ban hit is a fact and gates unconditionally. Bullet, paragraph, and',
63
80
  'depth weight are judgments a reader settles, so all three report and',
@@ -68,12 +85,11 @@ export function register(program: Command): void {
68
85
  'where the token is genuinely an identifier under discussion, which is',
69
86
  'what markdown.md reserves the span for.',
70
87
  '',
71
- 'Bans and checkpoints are read from markdown.md and prose.md, resolved',
72
- 'under .claude/standards/, then standards/, then the corpus inside the',
73
- 'aitk package, so a project that installed neither is still measured.',
74
- 'The report names the copy it read. No folder has to resolve and no',
75
- 'index.md has to exist, so .claude/rules/, governance/, and snippets/',
76
- 'are in reach.',
88
+ 'Bans and checkpoints ship with the aitk package rather than being read',
89
+ 'out of a standards file, so a project that installed no standards is',
90
+ 'measured the same as one that did. markdown.md and prose.md still state',
91
+ 'every rule for a reader. No folder has to resolve and no index.md has',
92
+ 'to exist, so .claude/rules/, governance/, and snippets/ are in reach.',
77
93
  '',
78
94
  'Examples:',
79
95
  ' aitk markdown audit',
@@ -110,9 +126,9 @@ async function runAudit(
110
126
  )
111
127
  }
112
128
 
113
- const standards = await loadStandards(root)
114
- const bans = banReport(standards)
115
- const checkpoints = parseCheckpoints(standards.markdown?.text ?? '')
129
+ const bans = BAN_SETS
130
+ const empty = emptyBanSets(bans)
131
+ const checkpoints = CHECKPOINTS
116
132
 
117
133
  const reports: FileReport[] = await Promise.all(
118
134
  scope.files.map(async (rel) => {
@@ -127,7 +143,7 @@ async function runAudit(
127
143
 
128
144
  intro('aitk markdown audit')
129
145
  reportScope(scope.files, scope.unmatched)
130
- reportBans(reports, bans)
146
+ reportBans(reports, bans, empty)
131
147
  reportBullets(reports, checkpoints)
132
148
  reportParagraphs(reports, checkpoints)
133
149
  reportDepth(reports, checkpoints)
@@ -143,8 +159,7 @@ async function runAudit(
143
159
  characters: bans.characters,
144
160
  words: bans.words,
145
161
  spellings: bans.spellings,
146
- sources: bans.sources,
147
- missingStandards: bans.missing,
162
+ emptySets: empty,
148
163
  },
149
164
  checkpoints: {
150
165
  run: checkpoints.run,
@@ -153,7 +168,6 @@ async function runAudit(
153
168
  paragraph: checkpoints.paragraph,
154
169
  sentences: checkpoints.sentences,
155
170
  renderWidth: checkpoints.renderWidth,
156
- fellBack: checkpoints.fellBack,
157
171
  },
158
172
  entries: reports.map((report) => ({
159
173
  path: report.rel,
@@ -167,6 +181,10 @@ async function runAudit(
167
181
  )
168
182
  }
169
183
 
184
+ // An empty set finds nothing and would exit clean, which reports a corpus
185
+ // nobody checked as a corpus carrying no violation.
186
+ if (empty.length > 0) return EXIT_UNUSABLE
187
+
170
188
  const gating = isGating({
171
189
  bans: reports.flatMap((report) => report.bans),
172
190
  structure: reports.map((report) => report.structure),
@@ -180,7 +198,7 @@ function refuse(message: string): number {
180
198
  logStep('Refused')
181
199
  logWarn(message)
182
200
  outro()
183
- return 1
201
+ return EXIT_REFUSED
184
202
  }
185
203
 
186
204
  function reportScope(
@@ -203,18 +221,22 @@ function reportScope(
203
221
  * the sentence and every voice rule is a judgment, so a report listing hits
204
222
  * without naming those would read as a verdict on the whole standard.
205
223
  */
206
- function reportBans(reports: readonly FileReport[], bans: BanReport): void {
224
+ function reportBans(
225
+ reports: readonly FileReport[],
226
+ bans: BanSets,
227
+ empty: readonly string[],
228
+ ): void {
207
229
  logStep('Bans')
208
230
 
209
- if (bans.missing.length > 0) {
231
+ if (empty.length > 0) {
210
232
  logWarn(
211
- `Not measured. Found no copy of: ${bans.missing.join(', ')}. Looked under .claude/standards/, then standards/, then the aitk package.`,
233
+ `Not measured. The shipped set is empty for: ${empty.join(', ')}. The sets ship with the aitk package, so an empty one is a defect in the build rather than a missing install.`,
212
234
  )
213
- if (bans.sources.length === 0) return
235
+ return
214
236
  }
215
237
 
216
238
  logInfo(
217
- `${plural(bans.characters.length, 'character')}, ${plural(bans.words.length, 'word')}, and ${plural(bans.spellings.length, 'spelling')} read from ${bans.sources.join(' and ')}`,
239
+ `${plural(bans.characters.length, 'character')}, ${plural(bans.words.length, 'word')}, and ${plural(bans.spellings.length, 'spelling')} shipped with the aitk package`,
218
240
  )
219
241
  logInfo(
220
242
  'Frontmatter, fenced blocks, code spans, and link destinations are excluded.',
@@ -379,12 +401,6 @@ function reportDepth(
379
401
  'A run that is entirely table rows is excluded too, since a heading inside a table splits the table rather than the run.',
380
402
  )
381
403
 
382
- if (checkpoints.fellBack.length > 0) {
383
- logWarn(
384
- `Read no number from the standard for: ${checkpoints.fellBack.join(', ')}. Measured against the shipped default instead.`,
385
- )
386
- }
387
-
388
404
  const over = reports
389
405
  .filter((report) => report.structure.longestRun > checkpoints.run)
390
406
  .sort((a, b) => b.structure.longestRun - a.structure.longestRun)
@@ -104,7 +104,7 @@ export function register(program: Command): void {
104
104
  '',
105
105
  'Checks:',
106
106
  ' every Run now row points at a plan file that resolves',
107
- ' every row maps to a task file and every task file to a row',
107
+ ' every task file carries a board row or a backlog line, never both',
108
108
  ' no task carries more than one row',
109
109
  ' no two Run now rows touch the same file',
110
110
  '',
@@ -450,12 +450,14 @@ function reportValidation(
450
450
  intro('aitk tasks validate')
451
451
  logStep('Board')
452
452
  logInfo(
453
- `${outcome.rows} row(s) across the readiness groups, ${outcome.tasks} task file(s)`,
453
+ `${outcome.rows} row(s) across the readiness groups, ${outcome.backlog} backlog line(s), ${outcome.tasks} task file(s)`,
454
454
  )
455
455
 
456
456
  logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
457
457
  if (outcome.findings.length === 0) {
458
- logInfo('every row resolves, maps one to one, and touches its own files')
458
+ logInfo(
459
+ 'every row resolves, every task sits on one surface, and each touches its own files',
460
+ )
459
461
  } else {
460
462
  for (const finding of outcome.findings) logWarn(describe(finding))
461
463
  }
@@ -481,6 +483,7 @@ function reportValidation(
481
483
  ok: true,
482
484
  root,
483
485
  rows: outcome.rows,
486
+ backlog: outcome.backlog,
484
487
  tasks: outcome.tasks,
485
488
  findings: outcome.findings,
486
489
  untested: outcome.untested,
@@ -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
  *
@@ -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-'