@ancleto/spec 0.6.26 → 0.6.28

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
@@ -120,6 +120,8 @@ Cómo bajarlo:
120
120
  - Evitá `--with-engram` salvo que uses esa memoria: es el que más pesa (9× la memoria propia).
121
121
  - Podés deshabilitar cualquier servidor en tu `opencode.json`: `"mcp": { "engram": { "enabled": false } }`.
122
122
 
123
+ Para medir el consumo **real** por sesión (no estimado): `ancleto stats` lee la base de sesiones de opencode y reporta tokens de entrada/salida/razonamiento/cache, costo y subagentes por sesión. Con `--session <id>` desglosa por agente y con `--since YYYY-MM-DD` permite comparar antes/después de una optimización. Solo funciona con opencode.
124
+
123
125
  ---
124
126
 
125
127
  ## Guía de .gitignore
@@ -201,6 +203,12 @@ ancleto upgrade # Re-aplica templates y skills respetando tus personal
201
203
 
202
204
  ancleto check # Verifica la integridad de la instalación (faltantes / huérfanos)
203
205
 
206
+ ancleto specs check [--change <nombre>] [--json]
207
+ # Valida keywords canónicos en aspec/specs y en los deltas de un change
208
+
209
+ ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
210
+ # Tokens por sesión de opencode (rollup de subagentes; --session desglosa por agente)
211
+
204
212
  ancleto doctor # Diagnostica el entorno (Node, node:sqlite, opencode.json)
205
213
 
206
214
  ancleto memory context [--scope <project|feature|task>] [--out <archivo>]
@@ -105,12 +105,13 @@ Do not treat archive as a blind file move.
105
105
  - `tasks.md`
106
106
  - `specs/` when the change includes delta specs
107
107
  3. Determine whether the change includes spec deltas that must be reflected in `aspec/specs/`
108
- 4. If needed, update the relevant source-of-truth specs so they reflect the completed change
109
- 5. If the change is not ready for archive, stop and report the inconsistency clearly
110
- 6. If the change is ready, archive it to `aspec/changes/archive/{YYYY-MM-DD}-{change-name}/`
111
- 7. Remove the original active change directory from `aspec/changes/{change-name}/` after a successful archive
112
- 8. If any files or folders remain in the original active change directory, report them as cleanup issues instead of leaving silent residue
113
- 9. Return a short structured summary of the finalization and archive result
108
+ 4. When deltas exist, run `ancleto specs check --change {change-name} --json` before merging. If a delta spec or its target source-of-truth spec is non-canonical (translated keywords), stop and report it instead of merging into it
109
+ 5. If needed, update the relevant source-of-truth specs so they reflect the completed change
110
+ 6. If the change is not ready for archive, stop and report the inconsistency clearly
111
+ 7. If the change is ready, archive it to `aspec/changes/archive/{YYYY-MM-DD}-{change-name}/`
112
+ 8. Remove the original active change directory from `aspec/changes/{change-name}/` after a successful archive
113
+ 9. If any files or folders remain in the original active change directory, report them as cleanup issues instead of leaving silent residue
114
+ 10. Return a short structured summary of the finalization and archive result
114
115
 
115
116
  ## Source-of-Truth Rules
116
117
 
@@ -44,8 +44,9 @@ using the `ancleto-workflow` skill and the conventions of:
44
44
  - `design.md`
45
45
  - `tasks.md`
46
46
  - `specs/{capability}/spec.md` as needed
47
- 6. When the delegation says the context was produced without a technical seed, declare that in the generated artifacts.
48
- 7. Return a brief summary with generated paths, scope, and open questions or risks
47
+ 6. Before returning, verify every generated spec uses the canonical keywords (`### Requirement:`, `#### Scenario:`, `**WHEN**`/`**THEN**`, `SHALL`); fix any translated heading or keyword in place — never leave a spec with translated keywords.
48
+ 7. When the delegation says the context was produced without a technical seed, declare that in the generated artifacts.
49
+ 8. Return a brief summary with generated paths, scope, and open questions or risks
49
50
 
50
51
  ## Change Naming
51
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,8 @@ Read `aspec/changes/<name>/tasks.md` (when present); count `- [ ]` (incomplete)
31
31
 
32
32
  Check for delta specs at `aspec/changes/<name>/specs/`; if none, proceed without a sync prompt.
33
33
 
34
+ **Canonical keywords first:** when delta specs exist and the CLI is available, run `ancleto specs check --change <name> --json`. If a delta spec or its target source-of-truth spec is non-canonical (translated keywords), warn that requirement blocks cannot be matched reliably and ask whether to normalize the affected spec first or archive without syncing that capability. Never merge into a non-canonical spec silently.
35
+
34
36
  **If delta specs exist:**
35
37
 
36
38
  - Diff each delta against its main spec at `aspec/specs/<capability>/spec.md`; show a combined summary of adds/modifications/removals/renames.
@@ -26,6 +26,12 @@ Read under `aspec/changes/<name>/`: `tasks.md` (required), plus `proposal.md`, `
26
26
 
27
27
  **Tasks:** parse checkboxes (`- [ ]` incomplete vs `- [x]` complete) and count. Each incomplete → CRITICAL: "Complete task: `<description>`" or "Mark as done if already implemented".
28
28
 
29
+ **Canonical spec keywords:** run `ancleto specs check --change <name> --json` when the CLI is available. For every reported file:
30
+ - Delta spec (`aspec/changes/<name>/specs/...`) → CRITICAL: "Non-canonical spec: `<file>` (missing: `<keywords>`)" + "Normalize `<file>` to the canonical English keywords before archiving".
31
+ - Source-of-truth spec (`aspec/specs/...`) → WARNING with the same recommendation.
32
+
33
+ A non-canonical spec cannot be matched for coverage or merged reliably, so report it before the checks below.
34
+
29
35
  **Spec coverage:** for each `### Requirement:` in `specs/`, search the codebase for related keywords. Unimplemented → CRITICAL: "Requirement not found: `<requirement name>`" + "Implement requirement: `<description>`".
30
36
 
31
37
  ### 3. Correctness
package/src/cli/index.js CHANGED
@@ -4,7 +4,8 @@ import { cp, mkdir, access, writeFile, readFile, readdir, rename } from 'node:fs
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { createInterface } from 'node:readline'
7
- import { join, dirname, resolve, basename } from 'node:path'
7
+ import { DatabaseSync } from 'node:sqlite'
8
+ import { join, dirname, resolve, basename, relative } from 'node:path'
8
9
  import { fileURLToPath } from 'node:url'
9
10
  import { homedir, tmpdir } from 'node:os'
10
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
@@ -40,6 +41,12 @@ Uso:
40
41
  ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
41
42
  ancleto discovery [--compress] [--include G] [--ignore G] [--token-budget N]
42
43
  Empaca el repo con Repomix y guarda estado
44
+ ancleto specs check [--change <name>] [--json]
45
+ Valida keywords canonicos (### Requirement:, WHEN, THEN)
46
+ en aspec/specs y, con --change, en los deltas del change
47
+ ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
48
+ Tokens por sesion de opencode (entrada/salida/razonamiento/cache),
49
+ con rollup de subagentes y desglose por agente con --session
43
50
  ancleto memory context [--scope X] [--out file]
44
51
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
45
52
  ancleto memory list [--type X] [--scope X] [--all] [--json]
@@ -980,6 +987,311 @@ async function checkTierOrphan(cwd) {
980
987
  return warnings
981
988
  }
982
989
 
990
+ // Un spec es canonico cuando usa los keywords literales en ingles. Detecta specs
991
+ // traducidos (p. ej. "#### RF1:" + "**Dado**/**Cuando**/**Entonces**"), que rompen
992
+ // la cobertura por keywords y el merge de deltas.
993
+ function specIssues(content) {
994
+ const missing = []
995
+ if (!/^###\s+Requirement:/m.test(content)) missing.push('### Requirement:')
996
+ if (/^####\s+Scenario:/m.test(content)) {
997
+ if (!/\bWHEN\b/.test(content)) missing.push('WHEN')
998
+ if (!/\bTHEN\b/.test(content)) missing.push('THEN')
999
+ }
1000
+ const unexpected = []
1001
+ const re = /^####\s+(.+)$/gm
1002
+ let m
1003
+ while ((m = re.exec(content)) !== null) {
1004
+ if (!/^Scenario\b/.test(m[1])) unexpected.push(`#### ${m[1]}`)
1005
+ }
1006
+ return { missing, unexpected }
1007
+ }
1008
+
1009
+ async function collectSpecFiles(dir) {
1010
+ const out = []
1011
+ if (!(await exists(dir))) return out
1012
+ for (const e of await readdir(dir, { withFileTypes: true })) {
1013
+ const p = join(dir, e.name)
1014
+ if (e.isDirectory()) out.push(...(await collectSpecFiles(p)))
1015
+ else if (e.name === 'spec.md') out.push(p)
1016
+ }
1017
+ return out
1018
+ }
1019
+
1020
+ async function specsCheckCommand(args) {
1021
+ const cwd = process.cwd()
1022
+ const asJson = args.includes('--json')
1023
+ const ci = args.indexOf('--change')
1024
+ const change = ci >= 0 ? args[ci + 1] : null
1025
+ const files = await collectSpecFiles(join(cwd, CHANGES_ROOT, 'specs'))
1026
+ if (change) {
1027
+ const changeDir = join(cwd, CHANGES_ROOT, 'changes', change)
1028
+ if (!(await exists(changeDir))) {
1029
+ console.error(`ancleto: no existe el change "${change}" en ${CHANGES_ROOT}/changes/`)
1030
+ process.exit(1)
1031
+ }
1032
+ files.push(...(await collectSpecFiles(join(changeDir, 'specs'))))
1033
+ }
1034
+ const findings = []
1035
+ for (const f of files) {
1036
+ const { missing, unexpected } = specIssues(await readFile(f, 'utf8'))
1037
+ if (missing.length || unexpected.length) {
1038
+ findings.push({ file: relative(cwd, f).replace(/\\/g, '/'), missing, unexpected })
1039
+ }
1040
+ }
1041
+ const ok = findings.length === 0
1042
+ if (asJson) {
1043
+ console.log(JSON.stringify({ ok, scanned: files.length, nonCanonical: findings }, null, 2))
1044
+ } else if (files.length === 0) {
1045
+ console.log('ancleto: no hay specs para revisar')
1046
+ } else {
1047
+ for (const f of files) {
1048
+ const rel = relative(cwd, f).replace(/\\/g, '/')
1049
+ const found = findings.find((x) => x.file === rel)
1050
+ if (found) {
1051
+ console.log(` ✖ ${rel} (revisar: ${[...found.missing, ...found.unexpected].join(', ')})`)
1052
+ } else {
1053
+ console.log(` ✔ ${rel}`)
1054
+ }
1055
+ }
1056
+ console.log(ok ? `ancleto: ${files.length} spec(s) canonicos` : `ancleto: ${findings.length} de ${files.length} spec(s) sin keywords canonicos`)
1057
+ }
1058
+ process.exit(ok ? 0 : 1)
1059
+ }
1060
+
1061
+ function opencodeDbPath() {
1062
+ return process.env.ANCLETO_OPENCODE_DB || join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
1063
+ }
1064
+
1065
+ function fmtTokens(n) {
1066
+ if (!n) return '0'
1067
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
1068
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
1069
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
1070
+ return String(n)
1071
+ }
1072
+
1073
+ function fmtCost(c) {
1074
+ return c && c < 0.01 ? `$${c.toFixed(4)}` : `$${(c || 0).toFixed(2)}`
1075
+ }
1076
+
1077
+ function fmtDate(ms) {
1078
+ const d = new Date(ms || 0)
1079
+ const p = (x) => String(x).padStart(2, '0')
1080
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
1081
+ }
1082
+
1083
+ const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
1084
+
1085
+ function sessionModel(model) {
1086
+ try {
1087
+ const m = JSON.parse(model || '{}')
1088
+ return m.id ? `${m.id}${m.providerID ? ` (${m.providerID})` : ''}` : ''
1089
+ } catch {
1090
+ return ''
1091
+ }
1092
+ }
1093
+
1094
+ function buildSessionTree(sessions) {
1095
+ const byId = new Map(sessions.map((s) => [s.id, s]))
1096
+ const children = new Map()
1097
+ for (const s of sessions) {
1098
+ if (!s.parent_id) continue
1099
+ if (!children.has(s.parent_id)) children.set(s.parent_id, [])
1100
+ children.get(s.parent_id).push(s)
1101
+ }
1102
+ const cache = new Map()
1103
+ const totalsOf = (id) => {
1104
+ if (cache.has(id)) return cache.get(id)
1105
+ const own = byId.get(id)
1106
+ const t = {
1107
+ input: own?.tokens_input || 0,
1108
+ output: own?.tokens_output || 0,
1109
+ reasoning: own?.tokens_reasoning || 0,
1110
+ cacheRead: own?.tokens_cache_read || 0,
1111
+ cacheWrite: own?.tokens_cache_write || 0,
1112
+ cost: own?.cost || 0,
1113
+ sessions: own ? 1 : 0
1114
+ }
1115
+ for (const c of children.get(id) || []) {
1116
+ const ct = totalsOf(c.id)
1117
+ t.input += ct.input
1118
+ t.output += ct.output
1119
+ t.reasoning += ct.reasoning
1120
+ t.cacheRead += ct.cacheRead
1121
+ t.cacheWrite += ct.cacheWrite
1122
+ t.cost += ct.cost
1123
+ t.sessions += ct.sessions
1124
+ }
1125
+ cache.set(id, t)
1126
+ return t
1127
+ }
1128
+ const subtreeIds = (id) => [id, ...(children.get(id) || []).flatMap((c) => subtreeIds(c.id))]
1129
+ return { byId, totalsOf, subtreeIds }
1130
+ }
1131
+
1132
+ function sessionInfo(s, totals) {
1133
+ return {
1134
+ id: s.id,
1135
+ title: s.title || null,
1136
+ agent: s.agent || null,
1137
+ model: sessionModel(s.model) || null,
1138
+ subagentSessions: Math.max(0, totals.sessions - 1),
1139
+ timeCreated: s.time_created || null,
1140
+ timeUpdated: s.time_updated || null,
1141
+ cost: totals.cost,
1142
+ tokens: {
1143
+ input: totals.input,
1144
+ output: totals.output,
1145
+ reasoning: totals.reasoning,
1146
+ cacheRead: totals.cacheRead,
1147
+ cacheWrite: totals.cacheWrite
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ async function statsCommand(args) {
1153
+ const asJson = args.includes('--json')
1154
+ const all = args.includes('--all')
1155
+ const si = args.indexOf('--session')
1156
+ const sessionId = si >= 0 ? args[si + 1] : null
1157
+ const li = args.indexOf('--limit')
1158
+ const limit = li >= 0 ? Number.parseInt(args[li + 1], 10) : 10
1159
+ if (li >= 0 && (!Number.isFinite(limit) || limit < 1)) {
1160
+ console.error('ancleto: --limit requiere un entero >= 1')
1161
+ process.exit(1)
1162
+ }
1163
+ const sinceIdx = args.indexOf('--since')
1164
+ const sinceRaw = sinceIdx >= 0 ? args[sinceIdx + 1] : null
1165
+ let since = null
1166
+ if (sinceRaw) {
1167
+ const t = Date.parse(`${sinceRaw}T00:00:00`)
1168
+ if (Number.isNaN(t)) {
1169
+ console.error('ancleto: --since requiere formato YYYY-MM-DD')
1170
+ process.exit(1)
1171
+ }
1172
+ since = t
1173
+ }
1174
+
1175
+ const dbPath = opencodeDbPath()
1176
+ if (!(await exists(dbPath))) {
1177
+ console.error(`ancleto: no se encontro la base de sesiones de opencode en ${dbPath}`)
1178
+ console.error('ancleto: ancleto stats solo funciona con opencode (podes apuntar ANCLETO_OPENCODE_DB a la base)')
1179
+ process.exit(1)
1180
+ }
1181
+ const db = new DatabaseSync(dbPath, { readOnly: true })
1182
+ let allSessions
1183
+ try {
1184
+ allSessions = db.prepare('SELECT * FROM session').all()
1185
+ } catch (err) {
1186
+ console.error(`ancleto: no se pudo leer la base de sesiones: ${err.message}`)
1187
+ process.exit(1)
1188
+ }
1189
+ const tree = buildSessionTree(allSessions)
1190
+ const cwd = process.cwd()
1191
+
1192
+ if (sessionId) {
1193
+ const root = tree.byId.get(sessionId)
1194
+ if (!root) {
1195
+ console.error(`ancleto: no existe la sesion "${sessionId}"`)
1196
+ process.exit(1)
1197
+ }
1198
+ const ids = tree.subtreeIds(sessionId)
1199
+ let messages = []
1200
+ try {
1201
+ messages = db.prepare(`SELECT data FROM message WHERE session_id IN (${ids.map(() => '?').join(',')})`).all(...ids)
1202
+ } catch {
1203
+ messages = []
1204
+ }
1205
+ const byAgent = new Map()
1206
+ for (const row of messages) {
1207
+ let d
1208
+ try {
1209
+ d = JSON.parse(row.data)
1210
+ } catch {
1211
+ continue
1212
+ }
1213
+ if (d.role !== 'assistant' || !d.tokens) continue
1214
+ const key = d.agent || '(desconocido)'
1215
+ const agg = byAgent.get(key) || { agent: key, messages: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
1216
+ agg.messages++
1217
+ agg.input += d.tokens.input || 0
1218
+ agg.output += d.tokens.output || 0
1219
+ agg.reasoning += d.tokens.reasoning || 0
1220
+ agg.cacheRead += d.tokens.cache?.read || 0
1221
+ agg.cacheWrite += d.tokens.cache?.write || 0
1222
+ byAgent.set(key, agg)
1223
+ }
1224
+ const agents = [...byAgent.values()].sort((a, b) => b.input - a.input)
1225
+ const totals = tree.totalsOf(sessionId)
1226
+ db.close()
1227
+ if (asJson) {
1228
+ console.log(JSON.stringify({ ok: true, scope: 'session', session: sessionInfo(root, totals), byAgent: agents, totals }, null, 2))
1229
+ return
1230
+ }
1231
+ console.log(`ancleto: sesion ${root.id}`)
1232
+ console.log(` titulo: ${root.title || '(sin titulo)'}`)
1233
+ console.log(` agente: ${root.agent || '-'} · modelo: ${sessionModel(root.model) || '-'}`)
1234
+ console.log(` creada: ${fmtDate(root.time_created)} · ultima actividad: ${fmtDate(root.time_updated)}`)
1235
+ console.log(` sesiones incluidas: ${totals.sessions} (raiz + subagentes)`)
1236
+ console.log('')
1237
+ console.log(' agente mensajes entrada salida razon cache-read')
1238
+ for (const a of agents) {
1239
+ console.log(` ${String(a.agent).padEnd(13)} ${String(a.messages).padEnd(10)} ${fmtTokens(a.input).padEnd(9)} ${fmtTokens(a.output).padEnd(9)} ${fmtTokens(a.reasoning).padEnd(9)} ${fmtTokens(a.cacheRead)}`)
1240
+ }
1241
+ console.log('')
1242
+ console.log(` totales: entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
1243
+ return
1244
+ }
1245
+
1246
+ let roots = allSessions.filter((s) => !s.parent_id)
1247
+ let scope = 'all'
1248
+ if (!all) {
1249
+ scope = 'directory'
1250
+ const cwdN = normPath(cwd)
1251
+ const inDir = (d) => {
1252
+ const dn = normPath(d)
1253
+ return dn === cwdN || dn.startsWith(`${cwdN}/`) || cwdN.startsWith(`${dn}/`)
1254
+ }
1255
+ roots = roots.filter((s) => inDir(s.directory))
1256
+ }
1257
+ if (since !== null) roots = roots.filter((s) => (s.time_created || 0) >= since)
1258
+ roots.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0))
1259
+ const shown = roots.slice(0, limit)
1260
+ const rows = shown.map((s) => ({ ...sessionInfo(s, tree.totalsOf(s.id)), directory: s.directory }))
1261
+ const totals = rows.reduce(
1262
+ (acc, r) => {
1263
+ acc.input += r.tokens.input
1264
+ acc.output += r.tokens.output
1265
+ acc.reasoning += r.tokens.reasoning
1266
+ acc.cacheRead += r.tokens.cacheRead
1267
+ acc.cacheWrite += r.tokens.cacheWrite
1268
+ acc.cost += r.cost
1269
+ return acc
1270
+ },
1271
+ { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
1272
+ )
1273
+ db.close()
1274
+
1275
+ if (asJson) {
1276
+ console.log(JSON.stringify({ ok: true, scope, directory: scope === 'directory' ? cwd.replace(/\\/g, '/') : null, since: sinceRaw || null, sessions: rows, totals }, null, 2))
1277
+ return
1278
+ }
1279
+ if (rows.length === 0) {
1280
+ console.log(`ancleto: no hay sesiones${scope === 'directory' ? ' para este directorio' : ''}${sinceRaw ? ` desde ${sinceRaw}` : ''}`)
1281
+ if (scope === 'directory') console.log('ancleto: proba --all para ver todas las sesiones de la maquina')
1282
+ return
1283
+ }
1284
+ console.log(`ancleto: stats (opencode)${scope === 'directory' ? ` — ${cwd.replace(/\\/g, '/')}` : ' — todas las sesiones'}${sinceRaw ? ` — desde ${sinceRaw}` : ''}`)
1285
+ console.log('')
1286
+ console.log(' fecha agente entrada salida razon cache costo titulo')
1287
+ for (const r of rows) {
1288
+ console.log(` ${fmtDate(r.timeUpdated).padEnd(18)} ${String(r.agent || '-').padEnd(13)} ${fmtTokens(r.tokens.input).padEnd(9)} ${fmtTokens(r.tokens.output).padEnd(9)} ${fmtTokens(r.tokens.reasoning).padEnd(9)} ${fmtTokens(r.tokens.cacheRead).padEnd(9)} ${fmtCost(r.cost).padEnd(8)} ${String(r.title || '').slice(0, 42)}`)
1289
+ }
1290
+ console.log('')
1291
+ const shownLabel = roots.length > rows.length ? `${rows.length} de ${roots.length} sesiones` : rows.length === 1 ? '1 sesion' : `${rows.length} sesiones`
1292
+ console.log(` totales (${shownLabel}): entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
1293
+ }
1294
+
983
1295
  async function doctorCommand() {
984
1296
  let fatal = false
985
1297
 
@@ -1045,6 +1357,12 @@ switch (cmd) {
1045
1357
  case 'check':
1046
1358
  await checkCommand()
1047
1359
  break
1360
+ case 'specs':
1361
+ await specsCheckCommand(rest)
1362
+ break
1363
+ case 'stats':
1364
+ await statsCommand(rest)
1365
+ break
1048
1366
  case 'doctor':
1049
1367
  await doctorCommand()
1050
1368
  break