@erclx/aitk 0.35.2 → 0.37.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.35.2",
4
+ "version": "0.37.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
package/docs/agents.md CHANGED
@@ -80,7 +80,7 @@ Full help: `aitk <command> --help`.
80
80
  | `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
81
81
  | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
82
82
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
83
- | `aitk context audit` | Report entry length, depth, bullet weight, cited-path resolution, provenance, and index drift |
83
+ | `aitk context audit` | Report required sections, length, depth, bullet weight, cited paths, provenance, and drift |
84
84
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
85
85
 
86
86
  ### Domain commands
@@ -387,10 +387,16 @@ aitk context audit --folder context,diagrams
387
387
 
388
388
  Scope defaults to `context`, `diagrams`, and `wireframes`, and a folder the project does not carry is skipped rather than reported. A domain that outgrew one file and split into `<domain>/` is audited as its own folder, so a split entry measures at the same grain as a flat one.
389
389
 
390
- Exit codes are `0` for a clean run, `1` for a refusal, and `2` for an unresolved citation. Only the citation check sets a failing code. Length, depth, bullet weight, table, provenance, and index findings print and return `0`, because each is a judgment and failing a push on one would make the check something to route around.
390
+ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for an unresolved citation. Only the citation check sets a failing code. Required-section, length, depth, bullet weight, table, provenance, and index findings print and return `0`, because each is a judgment and failing a push on one would make the check something to route around.
391
391
 
392
392
  ### What each check reports
393
393
 
394
+ The required-section check reports what does not declare `## Overview` and `## Layout`, the two sections `.claude/standards/context.md` marks required. The list is held in code beside the numeric checkpoints rather than parsed out of the standard, so it fails on a defect rather than on a rewrite of the wording around it. A heading at any level counts, because a domain that split into a folder carries its overview in a sibling named `overview.md` where the section is the `#` title and an `##` beneath it would repeat the filename. Matching exactly is what keeps `## Layout catalog` from satisfying `Layout`.
395
+
396
+ Which unit answers depends on the folder. Entries of the folder named under `.claude/` are one domain each, so each answers for itself and a finding names the entry. Entries of a folder a domain split into describe that one domain between them, so any sibling answers and a finding names the folder. Holding a split folder to the rule per file would report every child beside its `overview.md`, and rolling the named folder up would let one conforming entry stand in for every other domain sitting next to it.
397
+
398
+ It reports rather than gates, the closer call because a missing section reads more like a fact than the other judgments. What settles it is that the standard sanctions omitting `## Layout` from a domain owning no paths in the repo, and no measure separates that from an entry that forgot it. A domain covering only external tools is that case, and it reports on every run. The JSON record carries the findings as `missingSections` and the list as `checkpoints.requiredSections`.
399
+
394
400
  Length and depth quote their checkpoints from `.claude/standards/context.md`: roughly 150 rendered lines for an entry, roughly 40 for a run no heading breaks. Depth measures the longest such run rather than everything under one `##`, skips fenced blocks so a markdown example does not read as three headings, and exempts a run whose lines are all list items at one indent averaging under 130 characters. The weight condition is what separates a scannable catalog of one-liners from a stack of paragraph-bullets, which reach the same count and read nothing alike.
395
401
 
396
402
  Both checks count rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. Measuring one checkpoint in each unit would put an entry length beside a run length that mean different things. Their exclusions still differ: the file measure counts fenced blocks and frontmatter, while the run measure skips a fence so an example cannot break the run around it. A reference-heavy entry therefore ranks by its examples, which the length legend states on every run. Runs count blank lines, which the standard leaves open, so a hand reader who drops them lands a line or two lower. Both sections state the width on every run, since a number in rendered lines cannot be reproduced without it.
@@ -401,7 +407,7 @@ The table check reports a catalog that grows a row per shipped thing, not a tabl
401
407
 
402
408
  The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open. Fenced blocks are excluded, which keeps a pinned version in an install command from reading as a claim the entry makes. Frontmatter is excluded with them, since the content checks read the body alone, and that is what keeps a diagram entry's dated `verified` stamp a record of its last check rather than a marker to settle. Length is the exception, counting the whole file, so a reader applying the 150-rendered-line checkpoint against the body alone lands a few lines under what the tool reports.
403
409
 
404
- The provenance and bullet-weight checks cover `.claude/context/` alone, while length, depth, and the table finding reach every audited folder. The rule is stated in `.claude/standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere. The split is between kinds of rule rather than kinds of folder, and what decides it is whether the remedy is actionable rather than what the check measures. Subdividing a run and splitting a file mean something in any entry, so length and depth generalize. Moving an incident out of a bullet and keeping the decision means nothing in a folder whose entries declare no decisions, which is why bullet weight narrows despite measuring a distance like the two that do not. The scoping key is the folder an entry was audited under, so `--folder` still reaches a folder the default list does not carry, and a domain split into `context/<sub-area>/` is governed as `context`. Every run states the reach, including a run where no audited folder is the governed one. The JSON record carries it as `checkpoints.provenanceFolder` and a per-folder `governsContent`.
410
+ The provenance, bullet-weight, and required-section checks cover `.claude/context/` alone, while length, depth, and the table finding reach every audited folder. The rule is stated in `.claude/standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere. The split is between kinds of rule rather than kinds of folder, and what decides it is whether the remedy is actionable rather than what the check measures. Subdividing a run and splitting a file mean something in any entry, so length and depth generalize. Moving an incident out of a bullet and keeping the decision means nothing in a folder whose entries declare no decisions, which is why bullet weight narrows despite measuring a distance like the two that do not. Required sections narrow for a plainer reason: the names are the context standard's own, and neither sibling standard states a required section at all. The scoping key is the folder an entry was audited under, so `--folder` still reaches a folder the default list does not carry, and a domain split into `context/<sub-area>/` is governed as `context`. Every run states the reach, including a run where no audited folder is the governed one. The JSON record carries it as `checkpoints.provenanceFolder` and a per-folder `governsContent`.
405
411
 
406
412
  Index drift compares an index against its siblings in both directions. An entry the index does not link is invisible to a session choosing what to open, and a linked name resolving to nothing sends one to a path that opens nothing.
407
413
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.35.2",
4
+ "version": "0.37.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -6,10 +6,13 @@ import {
6
6
  governsContent,
7
7
  LENGTH_CHECKPOINT,
8
8
  measureFolders,
9
+ missingSections,
9
10
  PEER_BULLET_CHECKPOINT,
10
11
  PROVENANCE_FOLDER,
11
12
  RENDER_WIDTH,
13
+ REQUIRED_SECTIONS,
12
14
  RUN_CHECKPOINT,
15
+ type SectionFinding,
13
16
  } from '@/context/audit'
14
17
  import { auditCitations, type CitationReport } from '@/context/citations'
15
18
  import {
@@ -51,7 +54,7 @@ export function register(program: Command): void {
51
54
  context
52
55
  .command('audit')
53
56
  .description(
54
- 'Report entry length, depth, bullet weight, citations, provenance, and index drift',
57
+ 'Report required sections, entry length, depth, bullet weight, citations, provenance, and index drift',
55
58
  )
56
59
  .argument('[path]', 'Project root, defaulting to the current directory')
57
60
  .helpOption('-h, --help', 'Show this help message')
@@ -67,8 +70,8 @@ export function register(program: Command): void {
67
70
  ' 1 refused, with the reason on stderr',
68
71
  ' 2 a cited path did not resolve',
69
72
  '',
70
- 'Only unresolved citations set a failing exit code. Length, depth,',
71
- 'bullet, table, provenance, and index findings are advisory.',
73
+ 'Only unresolved citations set a failing exit code. Section, length,',
74
+ 'depth, bullet, table, provenance, and index findings are advisory.',
72
75
  '',
73
76
  'Examples:',
74
77
  ' aitk context audit',
@@ -131,6 +134,7 @@ async function runAudit(
131
134
 
132
135
  const entries = gateOnly ? [] : await measureFolders(root, folders)
133
136
  const drift = gateOnly ? [] : await auditIndexes(folders)
137
+ const sections = gateOnly ? [] : missingSections(root, folders, entries)
134
138
 
135
139
  if (gateOnly) {
136
140
  reportGate(citations)
@@ -138,6 +142,7 @@ async function runAudit(
138
142
  intro('aitk context audit')
139
143
  reportScope(folders)
140
144
  reportCitations(citations)
145
+ reportSections(sections, folders)
141
146
  reportLength(entries)
142
147
  reportDepth(entries)
143
148
  reportBullets(entries, folders)
@@ -162,6 +167,7 @@ async function runAudit(
162
167
  unresolved: citations.unresolved,
163
168
  },
164
169
  entries,
170
+ missingSections: sections,
165
171
  indexDrift: drift,
166
172
  checkpoints: {
167
173
  lines: LENGTH_CHECKPOINT,
@@ -171,6 +177,7 @@ async function runAudit(
171
177
  peerBullet: PEER_BULLET_CHECKPOINT,
172
178
  bullet: BULLET_CHECKPOINT,
173
179
  provenanceFolder: PROVENANCE_FOLDER,
180
+ requiredSections: REQUIRED_SECTIONS,
174
181
  },
175
182
  })}\n`,
176
183
  )
@@ -250,6 +257,49 @@ function reportCitations(report: ScannedCitations): void {
250
257
  )
251
258
  }
252
259
 
260
+ /**
261
+ * Names the path each finding belongs to, which is an entry in the folder named
262
+ * under `.claude/` and the folder itself in a domain split across one. States
263
+ * the reach on every run for the reason the provenance report does.
264
+ *
265
+ * This prints ahead of the four readability measures because a missing section
266
+ * asks whether the entry is the right shape at all, which precedes asking
267
+ * whether it has grown too long.
268
+ */
269
+ function reportSections(
270
+ missing: readonly SectionFinding[],
271
+ folders: readonly AuditedFolder[],
272
+ ): void {
273
+ logStep('Sections')
274
+
275
+ const governed = folders.filter(governsContent)
276
+ if (governed.length === 0) {
277
+ logInfo(
278
+ `Out of scope. The list is stated in the standard governing .claude/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
279
+ )
280
+ return
281
+ }
282
+
283
+ logInfo(
284
+ `Covers .claude/${PROVENANCE_FOLDER}/ alone, whose standard requires ${REQUIRED_SECTIONS.join(' and ')}.`,
285
+ )
286
+ logInfo(
287
+ 'A heading at any level counts. Each entry answers for itself, except in a domain split across a folder, where a sibling answers for the rest.',
288
+ )
289
+
290
+ if (missing.length === 0) {
291
+ logInfo('Every entry declares each required section.')
292
+ return
293
+ }
294
+
295
+ logWarn(`${plural(missing.length, 'path')} short a required section`)
296
+ pipeOutput(
297
+ missing
298
+ .map((found) => `${found.rel} missing: ${found.missing.join(', ')}`)
299
+ .join('\n'),
300
+ )
301
+ }
302
+
253
303
  function reportLength(entries: readonly EntryReport[]): void {
254
304
  logStep('Length')
255
305
  logInfo(
@@ -50,9 +50,25 @@ export const CATALOG_ROW_CHECKPOINT = 6
50
50
  /** Share of first cells that must name an artifact for a table to qualify. */
51
51
  const CATALOG_NAMED_RATIO = 0.6
52
52
 
53
+ /**
54
+ * Sections `standards/context.md` marks required, in the order it states them.
55
+ *
56
+ * The list is held here rather than read out of the standard, the way the four
57
+ * checkpoints above quote their numbers. A parser over the standard's prose
58
+ * would decide which sections are required from the wording around them, so it
59
+ * fails on a rewrite of that wording rather than on a defect in an entry.
60
+ *
61
+ * These names do not generalize the way a length threshold does, which is why
62
+ * the measure is scoped to the folder `governsContent` names. A diagram entry
63
+ * declares a heading per kind and a wireframe entry per screen, and neither
64
+ * sibling standard states a required section at all.
65
+ */
66
+ export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
67
+
53
68
  const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
54
69
  const FENCE = /^\s*(```|~~~)/
55
70
  const HEADING = /^#{1,6}\s/
71
+ const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
56
72
  const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
57
73
  const TABLE_ROW = /^\s*\|/
58
74
  const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
@@ -144,6 +160,23 @@ export interface EntryReport {
144
160
  readonly provenance: readonly ProvenanceFinding[]
145
161
  /** Empty for the same reason `provenance` is, and under the same folder. */
146
162
  readonly heavyBullets: readonly BulletFinding[]
163
+ /**
164
+ * Required sections this entry declares, in the standard's order, and empty
165
+ * outside the folder whose standard names them. What the folder is short of
166
+ * is `missingSections`, since one entry answers for its siblings.
167
+ */
168
+ readonly sections: readonly string[]
169
+ }
170
+
171
+ export interface SectionFinding {
172
+ /**
173
+ * Repo-relative path of whatever owes the sections: the entry itself in the
174
+ * folder named under `.claude/`, and the folder in a domain split across
175
+ * one, since the split folder's entries answer for each other.
176
+ */
177
+ readonly rel: string
178
+ /** Required sections the path above does not declare, never empty. */
179
+ readonly missing: readonly string[]
147
180
  }
148
181
 
149
182
  interface BodyLine {
@@ -422,6 +455,38 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
422
455
  .map((each) => each.finding)
423
456
  }
424
457
 
458
+ /**
459
+ * Finds which required sections the entry declares.
460
+ *
461
+ * A heading at any level counts rather than the `##` the standard writes its
462
+ * examples at. A domain that split into a folder puts its overview in a sibling
463
+ * named for it, where the section is the `#` title and an `##` beneath it would
464
+ * repeat the file's own name. All three split folders in this repository are
465
+ * that shape, so matching `##` alone would report every one of them. Nothing is
466
+ * titled for a required section without being about it, so the looser match
467
+ * costs no precision.
468
+ *
469
+ * Fenced blocks are skipped for the reason the scans above skip them. A
470
+ * standard quoted inside an example declares nothing about the entry quoting it.
471
+ */
472
+ function declaredSections(lines: readonly BodyLine[]): string[] {
473
+ const found = new Set<string>()
474
+ let fenced = false
475
+
476
+ for (const line of lines) {
477
+ if (FENCE.test(line.text)) {
478
+ fenced = !fenced
479
+ continue
480
+ }
481
+ if (fenced) continue
482
+
483
+ const match = line.text.match(HEADING_TEXT)
484
+ if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
485
+ }
486
+
487
+ return REQUIRED_SECTIONS.filter((section) => found.has(section))
488
+ }
489
+
425
490
  /**
426
491
  * Measures one entry, scanning for provenance only when a standard claims it.
427
492
  *
@@ -448,6 +513,7 @@ export function measureEntry(
448
513
  catalogTables: catalogTables(lines),
449
514
  provenance: governsContent ? provenance(lines) : [],
450
515
  heavyBullets: governsContent ? heavyBullets(lines) : [],
516
+ sections: governsContent ? declaredSections(lines) : [],
451
517
  }
452
518
  }
453
519
 
@@ -484,3 +550,56 @@ export async function measureFolders(
484
550
  export function governsContent(folder: AuditedFolder): boolean {
485
551
  return folder.name === PROVENANCE_FOLDER
486
552
  }
553
+
554
+ /**
555
+ * Names what does not declare the sections the standard requires.
556
+ *
557
+ * Which unit answers depends on what the folder is. A split folder's entries
558
+ * describe one domain between them and carry the overview and the layout in a
559
+ * sibling named for them, so any one of them answers and a per-file rule there
560
+ * would report every other child of all three shipped splits. The entries of
561
+ * the folder named under `.claude/` are one domain each, so each answers for
562
+ * itself. Rolling those up too was the first shape of this check, and it let a
563
+ * single sibling stand in for thirteen domains it says nothing about.
564
+ *
565
+ * The judgment sits in the caller because the split case needs the folder's
566
+ * other entries, which `measureFolders` holds and `measureEntry` does not. A
567
+ * folder with no entries of its own is a split parent holding an index and
568
+ * subfolders, and it has nothing to require a section of.
569
+ *
570
+ * The standard sanctions omitting `## Layout` from a domain owning no paths,
571
+ * which no measure can tell from an entry that forgot it. An entry of that
572
+ * shape therefore reports, which is a reason this is printed and never gated on.
573
+ */
574
+ export function missingSections(
575
+ root: string,
576
+ folders: readonly AuditedFolder[],
577
+ entries: readonly EntryReport[],
578
+ ): SectionFinding[] {
579
+ const byRel = new Map(entries.map((entry) => [entry.rel, entry]))
580
+ const findings: SectionFinding[] = []
581
+
582
+ const shortOf = (declared: readonly string[]): string[] =>
583
+ REQUIRED_SECTIONS.filter((name) => !declared.includes(name))
584
+
585
+ for (const folder of folders) {
586
+ if (!governsContent(folder) || folder.entries.length === 0) continue
587
+
588
+ const reports = folder.entries
589
+ .map((path) => byRel.get(relative(root, path)))
590
+ .filter((entry) => entry !== undefined)
591
+
592
+ if (folder.nested) {
593
+ const missing = shortOf(reports.flatMap((entry) => entry.sections))
594
+ if (missing.length > 0) findings.push({ rel: folder.rel, missing })
595
+ continue
596
+ }
597
+
598
+ for (const entry of reports) {
599
+ const missing = shortOf(entry.sections)
600
+ if (missing.length > 0) findings.push({ rel: entry.rel, missing })
601
+ }
602
+ }
603
+
604
+ return findings
605
+ }
@@ -30,6 +30,16 @@ export interface AuditedFolder {
30
30
  readonly indexPath: string
31
31
  /** Absolute paths of the folder's own entries, excluding its `index.md`. */
32
32
  readonly entries: readonly string[]
33
+ /**
34
+ * Whether this is a sub-area a domain split into rather than the folder
35
+ * named under `.claude/`.
36
+ *
37
+ * The entries of a split folder describe one domain between them, so a rule
38
+ * about what a domain declares is answered by the folder. The entries of the
39
+ * named folder are one domain each, and a rule answered by a sibling there
40
+ * would let one entry stand in for domains it says nothing about.
41
+ */
42
+ readonly nested: boolean
33
43
  }
34
44
 
35
45
  /**
@@ -91,6 +101,7 @@ export async function resolveFolders(
91
101
  rel: relative(root, each),
92
102
  indexPath: `${each}/${INDEX_FILE}`,
93
103
  entries: await readEntries(each),
104
+ nested: each !== dir,
94
105
  })
95
106
  }
96
107
  }
@@ -5,7 +5,13 @@ description: GitHub Actions workflow triggers and checks
5
5
 
6
6
  # CI
7
7
 
8
- GitHub Actions workflow for this project.
8
+ ## Overview
9
+
10
+ Owns the GitHub Actions workflow that gates a merge: which events start a run, and which checks have to pass before the branch can land. The checks call package scripts rather than defining commands of their own, so what each one runs is the development entry's subject.
11
+
12
+ ## Layout
13
+
14
+ - `.github/workflows/` owns the workflow definitions a trigger below starts
9
15
 
10
16
  ## Triggers
11
17
 
@@ -5,7 +5,14 @@ description: Local dev workflow, scripts, and husky hooks
5
5
 
6
6
  # Development
7
7
 
8
- Local dev workflow for this project.
8
+ ## Overview
9
+
10
+ Owns how the project runs on a developer machine: installing the toolchain, the scripts that verify a change, and the git hooks that run them before a commit or a push leaves. CI calls the same scripts from a workflow, which is the CI entry's subject.
11
+
12
+ ## Layout
13
+
14
+ - `scripts/` owns the shell scripts the package scripts below call
15
+ - `.husky/` owns the git hooks
9
16
 
10
17
  ## Setup
11
18
 
@@ -0,0 +1,11 @@
1
+ ---
2
+ title: Context
3
+ subtitle: Per-domain narrative loaded on demand
4
+ ---
5
+
6
+ # Context
7
+
8
+ Per-domain narrative loaded on demand
9
+
10
+ - [CI](ci.md): GitHub Actions workflow triggers and checks
11
+ - [Development](development.md): Local dev workflow, scripts, and husky hooks
@@ -1,8 +0,0 @@
1
- ---
2
- title: Context
3
- subtitle: Per-domain narrative loaded on demand
4
- ---
5
-
6
- # Context
7
-
8
- Per-domain narrative loaded on demand