wendkeep 0.68.6 → 0.69.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.69.0] — 2026-08-16
8
+
9
+ ### Added
10
+
11
+ - **A Shared Project Memory v2 preserva handoffs estruturados entre providers.** Stop publica
12
+ objetivo, entrega, restrições, decisões, próximas ações, bloqueios e riscos com `work_session_id`;
13
+ CORE continua manual com cap 40/alerta 35, e status/validate-memory diagnosticam cobertura
14
+ semântica sem expor valores privados.
15
+
7
16
  ## [0.68.6] — 2026-08-16
8
17
 
9
18
  ### Fixed
package/README.en.md CHANGED
@@ -357,7 +357,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
357
357
 
358
358
  ### Injection and budgets
359
359
 
360
- `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; CORE reserves up to 4 KiB, SHARED up to 6 KiB, and each line is capped at 320 characters. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
360
+ `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
361
+
362
+ `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
361
363
 
362
364
  `DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
363
365
 
package/README.md CHANGED
@@ -357,7 +357,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
357
357
 
358
358
  ### Injection and budgets
359
359
 
360
- `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; CORE reserves up to 4 KiB, SHARED up to 6 KiB, and each line is capped at 320 characters. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
360
+ `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
361
+
362
+ `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
361
363
 
362
364
  `DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
363
365
 
@@ -7,6 +7,10 @@
7
7
  Inspect and curate CORE, SHARED, ledger, outbox, attempts, and candidates without confusing
8
8
  canonical authorship with generated operational state.
9
9
 
10
+ `CORE.md` is the only manual, canonical layer: it accepts up to 40 lines, warns from 35,
11
+ keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated
12
+ from the ledger and must not be hand-edited.
13
+
10
14
  ## When to use
11
15
 
12
16
  Use in CI, before verify/archive, after doctor warnings, or when deciding candidates.
@@ -115,7 +119,12 @@ npx wendkeep validate-memory --vault <v2-vault>
115
119
  different, incomplete, or ambiguous identity keeps the candidate queued for curation. `memory
116
120
  repair` compares the old and current replay and migrates checkpoint+mirror only with exact
117
121
  identity, backup, audit, and CAS; it does not reorder, rewrite, or append a ledger event.
118
- - `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
122
+ - `validate-memory <CORE.md>` checks the hard 40-line cap, warns from 35, enforces 4 KiB and
123
+ 320 characters per line, and checks required sections and secrets/PII.
124
+ - `validate-memory --vault` also compares semantic ledger coverage with SHARED and prints a code,
125
+ counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable
126
+ event, exclusive placeholders, or a dead decision link produces a degraded/blocking diagnosis
127
+ without exposing values.
119
128
  - `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
120
129
  - For `recover-attempt`, exit `0` means a valid dry run/apply, including `unchanged`; exit `1`
121
130
  means a precondition, authority, CAS, topology, or lock check failed; exit `2` means a missing
@@ -7,6 +7,10 @@
7
7
  Inspecionar e curar CORE, SHARED, ledger, outbox, attempts e candidates sem confundir autoria
8
8
  canônica com estado operacional gerado.
9
9
 
10
+ `CORE.md` é a única camada manual e canônica: aceita até 40 linhas, alerta a partir de 35,
11
+ mantém o teto de 4 KiB e limita cada linha a 320 caracteres. `SHARED_MEMORY.md` é uma projeção
12
+ gerada pelo ledger e não deve ser editado à mão.
13
+
10
14
  ## Quando usar
11
15
 
12
16
  Use no CI, antes de verify/archive, diante de avisos do doctor ou para decidir candidates.
@@ -113,7 +117,12 @@ npx wendkeep validate-memory --vault <cofre-v2>
113
117
  divergente, incompleta ou ambígua mantém o candidate para curadoria. `memory repair` compara o
114
118
  replay anterior e o atual e só migra checkpoint+espelho com identidade exata, backup, audit e
115
119
  CAS; ele não reordena, reescreve nem acrescenta evento ao ledger.
116
- - `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
120
+ - `validate-memory <CORE.md>` valida cap rígido de 40 linhas, alerta em 35, 4 KiB, 320 caracteres
121
+ por linha, seções e segredos/PII.
122
+ - `validate-memory --vault` também compara a cobertura semântica do ledger com SHARED e imprime
123
+ código, contagens e chaves ativas/projetadas/ausentes. Bundle v2 vazio é neutro; evento
124
+ projetável ausente, placeholders exclusivos ou link de decisão morto geram diagnóstico
125
+ degradado/bloqueante sem expor valores.
117
126
  - `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
118
127
  - Para `recover-attempt`, exit `0` indica dry-run/apply válido, inclusive `unchanged`; exit `1`
119
128
  indica falha de pré-condição, autoridade, CAS, topologia ou lock; exit `2` indica
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { basename, join } from 'node:path';
5
5
  import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
6
  import { getLocale } from './locale.mjs';
7
+ import { sanitizeMemoryText } from './memory-schema.mjs';
7
8
 
8
9
  export function brainDir(vaultBase) {
9
10
  return join(vaultBase, '.brain');
@@ -104,6 +105,41 @@ function adrNumber(path) {
104
105
  return m ? Number(m[1]) : -1;
105
106
  }
106
107
 
108
+ function decisionTitle(content, path) {
109
+ const frontmatter = parseFrontmatter(content);
110
+ const heading = content.match(/^#\s+(.+?)\s*$/m)?.[1] || '';
111
+ const raw = frontmatter.title || frontmatter.name || heading;
112
+ if (!raw) return '';
113
+ const title = sanitizeMemoryText(String(raw)
114
+ .replace(/^ADR-\d+\s*(?:[-:—]\s*)?/i, '')
115
+ .replace(/\s+/g, ' ')
116
+ .trim());
117
+ return title.slice(0, 180).trim();
118
+ }
119
+
120
+ function decisionNotes(vaultBase) {
121
+ const byPath = new Map();
122
+ const byBasename = new Map();
123
+ const decisionsDir = join(vaultBase, getLocale(vaultBase).folders.decisions);
124
+ for (const filePath of walkMd(decisionsDir)) {
125
+ let content;
126
+ try { content = readFileSync(filePath, 'utf8'); } catch { continue; }
127
+ const rel = toVaultRelative(vaultBase, filePath).replace(/\.md$/i, '');
128
+ const note = { path: rel, title: decisionTitle(content, rel) };
129
+ byPath.set(rel, note);
130
+ const key = basename(rel);
131
+ if (!byBasename.has(key)) byBasename.set(key, note);
132
+ }
133
+ return { byPath, byBasename };
134
+ }
135
+
136
+ function resolveDecisionTarget(target, notes) {
137
+ const normalized = String(target || '').replace(/\.md$/i, '').trim();
138
+ if (!normalized || normalized.includes('...') || normalized.includes('…')) return null;
139
+ const note = notes.byPath.get(normalized) || notes.byBasename.get(basename(normalized));
140
+ return note?.title ? note : null;
141
+ }
142
+
107
143
  // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
108
144
  // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
109
145
  export function buildBrainDigest(vaultBase, rows = null) {
@@ -139,13 +175,21 @@ export function buildBrainDigest(vaultBase, rows = null) {
139
175
  };
140
176
  const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
177
 
142
- const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
178
+ const notes = decisionNotes(vaultBase);
179
+ const decisions = [];
180
+ for (const target of pick('decisions', DIGEST_CAPS.decisions * 4)) {
181
+ const resolved = resolveDecisionTarget(target, notes);
182
+ if (!resolved || decisions.some((item) => item.path === resolved.path)) continue;
183
+ decisions.push(resolved);
184
+ if (decisions.length >= DIGEST_CAPS.decisions) break;
185
+ }
186
+ decisions.sort((a, b) => adrNumber(b.path) - adrNumber(a.path));
143
187
  const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
144
188
  const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
189
  const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
146
190
 
147
191
  const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
148
- for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
192
+ for (const d of decisions) lines.push(`- Decisão: [[${d.path}]] — ${d.title}`);
149
193
  for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
150
194
  for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
151
195
  for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
@@ -19,7 +19,7 @@ import {
19
19
  resolveHookOperatingProfile,
20
20
  } from './operating-profile-runtime.mjs';
21
21
  import { assertVaultPathSafe } from './vault-path-safety.mjs';
22
- import { validateCore } from '../src/validate-core.mjs';
22
+ import { CORE_LIMITS, validateCore } from '../src/validate-core.mjs';
23
23
 
24
24
  // The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
25
25
  // standing instruction the model plans in chat, leaves the change scaffold raw and forces the
@@ -52,8 +52,8 @@ function processRouter(localeId) {
52
52
 
53
53
  const INJECTION_LIMITS = Object.freeze({
54
54
  totalBytes: 24 * 1024,
55
- lineChars: 320,
56
- coreBytes: 4 * 1024,
55
+ lineChars: CORE_LIMITS.lineChars,
56
+ coreBytes: CORE_LIMITS.bytes,
57
57
  sharedBytes: 6 * 1024,
58
58
  attentionBytes: 1024,
59
59
  recallBytes: 512,
@@ -36,11 +36,29 @@ import { resolveSessionIdentity } from './session-identity.mjs';
36
36
  import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
37
37
  import { mutateSessionNote } from './session-note-io.mjs';
38
38
  import { captureProjectScope, projectScopePatch } from './project-scope.mjs';
39
+ import { sanitizeMemoryText } from './memory-schema.mjs';
39
40
 
40
41
  function sessionIdFromInput(input) {
41
42
  return input.session_id || input.sessionId || input.codex_session_id || '';
42
43
  }
43
44
 
45
+ function workSessionIdFromInput(input = {}) {
46
+ const shared = input.shared || input.handoff?.shared;
47
+ const value = input.work_session_id
48
+ || input.workSessionId
49
+ || shared?.work_session_id
50
+ || shared?.workSessionId
51
+ || input.handoff?.work_session_id
52
+ || input.handoff?.workSessionId
53
+ || '';
54
+ return sanitizeMemoryText(value).trim();
55
+ }
56
+
57
+ function workSessionPatch(input = {}) {
58
+ const workSessionId = workSessionIdFromInput(input);
59
+ return workSessionId ? { work_session_id: workSessionId } : {};
60
+ }
61
+
44
62
  function turnSequenceFromInput(input = {}) {
45
63
  const value = input.turn_sequence ?? input.turnSequence;
46
64
  const parsed = Number(value);
@@ -290,6 +308,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
290
308
  transcript_path: identity.transcriptPath,
291
309
  transcript_id: identity.transcriptId,
292
310
  provider: identity.provider,
311
+ ...workSessionPatch(input),
293
312
  ...scopePatch,
294
313
  ...causalTurnPatch(input, now),
295
314
  });
@@ -318,6 +337,7 @@ function createSession({ vaultBase, sessionId, input, now, identity, scopePatch
318
337
  transcript_path: identity.transcriptPath,
319
338
  transcript_id: identity.transcriptId,
320
339
  provider: identity.provider,
340
+ ...workSessionPatch(input),
321
341
  ...scopePatch,
322
342
  ...causalTurnPatch(input, now),
323
343
  });
@@ -361,6 +381,7 @@ function main() {
361
381
  upsertSessionRegistry(vaultBase, sessionId, {
362
382
  transcript_paths: [identity.transcriptPath],
363
383
  provider: identity.provider,
384
+ ...workSessionPatch(input),
364
385
  });
365
386
  }
366
387
  writeHookOutput({});
@@ -398,6 +419,7 @@ function main() {
398
419
  transcript_path: identity.transcriptPath,
399
420
  transcript_id: identity.transcriptId,
400
421
  provider: identity.provider,
422
+ ...workSessionPatch(input),
401
423
  ...scopePatch,
402
424
  ...causalTurnPatch(input, now),
403
425
  });
@@ -443,6 +465,7 @@ function main() {
443
465
  transcript_path: identity.transcriptPath,
444
466
  transcript_id: identity.transcriptId,
445
467
  provider: identity.provider,
468
+ ...workSessionPatch(input),
446
469
  ...scopePatch,
447
470
  ...causalTurnPatch(input, now),
448
471
  });
@@ -65,8 +65,10 @@ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectP
65
65
  export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
66
66
  const identity = resolveSessionIdentity(vaultBase, input, provider);
67
67
  if (identity.state !== 'resolved') return { identity, entry: null };
68
+ const entry = readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null;
69
+ const workSessionId = entry?.work_session_id ? String(entry.work_session_id) : '';
68
70
  return {
69
- identity,
70
- entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null,
71
+ identity: workSessionId ? { ...identity, work_session_id: workSessionId } : identity,
72
+ entry,
71
73
  };
72
74
  }
@@ -521,6 +521,21 @@ function shouldFinalizeSession() {
521
521
  return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
522
522
  }
523
523
 
524
+ function sharedHandoffFromInput(input = {}, entry = {}) {
525
+ const supplied = input.shared || input.handoff?.shared;
526
+ const shared = supplied && typeof supplied === 'object' && !Array.isArray(supplied)
527
+ ? { ...supplied }
528
+ : {};
529
+ const workSessionId = shared.work_session_id
530
+ || shared.workSessionId
531
+ || input.work_session_id
532
+ || input.workSessionId
533
+ || entry?.work_session_id
534
+ || '';
535
+ if (!shared.work_session_id && workSessionId) shared.work_session_id = workSessionId;
536
+ return Object.keys(shared).length ? shared : null;
537
+ }
538
+
524
539
  export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
525
540
  if (detectMemoryMode(vaultBase).mode === 'legacy') {
526
541
  return { status: 'legacy', eventCount: 0, eventIds: [], checkpoint: null };
@@ -1378,6 +1393,7 @@ export async function main({
1378
1393
  summary: finalSummary,
1379
1394
  noteRel: sessionRel,
1380
1395
  });
1396
+ const sharedHandoff = sharedHandoffFromInput(input, entry);
1381
1397
  memoryHandoff = {
1382
1398
  projectId,
1383
1399
  identity,
@@ -1390,6 +1406,7 @@ export async function main({
1390
1406
  observedAt: turnIdentity.observedAt || new Date(0).toISOString(),
1391
1407
  summary: finalSummary,
1392
1408
  evidence: memoryEvidence,
1409
+ ...(sharedHandoff ? { shared: sharedHandoff } : {}),
1393
1410
  };
1394
1411
  memoryAttempt = stageMemory(vaultBase, {
1395
1412
  handoff: memoryHandoff,
@@ -231,6 +231,12 @@ function memoryMetrics() {
231
231
  pendingOutbox: 0,
232
232
  candidates: 0,
233
233
  activeConflicts: 0,
234
+ semanticStatus: null,
235
+ semanticCode: null,
236
+ semanticActiveKeys: [],
237
+ semanticProjectedKeys: [],
238
+ semanticMissingKeys: [],
239
+ semanticCounts: {},
234
240
  };
235
241
  }
236
242
 
@@ -505,6 +511,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
505
511
  for (const warning of bundle.warnings || []) warnings.push(warning);
506
512
 
507
513
  const ok = failures.length === 0;
514
+ const semantic = bundle.semantic || {};
508
515
  return {
509
516
  ok,
510
517
  status: ok ? (warnings.length ? 'warning' : 'healthy') : 'blocked',
@@ -519,6 +526,12 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
519
526
  pendingOutbox: outbox.count,
520
527
  candidates: candidates.items.length,
521
528
  activeConflicts: activeConflicts.length,
529
+ semanticStatus: semantic.status ?? null,
530
+ semanticCode: semantic.code ?? null,
531
+ semanticActiveKeys: semantic.activeKeys || [],
532
+ semanticProjectedKeys: semantic.projectedKeys || [],
533
+ semanticMissingKeys: semantic.missingKeys || [],
534
+ semanticCounts: semantic.counts || {},
522
535
  },
523
536
  };
524
537
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.68.6",
3
+ "version": "0.69.0",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -70,6 +70,6 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "acorn": "^8.18.0",
73
- "wendkeep": "^0.68.0"
73
+ "wendkeep": "^0.68.6"
74
74
  }
75
75
  }
@@ -107,7 +107,7 @@ Usage:
107
107
  promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
108
108
  Reconcile is dry-run by default; the original attempt remains audited.
109
109
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
110
- protocol (cap 25, 3 sections, no secrets/PII).
110
+ protocol (cap 40, warning 35, 4 KiB, 320 chars/line, 3 sections, no secrets/PII).
111
111
  --vault <path> validates the complete v2 bundle.
112
112
  wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
113
113
  project: .brain/agents/*.toml -> .codex/agents,
@@ -4,12 +4,55 @@ import { basename, join, relative } from 'node:path';
4
4
 
5
5
  import { sanitizeMemoryText } from './memory-schema.mjs';
6
6
 
7
+ const SHARED_HANDOFF_FIELDS = Object.freeze([
8
+ ['objective', 'objective.current'],
9
+ ['delivered', 'state.delivered'],
10
+ ['constraints', 'constraint.active'],
11
+ ['decisions', 'decision.active'],
12
+ ['next_actions', 'next.action'],
13
+ ['blockers', 'blocker.active'],
14
+ ['risks', 'risk.known'],
15
+ ]);
16
+
7
17
  function canonicalValue(value) {
8
18
  if (Array.isArray(value)) return value.map(canonicalValue);
9
19
  if (!value || typeof value !== 'object') return value;
10
20
  return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
11
21
  }
12
22
 
23
+ function sanitizeValue(value) {
24
+ if (typeof value === 'string') return sanitizeMemoryText(value);
25
+ if (Array.isArray(value)) return value.map(sanitizeValue);
26
+ if (value && typeof value === 'object') {
27
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sanitizeValue(value[key])]));
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function hasMeaningfulValue(value) {
33
+ if (typeof value === 'string') return value.trim().length > 0;
34
+ if (Array.isArray(value)) return value.length > 0;
35
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
36
+ return value !== undefined && value !== null;
37
+ }
38
+
39
+ /** Normalize the portable operational handoff without inventing missing identity. */
40
+ export function normalizeSharedHandoff(shared) {
41
+ if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
42
+
43
+ const normalized = {};
44
+ const workSessionId = sanitizeMemoryText(shared.work_session_id ?? shared.workSessionId ?? '').trim();
45
+ if (workSessionId) normalized.work_session_id = workSessionId;
46
+
47
+ for (const [field] of SHARED_HANDOFF_FIELDS) {
48
+ if (!Object.hasOwn(shared, field)) continue;
49
+ const value = sanitizeValue(shared[field]);
50
+ if (hasMeaningfulValue(value)) normalized[field] = value;
51
+ }
52
+
53
+ return Object.keys(normalized).length ? normalized : null;
54
+ }
55
+
13
56
  function eventId(context, memoryKey, value) {
14
57
  const digest = createHash('sha256')
15
58
  .update(JSON.stringify([
@@ -26,8 +69,8 @@ function eventId(context, memoryKey, value) {
26
69
  }
27
70
 
28
71
  function makeEvent(context, { memoryKey, value, authority, evidence }) {
29
- const cleanValue = typeof value === 'string' ? sanitizeMemoryText(value) : canonicalValue(value);
30
- return {
72
+ const cleanValue = sanitizeValue(value);
73
+ const event = {
31
74
  v: 1,
32
75
  event_id: eventId(context, memoryKey, cleanValue),
33
76
  project_id: String(context.projectId || ''),
@@ -43,6 +86,8 @@ function makeEvent(context, { memoryKey, value, authority, evidence }) {
43
86
  observed_at: context.observedAt,
44
87
  evidence: (evidence || []).filter(Boolean).map((item) => sanitizeMemoryText(item)),
45
88
  };
89
+ if (context.workSessionId) event.work_session_id = context.workSessionId;
90
+ return event;
46
91
  }
47
92
 
48
93
  function readJson(path) {
@@ -133,14 +178,35 @@ export function buildSessionMemoryEvents({
133
178
  observedAt,
134
179
  summary,
135
180
  evidence = {},
181
+ shared,
136
182
  }) {
137
- const context = { projectId, identity, activation, turn, observedAt };
138
- const events = [makeEvent(context, {
139
- memoryKey: 'handoff.latest',
140
- value: sanitizeMemoryText(summary),
141
- authority: 'reported',
142
- evidence: [noteRel],
143
- })];
183
+ const normalizedShared = normalizeSharedHandoff(shared);
184
+ const context = {
185
+ projectId, identity, activation, turn, observedAt,
186
+ workSessionId: normalizedShared?.work_session_id || '',
187
+ };
188
+ const events = [];
189
+
190
+ if (normalizedShared) {
191
+ for (const [field, memoryKey] of SHARED_HANDOFF_FIELDS) {
192
+ if (!Object.hasOwn(normalizedShared, field)) continue;
193
+ events.push(makeEvent(context, {
194
+ memoryKey,
195
+ value: normalizedShared[field],
196
+ authority: 'reported',
197
+ evidence: [noteRel],
198
+ }));
199
+ }
200
+ }
201
+
202
+ if (!events.length) {
203
+ events.push(makeEvent(context, {
204
+ memoryKey: 'handoff.latest',
205
+ value: sanitizeMemoryText(summary),
206
+ authority: 'reported',
207
+ evidence: [noteRel],
208
+ }));
209
+ }
144
210
 
145
211
  if (evidence.change?.slug && evidence.change?.status && evidence.change?.adr) {
146
212
  events.push(makeEvent(context, {
@@ -437,6 +437,29 @@ function conflictCandidate(memoryKey, events, currentEvent = null) {
437
437
  };
438
438
  }
439
439
 
440
+ function conflictReviewEvent(candidate, candidateCount = 1) {
441
+ const source = candidate.events?.[0] || {};
442
+ const memoryKey = sanitizeMemoryText(candidate.memory_key || 'unknown');
443
+ const eventCount = Array.isArray(candidate.event_ids) ? candidate.event_ids.length : 0;
444
+ return {
445
+ v: 1,
446
+ event_id: `mem-review-${candidate.candidate_id}`,
447
+ project_id: source.project_id || '',
448
+ memory_key: candidate.memory_key,
449
+ operation: 'assert',
450
+ value: `[revisão pendente: ${memoryKey}; candidates: ${candidateCount}; events: ${eventCount}]`,
451
+ authority: 'candidate',
452
+ canonical_session_id: source.canonical_session_id || 'memory-reducer',
453
+ activation_id: source.activation_id || 'memory-reducer',
454
+ activation_epoch: Number.isInteger(source.activation_epoch) ? source.activation_epoch : 0,
455
+ turn_sequence: 0,
456
+ source_turn_id: 'memory-review',
457
+ observed_at: source.observed_at || new Date(0).toISOString(),
458
+ evidence: ['MEMORY_CANDIDATES.jsonl'],
459
+ review_pending: true,
460
+ };
461
+ }
462
+
440
463
  function sortedObject(entries) {
441
464
  return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right)));
442
465
  }
@@ -753,14 +776,19 @@ export function reduceMemoryEvents(inputEvents = [], {
753
776
  && !resolvedCandidateIds.has(item.candidate_id));
754
777
  unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
755
778
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
756
- const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
757
- ...record.source,
758
- memory_key: memoryKey,
759
- operation: 'assert',
760
- value: record.value,
761
- }));
762
779
  const eventCursor = events.at(-1)?.event_id || 'none';
763
780
  const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
781
+ const activeEvents = [
782
+ ...Object.entries(recordObject).map(([memoryKey, record]) => ({
783
+ ...record.source,
784
+ memory_key: memoryKey,
785
+ operation: 'assert',
786
+ value: record.value,
787
+ })),
788
+ ...unresolvedCandidates.map((candidate) => (
789
+ conflictReviewEvent(candidate, unresolvedCandidates.length)
790
+ )),
791
+ ];
764
792
 
765
793
  return {
766
794
  state,
@@ -1,6 +1,7 @@
1
1
  // Memory-compaction protocol for the curated .brain/CORE.md layer.
2
2
  // Ported from NutriGym-Vision's scripts/validate-brain-core.js to ESM:
3
- // - cap 25 lines (hard), 22 (soft warning) — 1 durable item per line
3
+ // - cap 40 lines (hard), 35 (soft warning) — 1 durable item per line
4
+ // - 4 KiB and 320 characters per line
4
5
  // - 3 required sections
5
6
  // - no secrets / no real-provider PII emails
6
7
  // Plus the seeded skeleton and the protocol reference doc.
@@ -8,8 +9,12 @@
8
9
  import { existsSync, readFileSync } from 'node:fs';
9
10
  import { isAbsolute, join, resolve } from 'node:path';
10
11
 
11
- const HARD_LIMIT = 25;
12
- const SOFT_LIMIT = 22;
12
+ export const CORE_LIMITS = Object.freeze({
13
+ lines: 40,
14
+ warningLines: 35,
15
+ bytes: 4 * 1024,
16
+ lineChars: 320,
17
+ });
13
18
 
14
19
  // Bilingual (0.8.0): a CORE is valid when it carries the COMPLETE section set of either
15
20
  // locale — pt-BR or en. Mixed/partial sets fail (the 3 sections are one contract).
@@ -40,16 +45,25 @@ const SECRET_PATTERNS = [
40
45
 
41
46
  const PII_EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b)(?:gmail|hotmail|yahoo|outlook|live|icloud|protonmail)\.[A-Za-z]{2,}\b/i;
42
47
 
43
- // Validate CORE.md content. Returns { ok, errors, warnings, lineCount }.
48
+ // Validate CORE.md content. Returns { ok, errors, warnings, lineCount, byteCount }.
44
49
  export function validateCore(content) {
45
50
  const text = String(content ?? '');
46
51
  const lines = text.split('\n');
47
52
  const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
53
+ const byteCount = Buffer.byteLength(text, 'utf8');
48
54
  const errors = [];
49
55
 
50
- if (lineCount > HARD_LIMIT) {
51
- errors.push(`Tamanho ${lineCount} > ${HARD_LIMIT} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
56
+ if (lineCount > CORE_LIMITS.lines) {
57
+ errors.push(`Tamanho ${lineCount} > ${CORE_LIMITS.lines} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
52
58
  }
59
+ if (byteCount > CORE_LIMITS.bytes) {
60
+ errors.push(`Tamanho ${byteCount} > ${CORE_LIMITS.bytes} bytes (budget do CORE). Curar: manter apenas estado durável.`);
61
+ }
62
+ lines.forEach((line, index) => {
63
+ if (line.length > CORE_LIMITS.lineChars) {
64
+ errors.push(`Linha ${index + 1} tem ${line.length} caracteres; limite ${CORE_LIMITS.lineChars}.`);
65
+ }
66
+ });
53
67
  // Pick the locale set that matches best; require it to be complete.
54
68
  const missingBySet = Object.values(SECTION_SETS).map((set) => set.filter(({ regex }) => !regex.test(text)));
55
69
  const best = missingBySet.reduce((a, b) => (b.length < a.length ? b : a));
@@ -62,11 +76,11 @@ export function validateCore(content) {
62
76
  if (em) errors.push(`Email real detectado: "${em[0]}" — usar user@example.com.`);
63
77
 
64
78
  const warnings = [];
65
- if (lineCount >= SOFT_LIMIT && lineCount <= HARD_LIMIT) {
66
- warnings.push(`Tamanho ${lineCount}/${HARD_LIMIT} linhas — perto do limite; remover itens resolvidos (≥${SOFT_LIMIT}).`);
79
+ if (lineCount >= CORE_LIMITS.warningLines && lineCount <= CORE_LIMITS.lines) {
80
+ warnings.push(`Tamanho ${lineCount}/${CORE_LIMITS.lines} linhas — perto do limite; remover itens resolvidos (≥${CORE_LIMITS.warningLines}).`);
67
81
  }
68
82
 
69
- return { ok: errors.length === 0, errors, warnings, lineCount };
83
+ return { ok: errors.length === 0, errors, warnings, lineCount, byteCount };
70
84
  }
71
85
 
72
86
  // The seeded CORE.md (must pass validateCore). Bootstraps the 3 sections so the
@@ -75,7 +89,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
75
89
  if (localeId === 'en') {
76
90
  return `# CORE — curated memory core (.brain)
77
91
 
78
- > RULE #1 — the project's canonical memory. Hand-curated, 25-line cap (validate: \`wendkeep validate-memory\`). Volatile facts live in DIGEST.md (auto). Depth: /brain-recall <topic>.
92
+ > RULE #1 — the project's canonical memory. Hand-curated, 40-line cap (validate: \`wendkeep validate-memory\`). Volatile facts live in DIGEST.md (auto). Depth: /brain-recall <topic>.
79
93
 
80
94
  ## User Preferences
81
95
  - (durable preferences: language, style, conventions)
@@ -89,7 +103,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
89
103
  }
90
104
  return `# CORE — núcleo curado da memória (.brain)
91
105
 
92
- > REGRA #1 — memória canônica do projeto. Curado à mão, cap 25 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
106
+ > REGRA #1 — memória canônica do projeto. Curado à mão, cap 40 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
93
107
 
94
108
  ## Preferências do Usuário
95
109
  - (preferências duráveis: idioma, estilo, convenções)
@@ -110,8 +124,8 @@ export function renderCompactionProtocol() {
110
124
 
111
125
  ## 1. Duas camadas
112
126
 
113
- - **QUENTE** (auto-injetada por sessão, budget ~45 linhas):
114
- - \`.brain/CORE.md\` — curado à mão, **≤25 linhas** (1 item/linha): preferências, padrões, pendências.
127
+ - **QUENTE** (auto-injetada por sessão, com budgets por camada):
128
+ - \`.brain/CORE.md\` — curado à mão, **≤40 linhas** (alerta em 35; 4 KiB; 320 caracteres por linha): preferências, padrões, pendências.
115
129
  - \`.brain/DIGEST.md\` — auto-gerado (0 token LLM, ≤15 linhas): decisões/sessões/bugs/aprendizados recentes.
116
130
  - **FRIA** (sob demanda):
117
131
  - \`.brain/index.jsonl\` — índice de todas as sessões (1/linha, frontmatter).
@@ -120,7 +134,7 @@ export function renderCompactionProtocol() {
120
134
  ## 2. Compactação = regra de geração (sem trabalho manual)
121
135
 
122
136
  - **DIGEST se auto-compacta**: caps determinísticos (5 decisões, 4 sessões, 2 bugs, 2 aprendizados + \`+N mais\`). O velho cai do quente sozinho e permanece no índice/vault. **NUNCA editar** \`DIGEST.md\`/\`index.jsonl\`.
123
- - **CORE**: quando ≥22 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
137
+ - **CORE**: quando ≥35 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
124
138
 
125
139
  ## 3. O que escrever no CORE
126
140
 
@@ -139,7 +153,7 @@ wendkeep validate-memory # valida <vault>/.brain/CORE.md
139
153
  wendkeep validate-memory <path> # valida outro arquivo
140
154
  \`\`\`
141
155
 
142
- Checa: cap 25 (soft 22), 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
156
+ Checa: cap 40 (soft 35), 4 KiB, 320 caracteres por linha, 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
143
157
  `;
144
158
  }
145
159
 
@@ -1,6 +1,7 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+ import { sanitizeMemoryText, validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
4
+ import { deriveMemoryProjection } from './memory-store.mjs';
4
5
  import { assertVaultPathSafe } from './vault-path-safety.mjs';
5
6
  import { validateCore } from './validate-core.mjs';
6
7
 
@@ -104,8 +105,208 @@ function validateSharedArtifact(vaultBase, eventIds) {
104
105
  return { ...validateSharedMemory(read.content, { eventIds }), path, content: read.content };
105
106
  }
106
107
 
107
- export function combineMemoryResults({ project, core, ledger, shared }) {
108
- const components = { project, core, ledger, shared };
108
+ const TERMINAL_CANDIDATE_STATUSES = new Set(['resolved', 'rejected', 'superseded']);
109
+ const DECISION_LINK_RE = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
110
+
111
+ function readCandidateInventory(vaultBase) {
112
+ const path = join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl');
113
+ let checked;
114
+ try {
115
+ checked = assertVaultPathSafe(vaultBase, path, {
116
+ allowMissing: true, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
117
+ });
118
+ } catch (error) {
119
+ return failedComponent(`MEMORY_CANDIDATES.jsonl inseguro: ${error?.message || error}`, {
120
+ count: 0, activeCount: 0, path,
121
+ });
122
+ }
123
+ if (!checked.exists) return { ok: true, errors: [], warnings: [], count: 0, activeCount: 0, path };
124
+
125
+ try {
126
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
127
+ allowMissing: false, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
128
+ });
129
+ const lines = readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n')
130
+ .split('\n').filter((line) => line.trim());
131
+ const errors = [];
132
+ let count = 0;
133
+ let activeCount = 0;
134
+ for (const [index, line] of lines.entries()) {
135
+ try {
136
+ const item = JSON.parse(line);
137
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
138
+ errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} deve conter um objeto.`);
139
+ continue;
140
+ }
141
+ count += 1;
142
+ if (!TERMINAL_CANDIDATE_STATUSES.has(item.status || 'active')) activeCount += 1;
143
+ } catch (error) {
144
+ errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} contém JSON inválido: ${error?.message || error}`);
145
+ }
146
+ }
147
+ return { ok: errors.length === 0, errors, warnings: [], count, activeCount, path };
148
+ } catch (error) {
149
+ return failedComponent(`MEMORY_CANDIDATES.jsonl ilegível: ${error?.message || error}`, {
150
+ count: 0, activeCount: 0, path,
151
+ });
152
+ }
153
+ }
154
+
155
+ function sharedEventIds(content) {
156
+ const ids = new Set();
157
+ for (const line of String(content || '').split('\n')) {
158
+ const match = line.match(/^\s*-\s+\[([^\]]+)\]/);
159
+ if (match?.[1]) ids.add(match[1]);
160
+ }
161
+ return ids;
162
+ }
163
+
164
+ function walkDecisionFiles(root, vaultBase, output = []) {
165
+ let entries;
166
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return output; }
167
+ for (const entry of entries) {
168
+ const path = join(root, entry.name);
169
+ if (entry.isDirectory()) walkDecisionFiles(path, vaultBase, output);
170
+ else if (entry.isFile() && /^ADR-\d+.*\.md$/i.test(entry.name)) {
171
+ const rel = relative(vaultBase, path).replace(/\\/g, '/').replace(/\.md$/i, '');
172
+ output.push(rel);
173
+ }
174
+ }
175
+ return output;
176
+ }
177
+
178
+ function decisionInventory(vaultBase) {
179
+ const paths = [];
180
+ for (const folder of ['04-Decisões', '04-Decisions']) {
181
+ walkDecisionFiles(join(vaultBase, folder), vaultBase, paths);
182
+ }
183
+ const byPath = new Set(paths);
184
+ const byBasename = new Set(paths.map((path) => basename(path)));
185
+ return { byPath, byBasename };
186
+ }
187
+
188
+ function unresolvedDecisionLinks(vaultBase, content) {
189
+ const inventory = decisionInventory(vaultBase);
190
+ const unresolved = new Set();
191
+ DECISION_LINK_RE.lastIndex = 0;
192
+ let match;
193
+ while ((match = DECISION_LINK_RE.exec(String(content || '')))) {
194
+ const target = String(match[1] || '').trim().replace(/\\/g, '/').replace(/\.md$/i, '');
195
+ const targetBase = basename(target);
196
+ const isDecision = /^ADR-\d+/i.test(targetBase)
197
+ || /(^|\/)(?:04-Decis(?:õ|o)es|04-Decisions)(?:\/|$)/i.test(target);
198
+ if (!isDecision) continue;
199
+ if (!target || target.includes('...') || target.includes('…')
200
+ || (!inventory.byPath.has(target) && !inventory.byBasename.has(targetBase))) {
201
+ unresolved.add(sanitizeMemoryText(target).slice(0, 180));
202
+ }
203
+ }
204
+ return [...unresolved].sort();
205
+ }
206
+
207
+ function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
208
+ const base = {
209
+ ok: true,
210
+ status: 'unavailable',
211
+ code: 'MEMORY_SEMANTIC_STRUCTURAL_UNAVAILABLE',
212
+ errors: [],
213
+ warnings: [],
214
+ activeKeys: [],
215
+ projectedKeys: [],
216
+ missingKeys: [],
217
+ unresolvedDecisionLinks: [],
218
+ placeholderOnly: false,
219
+ counts: { activeKeys: 0, projectedKeys: 0, missingKeys: 0, candidates: candidates?.activeCount || 0, placeholderSections: 0, unresolvedDecisionLinks: 0 },
220
+ };
221
+ if (!ledger?.ok || !shared?.ok) return base;
222
+
223
+ let replay;
224
+ try { replay = deriveMemoryProjection(vaultBase, ledger.events); }
225
+ catch (error) {
226
+ return {
227
+ ...base,
228
+ ok: false,
229
+ status: 'degraded',
230
+ code: 'MEMORY_SEMANTIC_REPLAY_UNAVAILABLE',
231
+ errors: ['[MEMORY_SEMANTIC_REPLAY_UNAVAILABLE] não foi possível rederivar as chaves ativas do ledger.'],
232
+ };
233
+ }
234
+
235
+ const active = Object.entries(replay.records || {}).map(([memoryKey, record]) => ({
236
+ memoryKey: sanitizeMemoryText(memoryKey),
237
+ eventId: record?.source?.event_id,
238
+ }));
239
+ const activeKeys = active.map(({ memoryKey }) => memoryKey).sort();
240
+ const projectedIds = sharedEventIds(shared.content);
241
+ const projectedKeys = active
242
+ .filter(({ eventId }) => projectedIds.has(eventId))
243
+ .map(({ memoryKey }) => memoryKey)
244
+ .sort();
245
+ const missingKeys = active
246
+ .filter(({ eventId }) => !projectedIds.has(eventId))
247
+ .map(({ memoryKey }) => memoryKey)
248
+ .sort();
249
+ const sectionValues = [...(shared.sections?.values?.() || [])].flat();
250
+ const placeholderSections = sectionValues.filter((line) => /^-\s*\(vazio\)\s*$/i.test(line)).length;
251
+ const nonPlaceholderLines = sectionValues.filter((line) => !/^-\s*\(vazio\)\s*$/i.test(line));
252
+ const placeholderOnly = sectionValues.length > 0 && nonPlaceholderLines.length === 0;
253
+ const unresolvedLinks = unresolvedDecisionLinks(vaultBase, shared.content);
254
+ const candidateCount = Math.max(candidates?.activeCount || 0, replay.candidates?.length || 0);
255
+ const errors = [];
256
+ const warnings = [];
257
+ const codes = [];
258
+
259
+ if (missingKeys.length && placeholderOnly) {
260
+ codes.push('MEMORY_SEMANTIC_PLACEHOLDER_ONLY');
261
+ errors.push(`[MEMORY_SEMANTIC_PLACEHOLDER_ONLY] SHARED contém somente placeholders para ${activeKeys.length} chave(s) ativa(s); candidates=${candidateCount}.`);
262
+ } else if (missingKeys.length) {
263
+ codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
264
+ errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
265
+ }
266
+ if (unresolvedLinks.length) {
267
+ codes.push('MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED');
268
+ errors.push(`[MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED] ${unresolvedLinks.length} link(s) de decisão não resolvido(s).`);
269
+ }
270
+
271
+ let status = 'healthy';
272
+ let code = 'MEMORY_SEMANTIC_COVERAGE_OK';
273
+ if (!ledger.events.length && !candidateCount) {
274
+ status = 'neutral';
275
+ code = 'MEMORY_SEMANTIC_EMPTY_NEUTRAL';
276
+ } else if (!activeKeys.length && candidateCount) {
277
+ status = 'degraded';
278
+ code = 'MEMORY_SEMANTIC_CANDIDATES_PENDING';
279
+ warnings.push(`[MEMORY_SEMANTIC_CANDIDATES_PENDING] ${candidateCount} candidate(s) preservado(s); o estado não é memória vazia.`);
280
+ } else if (codes.length) {
281
+ status = 'degraded';
282
+ code = codes[0];
283
+ }
284
+
285
+ return {
286
+ ok: errors.length === 0,
287
+ status,
288
+ code,
289
+ codes,
290
+ errors,
291
+ warnings,
292
+ activeKeys,
293
+ projectedKeys,
294
+ missingKeys,
295
+ unresolvedDecisionLinks: unresolvedLinks,
296
+ placeholderOnly,
297
+ counts: {
298
+ activeKeys: activeKeys.length,
299
+ projectedKeys: projectedKeys.length,
300
+ missingKeys: missingKeys.length,
301
+ candidates: candidateCount,
302
+ placeholderSections,
303
+ unresolvedDecisionLinks: unresolvedLinks.length,
304
+ },
305
+ };
306
+ }
307
+
308
+ export function combineMemoryResults({ project, core, ledger, shared, candidates, semantic }) {
309
+ const components = { project, core, ledger, shared, candidates, semantic };
109
310
  const errors = [];
110
311
  const warnings = [];
111
312
  for (const [name, result] of Object.entries(components)) {
@@ -124,5 +325,7 @@ export function validateMemoryBundle(vaultBase) {
124
325
  const core = validateCoreArtifact(vaultBase);
125
326
  const ledger = readLedgerForValidation(vaultBase, { projectId: project.projectId });
126
327
  const shared = validateSharedArtifact(vaultBase, ledger.eventIds);
127
- return combineMemoryResults({ project, core, ledger, shared });
328
+ const candidates = readCandidateInventory(vaultBase);
329
+ const semantic = semanticMemoryHealth(vaultBase, { ledger, shared, candidates });
330
+ return combineMemoryResults({ project, core, ledger, shared, candidates, semantic });
128
331
  }
package/src/doctor.mjs CHANGED
@@ -39,6 +39,10 @@ export function renderVaultHealthLines(result) {
39
39
  `[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
40
40
  );
41
41
  lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
42
+ const semanticKeys = memory.semanticActiveKeys || [];
43
+ const semanticProjected = memory.semanticProjectedKeys || [];
44
+ const semanticMissing = memory.semanticMissingKeys || [];
45
+ lines.push(` semântica: ${metricValue(memory.semanticCode)} · ativas: ${semanticKeys.length} [${semanticKeys.join(', ')}] · projetadas: ${semanticProjected.length} · ausentes: ${semanticMissing.length}`);
42
46
  for (const failure of memoryFailures) lines.push(` ✗ ${failure}`);
43
47
  for (const warning of memoryWarnings) lines.push(` ! ${warning}`);
44
48
  if (result.memoryStatus === 'healthy' && !memoryFailures.length && !memoryWarnings.length) {
package/src/memory.mjs CHANGED
@@ -1874,13 +1874,16 @@ export function runValidateMemoryBundle(argv) {
1874
1874
  return;
1875
1875
  }
1876
1876
  const result = validateMemoryBundle(vault);
1877
+ const semantic = result.semantic || {};
1878
+ const semanticSummary = `semântica ${semantic.code || 'n/a'} · ativas: ${semantic.counts?.activeKeys ?? 0} · projetadas: ${semantic.counts?.projectedKeys ?? 0} · ausentes: ${semantic.counts?.missingKeys ?? 0}`;
1877
1879
  if (!result.ok) {
1878
1880
  process.stderr.write(`❌ bundle de memória inválido (${result.errors.length} erro(s)):\n`);
1881
+ process.stderr.write(` ${semanticSummary}\n`);
1879
1882
  for (const error of result.errors) process.stderr.write(` - ${error}\n`);
1880
1883
  process.exitCode = 1;
1881
1884
  return;
1882
1885
  }
1883
- process.stdout.write('✅ bundle de memória v2 OK (CORE + ledger + SHARED).\n');
1886
+ process.stdout.write(`✅ bundle de memória v2 OK (CORE + ledger + SHARED; ${semanticSummary}).\n`);
1884
1887
  process.exitCode = 0;
1885
1888
  }
1886
1889