@erclx/aitk 3.29.1 → 3.30.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": "3.29.1",
4
+ "version": "3.30.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -46,6 +46,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
46
46
  | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
47
47
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
48
48
  | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
49
+ | `aitk standards audit` | Report the corpus against the `## Success criterion` gate, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
49
50
  | `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
50
51
  | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
51
52
  | `aitk claude skills rank` | Score the shipped catalog's descriptions against a hand-authored case corpus by TF-IDF cosine similarity, reporting rank-one and top-three |
@@ -83,7 +84,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
83
84
  | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
84
85
  | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
85
86
  | `snippets` | `list`, `create` |
86
- | `standards` | `list`, `<name>` |
87
+ | `standards` | `list`, `audit`, `<name>` |
87
88
  | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
88
89
  | `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
89
90
  | `demo` | `compile`, `run` |
@@ -33,6 +33,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
33
33
  - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
34
34
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
35
35
  - [Citation reach](skills-reach.md): Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
36
+ - [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
36
37
  - [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
37
38
  - [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
38
39
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
@@ -0,0 +1,38 @@
1
+ ---
2
+ title: Standard success criteria
3
+ description: Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
4
+ ---
5
+
6
+ # Standard success criteria
7
+
8
+ `aitk standards audit` reads the corpus at `standards/` and reports which files carry a `## Success criterion` section against which do not, per `standards/standard.md`. It fails only on a standard new to the current branch, never on one already short the section.
9
+
10
+ ```bash
11
+ aitk standards audit
12
+ aitk standards audit --json
13
+ aitk standards audit --arrivals-only
14
+ ```
15
+
16
+ | Option | Behavior |
17
+ | ----------------- | ------------------------------------------------------------ |
18
+ | `[path]` | Project root, defaulting to the current directory |
19
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
+ | `--arrivals-only` | Run the gating check alone, printing nothing on a clean pass |
21
+
22
+ ## Why arrival rather than the corpus
23
+
24
+ `standards/standard.md` states that a criterion is added to an existing standard when that standard is next exercised, not in a sweep: a criterion written with no failure to point at is the taste-based edit the rule exists to prevent. Gating the whole corpus would fail every push until every standard already short the section was closed at once, which is the sweep that rule forbids. The check reads the whole corpus and fails only on a file present in the working tree and absent at the branch's merge base, treating a rename into the corpus the same as a standard authored fresh.
25
+
26
+ ## Exit codes and refusals
27
+
28
+ | Code | Meaning |
29
+ | ---- | --------------------------------------------------------------- |
30
+ | `0` | every arriving standard carries the section |
31
+ | `1` | refused, with `reason` naming the cause |
32
+ | `2` | a standard new to this branch carries no `## Success criterion` |
33
+
34
+ A project authoring no standards refuses with `no-corpus`, the ordinary state of most targets, the same absence `aitk claude skills audit` reads as its own `no-corpus`.
35
+
36
+ ## What it does not measure
37
+
38
+ Presence of the heading is the whole check. The section's content, the questions it must answer or the task it must let a reader complete, is a judgment `aitk standards audit` cannot read, so a standard carrying an empty or token section still passes.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.29.1",
4
+ "version": "3.30.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -460,6 +460,23 @@ main() {
460
460
  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."
461
461
  log_info "Skill requirements present"
462
462
 
463
+ # Scoped to arrival rather than the corpus, since standards/standard.md
464
+ # forbids writing a criterion into an existing standard outside the change
465
+ # that exercises it. Gating the 26 known gaps would fail every push until
466
+ # someone closed them all, which is the sweep that rule exists to prevent.
467
+ log_step "Standard success criteria"
468
+ local standards_output standards_status=0
469
+ standards_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts standards audit --arrivals-only 2>&1) || standards_status=$?
470
+ if [ "$standards_status" -eq 0 ]; then
471
+ log_info "Arriving standards carry a success criterion"
472
+ elif [ "$standards_status" -eq 2 ]; then
473
+ echo "$standards_output" | pipe_output
474
+ log_error "A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit."
475
+ else
476
+ echo "$standards_output" | pipe_output
477
+ log_error "aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why."
478
+ fi
479
+
463
480
  # `aitk sandbox coverage` moves only when a person runs it, so a scenario added
464
481
  # with no expectation ships unnoticed. The gate is an absolute count of
465
482
  # undeclared scenarios rather than a ratio or a floor under the declared count.
@@ -1,12 +1,33 @@
1
+ import { resolve } from 'node:path'
1
2
  import type { Command } from 'commander'
2
3
  import { registerPassThroughVerbs } from '@/commands/pass-through'
4
+ import {
5
+ auditExitCode,
6
+ auditStandards,
7
+ type StandardsAudit,
8
+ } from '@/standards/audit'
3
9
  import { listStandards, readStandard, resolveStandard } from '@/standards/read'
4
- import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
10
+ import {
11
+ frameError,
12
+ intro,
13
+ logError,
14
+ logInfo,
15
+ logStep,
16
+ logWarn,
17
+ outro,
18
+ pipeOutput,
19
+ plural,
20
+ } from '@/ui'
21
+
22
+ interface StandardsAuditOptions {
23
+ readonly json?: boolean
24
+ readonly arrivalsOnly?: boolean
25
+ }
5
26
 
6
27
  export function register(program: Command): void {
7
28
  const standards = program
8
29
  .command('standards')
9
- .description('Standards commands (list, <name>)')
30
+ .description('Standards commands (list, audit, <name>)')
10
31
  .argument('[name]', 'Standard to print, by name with or without .md')
11
32
  .helpOption('-h, --help', 'Show this help message')
12
33
  .addHelpText(
@@ -37,6 +58,41 @@ export function register(program: Command): void {
37
58
  })
38
59
 
39
60
  registerPassThroughVerbs(standards, 'standards', ['list'])
61
+
62
+ standards
63
+ .command('audit')
64
+ .description(
65
+ 'Report the corpus against the `## Success criterion` gate in standards/standard.md',
66
+ )
67
+ .argument('[path]', 'Project root, defaulting to the current directory')
68
+ .helpOption('-h, --help', 'Show this help message')
69
+ .option('--json', 'Add a machine-readable record on stdout')
70
+ .option(
71
+ '--arrivals-only',
72
+ 'Run the gating check for standards new on this branch alone',
73
+ )
74
+ .addHelpText(
75
+ 'after',
76
+ [
77
+ '',
78
+ 'Exit codes:',
79
+ ' 0 the audit completed with every arriving standard carrying the section',
80
+ ' 1 refused, with the reason on stderr',
81
+ ' 2 a standard new to this branch carries no ## Success criterion section',
82
+ '',
83
+ 'A standard already in the corpus without the section is a known gap',
84
+ 'standards/standard.md names, not a violation, so only an arrival fails.',
85
+ '',
86
+ 'Examples:',
87
+ ' aitk standards audit',
88
+ ' aitk standards audit --json',
89
+ ' aitk standards audit --arrivals-only',
90
+ '',
91
+ ].join('\n'),
92
+ )
93
+ .action(async (path: string | undefined, opts: StandardsAuditOptions) => {
94
+ process.exitCode = await runStandardsAudit(path, opts)
95
+ })
40
96
  }
41
97
 
42
98
  /**
@@ -67,3 +123,125 @@ function print(name: string): number {
67
123
  outro()
68
124
  return 0
69
125
  }
126
+
127
+ /**
128
+ * Measures the corpus at the cwd rather than the toolkit root the catalog
129
+ * reads, so a linked worktree audits its own branch instead of `main`.
130
+ */
131
+ async function runStandardsAudit(
132
+ path: string | undefined,
133
+ opts: StandardsAuditOptions,
134
+ ): Promise<number> {
135
+ const root = resolve(path ?? process.cwd())
136
+ const gateOnly = opts.arrivalsOnly ?? false
137
+ const audit = await auditStandards(root)
138
+
139
+ if (audit.kind === 'refused') {
140
+ const message =
141
+ audit.reason === 'no-corpus'
142
+ ? `No standards/ under ${root}.`
143
+ : audit.reason === 'no-base'
144
+ ? 'No merge base against main resolved.'
145
+ : 'Could not read which standards arrived on this branch.'
146
+
147
+ if (gateOnly) {
148
+ frameError(message)
149
+ } else {
150
+ intro('aitk standards audit')
151
+ logStep('Refused')
152
+ logWarn(message)
153
+ outro()
154
+ }
155
+
156
+ if (opts.json) {
157
+ process.stdout.write(
158
+ `${JSON.stringify({ root, reason: audit.reason, message })}\n`,
159
+ )
160
+ }
161
+
162
+ return auditExitCode(audit)
163
+ }
164
+
165
+ if (gateOnly) {
166
+ reportArrivalGate(audit)
167
+ } else {
168
+ intro('aitk standards audit')
169
+ reportCorpus(audit)
170
+ outro()
171
+ }
172
+
173
+ if (opts.json) {
174
+ process.stdout.write(
175
+ `${JSON.stringify({
176
+ root,
177
+ base: audit.base,
178
+ standards: audit.standards,
179
+ withCriterion: audit.withCriterion,
180
+ withoutCriterion: audit.withoutCriterion,
181
+ arrivals: audit.arrivals,
182
+ arrivalsWithoutCriterion: audit.arrivalsWithoutCriterion,
183
+ })}\n`,
184
+ )
185
+ }
186
+
187
+ return auditExitCode(audit)
188
+ }
189
+
190
+ /**
191
+ * Prints nothing when every arriving standard carries the section.
192
+ *
193
+ * `--arrivals-only` is what `verify.sh` runs on every push, and that script
194
+ * pipes a stage's whole output into its own frame. A passing gate that
195
+ * printed its frame would nest one inside the other on every contributor's
196
+ * push.
197
+ */
198
+ function reportArrivalGate(
199
+ audit: Extract<StandardsAudit, { kind: 'measured' }>,
200
+ ): void {
201
+ const missing = audit.arrivalsWithoutCriterion
202
+ if (missing.length === 0) return
203
+
204
+ intro('aitk standards audit')
205
+ logError(
206
+ missing.length === 1
207
+ ? '1 standard new to this branch carries no ## Success criterion section'
208
+ : `${missing.length} standards new to this branch carry no ## Success criterion section`,
209
+ )
210
+ pipeOutput(missing.join('\n'))
211
+ outro()
212
+ }
213
+
214
+ function reportCorpus(
215
+ audit: Extract<StandardsAudit, { kind: 'measured' }>,
216
+ ): void {
217
+ logStep('Corpus')
218
+ logInfo(`${plural(audit.standards.length, 'standard')} at standards/`)
219
+ logInfo(
220
+ `${plural(audit.withCriterion.length, 'standard')} carrying ## Success criterion`,
221
+ )
222
+
223
+ logStep('Known gaps')
224
+ if (audit.withoutCriterion.length === 0) {
225
+ logInfo('None. Every standard carries the section.')
226
+ } else {
227
+ pipeOutput(audit.withoutCriterion.join('\n'))
228
+ }
229
+
230
+ logStep('Arrivals since main')
231
+ if (audit.arrivals.length === 0) {
232
+ logInfo('No standard new to this branch.')
233
+ return
234
+ }
235
+
236
+ if (audit.arrivalsWithoutCriterion.length === 0) {
237
+ logInfo(
238
+ `${plural(audit.arrivals.length, 'standard')} arrived, every one carrying the section.`,
239
+ )
240
+ return
241
+ }
242
+
243
+ logError(
244
+ `${plural(audit.arrivalsWithoutCriterion.length, 'standard')} arrived carrying no ## Success criterion section`,
245
+ )
246
+ pipeOutput(audit.arrivalsWithoutCriterion.join('\n'))
247
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import { gitEnv } from '@/git-env'
5
+ import { resolveBaseRef } from '@/git-files'
6
+ import { INDEX_FILE, standardsSourceDir } from '@/standards/read'
7
+
8
+ /** Returned when a standard new to this branch carries no `## Success criterion` section, the gating check. */
9
+ export const EXIT_MISSING_CRITERION = 2
10
+
11
+ /** Matched at any casing, level-2 only, per the heading `standards/standard.md` itself uses. */
12
+ const CRITERION_HEADING = /^##\s+success criterion\s*$/im
13
+
14
+ /**
15
+ * The reasons an audit produces no reading. `no-corpus` is the ordinary state
16
+ * of a target that authors no standards of its own, the same absence the
17
+ * skills audit reads as its own `no-corpus`. The other two are a broken git
18
+ * invocation rather than a project stating nothing.
19
+ */
20
+ export type StandardsAuditRefusal =
21
+ | 'no-corpus'
22
+ | 'no-base'
23
+ | 'unreadable-arrivals'
24
+
25
+ export type StandardsAudit =
26
+ | {
27
+ readonly kind: 'measured'
28
+ readonly base: string
29
+ readonly standards: readonly string[]
30
+ readonly withCriterion: readonly string[]
31
+ readonly withoutCriterion: readonly string[]
32
+ readonly arrivals: readonly string[]
33
+ readonly arrivalsWithoutCriterion: readonly string[]
34
+ }
35
+ | { readonly kind: 'refused'; readonly reason: StandardsAuditRefusal }
36
+
37
+ /**
38
+ * Measures the corpus authored at `standards/` under `root` against the
39
+ * `## Success criterion` gate `standards/standard.md` states, and names which
40
+ * of those files are new since the branch's merge base.
41
+ *
42
+ * Reads the working-root corpus alone, never the packaged fallback
43
+ * `src/standards/read.ts` falls through to for a name lookup, since a target
44
+ * with no authored standards of its own has nothing here to gate.
45
+ */
46
+ export async function auditStandards(root: string): Promise<StandardsAudit> {
47
+ const dir = standardsSourceDir(root)
48
+ if (!existsSync(dir)) return { kind: 'refused', reason: 'no-corpus' }
49
+
50
+ const standards = readdirSync(dir, { withFileTypes: true })
51
+ .filter(
52
+ (entry) =>
53
+ entry.isFile() &&
54
+ entry.name.endsWith('.md') &&
55
+ entry.name !== INDEX_FILE,
56
+ )
57
+ .map((entry) => entry.name)
58
+ .sort()
59
+
60
+ const withCriterion: string[] = []
61
+ const withoutCriterion: string[] = []
62
+ for (const name of standards) {
63
+ const body = readFileSync(join(dir, name), 'utf8')
64
+ ;(CRITERION_HEADING.test(body) ? withCriterion : withoutCriterion).push(
65
+ name,
66
+ )
67
+ }
68
+
69
+ const base = await resolveBaseRef(root)
70
+ if (base === undefined) return { kind: 'refused', reason: 'no-base' }
71
+
72
+ const arrived = await arrivedStandards(root, base)
73
+ if (arrived === undefined) {
74
+ return { kind: 'refused', reason: 'unreadable-arrivals' }
75
+ }
76
+
77
+ const arrivals = standards.filter((name) => arrived.has(name))
78
+ const arrivalsWithoutCriterion = arrivals.filter((name) =>
79
+ withoutCriterion.includes(name),
80
+ )
81
+
82
+ return {
83
+ kind: 'measured',
84
+ base,
85
+ standards,
86
+ withCriterion,
87
+ withoutCriterion,
88
+ arrivals,
89
+ arrivalsWithoutCriterion,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Only an arrival missing the section sets a failing code. Every other
95
+ * standard without one is a known gap `standards/standard.md` names rather
96
+ * than a violation, so failing the push on the 26 already there teaches
97
+ * contributors to route around the stage.
98
+ */
99
+ export function auditExitCode(audit: StandardsAudit): number {
100
+ if (audit.kind === 'refused') return 1
101
+ return audit.arrivalsWithoutCriterion.length > 0 ? EXIT_MISSING_CRITERION : 0
102
+ }
103
+
104
+ /**
105
+ * Filenames under `standards/` present in the working tree and absent at
106
+ * `base`: a plain add, with rename detection forced off so a standard moved
107
+ * into the corpus from elsewhere counts the same as one authored fresh.
108
+ */
109
+ async function arrivedStandards(
110
+ root: string,
111
+ base: string,
112
+ ): Promise<Set<string> | undefined> {
113
+ const [added, untracked] = await Promise.all([
114
+ $`git -C ${root} diff --no-renames --name-only --diff-filter=A ${base} -- standards`
115
+ .env(gitEnv())
116
+ .quiet()
117
+ .nothrow(),
118
+ $`git -C ${root} ls-files --others --exclude-standard -- standards`
119
+ .env(gitEnv())
120
+ .quiet()
121
+ .nothrow(),
122
+ ])
123
+
124
+ if (added.exitCode !== 0 || untracked.exitCode !== 0) return undefined
125
+
126
+ const paths = [
127
+ ...added.text().split('\n'),
128
+ ...untracked.text().split('\n'),
129
+ ].filter(Boolean)
130
+
131
+ return new Set(paths.map((path) => basename(path)))
132
+ }
@@ -18,14 +18,14 @@ Does not govern:
18
18
 
19
19
  ## What goes in
20
20
 
21
- - Tokens described as intent ("mid gray, muted text"), not computed values. Exact values live in code.
21
+ - A token's exact value, anchored to the surface it was read from and tagged per `## The uncertainty tag` when unconfirmed. Fall back to intent language ("mid gray, muted text") only where no source exists yet to anchor from.
22
22
  - Layout constraints and sizing rules not obvious from wireframes
23
23
  - Visual rules a developer could get wrong without guidance
24
24
  - Non-obvious omissions ("no motion", "no custom icons") that prevent scope creep
25
25
 
26
26
  ## What does not go in
27
27
 
28
- - CSS classes, computed values, component filenames, and prop names. Those live in code.
28
+ - CSS classes and prop names. Those live in code.
29
29
  - Anything that needs updating every time the code is refactored
30
30
 
31
31
  ## Format