@erclx/aitk 0.110.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/README.md CHANGED
@@ -38,11 +38,15 @@ Nothing refreshes on its own. Claude Code ships auto-update off for third-party
38
38
  ```bash
39
39
  claude plugin marketplace update aitk
40
40
  claude plugin update aitk@aitk
41
- bun install --global @erclx/aitk
41
+ aitk upgrade
42
42
  ```
43
43
 
44
44
  The first two update the skills, the third updates the CLI, and they move independently. Restart Claude Code, or run `/reload-plugins`, to pick the skills up.
45
45
 
46
+ `aitk upgrade` reads the package manager off its own install path and reinstalls with that one, so you don't have to remember which put it there. It names what it detected before it runs anything, and it refuses a source checkout rather than reinstalling over your clone.
47
+
48
+ You don't have to wait until something breaks to find out you're behind. `aitk sync --check` and `aitk claude skills drift` both report the installed version against the newest published one, and neither changes its exit code over it, so an offline machine reads unknown rather than red.
49
+
46
50
  To stop doing this by hand, turn auto-update on once under `/plugin` in the Marketplaces tab. Confirm what you are running with `aitk --version` and `claude plugin list`.
47
51
 
48
52
  ## Why
@@ -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.110.0",
4
+ "version": "0.111.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -13,7 +13,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
13
13
  | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
14
14
  | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
15
15
  | `aitk sync [path]` | Sync all installed domains in a target project |
16
- | `aitk sync --check` | Report toolkit drift without writing (`--json`, `--exit-code`) |
16
+ | `aitk sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
17
17
  | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
18
  | `aitk sandbox reset` | Reset sandbox to baseline |
19
19
  | `aitk sandbox clean` | Wipe the sandbox |
@@ -44,11 +44,12 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
44
44
  | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
45
45
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
46
46
  | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
47
- | `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD` (`--json`) |
47
+ | `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`) |
48
48
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
49
49
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
50
50
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
51
51
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
52
+ | `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
52
53
 
53
54
  ## Domain commands
54
55
 
@@ -78,3 +79,17 @@ Common patterns:
78
79
  - `install <name> <path>` → install a specific entry into a target project.
79
80
  - `sync <path>` → reapply all installed entries in a target project.
80
81
  - `create [name]` → scaffold a new authoring entry in this repo.
82
+
83
+ ## Version skew
84
+
85
+ `aitk sync --check` and `aitk claude skills drift` are the two moments a target
86
+ already stops to reconcile with the toolkit, so each reports the installed
87
+ version against the newest published one. No other command performs the lookup,
88
+ which keeps a registry round trip out of the catalog reads an agent runs in a
89
+ loop.
90
+
91
+ The report carries three states and never changes an exit code. `behind` names
92
+ `aitk upgrade` as the remedy, `current` says so, and `unknown` carries the
93
+ reason the registry could not be reached. Branch on the `skew.state` field in
94
+ the JSON record rather than on the exit, since an offline machine has to read as
95
+ unmeasured rather than as a failing check.
@@ -114,6 +114,12 @@ When the toolkit updates, target projects pull changes per domain. There is one
114
114
 
115
115
  `aitk sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
116
116
 
117
+ #### The binary first
118
+
119
+ The report opens by naming the binary running it. The installed version reads against the newest published one, and a version behind that points you at `aitk upgrade`, since every section under it is a reading taken by whichever toolkit you happen to have. An unreachable registry reports unknown with its reason rather than failing, so `--exit-code` still gates on the drift the check measured locally and an offline machine stays green.
120
+
121
+ #### Then the causes
122
+
117
123
  A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
118
124
 
119
125
  That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes. Standards, snippets, and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.110.0",
4
+ "version": "0.111.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
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'
@@ -28,7 +26,8 @@ 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
28
  import { register as audits } from '@/commands/audits'
31
- import { PROJECT_ROOT } from '@/project-root'
29
+ import { register as upgrade } from '@/commands/upgrade'
30
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
32
31
 
33
32
  const GREY = '\x1b[0;90m'
34
33
  const WHITE = '\x1b[1;37m'
@@ -65,6 +64,7 @@ function showHelp(): void {
65
64
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
66
65
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
67
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}`,
68
68
  `${GREY}│${NC}`,
69
69
  `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
70
70
  `${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
@@ -104,31 +104,16 @@ function showHelp(): void {
104
104
  `${GREY}│${NC} aitk records push --json`,
105
105
  `${GREY}│${NC} aitk sessions list --json`,
106
106
  `${GREY}│${NC} aitk audits run --json`,
107
+ `${GREY}│${NC} aitk upgrade --json`,
107
108
  `${GREY}└${NC}`,
108
109
  ]
109
110
  console.log(lines.join('\n'))
110
111
  }
111
112
 
112
- /**
113
- * Read at runtime rather than inlined, because a literal here is a second place
114
- * the version lives and it stopped tracking `package.json` at `0.1.0`. The
115
- * release tool writes one file and this follows it. `package.json` ships in
116
- * every npm tarball regardless of the `files` list, so the read resolves from a
117
- * registry install as well as from a clone.
118
- */
119
- function readVersion(): string {
120
- try {
121
- const raw = readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')
122
- return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
123
- } catch {
124
- return 'unknown'
125
- }
126
- }
127
-
128
113
  const program = new Command()
129
114
  program
130
115
  .name('aitk')
131
- .version(readVersion())
116
+ .version(readInstalled().version ?? UNKNOWN_LABEL)
132
117
  .enablePositionalOptions()
133
118
  .helpOption(false)
134
119
  program.action(() => showHelp())
@@ -163,5 +148,6 @@ markdown(program)
163
148
  records(program)
164
149
  sessions(program)
165
150
  audits(program)
151
+ upgrade(program)
166
152
 
167
153
  program.parse()
@@ -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.
@@ -0,0 +1,231 @@
1
+ import type { Command } from 'commander'
2
+ import { execa } from 'execa'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
+ import {
5
+ frameError,
6
+ intro,
7
+ logInfo,
8
+ logStep,
9
+ logWarn,
10
+ outro,
11
+ select,
12
+ } from '@/ui'
13
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
14
+ import { detectManager, installCommand, type Manager } from '@/version/manager'
15
+ import {
16
+ describeSkew,
17
+ latestOf,
18
+ readSkew,
19
+ type SkewReport,
20
+ } from '@/version/skew'
21
+
22
+ interface UpgradeOptions {
23
+ readonly json?: boolean
24
+ }
25
+
26
+ interface UpgradeRecord {
27
+ readonly root: string
28
+ readonly manager?: string
29
+ readonly command?: string
30
+ readonly before: string
31
+ readonly after?: string
32
+ readonly latest?: string
33
+ readonly state: 'upgraded' | 'current' | 'cancelled' | 'refused'
34
+ readonly reason?: string
35
+ }
36
+
37
+ export function register(program: Command): void {
38
+ program
39
+ .command('upgrade')
40
+ .description(
41
+ 'Reinstall the CLI globally with whichever package manager installed it',
42
+ )
43
+ .helpOption('-h, --help', 'Show this help message')
44
+ .option('--json', 'Add a machine-readable record on stdout')
45
+ .addHelpText(
46
+ 'after',
47
+ [
48
+ '',
49
+ 'Exit codes:',
50
+ ' 0 the binary is current, the reinstall completed, or it was cancelled',
51
+ ' 1 refused or the reinstall failed, with the reason on stderr',
52
+ '',
53
+ 'The package manager is read off the install path rather than guessed',
54
+ 'from PATH, and named before anything runs. A source checkout matches no',
55
+ 'install tree and is refused rather than reinstalled over.',
56
+ '',
57
+ 'Examples:',
58
+ ' aitk upgrade',
59
+ ' aitk upgrade --json',
60
+ '',
61
+ ].join('\n'),
62
+ )
63
+ .action(async (opts: UpgradeOptions) => {
64
+ process.exitCode = await runUpgrade(opts)
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Detection runs before the registry lookup so the one case that cannot upgrade
70
+ * at all, a source checkout, refuses without waiting on a network call whose
71
+ * answer it would then discard.
72
+ */
73
+ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
74
+ const installed = readInstalled()
75
+ const before = installed.version ?? UNKNOWN_LABEL
76
+
77
+ intro('aitk upgrade')
78
+ logStep('Installed')
79
+ logInfo(`${installed.name ?? UNKNOWN_LABEL} ${before}`)
80
+ logInfo(PROJECT_ROOT)
81
+
82
+ const manager = detectManager(PROJECT_ROOT)
83
+ if (manager === undefined) {
84
+ return refuse(
85
+ opts,
86
+ before,
87
+ `No package manager owns ${PROJECT_ROOT}. A source checkout is upgraded by pulling, not by reinstalling over it.`,
88
+ )
89
+ }
90
+
91
+ // A manifest that did not parse, or carried no `name`, would otherwise reach
92
+ // `installCommand` and produce a global install of whatever sits under that
93
+ // placeholder on the registry. The prompt below defaults to yes headlessly,
94
+ // so nothing downstream would stop it.
95
+ if (installed.name === undefined) {
96
+ return refuse(
97
+ opts,
98
+ before,
99
+ `No package name in ${PROJECT_ROOT}/package.json, so there is nothing safe to reinstall. Repair the manifest, or reinstall by name yourself.`,
100
+ manager,
101
+ )
102
+ }
103
+
104
+ const command = installCommand(manager.id, installed.name)
105
+ logStep('Detected')
106
+ logInfo(`${manager.id}, from the \`${manager.evidence}\` path segment`)
107
+ logInfo(command.join(' '))
108
+
109
+ const skew = await readSkew({ installed })
110
+ logStep('Published')
111
+ logInfo(describeSkew(skew))
112
+
113
+ if (skew.state === 'current') {
114
+ outro()
115
+ emit(opts, {
116
+ ...base(before, manager, command, skew),
117
+ after: before,
118
+ state: 'current',
119
+ })
120
+ return 0
121
+ }
122
+
123
+ return await applyUpgrade(opts, before, skew, manager, command)
124
+ }
125
+
126
+ /**
127
+ * Runs the reinstall and reads the version back off disk rather than trusting
128
+ * the manager's own report, since each spells success differently and one of
129
+ * them exits zero on a no-op. The read is the same `package.json` the CLI names
130
+ * on `--version`, which the install has overwritten by this point.
131
+ *
132
+ * An `unknown` skew reaches here rather than stopping. The operator asked for
133
+ * the reinstall, and the manager reports its own network failure in terms the
134
+ * dist-tag endpoint cannot.
135
+ */
136
+ async function applyUpgrade(
137
+ opts: UpgradeOptions,
138
+ before: string,
139
+ skew: SkewReport,
140
+ manager: Manager,
141
+ command: readonly string[],
142
+ ): Promise<number> {
143
+ const proceed = await select({
144
+ message: `Run \`${command.join(' ')}\`?`,
145
+ options: [
146
+ { value: true, label: 'Upgrade' },
147
+ { value: false, label: 'Cancel' },
148
+ ],
149
+ nonInteractiveDefault: true,
150
+ })
151
+
152
+ if (!proceed) {
153
+ logWarn('Cancelled')
154
+ outro()
155
+ emit(opts, { ...base(before, manager, command, skew), state: 'cancelled' })
156
+ return 0
157
+ }
158
+
159
+ logStep('Upgrading')
160
+ const [bin, ...args] = command
161
+ // The manager's own progress goes to stderr with the rest of the UI, leaving
162
+ // stdout carrying nothing but the record. Inheriting all three streams would
163
+ // put npm's output ahead of the JSON and break every wrapper parsing it.
164
+ const result = await execa(bin, args, {
165
+ reject: false,
166
+ stdin: 'inherit',
167
+ stdout: process.stderr,
168
+ stderr: 'inherit',
169
+ })
170
+
171
+ if (result.exitCode !== 0) {
172
+ return refuse(
173
+ opts,
174
+ before,
175
+ `\`${command.join(' ')}\` exited ${result.exitCode}. Run it yourself to read what it reported.`,
176
+ manager,
177
+ )
178
+ }
179
+
180
+ const after = readInstalled().version ?? UNKNOWN_LABEL
181
+ logStep('Installed')
182
+ logInfo(after === before ? `${after}, unchanged` : `${before} to ${after}`)
183
+ outro()
184
+
185
+ emit(opts, {
186
+ ...base(before, manager, command, skew),
187
+ after,
188
+ state: 'upgraded',
189
+ })
190
+ return 0
191
+ }
192
+
193
+ function base(
194
+ before: string,
195
+ manager: Manager,
196
+ command: readonly string[],
197
+ skew: SkewReport,
198
+ ): Omit<UpgradeRecord, 'state'> {
199
+ const latest = latestOf(skew)
200
+
201
+ return {
202
+ root: PROJECT_ROOT,
203
+ manager: manager.id,
204
+ command: command.join(' '),
205
+ before,
206
+ ...(latest === undefined ? {} : { latest }),
207
+ }
208
+ }
209
+
210
+ function refuse(
211
+ opts: UpgradeOptions,
212
+ before: string,
213
+ reason: string,
214
+ manager?: Manager,
215
+ ): number {
216
+ outro()
217
+ frameError(reason)
218
+ emit(opts, {
219
+ root: PROJECT_ROOT,
220
+ ...(manager === undefined ? {} : { manager: manager.id }),
221
+ before,
222
+ state: 'refused',
223
+ reason,
224
+ })
225
+ return 1
226
+ }
227
+
228
+ function emit(opts: UpgradeOptions, record: UpgradeRecord): void {
229
+ if (opts.json !== true) return
230
+ process.stdout.write(`${JSON.stringify(record)}\n`)
231
+ }
package/src/sync/check.ts CHANGED
@@ -27,6 +27,7 @@ import { createStandardsAdapter } from '@/standards/adapter'
27
27
  import { isDirectory } from '@/target'
28
28
  import { loadManifest } from '@/tooling/manifest'
29
29
  import { scan } from '@/tooling/scan'
30
+ import { readSkew, type SkewReport } from '@/version/skew'
30
31
 
31
32
  /**
32
33
  * Domains the sync engine walks file by file. Tooling is a stamp domain without
@@ -163,6 +164,17 @@ export interface CheckReport {
163
164
  * the same question correctly. See `@/sync/reverse`.
164
165
  */
165
166
  readonly reverse: ReverseReport
167
+ /**
168
+ * The binary running the check, not the target. It reports on an unmanaged
169
+ * target too, since a reader told to run `aitk init` is better off knowing
170
+ * first whether the binary about to install is the current one.
171
+ *
172
+ * `hasDrift` deliberately ignores it. A registry lookup inside a check that
173
+ * gates would fail CI on an offline machine for a condition the check never
174
+ * measured, and the state reaching the reader is the point rather than the
175
+ * exit code.
176
+ */
177
+ readonly skew: SkewReport
166
178
  }
167
179
 
168
180
  export function installedStampDomains(target: string): ScannedDomain[] {
@@ -300,6 +312,10 @@ export async function buildCheckReport(
300
312
  ): Promise<CheckReport> {
301
313
  const stamp = readStamp(target)
302
314
 
315
+ // Started before the local scan and awaited after it, so the network wait
316
+ // overlaps work the report needs anyway rather than adding to it.
317
+ const skewRead = readSkew()
318
+
303
319
  const domains = await Promise.all(
304
320
  installedStampDomains(target).map((domain) =>
305
321
  buildDomainReport(toolkitRoot, target, stamp, domain),
@@ -324,6 +340,7 @@ export async function buildCheckReport(
324
340
  unmigrated: [],
325
341
  newSkills: [],
326
342
  reverse: emptyReverseReport(),
343
+ skew: await skewRead,
327
344
  }
328
345
  }
329
346
 
@@ -337,6 +354,7 @@ export async function buildCheckReport(
337
354
  unmigrated,
338
355
  newSkills: await readNewSkills(toolkitRoot, anchors),
339
356
  reverse: buildReverseReport(toolkitRoot, target),
357
+ skew: await skewRead,
340
358
  }
341
359
  }
342
360
 
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A `major.minor.patch` core with an optional prerelease tail. The registry
3
+ * publishes both shapes under the same dist tag, so a comparison that only
4
+ * understood the core would read `1.0.0-rc.1` as unparseable and report the
5
+ * whole lookup as unknown.
6
+ */
7
+ const VERSION =
8
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
9
+
10
+ export interface ParsedVersion {
11
+ readonly major: number
12
+ readonly minor: number
13
+ readonly patch: number
14
+ /** Absent on a release, which sorts above every prerelease of the same core. */
15
+ readonly prerelease?: string
16
+ }
17
+
18
+ export function parseVersion(raw: string): ParsedVersion | undefined {
19
+ const match = VERSION.exec(raw.trim())
20
+ if (match === null) return undefined
21
+
22
+ return {
23
+ major: Number(match[1]),
24
+ minor: Number(match[2]),
25
+ patch: Number(match[3]),
26
+ ...(match[4] === undefined ? {} : { prerelease: match[4] }),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Negative when `left` is older, positive when it is newer, zero when the two
32
+ * name the same version.
33
+ *
34
+ * Prerelease identifiers compare as whole strings rather than dot segment by
35
+ * dot segment, which is narrower than semver states. Every version this repo
36
+ * publishes is a plain core, so the ordering inside a prerelease series decides
37
+ * nothing here, and the one comparison that matters is that any prerelease
38
+ * sorts below the release sharing its core.
39
+ */
40
+ export function compareVersions(
41
+ left: ParsedVersion,
42
+ right: ParsedVersion,
43
+ ): number {
44
+ if (left.major !== right.major) return left.major - right.major
45
+ if (left.minor !== right.minor) return left.minor - right.minor
46
+ if (left.patch !== right.patch) return left.patch - right.patch
47
+
48
+ if (left.prerelease === right.prerelease) return 0
49
+ if (left.prerelease === undefined) return 1
50
+ if (right.prerelease === undefined) return -1
51
+
52
+ return left.prerelease < right.prerelease ? -1 : 1
53
+ }
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
+
5
+ /** What a reader prints in place of a field the manifest did not carry. */
6
+ export const UNKNOWN_LABEL = 'unknown'
7
+
8
+ /**
9
+ * Either field is absent when the manifest could not be read or did not carry
10
+ * it. They are optional rather than sentinel strings so a caller has to narrow
11
+ * before using one, which is what makes a name the manifest never supplied
12
+ * impossible to interpolate into a shell command. A sentinel typed `string`
13
+ * reads as an ordinary value at every call site and hides that case.
14
+ */
15
+ export interface InstalledPackage {
16
+ readonly name?: string
17
+ readonly version?: string
18
+ }
19
+
20
+ /**
21
+ * Read at runtime rather than inlined, because a literal is a second place the
22
+ * version lives and it stopped tracking `package.json` at `0.1.0`. The release
23
+ * tool writes one file and this follows it. `package.json` ships in every npm
24
+ * tarball regardless of the `files` list, so the read resolves from a registry
25
+ * install as well as from a clone.
26
+ *
27
+ * This sits apart from the skew read so `src/cli.ts` can name the version on
28
+ * every invocation without pulling the registry lookup into the startup import
29
+ * graph of a CLI that compiles nothing ahead of time.
30
+ */
31
+ export function readInstalled(root: string = PROJECT_ROOT): InstalledPackage {
32
+ try {
33
+ const raw = readFileSync(join(root, 'package.json'), 'utf8')
34
+ const parsed = JSON.parse(raw) as { name?: string; version?: string }
35
+
36
+ return { name: parsed.name, version: parsed.version }
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
@@ -0,0 +1,67 @@
1
+ export type ManagerId = 'bun' | 'pnpm' | 'yarn' | 'npm'
2
+
3
+ export interface Manager {
4
+ readonly id: ManagerId
5
+ /** The path segment the detection matched, so a wrong read is correctable. */
6
+ readonly evidence: string
7
+ }
8
+
9
+ /**
10
+ * Segments that only appear in one manager's global install tree, checked
11
+ * before the `node_modules` fallback because every one of these trees contains
12
+ * a `node_modules` too.
13
+ */
14
+ const SIGNATURES: readonly (readonly [ManagerId, string])[] = [
15
+ ['bun', '.bun'],
16
+ ['pnpm', 'pnpm'],
17
+ ['pnpm', '.pnpm'],
18
+ ['yarn', 'yarn'],
19
+ ['yarn', '.yarn'],
20
+ ]
21
+
22
+ /**
23
+ * Which package manager installed the package rooted at `root`, read off the
24
+ * install path rather than guessed from what is on `PATH`.
25
+ *
26
+ * Removing the guess is the whole case for the upgrade verb, so a detection
27
+ * that cannot be read back is worth no more than the guess it replaced. The
28
+ * evidence travels with the answer and the verb prints it before running
29
+ * anything, which lets an operator correct a wrong read without the detection
30
+ * having to be right every time.
31
+ *
32
+ * Returns `undefined` for a path outside any install tree, which is a source
33
+ * checkout. That is not a case to guess at either: reinstalling over a clone
34
+ * would replace what the operator is working in.
35
+ */
36
+ export function detectManager(root: string): Manager | undefined {
37
+ const segments = root.split(/[/\\]/).filter((segment) => segment !== '')
38
+
39
+ for (const [id, signature] of SIGNATURES) {
40
+ if (segments.includes(signature)) return { id, evidence: signature }
41
+ }
42
+
43
+ if (segments.includes('node_modules')) {
44
+ return { id: 'npm', evidence: 'node_modules' }
45
+ }
46
+
47
+ return undefined
48
+ }
49
+
50
+ /** The global reinstall each manager spells, pinned to the newest published. */
51
+ export function installCommand(
52
+ manager: ManagerId,
53
+ name: string,
54
+ ): readonly string[] {
55
+ const spec = `${name}@latest`
56
+
57
+ switch (manager) {
58
+ case 'bun':
59
+ return ['bun', 'add', '--global', spec]
60
+ case 'pnpm':
61
+ return ['pnpm', 'add', '--global', spec]
62
+ case 'yarn':
63
+ return ['yarn', 'global', 'add', spec]
64
+ case 'npm':
65
+ return ['npm', 'install', '--global', spec]
66
+ }
67
+ }
@@ -0,0 +1,192 @@
1
+ import { PROJECT_ROOT } from '@/project-root'
2
+ import { compareVersions, parseVersion } from '@/version/compare'
3
+ import {
4
+ type InstalledPackage,
5
+ readInstalled,
6
+ UNKNOWN_LABEL,
7
+ } from '@/version/installed'
8
+ import { detectManager } from '@/version/manager'
9
+
10
+ const REGISTRY = 'https://registry.npmjs.org'
11
+
12
+ /**
13
+ * Short enough that a check waiting on a dead network still returns inside the
14
+ * time an operator would give the command anyway. The skew line is one section
15
+ * of a report the rest of which needs no network at all, so the budget is set
16
+ * against how long the report may be held up rather than against how long the
17
+ * registry usually takes.
18
+ */
19
+ const LOOKUP_TIMEOUT_MS = 3_000
20
+
21
+ /**
22
+ * Three states and no fourth. `unknown` covers every way the question could not
23
+ * be answered and carries the reason, which is what a caller reports instead of
24
+ * a version.
25
+ *
26
+ * An installed version ahead of the published one reports `current`. That is a
27
+ * source checkout between a release commit and the publish job, or a local
28
+ * build, and neither is skew. Giving it a state of its own would fire a warning
29
+ * on every maintainer run for a condition with no remedy.
30
+ */
31
+ export type SkewState = 'current' | 'behind' | 'unknown'
32
+
33
+ interface SkewBase {
34
+ readonly name: string
35
+ readonly installed: string
36
+ }
37
+
38
+ /**
39
+ * A union rather than one shape with two optional fields, so a `behind` report
40
+ * cannot exist without the version it is behind and an `unknown` one cannot
41
+ * exist without its reason. Both are what `describeSkew` renders into a line an
42
+ * operator reads, and an optional field renders the word `undefined` there.
43
+ */
44
+ export type SkewReport =
45
+ | (SkewBase & {
46
+ readonly state: 'current' | 'behind'
47
+ readonly latest: string
48
+ })
49
+ | (SkewBase & { readonly state: 'unknown'; readonly reason: string })
50
+
51
+ /** Resolves the newest published version, or throws for `readSkew` to absorb. */
52
+ export type LatestLookup = (name: string) => Promise<string>
53
+
54
+ export interface SkewOptions {
55
+ readonly installed?: InstalledPackage
56
+ readonly lookup?: LatestLookup
57
+ }
58
+
59
+ /**
60
+ * The installed version against the newest published one.
61
+ *
62
+ * Never rejects and never reports through an exit code. `aitk sync --check
63
+ * --exit-code` gates CI on drift it measured locally, so a lookup that failed
64
+ * the caller would turn an offline machine into a failing check and the check
65
+ * would be routed around. Every failure lands in `unknown` with its reason.
66
+ */
67
+ export async function readSkew(options: SkewOptions = {}): Promise<SkewReport> {
68
+ const installed = options.installed ?? readInstalled()
69
+ const lookup = options.lookup ?? fetchLatest
70
+ const { name, version } = installed
71
+
72
+ if (version === undefined) {
73
+ return unknown(
74
+ installed,
75
+ 'No version in the package manifest, so there is nothing to compare.',
76
+ )
77
+ }
78
+
79
+ if (name === undefined) {
80
+ return unknown(
81
+ installed,
82
+ 'No name in the package manifest, so the registry has nothing to look up.',
83
+ )
84
+ }
85
+
86
+ const local = parseVersion(version)
87
+ if (local === undefined) {
88
+ return unknown(
89
+ installed,
90
+ `Installed version ${version} is not a version this can parse.`,
91
+ )
92
+ }
93
+
94
+ let raw: string
95
+ try {
96
+ raw = await lookup(name)
97
+ } catch (error) {
98
+ return unknown(installed, `Registry lookup failed: ${describe(error)}`)
99
+ }
100
+
101
+ const published = parseVersion(raw)
102
+ if (published === undefined) {
103
+ return unknown(
104
+ installed,
105
+ `Registry reported ${raw} as the newest version, which is not a version this can parse.`,
106
+ )
107
+ }
108
+
109
+ return {
110
+ state: compareVersions(local, published) < 0 ? 'behind' : 'current',
111
+ name,
112
+ installed: version,
113
+ latest: raw,
114
+ }
115
+ }
116
+
117
+ function unknown(installed: InstalledPackage, reason: string): SkewReport {
118
+ return {
119
+ state: 'unknown',
120
+ name: installed.name ?? UNKNOWN_LABEL,
121
+ installed: installed.version ?? UNKNOWN_LABEL,
122
+ reason,
123
+ }
124
+ }
125
+
126
+ /** The newest published version, or `undefined` when it could not be read. */
127
+ export function latestOf(report: SkewReport): string | undefined {
128
+ return report.state === 'unknown' ? undefined : report.latest
129
+ }
130
+
131
+ function describe(error: unknown): string {
132
+ return error instanceof Error ? error.message : String(error)
133
+ }
134
+
135
+ /**
136
+ * The dist-tag endpoint rather than the full packument, which for this package
137
+ * carries every published manifest and is the larger part of a megabyte. The
138
+ * question is one string and this is the endpoint that answers only it.
139
+ */
140
+ async function fetchLatest(name: string): Promise<string> {
141
+ const response = await fetch(
142
+ `${REGISTRY}/-/package/${encodeURIComponent(name)}/dist-tags`,
143
+ {
144
+ signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
145
+ headers: { accept: 'application/json' },
146
+ },
147
+ )
148
+
149
+ if (!response.ok) {
150
+ throw new Error(`registry returned ${response.status}`)
151
+ }
152
+
153
+ const tags = (await response.json()) as Record<string, unknown>
154
+ const latest = tags.latest
155
+
156
+ if (typeof latest !== 'string') {
157
+ throw new Error('registry reported no latest dist-tag')
158
+ }
159
+
160
+ return latest
161
+ }
162
+
163
+ /**
164
+ * One line naming the state, for a caller that renders the skew beside sections
165
+ * it does not own. Held here so `aitk sync --check` and `aitk claude skills
166
+ * drift` cannot word the same three states differently.
167
+ *
168
+ * The remedy is chosen by the same detection `aitk upgrade` runs, because both
169
+ * callers run from a source checkout routinely and that is where the verb
170
+ * refuses. Naming it unconditionally sends a contributor whose clone sits a
171
+ * release behind to a command that declines. The read is a match against the
172
+ * root string rather than a filesystem call, so the line stays cheap.
173
+ */
174
+ export function describeSkew(
175
+ report: SkewReport,
176
+ root: string = PROJECT_ROOT,
177
+ ): string {
178
+ if (report.state === 'unknown') {
179
+ return `Installed ${report.installed}, published unknown. ${report.reason}`
180
+ }
181
+
182
+ if (report.state === 'current') {
183
+ return `Installed ${report.installed}, which is the newest published.`
184
+ }
185
+
186
+ const remedy =
187
+ detectManager(root) === undefined
188
+ ? 'This is a source checkout, so pull rather than reinstalling.'
189
+ : 'Run `aitk upgrade`.'
190
+
191
+ return `Installed ${report.installed}, published ${report.latest}. ${remedy}`
192
+ }