@ancleto/spec 0.6.37 → 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 +1 -1
- package/package.json +1 -1
- package/src/cli/index.js +38 -15
- package/src/core/memory/engine.js +2 -2
- package/src/core/memory/mcp-server.js +3 -1
- package/src/core/memory/tools.js +17 -3
- package/src/core/memory/working-context.js +46 -0
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,
|
|
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
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
|
|
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
|
-
|
|
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)
|
|
@@ -1083,6 +1110,7 @@ async function initProject(args) {
|
|
|
1083
1110
|
if (tier) {
|
|
1084
1111
|
await applyTier(join(projectDir, '.opencode', 'agents'), tier, tierModels(tier, TIERS, gratisModelChoice))
|
|
1085
1112
|
}
|
|
1113
|
+
await migrateLegacyOpenspec(projectDir)
|
|
1086
1114
|
await scaffoldAspec(projectDir)
|
|
1087
1115
|
await refreshWorkingContext(projectDir)
|
|
1088
1116
|
await registerProject(projectDir, { agent, tier: tier || null })
|
|
@@ -1419,16 +1447,11 @@ async function refreshWorkingContext(projectDir, scope = 'project') {
|
|
|
1419
1447
|
const dbPath = defaultMemoryDbPath(projectDir)
|
|
1420
1448
|
if (!(await exists(dbPath))) return null
|
|
1421
1449
|
const engine = createMemoryEngine(dbPath)
|
|
1422
|
-
let block
|
|
1423
1450
|
try {
|
|
1424
|
-
|
|
1451
|
+
return writeWorkingContext(engine, projectDir, scope)
|
|
1425
1452
|
} finally {
|
|
1426
1453
|
engine.close()
|
|
1427
1454
|
}
|
|
1428
|
-
const out = join(projectDir, '.ancleto', 'working-context.md')
|
|
1429
|
-
await mkdir(dirname(out), { recursive: true })
|
|
1430
|
-
await writeFile(out, block === null ? '' : block + '\n')
|
|
1431
|
-
return block === null ? null : out
|
|
1432
1455
|
}
|
|
1433
1456
|
|
|
1434
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
|
}
|
package/src/core/memory/tools.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
68
|
-
recordDecision: (args) =>
|
|
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
|
+
}
|