@erclx/aitk 0.44.0 → 0.45.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.
@@ -9,46 +9,47 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
9
9
 
10
10
  ## Project-level
11
11
 
12
- | Command | Purpose |
13
- | ------------------------ | ---------------------------------------------------------------------------------------------- |
14
- | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
15
- | `aitk sync [path]` | Sync all installed domains in a target project |
16
- | `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`) |
17
- | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
- | `aitk sandbox reset` | Reset sandbox to baseline |
19
- | `aitk sandbox clean` | Wipe the sandbox |
20
- | `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
- | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
- | `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
- | `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
- | `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
- | `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck |
26
- | `aitk slides list` | List the available slide layouts (`--json` for the catalog) |
27
- | `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github` |
28
- | `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
29
- | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
30
- | `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
31
- | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
32
- | `aitk context audit` | Report required sections, length, depth, bullet weight, cited paths, provenance, and drift |
33
- | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
12
+ | Command | Purpose |
13
+ | -------------------------- | ---------------------------------------------------------------------------------------------- |
14
+ | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
15
+ | `aitk sync [path]` | Sync all installed domains in a target project |
16
+ | `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`) |
17
+ | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
+ | `aitk sandbox reset` | Reset sandbox to baseline |
19
+ | `aitk sandbox clean` | Wipe the sandbox |
20
+ | `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
+ | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
+ | `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
+ | `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
+ | `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
+ | `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck |
26
+ | `aitk slides list` | List the available slide layouts (`--json` for the catalog) |
27
+ | `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github` |
28
+ | `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
29
+ | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
30
+ | `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
31
+ | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
32
+ | `aitk context audit` | Report required sections, length, depth, bullet weight, cited paths, provenance, and drift |
33
+ | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
34
+ | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
34
35
 
35
36
  ## Domain commands
36
37
 
37
38
  Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
38
39
 
39
- | Domain | Subcommands |
40
- | ----------- | ---------------------------------------------------------------------- |
41
- | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
42
- | `snippets` | `list`, `install`, `sync`, `create` |
43
- | `standards` | `list`, `install`, `sync` |
44
- | `gov` | `list`, `install`, `sync`, `build` |
45
- | `claude` | `init`, `sync`, `seeds list`, `skills list`, `setup [dest]` |
46
- | `wiki` | `init` |
47
- | `design` | `render` |
48
- | `slides` | `render`, `list` |
49
- | `tasks` | `archive`, `validate` |
50
- | `comments` | `scan` |
51
- | `context` | `audit` |
40
+ | Domain | Subcommands |
41
+ | ----------- | --------------------------------------------------------------------------- |
42
+ | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
43
+ | `snippets` | `list`, `install`, `sync`, `create` |
44
+ | `standards` | `list`, `install`, `sync` |
45
+ | `gov` | `list`, `install`, `sync`, `build` |
46
+ | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `setup [dest]` |
47
+ | `wiki` | `init` |
48
+ | `design` | `render` |
49
+ | `slides` | `render`, `list` |
50
+ | `tasks` | `archive`, `validate` |
51
+ | `comments` | `scan` |
52
+ | `context` | `audit` |
52
53
 
53
54
  Common patterns:
54
55
 
@@ -20,4 +20,5 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
20
20
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
21
21
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
22
22
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
23
+ - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, and the requirement gate that is the only failing one
23
24
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, the refusal reasons, the board checks validate runs, and why the board root defaults to the main worktree
@@ -7,7 +7,9 @@ description: Flags, exit codes, and JSON shape for aitk indexes regen, plus when
7
7
 
8
8
  `aitk indexes regen` rewrites `index.md` files from sibling frontmatter. With no positional paths, it walks the current directory. With paths, each resolves by walking up to the nearest indexed ancestor, bounded by `--root`. Duplicates dedupe. The whole-repo walk prunes `.git`, `node_modules`, and anything `.gitignore` covers via `git check-ignore`.
9
9
 
10
- A positional path is not filtered that way, because the walk-up resolves on the filesystem and never consults git. That is the only way to regenerate an index inside a gitignored folder, and it is how `.claude/tasks/` stays current.
10
+ A positional path is not filtered that way, because the walk-up resolves on the filesystem and never consults git. That is the only way to regenerate an index inside a gitignored folder, and it is how `.claude/tasks/` and `.claude/memory/` both stay current.
11
+
12
+ Each is driven by a `PostToolUse` hook matching `Write|Edit|MultiEdit`, so a file moved by a shell `mv` fires nothing. A caller that relocates an entry runs the command itself once the last move is done.
11
13
 
12
14
  | Option | Behavior |
13
15
  | --------------- | ---------------------------------------------------------------- |
@@ -53,9 +53,10 @@ names one per line.
53
53
  Each entry also carries `requirement`, whether the folder holds a sibling
54
54
  `REQUIREMENT.md`. Every skill is meant to carry one, so a `false` is a gap to
55
55
  close rather than a recorded exemption, and the flag answers which skills are
56
- missing theirs without a caller listing the directory itself. Nothing gates the
57
- rule yet, which is why the flag is worth reading against the shipped corpus after
58
- a merge.
56
+ missing theirs without a caller listing the directory itself. `aitk claude skills
57
+ audit` gates that rule across both corpora, so read this flag for the state of
58
+ the shipped catalog and the audit for whether a working tree conforms. Its
59
+ surface is in `skills-audit.md`.
59
60
 
60
61
  ## Non-interactive examples
61
62
 
@@ -0,0 +1,55 @@
1
+ ---
2
+ title: Skill audit
3
+ description: Measuring both skill corpora against standards/skill.md, the checks it reads, and the requirement gate that is the only failing one
4
+ ---
5
+
6
+ # Skill audit
7
+
8
+ `aitk claude skills audit [path]` reports both skill corpora against the rules `standards/skill.md` states mechanically. It reads and reports. Fixing what it finds is separate work.
9
+
10
+ ```bash
11
+ aitk claude skills audit
12
+ aitk claude skills audit --json
13
+ aitk claude skills audit --requirements-only
14
+ ```
15
+
16
+ | Option | Behavior |
17
+ | --------------------- | -------------------------------------------------------------------- |
18
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
19
+ | `--requirements-only` | Run the gating presence check alone, printing nothing when it passes |
20
+
21
+ ## Corpus scope
22
+
23
+ Both trees are measured. `claude/skills/` installs into a target and `.claude/skills/` stays in the toolkit, the standard governs each, so a run reading one reports a pass over half its subject. A tree the project does not carry is skipped, which puts a target holding `.claude/skills/` alone in scope. A run where neither resolves refuses, since a clean exit over nothing measured is the outcome the command exists to prevent.
24
+
25
+ The audit reads the directory it is pointed at, defaulting to the cwd. `aitk claude skills list` resolves the shipped corpus from its own install root instead, so a dev-linked binary reports `main` no matter which worktree runs it. Auditing a branch needs the cwd reading, which is why the two commands resolve their root differently.
26
+
27
+ ## What it measures
28
+
29
+ Each check traces to a line in the standard.
30
+
31
+ - `REQUIREMENT.md` present in every skill folder
32
+ - Frontmatter `name` matching the folder name
33
+ - Frontmatter `description` present
34
+ - `description` under 1024 characters
35
+ - No `README.md` inside a skill folder
36
+ - Folder name in kebab-case
37
+ - Each `REQUIREMENT.md` declaring `Gap` and `Must`, matched at any heading level
38
+
39
+ A body whose frontmatter does not parse reports as declaring neither field rather than ending the run, so one malformed file cannot hide the corpus behind it. A key present with an empty value reads as absent, since a blank `name` would otherwise report as a name disagreeing with every folder. A folder carrying no requirement is reported once under presence rather than counted again for the sections it therefore lacks.
40
+
41
+ ## What it leaves alone
42
+
43
+ The report names its own blind spots on every run, including the run where everything passed. Whether each `Must` traces to a stated gap is the rule in that standard worth the most and no parser reads it. Whether a gap states an observed failure rather than an intent, and whether a description routes, are the same kind of judgment. The 5,000-word body ceiling is mechanical and still absent here.
44
+
45
+ A check with no rule behind it prints an opinion as a defect, which is where the list stops.
46
+
47
+ ## Exit codes
48
+
49
+ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folder carrying no `REQUIREMENT.md`. Only presence sets a failing code. Name, description, folder, and requirement-section findings print and return `0`, because each is a judgment a reader settles and failing a push on one would make the check something to route around.
50
+
51
+ ## The requirement gate
52
+
53
+ `--requirements-only` is the half wired into `bun run check`. Presence of a required file is a fact with no false positives, and the rule had nothing reading it, so a skill shipped without the sibling passed every stage while the standard required it.
54
+
55
+ The check is preventive rather than diagnostic. Every mechanical rule passed across both corpora the day it shipped, so what it buys is the regression it stops rather than a backlog it surfaces.
@@ -84,7 +84,7 @@ The sweep reads the whole board rather than the tasks the session touched. It is
84
84
 
85
85
  `aitk:claude-docs` decides which task closed by reading the diff rather than the conversation. It resolves a merge base against `origin/main`, unions the committed diff with the working tree and untracked files, then matches unchecked outcomes on the board against what shipped. A task that shipped without ever being discussed still gets marked. Requirements, architecture, and design stay session-sourced, because a diff cannot carry a judgment.
86
86
 
87
- `.claude/tasks/` is gitignored and resolves at the main worktree root, so every session shares one board. One file per task is what keeps concurrent sessions from overwriting each other, since a gitignored board has no history to recover a lost write from. Its `index.md` is generated by a hook rather than by `bun run check`, because the whole-repo index walk skips gitignored folders. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
87
+ `.claude/tasks/` is gitignored and resolves at the main worktree root, so every session shares one board. One file per task is what keeps concurrent sessions from overwriting each other, since a gitignored board has no history to recover a lost write from. Its `index.md` is generated by a hook rather than by `bun run check`, because the whole-repo index walk skips gitignored folders. `.claude/memory/` carries the same arrangement, its own hook regenerating `index.md` from each entry's `title`, `description`, and `category`. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
88
88
 
89
89
  `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every row and task file map one to one, no task sits in two groups, and no two rows marked ready touch the same file. That last check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made.
90
90
 
@@ -106,7 +106,8 @@ For features on a mature stack, chain the post-plan pipeline in one session. App
106
106
  - An empty changed-file list stops the chain rather than counting as prose-only. The filename test passes vacuously on an empty set, which routed a branch past review instead of through it.
107
107
  - Every stop leaves recoverable state. Fix and resume with `/git-ship`
108
108
  - Skip autoship for auth, migrations, security-sensitive changes, or work where the plan itself is uncertain
109
- - After the PR is open, both `autoship` and `git-ship` invoke `claude-memory-capture` to write session patterns into `.claude/memory/`. If capture wrote at least one entry, `claude-memory-review` then proposes a decision-ready fix scoped to those captures while context is fresh, otherwise it is skipped. They stop at Propose. Review the receipt and run Apply yourself, on its own commit separate from the feature. Run `claude-memory-review` standalone to curate the whole pen.
109
+ - Both `autoship` and `git-ship` open with `claude-memory-capture`, which sends what the session learned to the surface that owns it. A fact about a domain carrying an entry in `.claude/context/index.md` is routed to that entry, and `claude-docs` folds it in on the next step, so it ships in the same pull request. Anything no entry owns stays a file in `.claude/memory/`. Capture leads rather than trails because a routed fact edits a tracked file, which has to reach the branch before the commit steps run.
110
+ - If capture wrote at least one memory file, `claude-memory-review` then proposes a decision-ready fix scoped to those entries while context is fresh, otherwise it is skipped. It stops at Propose. Review the receipt and run Apply yourself, on its own commit separate from the feature. Run `claude-memory-review` standalone to curate the whole pen. An entry it retires moves to `.claude/.tmp/memory-archive/` rather than being deleted, since the folder is gitignored and a bulk pass has no undo.
110
111
 
111
112
  ### UI polish
112
113
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.44.0",
4
+ "version": "0.45.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -183,6 +183,14 @@ main() {
183
183
  run_check "cd $PROJECT_ROOT && bun src/cli.ts context audit --citations-only" "A cited context path does not resolve. Run bun src/cli.ts context audit."
184
184
  log_info "Context citations resolve"
185
185
 
186
+ # Presence of a required file is a fact, so it gates. The name, description,
187
+ # folder, and requirement-section measures beside it report and are read from a
188
+ # bare run. `bun src/cli.ts` for the reason the stage above uses it, and the
189
+ # command reads the cwd, so this measures the worktree being pushed.
190
+ log_step "Skill requirements"
191
+ run_check "cd $PROJECT_ROOT && bun src/cli.ts claude skills audit --requirements-only" "A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit."
192
+ log_info "Skill requirements present"
193
+
186
194
  log_step "Plugin manifests"
187
195
  if ! command -v claude >/dev/null 2>&1; then
188
196
  log_info "Skipped, claude is not installed"
@@ -11,6 +11,7 @@ const SUBDIRS: readonly string[] = [
11
11
  HOOKS,
12
12
  'context',
13
13
  'diagrams',
14
+ 'memory',
14
15
  'tasks',
15
16
  'wireframes',
16
17
  ]
@@ -0,0 +1,215 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import {
4
+ type Frontmatter,
5
+ parseFrontmatter,
6
+ readField,
7
+ } from '@/indexes/frontmatter'
8
+
9
+ /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
10
+ export const EXIT_MISSING_REQUIREMENT = 2
11
+
12
+ /** The description ceiling stated in the frontmatter rules of `standards/skill.md`. */
13
+ export const DESCRIPTION_LIMIT = 1024
14
+
15
+ /** The two headings the requirement template declares, matched at any level. */
16
+ export const REQUIREMENT_SECTIONS: readonly string[] = ['Gap', 'Must']
17
+
18
+ /**
19
+ * Both trees the standard governs. `claude/skills/` ships to a target and
20
+ * `.claude/skills/` stays here, and every rule measured below applies to each,
21
+ * so a corpus reading one of them reports a pass over half the subject.
22
+ */
23
+ export const CORPORA: readonly string[] = [
24
+ join('claude', 'skills'),
25
+ join('.claude', 'skills'),
26
+ ]
27
+
28
+ /** Kebab-case, which the standard states as no spaces, capitals, or underscores. */
29
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
30
+
31
+ const HEADING = /^#{1,6}\s+(.+?)\s*$/
32
+
33
+ export interface SkillFinding {
34
+ readonly rel: string
35
+ readonly detail: string
36
+ }
37
+
38
+ export interface CorpusReport {
39
+ readonly rel: string
40
+ readonly skills: number
41
+ }
42
+
43
+ export interface SkillsAudit {
44
+ readonly corpora: readonly CorpusReport[]
45
+ readonly skills: number
46
+ readonly missingRequirement: readonly string[]
47
+ readonly readme: readonly string[]
48
+ readonly folderName: readonly string[]
49
+ readonly missingDescription: readonly string[]
50
+ readonly nameMismatch: readonly SkillFinding[]
51
+ readonly longDescription: readonly SkillFinding[]
52
+ readonly requirementSections: readonly SkillFinding[]
53
+ }
54
+
55
+ interface SkillSource {
56
+ readonly rel: string
57
+ readonly folder: string
58
+ readonly hasReadme: boolean
59
+ readonly name: string | undefined
60
+ readonly description: string | undefined
61
+ /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
62
+ readonly requirementHeadings: readonly string[] | undefined
63
+ }
64
+
65
+ /**
66
+ * Measures both skill corpora against the rules `standards/skill.md` states
67
+ * mechanically. A corpus the project does not carry is skipped rather than
68
+ * reported, so a target holding only `.claude/skills/` reads as in scope.
69
+ *
70
+ * Reads raw frontmatter rather than `listSkills`, which prefers the folder name
71
+ * over the declared one and so can never surface a disagreement between them.
72
+ */
73
+ export async function auditSkills(root: string): Promise<SkillsAudit> {
74
+ const present = CORPORA.map((rel) => ({ rel, dir: join(root, rel) })).filter(
75
+ (corpus) => existsSync(corpus.dir),
76
+ )
77
+
78
+ const perCorpus = await Promise.all(
79
+ present.map((corpus) => readCorpus(corpus.rel, corpus.dir)),
80
+ )
81
+ const sources = perCorpus.flat()
82
+
83
+ return {
84
+ corpora: present.map((corpus, index) => ({
85
+ rel: corpus.rel,
86
+ skills: perCorpus[index]?.length ?? 0,
87
+ })),
88
+ skills: sources.length,
89
+ missingRequirement: sources
90
+ .filter((source) => source.requirementHeadings === undefined)
91
+ .map((source) => source.rel),
92
+ readme: sources
93
+ .filter((source) => source.hasReadme)
94
+ .map((source) => source.rel),
95
+ folderName: sources
96
+ .filter((source) => !KEBAB_CASE.test(source.folder))
97
+ .map((source) => source.rel),
98
+ missingDescription: sources
99
+ .filter((source) => source.description === undefined)
100
+ .map((source) => source.rel),
101
+ nameMismatch: sources.flatMap(nameFindings),
102
+ longDescription: sources.flatMap(lengthFindings),
103
+ requirementSections: sources.flatMap(sectionFindings),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Only a required file that is absent sets a failing code, which is a fact.
109
+ * Every other measure is a judgment a reader settles, and failing a push on one
110
+ * teaches contributors to route around the stage.
111
+ */
112
+ export function auditExitCode(report: SkillsAudit): number {
113
+ return report.missingRequirement.length > 0 ? EXIT_MISSING_REQUIREMENT : 0
114
+ }
115
+
116
+ async function readCorpus(rel: string, dir: string): Promise<SkillSource[]> {
117
+ const paths = [
118
+ ...new Bun.Glob('*/SKILL.md').scanSync({ cwd: dir, onlyFiles: true }),
119
+ ].sort()
120
+
121
+ return Promise.all(paths.map((path) => readSkill(rel, dir, dirname(path))))
122
+ }
123
+
124
+ async function readSkill(
125
+ rel: string,
126
+ dir: string,
127
+ folder: string,
128
+ ): Promise<SkillSource> {
129
+ const skillDir = join(dir, folder)
130
+ const requirementPath = join(skillDir, 'REQUIREMENT.md')
131
+
132
+ const [body, requirement] = await Promise.all([
133
+ Bun.file(join(skillDir, 'SKILL.md')).text(),
134
+ existsSync(requirementPath)
135
+ ? Bun.file(requirementPath).text()
136
+ : Promise.resolve(undefined),
137
+ ])
138
+
139
+ const fields = readFields(body)
140
+
141
+ return {
142
+ rel: join(rel, folder),
143
+ folder,
144
+ hasReadme: existsSync(join(skillDir, 'README.md')),
145
+ name: declared(readField(fields, 'name')),
146
+ description: declared(readField(fields, 'description')),
147
+ requirementHeadings:
148
+ requirement === undefined ? undefined : headings(requirement),
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Returns undefined rather than throwing on unparseable YAML, so one malformed
154
+ * body reports as missing its required fields instead of ending the audit
155
+ * before it reaches the rest of the corpus.
156
+ */
157
+ function readFields(body: string): Frontmatter | undefined {
158
+ try {
159
+ return parseFrontmatter(body)
160
+ } catch {
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A key carrying an empty value declares nothing, so blank reads as absent
167
+ * rather than as a name that disagrees with every folder.
168
+ */
169
+ function declared(value: string | undefined): string | undefined {
170
+ const trimmed = value?.trim()
171
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
172
+ }
173
+
174
+ function headings(source: string): string[] {
175
+ return source
176
+ .split('\n')
177
+ .map((line) => HEADING.exec(line)?.[1])
178
+ .filter((text): text is string => text !== undefined)
179
+ }
180
+
181
+ function nameFindings(source: SkillSource): SkillFinding[] {
182
+ if (source.name === undefined) {
183
+ return [{ rel: source.rel, detail: 'frontmatter declares no name' }]
184
+ }
185
+ if (source.name === source.folder) return []
186
+ return [{ rel: source.rel, detail: `frontmatter name: ${source.name}` }]
187
+ }
188
+
189
+ function lengthFindings(source: SkillSource): SkillFinding[] {
190
+ const { description } = source
191
+ if (description === undefined) return []
192
+ if (description.length <= DESCRIPTION_LIMIT) return []
193
+ return [{ rel: source.rel, detail: `${description.length} characters` }]
194
+ }
195
+
196
+ /**
197
+ * Stays silent on a folder carrying no requirement at all, which the presence
198
+ * check already reports. Counting the same skill twice reads as two defects.
199
+ */
200
+ function sectionFindings(source: SkillSource): SkillFinding[] {
201
+ const { requirementHeadings } = source
202
+ if (requirementHeadings === undefined) return []
203
+
204
+ const missing = REQUIREMENT_SECTIONS.filter(
205
+ (section) => !requirementHeadings.includes(section),
206
+ )
207
+ if (missing.length === 0) return []
208
+
209
+ return [
210
+ {
211
+ rel: join(source.rel, 'REQUIREMENT.md'),
212
+ detail: `missing: ${missing.join(', ')}`,
213
+ },
214
+ ]
215
+ }
@@ -17,9 +17,9 @@ export interface SkillListing {
17
17
  * The folder name wins over the frontmatter `name` when they disagree, because
18
18
  * Claude Code invokes a skill by its directory.
19
19
  *
20
- * `requirement` reports whether the folder carries `REQUIREMENT.md`. Coverage is
21
- * selective by design, so a false is not a gap to close and this field carries no
22
- * reason for one. The toolkit records those separately from the catalog.
20
+ * `requirement` reports whether the folder carries `REQUIREMENT.md`. Every skill
21
+ * is meant to carry one, so a false is a gap rather than a recorded exemption.
22
+ * `aitk claude skills audit` is what fails on it, across both corpora.
23
23
  */
24
24
  export function listSkills(root: string): SkillListing[] {
25
25
  const skillsRoot = join(root, 'claude', 'skills')