@erclx/aitk 0.107.2 → 0.108.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,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.107.2",
4
+ "version": "0.108.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -27,7 +27,9 @@ A bare run measures every markdown file git lists, tracked plus untracked-and-no
27
27
 
28
28
  ## Where the rules come from
29
29
 
30
- The three ban sets and all six checkpoints ship with the `aitk` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
30
+ The three ban sets and all nine checkpoints ship with the `aitk` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
31
+
32
+ Six of the nine are stated in `markdown.md` and the three cadence numbers are stated in the `write-human` skill. That split is the content boundary rather than an accident: `markdown.md` carries the enforced rules a scan can decide, and the skill carries the rhythm rules a ban list cannot express. A cadence number moved in the skill and left in the code drifts the same way, so move both.
31
33
 
32
34
  Reading them out of the standards per run was the original design. It put a parser contract on a document authored for people, and the standard had to carry a paragraph of its own warning an author that a one-word backticked example in a `- Do not use ` bullet would be lifted into a literal ban set and ban that word everywhere. A rule existing to protect a parser from the prose it parses is the argument for separating them.
33
35
 
@@ -98,9 +100,29 @@ That re-sample ran once the scan stopped counting link syntax as prose. Findings
98
100
 
99
101
  A bullet, a heading, a table row, a blockquote, a blank line, and a fence each end a paragraph, so a heavy bullet is reported by the bullet check alone and never counted twice.
100
102
 
103
+ ### Cadence
104
+
105
+ Uniform cadence is the failure a ban list cannot express. A ban set states negatives, and fragments, verbless clauses, and sentences that all run one length are each the absence of something, so no addition to the thirteen banned words reaches any of them. The shape layer already measured a bullet, a paragraph, and a run, and stopped one level above where that failure lives.
106
+
107
+ Cadence measures a paragraph on two numbers. The spread is the words between its longest and shortest sentence, and the opener count is the times one word opens a sentence in it. A spread of five words or under reads as one cadence, and a word opening more than two sentences is a pattern rather than a coincidence. Both come from `## Rhythm` in the `write-human` skill, which states them about prose a person reads, and this measures against that statement rather than setting a threshold of its own.
108
+
109
+ Words are counted off the text a reader is shown. A link contributes its anchor text, an autolink contributes nothing, and the sentence boundaries do not move under that masking, since the boundary pattern requires whitespace after the terminal punctuation and no destination carries any. An opening word is lowercased and stripped of punctuation, so a sentence opening on a backticked command name reports the command.
110
+
111
+ A paragraph carrying fewer than three sentences is skipped rather than scored. A two-sentence configuration note has no spread worth reading, and the opener rule is written about a third sentence turning a coincidence into a pattern, so neither number says anything before the floor. That is the cheap form of a wider exemption: a shape-aware one, exempting a short reference block by what it is rather than by how many sentences it holds, waits on a second case.
112
+
113
+ The unit is the paragraph and each file names its worst on each measure, which follows the depth check rather than setting a precedent. A file's flattest paragraph and its most repetitive one are named only when each crosses its checkpoint, so a file reading healthy names nothing rather than offering its least healthy paragraph as a finding.
114
+
115
+ Neither number gates and neither names a file wrong. This is a weaker claim than the one the weight checkpoints make, because a healthy range differs by surface: a catalog entry is several short sentences carrying one fact each, and a page arguing a decision is not, so one range applied across the corpus would report the surfaces that are correct. The run therefore states where the numbers came from beside them, and the counts are what a reader compares against.
116
+
117
+ Measured at `6c273324` across the corpus, 2119 paragraphs reached the floor, 165 sat at or under the spread checkpoint and 50 past the opener checkpoint. Over the 60 files carrying at least ten measured paragraphs the flat rate runs from zero to 21 percent with a median near 6, which is the spread that makes the measure worth reading. Two wiki pages sit at either end of it: a server catalog reports 19 percent, its flattest paragraph five sentences within four words of each other and three of them verbless, while a page arguing where the prose rules came from reports none.
118
+
119
+ That reading travels with the command rather than staying here. `BASELINE` in `src/markdown/structure.ts` carries the overall share, the per-file range, and the ten-paragraph floor beneath which a file's own rate says nothing, and the run prints all four in the legend beside the rate it measured. A count with no range beside it reads as a finding, and naming that a healthy range differs by surface states that a range exists rather than what it looks like. This page is toolkit-internal, so a reader running the command in a project that installed no standards would otherwise have two counts and nothing to place them against.
120
+
121
+ Two of the rules `write-human` states are deliberately not implemented. A sentence's grammatical shape and whether it carries a finite verb each need a parse rather than a match, and an imperative or a heading fragment would read as a defect under a pattern that approximated either. The verbless share is the measure closest to the reported symptom, which is exactly why shipping it wrong would discredit the two that hold. It ships when something can identify a finite verb rather than guess at one.
122
+
101
123
  ## Exit codes
102
124
 
103
- Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling fails the run. Bullet, paragraph, and depth weight are judgments a reader settles, so all three report under every code.
125
+ Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling fails the run. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
104
126
 
105
127
  `3` is separate from `1` because the two want different responses from a caller. A refusal means no corpus was built, and the `Markdown bans` stage in `scripts/core/verify.sh` is right to warn and skip. An empty set means the corpus was walked and nothing was looked for, so that stage fails the push on `3` rather than skipping.
106
128
 
@@ -67,7 +67,11 @@ aitk claude skills drift 02d7b265 --json
67
67
  | -------- | ---------------------------------------------------------- |
68
68
  | `--json` | Add a machine-readable record on stdout, keeping the frame |
69
69
 
70
- A skill body enters a session once and stays. Re-invoking the skill replays the copy the session already holds rather than re-reading the file, so a session that outlives a merge touching a body it loaded keeps applying what it loaded and nothing compares the two. A compaction carries the held copy forward with the summary it writes, which makes the exposure the age of the oldest load rather than the age of the session.
70
+ A skill body enters a session once and stays. Re-invoking the skill does not re-read the file, so an edit made this session is not picked up and nothing compares the two. A session that outlives a merge touching a body it loaded keeps applying what it loaded, and a compaction carries the held copy forward with the summary it writes, which makes that exposure the age of the oldest load rather than the age of the session.
71
+
72
+ Age is one route and not the only one. A session that edits a body and then invokes that same skill later in the same run replays the pre-edit text, which is what a ship chain does whenever a branch touches a body the chain runs at the end. This verb does not reach that route, because it reads history and the edit is still uncommitted when the replay happens. Re-read a body after editing it, and do not wait for a report to name it.
73
+
74
+ Which shape the staleness takes decides whether anyone notices. A held body naming a file the branch deleted fails loudly by having nothing to read. One naming a file that still exists while saying something different resolves and reads current, so the session applies the stale rule and reports success.
71
75
 
72
76
  The ref is required and carries no default. `HEAD` is the only value the command could supply for itself, and it answers every run with nothing moved, which is the silence this verb exists to break. A session passes the commit it started from.
73
77
 
@@ -83,6 +87,8 @@ The report names the newest commit that rewrote each body rather than the first,
83
87
 
84
88
  The report says a file moved, not that a session holds a stale copy. A session cannot read its own loaded body as bytes, so the comparison runs against history and a ref older than the oldest load over-reports. That is the safe direction, since confirming a name costs one read of the body while the failure being answered is silence. Every run states the bound, including the run that names nothing.
85
89
 
90
+ The verb answers only when someone runs it, and a report read is not a report acted on. A session that sees a moved body in the result and does not re-read that body stands where it started. One session reached that outcome after running the verb at session start, then followed the held copy for the rest of the day.
91
+
86
92
  Two cases fall outside the range. An uncommitted edit in the working tree is not history yet, so a body changed and left unstaged reports as unmoved. A target project loads the plugin from a marketplace cache with no repository behind it, where the verb refuses and names the absent history rather than reporting a clean tree.
87
93
 
88
94
  Exit codes are `0` when history was read, whether or not a body moved, and `1` when the question could not be answered. An absent shipped corpus, an absent repository, and a ref matching no commit each refuse with their own reason on stderr. A command exiting zero on a question it never answered is the failure `.claude/ARCHITECTURE.md` already records against two skills, which is why no path here reports an empty result in place of a refusal.
@@ -13,6 +13,11 @@ paths:
13
13
 
14
14
  - Read the skill's sibling `REQUIREMENT.md` when one exists. If the change closes no gap it states, change the requirement first or drop the change.
15
15
 
16
+ ## After editing
17
+
18
+ - Re-read a skill body this session edited before invoking that skill again in the same session
19
+ - Do not read a resolved file path in a held body as evidence the body is current
20
+
16
21
  ## Authority
17
22
 
18
23
  - Follow `.claude/standards/skill.md` for skill structure, frontmatter fields, invocation rules, and the shape a `REQUIREMENT.md` states. It is the single source.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.107.2",
4
+ "version": "0.108.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -11,6 +11,7 @@ import {
11
11
  scanBans,
12
12
  } from '@/markdown/scan'
13
13
  import {
14
+ BASELINE,
14
15
  CHECKPOINTS,
15
16
  type Checkpoints,
16
17
  measureStructure,
@@ -58,7 +59,7 @@ export function register(program: Command): void {
58
59
  markdown
59
60
  .command('audit')
60
61
  .description(
61
- 'Fail on a banned character, word, or spelling, and report bullet, paragraph, and depth weight',
62
+ 'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and depth',
62
63
  )
63
64
  .argument(
64
65
  '[path...]',
@@ -80,6 +81,10 @@ export function register(program: Command): void {
80
81
  'depth weight are judgments a reader settles, so all three report and',
81
82
  'none of them fails a run.',
82
83
  '',
84
+ 'Cadence reports the same way and carries one more caveat. Its range is',
85
+ 'drawn from prose a person reads, so terse reference prose sits below',
86
+ 'it correctly and a flat paragraph there is not a defect.',
87
+ '',
83
88
  'Rewrite the sentence carrying a hit rather than swapping the token for',
84
89
  'a near-synonym. A code span clears the report and is the answer only',
85
90
  'where the token is genuinely an identifier under discussion, which is',
@@ -146,6 +151,7 @@ async function runAudit(
146
151
  reportBans(reports, bans, empty)
147
152
  reportBullets(reports, checkpoints)
148
153
  reportParagraphs(reports, checkpoints)
154
+ reportCadence(reports, checkpoints)
149
155
  reportDepth(reports, checkpoints)
150
156
  outro()
151
157
 
@@ -168,6 +174,9 @@ async function runAudit(
168
174
  paragraph: checkpoints.paragraph,
169
175
  sentences: checkpoints.sentences,
170
176
  renderWidth: checkpoints.renderWidth,
177
+ cadence: checkpoints.cadence,
178
+ spread: checkpoints.spread,
179
+ opener: checkpoints.opener,
171
180
  },
172
181
  entries: reports.map((report) => ({
173
182
  path: report.rel,
@@ -176,6 +185,7 @@ async function runAudit(
176
185
  longestRunLine: report.structure.longestRunLine,
177
186
  heavyBullets: report.structure.heavyBullets,
178
187
  heavyParagraphs: report.structure.heavyParagraphs,
188
+ cadence: report.structure.cadence,
179
189
  })),
180
190
  })}\n`,
181
191
  )
@@ -377,6 +387,99 @@ function reportParagraphs(
377
387
  )
378
388
  }
379
389
 
390
+ /**
391
+ * Reports the distribution rather than a verdict, which is what separates this
392
+ * step from the three above it.
393
+ *
394
+ * Bullet, paragraph, and depth weight each report a count against a checkpoint
395
+ * a reader settles. Cadence reports a count as well, and the range behind it is
396
+ * drawn from one surface class rather than from the corpus, so the step states
397
+ * where the numbers came from beside them. A reader whose file is terse
398
+ * reference prose is meant to read a flat paragraph as correct, and a number
399
+ * printed with no comparison beside it reads as a finding whatever the step is
400
+ * called.
401
+ */
402
+ function reportCadence(
403
+ reports: readonly FileReport[],
404
+ checkpoints: Checkpoints,
405
+ ): void {
406
+ logStep('Cadence')
407
+ logInfo(
408
+ `Paragraphs of ${checkpoints.cadence} sentences or more measure the words between their longest and shortest sentence, and the times one word opens a sentence.`,
409
+ )
410
+ logInfo(
411
+ `A spread of ${checkpoints.spread} words or under reads as one cadence, and a word opening more than ${checkpoints.opener} sentences is a pattern rather than a coincidence.`,
412
+ )
413
+ logInfo(
414
+ 'Both numbers are stated under Rhythm in the write-human skill, which writes them about prose a person reads.',
415
+ )
416
+ logInfo(
417
+ 'A shorter paragraph stays unmeasured, since a two-sentence note carries no spread worth reading.',
418
+ )
419
+ logInfo(
420
+ 'A healthy range differs by surface, so neither number gates and neither names a file wrong. Terse reference prose sits below the range a page written for a reader sits in.',
421
+ )
422
+ logInfo(
423
+ `The corpus these numbers were read against measured ${BASELINE.flatShare} percent flat overall, and its files carrying ${BASELINE.floor} or more measured paragraphs ran from ${BASELINE.low} to ${BASELINE.high} percent with a median near ${BASELINE.median}. Compare a rate against that rather than against zero.`,
424
+ )
425
+
426
+ const measured = reports.reduce(
427
+ (sum, report) => sum + report.structure.cadence.measured,
428
+ 0,
429
+ )
430
+
431
+ if (measured === 0) {
432
+ logInfo(
433
+ `No paragraph reached ${checkpoints.cadence} sentences, so nothing was measured.`,
434
+ )
435
+ return
436
+ }
437
+
438
+ const flat = reports.reduce(
439
+ (sum, report) => sum + report.structure.cadence.flat,
440
+ 0,
441
+ )
442
+ const repeating = reports.reduce(
443
+ (sum, report) => sum + report.structure.cadence.repeating,
444
+ 0,
445
+ )
446
+
447
+ logInfo(
448
+ `${plural(measured, 'paragraph')} measured, ${flat} at or under the spread checkpoint (${Math.round((flat / measured) * 100)} percent) and ${repeating} past the opener checkpoint.`,
449
+ )
450
+
451
+ const carrying = reports
452
+ .filter(
453
+ (report) =>
454
+ report.structure.cadence.flattest ||
455
+ report.structure.cadence.mostRepeated,
456
+ )
457
+ .sort(
458
+ (a, b) =>
459
+ b.structure.cadence.flat +
460
+ b.structure.cadence.repeating -
461
+ (a.structure.cadence.flat + a.structure.cadence.repeating),
462
+ )
463
+
464
+ if (carrying.length === 0) return
465
+
466
+ pipeOutput(
467
+ carrying
468
+ .map((report) => {
469
+ const { flattest, mostRepeated } = report.structure.cadence
470
+ const lines = [
471
+ flattest &&
472
+ ` :${flattest.line} ${plural(flattest.sentences, 'sentence')}, spread ${plural(flattest.spread, 'word')}`,
473
+ mostRepeated &&
474
+ ` :${mostRepeated.line} ${plural(mostRepeated.sentences, 'sentence')}, "${mostRepeated.opener}" opens ${mostRepeated.repeats}`,
475
+ ].filter(Boolean)
476
+
477
+ return `${report.rel}\n${lines.join('\n')}`
478
+ })
479
+ .join('\n'),
480
+ )
481
+ }
482
+
380
483
  /**
381
484
  * Names the render width and the blank-line convention on every run.
382
485
  *
@@ -33,6 +33,14 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
33
33
  * degraded a number and the report had to carry a legend saying which one. The
34
34
  * standard still states every number for a reader, and moving one is an edit to
35
35
  * both.
36
+ *
37
+ * The last three come from the `## Rhythm` section of the `write-human` skill
38
+ * rather than from a standard. That skill states what good rhythm is and this
39
+ * measures against the statement, so the numbers are read off the sentences
40
+ * that already carry them: a longest and shortest sentence within roughly five
41
+ * words of each other is one cadence, an opening word repeating twice is
42
+ * coincidence and three times is a pattern, and both rules are written about a
43
+ * paragraph, which needs a third sentence before either says anything.
36
44
  */
37
45
  export const CHECKPOINTS = {
38
46
  run: 40,
@@ -41,6 +49,37 @@ export const CHECKPOINTS = {
41
49
  paragraph: 700,
42
50
  sentences: 4,
43
51
  renderWidth: 80,
52
+ cadence: 3,
53
+ spread: 5,
54
+ opener: 2,
55
+ } as const
56
+
57
+ /**
58
+ * The reading a cadence rate is compared against, rather than a checkpoint.
59
+ *
60
+ * A count with nothing beside it reads as a finding, and the two counts the
61
+ * cadence step reports have no range a reader can place them in: twelve flat
62
+ * paragraphs out of forty says the same as two without one. Naming that a
63
+ * healthy range differs by surface states that a range exists rather than what
64
+ * it looks like, so the reading travels with the command.
65
+ *
66
+ * This is an observation rather than a rule, which is why it sits apart from
67
+ * `CHECKPOINTS`. Nothing compares a run against it and no exit code reads it. A
68
+ * project whose corpus is entirely terse reference prose is expected to sit
69
+ * above the high end, and that is the measure working.
70
+ *
71
+ * Read across 483 markdown files at `6c273324` on 2026-08-20. A number here
72
+ * goes stale against the corpus it describes with nothing comparing the two, so
73
+ * re-measure before moving one.
74
+ */
75
+ export const BASELINE = {
76
+ /** Share of every measured paragraph sitting at or under the spread checkpoint. */
77
+ flatShare: 8,
78
+ /** Measured paragraphs a file needs before its own rate means anything. */
79
+ floor: 10,
80
+ low: 0,
81
+ median: 6,
82
+ high: 21,
44
83
  } as const
45
84
 
46
85
  /**
@@ -58,6 +97,12 @@ export interface Checkpoints {
58
97
  readonly paragraph: number
59
98
  readonly sentences: number
60
99
  readonly renderWidth: number
100
+ /** Sentences a paragraph needs before its cadence is measured at all. */
101
+ readonly cadence: number
102
+ /** Words between the longest and shortest sentence, at or under which the paragraph reads as one cadence. */
103
+ readonly spread: number
104
+ /** Times one opening word may open a sentence in a paragraph before it is a pattern. */
105
+ readonly opener: number
61
106
  }
62
107
 
63
108
  export interface BulletFinding {
@@ -72,6 +117,47 @@ export interface ParagraphFinding {
72
117
  readonly characters: number
73
118
  }
74
119
 
120
+ /**
121
+ * One measured paragraph, carrying both cadence numbers rather than one.
122
+ *
123
+ * A paragraph reported for a narrow spread is usually worth reading for its
124
+ * openers too, and splitting the two into separate finding types would name the
125
+ * same line twice with half the picture on each.
126
+ */
127
+ export interface CadenceFinding {
128
+ readonly line: number
129
+ readonly sentences: number
130
+ /** Words between the longest and shortest sentence. */
131
+ readonly spread: number
132
+ /** Times the most repeated opening word opens a sentence here. */
133
+ readonly repeats: number
134
+ /** The opening word behind `repeats`, lowercased and stripped of punctuation. */
135
+ readonly opener: string
136
+ }
137
+
138
+ /**
139
+ * A file's cadence distribution, reported rather than listed finding by finding.
140
+ *
141
+ * Both numbers are advisory in a way even the weight checkpoints are not. A
142
+ * healthy range differs by surface, so terse reference prose and a page written
143
+ * for a reader sit at different spreads and one range applied across the corpus
144
+ * would report the surfaces that are correct. The counts are what a reader
145
+ * compares against the stated range, and the two worst paragraphs are where
146
+ * they open the file.
147
+ */
148
+ export interface CadenceReport {
149
+ /** Paragraphs carrying at least the floor sentence count. */
150
+ readonly measured: number
151
+ /** Measured paragraphs at or under the spread checkpoint. */
152
+ readonly flat: number
153
+ /** Measured paragraphs past the opener checkpoint. */
154
+ readonly repeating: number
155
+ /** Narrowest paragraph among the flat ones, or undefined when none is flat. */
156
+ readonly flattest: CadenceFinding | undefined
157
+ /** Most repetitive paragraph among the repeating ones, or undefined when none repeats. */
158
+ readonly mostRepeated: CadenceFinding | undefined
159
+ }
160
+
75
161
  export interface StructureReport {
76
162
  readonly rel: string
77
163
  /** Rendered lines at the render width, not source lines. */
@@ -80,6 +166,7 @@ export interface StructureReport {
80
166
  readonly longestRunLine: number
81
167
  readonly heavyBullets: readonly BulletFinding[]
82
168
  readonly heavyParagraphs: readonly ParagraphFinding[]
169
+ readonly cadence: CadenceReport
83
170
  }
84
171
 
85
172
  /**
@@ -275,48 +362,46 @@ export function heavyBullets(
275
362
  return findings
276
363
  }
277
364
 
365
+ /**
366
+ * Splits a paragraph into the sentences terminal punctuation closes.
367
+ *
368
+ * A trailing span no punctuation closes is dropped rather than returned, which
369
+ * keeps this and the count below one definition. The pattern already matches a
370
+ * paragraph's final period through its end-of-text alternative, so the only
371
+ * span this drops is a paragraph genuinely ending without terminal punctuation,
372
+ * which is a fragment rather than a sentence to either measure.
373
+ */
374
+ function splitSentences(text: string): string[] {
375
+ const sentences: string[] = []
376
+ let start = 0
377
+
378
+ for (const match of text.matchAll(SENTENCE_END)) {
379
+ const end = match.index + match[0].length
380
+ sentences.push(text.slice(start, end).trim())
381
+ start = end
382
+ }
383
+
384
+ return sentences
385
+ }
386
+
278
387
  function countSentences(text: string): number {
279
- return [...text.matchAll(SENTENCE_END)].length
388
+ return splitSentences(text).length
280
389
  }
281
390
 
282
391
  /**
283
- * Finds the prose paragraphs past either half of the standard's checkpoint.
392
+ * Splits the body into its prose paragraphs, each a run of consecutive lines.
284
393
  *
285
- * Both halves are stated in the standard and both are read from it. The weight
286
- * half was added there rather than borrowed from the bullet checkpoint, which
287
- * governs a different construct: one number feeding both would move the
288
- * paragraph rule whenever the bullet rule was changed, and an author reading
289
- * the sentence cap would find no weight rule to read at all.
290
- *
291
- * The two numbers coincide today because paragraph and bullet weight measure
292
- * one population, sharing a median near 170 characters with no gap behind
293
- * either candidate. They are separate checkpoints regardless, so either moves
294
- * without dragging the other.
295
- *
296
- * A paragraph is a run of consecutive prose lines. A heading, a list item, a
297
- * table row, a blockquote, a blank line, and a fence each end one, so a bullet
298
- * is measured by `heavyBullets` alone and never twice.
394
+ * A heading, a list item, a table row, a blockquote, a blank line, and a fence
395
+ * each end one, so a bullet is measured by `heavyBullets` alone and never
396
+ * twice. Both paragraph measures walk this rather than one each, since two
397
+ * walks deciding what a paragraph is would drift apart.
299
398
  */
300
- export function heavyParagraphs(
301
- lines: readonly BodyLine[],
302
- checkpoints: Checkpoints,
303
- ): ParagraphFinding[] {
304
- const findings: ParagraphFinding[] = []
399
+ function paragraphBlocks(lines: readonly BodyLine[]): BodyLine[][] {
400
+ const blocks: BodyLine[][] = []
305
401
  let block: BodyLine[] = []
306
402
 
307
403
  const close = (): void => {
308
- if (block.length > 0) {
309
- const text = block.map((line) => line.text.trim()).join(' ')
310
- const sentences = countSentences(text)
311
- const characters = visibleText(text).length
312
-
313
- if (
314
- sentences > checkpoints.sentences ||
315
- characters > checkpoints.paragraph
316
- ) {
317
- findings.push({ line: block[0].number, sentences, characters })
318
- }
319
- }
404
+ if (block.length > 0) blocks.push(block)
320
405
  block = []
321
406
  }
322
407
 
@@ -340,9 +425,168 @@ export function heavyParagraphs(
340
425
 
341
426
  close()
342
427
 
428
+ return blocks
429
+ }
430
+
431
+ /** Folds a paragraph's source lines into the one line they would have wrapped from. */
432
+ function paragraphText(block: readonly BodyLine[]): string {
433
+ return block.map((line) => line.text.trim()).join(' ')
434
+ }
435
+
436
+ /**
437
+ * Finds the prose paragraphs past either half of the standard's checkpoint.
438
+ *
439
+ * Both halves are stated in the standard and both are read from it. The weight
440
+ * half was added there rather than borrowed from the bullet checkpoint, which
441
+ * governs a different construct: one number feeding both would move the
442
+ * paragraph rule whenever the bullet rule was changed, and an author reading
443
+ * the sentence cap would find no weight rule to read at all.
444
+ *
445
+ * The two numbers coincide today because paragraph and bullet weight measure
446
+ * one population, sharing a median near 170 characters with no gap behind
447
+ * either candidate. They are separate checkpoints regardless, so either moves
448
+ * without dragging the other.
449
+ */
450
+ export function heavyParagraphs(
451
+ lines: readonly BodyLine[],
452
+ checkpoints: Checkpoints,
453
+ ): ParagraphFinding[] {
454
+ const findings: ParagraphFinding[] = []
455
+
456
+ for (const block of paragraphBlocks(lines)) {
457
+ const text = paragraphText(block)
458
+ const sentences = countSentences(text)
459
+ const characters = visibleText(text).length
460
+
461
+ if (
462
+ sentences > checkpoints.sentences ||
463
+ characters > checkpoints.paragraph
464
+ ) {
465
+ findings.push({ line: block[0].number, sentences, characters })
466
+ }
467
+ }
468
+
343
469
  return findings
344
470
  }
345
471
 
472
+ /** Punctuation either side of a word, so an opener is compared on its letters. */
473
+ const WORD_EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
474
+
475
+ /**
476
+ * The word a sentence opens on, lowercased and stripped of punctuation.
477
+ *
478
+ * A sentence opening on a code span yields the command inside it rather than a
479
+ * backtick, which is the word a reader hears. Casing is dropped because an
480
+ * opener repeating is a repetition whether or not one of the two sits mid-list
481
+ * and lost its capital.
482
+ */
483
+ function openingWord(sentence: string): string {
484
+ const [first = ''] = sentence.split(/\s+/)
485
+ return first.replace(WORD_EDGE, '').toLowerCase()
486
+ }
487
+
488
+ /**
489
+ * Measures one paragraph's spread and its most repeated opener.
490
+ *
491
+ * Words are counted off the text a reader is shown, so a link contributes its
492
+ * anchor text and a destination contributes nothing. The sentence boundaries do
493
+ * not move with that masking, since the pattern requires whitespace after the
494
+ * terminal punctuation and no destination or autolink carries any.
495
+ */
496
+ function measureParagraph(block: readonly BodyLine[]): CadenceFinding {
497
+ const sentences = splitSentences(visibleText(paragraphText(block)))
498
+ const lengths = sentences.map(
499
+ (sentence) => sentence.split(/\s+/).filter(Boolean).length,
500
+ )
501
+
502
+ const counts = new Map<string, number>()
503
+ for (const sentence of sentences) {
504
+ const word = openingWord(sentence)
505
+ if (word !== '') counts.set(word, (counts.get(word) ?? 0) + 1)
506
+ }
507
+
508
+ let opener = ''
509
+ let repeats = 0
510
+ for (const [word, count] of counts) {
511
+ if (count > repeats) {
512
+ opener = word
513
+ repeats = count
514
+ }
515
+ }
516
+
517
+ // A paragraph closing no sentence at all has no spread rather than a
518
+ // negative one, and the floor drops it before either number is read.
519
+ const spread =
520
+ lengths.length === 0 ? 0 : Math.max(...lengths) - Math.min(...lengths)
521
+
522
+ return {
523
+ line: block[0].number,
524
+ sentences: sentences.length,
525
+ spread,
526
+ repeats,
527
+ opener,
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Reports how a file's prose is distributed across sentence length and opening
533
+ * word, which is the layer the ban sets cannot reach.
534
+ *
535
+ * A banned-word list expresses negatives and every symptom this answers is the
536
+ * absence of something, so no addition to that set catches a paragraph whose
537
+ * sentences are all one length. The measure stops at what is countable. A
538
+ * sentence's grammatical shape and whether it carries a finite verb are the two
539
+ * rules `write-human` states that this does not implement, because identifying
540
+ * either needs a parse rather than a match, and an imperative or a heading
541
+ * fragment would read as a defect. Reporting them wrong is worse than not
542
+ * reporting them, since they name the exact failure this exists to measure.
543
+ *
544
+ * A paragraph under the floor is skipped rather than scored. A two-sentence
545
+ * configuration note has no spread worth reading, and the opener rule is
546
+ * written about a third sentence turning a coincidence into a pattern, so
547
+ * neither measure says anything before the floor is reached.
548
+ */
549
+ export function measureCadence(
550
+ lines: readonly BodyLine[],
551
+ checkpoints: Checkpoints,
552
+ ): CadenceReport {
553
+ const measured = paragraphBlocks(lines)
554
+ .map(measureParagraph)
555
+ .filter((finding) => finding.sentences >= checkpoints.cadence)
556
+
557
+ const flat = measured.filter(
558
+ (finding) => finding.spread <= checkpoints.spread,
559
+ )
560
+ const repeating = measured.filter(
561
+ (finding) => finding.repeats > checkpoints.opener,
562
+ )
563
+
564
+ return {
565
+ measured: measured.length,
566
+ flat: flat.length,
567
+ repeating: repeating.length,
568
+ flattest: worst(flat, (finding) => -finding.spread),
569
+ mostRepeated: worst(repeating, (finding) => finding.repeats),
570
+ }
571
+ }
572
+
573
+ /**
574
+ * The file's worst paragraph on one measure, earliest line breaking a tie.
575
+ *
576
+ * Only paragraphs already past their checkpoint are passed in, so a file
577
+ * reading healthy names nothing rather than naming its least healthy paragraph,
578
+ * which a reader would take for a finding.
579
+ */
580
+ function worst(
581
+ findings: readonly CadenceFinding[],
582
+ rank: (finding: CadenceFinding) => number,
583
+ ): CadenceFinding | undefined {
584
+ return findings.reduce<CadenceFinding | undefined>(
585
+ (held, finding) => (!held || rank(finding) > rank(held) ? finding : held),
586
+ undefined,
587
+ )
588
+ }
589
+
346
590
  export function measureStructure(
347
591
  rel: string,
348
592
  lines: readonly BodyLine[],
@@ -356,5 +600,6 @@ export function measureStructure(
356
600
  longestRunLine: run.line,
357
601
  heavyBullets: heavyBullets(lines, checkpoints),
358
602
  heavyParagraphs: heavyParagraphs(lines, checkpoints),
603
+ cadence: measureCadence(lines, checkpoints),
359
604
  }
360
605
  }
@@ -57,7 +57,7 @@ Add a section only for content that fits none of the three and would otherwise b
57
57
  ## Writing one
58
58
 
59
59
  1. Capture what the session learned first, so the map cites what was written instead of restating the same lesson in prose.
60
- 2. Run `aitk claude skills drift <the commit this session started from>` and record what it names under `## Standing cautions`. A skill body enters a session once and re-invoking the skill replays the held copy rather than the file, so the drift is worst at exactly this moment and a name here is a body the session has been following out of date. This step belongs to session length rather than to any role, so every writer runs it, and a refusal names the boundary of what the verb can read rather than a fault.
60
+ 2. Run `aitk claude skills drift <the commit this session started from>` and record what it names under `## Standing cautions`. A skill body enters a session once and re-invoking the skill does not re-read the file, so the drift is worst at exactly this moment and a name here is a body the session has been following out of date. Recording a name is not acting on it, and the verb misses a body this session edited and has not committed. This step belongs to session length rather than to any role, so every writer runs it, and a refusal names the boundary of what the verb can read rather than a fault.
61
61
  3. Recover that commit from how long the session has been running with `git log -1 --format=%H --before='<duration> ago'`, rounding the duration up rather than down. Nothing on the machine records it. A ref older than the oldest load over-reports, and confirming a name costs one read of the body, so the generous end is the safe one and a guess at the exact commit is not worth making.
62
62
  4. Write only what a compaction destroys and no other artifact already carries. The board holds the ordering and what each task waits on, a task file holds its own findings, and a measurement folder holds its track.
63
63
  5. Cite a commit, a task, or a file and line for every claim, so the next session can tell a read from a recall.