@erclx/aitk 0.22.0 → 0.23.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.22.0",
4
+ "version": "0.23.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-standards-audit
3
- description: Audits changed markdown files against applicable authoring standards (prose, skill, readme, branch, pr) and reports violations without fixing. Maps each file to its standards, greps for banned tokens, and groups findings by file. Use when asked to "audit prose", "audit standards", "check standards", "standards audit", or after editing markdown where standards compliance matters. Do NOT fix violations. Reporting only.
3
+ description: Audits changed markdown files against applicable authoring standards (prose, skill, readme) and reports violations without fixing. Maps each file to its standards, greps for banned tokens, and groups findings by file. Use when asked to "audit prose", "audit standards", "check standards", "standards audit", or after editing markdown where standards compliance matters. Do NOT fix violations. Reporting only.
4
4
  ---
5
5
 
6
6
  # Claude standards audit
@@ -44,11 +44,11 @@ For each changed markdown file, pick the applicable standards:
44
44
  - Any markdown with prose: `.claude/standards/prose.md`
45
45
  - `SKILL.md` under `.claude/skills/` or `claude/skills/`: also `.claude/standards/skill.md`
46
46
  - `README.md` at any level: also `.claude/standards/readme.md`
47
- - Branch names proposed in the session: `${CLAUDE_SKILL_DIR}/references/branch.md`
48
- - PR titles or bodies drafted in the session: `${CLAUDE_SKILL_DIR}/references/pr.md`
49
47
 
50
48
  Read a standard from `${CLAUDE_SKILL_DIR}/../../standards/` instead when the project does not have it.
51
49
 
50
+ Every mapping names a changed markdown file, which is the only thing Step 1 produces. Text that never lands in the tree, such as a branch name or a pull request body, is checked by the skill that publishes it rather than here.
51
+
52
52
  ## Step 3: read standards and audit
53
53
 
54
54
  Read each applicable standard once. For each changed file, audit against every rule:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr, claude-standards-audit
4
+ consumers: git-branch, git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr, claude-standards-audit
4
+ consumers: git-branch, git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Pull request reference
3
3
  description: Pull request title and body conventions
4
- consumers: git-split, git-pr, claude-standards-audit
4
+ consumers: git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Pull request reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr, claude-standards-audit
4
+ consumers: git-branch, git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Pull request reference
3
3
  description: Pull request title and body conventions
4
- consumers: git-split, git-pr, claude-standards-audit
4
+ consumers: git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Pull request reference
package/docs/agents.md CHANGED
@@ -66,11 +66,11 @@ Full help: `aitk <command> --help`.
66
66
  | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
67
67
  | `aitk sync [path]` | Sync all installed domains in a target project |
68
68
  | `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`) |
69
- | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed) |
69
+ | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
70
70
  | `aitk sandbox reset` | Reset sandbox to baseline |
71
71
  | `aitk sandbox clean` | Wipe the sandbox |
72
72
  | `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
73
- | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`) |
73
+ | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
74
74
  | `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
75
75
  | `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
76
76
  | `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
@@ -189,7 +189,7 @@ inside an already-open frame.
189
189
 
190
190
  ### Sandbox scenarios
191
191
 
192
- Scenarios live under `scripts/sandbox/`, one folder per category. `scripts/sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. Route non-interactively with `SANDBOX_SCENARIO`:
192
+ Scenarios live under `scripts/sandbox/`, one folder per category. `scripts/sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. `files` in `package.json` excludes that tree, so an installed `aitk` carries the command, reports it as toolkit-only on one line, and exits 1 rather than failing on the missing directory. Route non-interactively with `SANDBOX_SCENARIO`:
193
193
 
194
194
  ```bash
195
195
  SANDBOX_SCENARIO=sync aitk sandbox infra:tooling
@@ -222,19 +222,24 @@ Exit 0 means `pass` or `unchecked`. Exit 1 means `fail`, or a caller error: a ma
222
222
 
223
223
  ### Scenario coverage
224
224
 
225
- `aitk sandbox coverage` reports which scenarios declare expectations and which only provision a state. It reads the fixture tree, so it needs no provisioned sandbox and runs nothing.
225
+ `aitk sandbox coverage` reports which scenarios declare expectations and which only provision a state. It reads the fixture tree, so it needs no provisioned sandbox and runs nothing. Where that tree does not ship it exits 1 and prints no percentage, since a denominator nobody looked at is not a coverage result. A tree that is present and holds no scenarios is a real zero and still reports one.
226
226
 
227
227
  ```bash
228
228
  aitk sandbox coverage --json
229
229
  ```
230
230
 
231
- | Flag | Effect |
232
- | ---------- | ------------------------------------------------- |
233
- | `--json` | Emit the coverage record on stdout |
234
- | `--strict` | Exit 1 while any scenario declares no expectation |
231
+ | Flag | Effect |
232
+ | ---------- | ----------------------------------------------------------- |
233
+ | `--json` | Emit the coverage record on stdout |
234
+ | `--strict` | Exit 1 while any scenario declares no expectation |
235
+ | `--skills` | Add a per-skill asserted, should-be-asserted, exempt census |
235
236
 
236
237
  The record carries every scenario with the arms that declare, plus `totalScenarios`, `armedScenarios`, and `armedArms`. Scenarios and arms count separately, since several arms can share one scenario and dividing one by the other overstates the rollout.
237
238
 
239
+ `--skills` answers what the scenario count cannot, which is whether anything can fail a given skill. It adds `skills`, `totalSkills`, `asserted`, `shouldBeAsserted`, `exempt`, `staleExemptions`, and `supersededExemptions` to the record, and keeps the scenario view rather than replacing it. The two denominators disagree on purpose: an armed scenario under `infra/` or `tooling/` exercises a CLI domain and pairs with no skill at all.
240
+
241
+ A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/setup-init.sh` reaches the `setup-init` skill. `should-be-asserted` is the default and is the work queue for writing arms. `exempt` means no arm should be written and holds only with a reason, declared in `scripts/sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
242
+
238
243
  `scripts/sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.claude/.tmp/sandbox-runs/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit. Stdout carries the same bytes it did before the record existed.
239
244
 
240
245
  ## Docs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.22.0",
4
+ "version": "0.23.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -3,6 +3,11 @@ import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import type { Command } from 'commander'
5
5
  import { PROJECT_ROOT, execScript } from '@/exec'
6
+ import {
7
+ assertedPercent,
8
+ collectCensus,
9
+ type CensusReport,
10
+ } from '@/sandbox/census'
6
11
  import {
7
12
  collectCoverage,
8
13
  coveragePercent,
@@ -17,6 +22,7 @@ import {
17
22
  type Verdict,
18
23
  } from '@/sandbox/expect'
19
24
  import {
25
+ frameError,
20
26
  intro,
21
27
  logError,
22
28
  logInfo,
@@ -29,6 +35,34 @@ import {
29
35
 
30
36
  const SANDBOX_DIR = join(PROJECT_ROOT, 'scripts', 'sandbox')
31
37
 
38
+ /**
39
+ * Reports that the scenario tree does not ship, and answers whether it reported,
40
+ * so a caller that sees `true` returns without touching the tree.
41
+ *
42
+ * `scripts/sandbox` is excluded from the published package, so an installed
43
+ * `aitk` resolves `SANDBOX_DIR` to a directory that is not there. Both entry
44
+ * points that walk the tree ask here rather than carrying a check each, because
45
+ * absence is a property of the install rather than of a verb, and a second copy
46
+ * of the question is a second message to keep true. `check` reads the tree as
47
+ * well, through `expectFilePath`, and needs no guard because its provisioned-tree
48
+ * check already stops an installed run before the read.
49
+ *
50
+ * The distinction it preserves is between an absent tree and an empty one. Only
51
+ * the second is a real zero, and a coverage percentage over a denominator nobody
52
+ * looked at reads as a suite that examined everything and found it clean.
53
+ *
54
+ * The frame opens and closes here, so this runs before `intro` rather than
55
+ * inside an open frame.
56
+ */
57
+ function reportAbsentScenarioTree(): boolean {
58
+ if (existsSync(SANDBOX_DIR)) return false
59
+
60
+ frameError('sandbox is toolkit-only and is absent from an installed aitk')
61
+ process.exitCode = 1
62
+
63
+ return true
64
+ }
65
+
32
66
  /**
33
67
  * The provisioned tree, as opposed to `SANDBOX_DIR` above, which holds the
34
68
  * scenario scripts. It sits outside the toolkit worktree so the toolkit's own
@@ -71,6 +105,7 @@ interface CheckOptions {
71
105
  interface CoverageOptions {
72
106
  readonly json?: boolean
73
107
  readonly strict?: boolean
108
+ readonly skills?: boolean
74
109
  }
75
110
 
76
111
  function getCategories(): string[] {
@@ -216,18 +251,105 @@ function reportCoverage(report: CoverageReport): void {
216
251
  else logInfo(summary)
217
252
  }
218
253
 
254
+ /**
255
+ * Names the skills nothing can fail, which is the work queue the arm batches
256
+ * consume. Exempt entries print their reason, since an exemption with no reason
257
+ * beside it is indistinguishable from a skill nobody has got to yet.
258
+ */
259
+ function reportCensus(report: CensusReport): void {
260
+ const shouldBe = report.skills.filter(
261
+ (s) => s.verdict === 'should-be-asserted',
262
+ )
263
+ const exempt = report.skills.filter((s) => s.verdict === 'exempt')
264
+
265
+ if (shouldBe.length > 0) {
266
+ logStep('No arm can fail these')
267
+ for (const entry of shouldBe)
268
+ logWarn(
269
+ `${entry.skill} (${entry.scenarios.length === 0 ? 'no scenario' : entry.scenarios.join(', ')})`,
270
+ )
271
+ }
272
+
273
+ if (exempt.length > 0) {
274
+ logStep('Exempt')
275
+ for (const entry of exempt) logInfo(`${entry.skill}: ${entry.reason}`)
276
+ }
277
+
278
+ // A wrong exemption is the one way this report can overstate itself, so both
279
+ // kinds print as errors even on a run that is otherwise clean. They separate
280
+ // because the reader checks a different thing: whether the skill left the
281
+ // tree, or whether an arm landed and the entry outlived its reason.
282
+ if (report.staleExemptions.length > 0) {
283
+ logStep('Exemptions naming no shipped skill')
284
+ for (const skill of report.staleExemptions) logError(skill)
285
+ }
286
+
287
+ if (report.supersededExemptions.length > 0) {
288
+ logStep('Exemptions an arm now asserts')
289
+ for (const skill of report.supersededExemptions)
290
+ logError(`${skill}, delete the entry`)
291
+ }
292
+
293
+ logStep('Skills')
294
+ const summary =
295
+ `${report.asserted}/${report.totalSkills} skills asserted ` +
296
+ `(${assertedPercent(report)}%), ${report.shouldBeAsserted} should be, ` +
297
+ `${report.exempt} exempt`
298
+ if (report.shouldBeAsserted > 0) logWarn(summary)
299
+ else logInfo(summary)
300
+ }
301
+
219
302
  function runCoverage(options: CoverageOptions): void {
303
+ if (reportAbsentScenarioTree()) return
304
+
220
305
  intro('aitk sandbox coverage')
221
306
 
222
307
  const report = collectCoverage(PROJECT_ROOT)
308
+
309
+ // A broken `exempt.toml` reads as a caller error, not as a crash. The parser
310
+ // throws rather than returning a smaller set, since a dropped exemption is
311
+ // invisible in the counts, and the conversion belongs here for the reason
312
+ // `resolveVerdict` catches its own parse: a typo in a declaration should read
313
+ // the way a pattern that does not compile does rather than as a stack trace.
314
+ let census: CensusReport | undefined
315
+ if (options.skills === true) {
316
+ try {
317
+ census = collectCensus(PROJECT_ROOT, report)
318
+ } catch (error) {
319
+ const reason = error instanceof Error ? error.message : String(error)
320
+ reportCoverage(report)
321
+ logStep('Skills')
322
+ logError(`Cannot read the exemptions: ${reason}`)
323
+ outro()
324
+ process.exitCode = 1
325
+
326
+ return
327
+ }
328
+ }
329
+
330
+ // The scenario view stays whichever way `--skills` is set. The two count
331
+ // different denominators, and replacing one with the other loses the scenario
332
+ // rollout the strict gate is written against.
223
333
  reportCoverage(report)
224
- if (options.json === true) process.stdout.write(`${JSON.stringify(report)}\n`)
334
+ if (census !== undefined) reportCensus(census)
335
+
336
+ if (options.json === true)
337
+ process.stdout.write(
338
+ `${JSON.stringify(census === undefined ? report : { ...report, ...census })}\n`,
339
+ )
225
340
 
226
341
  outro()
227
- process.exitCode =
342
+
343
+ // A wrong exemption exits non-zero without `--strict`. It is a wrong claim in
344
+ // committed data rather than a rollout still in progress, and the whole point
345
+ // of the verdict is that an exemption someone can no longer check is worse
346
+ // than no exemption at all.
347
+ const wrongExemption =
348
+ (census?.staleExemptions.length ?? 0) > 0 ||
349
+ (census?.supersededExemptions.length ?? 0) > 0
350
+ const rolloutIncomplete =
228
351
  options.strict === true && report.armedScenarios < report.totalScenarios
229
- ? 1
230
- : 0
352
+ process.exitCode = wrongExemption || rolloutIncomplete ? 1 : 0
231
353
  }
232
354
 
233
355
  function runCheck(
@@ -284,6 +406,8 @@ export function register(program: Command): void {
284
406
  .allowExcessArguments(true)
285
407
  .passThroughOptions()
286
408
  .action(async (_opts: unknown, cmd: Command) => {
409
+ if (reportAbsentScenarioTree()) return
410
+
287
411
  const args = cmd.args
288
412
 
289
413
  if (args.length === 0) {
@@ -328,6 +452,7 @@ export function register(program: Command): void {
328
452
  .helpOption('-h, --help', 'Show this help message')
329
453
  .option('--json', 'Emit the report as JSON on stdout')
330
454
  .option('--strict', 'Exit non-zero while any scenario declares nothing')
455
+ .option('--skills', 'Add a per-skill asserted, should-be, or exempt census')
331
456
  .addHelpText(
332
457
  'after',
333
458
  [
@@ -335,8 +460,11 @@ export function register(program: Command): void {
335
460
  'Examples:',
336
461
  ' aitk sandbox coverage',
337
462
  ' aitk sandbox coverage --json',
463
+ ' aitk sandbox coverage --skills',
338
464
  '',
339
- 'Exit codes: 0 always, unless --strict and a scenario declares nothing.',
465
+ 'Exit codes: 0, unless --strict and a scenario declares nothing, or',
466
+ '--skills and an exemption names no shipped skill or one an arm asserts.',
467
+ 'Where the scenario tree does not ship, exits 1 without a report.',
340
468
  ].join('\n'),
341
469
  )
342
470
  .action((options: CoverageOptions) => {
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { collectCoverage, type CoverageReport } from '@/sandbox/coverage'
4
+
5
+ /**
6
+ * Holds the exemption declarations, beside the scenario categories rather than
7
+ * inside one. `listScenarios` in `coverage.ts` walks directories only, so a file
8
+ * at that level joins no category and never reads as a scenario.
9
+ */
10
+ const EXEMPT_FILE = 'exempt.toml'
11
+
12
+ export type SkillVerdict = 'asserted' | 'should-be-asserted' | 'exempt'
13
+
14
+ export interface SkillCensusEntry {
15
+ readonly skill: string
16
+ readonly verdict: SkillVerdict
17
+ /**
18
+ * Every `<category>:<command>` pairing to this skill, empty when none does.
19
+ * Plural because the mapping is many-to-one: two scenarios can drive one
20
+ * skill, and naming only the first would credit its arms to the wrong file.
21
+ */
22
+ readonly scenarios: readonly string[]
23
+ /**
24
+ * Each armed arm as `<category>:<command>/<arm>`. Qualified rather than bare,
25
+ * because a skill two scenarios drive can hold two arms of the same name and
26
+ * a bare list renders them as one label twice. Deduplicating instead would
27
+ * read as one arm where two assert, which understates in the one direction
28
+ * this report exists to keep honest.
29
+ */
30
+ readonly armed: readonly string[]
31
+ /** Why no arm asserts this skill. Present only on `exempt`. */
32
+ readonly reason?: string
33
+ }
34
+
35
+ export interface CensusReport {
36
+ readonly skills: readonly SkillCensusEntry[]
37
+ readonly totalSkills: number
38
+ readonly asserted: number
39
+ readonly shouldBeAsserted: number
40
+ readonly exempt: number
41
+ /** Exemptions naming a skill the tree does not carry. */
42
+ readonly staleExemptions: readonly string[]
43
+ /**
44
+ * Exemptions on a skill an arm now asserts. The claim is wrong in the one
45
+ * direction a verdict decays, and the entry outranks nothing, so dropping it
46
+ * silently leaves committed data nobody is told to delete.
47
+ */
48
+ readonly supersededExemptions: readonly string[]
49
+ }
50
+
51
+ function directories(path: string): string[] {
52
+ if (!existsSync(path)) return []
53
+
54
+ return readdirSync(path, { withFileTypes: true })
55
+ .filter((entry) => entry.isDirectory())
56
+ .map((entry) => entry.name)
57
+ .sort()
58
+ }
59
+
60
+ /**
61
+ * Every skill the plugin ships, which is the census denominator. Reads the
62
+ * authoring root rather than `.claude/skills/`, since the latter holds
63
+ * toolkit-internal skills that reach no target and would inflate the count.
64
+ */
65
+ export function listSkills(root: string): string[] {
66
+ return directories(join(root, 'claude', 'skills'))
67
+ }
68
+
69
+ /**
70
+ * Maps a scenario to the skill it drives, trying two spellings in order.
71
+ *
72
+ * `<category>-<command>` is the rule `.claude/context/sandbox.md` states, and it
73
+ * alone pairs 29 of 55 skills. The bare `<command>` fallback is what reaches the
74
+ * rest: `claude/setup-init.sh` drives the `setup-init` skill, not a
75
+ * `claude-setup-init` that does not exist. Stating one spelling and shipping two
76
+ * is what let the audit report a paired skill as unpaired.
77
+ *
78
+ * Returns undefined for a scenario driving no skill at all, which is every
79
+ * `infra/` and `tooling/` scenario. Those exercise a CLI domain rather than a
80
+ * skill and belong to the scenario count, not to this one.
81
+ */
82
+ export function skillForScenario(
83
+ category: string,
84
+ command: string,
85
+ skills: ReadonlySet<string>,
86
+ ): string | undefined {
87
+ const prefixed = `${category}-${command}`
88
+ if (skills.has(prefixed)) return prefixed
89
+ if (skills.has(command)) return command
90
+
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Reads the exemption declarations, keyed by skill with a `reason` each.
96
+ *
97
+ * The reason cannot live in the arm's `manual` array, which is where `#723` put
98
+ * prose a checker could not assert. `resolveVerdict` fails any declaration
99
+ * carrying zero mechanical assertions, so an `expect.toml` holding only an
100
+ * exempt reason goes red at the moment it is written. An exempt skill has no
101
+ * assertion to pair the prose with, which is what makes it exempt, so the two
102
+ * cases cannot share a home.
103
+ *
104
+ * Throws on a malformed file and on a table carrying no usable `reason`, rather
105
+ * than reporting a smaller set. Both losses are invisible downstream: the skill
106
+ * reclassifies to should-be-asserted and rejoins a work queue someone already
107
+ * ruled it out of, with nothing naming the entry that stopped counting. This is
108
+ * the stray-key rule `contentArray` applies in `expect.ts` for the same reason,
109
+ * where the declaration is well-formed and silently lost a key it appears to
110
+ * carry. `runCoverage` catches both and frames them.
111
+ */
112
+ export function parseExemptions(source: string): Map<string, string> {
113
+ const parsed = Bun.TOML.parse(source) as Record<string, unknown>
114
+ const exemptions = new Map<string, string>()
115
+
116
+ for (const [skill, value] of Object.entries(parsed)) {
117
+ if (typeof value !== 'object' || value === null) {
118
+ throw new Error(
119
+ `exemption ${skill} is not a table. Write [${skill}] with a reason below it.`,
120
+ )
121
+ }
122
+
123
+ const reason = (value as Record<string, unknown>).reason
124
+ if (typeof reason !== 'string' || reason === '') {
125
+ throw new Error(
126
+ `exemption ${skill} declares no reason. An exemption without one cannot be checked or overturned.`,
127
+ )
128
+ }
129
+
130
+ exemptions.set(skill, reason)
131
+ }
132
+
133
+ return exemptions
134
+ }
135
+
136
+ function readExemptions(root: string): Map<string, string> {
137
+ const path = join(root, 'scripts', 'sandbox', EXEMPT_FILE)
138
+ if (!existsSync(path)) return new Map()
139
+
140
+ return parseExemptions(readFileSync(path, 'utf8'))
141
+ }
142
+
143
+ /**
144
+ * Gives every shipped skill an asserted, should-be-asserted, or exempt verdict
145
+ * against declared expectations rather than against a scenario existing.
146
+ *
147
+ * A paired scenario is not an asserted skill. A scenario with no `expect.toml`
148
+ * provisions a state and prints a human-readable `Expect:` line, so a run over
149
+ * it exits zero having proved nothing. Counting pairs would report coverage the
150
+ * suite does not have, which is the measure this report exists to replace.
151
+ *
152
+ * An armed arm outranks an exemption. A skill listed exempt that acquired an arm
153
+ * is asserted in fact, and reporting the stale claim instead would hide the one
154
+ * direction the verdict decays in.
155
+ */
156
+ export function collectCensus(
157
+ root: string,
158
+ coverage: CoverageReport = collectCoverage(root),
159
+ ): CensusReport {
160
+ const skills = listSkills(root)
161
+ const known = new Set(skills)
162
+ const exemptions = readExemptions(root)
163
+
164
+ const pairings = new Map<string, { scenarios: string[]; armed: string[] }>()
165
+ for (const scenario of coverage.scenarios) {
166
+ const skill = skillForScenario(scenario.category, scenario.command, known)
167
+ if (skill === undefined) continue
168
+
169
+ // Accumulate rather than assign. Dropping a second scenario would report a
170
+ // skill unarmed while an arm under the other spelling asserts it.
171
+ const pair = `${scenario.category}:${scenario.command}`
172
+ const existing = pairings.get(skill) ?? { scenarios: [], armed: [] }
173
+ existing.scenarios.push(pair)
174
+ existing.armed.push(...scenario.armed.map((arm) => `${pair}/${arm}`))
175
+ pairings.set(skill, existing)
176
+ }
177
+
178
+ const entries = skills.map((skill): SkillCensusEntry => {
179
+ const pairing = pairings.get(skill)
180
+ const scenarios = pairing?.scenarios ?? []
181
+ const armed = pairing?.armed ?? []
182
+ const reason = exemptions.get(skill)
183
+
184
+ if (armed.length > 0) {
185
+ return { skill, verdict: 'asserted', scenarios, armed }
186
+ }
187
+
188
+ if (reason !== undefined) {
189
+ return { skill, verdict: 'exempt', scenarios, armed, reason }
190
+ }
191
+
192
+ return { skill, verdict: 'should-be-asserted', scenarios, armed }
193
+ })
194
+
195
+ // An exemption outranked by an arm is as wrong as one naming no skill, and it
196
+ // is the case the armed-wins rule creates rather than one the tree arrives
197
+ // with. Reading `entries` rather than recomputing keeps the two in step, so a
198
+ // change to the precedence cannot leave the report contradicting the verdict.
199
+ const assertedSkills = new Set(
200
+ entries.filter((e) => e.verdict === 'asserted').map((e) => e.skill),
201
+ )
202
+
203
+ return {
204
+ skills: entries,
205
+ totalSkills: entries.length,
206
+ asserted: assertedSkills.size,
207
+ shouldBeAsserted: entries.filter((e) => e.verdict === 'should-be-asserted')
208
+ .length,
209
+ exempt: entries.filter((e) => e.verdict === 'exempt').length,
210
+ staleExemptions: [...exemptions.keys()]
211
+ .filter((skill) => !known.has(skill))
212
+ .sort(),
213
+ supersededExemptions: [...exemptions.keys()]
214
+ .filter((skill) => assertedSkills.has(skill))
215
+ .sort(),
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Whole percent, floored, over skills rather than scenarios. Floored for the
221
+ * reason `coveragePercent` is: the number exists to look worse than a wall of
222
+ * green does.
223
+ */
224
+ export function assertedPercent(report: CensusReport): number {
225
+ if (report.totalSkills === 0) return 0
226
+
227
+ return Math.floor((report.asserted / report.totalSkills) * 100)
228
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr, claude-standards-audit
4
+ consumers: git-branch, git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Pull request reference
3
3
  description: Pull request title and body conventions
4
- consumers: git-split, git-pr, claude-standards-audit
4
+ consumers: git-split, git-pr
5
5
  ---
6
6
 
7
7
  # Pull request reference
@@ -1,49 +0,0 @@
1
- ---
2
- title: Branch reference
3
- description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr, claude-standards-audit
5
- ---
6
-
7
- # Branch reference
8
-
9
- ## Format
10
-
11
- - Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
12
- - Length: 50 characters maximum
13
- - Casing: kebab-case only, no underscores or camelCase
14
- - Description: 2 words maximum, 3 only when genuinely needed for specificity
15
- - Capture the core change, not the commit message verbatim
16
- - For branches with multiple commits, use the unifying concern as the description.
17
- - Do not duplicate type in description (e.g., `feat/feature-login`)
18
-
19
- ## Types
20
-
21
- - `feat`: new feature or capability
22
- - `fix`: bug fix
23
- - `refactor`: structural changes (not a fix or feature)
24
- - `docs`: documentation only (README)
25
- - `chore`: maintenance tasks (deps, tooling, configs)
26
- - `perf`: performance improvements
27
- - `test`: add or modify tests
28
- - `style`: code formatting (whitespace, semicolons)
29
- - `build`: build system changes (webpack, npm scripts)
30
- - `ci`: CI/CD pipeline changes (GitHub Actions)
31
- - `revert`: revert a previous commit
32
-
33
- ## Examples
34
-
35
- ### Correct
36
-
37
- ```plaintext
38
- feat/jwt-expiration # clear feature scope
39
- fix/AUTH-123-connection-pool # includes ticket ID
40
- refactor/remove-deprecated-endpoints # clear refactor intent
41
- ```
42
-
43
- ### Incorrect
44
-
45
- ```plaintext
46
- feature/auth_stuff # wrong type + underscore
47
- feat/feature-add-login # duplicates type in description
48
- fix/DB-456-fix-the-database-connection-pool-memory-leak # exceeds 50 chars + verbatim message
49
- ```
@@ -1,124 +0,0 @@
1
- ---
2
- title: Pull request reference
3
- description: Pull request title and body conventions
4
- consumers: git-split, git-pr, claude-standards-audit
5
- ---
6
-
7
- # Pull request reference
8
-
9
- ## Title
10
-
11
- - Format: `<type>(<scope>): <subject>`
12
- - Casing: lowercase for `<type>`, `<scope>`, and first word of `<subject>`
13
- - Length: 72 characters maximum
14
-
15
- ## Content
16
-
17
- - Use imperative mood for all content (`add`, `fix`, `refactor`)
18
- - Do not start with "This PR," "This commit," "Included are," or "I have"
19
- - Do not use buzzwords (`seamless`, `robust`, `game-changer`, `enhanced`)
20
- - Do not describe historical behavior or unchanged code. Describe new behavior only.
21
- - Do not include future promises or speculative documentation
22
- - Do not explain obvious changes (formatting, renaming variables)
23
- - Do not duplicate commit messages verbatim
24
-
25
- ## Sections
26
-
27
- - `## Summary`: 1-2 sentences following `<Action Verb> <Direct Object> to <Result>`, expand for clarity if needed
28
- - `## Key Changes`: name actual files, functions, or modules (e.g., `AuthService.verify()` not "auth handler"). Always use bullet points, never prose.
29
- - `## Technical Context` (optional): 1-2 lines of architectural reasoning explaining why, not what
30
- - Omit Technical Context for docs, config, or trivial changes
31
- - Use bullet points for multiple reasons, one sentence for a single reason
32
- - `## Testing` (optional): specify exact commands or test cases run
33
- - Omit Testing for docs, config, or trivial sync changes
34
- - Use checkboxes, never prose. See Testing discipline for which box gets ticked.
35
- - `## For the reviewer` (optional): what the reviewer should confirm, one bullet per request
36
- - Visuals: include only when they clarify architecture, UI, or complex logic flows
37
-
38
- ## Testing discipline
39
-
40
- - Run the check before writing its line. A `- [ ]` reports a check that has not run rather than one that is planned.
41
- - Tick the box and state the observed result. `- [x] npm test passes, 42 tests` beats `- [ ] run npm test`.
42
- - Quote the count or output the run reported, never a figure carried from elsewhere.
43
- - Leave a box unchecked only when a human is required, and name which human and why on the same line.
44
- - Human-only covers visual or aesthetic judgment, anything needing credentials or a live third-party service, anything needing a second machine or a fresh OS, and judgment about whether a boundary or an abstraction reads correctly. The agent runs everything else.
45
- - Put a request for the reviewer under `## For the reviewer`. It is a request rather than unfinished testing, so it never appears as an unchecked Testing box.
46
-
47
- ## Formatting
48
-
49
- - End every bullet point with a period
50
-
51
- ## Examples
52
-
53
- ### Template
54
-
55
- ```markdown
56
- ## Summary
57
-
58
- <Action Verb> <Direct Object> to <Result>.
59
-
60
- ## Key Changes
61
-
62
- - <Verb> <specific component/file/function> (<reason if non-obvious>)
63
- - <Verb> <specific component/file/function>
64
-
65
- ## Technical Context
66
-
67
- - <Architectural reasoning explaining why, not what>
68
-
69
- ## Testing
70
-
71
- - [x] <Command run> <observed result>
72
- - [x] <Edge case verified> <what was observed>
73
- - [ ] <Human-only check> (<which human, why>)
74
-
75
- ## For the reviewer
76
-
77
- - <What the reviewer should confirm>
78
- ```
79
-
80
- ### Correct
81
-
82
- ```markdown
83
- ## Summary
84
-
85
- Update auth middleware to enforce jwt expiration checks. # imperative + direct object + result
86
-
87
- ## Key Changes
88
-
89
- - Add `verifyExpiration()` to `src/auth/validators.ts`. # specific function + file path
90
- - Refactor `AuthService.authenticate()` to handle 401 codes. # named component + clear change
91
-
92
- ## Technical Context
93
-
94
- - Migration to stateless session management for horizontal scalability. # why, not what
95
-
96
- ## Testing
97
-
98
- - [x] `npm run test:auth` passes, 42 tests. # command run + observed result
99
- - [x] Expired token rejected with a 401 against a local server. # edge case + what happened
100
- - [ ] Staging smoke test (release owner, needs staging credentials). # unchecked + which human + why
101
-
102
- ## For the reviewer
103
-
104
- - Confirm the 401 and 403 split reads correctly for the public API. # a request, not a test result
105
- ```
106
-
107
- ### Incorrect
108
-
109
- ```markdown
110
- ## Summary
111
-
112
- This PR updates the authentication system to be more robust. # "This PR" opener + buzzword
113
-
114
- ## Key Changes
115
-
116
- - Updated auth middleware files # vague, no specific component, no period
117
- - The old system used to check tokens differently # describes historical behavior
118
-
119
- ## Testing
120
-
121
- - Tested manually # no specific command or case
122
- - [ ] `npm run test:auth` # unchecked box for a check the agent can run
123
- - [ ] Reviewer confirms the error split reads correctly # a reviewer request, belongs under `## For the reviewer`
124
- ```