@ancleto/spec 0.2.0 → 0.2.2

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/src/cli/index.js CHANGED
@@ -1,492 +1,495 @@
1
- #!/usr/bin/env node
2
- import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
3
- import { spawnSync } from 'node:child_process'
4
- import { createHash } from 'node:crypto'
5
- import { createInterface } from 'node:readline'
6
- import { join, dirname, resolve, basename } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
- import { homedir, tmpdir } from 'node:os'
9
-
10
- const __dirname = dirname(fileURLToPath(import.meta.url))
11
- const ROOT = join(__dirname, '..', '..')
12
- const ASSETS = ['agents', 'commands', 'skills']
13
- const TEMPLATES = ['AGENTS.md', 'PRODUCT.md', 'CONTRIBUTING.md']
14
-
15
- const HELP = `ancleto - orquestador SDD liviano con subagentes optimizados para costo/tokens
16
- (alias: aspec)
17
-
18
- Uso:
19
- ancleto install [--project <dir>] Instala agents/commands/skills en opencode
20
- (global por defecto, o en .opencode/ del proyecto)
21
- Configura los MCP engram + caveman por defecto
22
- ancleto install --no-mcp Igual que install pero sin tocar config MCP
23
- ancleto install --tier <nivel> normal | minimo | gratis (pregunta en la 1ra config)
24
- ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
25
- ancleto init [--with-azure] Crea .ancletorc en el repositorio actual
26
- (Azure desactivado por defecto)
27
- ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
28
- ancleto discovery [--compress] [--include G] [--ignore G] [--token-budget N]
29
- Empaca el repo con Repomix y guarda estado
30
- ancleto --help Esta ayuda
31
- ancleto --version Version del paquete
32
- `
33
-
34
- async function exists(p) {
35
- try { await access(p); return true } catch { return false }
36
- }
37
-
38
- function globalConfigDir() {
39
- return process.env.XDG_CONFIG_HOME
40
- ? join(process.env.XDG_CONFIG_HOME, 'opencode')
41
- : join(homedir(), '.config', 'opencode')
42
- }
43
-
44
- function resolveBin(name, fallbacks = []) {
45
- const probe = process.platform === 'win32' ? 'where' : 'which'
46
- const r = spawnSync(probe, [name], { encoding: 'utf8' })
47
- if (r.status === 0 && r.stdout) {
48
- const first = r.stdout.split(/\r?\n/)
49
- .map((s) => s.trim())
50
- .find((s) => s && !/^informacion:/i.test(s))
51
- if (first) return first
52
- }
53
- for (const fb of fallbacks) {
54
- if (fb) return fb
55
- }
56
- return null
57
- }
58
-
59
- function buildDefaultMcp() {
60
- const mcp = {}
61
-
62
- const engramBin = resolveBin('engram', [join(homedir(), 'go', 'bin', 'engram.exe')])
63
- if (engramBin) {
64
- mcp.engram = { type: 'local', enabled: true, command: [engramBin, 'mcp', '--tools=agent'] }
65
- } else {
66
- console.warn('ancleto: no se encontro engram (memoria) en PATH; se omitio su MCP')
67
- }
68
-
69
- const cavemanBin = resolveBin('caveman-mcp', [
70
- join(homedir(), '.caveman', 'bin', 'caveman-mcp.exe')
71
- ])
72
- if (cavemanBin) {
73
- mcp.caveman = { type: 'local', enabled: true, command: [cavemanBin] }
74
- } else {
75
- console.warn('ancleto: no se encontro caveman-mcp (compresion) en PATH; se omitio su MCP')
76
- }
77
-
78
- return mcp
79
- }
80
-
81
- const TIERS = {
82
- normal: {
83
- orchestrator: 'opencode-go/qwen3.7-plus',
84
- coder: 'opencode-go/minimax-m3',
85
- tester: 'opencode-go/deepseek-v4-flash',
86
- 'spec-writer': 'opencode-go/qwen3.7-plus',
87
- reviewer: 'opencode-go/qwen3.6-plus',
88
- 'technical-discovery': 'opencode-go/deepseek-v4-flash',
89
- 'technical-seed-writer': 'opencode-go/minimax-m3',
90
- 'memory-keeper': 'opencode-go/deepseek-v4-flash',
91
- 'context-resolver': 'opencode-go/deepseek-v4-flash',
92
- documenter: 'opencode-go/deepseek-v4-flash'
93
- },
94
- minimo: {
95
- orchestrator: 'opencode-go/deepseek-v4-flash',
96
- coder: 'opencode-go/minimax-m2.7',
97
- tester: 'opencode-go/deepseek-v4-flash',
98
- 'spec-writer': 'opencode-go/qwen3.6-plus',
99
- reviewer: 'opencode-go/deepseek-v4-flash',
100
- 'technical-discovery': 'opencode-go/deepseek-v4-flash',
101
- 'technical-seed-writer': 'opencode-go/minimax-m2.7',
102
- 'memory-keeper': 'opencode-go/deepseek-v4-flash',
103
- 'context-resolver': 'opencode-go/deepseek-v4-flash',
104
- documenter: 'opencode-go/deepseek-v4-flash'
105
- },
106
- gratis: {
107
- orchestrator: 'opencode/big-pickle',
108
- coder: 'opencode/big-pickle',
109
- tester: 'opencode/big-pickle',
110
- 'spec-writer': 'opencode/big-pickle',
111
- reviewer: 'opencode/big-pickle',
112
- 'technical-discovery': 'opencode/big-pickle',
113
- 'technical-seed-writer': 'opencode/big-pickle',
114
- 'memory-keeper': 'opencode/big-pickle',
115
- 'context-resolver': 'opencode/big-pickle',
116
- documenter: 'opencode/big-pickle'
117
- }
118
- }
119
-
120
- function tierStatePath(targetDir) {
121
- return join(targetDir, '.ancleto-tier')
122
- }
123
-
124
- async function applyTier(agentsDir, tier) {
125
- const map = TIERS[tier]
126
- for (const [name, model] of Object.entries(map)) {
127
- const p = join(agentsDir, name + '.md')
128
- if (!(await exists(p))) continue
129
- const c = await readFile(p, 'utf8')
130
- const o = c.replace(/^model: .*$/m, `model: ${model}`)
131
- if (o !== c) await writeFile(p, o)
132
- }
133
- }
134
-
135
- function askTier() {
136
- return new Promise((resolve) => {
137
- const rl = createInterface({ input: process.stdin, output: process.stdout })
138
- rl.question('Tier de costo de los agents [normal/minimo/gratis] (default: normal): ', (a) => {
139
- rl.close()
140
- const t = a.trim().toLowerCase()
141
- resolve(TIERS[t] ? t : 'normal')
142
- })
143
- })
144
- }
145
-
146
- async function mergeMcp(configDir, mcpMap) {
147
- if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
148
-
149
- const existing = []
150
- for (const c of ['opencode.json', 'opencode.jsonc']) {
151
- const p = join(configDir, c)
152
- if (await exists(p)) existing.push(p)
153
- }
154
- const targets = existing.length ? existing : [join(configDir, 'opencode.json')]
155
-
156
- const seen = new Set()
157
- const added = []
158
- for (const file of targets) {
159
- let cfg = {}
160
- if (await exists(file)) {
161
- try {
162
- cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
163
- } catch {
164
- console.warn(`ancleto: no se pudo leer ${basename(file)} como JSON; MCP no se agrego ahi`)
165
- continue
166
- }
167
- }
168
- cfg.mcp = cfg.mcp || {}
169
- for (const [name, def] of Object.entries(mcpMap)) {
170
- if (seen.has(name)) continue
171
- if (cfg.mcp[name]) { seen.add(name); continue }
172
- cfg.mcp[name] = def
173
- seen.add(name)
174
- added.push(name)
175
- }
176
- await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
177
- }
178
- return { file: targets.join(', '), added: [...new Set(added)] }
179
- }
180
-
181
- async function copyAssets(dest) {
182
- await mkdir(dest, { recursive: true })
183
- for (const d of ASSETS) {
184
- await cp(join(ROOT, d), join(dest, d), { recursive: true })
185
- }
186
- }
187
-
188
- async function copyTemplates(projectDir) {
189
- const dest = join(projectDir, '.opencode')
190
- await copyAssets(dest)
191
- for (const t of TEMPLATES) {
192
- const target = join(projectDir, t)
193
- if (await exists(target)) continue
194
- await writeFile(target, await readFile(join(ROOT, 'templates', t)))
195
- }
196
- }
197
-
198
- async function install(args) {
199
- const pi = args.indexOf('--project')
200
- const project = pi >= 0 ? args[pi + 1] : null
201
- const withMcp = !args.includes('--no-mcp')
202
- const mcpMap = withMcp ? buildDefaultMcp() : {}
203
-
204
- const ti = args.indexOf('--tier')
205
- let tier = ti >= 0 ? args[ti + 1] : null
206
- if (tier && !TIERS[tier]) {
207
- console.error(`ancleto: tier invalido: ${tier} (normal|minimo|gratis)`)
208
- process.exit(1)
209
- }
210
-
211
- if (project) {
212
- const dir = resolve(project)
213
- if (!(await exists(dir))) {
214
- console.error(`ancleto: el directorio no existe: ${dir}`)
215
- process.exit(1)
216
- }
217
- }
218
-
219
- const target = project ? join(resolve(project), '.opencode') : globalConfigDir()
220
-
221
- if (project) {
222
- await copyTemplates(resolve(project))
223
- } else {
224
- await copyAssets(target)
225
- }
226
-
227
- if (!tier) {
228
- const stored = (await exists(tierStatePath(target)))
229
- ? (await readFile(tierStatePath(target), 'utf8')).trim()
230
- : null
231
- tier = TIERS[stored] ? stored : await askTier()
232
- }
233
- await applyTier(join(target, 'agents'), tier)
234
- await writeFile(tierStatePath(target), tier + '\n')
235
-
236
- const res = await mergeMcp(target, mcpMap)
237
- const loc = project
238
- ? `${resolve(project)} (.opencode/ + templates en la raiz)`
239
- : `${target} (disponible en todos tus proyectos)`
240
- console.log(`ancleto: instalado en ${loc}`)
241
- console.log(`ancleto: tier de costo de agents: ${tier}`)
242
- if (res.added.length) {
243
- console.log(`ancleto: MCP configurados: ${res.added.join(', ')} en ${res.file}`)
244
- }
245
- }
246
-
247
- async function initProject(args) {
248
- const rc = join(process.cwd(), '.ancletorc')
249
- if (await exists(rc)) {
250
- console.log('ancleto: .ancletorc ya existe, no se toca')
251
- return
252
- }
253
- const withAzure = args.includes('--with-azure')
254
- const content = JSON.stringify({
255
- version: 1,
256
- azure: { enabled: withAzure },
257
- discovery: {
258
- outputDir: 'docs/technical-discovery',
259
- exclude: []
260
- }
261
- }, null, 2)
262
- await writeFile(rc, content + '\n')
263
- const azureNote = withAzure ? ' (Azure habilitado)' : ' (Azure desactivado)'
264
- console.log(`ancleto: .ancletorc creado en ${process.cwd()}${azureNote}`)
265
- }
266
-
267
- const DEFAULT_IGNORES = ['node_modules', '.git', 'dist']
268
- const EXPECTED_DOCS = ['index.md', 'overview.md', 'setup.md', 'inventory.md', 'integrations.md', 'decisions.md', 'unknowns.md', 'units/_map.md']
269
-
270
- async function loadDiscoveryConfig() {
271
- const rc = join(process.cwd(), '.ancletorc')
272
- const defaults = { outputDir: 'docs/technical-discovery', exclude: [] }
273
- if (!(await exists(rc))) return defaults
274
- try {
275
- const cfg = JSON.parse((await readFile(rc, 'utf8')).replace(/^\uFEFF/, ''))
276
- const d = cfg.discovery || {}
277
- return {
278
- outputDir: d.outputDir || defaults.outputDir,
279
- exclude: Array.isArray(d.exclude) ? d.exclude : []
280
- }
281
- } catch {
282
- return defaults
283
- }
284
- }
285
-
286
- function escapeRe(s) {
287
- return s.replace(/[.+^${}()|[\]\\]/g, '\\$&')
288
- }
289
-
290
- function matchesGlob(rel, glob) {
291
- if (glob.includes('**')) {
292
- return new RegExp('^' + glob.split('**').map(escapeRe).join('.*') + '$').test(rel)
293
- }
294
- return new RegExp('^' + glob.split('*').map(escapeRe).join('[^/]*') + '(/|$)').test(rel)
295
- }
296
-
297
- function isIgnored(rel, exclude) {
298
- if (rel.split('/').some((seg) => DEFAULT_IGNORES.includes(seg))) return true
299
- return exclude.some((g) => matchesGlob(rel, g))
300
- }
301
-
302
- async function walk(dir, rel, exclude, out, outputDir) {
303
- const entries = await readdir(dir, { withFileTypes: true })
304
- for (const e of entries) {
305
- const relPath = rel ? `${rel}/${e.name}` : e.name
306
- if (relPath === outputDir) continue
307
- if (e.isDirectory()) {
308
- if (isIgnored(relPath, exclude)) continue
309
- await walk(join(dir, e.name), relPath, exclude, out, outputDir)
310
- } else if (e.isFile()) {
311
- if (isIgnored(relPath, exclude)) continue
312
- out.push(relPath)
313
- }
314
- }
315
- }
316
-
317
- async function computeSources() {
318
- const { outputDir, exclude } = await loadDiscoveryConfig()
319
- const sources = []
320
- await walk(process.cwd(), '', exclude, sources, outputDir)
321
- return sources.sort()
322
- }
323
-
324
- async function hashSources(sources) {
325
- const h = createHash('sha256')
326
- for (const rel of sources) {
327
- try {
328
- const content = await readFile(join(process.cwd(), rel))
329
- h.update(rel)
330
- h.update('\0')
331
- h.update(String(content.length))
332
- h.update('\0')
333
- h.update(content)
334
- h.update('\n')
335
- } catch {}
336
- }
337
- return h.digest('hex')
338
- }
339
-
340
- function statePath(outputDir) {
341
- return join(outputDir, '.discovery-state.json')
342
- }
343
-
344
- async function presentDocs(outputDir) {
345
- const present = []
346
- for (const d of EXPECTED_DOCS) {
347
- if (await exists(join(outputDir, d))) present.push(d)
348
- }
349
- return present
350
- }
351
-
352
- function flagValue(args, flag) {
353
- const i = args.indexOf(flag)
354
- return i >= 0 && args[i + 1] ? args[i + 1] : null
355
- }
356
-
357
- async function checkDiscovery() {
358
- const { outputDir } = await loadDiscoveryConfig()
359
- const present = await presentDocs(outputDir)
360
- let state, action, message, missingDocs
361
- if (present.length === 0) {
362
- state = 'MISSING'
363
- action = 'generate'
364
- message = 'No technical seed documents found.'
365
- missingDocs = EXPECTED_DOCS
366
- } else if (present.length < EXPECTED_DOCS.length) {
367
- state = 'PARTIAL'
368
- action = 'complete'
369
- missingDocs = EXPECTED_DOCS.filter((d) => !present.includes(d))
370
- message = `Faltan ${missingDocs.length} documentos del seed.`
371
- } else {
372
- const sources = await computeSources()
373
- const hash = await hashSources(sources)
374
- let storedHash = null
375
- const sp = statePath(outputDir)
376
- if (await exists(sp)) {
377
- try {
378
- storedHash = JSON.parse((await readFile(sp, 'utf8')).replace(/^\uFEFF/, '')).hash
379
- } catch {}
380
- }
381
- if (!storedHash) {
382
- state = 'STALE'
383
- action = 'regenerate'
384
- message = 'Seed completo pero sin estado registrado — frescura desconocida.'
385
- } else if (storedHash === hash) {
386
- state = 'READY'
387
- action = 'continue'
388
- message = 'El seed esta al dia.'
389
- } else {
390
- state = 'STALE'
391
- action = 'regenerate'
392
- message = 'El repositorio cambio desde el ultimo pack.'
393
- }
394
- missingDocs = []
395
- }
396
- console.log(JSON.stringify({
397
- schemaVersion: 2,
398
- state,
399
- recommendedAction: action,
400
- message,
401
- missingDocs
402
- }, null, 2))
403
- }
404
-
405
- async function runRepomix(flags, tmpFile) {
406
- const local = await resolveBin('repomix')
407
- const args = []
408
- if (!local) args.push('-y', 'repomix@1.18.0')
409
- args.push('--output', tmpFile)
410
- const inc = flagValue(flags, '--include')
411
- if (inc) args.push('--include', inc)
412
- const extraIgnore = flagValue(flags, '--ignore')
413
- const { exclude } = await loadDiscoveryConfig()
414
- const ignore = [...exclude, ...(extraIgnore ? extraIgnore.split(',') : [])].filter(Boolean)
415
- if (ignore.length) args.push('--ignore', ignore.join(','))
416
- if (flags.includes('--compress')) args.push('--compress')
417
- const cmd = local || 'npx'
418
- const r = spawnSync(cmd, args, { encoding: 'utf8', cwd: process.cwd(), shell: true })
419
- if (r.error) {
420
- console.error(`ancleto: no se pudo ejecutar repomix: ${r.error.message}`)
421
- process.exit(1)
422
- }
423
- if (r.status !== 0) {
424
- console.error(`ancleto: repomix fallo (exit ${r.status}):`)
425
- console.error((r.stderr || r.stdout || '').trim())
426
- process.exit(1)
427
- }
428
- return r
429
- }
430
-
431
- async function packDiscovery(flags) {
432
- const { outputDir } = await loadDiscoveryConfig()
433
- const tmpFile = join(tmpdir(), `ancleto-pack-${Date.now()}.txt`)
434
- await runRepomix(flags, tmpFile)
435
- let content = ''
436
- try { content = await readFile(tmpFile, 'utf8') } catch {}
437
- const tokens = Math.round(content.length / 4)
438
- const budget = flagValue(flags, '--token-budget')
439
- if (budget && tokens > Number(budget)) {
440
- console.error(`ancleto: el pack supera el token-budget (${tokens} > ${budget})`)
441
- process.exit(1)
442
- }
443
- const sources = await computeSources()
444
- const hash = await hashSources(sources)
445
- await mkdir(outputDir, { recursive: true })
446
- await writeFile(statePath(outputDir), JSON.stringify({
447
- version: 1,
448
- generatedAt: new Date().toISOString(),
449
- sources,
450
- hash,
451
- packTokens: tokens
452
- }, null, 2) + '\n')
453
- console.log(`ancleto: pack generado (${sources.length} archivos, ~${tokens} tokens)`)
454
- console.log(`ancleto: estado guardado en ${statePath(outputDir)}`)
455
- console.log(`ancleto: el seed lo genera la skill ancleto-technical-discovery a partir del pack`)
456
- }
457
-
458
- async function discovery(flags) {
459
- if (flags.includes('--check')) {
460
- await checkDiscovery()
461
- return
462
- }
463
- await packDiscovery(flags)
464
- }
465
-
466
- const [cmd, ...rest] = process.argv.slice(2)
467
-
468
- switch (cmd) {
469
- case 'install':
470
- case 'update':
471
- await install(rest)
472
- break
473
- case 'init':
474
- await initProject(rest)
475
- break
476
- case 'discovery':
477
- await discovery(rest)
478
- break
479
- case '--version':
480
- case '-v':
481
- console.log('ancleto 0.1.1')
482
- break
483
- case '--help':
484
- case '-h':
485
- case undefined:
486
- console.log(HELP)
487
- break
488
- default:
489
- console.error(`ancleto: comando desconocido: ${cmd}`)
490
- console.log(HELP)
491
- process.exit(1)
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { createHash } from 'node:crypto'
5
+ import { createInterface } from 'node:readline'
6
+ import { join, dirname, resolve, basename } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { homedir, tmpdir } from 'node:os'
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url))
11
+ const ROOT = join(__dirname, '..', '..')
12
+ const ASSETS = ['agents', 'commands', 'skills']
13
+ const TEMPLATES = ['AGENTS.md', 'PRODUCT.md']
14
+
15
+ const HELP = `ancleto - orquestador SDD liviano con subagentes optimizados para costo/tokens
16
+ (alias: aspec)
17
+
18
+ Uso:
19
+ ancleto install [--project <dir>] Instala agents/commands/skills en opencode
20
+ (global por defecto, o en .opencode/ del proyecto)
21
+ Configura los MCP engram + caveman por defecto
22
+ ancleto install --no-mcp Igual que install pero sin tocar config MCP
23
+ ancleto install --tier <nivel> normal | minimo | gratis (pregunta en la 1ra config)
24
+ ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
25
+ ancleto init [--with-azure] Crea .ancletorc en el repositorio actual
26
+ (Azure desactivado por defecto)
27
+ ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
28
+ ancleto discovery [--compress] [--include G] [--ignore G] [--token-budget N]
29
+ Empaca el repo con Repomix y guarda estado
30
+ ancleto --help Esta ayuda
31
+ ancleto --version Version del paquete
32
+ `
33
+
34
+ async function exists(p) {
35
+ try { await access(p); return true } catch { return false }
36
+ }
37
+
38
+ function globalConfigDir() {
39
+ return process.env.XDG_CONFIG_HOME
40
+ ? join(process.env.XDG_CONFIG_HOME, 'opencode')
41
+ : join(homedir(), '.config', 'opencode')
42
+ }
43
+
44
+ function resolveBin(name, fallbacks = []) {
45
+ const probe = process.platform === 'win32' ? 'where' : 'which'
46
+ const r = spawnSync(probe, [name], { encoding: 'utf8' })
47
+ if (r.status === 0 && r.stdout) {
48
+ const first = r.stdout.split(/\r?\n/)
49
+ .map((s) => s.trim())
50
+ .find((s) => s && !/^informacion:/i.test(s))
51
+ if (first) return first
52
+ }
53
+ for (const fb of fallbacks) {
54
+ if (fb) return fb
55
+ }
56
+ return null
57
+ }
58
+
59
+ function buildDefaultMcp() {
60
+ const mcp = {}
61
+
62
+ const engramBin = resolveBin('engram', [join(homedir(), 'go', 'bin', 'engram.exe')])
63
+ if (engramBin) {
64
+ mcp.engram = { type: 'local', enabled: true, command: [engramBin, 'mcp', '--tools=agent'] }
65
+ } else {
66
+ console.warn('ancleto: no se encontro engram (memoria) en PATH; se omitio su MCP')
67
+ }
68
+
69
+ const cavemanBin = resolveBin('caveman-mcp', [
70
+ join(homedir(), '.caveman', 'bin', 'caveman-mcp.exe')
71
+ ])
72
+ if (cavemanBin) {
73
+ mcp.caveman = { type: 'local', enabled: true, command: [cavemanBin] }
74
+ } else {
75
+ console.warn('ancleto: no se encontro caveman-mcp (compresion) en PATH; se omitio su MCP')
76
+ }
77
+
78
+ return mcp
79
+ }
80
+
81
+ const TIERS = {
82
+ normal: {
83
+ orchestrator: 'opencode-go/qwen3.7-plus',
84
+ coder: 'opencode-go/minimax-m3',
85
+ tester: 'opencode-go/deepseek-v4-flash',
86
+ 'spec-writer': 'opencode-go/qwen3.7-plus',
87
+ reviewer: 'opencode-go/qwen3.6-plus',
88
+ 'technical-discovery': 'opencode-go/deepseek-v4-flash',
89
+ 'technical-seed-writer': 'opencode-go/minimax-m3',
90
+ 'memory-keeper': 'opencode-go/deepseek-v4-flash',
91
+ 'context-resolver': 'opencode-go/deepseek-v4-flash',
92
+ documenter: 'opencode-go/deepseek-v4-flash'
93
+ },
94
+ minimo: {
95
+ orchestrator: 'opencode-go/deepseek-v4-flash',
96
+ coder: 'opencode-go/minimax-m2.7',
97
+ tester: 'opencode-go/deepseek-v4-flash',
98
+ 'spec-writer': 'opencode-go/qwen3.6-plus',
99
+ reviewer: 'opencode-go/deepseek-v4-flash',
100
+ 'technical-discovery': 'opencode-go/deepseek-v4-flash',
101
+ 'technical-seed-writer': 'opencode-go/minimax-m2.7',
102
+ 'memory-keeper': 'opencode-go/deepseek-v4-flash',
103
+ 'context-resolver': 'opencode-go/deepseek-v4-flash',
104
+ documenter: 'opencode-go/deepseek-v4-flash'
105
+ },
106
+ gratis: {
107
+ orchestrator: 'opencode/big-pickle',
108
+ coder: 'opencode/big-pickle',
109
+ tester: 'opencode/big-pickle',
110
+ 'spec-writer': 'opencode/big-pickle',
111
+ reviewer: 'opencode/big-pickle',
112
+ 'technical-discovery': 'opencode/big-pickle',
113
+ 'technical-seed-writer': 'opencode/big-pickle',
114
+ 'memory-keeper': 'opencode/big-pickle',
115
+ 'context-resolver': 'opencode/big-pickle',
116
+ documenter: 'opencode/big-pickle'
117
+ }
118
+ }
119
+
120
+ function tierStatePath(targetDir) {
121
+ return join(targetDir, '.ancleto-tier')
122
+ }
123
+
124
+ async function applyTier(agentsDir, tier) {
125
+ const map = TIERS[tier]
126
+ for (const [name, model] of Object.entries(map)) {
127
+ const p = join(agentsDir, name + '.md')
128
+ if (!(await exists(p))) continue
129
+ const c = await readFile(p, 'utf8')
130
+ const o = c.replace(/^model: .*$/m, `model: ${model}`)
131
+ if (o !== c) await writeFile(p, o)
132
+ }
133
+ }
134
+
135
+ function askTier() {
136
+ return new Promise((resolve) => {
137
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
138
+ rl.question('Tier de costo de los agents [normal/minimo/gratis] (default: normal): ', (a) => {
139
+ rl.close()
140
+ const t = a.trim().toLowerCase()
141
+ resolve(TIERS[t] ? t : 'normal')
142
+ })
143
+ })
144
+ }
145
+
146
+ async function mergeMcp(configDir, mcpMap) {
147
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
148
+
149
+ const existing = []
150
+ for (const c of ['opencode.json', 'opencode.jsonc']) {
151
+ const p = join(configDir, c)
152
+ if (await exists(p)) existing.push(p)
153
+ }
154
+ const targets = existing.length ? existing : [join(configDir, 'opencode.json')]
155
+
156
+ const seen = new Set()
157
+ const added = []
158
+ for (const file of targets) {
159
+ let cfg = {}
160
+ if (await exists(file)) {
161
+ try {
162
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
163
+ } catch {
164
+ console.warn(`ancleto: no se pudo leer ${basename(file)} como JSON; MCP no se agrego ahi`)
165
+ continue
166
+ }
167
+ }
168
+ cfg.mcp = cfg.mcp || {}
169
+ for (const [name, def] of Object.entries(mcpMap)) {
170
+ if (seen.has(name)) continue
171
+ if (cfg.mcp[name]) { seen.add(name); continue }
172
+ cfg.mcp[name] = def
173
+ seen.add(name)
174
+ added.push(name)
175
+ }
176
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
177
+ }
178
+ return { file: targets.join(', '), added: [...new Set(added)] }
179
+ }
180
+
181
+ async function copyAssets(dest) {
182
+ await mkdir(dest, { recursive: true })
183
+ for (const d of ASSETS) {
184
+ await cp(join(ROOT, d), join(dest, d), { recursive: true })
185
+ }
186
+ }
187
+
188
+ async function copyTemplates(projectDir) {
189
+ const dest = join(projectDir, '.opencode')
190
+ await copyAssets(dest)
191
+ for (const t of TEMPLATES) {
192
+ const target = join(projectDir, t)
193
+ if (await exists(target)) continue
194
+ await writeFile(target, await readFile(join(ROOT, 'templates', t)))
195
+ }
196
+ }
197
+
198
+ async function install(args) {
199
+ const pi = args.indexOf('--project')
200
+ const project = pi >= 0 ? args[pi + 1] : null
201
+ const withMcp = !args.includes('--no-mcp')
202
+ const mcpMap = withMcp ? buildDefaultMcp() : {}
203
+
204
+ const ti = args.indexOf('--tier')
205
+ let tier = ti >= 0 ? args[ti + 1] : null
206
+ if (tier && !TIERS[tier]) {
207
+ console.error(`ancleto: tier invalido: ${tier} (normal|minimo|gratis)`)
208
+ process.exit(1)
209
+ }
210
+
211
+ if (project) {
212
+ const dir = resolve(project)
213
+ if (!(await exists(dir))) {
214
+ console.error(`ancleto: el directorio no existe: ${dir}`)
215
+ process.exit(1)
216
+ }
217
+ }
218
+
219
+ const target = project ? join(resolve(project), '.opencode') : globalConfigDir()
220
+
221
+ if (project) {
222
+ await copyTemplates(resolve(project))
223
+ } else {
224
+ await copyAssets(target)
225
+ }
226
+
227
+ if (!tier) {
228
+ const stored = (await exists(tierStatePath(target)))
229
+ ? (await readFile(tierStatePath(target), 'utf8')).trim()
230
+ : null
231
+ tier = TIERS[stored] ? stored : await askTier()
232
+ }
233
+ await applyTier(join(target, 'agents'), tier)
234
+ await writeFile(tierStatePath(target), tier + '\n')
235
+
236
+ const res = await mergeMcp(target, mcpMap)
237
+ const loc = project
238
+ ? `${resolve(project)} (.opencode/ + templates en la raiz)`
239
+ : `${target} (disponible en todos tus proyectos)`
240
+ console.log(`ancleto: instalado en ${loc}`)
241
+ console.log(`ancleto: tier de costo de agents: ${tier}`)
242
+ if (res.added.length) {
243
+ console.log(`ancleto: MCP configurados: ${res.added.join(', ')} en ${res.file}`)
244
+ }
245
+ }
246
+
247
+ async function initProject(args) {
248
+ const rc = join(process.cwd(), '.ancletorc')
249
+ if (await exists(rc)) {
250
+ console.log('ancleto: .ancletorc ya existe, no se toca')
251
+ return
252
+ }
253
+ const withAzure = args.includes('--with-azure')
254
+ const content = JSON.stringify({
255
+ version: 1,
256
+ azure: { enabled: withAzure },
257
+ discovery: {
258
+ outputDir: 'docs/technical-discovery',
259
+ exclude: []
260
+ }
261
+ }, null, 2)
262
+ await writeFile(rc, content + '\n')
263
+ const azureNote = withAzure ? ' (Azure habilitado)' : ' (Azure desactivado)'
264
+ console.log(`ancleto: .ancletorc creado en ${process.cwd()}${azureNote}`)
265
+ }
266
+
267
+ const DEFAULT_IGNORES = ['node_modules', '.git', 'dist']
268
+ const EXPECTED_DOCS = ['index.md', 'overview.md', 'setup.md', 'inventory.md', 'integrations.md', 'decisions.md', 'unknowns.md', 'units/_map.md']
269
+
270
+ async function loadDiscoveryConfig() {
271
+ const rc = join(process.cwd(), '.ancletorc')
272
+ const defaults = { outputDir: 'docs/technical-discovery', exclude: [] }
273
+ if (!(await exists(rc))) return defaults
274
+ try {
275
+ const cfg = JSON.parse((await readFile(rc, 'utf8')).replace(/^\uFEFF/, ''))
276
+ const d = cfg.discovery || {}
277
+ return {
278
+ outputDir: d.outputDir || defaults.outputDir,
279
+ exclude: Array.isArray(d.exclude) ? d.exclude : []
280
+ }
281
+ } catch {
282
+ return defaults
283
+ }
284
+ }
285
+
286
+ function escapeRe(s) {
287
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&')
288
+ }
289
+
290
+ function matchesGlob(rel, glob) {
291
+ if (glob.includes('**')) {
292
+ return new RegExp('^' + glob.split('**').map(escapeRe).join('.*') + '$').test(rel)
293
+ }
294
+ return new RegExp('^' + glob.split('*').map(escapeRe).join('[^/]*') + '(/|$)').test(rel)
295
+ }
296
+
297
+ function isIgnored(rel, exclude) {
298
+ if (rel.split('/').some((seg) => DEFAULT_IGNORES.includes(seg))) return true
299
+ return exclude.some((g) => matchesGlob(rel, g))
300
+ }
301
+
302
+ async function walk(dir, rel, exclude, out, outputDir) {
303
+ const entries = await readdir(dir, { withFileTypes: true })
304
+ for (const e of entries) {
305
+ const relPath = rel ? `${rel}/${e.name}` : e.name
306
+ if (relPath === outputDir) continue
307
+ if (e.isDirectory()) {
308
+ if (isIgnored(relPath, exclude)) continue
309
+ await walk(join(dir, e.name), relPath, exclude, out, outputDir)
310
+ } else if (e.isFile()) {
311
+ if (isIgnored(relPath, exclude)) continue
312
+ out.push(relPath)
313
+ }
314
+ }
315
+ }
316
+
317
+ async function computeSources() {
318
+ const { outputDir, exclude } = await loadDiscoveryConfig()
319
+ const sources = []
320
+ await walk(process.cwd(), '', exclude, sources, outputDir)
321
+ return sources.sort()
322
+ }
323
+
324
+ async function hashSources(sources) {
325
+ const h = createHash('sha256')
326
+ for (const rel of sources) {
327
+ try {
328
+ const content = await readFile(join(process.cwd(), rel))
329
+ h.update(rel)
330
+ h.update('\0')
331
+ h.update(String(content.length))
332
+ h.update('\0')
333
+ h.update(content)
334
+ h.update('\n')
335
+ } catch {}
336
+ }
337
+ return h.digest('hex')
338
+ }
339
+
340
+ function statePath(outputDir) {
341
+ return join(outputDir, '.discovery-state.json')
342
+ }
343
+
344
+ async function presentDocs(outputDir) {
345
+ const present = []
346
+ for (const d of EXPECTED_DOCS) {
347
+ if (await exists(join(outputDir, d))) present.push(d)
348
+ }
349
+ return present
350
+ }
351
+
352
+ function flagValue(args, flag) {
353
+ const i = args.indexOf(flag)
354
+ return i >= 0 && args[i + 1] ? args[i + 1] : null
355
+ }
356
+
357
+ async function checkDiscovery() {
358
+ const { outputDir } = await loadDiscoveryConfig()
359
+ const present = await presentDocs(outputDir)
360
+ let state, action, message, missingDocs
361
+ if (present.length === 0) {
362
+ state = 'MISSING'
363
+ action = 'generate'
364
+ message = 'No technical seed documents found.'
365
+ missingDocs = EXPECTED_DOCS
366
+ } else if (present.length < EXPECTED_DOCS.length) {
367
+ state = 'PARTIAL'
368
+ action = 'complete'
369
+ missingDocs = EXPECTED_DOCS.filter((d) => !present.includes(d))
370
+ message = `Faltan ${missingDocs.length} documentos del seed.`
371
+ } else {
372
+ const sources = await computeSources()
373
+ const hash = await hashSources(sources)
374
+ let storedHash = null
375
+ const sp = statePath(outputDir)
376
+ if (await exists(sp)) {
377
+ try {
378
+ storedHash = JSON.parse((await readFile(sp, 'utf8')).replace(/^\uFEFF/, '')).hash
379
+ } catch {}
380
+ }
381
+ if (!storedHash) {
382
+ state = 'STALE'
383
+ action = 'regenerate'
384
+ message = 'Seed completo pero sin estado registrado — frescura desconocida.'
385
+ } else if (storedHash === hash) {
386
+ state = 'READY'
387
+ action = 'continue'
388
+ message = 'El seed esta al dia.'
389
+ } else {
390
+ state = 'STALE'
391
+ action = 'regenerate'
392
+ message = 'El repositorio cambio desde el ultimo pack.'
393
+ }
394
+ missingDocs = []
395
+ }
396
+ console.log(JSON.stringify({
397
+ schemaVersion: 2,
398
+ state,
399
+ recommendedAction: action,
400
+ message,
401
+ missingDocs
402
+ }, null, 2))
403
+ }
404
+
405
+ async function runRepomix(flags, tmpFile) {
406
+ const local = await resolveBin('repomix')
407
+ const args = []
408
+ if (!local) args.push('-y', 'repomix@1.18.0')
409
+ args.push('--output', tmpFile)
410
+ const inc = flagValue(flags, '--include')
411
+ if (inc) args.push('--include', inc)
412
+ const extraIgnore = flagValue(flags, '--ignore')
413
+ const { exclude } = await loadDiscoveryConfig()
414
+ const ignore = [...exclude, ...(extraIgnore ? extraIgnore.split(',') : [])].filter(Boolean)
415
+ if (ignore.length) args.push('--ignore', ignore.join(','))
416
+ if (flags.includes('--compress')) args.push('--compress')
417
+ const cmd = local || 'npx'
418
+ const r = spawnSync(cmd, args, { encoding: 'utf8', cwd: process.cwd(), shell: true })
419
+ if (r.error) {
420
+ console.error(`ancleto: no se pudo ejecutar repomix: ${r.error.message}`)
421
+ process.exit(1)
422
+ }
423
+ if (r.status !== 0) {
424
+ console.error(`ancleto: repomix fallo (exit ${r.status}):`)
425
+ console.error((r.stderr || r.stdout || '').trim())
426
+ process.exit(1)
427
+ }
428
+ return r
429
+ }
430
+
431
+ async function packDiscovery(flags) {
432
+ const { outputDir } = await loadDiscoveryConfig()
433
+ const tmpFile = join(tmpdir(), `ancleto-pack-${Date.now()}.txt`)
434
+ await runRepomix(flags, tmpFile)
435
+ let content = ''
436
+ try { content = await readFile(tmpFile, 'utf8') } catch {}
437
+ const tokens = Math.round(content.length / 4)
438
+ const budget = flagValue(flags, '--token-budget')
439
+ if (budget && tokens > Number(budget)) {
440
+ console.error(`ancleto: el pack supera el token-budget (${tokens} > ${budget})`)
441
+ process.exit(1)
442
+ }
443
+ const sources = await computeSources()
444
+ const hash = await hashSources(sources)
445
+ await mkdir(outputDir, { recursive: true })
446
+ await writeFile(statePath(outputDir), JSON.stringify({
447
+ version: 1,
448
+ generatedAt: new Date().toISOString(),
449
+ sources,
450
+ hash,
451
+ packTokens: tokens
452
+ }, null, 2) + '\n')
453
+ console.log(`ancleto: pack generado (${sources.length} archivos, ~${tokens} tokens)`)
454
+ console.log(`ancleto: estado guardado en ${statePath(outputDir)}`)
455
+ console.log(`ancleto: el seed lo genera la skill ancleto-technical-discovery a partir del pack`)
456
+ }
457
+
458
+ async function discovery(flags) {
459
+ if (flags.includes('--check')) {
460
+ await checkDiscovery()
461
+ return
462
+ }
463
+ await packDiscovery(flags)
464
+ }
465
+
466
+ const [cmd, ...rest] = process.argv.slice(2)
467
+
468
+ switch (cmd) {
469
+ case 'install':
470
+ case 'update':
471
+ await install(rest)
472
+ break
473
+ case 'init':
474
+ await initProject(rest)
475
+ break
476
+ case 'discovery':
477
+ await discovery(rest)
478
+ break
479
+ case '--version':
480
+ case '-v':
481
+ {
482
+ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
483
+ console.log(`ancleto ${pkg.version}`)
484
+ }
485
+ break
486
+ case '--help':
487
+ case '-h':
488
+ case undefined:
489
+ console.log(HELP)
490
+ break
491
+ default:
492
+ console.error(`ancleto: comando desconocido: ${cmd}`)
493
+ console.log(HELP)
494
+ process.exit(1)
492
495
  }