@ancleto/spec 0.15.3 → 0.16.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/README.md CHANGED
@@ -401,8 +401,14 @@ ancleto memory list [--type rule|decision] [--scope project|feature|task] [--all
401
401
  # Lista los nodos de memoria (read-only: no toca la base)
402
402
 
403
403
  ancleto memory doctor [--rebuild]
404
- # Diagnostica la base de memoria, reconstruye el índice FTS5 y
405
- # fusiona el WAL en el .db (deja la base segura para copiar)
404
+ # Diagnostica la base de memoria (read-only por defecto);
405
+ # --rebuild es el único camino que escribe y reconstruye el índice FTS5
406
+
407
+ ancleto memory snapshot --out <archivo.db> [--force] [--json]
408
+ # Copia consistente de la base vía VACUUM INTO
409
+
410
+ ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]
411
+ # Fusiona nodos fuente en el target y supersede los sources
406
412
 
407
413
  ancleto mcp # Servidor MCP de memoria propia (stdio): lo consume tu IDE
408
414
 
@@ -422,7 +428,7 @@ Funciona en tres piezas que comparten el mismo archivo:
422
428
  |---|---|
423
429
  | **Almacenamiento** | Reglas y decisiones con supersesión atómica por `memory_key` (una sola activa por clave). |
424
430
  | **Tools del LLM** (`ancleto mcp`) | Servidor MCP local, sin dependencias, que expone tres herramientas a tu IDE. |
425
- | **CLI** | `ancleto memory context` materializa el bloque `<ProjectMemoryRules>`, `ancleto memory list` permite inspeccionar los nodos sin escribir, y `ancleto memory doctor` verifica integridad, reconstruye el índice FTS5 y fusiona el WAL. |
431
+ | **CLI** | `ancleto memory context` materializa el bloque `<ProjectMemoryRules>`, `ancleto memory list` permite inspeccionar los nodos sin escribir, y `ancleto memory doctor` verifica integridad (read-only por defecto; `--rebuild` reconstruye el índice FTS5). |
426
432
 
427
433
  | Herramienta | Función |
428
434
  |---|---|
@@ -79,7 +79,7 @@ For `STALE`, delegate `regenerate` to `@technical-seed-writer` only after explic
79
79
  - **NEVER** execute `ancleto discovery --check`; delegate repository orientation to `@technical-discovery`
80
80
  - **NEVER** use `bash` for code implementation
81
81
  - **NEVER** create/modify implementation files
82
- - **NEVER** skip the User Checkpoint after `@spec-writer` when spec generation was required
82
+ - **NEVER** skip the contract checkpoint after `@spec-writer` when spec generation was required
83
83
  - **NEVER** implement code directly - only delegate to @coder
84
84
  - **NEVER** auto-iterate between subagents without explicit user approval
85
85
  - **NEVER** continue to the next stage if a subagent reports blocking issues, ambiguity, or incomplete output
@@ -90,7 +90,7 @@ For `STALE`, delegate `regenerate` to `@technical-seed-writer` only after explic
90
90
 
91
91
  - **MUST STOP** and ask the user for clarification only when one concise question is still needed to classify safely
92
92
  - **MUST STOP** before implementation if the runtime indicates plan mode, read-only mode, or another no-write restriction
93
- - **MUST STOP** after `@spec-writer` and wait for explicit user approval
93
+ - **MUST STOP** after `@spec-writer` and wait for explicit approval of the change contract
94
94
  - **MUST REPORT** subagent failures and wait for instructions
95
95
  - **MUST STOP** if `@tester` reports failed verification
96
96
  - **MUST STOP** if `@reviewer` reports critical issues (including a `GUARANTEE NOT SUSTAINED` finding) and do not advance toward archive while it is present
@@ -298,11 +298,27 @@ Ask at most one concise clarification question focused on the dominant ambiguity
298
298
 
299
299
  Before any write-capable stage, apply the **Runtime Safety** rule declared once in Enforcement Rules: if the runtime is plan-only or read-only, stop after analysis, clarification, or spec/planning work, delegate no write-capable stage, and report that implementation cannot continue until write-capable execution is available.
300
300
 
301
+ ### Contract Checkpoint
302
+
303
+ After `@spec-writer` generates the artifacts of a change that required specification, STOP and present the **contract of the change**, not its documents. The presentation must fit in less than one screen and contain, in this order:
304
+
305
+ - **Intention** — what the change does and why, in one or two lines
306
+ - **Scope** — what is in, and what is explicitly out
307
+ - **Requirement** — the new one, or the existing `spec:<capability>#<requirement>` cited as conformity
308
+ - **Risks** — the ones that could change the decision, each with its mitigation
309
+ - **Architecture decision** — only when the change takes one; state that none applies otherwise
310
+ - **Planned validation** — only the commands the project declares
311
+ - **Authorized paths** — the file set the implementation and its commit may touch
312
+
313
+ Then wait for explicit approval. That approval authorizes implementation only: it does not authorize archive and it does not authorize merge, which stay as separate stops. Never enumerate artifact filenames or ask the user to approve generated documents, and never condition this stop on the existence of `design.md`.
314
+
315
+ If scope, risk, or the architecture decision changes after approval, present the contract as an amendment and wait for a new explicit approval before continuing. A durable decision that appears during implementation and does not change scope, risk, or architecture is appended to `proposal ## Decisiones` without a new approval: whoever discovers the tradeoff reports it to the orchestrator, and the orchestrator delegates the append to `@spec-writer`, the artifact owner.
316
+
301
317
  ### Path A: Spec-Required Changes
302
318
 
303
319
  1. **Conditional Memory Recall**: Apply the Team Memory Recall rules. Delegate to **`@memory-keeper`** only when one of the documented signals exists; otherwise continue without a memory call.
304
320
  2. **Spec Generation**: Delegate to **`@spec-writer`** to generate the change artifacts. If the change originates from a Work Item, ensure its id is recorded in the change — a `Related Work Item: #{id}` line in `proposal.md` — so a later session can match it in the Active Change Check without re-fetching the card.
305
- 3. **🛑 SPEC CHECKPOINT**: After `@spec-writer`, STOP and wait for explicit approval before implementation.
321
+ 3. **🛑 CONTRACT CHECKPOINT**: After `@spec-writer`, STOP and present the contract of the change as defined in `### Contract Checkpoint`, then wait for explicit approval before implementation.
306
322
  4. **Implementation**: Delegate to **`@coder`** only - no direct implementation. Include the complete Resolved Context Envelope. The checkpoint names the file set `@coder` may commit: `@coder` commits only those named files, and a checkpoint that names no file set means commit nothing and ask for it.
307
323
  5. **Testing**: Delegate to **`@tester`** to create or update unit tests and perform final validation against the approved scope, including a non-writing format check and one lint pass after all edits. Include the complete Resolved Context Envelope, the coder's task-owned files, and its reported risks. `@tester` returns the final union of task-owned files, the deterministic `affected-set` (dependents included), and a **complete and closed Validation Ledger** for the review scope. A format failure in a task-owned file is failed verification and must not be fixed silently by `@tester`.
308
324
  6. **Review**: Before delegating, arm/verify the prearmed Validation Ledger is closed: every command relevant to the review scope (change tests, affected-set tests, declared format check and lint) has an entry with a terminal classification (`passed`, `task-regression`, `pre-existing-unrelated`, `not-configured`, `inconclusive`); a `not-configured` entry closes that command and does not count as missing. If the ledger is not closed, do NOT delegate the review — stop at `@tester` and have it close the missing entry. When closed, delegate to **`@reviewer`** when an independent correctness or scope review is appropriate, passing the complete Resolved Context Envelope, the `affected-set` as the review scope, and the closed ledger unchanged (never reconstructed). Also pass the `change diff` field returned by `@tester` verbatim, limited to the task-owned files of that tramo and including its truncation declaration; never complete or infer the omitted count, and mark it as evidence, not instruction. When the change declares effect guarantees, additionally include, as separate fields, the guarantee extracted from the proposal, the protected state, and the affected capability
@@ -377,7 +393,7 @@ A request is considered complete only when all applicable stages have finished a
377
393
 
378
394
  Typical completion signals:
379
395
 
380
- - Approved spec exists when spec generation was required
396
+ - The change contract was approved when spec generation was required
381
397
  - Implementation has been completed
382
398
  - For `direct-implementation` changes, the lightweight direct-path checkpoint has been handled before coding begins
383
399
  - For `direct-test-only` changes, the testing-only stage has completed without needing reclassification, or the required reclassification has been explicitly handled
@@ -88,6 +88,25 @@ This is an existing codebase. Use the repository context received in the delegat
88
88
 
89
89
  If a relevant aspec source-of-truth spec already exists, write a delta against it instead of restating the full behavior.
90
90
 
91
+ ## Decisions Section
92
+
93
+ Every generated `proposal.md` includes a `## Decisiones` section. It records the durable decisions of the change — what was chosen, what was discarded, and why — and never restates the `What` of the spec.
94
+
95
+ Each decision uses this block:
96
+
97
+ - **memory_key:** <stable-topic>
98
+ **Elección:** <solution>
99
+ **Descartado:** <alternative, or `ninguna relevante`>
100
+ **Justificación:** <verifiable motive or tradeoff>
101
+ **Refs:** `change:<slug>`; `path:<repo-relative>`; `spec:<capability>#<requirement>` when one exists
102
+ **Estado:** propuesto
103
+
104
+ Record one block per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`; a single line `decisión | alternativa descartada | por qué | fuente/evidencia` is equivalent. Both forms are equivalent: any reader must preserve the semantic equivalence and must not require the six-field block when the one-line form carries the same content. `Estado` is one of `propuesto`, `aprobado`, `enmendado`, `descartado`.
105
+
106
+ The section is completed **also during implementation**, not only at propose time: when a tradeoff that was not anticipated appears while implementing, it is appended with its discarded alternative and its evidence. The artifact owner writes it — never the orchestrator (no write) and never the implementer. Never invent refs; record only refs that resolve.
107
+
108
+ A trivial implementation detail is not a decision: when the choice was obvious or had no discarded alternative worth recording, it is not written in this section.
109
+
91
110
  ## Output Expectations
92
111
 
93
112
  After writing the artifacts, respond with a short structured summary including:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.15.3",
3
+ "version": "0.16.2",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@ Read every present artifact — they constrain what you write. If `context.md` e
42
42
 
43
43
  Draft the missing artifact using the same templates as `ancleto-propose`:
44
44
 
45
- - **proposal.md** (first): problem statement, proposed change, scope, risks. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
45
+ - **proposal.md** (first): problem statement, proposed change, scope, risks, and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; reuse the shape defined in the Decisions section of `@spec-writer`. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
46
46
  - **specs/\<capability\>/spec.md**: one spec file per capability touched, with `## ADDED Requirements` / `#### Scenario:` blocks in WHEN/THEN form.
47
47
  - **design.md**: approach, architecture, validation.
48
48
  - **tasks.md**: phased `- [ ]` checklist.
@@ -45,7 +45,7 @@ Create `aspec/changes/<name>/` directly.
45
45
 
46
46
  Write each file directly, reading completed ones first:
47
47
 
48
- 1. **`proposal.md`** — problem, proposed change, scope, risks. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
48
+ 1. **`proposal.md`** — problem, proposed change, scope, risks, and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape lives in the Decisions section of `@spec-writer`. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
49
49
  2. **`specs/<capability>/spec.md`** — one file per capability touched, with `## ADDED Requirements` / `#### Scenario:` blocks in WHEN/THEN form. Skip only when the change specifies no behavior, and say why.
50
50
  3. **`design.md`** — approach, architecture, validation.
51
51
  4. **`tasks.md`** — phased `- [ ]` checklist of small, independently verifiable tasks.
@@ -56,7 +56,7 @@ Report: "Change `<name>` created at `aspec/changes/<name>/`. 0/4 artifacts compl
56
56
 
57
57
  ### 5. Show the template for the first artifact
58
58
 
59
- Present the `proposal.md` template: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out), `## Risks` (`<risk>: <mitigation>`).
59
+ Present the `proposal.md` template: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out), `## Risks` (`<risk>: <mitigation>`), and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the one-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the shape is owned by the Decisions section of `@spec-writer`.
60
60
 
61
61
  ### 6. STOP and wait for user direction
62
62
 
@@ -69,7 +69,7 @@ Demo explore mode: read involved files, sketch diagrams. **PAUSE** for acknowled
69
69
  ## Phase 5: Proposal
70
70
 
71
71
  **EXPLAIN:** the proposal captures **why** and **what**.
72
- **DO:** draft (Why / What Changes / Capabilities / Impact), show it, **PAUSE** for approval, write `aspec/changes/<name>/proposal.md`.
72
+ **DO:** draft (Why / What Changes / Capabilities / Impact), show it, **PAUSE** for approval, write `aspec/changes/<name>/proposal.md` including `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; follow the shape defined by `@spec-writer`.
73
73
 
74
74
  ## Phase 6: Specs
75
75
 
@@ -42,7 +42,7 @@ Create `aspec/changes/<name>/` if missing.
42
42
 
43
43
  Write each directly, reading each completed one before drafting the next.
44
44
 
45
- **4a. `proposal.md`** — what & why: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out of scope), `## Risks` (`<risk>: <mitigation>`).
45
+ **4a. `proposal.md`** — what & why: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out of scope), `## Risks` (`<risk>: <mitigation>`), and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the one-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape is defined in the Decisions section of the `@spec-writer` contract. The section is completed also during implementation when a tradeoff appears.
46
46
 
47
47
  **4b. `design.md`** — how: `# Design: <title>`, then `## Approach`, `## Architecture`, `## Validation`.
48
48
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## proposal.md
4
4
 
5
+ The emitted proposal includes a `## Decisiones` section — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape is defined by the Decisions section of `@spec-writer`.
6
+
5
7
  ```markdown
6
8
  # Proposal: Migration to {LIBRARY} v{TARGET_VERSION}
7
9
 
package/src/cli/index.js CHANGED
@@ -9,8 +9,9 @@ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:pat
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 { snapshotDatabase } from '../core/memory/snapshot.js'
12
13
  import { adaptFrontmatter, parseFrontmatter } from '../core/adapters/frontmatter.js'
13
- import { writeWorkingContext, writeWorkingContextBlock, renderWorkingContextBlock, resolveMemoryProjectRoot, checkWorkingContextFreshness, refreshWorkingContextOnWrite } from '../core/memory/working-context.js'
14
+ import { writeWorkingContext, writeWorkingContextBlock, workingContextPath, renderWorkingContextBlock, resolveMemoryProjectRoot, checkWorkingContextFreshness, refreshWorkingContextOnWrite } from '../core/memory/working-context.js'
14
15
  import { materializeMachineEnv, checkMachineEnvTracked } from '../core/machine-env.js'
15
16
  import { memoryDoctor } from '../core/memory/doctor.js'
16
17
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
@@ -91,6 +92,10 @@ ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FT
91
92
  ancleto memory export [--out <archivo>] Exporta nodos activos a JSON (stdout o archivo)
92
93
  ancleto memory import <archivo> Importa nodos desde JSON (upsert por memory_key, idempotente)
93
94
  ancleto memory gc [--dry-run] [--days N] Purga superseded con antiguedad > N dias (default 30)
95
+ ancleto memory snapshot --out <archivo.db> [--force] [--json]
96
+ Copia consistente de la memoria (incluye historial; --force sobrescribe)
97
+ ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]
98
+ Supersede cada source activa hacia el target (sin crear nodos)
94
99
  ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
95
100
  expone searchMemory, recordRule y recordDecision
96
101
  ancleto check Verifica integridad de archivos instalados vs manifiesto
@@ -2240,7 +2245,7 @@ async function memoryDoctorCmd(flags) {
2240
2245
  const { checks, healthy, rebuilt } = memoryDoctor(dbPath, { rebuild })
2241
2246
  if (rebuilt) console.log('ancleto: indice FTS5 reconstruido')
2242
2247
  for (const c of checks) {
2243
- console.log(` ${c.ok ? '✔' : '✖'} ${c.name}: ${c.detail}`)
2248
+ console.log(` ${c.skipped ? '−' : c.ok ? '✔' : '✖'} ${c.name}: ${c.detail}`)
2244
2249
  }
2245
2250
  // Frescura del working-context: advisory, no afecta el exit code (que sigue midiendo
2246
2251
  // la integridad de la DB). Read-only: no reescribe el artefacto.
@@ -2393,6 +2398,103 @@ async function memoryGc(flags) {
2393
2398
  }
2394
2399
  }
2395
2400
 
2401
+ // `flagValue` devuelve el token siguiente sin mirar si es otro flag. En los
2402
+ // comandos con flags obligatorios, un valor vacio o que empieza con `--` cuenta
2403
+ // como flag faltante (sin cambiar el helper global que usan otros comandos).
2404
+ function requiredMemoryFlag(flags, flag) {
2405
+ const value = flagValue(flags, flag)
2406
+ return value && !value.startsWith('--') ? value : null
2407
+ }
2408
+
2409
+ async function memorySnapshot(flags) {
2410
+ const out = requiredMemoryFlag(flags, '--out')
2411
+ if (!out) {
2412
+ console.error('ancleto: uso: ancleto memory snapshot --out <archivo.db> [--force] [--json]')
2413
+ process.exitCode = 1
2414
+ return
2415
+ }
2416
+ const dbPath = defaultMemoryDbPath()
2417
+ if (!(await exists(dbPath))) {
2418
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2419
+ process.exitCode = 1
2420
+ return
2421
+ }
2422
+ let result
2423
+ try {
2424
+ result = snapshotDatabase(dbPath, resolve(out), { overwrite: flags.includes('--force') })
2425
+ } catch (err) {
2426
+ console.error(`ancleto: no se pudo crear el snapshot: ${err.message}`)
2427
+ process.exitCode = 1
2428
+ return
2429
+ }
2430
+ if (flags.includes('--json')) {
2431
+ console.log(JSON.stringify({ out: result.out, bytes: result.bytes }))
2432
+ } else {
2433
+ console.log(`ancleto: snapshot escrito en ${result.out} (${result.bytes} bytes)`)
2434
+ }
2435
+ }
2436
+
2437
+ async function memoryMerge(flags) {
2438
+ const target = requiredMemoryFlag(flags, '--target')
2439
+ const sourcesRaw = requiredMemoryFlag(flags, '--sources')
2440
+ if (!target || !sourcesRaw) {
2441
+ console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
2442
+ process.exitCode = 1
2443
+ return
2444
+ }
2445
+ const sources = sourcesRaw.split(',').map((s) => s.trim()).filter(Boolean)
2446
+ if (sources.length === 0) {
2447
+ console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
2448
+ process.exitCode = 1
2449
+ return
2450
+ }
2451
+ const dryRun = flags.includes('--dry-run')
2452
+ const dbPath = defaultMemoryDbPath()
2453
+ if (!(await exists(dbPath))) {
2454
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2455
+ process.exitCode = 1
2456
+ return
2457
+ }
2458
+ // `createMemoryEngine` aplica `PRAGMA journal_mode=WAL` y migraciones al abrir;
2459
+ // un dry-run no debe escribir ni la base ni sus sidecars, asi que usa la
2460
+ // conexion read-only (su `merge` solo acepta `dryRun: true`).
2461
+ const engine = dryRun ? createReadonlyMemoryEngine(dbPath) : createMemoryEngine(dbPath)
2462
+ try {
2463
+ let result
2464
+ try {
2465
+ result = engine.merge({ target, sources, dryRun })
2466
+ } catch (err) {
2467
+ console.error(`ancleto: merge rechazado: ${err.message}`)
2468
+ process.exitCode = 1
2469
+ return
2470
+ }
2471
+ if (result.dryRun) {
2472
+ if (flags.includes('--json')) console.log(JSON.stringify(result))
2473
+ else console.log(`ancleto: merge --dry-run -> target ${result.target}; sources ${result.sources.join(', ')}; ${result.superseded} nodo(s) a superseder`)
2474
+ return
2475
+ }
2476
+ // Mismo render que el refresh on-write de `import`; la raiz se deriva del
2477
+ // directorio de la DB, no de process.cwd() (aisla fixtures en tmpdir).
2478
+ const projectRoot = resolveMemoryProjectRoot(dbPath)
2479
+ try {
2480
+ refreshWorkingContextOnWrite(engine, projectRoot)
2481
+ } catch (err) {
2482
+ // El merge ya quedo commiteado en la base: no se revierte. Reportar exito
2483
+ // sobre un bloque derivado desactualizado seria mentir; salir != 0 y decir
2484
+ // exactamente que si se aplico, que quedo viejo y como regenerarlo.
2485
+ const ctxPath = workingContextPath(projectRoot)
2486
+ console.error(`ancleto: el merge SI se aplico en la base (${result.superseded} nodo(s) superseded hacia ${result.target}), pero el bloque derivado ${ctxPath} quedo desactualizado: ${err.message}`)
2487
+ console.error(`ancleto: regenera el bloque con: ancleto memory context --out ${ctxPath}`)
2488
+ process.exitCode = 1
2489
+ return
2490
+ }
2491
+ if (flags.includes('--json')) console.log(JSON.stringify(result))
2492
+ else console.log(`ancleto: merge OK -> ${result.superseded} nodo(s) superseded hacia ${result.target}`)
2493
+ } finally {
2494
+ engine.close()
2495
+ }
2496
+ }
2497
+
2396
2498
  async function memoryCmd(args) {
2397
2499
  const [sub, ...flags] = args
2398
2500
  if (sub === 'context') {
@@ -2419,6 +2521,14 @@ async function memoryCmd(args) {
2419
2521
  await memoryGc(flags)
2420
2522
  return
2421
2523
  }
2524
+ if (sub === 'snapshot') {
2525
+ await memorySnapshot(flags)
2526
+ return
2527
+ }
2528
+ if (sub === 'merge') {
2529
+ await memoryMerge(flags)
2530
+ return
2531
+ }
2422
2532
  console.error(`ancleto: subcomando de memory desconocido: ${sub || '(ninguno)'}`)
2423
2533
  console.error('ancleto: uso: ancleto memory context [--scope X] [--out file]')
2424
2534
  console.error('ancleto: uso: ancleto memory list [--type X] [--scope X] [--all] [--json]')
@@ -2426,6 +2536,8 @@ async function memoryCmd(args) {
2426
2536
  console.error('ancleto: uso: ancleto memory export [--out <archivo>]')
2427
2537
  console.error('ancleto: uso: ancleto memory import <archivo>')
2428
2538
  console.error('ancleto: uso: ancleto memory gc [--dry-run] [--days <n>]')
2539
+ console.error('ancleto: uso: ancleto memory snapshot --out <archivo.db> [--force] [--json]')
2540
+ console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
2429
2541
  process.exit(1)
2430
2542
  }
2431
2543
 
@@ -1,15 +1,47 @@
1
+ import { statSync } from 'node:fs'
1
2
  import { openDatabase, checkpointDatabase } from './database.js'
2
3
 
4
+ // Estado observable del WAL sin ejecutar checkpoint ni ningun pragma mutante:
5
+ // solo se mira el tamano del archivo `-wal` que ya existe en disco.
6
+ function observeWal(dbPath) {
7
+ try {
8
+ return `-wal presente (${statSync(`${dbPath}-wal`).size} bytes)`
9
+ } catch {
10
+ return 'sin -wal'
11
+ }
12
+ }
13
+
3
14
  export function memoryDoctor(dbPath, { rebuild = false } = {}) {
4
- const db = openDatabase(dbPath, { migrate: false })
15
+ // Default estrictamente read-only: sin migraciones, sin pragmas mutantes y sin
16
+ // checkpoint. `--rebuild` es el unico camino que abre en escritura.
17
+ const db = openDatabase(dbPath, rebuild ? { migrate: false } : { migrate: false, readonly: true })
18
+ let rebuilt = false
5
19
  try {
6
20
  const checks = []
7
21
 
8
22
  const hasSchema = db.prepare(`SELECT count(*) AS n FROM sqlite_master WHERE name = 'memory_nodes'`).get().n > 0
9
23
  if (!hasSchema) {
10
- return { checks: [{ name: 'Integridad DB', ok: false, detail: 'esquema no inicializado' }], healthy: false, rebuilt: false }
24
+ // Error path: los cuatro nombres se conservan y cada check no ejecutable
25
+ // se reporta como falla con su motivo (nunca se omite del array).
26
+ const detail = 'esquema no inicializado'
27
+ return {
28
+ checks: [
29
+ { name: 'Integridad DB', ok: false, detail },
30
+ { name: 'Indice FTS5', ok: false, detail: `no verificable: ${detail}` },
31
+ { name: 'Reglas activas (unicidad)', ok: false, detail: `no verificable: ${detail}` },
32
+ { name: 'Checkpoint WAL', ok: false, detail: `no verificable: ${detail}` }
33
+ ],
34
+ healthy: false,
35
+ rebuilt: false
36
+ }
11
37
  }
12
38
 
39
+ // La existencia del indice se verifica aparte del conteo: sin `memory_fts`
40
+ // la busqueda no funciona (esquema parcial/legacy) y no es un chequeo
41
+ // omitido. La tabla sombra `memory_fts_docsize` solo existe si FTS5 se creo
42
+ // con el default `columnsize=1`.
43
+ const hasFts = db.prepare(`SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts'`).get().n > 0
44
+
13
45
  const quick = db.prepare('PRAGMA quick_check').get()
14
46
  const integrityOk = quick && quick.quick_check === 'ok'
15
47
  checks.push({
@@ -27,19 +59,45 @@ export function memoryDoctor(dbPath, { rebuild = false } = {}) {
27
59
  }
28
60
  }
29
61
 
30
- let fts = runIntegrity()
31
- let rebuilt = false
32
- if (rebuild) {
33
- db.exec(`INSERT INTO memory_fts(memory_fts) VALUES('rebuild')`)
34
- rebuilt = true
35
- const after = runIntegrity()
36
- fts = { ok: after.ok, detail: after.ok ? 'indice reconstruido y consistente' : `reconstruccion sin exito: ${after.detail}` }
62
+ let fts
63
+ if (!hasFts) {
64
+ // Estado 1: el indice no existe. Aunque `memory_nodes` exista, la busqueda
65
+ // esta rota; se reporta como falla (nunca `skipped`) y baja `healthy`.
66
+ fts = { ok: false, detail: 'indice FTS5 ausente; la busqueda no funciona' }
67
+ } else if (rebuild) {
68
+ try {
69
+ db.exec(`INSERT INTO memory_fts(memory_fts) VALUES('rebuild')`)
70
+ rebuilt = true
71
+ const after = runIntegrity()
72
+ fts = { ok: after.ok, detail: after.ok ? 'indice reconstruido y consistente' : `reconstruccion sin exito: ${after.detail}` }
73
+ } catch (err) {
74
+ fts = { ok: false, detail: `reconstruccion fallo: ${err.message}` }
75
+ }
76
+ } else {
77
+ // Chequeo no mutante de alineacion. `count(*)` sobre `memory_fts` (external
78
+ // content) NO sirve: FTS5 lo resuelve contra `memory_nodes`, asi que un nodo
79
+ // sin indexar igual se cuenta. La tabla sombra `%_docsize` (default
80
+ // columnsize=1) tiene una fila por documento efectivamente indexado, y su
81
+ // conteo equivale a "filas indexadas". El integrity-check completo (INSERT
82
+ // de control) escribe y queda solo para `--rebuild`.
83
+ try {
84
+ const indexed = db.prepare('SELECT count(*) AS n FROM memory_fts_docsize').get().n
85
+ const total = db.prepare('SELECT count(*) AS n FROM memory_nodes').get().n
86
+ fts = indexed === total
87
+ ? { ok: true, detail: `${indexed} filas indexadas, coincide con memory_nodes (${total} nodos); integridad completa no verificable en read-only` }
88
+ : { ok: false, detail: `${indexed} filas indexadas vs ${total} nodos; indice desalineado, usar --rebuild` }
89
+ } catch (err) {
90
+ // No poder verificar no es una falla (D2): p. ej. un FTS5 creado con
91
+ // `columnsize=0` no materializa la tabla sombra `memory_fts_docsize`.
92
+ // El check se reporta omitido y no baja `healthy` ni el exit code.
93
+ fts = {
94
+ ok: true,
95
+ skipped: true,
96
+ detail: `omitido: alineacion de FTS no verificable (${err.message}); integridad completa no verificable en read-only`
97
+ }
98
+ }
37
99
  }
38
- checks.push({
39
- name: rebuilt ? 'Indice FTS5 (post-rebuild)' : 'Indice FTS5',
40
- ok: fts.ok,
41
- detail: fts.detail
42
- })
100
+ checks.push({ name: 'Indice FTS5', ok: fts.ok, ...(fts.skipped ? { skipped: true } : {}), detail: fts.detail })
43
101
 
44
102
  const dups = db.prepare(`SELECT memory_key, COUNT(*) AS n FROM memory_nodes
45
103
  WHERE status = 'active' GROUP BY memory_key HAVING n > 1`).all()
@@ -49,13 +107,21 @@ export function memoryDoctor(dbPath, { rebuild = false } = {}) {
49
107
  detail: dups.length === 0 ? 'una sola activa por memory_key' : dups.map((d) => `${d.memory_key} x${d.n}`).join(', ')
50
108
  })
51
109
 
52
- // Deja el WAL fusionado en el .db: una copia del archivo no pierde datos.
53
- const checkpointed = checkpointDatabase(db)
54
- checks.push({
55
- name: 'Checkpoint WAL',
56
- ok: checkpointed,
57
- detail: checkpointed ? 'WAL fusionado en el .db (seguro para copiar)' : 'no se pudo checkpointear (DB en uso por otro proceso)'
58
- })
110
+ if (rebuild) {
111
+ const checkpointed = checkpointDatabase(db)
112
+ checks.push({
113
+ name: 'Checkpoint WAL',
114
+ ok: checkpointed,
115
+ detail: checkpointed ? 'WAL fusionado en el .db (seguro para copiar)' : 'no se pudo checkpointear (DB en uso por otro proceso)'
116
+ })
117
+ } else {
118
+ checks.push({
119
+ name: 'Checkpoint WAL',
120
+ ok: true,
121
+ skipped: true,
122
+ detail: `omitido: no verificable en modo read-only; ${observeWal(dbPath)}`
123
+ })
124
+ }
59
125
 
60
126
  return { checks, healthy: checks.every((c) => c.ok), rebuilt }
61
127
  } finally {
@@ -91,13 +91,28 @@ export function createMemoryEngine(dbPath = defaultMemoryDbPath()) {
91
91
  // Apertura read-only: sin migraciones ni escrituras. Pensada para inspeccionar
92
92
  // la memoria sin tocar el WAL ni modificar archivos.
93
93
  export function createReadonlyMemoryEngine(dbPath = defaultMemoryDbPath()) {
94
- return buildEngine(openDatabase(dbPath, { migrate: false, readonly: true }))
94
+ return buildReadonlyEngine(openDatabase(dbPath, { migrate: false, readonly: true }))
95
+ }
96
+
97
+ // Misma superficie que el engine de escritura. `merge` solo puede planificar:
98
+ // una corrida real se rechaza antes de tocar la conexion read-only, y el plan
99
+ // sale de la validacion compartida (`validateMerge`), identico al de escritura.
100
+ function buildReadonlyEngine(db) {
101
+ const engine = buildEngine(db)
102
+ return {
103
+ ...engine,
104
+ merge({ target, sources, dryRun = false } = {}) {
105
+ if (!dryRun) throw new Error('merge: la conexion es read-only')
106
+ return engine.merge({ target, sources, dryRun: true })
107
+ }
108
+ }
95
109
  }
96
110
 
97
111
  function buildEngine(db) {
98
112
 
99
- const findActive = db.prepare(`SELECT id FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
113
+ const findActive = db.prepare(`SELECT id, type FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
100
114
  const findActiveRow = db.prepare(`SELECT id, created_at FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
115
+ const findLatestStatus = db.prepare(`SELECT status FROM memory_nodes WHERE memory_key = ? ORDER BY rowid DESC LIMIT 1`)
101
116
  const countSuperseded = db.prepare(`SELECT COUNT(*) AS c FROM memory_nodes WHERE memory_key = ? AND status = 'superseded'`)
102
117
  const supersede = db.prepare(`UPDATE memory_nodes SET status = 'superseded', superseded_by = ? WHERE id = ? AND status = 'active'`)
103
118
  const insertNode = db.prepare(
@@ -372,9 +387,82 @@ function buildEngine(db) {
372
387
  return checkpointDatabase(db)
373
388
  }
374
389
 
390
+ // Validacion compartida por el engine de escritura y el read-only: deja el
391
+ // plan listo y falla cerrado (target activo, sources activas, sin repetidas y
392
+ // con `type` identico) antes de abrir cualquier transaccion.
393
+ function validateMerge({ target, sources } = {}) {
394
+ const targetKey = String(target || '').trim()
395
+ if (!targetKey) throw new Error('merge: target es obligatorio')
396
+ if (!Array.isArray(sources) || sources.length === 0) throw new Error('merge: sources debe ser una lista no vacia')
397
+
398
+ const sourceKeys = sources.map((s) => String(s || '').trim())
399
+ const seen = new Set()
400
+ for (const key of sourceKeys) {
401
+ if (!key) throw new Error('merge: memory_key vacia en sources')
402
+ if (seen.has(key)) throw new Error(`merge: source repetida: ${key}`)
403
+ seen.add(key)
404
+ }
405
+
406
+ const targetRow = findActive.get(targetKey)
407
+ if (!targetRow) throw new Error(`merge: target "${targetKey}" no es un nodo activo existente`)
408
+
409
+ const sourceIds = []
410
+ for (const key of sourceKeys) {
411
+ if (key === targetKey) throw new Error(`merge: source "${key}" es igual al target`)
412
+ const row = findActive.get(key)
413
+ if (!row) {
414
+ const latest = findLatestStatus.get(key)
415
+ if (latest) throw new Error(`merge: source "${key}" no esta activa (status: ${latest.status})`)
416
+ throw new Error(`merge: source "${key}" no existe`)
417
+ }
418
+ if (row.type !== targetRow.type) {
419
+ throw new Error(`merge: type incompatible entre target "${targetKey}" (${targetRow.type}) y source "${key}" (${row.type})`)
420
+ }
421
+ sourceIds.push(row.id)
422
+ }
423
+
424
+ return { targetKey, sourceKeys, sourceIds }
425
+ }
426
+
427
+ // Consolida duplicados supersediendo cada source activa hacia un target activo,
428
+ // sin insertar nodos ni aceptar contenido. Valida todo antes de escribir y usa
429
+ // una unica transaccion: cualquier violacion deja la base intacta. `dryRun` no
430
+ // abre transaccion ni escribe.
431
+ function merge({ target, sources, dryRun = false } = {}) {
432
+ const { targetKey, sourceKeys, sourceIds } = validateMerge({ target, sources })
433
+ const plan = { target: targetKey, sources: sourceKeys, superseded: sourceIds.length }
434
+ if (dryRun) return { dryRun: true, ...plan }
435
+
436
+ db.exec('BEGIN IMMEDIATE')
437
+ let superseded = 0
438
+ try {
439
+ // Revalidacion bajo lock: el target pudo quedar superseded entre la
440
+ // validacion previa y la transaccion, y las sources quedarian enlazadas a
441
+ // un nodo inactivo.
442
+ const lockedTarget = findActive.get(targetKey)
443
+ if (!lockedTarget) {
444
+ throw new Error(`merge: el target "${targetKey}" dejo de estar activo durante la transaccion`)
445
+ }
446
+ for (const id of sourceIds) {
447
+ const result = supersede.run(lockedTarget.id, id)
448
+ superseded += Number(result.changes || 0)
449
+ }
450
+ // Si una source dejo de estar activa entre la validacion y el UPDATE,
451
+ // `changes` es 0: se aborta y se revierte todo.
452
+ if (superseded !== sourceIds.length) {
453
+ throw new Error('merge: una source dejo de estar activa durante la transaccion')
454
+ }
455
+ db.exec('COMMIT')
456
+ } catch (err) {
457
+ db.exec('ROLLBACK')
458
+ throw err
459
+ }
460
+ return { dryRun: false, ...plan }
461
+ }
462
+
375
463
  function close() {
376
464
  db.close()
377
465
  }
378
466
 
379
- return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, checkpoint, close }
467
+ return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, merge, checkpoint, close }
380
468
  }
@@ -0,0 +1,126 @@
1
+ import { existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { basename, dirname, join, resolve } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+
6
+ // Copia consistente de la base de memoria con historial (incluye superseded y
7
+ // superseded_by, a diferencia de `memory export`). No usa `openDatabase`: ese
8
+ // helper fija `PRAGMA journal_mode=WAL` y mutaria la fuente. Se abre una conexion
9
+ // directa read-only, sin pragmas ni migraciones: `VACUUM INTO` lee el estado
10
+ // commiteado (incluido el WAL) sin escribir la fuente.
11
+ // Identidad de archivo, no identidad de string: `path.resolve()` no normaliza
12
+ // mayusculas en Windows ni resuelve symlinks/junctions. Para una ruta que todavia
13
+ // no existe se resuelve el ancestro existente mas cercano y se recompone el resto.
14
+ function realpathish(p) {
15
+ const abs = resolve(p)
16
+ if (existsSync(abs)) return realpathSync.native(abs)
17
+ const tail = []
18
+ let cur = abs
19
+ for (;;) {
20
+ const parent = dirname(cur)
21
+ if (parent === cur) return abs
22
+ if (existsSync(parent)) {
23
+ return join(realpathSync.native(parent), basename(cur), ...tail.reverse())
24
+ }
25
+ tail.push(basename(cur))
26
+ cur = parent
27
+ }
28
+ }
29
+
30
+ function sameFile(a, b) {
31
+ let ca = realpathish(a)
32
+ let cb = realpathish(b)
33
+ if (process.platform === 'win32') {
34
+ ca = ca.toLowerCase()
35
+ cb = cb.toLowerCase()
36
+ }
37
+ return ca === cb
38
+ }
39
+
40
+ function vacuumInto(sourcePath, targetPath) {
41
+ const db = new DatabaseSync(sourcePath, { readOnly: true })
42
+ try {
43
+ // `VACUUM INTO` acepta una expresion SQL: el destino viaja como parametro
44
+ // vinculado, sin interpolar la ruta en el texto de la sentencia.
45
+ db.prepare('VACUUM INTO ?').run(targetPath)
46
+ } catch (err) {
47
+ throw new Error(`snapshot: VACUUM INTO fallo sobre la fuente read-only: ${err.message}`)
48
+ } finally {
49
+ db.close()
50
+ }
51
+ }
52
+
53
+ function verifySnapshot(targetPath) {
54
+ const check = new DatabaseSync(targetPath, { readOnly: true })
55
+ try {
56
+ const row = check.prepare('PRAGMA quick_check').get()
57
+ if (!row || row.quick_check !== 'ok') {
58
+ throw new Error(`snapshot: la verificacion quick_check fallo para ${targetPath}`)
59
+ }
60
+ } finally {
61
+ check.close()
62
+ }
63
+ }
64
+
65
+ // Reemplaza el destino recien despues de tener una copia verificada en el
66
+ // temporal (mismo directorio = mismo volumen). En Windows `rename` no reemplaza
67
+ // un destino existente: se aparta el viejo a un backup, se mueve la copia nueva
68
+ // y solo entonces se borra el backup. Si el movimiento final falla, se restaura
69
+ // el backup: en ningun camino de error se pierden a la vez el destino previo y
70
+ // la copia nueva.
71
+ function replaceDestination(tempPath, targetPath) {
72
+ if (!existsSync(targetPath)) {
73
+ renameSync(tempPath, targetPath)
74
+ return
75
+ }
76
+ const backup = `${targetPath}.bak-${randomUUID()}`
77
+ renameSync(targetPath, backup)
78
+ try {
79
+ renameSync(tempPath, targetPath)
80
+ } catch (err) {
81
+ try {
82
+ renameSync(backup, targetPath)
83
+ } catch {
84
+ // restauracion best-effort: se propaga el error original
85
+ }
86
+ throw err
87
+ }
88
+ try {
89
+ rmSync(backup, { force: true })
90
+ } catch {
91
+ // el snapshot ya quedo en su lugar: un .bak remanente no invalida la copia
92
+ }
93
+ }
94
+
95
+ export function snapshotDatabase(dbPath, outPath, { overwrite = false } = {}) {
96
+ if (!dbPath || !outPath) throw new Error('snapshot: se requieren la base fuente y la ruta destino')
97
+ const source = resolve(dbPath)
98
+ const out = resolve(outPath)
99
+ if (sameFile(source, out)) {
100
+ throw new Error(`snapshot: el destino no puede ser la fuente (${out})`)
101
+ }
102
+ if (!existsSync(source)) {
103
+ throw new Error(`snapshot: no existe la base fuente (${source})`)
104
+ }
105
+ if (existsSync(out) && !overwrite) {
106
+ throw new Error(`snapshot: el destino ya existe (${out}); usar overwrite/--force para reemplazarlo`)
107
+ }
108
+
109
+ mkdirSync(dirname(out), { recursive: true })
110
+ const temp = join(dirname(out), `.${basename(out)}.${process.pid}-${randomUUID()}.tmp`)
111
+ try {
112
+ vacuumInto(source, temp)
113
+ verifySnapshot(temp)
114
+ replaceDestination(temp, out)
115
+ return { out, bytes: statSync(out).size }
116
+ } catch (err) {
117
+ // La limpieza del temporal nunca debe reemplazar el error original. SQLite
118
+ // puede haber creado sidecars para el archivo temporal: se borran los tres.
119
+ try {
120
+ for (const file of [temp, `${temp}-wal`, `${temp}-shm`]) rmSync(file, { force: true })
121
+ } catch {
122
+ // best effort: el temporal queda huerfano pero el error original se propaga
123
+ }
124
+ throw err
125
+ }
126
+ }