@erclx/aitk 0.109.0 → 0.111.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.
package/src/cli.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { readFileSync } from 'node:fs'
4
- import { join } from 'node:path'
5
3
  import { Command } from 'commander'
6
4
  import { register as init } from '@/commands/init'
7
5
  import { register as sandbox } from '@/commands/sandbox'
@@ -27,7 +25,9 @@ import { register as context } from '@/commands/context'
27
25
  import { register as markdown } from '@/commands/markdown'
28
26
  import { register as records } from '@/commands/records'
29
27
  import { register as sessions } from '@/commands/sessions'
30
- import { PROJECT_ROOT } from '@/project-root'
28
+ import { register as audits } from '@/commands/audits'
29
+ import { register as upgrade } from '@/commands/upgrade'
30
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
31
31
 
32
32
  const GREY = '\x1b[0;90m'
33
33
  const WHITE = '\x1b[1;37m'
@@ -63,6 +63,8 @@ function showHelp(): void {
63
63
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
64
64
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
65
65
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
66
+ `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
67
+ `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
66
68
  `${GREY}│${NC}`,
67
69
  `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
68
70
  `${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
@@ -101,31 +103,17 @@ 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`,
107
+ `${GREY}│${NC} aitk upgrade --json`,
104
108
  `${GREY}└${NC}`,
105
109
  ]
106
110
  console.log(lines.join('\n'))
107
111
  }
108
112
 
109
- /**
110
- * Read at runtime rather than inlined, because a literal here is a second place
111
- * the version lives and it stopped tracking `package.json` at `0.1.0`. The
112
- * release tool writes one file and this follows it. `package.json` ships in
113
- * every npm tarball regardless of the `files` list, so the read resolves from a
114
- * registry install as well as from a clone.
115
- */
116
- function readVersion(): string {
117
- try {
118
- const raw = readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')
119
- return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
120
- } catch {
121
- return 'unknown'
122
- }
123
- }
124
-
125
113
  const program = new Command()
126
114
  program
127
115
  .name('aitk')
128
- .version(readVersion())
116
+ .version(readInstalled().version ?? UNKNOWN_LABEL)
129
117
  .enablePositionalOptions()
130
118
  .helpOption(false)
131
119
  program.action(() => showHelp())
@@ -159,5 +147,7 @@ context(program)
159
147
  markdown(program)
160
148
  records(program)
161
149
  sessions(program)
150
+ audits(program)
151
+ upgrade(program)
162
152
 
163
153
  program.parse()
@@ -0,0 +1,342 @@
1
+ import { execa } from 'execa'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ BASELINE_REL,
5
+ type Baseline,
6
+ baselineFrom,
7
+ compareBaseline,
8
+ type Delta,
9
+ readBaseline,
10
+ writeBaseline,
11
+ } from '@/audits/baseline'
12
+ import { AUDITS, type AuditResult } from '@/audits/catalog'
13
+ import {
14
+ exitCodeFor,
15
+ runAudits,
16
+ spawnAudit,
17
+ type Summary,
18
+ summarize,
19
+ } from '@/audits/run'
20
+ import { gitEnv } from '@/git-env'
21
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
22
+ import { currentWorktreeRoot } from '@/worktree'
23
+
24
+ interface RunCommandOptions {
25
+ readonly json?: boolean
26
+ readonly record?: boolean
27
+ readonly root?: string
28
+ }
29
+
30
+ interface ListCommandOptions {
31
+ readonly json?: boolean
32
+ }
33
+
34
+ export function register(program: Command): void {
35
+ const audits = program
36
+ .command('audits')
37
+ .description('Run every health check this repository owns as one set')
38
+ .helpOption('-h, --help', 'Show this help message')
39
+
40
+ audits
41
+ .command('run')
42
+ .description(
43
+ 'Run every audit, report per check under one verdict, and compare each count to the recorded baseline',
44
+ )
45
+ .helpOption('-h, --help', 'Show this help message')
46
+ .option('--json', 'Add a machine-readable record on stdout')
47
+ .option(
48
+ '--root <path>',
49
+ 'Tree to measure, defaulting to the current worktree',
50
+ )
51
+ .option(
52
+ '--record',
53
+ `Write this run's tracked counts to ${BASELINE_REL} as the new baseline`,
54
+ )
55
+ .addHelpText(
56
+ 'after',
57
+ [
58
+ '',
59
+ 'Exit codes:',
60
+ ' 0 every audit reported and none carried a finding that is a fact',
61
+ ' 1 refused, with the reason on stderr',
62
+ ' 2 an audit carries a finding that is a fact',
63
+ ' 3 an audit did not report, so the run measured less than the set',
64
+ '',
65
+ 'It gates on exactly what already gates a push and on nothing new:',
66
+ 'an unresolved context citation, a banned character, word, or spelling,',
67
+ 'and a skill folder carrying no REQUIREMENT.md. Every other measure is a',
68
+ 'judgment a reader settles, and failing a push on one teaches',
69
+ 'contributors to route around the stage.',
70
+ '',
71
+ 'Exit 3 is a defect in the run rather than in the tree. An aggregate that',
72
+ 'reports a pass over a set it did not finish measuring is the failure this',
73
+ 'command exists against, so a verb that did not report takes its own code.',
74
+ '',
75
+ `The baseline at ${BASELINE_REL} holds the counts from the last recorded`,
76
+ 'run, so a measure that reports rather than gates still costs something',
77
+ 'when it grows. Only a tracked corpus is retained: a gitignored record',
78
+ "folder holds one machine's scratch, and its counts answer nobody else.",
79
+ '',
80
+ 'Examples:',
81
+ ' aitk audits run',
82
+ ' aitk audits run --json',
83
+ ' aitk audits run --record',
84
+ '',
85
+ ].join('\n'),
86
+ )
87
+ .action(async (opts: RunCommandOptions) => {
88
+ process.exitCode = await runAll(opts)
89
+ })
90
+
91
+ audits
92
+ .command('list')
93
+ .description(
94
+ 'List every audit this command runs and what each one gates on',
95
+ )
96
+ .helpOption('-h, --help', 'Show this help message')
97
+ .option('--json', 'Emit JSON with the id, invocation, corpus, and gate')
98
+ .addHelpText(
99
+ 'after',
100
+ [
101
+ '',
102
+ 'Exit codes:',
103
+ ' 0 the catalog was listed',
104
+ '',
105
+ 'Examples:',
106
+ ' aitk audits list',
107
+ ' aitk audits list --json',
108
+ '',
109
+ ].join('\n'),
110
+ )
111
+ .action((opts: ListCommandOptions) => {
112
+ process.exitCode = runList(opts)
113
+ })
114
+ }
115
+
116
+ function runList(opts: ListCommandOptions): number {
117
+ if (opts.json) {
118
+ process.stdout.write(
119
+ `${JSON.stringify({
120
+ audits: AUDITS.map((audit) => ({
121
+ id: audit.id,
122
+ label: audit.label,
123
+ command: `aitk ${audit.argv.join(' ')}`,
124
+ corpus: audit.corpus,
125
+ gates: audit.gatingExits.length > 0,
126
+ })),
127
+ })}\n`,
128
+ )
129
+ return 0
130
+ }
131
+
132
+ intro('aitk audits list')
133
+ for (const audit of AUDITS) {
134
+ logStep(audit.label)
135
+ logInfo(`aitk ${audit.argv.join(' ')}`)
136
+ logInfo(
137
+ `${audit.corpus} corpus, ${audit.gatingExits.length > 0 ? 'gates on a fact' : 'reports only'}`,
138
+ )
139
+ }
140
+ outro()
141
+ return 0
142
+ }
143
+
144
+ /**
145
+ * The day a record is stamped with, as `YYYY-MM-DD`.
146
+ *
147
+ * Local rather than UTC, because the stamp is read beside a commit date in a
148
+ * context entry and a run taken in the evening should not record tomorrow.
149
+ */
150
+ function today(): string {
151
+ const now = new Date()
152
+ return [
153
+ now.getFullYear(),
154
+ String(now.getMonth() + 1).padStart(2, '0'),
155
+ String(now.getDate()).padStart(2, '0'),
156
+ ].join('-')
157
+ }
158
+
159
+ /**
160
+ * The commit the counts were read at, or `unknown` outside a repository.
161
+ *
162
+ * A target project can install this CLI without a git history behind it, and
163
+ * refusing there would withhold the whole report over a field that only makes
164
+ * the record reproducible.
165
+ */
166
+ async function headCommit(root: string): Promise<string> {
167
+ const result = await execa('git', ['-C', root, 'rev-parse', 'HEAD'], {
168
+ reject: false,
169
+ env: gitEnv(),
170
+ extendEnv: false,
171
+ })
172
+ return result.exitCode === 0 ? result.stdout.trim() : 'unknown'
173
+ }
174
+
175
+ async function runAll(opts: RunCommandOptions): Promise<number> {
176
+ const emitJson = opts.json ?? false
177
+ const root = opts.root ?? (await currentWorktreeRoot())
178
+
179
+ let baseline: Baseline | undefined
180
+ try {
181
+ baseline = await readBaseline(root)
182
+ } catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error)
184
+ if (emitJson) {
185
+ process.stderr.write(`${message}\n`)
186
+ process.stdout.write(
187
+ `${JSON.stringify({ ok: false, reason: 'bad-baseline', message })}\n`,
188
+ )
189
+ return 1
190
+ }
191
+ intro('aitk audits run')
192
+ logStep('Refused')
193
+ logError(message)
194
+ outro()
195
+ return 1
196
+ }
197
+
198
+ const results = await runAudits(AUDITS, spawnAudit(root))
199
+ const deltas = compareBaseline(baseline, results)
200
+ const summary = summarize(results, deltas)
201
+
202
+ let recorded: string | undefined
203
+ if (opts.record) {
204
+ const next = baselineFrom(results, {
205
+ recordedAt: today(),
206
+ commit: await headCommit(root),
207
+ })
208
+ recorded = await writeBaseline(root, next)
209
+ }
210
+
211
+ if (emitJson) {
212
+ process.stdout.write(
213
+ `${JSON.stringify({
214
+ ok: true,
215
+ root,
216
+ // Flat scalars, so a shell stage greps one out without a JSON parser.
217
+ // The nested arrays below carry the detail behind each number.
218
+ summary,
219
+ baseline: baseline
220
+ ? { recordedAt: baseline.recordedAt, commit: baseline.commit }
221
+ : undefined,
222
+ recorded,
223
+ audits: results,
224
+ deltas,
225
+ })}\n`,
226
+ )
227
+ } else {
228
+ report(results, deltas, baseline, recorded, summary)
229
+ }
230
+
231
+ return exitCodeFor(results)
232
+ }
233
+
234
+ /** The counts of one result, rendered as `key n` pairs a reader can scan. */
235
+ function countLine(counts: Record<string, number>): string {
236
+ return Object.entries(counts)
237
+ .map(([key, value]) => `${key} ${value}`)
238
+ .join(', ')
239
+ }
240
+
241
+ /**
242
+ * The delta line for one audit, or nothing when there is no comparison to
243
+ * report. A first run says so rather than showing a delta of zero, since zero
244
+ * against an absent baseline says the same as a corpus that did not move.
245
+ */
246
+ function deltaLine(delta: Delta): string | undefined {
247
+ if (delta.kind === 'per-machine' || delta.kind === 'unmeasured') {
248
+ return undefined
249
+ }
250
+ if (delta.kind === 'unrecorded') return 'No recorded baseline to compare'
251
+
252
+ const parts = [
253
+ ...delta.moved.map(
254
+ ({ key, from, to, delta: moved }) =>
255
+ `${key} ${from} to ${to} (${moved > 0 ? '+' : ''}${moved})`,
256
+ ),
257
+ ...delta.added.map(({ key, to }) => `${key} ${to}, newly measured`),
258
+ ...delta.dropped.map(({ key, from }) => `${key} was ${from}, not measured`),
259
+ ]
260
+
261
+ return parts.length === 0 ? undefined : parts.join(', ')
262
+ }
263
+
264
+ function report(
265
+ results: readonly AuditResult[],
266
+ deltas: readonly Delta[],
267
+ baseline: Baseline | undefined,
268
+ recorded: string | undefined,
269
+ summary: Summary,
270
+ ): void {
271
+ const byId = new Map(deltas.map((delta) => [delta.id, delta]))
272
+
273
+ intro('aitk audits run')
274
+
275
+ logStep('Baseline')
276
+ if (baseline === undefined) {
277
+ logWarn(`None recorded. Take one with aitk audits run --record.`)
278
+ } else {
279
+ logInfo(`${baseline.recordedAt} at ${baseline.commit.slice(0, 8)}`)
280
+ }
281
+
282
+ for (const result of results) {
283
+ logStep(result.label)
284
+
285
+ if (result.status === 'absent') {
286
+ logInfo(`No corpus on this machine: ${result.reason ?? 'not found'}`)
287
+ continue
288
+ }
289
+
290
+ if (result.status === 'unmeasured') {
291
+ logWarn(`Did not report: ${result.reason ?? 'no reason given'}`)
292
+ continue
293
+ }
294
+
295
+ const counts = result.counts ?? {}
296
+ const line = countLine(counts)
297
+ if (result.status === 'finding') {
298
+ logError(line === '' ? 'A finding that is a fact' : line)
299
+ } else {
300
+ logInfo(line === '' ? 'Reported' : line)
301
+ }
302
+
303
+ if (!result.tracked) {
304
+ logInfo('Per-machine corpus, so no baseline is kept')
305
+ continue
306
+ }
307
+
308
+ const moved = deltaLine(
309
+ byId.get(result.id) ?? { id: result.id, kind: 'unrecorded' },
310
+ )
311
+ if (moved !== undefined) logInfo(moved)
312
+ }
313
+
314
+ if (recorded !== undefined) {
315
+ logStep('Recorded')
316
+ logInfo(recorded)
317
+ }
318
+
319
+ logStep('Verdict')
320
+
321
+ if (summary.verdict === 'findings') {
322
+ logError(
323
+ `${plural(summary.facts, 'audit')} carrying a finding that is a fact. Fix what each names.`,
324
+ )
325
+ } else if (summary.verdict === 'incomplete') {
326
+ logError(
327
+ `${plural(summary.unmeasured, 'audit')} did not report, so this run measured less than the set.`,
328
+ )
329
+ } else if (summary.verdict === 'reported') {
330
+ logInfo('No fact. Every other finding is a judgment a reader settles.')
331
+ } else {
332
+ logInfo('Every audit reported and every count is zero.')
333
+ }
334
+
335
+ // Stated on every run, including a clean one. A count of what passed reads as
336
+ // a verdict on the whole set unless the run also says what it never reached.
337
+ logInfo(
338
+ `${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent on this machine`,
339
+ )
340
+
341
+ outro()
342
+ }
@@ -48,6 +48,7 @@ import {
48
48
  plural,
49
49
  select,
50
50
  } from '@/ui'
51
+ import { describeSkew, readSkew, type SkewReport } from '@/version/skew'
51
52
 
52
53
  const GREEN = '\x1b[0;32m'
53
54
  const GREY = '\x1b[0;90m'
@@ -249,14 +250,18 @@ export function register(program: Command): void {
249
250
  'copy. Passing a ref older than the oldest load over-reports, which is',
250
251
  'the safe direction. Confirm a name by reading the body.',
251
252
  '',
253
+ 'Every run also reports the installed version against the newest',
254
+ 'published one. That report never changes the exit code, so an offline',
255
+ 'machine reads it as unknown rather than as a failure.',
256
+ '',
252
257
  'Examples:',
253
258
  ' aitk claude skills drift HEAD~20',
254
259
  ' aitk claude skills drift 02d7b265 --json',
255
260
  '',
256
261
  ].join('\n'),
257
262
  )
258
- .action((ref: string, opts: SkillsDriftOptions) => {
259
- process.exitCode = runSkillsDrift(ref, opts)
263
+ .action(async (ref: string, opts: SkillsDriftOptions) => {
264
+ process.exitCode = await runSkillsDrift(ref, opts)
260
265
  })
261
266
  }
262
267
 
@@ -506,16 +511,28 @@ function runSkillsList(opts: SkillsListOptions): number {
506
511
  * it answers every run with nothing moved, which is the silence this reports
507
512
  * against.
508
513
  */
509
- function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
514
+ async function runSkillsDrift(
515
+ ref: string,
516
+ opts: SkillsDriftOptions,
517
+ ): Promise<number> {
510
518
  const root = process.cwd()
511
519
  const report = readDrift(root, ref)
520
+ const skew = await readSkew()
512
521
 
513
522
  if (report.kind === 'measured') {
514
523
  intro('aitk claude skills drift')
524
+ reportSkew(skew)
515
525
  reportDrift(report, ref)
516
526
  outro()
517
527
  } else {
518
528
  frameError(report.reason)
529
+ // The refusal path names the binary too. A project consuming the plugin
530
+ // from a marketplace cache is refused here for having no history, and that
531
+ // is the moment a skew warning is worth most, since an old binary is one
532
+ // reason the cache and the CLI disagree in the first place.
533
+ if (skew.state === 'behind') {
534
+ process.stderr.write(`${GREY}${describeSkew(skew)}${NC}\n`)
535
+ }
519
536
  }
520
537
 
521
538
  if (opts.json) {
@@ -528,8 +545,9 @@ function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
528
545
  base: report.base,
529
546
  head: report.head,
530
547
  moved: report.moved,
548
+ skew,
531
549
  }
532
- : { root, ref, unreadable: report.reason },
550
+ : { root, ref, unreadable: report.reason, skew },
533
551
  )}\n`,
534
552
  )
535
553
  }
@@ -537,6 +555,18 @@ function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
537
555
  return report.kind === 'measured' ? 0 : 1
538
556
  }
539
557
 
558
+ /**
559
+ * The binary reports before the range does, for the reason the range section
560
+ * states about itself: the command answers what changed on disk, and a binary
561
+ * behind the published one is a second way the tree a session reads differs
562
+ * from the tree it holds.
563
+ */
564
+ function reportSkew(skew: SkewReport): void {
565
+ logStep('Toolkit version')
566
+ if (skew.state === 'behind') logWarn(describeSkew(skew))
567
+ else logInfo(describeSkew(skew))
568
+ }
569
+
540
570
  /**
541
571
  * States the bound on every run, including the run that names nothing. A report
542
572
  * listing only what moved reads as a verdict on what a session holds, and the
@@ -30,6 +30,7 @@ import {
30
30
  logWarn,
31
31
  outro,
32
32
  } from '@/ui'
33
+ import { describeSkew } from '@/version/skew'
33
34
 
34
35
  const GREY = '\x1b[0;90m'
35
36
  const YELLOW = '\x1b[0;33m'
@@ -100,6 +101,8 @@ async function runCheck(target: string, options: SyncOptions): Promise<number> {
100
101
  function renderCheck(report: CheckReport): void {
101
102
  intro('aitk sync --check')
102
103
 
104
+ renderSkew(report)
105
+
103
106
  if (!report.managed) {
104
107
  logStep('Not a toolkit project')
105
108
  logWarn('No .claude/ directory and no CLAUDE.md at the target.')
@@ -182,6 +185,24 @@ function renderCheck(report: CheckReport): void {
182
185
  )
183
186
  }
184
187
 
188
+ /**
189
+ * The binary reports before any domain does, since a stale binary is what makes
190
+ * every section below it a reading from the wrong toolkit. It prints on all
191
+ * three states rather than only when behind: a check that goes quiet when the
192
+ * registry is unreachable is indistinguishable from one that found nothing.
193
+ */
194
+ function renderSkew(report: CheckReport): void {
195
+ const { skew } = report
196
+ logStep('toolkit version')
197
+
198
+ if (skew.state === 'behind') {
199
+ logWarn(describeSkew(skew))
200
+ return
201
+ }
202
+
203
+ logInfo(describeSkew(skew))
204
+ }
205
+
185
206
  /**
186
207
  * Tooling prints whether it was measured before it prints any count, because a
187
208
  * target with no chain recorded produces the same zero a current target does.