@ancleto/spec 0.6.28 → 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)
@@ -138,6 +139,36 @@ El framework **no gestiona tu `.gitignore`**: es una decisión del proyecto. Com
138
139
 
139
140
  ---
140
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
+
141
172
  ## Configuración y tiers de costo
142
173
 
143
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:
@@ -209,6 +240,15 @@ ancleto specs check [--change <nombre>] [--json]
209
240
  ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
210
241
  # Tokens por sesión de opencode (rollup de subagentes; --session desglosa por agente)
211
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
+
212
252
  ancleto doctor # Diagnostica el entorno (Node, node:sqlite, opencode.json)
213
253
 
214
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.28",
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
@@ -47,6 +47,14 @@ Uso:
47
47
  ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
48
48
  Tokens por sesion de opencode (entrada/salida/razonamiento/cache),
49
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"
50
58
  ancleto memory context [--scope X] [--out file]
51
59
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
52
60
  ancleto memory list [--type X] [--scope X] [--all] [--json]
@@ -101,6 +109,301 @@ function globalConfigDir() {
101
109
  : join(homedir(), '.config', 'opencode')
102
110
  }
103
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
+
104
407
  function binName(base) {
105
408
  return process.platform === 'win32' ? `${base}.exe` : base
106
409
  }
@@ -494,6 +797,8 @@ async function install(args) {
494
797
  await applyTier(join(target, 'agents'), tier, models)
495
798
  await writeFile(tierStatePath(target), tier + '\n')
496
799
 
800
+ if (projectDir) await registerProject(projectDir, { agent, tier })
801
+
497
802
  let azureMcp = false
498
803
  if (projectDir && withMcp) {
499
804
  const rc = await readAncletorc(projectDir)
@@ -591,6 +896,7 @@ async function initProject(args) {
591
896
  await copyTemplates(projectDir)
592
897
  await scaffoldAspec(projectDir)
593
898
  await refreshWorkingContext(projectDir)
899
+ await registerProject(projectDir, { agent, tier: tier || null })
594
900
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
595
901
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''}`)
596
902
  }
@@ -1363,6 +1669,17 @@ switch (cmd) {
1363
1669
  case 'stats':
1364
1670
  await statsCommand(rest)
1365
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
1366
1683
  case 'doctor':
1367
1684
  await doctorCommand()
1368
1685
  break