@ancleto/spec 0.2.0 → 0.2.1
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 +90 -88
- package/package.json +41 -41
- package/skills/ancleto-commit/SKILL.md +8 -8
- package/skills/ancleto-pr/SKILL.md +1 -1
- package/skills/openspec-recall/SKILL.md +91 -91
- package/skills/openspec-sync-specs/SKILL.md +149 -149
- package/skills/triage-clarifier/SKILL.md +100 -100
- package/src/cli/index.js +491 -491
- package/src/core/memory/database.js +49 -49
- package/src/core/memory/engine.js +103 -103
- package/src/core/memory/tools.js +69 -69
- package/templates/AGENTS.md +21 -21
- package/templates/PRODUCT.md +31 -120
- package/templates/CONTRIBUTING.md +0 -25
package/src/cli/index.js
CHANGED
|
@@ -1,492 +1,492 @@
|
|
|
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
|
-
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
|
+
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)
|
|
492
492
|
}
|