@ancleto/spec 0.6.36 → 0.6.39

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
@@ -333,7 +333,7 @@ Funciona en tres piezas que comparten el mismo archivo:
333
333
  | `recordRule` | Guarda una regla o restricción permanente. |
334
334
  | `recordDecision` | Registra el *por qué* de una decisión. |
335
335
 
336
- Cada entrada tiene un `scope`: `project` (default, entra en `<ProjectMemoryRules>`), `feature` o `task`. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
336
+ Cada entrada tiene un `scope`: `project` (default), `feature` o `task`. Solo las **reglas** activas con scope `project` entran en `<ProjectMemoryRules>`; las **decisiones** con scope `project` se recuperan reactivamente con `searchMemory` y no se inyectan de forma proactiva. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
337
337
 
338
338
  > El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera solo en `init`, `install --project` y `upgrade`.
339
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.36",
3
+ "version": "0.6.39",
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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs'
3
- import { cp, mkdir, access, writeFile, readFile, readdir, rename } from 'node:fs/promises'
3
+ import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { createInterface } from 'node:readline'
@@ -9,6 +9,7 @@ import { join, dirname, resolve, basename, relative } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { homedir, tmpdir } from 'node:os'
11
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
12
+ import { writeWorkingContext } from '../core/memory/working-context.js'
12
13
  import { memoryDoctor } from '../core/memory/doctor.js'
13
14
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
14
15
  import { writeDiscoveryMap } from '../core/discovery.js'
@@ -775,6 +776,39 @@ async function scaffoldAspec(projectDir) {
775
776
  }
776
777
  }
777
778
 
779
+ const LEGACY_OPENSPEC_DIR = 'openspec'
780
+ const MIGRATION_MARKER = '.migrated-from-openspec'
781
+
782
+ // "Contenido real" = al menos una entrada bajo changes/ o specs/. Un config.yaml
783
+ // solo (o el scaffold vacio) no cuenta como real: no bloquea la migracion.
784
+ async function hasRealContent(aspecDir) {
785
+ for (const sub of ['changes', 'specs']) {
786
+ try {
787
+ if ((await readdir(join(aspecDir, sub))).length > 0) return true
788
+ } catch {
789
+ // subcarpeta inexistente o ilegible: no aporta contenido real
790
+ }
791
+ }
792
+ return false
793
+ }
794
+
795
+ // Migra la carpeta legacy openspec/ a aspec/ por copia recursiva (no destructiva).
796
+ // Unico dueno del invariante: la invocan init y upgrade antes del scaffold.
797
+ async function migrateLegacyOpenspec(projectDir) {
798
+ const legacyDir = join(projectDir, LEGACY_OPENSPEC_DIR)
799
+ if (!(await exists(legacyDir))) return // sin legacy: no-op silencioso
800
+ const aspecDir = join(projectDir, CHANGES_ROOT)
801
+ const marker = join(aspecDir, MIGRATION_MARKER)
802
+ if (await exists(marker)) return // ya migrada: no-op silencioso
803
+ if (await hasRealContent(aspecDir)) {
804
+ console.warn('ancleto: aspec/ ya tiene contenido real — no se migro openspec/ (revisar manualmente; openspec/ se conserva)')
805
+ return
806
+ }
807
+ await cp(legacyDir, aspecDir, { recursive: true, force: false })
808
+ await writeFile(marker, '# Generado por ancleto: esta aspec/ ya fue migrada desde openspec/\n')
809
+ console.log('ancleto: contenido migrado de openspec/ a aspec/ (openspec/ se conserva como backup)')
810
+ }
811
+
778
812
  function extractLockedBlocks(content) {
779
813
  const blocks = new Map()
780
814
  const re = /<!--\s*LOCKED:\s*([\w-]+)\s*-->([\s\S]*?)<!--\s*\/LOCKED:\s*\1\s*-->/g
@@ -982,14 +1016,7 @@ async function upgradeCmd(args) {
982
1016
  console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
983
1017
  process.exit(1)
984
1018
  }
985
- const legacyChanges = join(projectDir, 'openspec')
986
- const changesRoot = join(projectDir, CHANGES_ROOT)
987
- if ((await exists(legacyChanges)) && !(await exists(changesRoot))) {
988
- await rename(legacyChanges, changesRoot)
989
- console.log('ancleto: changes migrados de openspec/ a aspec/')
990
- } else if ((await exists(legacyChanges)) && (await exists(changesRoot))) {
991
- console.warn('ancleto: existen openspec/ y aspec/ — no se migro nada (revisar manualmente)')
992
- }
1019
+ await migrateLegacyOpenspec(projectDir)
993
1020
  const agent = await resolveAgent(args, rc.agent)
994
1021
  await copyTemplates(projectDir)
995
1022
  const agentSkillsDir = await installAgentSkills(projectDir, agent)
@@ -1049,6 +1076,12 @@ async function initProject(args) {
1049
1076
  } else {
1050
1077
  agent = await resolveAgent(args, existing?.agent, false)
1051
1078
  tier = tierFlag
1079
+ if (!tier) {
1080
+ // Paridad con install: sin flag se respeta el tier guardado. Sin esto un
1081
+ // re-init pelado re-copiaba los agents de origen y perdia los modelos aplicados.
1082
+ const stored = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
1083
+ if (stored && TIERS[stored]) tier = stored
1084
+ }
1052
1085
  if (!language) language = 'auto'
1053
1086
  if (withAzure) azure.enabled = true
1054
1087
  }
@@ -1077,6 +1110,7 @@ async function initProject(args) {
1077
1110
  if (tier) {
1078
1111
  await applyTier(join(projectDir, '.opencode', 'agents'), tier, tierModels(tier, TIERS, gratisModelChoice))
1079
1112
  }
1113
+ await migrateLegacyOpenspec(projectDir)
1080
1114
  await scaffoldAspec(projectDir)
1081
1115
  await refreshWorkingContext(projectDir)
1082
1116
  await registerProject(projectDir, { agent, tier: tier || null })
@@ -1413,16 +1447,11 @@ async function refreshWorkingContext(projectDir, scope = 'project') {
1413
1447
  const dbPath = defaultMemoryDbPath(projectDir)
1414
1448
  if (!(await exists(dbPath))) return null
1415
1449
  const engine = createMemoryEngine(dbPath)
1416
- let block
1417
1450
  try {
1418
- block = engine.buildWorkingContext(scope)
1451
+ return writeWorkingContext(engine, projectDir, scope)
1419
1452
  } finally {
1420
1453
  engine.close()
1421
1454
  }
1422
- const out = join(projectDir, '.ancleto', 'working-context.md')
1423
- await mkdir(dirname(out), { recursive: true })
1424
- await writeFile(out, block === null ? '' : block + '\n')
1425
- return block === null ? null : out
1426
1455
  }
1427
1456
 
1428
1457
  async function memoryContext(flags) {
@@ -130,8 +130,8 @@ function buildEngine(db) {
130
130
  WHERE memory_fts MATCH ? AND n.status = 'active'`
131
131
  const run = (match, loose) => {
132
132
  const sql = type
133
- ? db.prepare(`${base} AND n.type = ? ORDER BY rank LIMIT ?`)
134
- : db.prepare(`${base} ORDER BY rank LIMIT ?`)
133
+ ? db.prepare(`${base} AND n.type = ? ORDER BY rank, n.rowid LIMIT ?`)
134
+ : db.prepare(`${base} ORDER BY rank, n.rowid LIMIT ?`)
135
135
  // FTS5 no acepta parametros en ORDER BY rank; el prefijo '*' va dentro del MATCH.
136
136
  try {
137
137
  return type ? sql.all(match, type, lim) : sql.all(match, lim)
@@ -1,5 +1,6 @@
1
1
  import { createMemoryEngine, defaultMemoryDbPath } from './engine.js'
2
2
  import { memoryTools, createMemoryToolHandlers } from './tools.js'
3
+ import { resolveMemoryProjectRoot } from './working-context.js'
3
4
 
4
5
  const PROTOCOL_VERSION = '2024-11-05'
5
6
  const SERVER_INFO = { name: 'ancleto-memory', version: '1.0.0' }
@@ -7,11 +8,12 @@ const SERVER_INFO = { name: 'ancleto-memory', version: '1.0.0' }
7
8
  export function createMemoryServer({ dbPath = defaultMemoryDbPath() } = {}) {
8
9
  let engine = null
9
10
  let handlers = null
11
+ const projectRoot = resolveMemoryProjectRoot(dbPath)
10
12
 
11
13
  function toolkit() {
12
14
  if (!engine) {
13
15
  engine = createMemoryEngine(dbPath)
14
- handlers = createMemoryToolHandlers(engine, { source: 'mcp:ancleto-memory' })
16
+ handlers = createMemoryToolHandlers(engine, { source: 'mcp:ancleto-memory', projectRoot })
15
17
  }
16
18
  return handlers
17
19
  }
@@ -1,11 +1,12 @@
1
1
  import { createMemoryEngine } from './engine.js'
2
+ import { shouldRefreshWorkingContext, writeWorkingContext } from './working-context.js'
2
3
 
3
4
  const KEY_DESCRIPTION = 'Clave conceptual estable (ej. "api-error-format"). Reusala para actualizar: la nueva version supersede automaticamente la anterior.'
4
5
 
5
6
  const SCOPE_SCHEMA = {
6
7
  type: 'string',
7
8
  enum: ['project', 'feature', 'task'],
8
- description: 'Ambito: project (default, entra en <ProjectMemoryRules>), feature o task para algo mas acotado.'
9
+ description: 'Ambito: project (default; las reglas con scope project entran en <ProjectMemoryRules>, las decisiones se recuperan reactivamente con searchMemory), feature o task para algo mas acotado.'
9
10
  }
10
11
 
11
12
  const SEARCH_SCHEMA = {
@@ -62,10 +63,23 @@ export const memoryTools = [
62
63
  ]
63
64
 
64
65
  export function createMemoryToolHandlers(engine, runtimeContext = {}) {
66
+ function record(args, type, defaultSource) {
67
+ const result = engine.recordNode({ ...args, type }, { ...runtimeContext, source: runtimeContext.source || defaultSource })
68
+ if (runtimeContext.projectRoot && shouldRefreshWorkingContext(result)) {
69
+ try {
70
+ writeWorkingContext(engine, runtimeContext.projectRoot)
71
+ } catch (err) {
72
+ // stderr: stdout es el canal JSON-RPC del server MCP.
73
+ console.warn(`ancleto: no se pudo refrescar el working-context: ${err.message}`)
74
+ }
75
+ }
76
+ return result
77
+ }
78
+
65
79
  return {
66
80
  searchMemory: (args) => engine.searchMemory(args),
67
- recordRule: (args) => engine.recordNode({ ...args, type: 'rule' }, { ...runtimeContext, source: runtimeContext.source || 'tool:recordRule' }),
68
- recordDecision: (args) => engine.recordNode({ ...args, type: 'decision' }, { ...runtimeContext, source: runtimeContext.source || 'tool:recordDecision' })
81
+ recordRule: (args) => record(args, 'rule', 'tool:recordRule'),
82
+ recordDecision: (args) => record(args, 'decision', 'tool:recordDecision')
69
83
  }
70
84
  }
71
85
 
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ // Invariante "memoria activa -> working-context.md": render desde el engine vivo
5
+ // (una sola conexion, sin reabrir la DB) y persistencia del artefacto derivado.
6
+
7
+ export function workingContextPath(projectRoot) {
8
+ return join(projectRoot, '.ancleto', 'working-context.md')
9
+ }
10
+
11
+ // Trigger del refresh sobre el resultado completo de `recordNode` `{ node, superseded }`:
12
+ // (a) contribucion directa: el nodo entrante es rule + project (alimenta el bloque);
13
+ // (b) efecto por supersesion: `memory_key` es identidad transversal (supersede sin filtrar
14
+ // type/scope), asi que cualquier supersesion pudo retirar del bloque una rule de scope project.
15
+ // El over-refresh (supersesion que no altera el bloque) es intencional: el engine solo expone
16
+ // `{ node, superseded }` y el unico consumidor lee el archivo una vez por sesion.
17
+ export function shouldRefreshWorkingContext(result) {
18
+ return !!result && ((result.node.type === 'rule' && result.node.scope === 'project') || result.superseded > 0)
19
+ }
20
+
21
+ // Deriva la raiz del proyecto desde la ubicacion de la DB (no desde el cwd del proceso).
22
+ // Prioriza el primer ancestro con `.ancletorc`; si no hay, usa el candidato derivado de
23
+ // `dbPath` mientras exista el layout `.ancleto/`; si no, no hay raiz (refresh best-effort).
24
+ export function resolveMemoryProjectRoot(dbPath) {
25
+ if (!dbPath) return null
26
+ const candidate = dirname(dirname(dbPath))
27
+ for (let dir = candidate; ; dir = dirname(dir)) {
28
+ if (existsSync(join(dir, '.ancletorc'))) return dir
29
+ const parent = dirname(dir)
30
+ if (parent === dir) break
31
+ }
32
+ if (existsSync(join(candidate, '.ancleto'))) return candidate
33
+ return null
34
+ }
35
+
36
+ // Render desde el engine vivo con la raiz explicita (topologia + salida) y escritura
37
+ // sincrona del archivo: paridad con `block + '\n'` y `''` cuando el bloque es null.
38
+ // Retorna el path escrito o null si no hay bloque/raiz.
39
+ export function writeWorkingContext(engine, projectRoot, scope = 'project') {
40
+ if (!projectRoot) return null
41
+ const block = engine.buildWorkingContext(scope, undefined, projectRoot)
42
+ const out = workingContextPath(projectRoot)
43
+ mkdirSync(dirname(out), { recursive: true })
44
+ writeFileSync(out, block === null ? '' : block + '\n')
45
+ return block === null ? null : out
46
+ }