@ancleto/spec 0.6.27 → 0.6.29

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
@@ -26,6 +26,7 @@
26
26
  - [Guía de .gitignore](#guía-de-gitignore)
27
27
  - [Configuración y tiers de costo](#configuración-y-tiers-de-costo)
28
28
  - [Uso rápido](#uso-rápido)
29
+ - [Proyectos con ancleto](#proyectos-con-ancleto)
29
30
  - [Referencia de comandos CLI](#referencia-de-comandos-cli)
30
31
  - [Motor de memoria persistente](#motor-de-memoria-persistente)
31
32
  - [Comandos del ciclo SDD en tu IDE](#comandos-del-ciclo-sdd-en-tu-ide)
@@ -120,6 +121,8 @@ Cómo bajarlo:
120
121
  - Evitá `--with-engram` salvo que uses esa memoria: es el que más pesa (9× la memoria propia).
121
122
  - Podés deshabilitar cualquier servidor en tu `opencode.json`: `"mcp": { "engram": { "enabled": false } }`.
122
123
 
124
+ 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.
125
+
123
126
  ---
124
127
 
125
128
  ## Guía de .gitignore
@@ -136,6 +139,36 @@ El framework **no gestiona tu `.gitignore`**: es una decisión del proyecto. Com
136
139
 
137
140
  ---
138
141
 
142
+ ## Proyectos con ancleto
143
+
144
+ Cuando instalás ancleto **en un proyecto** (`install --project`, `init` o `update`), el CLI lo anota en un registro global (`~/.config/ancleto/projects.json`). Así podés ver de un vistazo todos los proyectos que lo usan, sin importar desde qué terminal estés:
145
+
146
+ ```bash
147
+ ancleto projects # lista (o `ancleto list --projects`)
148
+ ancleto projects info # estado del proyecto actual
149
+ ancleto projects scan "D:/Repos D/Proyectos" # descubre y registra los que ya existen
150
+ ancleto projects prune # saca del registro los borrados/movidos
151
+ ```
152
+
153
+ La lista muestra versión instalada, origen (por proyecto vs agentes globales), tier y agente:
154
+
155
+ ```
156
+ ancleto: proyectos (2 activos de 3 registrados · CLI v0.6.28)
157
+
158
+ PROYECTO VERSIÓN ORIGEN TIER AGENTE RUTA
159
+ ● App Coffice 0.6.28 scoped minimo opencode D:/Repos D/Proyectos/App Coffice
160
+ ● spec 0.6.28 scoped normal opencode D:/Repos D/Proyectos/ancleto/spec
161
+ ✖ Volatile — muerto — — D:/Repos D/tmp/volatile
162
+ ```
163
+
164
+ - `●` verde: instalación por proyecto · `○` amarillo: usa agentes globales · `✖` rojo: ruta inexistente (candidata a `prune`).
165
+ - Versión en rojo = desactualizada respecto del CLI instalado.
166
+ - `--json` en todos para scripting.
167
+
168
+ El registro es tuyo y local: no viaja con el repo ni se comparte. `ANCLETO_PROJECTS_FILE` permite apuntarlo a otro lado.
169
+
170
+ ---
171
+
139
172
  ## Configuración y tiers de costo
140
173
 
141
174
  En la primera ejecución, un **wizard interactivo** te guía por los pasos de configuración (agente/IDE y nivel de gasto). También podés pasarlos por flags:
@@ -204,6 +237,18 @@ ancleto check # Verifica la integridad de la instalación (faltantes
204
237
  ancleto specs check [--change <nombre>] [--json]
205
238
  # Valida keywords canónicos en aspec/specs y en los deltas de un change
206
239
 
240
+ ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
241
+ # Tokens por sesión de opencode (rollup de subagentes; --session desglosa por agente)
242
+
243
+ ancleto projects [list] [--json]
244
+ # Lista los proyectos que usan ancleto (versión, tier, origen, ruta)
245
+ ancleto projects scan <raíz> [--dry-run] [--json]
246
+ # Descubre y registra proyectos ancleto bajo una raíz
247
+ ancleto projects prune [--dry-run] [--json]
248
+ # Quita del registro los proyectos borrados o movidos
249
+ ancleto projects info [ruta] [--json]
250
+ # Estado de un proyecto (versión, memoria, seed, changes activos)
251
+
207
252
  ancleto doctor # Diagnostica el entorno (Node, node:sqlite, opencode.json)
208
253
 
209
254
  ancleto memory context [--scope <project|feature|task>] [--out <archivo>]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.js CHANGED
@@ -4,6 +4,7 @@ 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 { DatabaseSync } from 'node:sqlite'
7
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'
@@ -43,6 +44,17 @@ Uso:
43
44
  ancleto specs check [--change <name>] [--json]
44
45
  Valida keywords canonicos (### Requirement:, WHEN, THEN)
45
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
50
+ ancleto projects [list] [--json] Lista los proyectos que usan ancleto (version, tier, origen, ruta)
51
+ ancleto projects scan <raiz> [--dry-run] [--json]
52
+ Descubre y registra proyectos ancleto bajo una raiz
53
+ ancleto projects prune [--dry-run] [--json]
54
+ Quita del registro los proyectos borrados o movidos
55
+ ancleto projects info [ruta] [--json]
56
+ Estado de un proyecto (version, memoria, seed, changes activos)
57
+ ancleto list --projects [--json] Alias de "ancleto projects list"
46
58
  ancleto memory context [--scope X] [--out file]
47
59
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
48
60
  ancleto memory list [--type X] [--scope X] [--all] [--json]
@@ -97,6 +109,301 @@ function globalConfigDir() {
97
109
  : join(homedir(), '.config', 'opencode')
98
110
  }
99
111
 
112
+ // Registro global de proyectos que usan ancleto (no depende de opencode).
113
+ function projectsRegistryPath() {
114
+ if (process.env.ANCLETO_PROJECTS_FILE) return process.env.ANCLETO_PROJECTS_FILE
115
+ const base = process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'ancleto') : join(homedir(), '.config', 'ancleto')
116
+ return join(base, 'projects.json')
117
+ }
118
+
119
+ function normKey(p) {
120
+ return normPath(resolve(p))
121
+ }
122
+
123
+ async function readProjectsRegistry() {
124
+ const file = projectsRegistryPath()
125
+ if (!(await exists(file))) return { schemaVersion: 1, projects: {} }
126
+ try {
127
+ const data = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
128
+ return { schemaVersion: 1, projects: data.projects || {} }
129
+ } catch (err) {
130
+ console.warn(`ancleto: registro de proyectos ilegible (${file}): ${err.message}`)
131
+ return { schemaVersion: 1, projects: {} }
132
+ }
133
+ }
134
+
135
+ async function writeProjectsRegistry(reg) {
136
+ const file = projectsRegistryPath()
137
+ await mkdir(dirname(file), { recursive: true })
138
+ await writeFile(file, JSON.stringify(reg, null, 2) + '\n')
139
+ return file
140
+ }
141
+
142
+ async function registerProject(projectDir, { agent, tier } = {}) {
143
+ const abs = resolve(projectDir)
144
+ const rc = await readAncletorc(abs)
145
+ const tierFile = join(abs, '.opencode', '.ancleto-tier')
146
+ const localTier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
147
+ const hasScopedAgents = await exists(join(abs, '.opencode', 'agents'))
148
+ const entry = {
149
+ path: abs.replace(/\\/g, '/'),
150
+ version: await packageVersion(),
151
+ agent: agent || rc?.agent || 'opencode',
152
+ tier: tier || localTier || null,
153
+ scoped: hasScopedAgents,
154
+ registeredAt: new Date().toISOString()
155
+ }
156
+ const reg = await readProjectsRegistry()
157
+ const key = normKey(abs)
158
+ entry.registeredAt = reg.projects[key]?.registeredAt || entry.registeredAt
159
+ entry.lastSeen = new Date().toISOString()
160
+ reg.projects[key] = entry
161
+ await writeProjectsRegistry(reg)
162
+ }
163
+
164
+ async function unregisterProject(projectDir) {
165
+ const reg = await readProjectsRegistry()
166
+ const key = normKey(projectDir)
167
+ if (!reg.projects[key]) return false
168
+ delete reg.projects[key]
169
+ await writeProjectsRegistry(reg)
170
+ return true
171
+ }
172
+
173
+ // Color helpers (respetan NO_COLOR y TTY).
174
+ const useColor = () => process.stdout.isTTY && !process.env.NO_COLOR
175
+ function paint(code, s) {
176
+ return useColor() ? `\x1b[${code}m${s}\x1b[0m` : String(s)
177
+ }
178
+ const c = {
179
+ dim: (s) => paint('2', s),
180
+ bold: (s) => paint('1', s),
181
+ green: (s) => paint('32', s),
182
+ yellow: (s) => paint('33', s),
183
+ red: (s) => paint('31', s),
184
+ cyan: (s) => paint('36', s)
185
+ }
186
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '')
187
+ function padVisible(s, width) {
188
+ const len = stripAnsi(s).length
189
+ return s + ' '.repeat(Math.max(0, width - len))
190
+ }
191
+
192
+ // Descubre proyectos ancleto bajo una raiz (busca .ancletorc, con profundidad acotada).
193
+ async function discoverProjectsUnder(root, maxDepth = 3) {
194
+ const found = []
195
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', 'vendor'])
196
+ async function walk(dir, depth) {
197
+ let entries
198
+ try {
199
+ entries = await readdir(dir, { withFileTypes: true })
200
+ } catch {
201
+ return
202
+ }
203
+ if (entries.some((e) => e.isFile() && (e.name === '.ancletorc' || e.name === '.ancletorc'))) found.push(dir)
204
+ if (depth >= maxDepth) return
205
+ for (const e of entries) {
206
+ if (!e.isDirectory() || skip.has(e.name) || e.name.startsWith('.')) continue
207
+ await walk(join(dir, e.name), depth + 1)
208
+ }
209
+ }
210
+ await walk(resolve(root), 0)
211
+ return found
212
+ }
213
+
214
+ // Lee el estado real de un proyecto registrado (o descubre que ya no existe).
215
+ async function projectStatus(entry) {
216
+ const abs = entry.path
217
+ if (!(await exists(abs))) return { ...entry, exists: false }
218
+ const rc = await readAncletorc(abs)
219
+ if (!rc) return { ...entry, exists: false, reason: 'sin .ancletorc' }
220
+ const tierFile = join(abs, '.opencode', '.ancleto-tier')
221
+ const tier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
222
+ const scoped = await exists(join(abs, '.opencode', 'agents'))
223
+ return {
224
+ ...entry,
225
+ exists: true,
226
+ path: abs.replace(/\\/g, '/'),
227
+ version: rc.version || entry.version || null,
228
+ agent: rc.agent || entry.agent || null,
229
+ tier,
230
+ scoped
231
+ }
232
+ }
233
+
234
+ async function projectsList(reg, { asJson, currentVersion }) {
235
+ const rows = await Promise.all(Object.values(reg.projects).map(projectStatus))
236
+ rows.sort((a, b) => String(a.path).localeCompare(String(b.path)))
237
+ if (asJson) {
238
+ console.log(JSON.stringify({ ok: true, cliVersion: currentVersion, registry: projectsRegistryPath(), count: rows.length, projects: rows }, null, 2))
239
+ return rows
240
+ }
241
+ if (rows.length === 0) {
242
+ console.log('ancleto: no hay proyectos registrados todavia')
243
+ console.log(c.dim(' registra uno con: ancleto install --project <ruta> · o descubre con: ancleto projects scan <raiz>'))
244
+ return rows
245
+ }
246
+ const live = rows.filter((r) => r.exists)
247
+ console.log(`${c.bold('ancleto: proyectos')} ${c.dim(`(${live.length} activos de ${rows.length} registrados · CLI v${currentVersion})`)}`)
248
+ console.log('')
249
+ const header = ['', padVisible('PROYECTO', 22), padVisible('VERSIÓN', 10), padVisible('ORIGEN', 10), padVisible('TIER', 9), padVisible('AGENTE', 10), 'RUTA']
250
+ console.log(c.dim(` ${header.join(' ')}`))
251
+ for (const r of rows) {
252
+ const name = r.path.split('/').filter(Boolean).pop() || r.path
253
+ if (!r.exists) {
254
+ console.log(` ${c.red('✖')} ${padVisible(c.red(name.slice(0, 21)), 22)} ${padVisible(c.dim('—'), 10)} ${padVisible(c.red('muerto'), 10)} ${padVisible('—', 9)} ${padVisible('—', 10)} ${c.dim(r.path)}`)
255
+ continue
256
+ }
257
+ const outdated = r.version && currentVersion && r.version !== currentVersion
258
+ const marker = r.scoped ? c.green('●') : c.yellow('○')
259
+ const ver = outdated ? c.red(r.version) : c.green(r.version || '—')
260
+ const origin = r.scoped ? c.green('scoped') : c.yellow('global')
261
+ console.log(` ${marker} ${padVisible(c.bold(name.slice(0, 21)), 22)} ${padVisible(ver, 10)} ${padVisible(origin, 10)} ${padVisible(r.tier || c.dim('—'), 9)} ${padVisible(r.agent || '—', 10)} ${r.path}`)
262
+ }
263
+ console.log('')
264
+ console.log(c.dim(' ● instalación por proyecto ○ usa agentes globales versión en rojo = desactualizada'))
265
+ return rows
266
+ }
267
+
268
+ async function projectsScan(root, { asJson, dryRun }) {
269
+ if (!root) {
270
+ console.error('ancleto: uso: ancleto projects scan <raiz> [--dry-run] [--json]')
271
+ process.exit(1)
272
+ }
273
+ if (!(await exists(root))) {
274
+ console.error(`ancleto: no existe la raiz "${root}"`)
275
+ process.exit(1)
276
+ }
277
+ const found = await discoverProjectsUnder(root)
278
+ const reg = await readProjectsRegistry()
279
+ const added = []
280
+ const known = []
281
+ for (const dir of found) {
282
+ const key = normKey(dir)
283
+ if (reg.projects[key]) {
284
+ known.push(dir)
285
+ continue
286
+ }
287
+ added.push(dir)
288
+ if (!dryRun) await registerProject(dir)
289
+ }
290
+ if (asJson) {
291
+ console.log(JSON.stringify({ ok: true, root: resolve(root), found: found.length, added: added.map((p) => p.replace(/\\/g, '/')), known: known.map((p) => p.replace(/\\/g, '/')), dryRun: !!dryRun }, null, 2))
292
+ return
293
+ }
294
+ console.log(`${c.bold('ancleto: scan')} ${c.dim(resolve(root).replace(/\\/g, '/'))}`)
295
+ console.log(` ${c.dim('encontrados:')} ${found.length} proyectos con .ancletorc`)
296
+ for (const p of added) console.log(` ${dryRun ? c.yellow('·') : c.green('+')} ${p.replace(/\\/g, '/')}`)
297
+ for (const p of known) console.log(` ${c.dim('=')} ${c.dim(p.replace(/\\/g, '/'))}`)
298
+ if (dryRun) console.log(c.dim('\n --dry-run: no se registro nada'))
299
+ else console.log(c.dim(`\n registrados: ${added.length} nuevos`))
300
+ }
301
+
302
+ async function projectsPrune({ asJson, dryRun }) {
303
+ const reg = await readProjectsRegistry()
304
+ const removed = []
305
+ for (const [key, entry] of Object.entries(reg.projects)) {
306
+ const st = await projectStatus(entry)
307
+ if (!st.exists) {
308
+ removed.push({ key, path: entry.path, reason: st.reason || 'ruta inexistente' })
309
+ if (!dryRun) delete reg.projects[key]
310
+ }
311
+ }
312
+ if (!dryRun && removed.length) await writeProjectsRegistry(reg)
313
+ if (asJson) {
314
+ console.log(JSON.stringify({ ok: true, pruned: removed.length, removed, dryRun: !!dryRun }, null, 2))
315
+ return
316
+ }
317
+ if (removed.length === 0) {
318
+ console.log(c.dim('ancleto: no hay entradas muertas en el registro'))
319
+ return
320
+ }
321
+ console.log(`${c.bold('ancleto: prune')}${dryRun ? ` ${c.dim('(dry-run)')}` : ''}`)
322
+ for (const r of removed) console.log(` ${dryRun ? c.yellow('·') : c.red('−')} ${r.path} ${c.dim(`(${r.reason})`)}`)
323
+ console.log(dryRun ? c.dim(`\n --dry-run: se quitarian ${removed.length}`) : c.dim(`\n removidos: ${removed.length}`))
324
+ }
325
+
326
+ async function projectsInfo(target, { asJson, currentVersion }) {
327
+ const abs = resolve(target || process.cwd())
328
+ const rc = await readAncletorc(abs)
329
+ if (!rc) {
330
+ console.error(`ancleto: "${abs}" no parece un proyecto ancleto (sin .ancletorc)`)
331
+ process.exit(1)
332
+ }
333
+ const st = await projectStatus({ path: abs, ...rc })
334
+ const hasMemory = await exists(join(abs, '.ancleto', 'memory.db'))
335
+ const hasSeedIndex = await exists(join(abs, rc.discovery?.outputDir || 'docs/technical-discovery', 'index.md'))
336
+ const hasWorkingContext = await exists(join(abs, '.ancleto', 'working-context.md'))
337
+ const activeChangesDir = join(abs, CHANGES_ROOT, 'changes')
338
+ let activeChanges = 0
339
+ if (await exists(activeChangesDir)) {
340
+ const entries = await readdir(activeChangesDir, { withFileTypes: true })
341
+ activeChanges = entries.filter((e) => e.isDirectory() && e.name !== 'archive').length
342
+ }
343
+ const info = {
344
+ path: abs.replace(/\\/g, '/'),
345
+ version: st.version,
346
+ outdated: st.version && currentVersion ? st.version !== currentVersion : false,
347
+ agent: st.agent,
348
+ tier: st.tier,
349
+ scoped: st.scoped,
350
+ azureEnabled: !!rc.azure?.enabled,
351
+ memoryDb: hasMemory,
352
+ technicalSeed: hasSeedIndex,
353
+ workingContext: hasWorkingContext,
354
+ activeChanges
355
+ }
356
+ if (asJson) {
357
+ console.log(JSON.stringify({ ok: true, cliVersion: currentVersion, ...info }, null, 2))
358
+ return
359
+ }
360
+ console.log(`${c.bold((abs.split(/[\\/]/).filter(Boolean).pop() || abs))} ${c.dim(info.path)}`)
361
+ console.log('')
362
+ console.log(` versión ${info.outdated ? c.red(`${info.version} → ${currentVersion} disponible`) : c.green(info.version || '—')}`)
363
+ console.log(` instalación ${info.scoped ? c.green('por proyecto (scoped)') : c.yellow('agentes globales')}`)
364
+ console.log(` tier / agente ${info.tier || c.dim('—')} / ${info.agent || '—'}`)
365
+ console.log(` Azure DevOps ${info.azureEnabled ? c.green('habilitado') : c.dim('deshabilitado')}`)
366
+ console.log(` memoria ${info.memoryDb ? c.green('sí (.ancleto/memory.db)') : c.dim('no')}`)
367
+ console.log(` seed técnico ${info.technicalSeed ? c.green('generado') : c.dim('sin generar')}`)
368
+ console.log(` changes ${info.activeChanges > 0 ? c.cyan(`${info.activeChanges} activo(s)`) : c.dim('ninguno')}`)
369
+ if (info.outdated) console.log(c.dim(`\n actualizá con: cd "${info.path}" && ancleto update`))
370
+ }
371
+
372
+ async function projectsCommand(args) {
373
+ const sub = args.find((a) => !a.startsWith('-')) || 'list'
374
+ const flags = args.filter((a) => a.startsWith('-'))
375
+ const asJson = flags.includes('--json')
376
+ const dryRun = flags.includes('--dry-run')
377
+ const currentVersion = await packageVersion()
378
+ switch (sub) {
379
+ case 'list': {
380
+ await projectsList(await readProjectsRegistry(), { asJson, currentVersion })
381
+ break
382
+ }
383
+ case 'scan': {
384
+ const idx = args.indexOf('scan')
385
+ const root = args[idx + 1] && !args[idx + 1].startsWith('-') ? args[idx + 1] : null
386
+ await projectsScan(root, { asJson, dryRun })
387
+ break
388
+ }
389
+ case 'prune': {
390
+ await projectsPrune({ asJson, dryRun })
391
+ break
392
+ }
393
+ case 'info': {
394
+ const idx = args.indexOf('info')
395
+ const target = args[idx + 1] && !args[idx + 1].startsWith('-') ? args[idx + 1] : null
396
+ await projectsInfo(target, { asJson, currentVersion })
397
+ break
398
+ }
399
+ default:
400
+ console.error(`ancleto: subcomando desconocido: projects ${sub}`)
401
+ console.error('ancleto: uso: ancleto projects [list|scan <raiz>|prune|info <ruta>] [--json] [--dry-run]')
402
+ process.exit(1)
403
+ }
404
+ }
405
+
406
+
100
407
  function binName(base) {
101
408
  return process.platform === 'win32' ? `${base}.exe` : base
102
409
  }
@@ -490,6 +797,8 @@ async function install(args) {
490
797
  await applyTier(join(target, 'agents'), tier, models)
491
798
  await writeFile(tierStatePath(target), tier + '\n')
492
799
 
800
+ if (projectDir) await registerProject(projectDir, { agent, tier })
801
+
493
802
  let azureMcp = false
494
803
  if (projectDir && withMcp) {
495
804
  const rc = await readAncletorc(projectDir)
@@ -587,6 +896,7 @@ async function initProject(args) {
587
896
  await copyTemplates(projectDir)
588
897
  await scaffoldAspec(projectDir)
589
898
  await refreshWorkingContext(projectDir)
899
+ await registerProject(projectDir, { agent, tier: tier || null })
590
900
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
591
901
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''}`)
592
902
  }
@@ -1054,6 +1364,240 @@ async function specsCheckCommand(args) {
1054
1364
  process.exit(ok ? 0 : 1)
1055
1365
  }
1056
1366
 
1367
+ function opencodeDbPath() {
1368
+ return process.env.ANCLETO_OPENCODE_DB || join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
1369
+ }
1370
+
1371
+ function fmtTokens(n) {
1372
+ if (!n) return '0'
1373
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
1374
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
1375
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
1376
+ return String(n)
1377
+ }
1378
+
1379
+ function fmtCost(c) {
1380
+ return c && c < 0.01 ? `$${c.toFixed(4)}` : `$${(c || 0).toFixed(2)}`
1381
+ }
1382
+
1383
+ function fmtDate(ms) {
1384
+ const d = new Date(ms || 0)
1385
+ const p = (x) => String(x).padStart(2, '0')
1386
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
1387
+ }
1388
+
1389
+ const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
1390
+
1391
+ function sessionModel(model) {
1392
+ try {
1393
+ const m = JSON.parse(model || '{}')
1394
+ return m.id ? `${m.id}${m.providerID ? ` (${m.providerID})` : ''}` : ''
1395
+ } catch {
1396
+ return ''
1397
+ }
1398
+ }
1399
+
1400
+ function buildSessionTree(sessions) {
1401
+ const byId = new Map(sessions.map((s) => [s.id, s]))
1402
+ const children = new Map()
1403
+ for (const s of sessions) {
1404
+ if (!s.parent_id) continue
1405
+ if (!children.has(s.parent_id)) children.set(s.parent_id, [])
1406
+ children.get(s.parent_id).push(s)
1407
+ }
1408
+ const cache = new Map()
1409
+ const totalsOf = (id) => {
1410
+ if (cache.has(id)) return cache.get(id)
1411
+ const own = byId.get(id)
1412
+ const t = {
1413
+ input: own?.tokens_input || 0,
1414
+ output: own?.tokens_output || 0,
1415
+ reasoning: own?.tokens_reasoning || 0,
1416
+ cacheRead: own?.tokens_cache_read || 0,
1417
+ cacheWrite: own?.tokens_cache_write || 0,
1418
+ cost: own?.cost || 0,
1419
+ sessions: own ? 1 : 0
1420
+ }
1421
+ for (const c of children.get(id) || []) {
1422
+ const ct = totalsOf(c.id)
1423
+ t.input += ct.input
1424
+ t.output += ct.output
1425
+ t.reasoning += ct.reasoning
1426
+ t.cacheRead += ct.cacheRead
1427
+ t.cacheWrite += ct.cacheWrite
1428
+ t.cost += ct.cost
1429
+ t.sessions += ct.sessions
1430
+ }
1431
+ cache.set(id, t)
1432
+ return t
1433
+ }
1434
+ const subtreeIds = (id) => [id, ...(children.get(id) || []).flatMap((c) => subtreeIds(c.id))]
1435
+ return { byId, totalsOf, subtreeIds }
1436
+ }
1437
+
1438
+ function sessionInfo(s, totals) {
1439
+ return {
1440
+ id: s.id,
1441
+ title: s.title || null,
1442
+ agent: s.agent || null,
1443
+ model: sessionModel(s.model) || null,
1444
+ subagentSessions: Math.max(0, totals.sessions - 1),
1445
+ timeCreated: s.time_created || null,
1446
+ timeUpdated: s.time_updated || null,
1447
+ cost: totals.cost,
1448
+ tokens: {
1449
+ input: totals.input,
1450
+ output: totals.output,
1451
+ reasoning: totals.reasoning,
1452
+ cacheRead: totals.cacheRead,
1453
+ cacheWrite: totals.cacheWrite
1454
+ }
1455
+ }
1456
+ }
1457
+
1458
+ async function statsCommand(args) {
1459
+ const asJson = args.includes('--json')
1460
+ const all = args.includes('--all')
1461
+ const si = args.indexOf('--session')
1462
+ const sessionId = si >= 0 ? args[si + 1] : null
1463
+ const li = args.indexOf('--limit')
1464
+ const limit = li >= 0 ? Number.parseInt(args[li + 1], 10) : 10
1465
+ if (li >= 0 && (!Number.isFinite(limit) || limit < 1)) {
1466
+ console.error('ancleto: --limit requiere un entero >= 1')
1467
+ process.exit(1)
1468
+ }
1469
+ const sinceIdx = args.indexOf('--since')
1470
+ const sinceRaw = sinceIdx >= 0 ? args[sinceIdx + 1] : null
1471
+ let since = null
1472
+ if (sinceRaw) {
1473
+ const t = Date.parse(`${sinceRaw}T00:00:00`)
1474
+ if (Number.isNaN(t)) {
1475
+ console.error('ancleto: --since requiere formato YYYY-MM-DD')
1476
+ process.exit(1)
1477
+ }
1478
+ since = t
1479
+ }
1480
+
1481
+ const dbPath = opencodeDbPath()
1482
+ if (!(await exists(dbPath))) {
1483
+ console.error(`ancleto: no se encontro la base de sesiones de opencode en ${dbPath}`)
1484
+ console.error('ancleto: ancleto stats solo funciona con opencode (podes apuntar ANCLETO_OPENCODE_DB a la base)')
1485
+ process.exit(1)
1486
+ }
1487
+ const db = new DatabaseSync(dbPath, { readOnly: true })
1488
+ let allSessions
1489
+ try {
1490
+ allSessions = db.prepare('SELECT * FROM session').all()
1491
+ } catch (err) {
1492
+ console.error(`ancleto: no se pudo leer la base de sesiones: ${err.message}`)
1493
+ process.exit(1)
1494
+ }
1495
+ const tree = buildSessionTree(allSessions)
1496
+ const cwd = process.cwd()
1497
+
1498
+ if (sessionId) {
1499
+ const root = tree.byId.get(sessionId)
1500
+ if (!root) {
1501
+ console.error(`ancleto: no existe la sesion "${sessionId}"`)
1502
+ process.exit(1)
1503
+ }
1504
+ const ids = tree.subtreeIds(sessionId)
1505
+ let messages = []
1506
+ try {
1507
+ messages = db.prepare(`SELECT data FROM message WHERE session_id IN (${ids.map(() => '?').join(',')})`).all(...ids)
1508
+ } catch {
1509
+ messages = []
1510
+ }
1511
+ const byAgent = new Map()
1512
+ for (const row of messages) {
1513
+ let d
1514
+ try {
1515
+ d = JSON.parse(row.data)
1516
+ } catch {
1517
+ continue
1518
+ }
1519
+ if (d.role !== 'assistant' || !d.tokens) continue
1520
+ const key = d.agent || '(desconocido)'
1521
+ const agg = byAgent.get(key) || { agent: key, messages: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
1522
+ agg.messages++
1523
+ agg.input += d.tokens.input || 0
1524
+ agg.output += d.tokens.output || 0
1525
+ agg.reasoning += d.tokens.reasoning || 0
1526
+ agg.cacheRead += d.tokens.cache?.read || 0
1527
+ agg.cacheWrite += d.tokens.cache?.write || 0
1528
+ byAgent.set(key, agg)
1529
+ }
1530
+ const agents = [...byAgent.values()].sort((a, b) => b.input - a.input)
1531
+ const totals = tree.totalsOf(sessionId)
1532
+ db.close()
1533
+ if (asJson) {
1534
+ console.log(JSON.stringify({ ok: true, scope: 'session', session: sessionInfo(root, totals), byAgent: agents, totals }, null, 2))
1535
+ return
1536
+ }
1537
+ console.log(`ancleto: sesion ${root.id}`)
1538
+ console.log(` titulo: ${root.title || '(sin titulo)'}`)
1539
+ console.log(` agente: ${root.agent || '-'} · modelo: ${sessionModel(root.model) || '-'}`)
1540
+ console.log(` creada: ${fmtDate(root.time_created)} · ultima actividad: ${fmtDate(root.time_updated)}`)
1541
+ console.log(` sesiones incluidas: ${totals.sessions} (raiz + subagentes)`)
1542
+ console.log('')
1543
+ console.log(' agente mensajes entrada salida razon cache-read')
1544
+ for (const a of agents) {
1545
+ 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)}`)
1546
+ }
1547
+ console.log('')
1548
+ console.log(` totales: entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
1549
+ return
1550
+ }
1551
+
1552
+ let roots = allSessions.filter((s) => !s.parent_id)
1553
+ let scope = 'all'
1554
+ if (!all) {
1555
+ scope = 'directory'
1556
+ const cwdN = normPath(cwd)
1557
+ const inDir = (d) => {
1558
+ const dn = normPath(d)
1559
+ return dn === cwdN || dn.startsWith(`${cwdN}/`) || cwdN.startsWith(`${dn}/`)
1560
+ }
1561
+ roots = roots.filter((s) => inDir(s.directory))
1562
+ }
1563
+ if (since !== null) roots = roots.filter((s) => (s.time_created || 0) >= since)
1564
+ roots.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0))
1565
+ const shown = roots.slice(0, limit)
1566
+ const rows = shown.map((s) => ({ ...sessionInfo(s, tree.totalsOf(s.id)), directory: s.directory }))
1567
+ const totals = rows.reduce(
1568
+ (acc, r) => {
1569
+ acc.input += r.tokens.input
1570
+ acc.output += r.tokens.output
1571
+ acc.reasoning += r.tokens.reasoning
1572
+ acc.cacheRead += r.tokens.cacheRead
1573
+ acc.cacheWrite += r.tokens.cacheWrite
1574
+ acc.cost += r.cost
1575
+ return acc
1576
+ },
1577
+ { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
1578
+ )
1579
+ db.close()
1580
+
1581
+ if (asJson) {
1582
+ console.log(JSON.stringify({ ok: true, scope, directory: scope === 'directory' ? cwd.replace(/\\/g, '/') : null, since: sinceRaw || null, sessions: rows, totals }, null, 2))
1583
+ return
1584
+ }
1585
+ if (rows.length === 0) {
1586
+ console.log(`ancleto: no hay sesiones${scope === 'directory' ? ' para este directorio' : ''}${sinceRaw ? ` desde ${sinceRaw}` : ''}`)
1587
+ if (scope === 'directory') console.log('ancleto: proba --all para ver todas las sesiones de la maquina')
1588
+ return
1589
+ }
1590
+ console.log(`ancleto: stats (opencode)${scope === 'directory' ? ` — ${cwd.replace(/\\/g, '/')}` : ' — todas las sesiones'}${sinceRaw ? ` — desde ${sinceRaw}` : ''}`)
1591
+ console.log('')
1592
+ console.log(' fecha agente entrada salida razon cache costo titulo')
1593
+ for (const r of rows) {
1594
+ 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)}`)
1595
+ }
1596
+ console.log('')
1597
+ const shownLabel = roots.length > rows.length ? `${rows.length} de ${roots.length} sesiones` : rows.length === 1 ? '1 sesion' : `${rows.length} sesiones`
1598
+ console.log(` totales (${shownLabel}): entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
1599
+ }
1600
+
1057
1601
  async function doctorCommand() {
1058
1602
  let fatal = false
1059
1603
 
@@ -1122,6 +1666,20 @@ switch (cmd) {
1122
1666
  case 'specs':
1123
1667
  await specsCheckCommand(rest)
1124
1668
  break
1669
+ case 'stats':
1670
+ await statsCommand(rest)
1671
+ break
1672
+ case 'projects':
1673
+ await projectsCommand(rest)
1674
+ break
1675
+ case 'list':
1676
+ // Alias conveniente: `ancleto list --projects`
1677
+ if (rest.includes('--projects')) await projectsCommand(rest.filter((a) => a !== '--projects'))
1678
+ else {
1679
+ console.error('ancleto: uso: ancleto list --projects [--json] (o ancleto projects)')
1680
+ process.exit(1)
1681
+ }
1682
+ break
1125
1683
  case 'doctor':
1126
1684
  await doctorCommand()
1127
1685
  break