@erclx/aitk 0.109.0 → 0.110.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.
@@ -0,0 +1,464 @@
1
+ import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
2
+ import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
3
+
4
+ /**
5
+ * The audits this repository already owns, and how to read each one's record.
6
+ *
7
+ * Every verb here ships its own `--json` shape with its own keys, and this
8
+ * module reads each shape rather than forcing a common envelope on them. Each
9
+ * record already has consumers, so an envelope would be a breaking change to
10
+ * every one of them bought for tidiness.
11
+ */
12
+
13
+ /** How far a finding from this audit reaches. */
14
+ export type Corpus =
15
+ /** Committed, so every clone measures the same files and a delta is shared. */
16
+ | 'tracked'
17
+ /** Gitignored session scratch, so the numbers are one machine's alone. */
18
+ | 'per-machine'
19
+
20
+ export type AuditStatus =
21
+ /** The audit reported and every count it produced is zero. */
22
+ | 'clean'
23
+ /** The audit reported findings it does not gate on. */
24
+ | 'reported'
25
+ /** The audit reported a finding that is a fact, which fails the aggregate. */
26
+ | 'finding'
27
+ /**
28
+ * The per-machine corpus this audit reads is not on this disk, which is the
29
+ * ordinary state of a gitignored folder on a fresh clone and in CI.
30
+ */
31
+ | 'absent'
32
+ /** The audit did not report, so the aggregate measured less than the set. */
33
+ | 'unmeasured'
34
+
35
+ export interface AuditSpec {
36
+ readonly id: string
37
+ readonly label: string
38
+ /** Arguments after `aitk`, always ending in `--json`. */
39
+ readonly argv: readonly string[]
40
+ /**
41
+ * Exit codes this verb sets on a finding that is a fact.
42
+ *
43
+ * Empty for every verb whose findings are judgments. The catalog decides
44
+ * this rather than the exit code, because a reporting verb sets 2 on
45
+ * findings it deliberately does not gate on, so the code alone cannot say
46
+ * whether a finding is a fact.
47
+ */
48
+ readonly gatingExits: readonly number[]
49
+ readonly corpus: Corpus
50
+ /**
51
+ * Pulls the counts worth retaining out of this verb's record.
52
+ *
53
+ * Returns `undefined` when the record does not carry the keys it reads,
54
+ * which is a measure that did not run rather than a corpus with nothing in
55
+ * it. Zero there would read as clean, which is the two-states-look-alike
56
+ * defect this repository has already fixed elsewhere.
57
+ */
58
+ readonly counts: (record: unknown) => Record<string, number> | undefined
59
+ }
60
+
61
+ export interface AuditResult {
62
+ readonly id: string
63
+ readonly label: string
64
+ readonly status: AuditStatus
65
+ readonly tracked: boolean
66
+ readonly exitCode: number
67
+ readonly counts?: Record<string, number>
68
+ /** Why the audit did not report, present only on `unmeasured`. */
69
+ readonly reason?: string
70
+ }
71
+
72
+ /** The exit code every command here sets when it refuses. */
73
+ const EXIT_REFUSED = 1
74
+
75
+ /** The exit code every command here sets when it carries findings. */
76
+ const EXIT_FINDINGS = 2
77
+
78
+ function asObject(value: unknown): Record<string, unknown> | undefined {
79
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
80
+ return undefined
81
+ }
82
+ return value as Record<string, unknown>
83
+ }
84
+
85
+ function lengthOf(value: unknown): number | undefined {
86
+ return Array.isArray(value) ? value.length : undefined
87
+ }
88
+
89
+ /**
90
+ * Folds a set of per-key readings into one record, or `undefined` when any of
91
+ * them could not be read. One unreadable key means the record is not the shape
92
+ * the extractor was written against, and reporting the rest would publish a
93
+ * partial count under a name that claims to be whole.
94
+ */
95
+ function allOf(
96
+ readings: Record<string, number | undefined>,
97
+ ): Record<string, number> | undefined {
98
+ const counts: Record<string, number> = {}
99
+ for (const [key, value] of Object.entries(readings)) {
100
+ if (value === undefined) return undefined
101
+ counts[key] = value
102
+ }
103
+ return counts
104
+ }
105
+
106
+ function contextCounts(record: unknown): Record<string, number> | undefined {
107
+ const root = asObject(record)
108
+ if (root === undefined) return undefined
109
+
110
+ const entries = root.entries
111
+ if (!Array.isArray(entries)) return undefined
112
+
113
+ let bareReferences = 0
114
+ for (const entry of entries) {
115
+ const bare = lengthOf(asObject(entry)?.bareReferences)
116
+ if (bare === undefined) return undefined
117
+ bareReferences += bare
118
+ }
119
+
120
+ return allOf({
121
+ unresolvedCitations: lengthOf(asObject(root.citations)?.unresolved),
122
+ longEntries: lengthOf(root.length),
123
+ missingSections: lengthOf(root.missingSections),
124
+ indexDrift: lengthOf(root.indexDrift),
125
+ bareReferences,
126
+ })
127
+ }
128
+
129
+ function markdownCounts(record: unknown): Record<string, number> | undefined {
130
+ const root = asObject(record)
131
+ if (root === undefined) return undefined
132
+
133
+ const entries = root.entries
134
+ if (!Array.isArray(entries)) return undefined
135
+
136
+ const depth = asObject(root.checkpoints)?.run
137
+ // A depth reading needs the checkpoint it is measured against. Counting zero
138
+ // without it reports a corpus nobody measured for depth as one with no file
139
+ // past the line.
140
+ if (entries.length > 0 && typeof depth !== 'number') return undefined
141
+
142
+ let bans = 0
143
+ let heavyBullets = 0
144
+ let heavyParagraphs = 0
145
+ let filesPastDepth = 0
146
+ let flatParagraphs = 0
147
+
148
+ for (const raw of entries) {
149
+ const entry = asObject(raw)
150
+ if (entry === undefined) return undefined
151
+
152
+ const entryBans = lengthOf(entry.bans)
153
+ const bullets = lengthOf(entry.heavyBullets)
154
+ const paragraphs = lengthOf(entry.heavyParagraphs)
155
+ const run = entry.longestRun
156
+ if (
157
+ entryBans === undefined ||
158
+ bullets === undefined ||
159
+ paragraphs === undefined ||
160
+ typeof run !== 'number'
161
+ ) {
162
+ return undefined
163
+ }
164
+
165
+ bans += entryBans
166
+ heavyBullets += bullets
167
+ heavyParagraphs += paragraphs
168
+ if (typeof depth === 'number' && run > depth) filesPastDepth += 1
169
+
170
+ // A file below the measuring floor carries no cadence key at all, which is
171
+ // a file that was not measured rather than one with no flat paragraph.
172
+ const flat = asObject(entry.cadence)?.flat
173
+ if (typeof flat === 'number') flatParagraphs += flat
174
+ }
175
+
176
+ return { bans, heavyBullets, heavyParagraphs, filesPastDepth, flatParagraphs }
177
+ }
178
+
179
+ /** The finding arrays `aitk claude skills audit` publishes, in its own order. */
180
+ const SKILL_FINDINGS = [
181
+ 'missingRequirement',
182
+ 'nameMismatch',
183
+ 'missingDescription',
184
+ 'longDescription',
185
+ 'readme',
186
+ 'folderName',
187
+ 'requirementSections',
188
+ ] as const
189
+
190
+ function skillCounts(record: unknown): Record<string, number> | undefined {
191
+ const findings = asObject(asObject(record)?.findings)
192
+ if (findings === undefined) return undefined
193
+
194
+ return allOf(
195
+ Object.fromEntries(
196
+ SKILL_FINDINGS.map((key) => [key, lengthOf(findings[key])]),
197
+ ),
198
+ )
199
+ }
200
+
201
+ function boardCounts(record: unknown): Record<string, number> | undefined {
202
+ const root = asObject(record)
203
+ if (root === undefined) return undefined
204
+
205
+ // The board keeps its untested rows apart from its findings on purpose, so
206
+ // they move no exit code. Folding them together here would undo that.
207
+ return allOf({
208
+ findings: lengthOf(root.findings),
209
+ untested: lengthOf(root.untested),
210
+ })
211
+ }
212
+
213
+ function findingsOnly(record: unknown): Record<string, number> | undefined {
214
+ const root = asObject(record)
215
+ if (root === undefined) return undefined
216
+
217
+ return allOf({ findings: lengthOf(root.findings) })
218
+ }
219
+
220
+ function commentCounts(record: unknown): Record<string, number> | undefined {
221
+ const snapshot = asObject(record)?.snapshot
222
+ if (!Array.isArray(snapshot)) return undefined
223
+
224
+ let degradationHits = 0
225
+ for (const language of snapshot) {
226
+ const hits = lengthOf(asObject(language)?.degradationHits)
227
+ if (hits === undefined) return undefined
228
+ degradationHits += hits
229
+ }
230
+
231
+ return { degradationHits }
232
+ }
233
+
234
+ function testOrderCounts(record: unknown): Record<string, number> | undefined {
235
+ const root = asObject(record)
236
+ if (root === undefined) return undefined
237
+
238
+ // The unclassified count travels with the findings because it is the honest
239
+ // shape of this measure: a refactor and a new behavior cannot be told apart
240
+ // from history, and a findings count alone claims coverage it does not have.
241
+ return allOf({
242
+ findings: lengthOf(root.findings),
243
+ unclassified: lengthOf(root.unclassified),
244
+ })
245
+ }
246
+
247
+ /** The record kinds `aitk records validate` takes, and the corpus each reads. */
248
+ const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
249
+ ['plans', 'per-machine'],
250
+ ['groundwork', 'per-machine'],
251
+ ['intake', 'per-machine'],
252
+ ['memory', 'per-machine'],
253
+ ['standards', 'tracked'],
254
+ ['teach', 'per-machine'],
255
+ ]
256
+
257
+ /**
258
+ * Every audit the aggregate runs.
259
+ *
260
+ * `context`, `markdown`, and `skills` are the three that gate, which is exactly
261
+ * the set `scripts/core/verify.sh` already fails a push on. Adding a fourth
262
+ * here widens what fails a push without anyone deciding to, and the split this
263
+ * repository records gates a fact and reports a judgment.
264
+ *
265
+ * Each verb runs once in its fullest form. Running the gating half separately
266
+ * would walk the same tree twice for a number the full record already carries.
267
+ */
268
+ export const AUDITS: readonly AuditSpec[] = [
269
+ {
270
+ id: 'context',
271
+ label: 'Context folders',
272
+ argv: ['context', 'audit', '--json'],
273
+ gatingExits: [EXIT_FINDINGS],
274
+ corpus: 'tracked',
275
+ counts: contextCounts,
276
+ },
277
+ {
278
+ id: 'markdown',
279
+ label: 'Markdown corpus',
280
+ argv: ['markdown', 'audit', '--json'],
281
+ // 3 is the empty ban set, which is the corpus walked with nothing looked
282
+ // for. That is a broken check rather than a clean tree, so it fails here
283
+ // the way it already fails the push.
284
+ gatingExits: [EXIT_FINDINGS, 3],
285
+ corpus: 'tracked',
286
+ counts: markdownCounts,
287
+ },
288
+ {
289
+ id: 'skills',
290
+ label: 'Skill corpora',
291
+ argv: ['claude', 'skills', 'audit', '--json'],
292
+ gatingExits: [EXIT_FINDINGS],
293
+ corpus: 'tracked',
294
+ counts: skillCounts,
295
+ },
296
+ {
297
+ id: 'tasks',
298
+ label: 'Task board',
299
+ argv: ['tasks', 'validate', '--json'],
300
+ gatingExits: [],
301
+ corpus: 'per-machine',
302
+ counts: boardCounts,
303
+ },
304
+ ...RECORD_KINDS.map(([kind, corpus]) => ({
305
+ id: `records-${kind}`,
306
+ label: `Records: ${kind}`,
307
+ argv: ['records', 'validate', kind, '--json'],
308
+ gatingExits: [],
309
+ corpus,
310
+ counts: findingsOnly,
311
+ })),
312
+ {
313
+ id: 'comments',
314
+ label: 'Comment census',
315
+ argv: ['comments', 'scan', '--json'],
316
+ gatingExits: [],
317
+ corpus: 'tracked',
318
+ counts: commentCounts,
319
+ },
320
+ {
321
+ id: 'test-order',
322
+ label: 'Test order',
323
+ argv: ['gov', 'test-order', '--json'],
324
+ gatingExits: [],
325
+ corpus: 'tracked',
326
+ counts: testOrderCounts,
327
+ },
328
+ ]
329
+
330
+ export function auditFor(id: string): AuditSpec | undefined {
331
+ return AUDITS.find((audit) => audit.id === id)
332
+ }
333
+
334
+ export function isTracked(spec: AuditSpec): boolean {
335
+ return spec.corpus === 'tracked'
336
+ }
337
+
338
+ export function countsFor(
339
+ spec: AuditSpec,
340
+ record: unknown,
341
+ ): Record<string, number> | undefined {
342
+ return spec.counts(record)
343
+ }
344
+
345
+ /**
346
+ * Reads the reason a refusing verb published, so the aggregate names the cause
347
+ * rather than only the exit. Every command here puts it in `reason`, and the
348
+ * ones that also carry a sentence put that in `message`.
349
+ */
350
+ function refusalReason(record: unknown, exitCode: number): string {
351
+ const root = asObject(record)
352
+ const reason = typeof root?.reason === 'string' ? root.reason : undefined
353
+ const message = typeof root?.message === 'string' ? root.message : undefined
354
+
355
+ if (reason !== undefined && message !== undefined) {
356
+ return `${reason}: ${message}`
357
+ }
358
+ return reason ?? message ?? `exited ${exitCode} with no reason on stdout`
359
+ }
360
+
361
+ /**
362
+ * The reasons a verb publishes when the folder it reads is simply not there.
363
+ *
364
+ * Typed against the unions those two verbs export rather than spelled as bare
365
+ * strings, so renaming a reason at its source fails this build. A literal here
366
+ * would go on matching nothing and quietly turn every expected absence back
367
+ * into an unmeasured audit.
368
+ */
369
+ const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
370
+ 'no-folder',
371
+ 'no-board',
372
+ ]
373
+
374
+ /**
375
+ * Whether a refusal is a folder this machine never created rather than a break.
376
+ *
377
+ * Every gitignored record folder is absent on a fresh clone and in CI, so six
378
+ * of the twelve refuse there on every run. Counting those as unmeasured pins
379
+ * the verdict at `incomplete` forever, and a signal that never changes is one
380
+ * nobody reads after the second time they see it.
381
+ *
382
+ * A tracked corpus gets no such allowance. That tree ships to targets, so a
383
+ * checkout that cannot find it is broken, and reading the absence as ordinary
384
+ * would report a pass over a corpus nobody measured.
385
+ */
386
+ function isExpectedAbsence(spec: AuditSpec, record: unknown): boolean {
387
+ if (spec.corpus !== 'per-machine') return false
388
+
389
+ const reason = asObject(record)?.reason
390
+ return (
391
+ typeof reason === 'string' &&
392
+ (ABSENT_REASONS as readonly string[]).includes(reason)
393
+ )
394
+ }
395
+
396
+ /**
397
+ * Turns one verb's exit code and stdout into a result the aggregate can report.
398
+ *
399
+ * Unparseable output is `unmeasured` rather than clean, for the reason the
400
+ * verify pipeline already states about its own skipped stages: a run that does
401
+ * not report is a broken command, and skipping would report the pass the check
402
+ * exists to withhold.
403
+ */
404
+ export function classify(
405
+ spec: AuditSpec,
406
+ exitCode: number,
407
+ stdout: string,
408
+ ): AuditResult {
409
+ const base = {
410
+ id: spec.id,
411
+ label: spec.label,
412
+ tracked: isTracked(spec),
413
+ exitCode,
414
+ }
415
+
416
+ let record: unknown
417
+ let parsed = true
418
+ try {
419
+ record = JSON.parse(stdout)
420
+ } catch {
421
+ parsed = false
422
+ }
423
+
424
+ if (exitCode === EXIT_REFUSED) {
425
+ return {
426
+ ...base,
427
+ status: isExpectedAbsence(spec, record) ? 'absent' : 'unmeasured',
428
+ reason: parsed
429
+ ? refusalReason(record, exitCode)
430
+ : `refused with no record on stdout`,
431
+ }
432
+ }
433
+
434
+ const counts = parsed ? spec.counts(record) : undefined
435
+
436
+ if (spec.gatingExits.includes(exitCode)) {
437
+ return { ...base, status: 'finding', ...(counts && { counts }) }
438
+ }
439
+
440
+ if (exitCode !== 0 && exitCode !== EXIT_FINDINGS) {
441
+ return {
442
+ ...base,
443
+ status: 'unmeasured',
444
+ reason: `exited ${exitCode}, which this verb does not document`,
445
+ }
446
+ }
447
+
448
+ if (counts === undefined) {
449
+ return {
450
+ ...base,
451
+ status: 'unmeasured',
452
+ reason: parsed
453
+ ? 'the record did not carry the keys this audit reads'
454
+ : 'stdout carried no JSON record',
455
+ }
456
+ }
457
+
458
+ if (exitCode === EXIT_FINDINGS) {
459
+ return { ...base, status: 'reported', counts }
460
+ }
461
+
462
+ const quiet = Object.values(counts).every((value) => value === 0)
463
+ return { ...base, status: quiet ? 'clean' : 'reported', counts }
464
+ }
@@ -0,0 +1,202 @@
1
+ import { join } from 'node:path'
2
+ import { execa } from 'execa'
3
+ import type { Delta } from '@/audits/baseline'
4
+ import { type AuditResult, type AuditSpec, classify } from '@/audits/catalog'
5
+ import { gitEnv } from '@/git-env'
6
+ import { PROJECT_ROOT } from '@/project-root'
7
+
8
+ /** At least one audit reported a finding that is a fact. */
9
+ export const EXIT_FINDING = 2
10
+
11
+ /** At least one audit did not report, so the run measured less than the set. */
12
+ export const EXIT_UNMEASURED = 3
13
+
14
+ export type Verdict =
15
+ /** Every audit reported and every count it produced was zero. */
16
+ | 'clean'
17
+ /** Every audit reported and at least one carries a judgment finding. */
18
+ | 'reported'
19
+ /** At least one audit carries a finding that is a fact. */
20
+ | 'findings'
21
+ /** At least one audit did not report, so this is not a pass. */
22
+ | 'incomplete'
23
+
24
+ export interface SpawnResult {
25
+ readonly exitCode: number
26
+ readonly stdout: string
27
+ }
28
+
29
+ export type Spawn = (spec: AuditSpec) => Promise<SpawnResult>
30
+
31
+ /**
32
+ * Runs each verb out of the checkout this CLI is executing from.
33
+ *
34
+ * `process.execPath` and the resolved `cli.ts` rather than a bare `aitk`, for
35
+ * the reason `verify.sh` already names: a globally installed binary resolves to
36
+ * the main checkout no matter which worktree is running, so the aggregate would
37
+ * measure the wrong tree and report a pass over a branch it never read.
38
+ *
39
+ * `reject: false` because a findings exit is the ordinary outcome for half of
40
+ * these verbs, and a throw there would be an error path for a working check.
41
+ */
42
+ export function spawnAudit(root: string): Spawn {
43
+ const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
44
+
45
+ return async (spec) => {
46
+ const result = await execa(process.execPath, [cli, ...spec.argv], {
47
+ cwd: root,
48
+ reject: false,
49
+ // `gitEnv` already returns the whole ambient environment minus git's
50
+ // resolution variables, so it replaces the environment rather than
51
+ // extending it. Spreading `process.env` alongside would put back the very
52
+ // variables it strips, and a hook's `GIT_DIR` would then point every
53
+ // history-reading verb at a repository nobody asked about.
54
+ env: { ...gitEnv(), AITK_NON_INTERACTIVE: '1' },
55
+ extendEnv: false,
56
+ })
57
+
58
+ return { exitCode: result.exitCode ?? 1, stdout: result.stdout }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Runs every audit and reads each one's own record shape.
64
+ *
65
+ * The verbs walk separate trees and share no state, so they run together
66
+ * rather than in sequence. Serially, the aggregate would be the slowest thing
67
+ * in the verify pipeline for no reason beyond the order they were written in.
68
+ *
69
+ * A spawn that throws becomes an `unmeasured` result rather than a rejection.
70
+ * One absent binary would otherwise take the whole aggregate down and report
71
+ * nothing about the audits that did run.
72
+ */
73
+ export async function runAudits(
74
+ specs: readonly AuditSpec[],
75
+ spawn: Spawn,
76
+ ): Promise<AuditResult[]> {
77
+ return Promise.all(
78
+ specs.map(async (spec) => {
79
+ try {
80
+ const { exitCode, stdout } = await spawn(spec)
81
+ return classify(spec, exitCode, stdout)
82
+ } catch (error) {
83
+ return {
84
+ id: spec.id,
85
+ label: spec.label,
86
+ status: 'unmeasured' as const,
87
+ tracked: spec.corpus === 'tracked',
88
+ exitCode: 1,
89
+ reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
90
+ }
91
+ }
92
+ }),
93
+ )
94
+ }
95
+
96
+ /**
97
+ * The single verdict over the set.
98
+ *
99
+ * `incomplete` outranks a quiet set on purpose. An aggregate reporting a pass
100
+ * over a tree it did not finish measuring is the failure this whole command
101
+ * exists against, and an empty set takes it for the same reason: nothing ran,
102
+ * so nothing passed.
103
+ *
104
+ * An `absent` per-machine corpus does not reach it. That folder is gitignored
105
+ * and missing on every fresh clone, so folding it in would pin the verdict at
106
+ * `incomplete` on every CI run and leave the word meaning nothing.
107
+ */
108
+ export function verdictOf(results: readonly AuditResult[]): Verdict {
109
+ if (results.length === 0) return 'incomplete'
110
+ if (results.some((result) => result.status === 'finding')) return 'findings'
111
+ if (results.some((result) => result.status === 'unmeasured')) {
112
+ return 'incomplete'
113
+ }
114
+ if (results.some((result) => result.status === 'reported')) return 'reported'
115
+ return 'clean'
116
+ }
117
+
118
+ /**
119
+ * Exits non-zero only on a fact, which is the split this aggregate inherits
120
+ * rather than moves. A growing judgment count reports as a delta and fails
121
+ * nothing, because the standards behind those measures set no hard cap.
122
+ *
123
+ * An audit that did not report takes its own code rather than the findings one.
124
+ * The two mean opposite things to whoever reads the exit: a fact is a defect in
125
+ * the tree, and an unmeasured audit is a defect in the run.
126
+ */
127
+ export function exitCodeFor(results: readonly AuditResult[]): number {
128
+ const verdict = verdictOf(results)
129
+ if (verdict === 'findings') return EXIT_FINDING
130
+ if (verdict === 'incomplete') return EXIT_UNMEASURED
131
+ return 0
132
+ }
133
+
134
+ export interface Summary {
135
+ readonly verdict: Verdict
136
+ /** Audits that reported, out of the whole set. */
137
+ readonly audited: number
138
+ /** Audits carrying a finding that is a fact. */
139
+ readonly facts: number
140
+ /** Audits that did not report at all. */
141
+ readonly unmeasured: number
142
+ /**
143
+ * Audits whose per-machine folder is not on this disk.
144
+ *
145
+ * Published rather than folded into `audited`, so a run stating twelve
146
+ * audits never implies twelve corpora were read.
147
+ */
148
+ readonly absent: number
149
+ /** Tracked counts that rose against the recorded floor. */
150
+ readonly grown: number
151
+ /** Tracked counts that fell against the recorded floor. */
152
+ readonly shrunk: number
153
+ /** Tracked audits with no recorded floor to compare against. */
154
+ readonly unrecorded: number
155
+ }
156
+
157
+ /**
158
+ * The flat reading a shell stage takes without parsing the nested record.
159
+ *
160
+ * Published rather than left to a consumer to derive, for the reason the
161
+ * context audit already gives about its own join: deriving it means restating
162
+ * which question each number answers, and one wrong restatement is growth
163
+ * reported against a measure that never moved. Every key here is unique across
164
+ * the whole record, so a grep for one reaches the top level alone.
165
+ */
166
+ export function summarize(
167
+ results: readonly AuditResult[],
168
+ deltas: readonly Delta[],
169
+ ): Summary {
170
+ let grown = 0
171
+ let shrunk = 0
172
+ let unrecorded = 0
173
+
174
+ for (const delta of deltas) {
175
+ if (delta.kind === 'unrecorded') {
176
+ unrecorded += 1
177
+ continue
178
+ }
179
+ if (delta.kind !== 'compared') continue
180
+
181
+ for (const moved of delta.moved) {
182
+ if (moved.delta > 0) grown += 1
183
+ else shrunk += 1
184
+ }
185
+ }
186
+
187
+ const counting = (status: AuditResult['status']) =>
188
+ results.filter((result) => result.status === status).length
189
+
190
+ return {
191
+ verdict: verdictOf(results),
192
+ audited: results.filter(
193
+ (result) => result.status !== 'unmeasured' && result.status !== 'absent',
194
+ ).length,
195
+ facts: counting('finding'),
196
+ unmeasured: counting('unmeasured'),
197
+ absent: counting('absent'),
198
+ grown,
199
+ shrunk,
200
+ unrecorded,
201
+ }
202
+ }
package/src/cli.ts CHANGED
@@ -27,6 +27,7 @@ import { register as context } from '@/commands/context'
27
27
  import { register as markdown } from '@/commands/markdown'
28
28
  import { register as records } from '@/commands/records'
29
29
  import { register as sessions } from '@/commands/sessions'
30
+ import { register as audits } from '@/commands/audits'
30
31
  import { PROJECT_ROOT } from '@/project-root'
31
32
 
32
33
  const GREY = '\x1b[0;90m'
@@ -63,6 +64,7 @@ function showHelp(): void {
63
64
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
64
65
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
65
66
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
67
+ `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
66
68
  `${GREY}│${NC}`,
67
69
  `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
68
70
  `${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
@@ -101,6 +103,7 @@ function showHelp(): void {
101
103
  `${GREY}│${NC} aitk records validate plans`,
102
104
  `${GREY}│${NC} aitk records push --json`,
103
105
  `${GREY}│${NC} aitk sessions list --json`,
106
+ `${GREY}│${NC} aitk audits run --json`,
104
107
  `${GREY}└${NC}`,
105
108
  ]
106
109
  console.log(lines.join('\n'))
@@ -159,5 +162,6 @@ context(program)
159
162
  markdown(program)
160
163
  records(program)
161
164
  sessions(program)
165
+ audits(program)
162
166
 
163
167
  program.parse()