@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 +45 -0
- package/package.json +1 -1
- package/src/cli/index.js +558 -0
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
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
|