wendkeep 0.58.1 → 0.59.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.en.md +70 -40
  3. package/README.md +70 -40
  4. package/bin/wendkeep.mjs +54 -6
  5. package/docs/en/commands/changes-and-verification.md +85 -0
  6. package/docs/en/commands/costs-and-observability.md +65 -0
  7. package/docs/en/commands/getting-started.md +86 -0
  8. package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
  9. package/docs/en/commands/memory-migration.md +73 -0
  10. package/docs/en/commands/memory.md +102 -0
  11. package/docs/en/commands/notes-and-knowledge.md +70 -0
  12. package/docs/en/commands/operating-profiles.md +173 -0
  13. package/docs/en/commands/retroactive-import.md +67 -0
  14. package/docs/en/commands/sessions-and-import.md +89 -0
  15. package/docs/en/commands/verify.md +92 -0
  16. package/docs/pt-BR/commands/changes-and-verification.md +85 -0
  17. package/docs/pt-BR/commands/costs-and-observability.md +65 -0
  18. package/docs/pt-BR/commands/getting-started.md +87 -0
  19. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
  20. package/docs/pt-BR/commands/memory-migration.md +73 -0
  21. package/docs/pt-BR/commands/memory.md +99 -0
  22. package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
  23. package/docs/pt-BR/commands/operating-profiles.md +171 -0
  24. package/docs/pt-BR/commands/retroactive-import.md +67 -0
  25. package/docs/pt-BR/commands/sessions-and-import.md +89 -0
  26. package/docs/pt-BR/commands/verify.md +93 -0
  27. package/hooks/brain-core.mjs +159 -159
  28. package/hooks/brain-inject.mjs +83 -26
  29. package/hooks/brain-recall.mjs +32 -32
  30. package/hooks/brain-reindex.mjs +13 -13
  31. package/hooks/change-context.mjs +24 -10
  32. package/hooks/change-core.mjs +174 -37
  33. package/hooks/change-guard.mjs +115 -16
  34. package/hooks/change-nag.mjs +20 -5
  35. package/hooks/change-warn.mjs +27 -9
  36. package/hooks/decision-capture.mjs +1 -1
  37. package/hooks/derived-sections.mjs +1 -1
  38. package/hooks/flow-core.mjs +891 -0
  39. package/hooks/flow-protected-policy.mjs +218 -0
  40. package/hooks/frontmatter-repair.mjs +3 -1
  41. package/hooks/git-snapshot.mjs +722 -0
  42. package/hooks/import-sessions.mjs +10 -5
  43. package/hooks/memory-mode.mjs +63 -13
  44. package/hooks/memory-store.mjs +309 -69
  45. package/hooks/obsidian-common.mjs +119 -84
  46. package/hooks/operating-profile-runtime.mjs +157 -0
  47. package/hooks/plan-capture.mjs +14 -3
  48. package/hooks/sensors-core.mjs +15 -3
  49. package/hooks/session-backfill.mjs +7 -2
  50. package/hooks/session-ensure.mjs +21 -12
  51. package/hooks/session-iteration.mjs +65 -0
  52. package/hooks/session-memory-lifecycle.mjs +335 -0
  53. package/hooks/session-note-io.mjs +130 -15
  54. package/hooks/session-observability.mjs +4 -2
  55. package/hooks/session-stop.mjs +181 -59
  56. package/hooks/spec-core.mjs +91 -12
  57. package/hooks/subagent-stop.mjs +4 -1
  58. package/hooks/subagent-usage.mjs +2 -2
  59. package/hooks/task-log.mjs +3 -1
  60. package/hooks/token-usage.mjs +1 -1
  61. package/hooks/vault-health.mjs +268 -25
  62. package/hooks/vault-path-safety.mjs +558 -0
  63. package/hooks/vault-runtime-store.mjs +558 -0
  64. package/package.json +5 -3
  65. package/src/change.mjs +2 -1
  66. package/src/flow.mjs +232 -0
  67. package/src/init.mjs +26 -3
  68. package/src/memory.mjs +785 -35
  69. package/src/operating-profile.mjs +133 -0
  70. package/src/profile.mjs +224 -0
  71. package/src/project-vault.mjs +110 -5
  72. package/src/rebuild-costs.mjs +11 -4
  73. package/src/skills-seed.mjs +38 -16
  74. package/src/sync-defs.mjs +16 -7
  75. package/src/sync.mjs +9 -1
  76. package/src/taxonomy.mjs +9 -0
  77. package/src/validate-memory.mjs +21 -8
  78. package/src/verify.mjs +12 -2
@@ -1,10 +1,12 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
3
  import { createHash } from 'node:crypto';
4
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { ensureDir } from './obsidian-common.mjs';
7
6
  import { getLocale } from './locale.mjs';
7
+ import {
8
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, writeVaultFileSync,
9
+ } from './vault-path-safety.mjs';
8
10
 
9
11
  // Short stable fingerprint of tarefas.md — freshness check between package/verdict and gate.
10
12
  export function tasksHashOf(md) {
@@ -122,8 +124,14 @@ export function livingSpecCapabilities(vaultBase) {
122
124
 
123
125
  export function adoptSpecsState(vaultBase) {
124
126
  const state = { version: 1, generatedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
125
- ensureDir(join(vaultBase, '.brain'));
126
- writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
127
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz do estado de specs' });
128
+ writeVaultFileSync(
129
+ vaultBase,
130
+ join(vaultBase, SPECS_STATE_FILE),
131
+ `${JSON.stringify(state, null, 2)}\n`,
132
+ 'utf8',
133
+ { label: 'estado consolidado de specs' },
134
+ );
127
135
  return state;
128
136
  }
129
137
 
@@ -150,17 +158,32 @@ function recordPromotedSpecs(vaultBase, capabilities) {
150
158
  else delete specs[capability];
151
159
  }
152
160
  const state = { version: 1, generatedAt: new Date().toISOString(), specs };
153
- writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
161
+ writeVaultFileSync(
162
+ vaultBase,
163
+ join(vaultBase, SPECS_STATE_FILE),
164
+ `${JSON.stringify(state, null, 2)}\n`,
165
+ 'utf8',
166
+ { label: 'estado consolidado de specs' },
167
+ );
154
168
  return state;
155
169
  }
156
170
 
157
171
  export function captureSpecBaseline(vaultBase, changeDir, { refresh = false } = {}) {
158
172
  const path = join(changeDir, SPEC_BASELINE_FILE);
159
- if (!refresh && existsSync(path)) {
173
+ const checked = assertVaultPathSafe(vaultBase, path, {
174
+ expectedType: 'file', label: 'baseline de specs da change',
175
+ });
176
+ if (!refresh && checked.exists) {
160
177
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { /* rebuild malformed baseline */ }
161
178
  }
162
179
  const baseline = { version: 1, capturedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
163
- writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
180
+ writeVaultFileSync(
181
+ vaultBase,
182
+ path,
183
+ `${JSON.stringify(baseline, null, 2)}\n`,
184
+ 'utf8',
185
+ { label: 'baseline de specs da change' },
186
+ );
164
187
  return baseline;
165
188
  }
166
189
 
@@ -325,7 +348,7 @@ export function ensureSpecsReadme(vaultBase) {
325
348
  const loc = getLocale(vaultBase);
326
349
  const en = loc.id === 'en';
327
350
  const dir = join(vaultBase, loc.folders.specs);
328
- ensureDir(dir);
351
+ mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
329
352
  const body = en
330
353
  ? `# Specs — generated living contract
331
354
 
@@ -355,7 +378,48 @@ Pense como código-fonte vs commits: esta pasta é o *código atual* de cada cap
355
378
  \`wendkeep change archive\` promove para esta pasta.
356
379
  - Histórico por mudança → \`${loc.folders.changes}/_arquivo/\`. Contrato atual → aqui.
357
380
  `;
358
- writeFileSync(join(dir, 'README.md'), body, 'utf8');
381
+ writeVaultFileSync(vaultBase, join(dir, 'README.md'), body, 'utf8', { label: 'README de specs' });
382
+ }
383
+
384
+ export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
385
+ const loc = getLocale(vaultBase);
386
+ const specsRoot = join(vaultBase, loc.folders.specs);
387
+ const checkedRoot = assertVaultPathSafe(vaultBase, specsRoot, {
388
+ expectedType: 'directory', label: 'raiz de specs consolidadas',
389
+ });
390
+ const targets = [
391
+ { path: join(specsRoot, 'README.md'), expectedType: 'file', label: 'README de specs' },
392
+ { path: join(vaultBase, '.brain'), expectedType: 'directory', label: 'raiz do estado de specs' },
393
+ { path: join(vaultBase, SPECS_STATE_FILE), expectedType: 'file', label: 'estado consolidado de specs' },
394
+ ];
395
+ if (checkedRoot.exists) {
396
+ for (const name of readdirSync(checkedRoot.target)) {
397
+ if (!name.endsWith('.md')) continue;
398
+ targets.push({
399
+ path: join(checkedRoot.target, name),
400
+ allowMissing: false,
401
+ expectedType: 'file',
402
+ label: `spec consolidada existente ${name}`,
403
+ });
404
+ }
405
+ }
406
+ for (const capability of specs) {
407
+ targets.push(
408
+ {
409
+ path: join(changeDir, 'specs', capability, 'spec.md'),
410
+ allowMissing: false,
411
+ expectedType: 'file',
412
+ label: `delta da spec ${capability}`,
413
+ },
414
+ {
415
+ path: join(specsRoot, `${capability}.md`),
416
+ expectedType: 'file',
417
+ label: `spec consolidada ${capability}`,
418
+ },
419
+ );
420
+ }
421
+ assertVaultPathsSafe(vaultBase, targets);
422
+ return { specsRoot: checkedRoot.target };
359
423
  }
360
424
 
361
425
  // Merge each capability's delta (in the change) into the living spec in 07-Specs.
@@ -364,6 +428,7 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
364
428
  const specsDir = loc.folders.specs;
365
429
  const promoted = [];
366
430
  const warnings = [];
431
+ const { specsRoot } = assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs);
367
432
  const state = checkSpecsState(vaultBase);
368
433
  const unmanaged = state.missing ? [] : state.changed.filter((capability) => specs.includes(capability));
369
434
  if (unmanaged.length) {
@@ -371,6 +436,7 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
371
436
  }
372
437
  const conflicts = specConflicts(vaultBase, changeDir, specs);
373
438
  if (conflicts.length) throw new Error(`conflito de spec: ${conflicts.join('; ')} — reconcilie o delta e rode \`wendkeep spec rebase --change <slug> --accept-current\``);
439
+ const materialized = [];
374
440
  for (const cap of specs) {
375
441
  let deltaMd;
376
442
  try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
@@ -382,10 +448,23 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
382
448
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
383
449
  const applied = applyDelta(current, delta);
384
450
  warnings.push(...applied.warnings.map((w) => `${cap}: ${w}`));
385
- ensureDir(join(vaultBase, specsDir));
386
451
  const footer = changeWikilink ? `Atualizado por ${changeWikilink} em ${dateStr}.` : '';
387
- writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }), 'utf8');
388
- promoted.push(cap);
452
+ materialized.push({
453
+ capability: cap,
454
+ livePath,
455
+ content: renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }),
456
+ });
457
+ }
458
+ mkdirVaultPath(vaultBase, specsRoot, { label: 'raiz de specs consolidadas' });
459
+ for (const item of materialized) {
460
+ writeVaultFileSync(
461
+ vaultBase,
462
+ item.livePath,
463
+ item.content,
464
+ 'utf8',
465
+ { label: `spec consolidada ${item.capability}` },
466
+ );
467
+ promoted.push(item.capability);
389
468
  }
390
469
  recordPromotedSpecs(vaultBase, promoted);
391
470
  ensureSpecsReadme(vaultBase); // self-heal the explainer so existing vaults get it on archive
@@ -20,7 +20,10 @@ export function refreshSubagents(vaultBase, input) {
20
20
  if (!sessionRel) return false;
21
21
  const sessionPath = join(vaultBase, sessionRel);
22
22
  if (!existsSync(sessionPath)) return false;
23
- updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
23
+ updateSessionObservability({
24
+ vaultBase, sessionPath, transcriptPath, caller: 'subagent-stop',
25
+ canonicalConversationId: identity.canonicalConversationId,
26
+ });
24
27
  return true;
25
28
  }
26
29
 
@@ -340,7 +340,7 @@ function upsertSection(content, heading, body) {
340
340
  }
341
341
 
342
342
  // Stop-hook entry: scan the session's subagents/workflows, fold into the note. Fail-open.
343
- export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs } = {}) {
343
+ export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs, vaultBase = '' } = {}) {
344
344
  if (!sessionPath || !existsSync(sessionPath)) return false;
345
345
  const collected = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
346
346
  if (!collected) return false;
@@ -377,6 +377,6 @@ export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs
377
377
  };
378
378
  content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
379
379
  return upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
380
- }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
380
+ }, { ...(lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {}), vaultBase });
381
381
  return outcome.written;
382
382
  }
@@ -52,7 +52,9 @@ export function logTask(vaultBase, input) {
52
52
 
53
53
  const heading = getLocale(vaultBase).id === 'en' ? 'Plan progress' : 'Progresso do plano';
54
54
  const line = `- [x] ${formatHourMinute(new Date()).replace('-', ':')} ${text}`;
55
- return mutateSessionNote(sessionPath, (content) => appendProgress(content, line, heading)).written;
55
+ return mutateSessionNote(sessionPath, (content) => appendProgress(content, line, heading), {
56
+ vaultBase,
57
+ }).written;
56
58
  }
57
59
 
58
60
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -986,7 +986,7 @@ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcr
986
986
  result = collectSessionUsage({ sessionContent, transcriptPath });
987
987
  if (!result) return null; // sem usage OU conteúdo corrompido: não grava
988
988
  return upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
989
- }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
989
+ }, { ...(lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {}), vaultBase });
990
990
  return outcome.written || outcome.reason === 'unchanged' ? result : null;
991
991
  }
992
992
 
@@ -13,7 +13,8 @@ import {
13
13
  import { getLocale } from './locale.mjs';
14
14
  import { parseSharedMemory, validateMemoryEvent } from './memory-schema.mjs';
15
15
  import { detectMemoryMode, LEGACY_MEMORY_WARNING } from './memory-mode.mjs';
16
- import { reduceMemoryEvents } from './memory-store.mjs';
16
+ import { deriveMemoryProjection } from './memory-store.mjs';
17
+ import { assertVaultPathSafe, assertVaultPathsSafe } from './vault-path-safety.mjs';
17
18
  import { validateMemoryBundle } from '../src/validate-memory.mjs';
18
19
 
19
20
  const DEFAULT_PENDING_PATTERNS = [
@@ -100,10 +101,21 @@ function linkedNotesFromSession(content) {
100
101
  const MEMORY_STATUS_COMMAND = 'wendkeep memory status --gate --vault <vault>';
101
102
  const MEMORY_REPAIR_COMMAND = 'wendkeep memory repair --vault <vault>';
102
103
 
103
- function readJsonLines(path, label) {
104
- if (!existsSync(path)) return { items: [], errors: [] };
104
+ function readJsonLines(vaultBase, path, label) {
105
+ let checked;
106
+ try {
107
+ checked = assertVaultPathSafe(vaultBase, path, { expectedType: 'file', label });
108
+ } catch (error) {
109
+ return { items: [], errors: [`${label} inseguro: ${error?.message || error}`] };
110
+ }
111
+ if (!checked.exists) return { items: [], errors: [] };
105
112
  let raw;
106
- try { raw = readFileSync(path, 'utf8').replace(/\r\n/g, '\n'); }
113
+ try {
114
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
115
+ allowMissing: false, expectedType: 'file', label,
116
+ });
117
+ raw = readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n');
118
+ }
107
119
  catch (error) { return { items: [], errors: [`${label} ilegível: ${error?.message || error}`] }; }
108
120
  const lines = raw.endsWith('\n') ? raw.split('\n').slice(0, -1) : raw.split('\n');
109
121
  const items = [];
@@ -121,45 +133,260 @@ function readJsonLines(path, label) {
121
133
 
122
134
  function inspectOutbox(vaultBase, projectId) {
123
135
  const dir = join(vaultBase, '.brain', 'memory-outbox');
124
- if (!existsSync(dir)) return { count: 0, errors: [] };
125
- const files = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
136
+ let checked;
137
+ try {
138
+ checked = assertVaultPathSafe(vaultBase, dir, {
139
+ expectedType: 'directory', label: 'outbox de memória',
140
+ });
141
+ } catch (error) {
142
+ return {
143
+ count: 0,
144
+ errors: [`outbox insegura: ${error?.message || error}`],
145
+ eventIds: new Set(),
146
+ eventsById: new Map(),
147
+ };
148
+ }
149
+ if (!checked.exists) return {
150
+ count: 0, errors: [], eventIds: new Set(), eventsById: new Map(),
151
+ };
152
+ try {
153
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
154
+ allowMissing: false, expectedType: 'directory', label: 'outbox de memória',
155
+ });
156
+ } catch (error) {
157
+ return {
158
+ count: 0,
159
+ errors: [`outbox insegura: ${error?.message || error}`],
160
+ eventIds: new Set(),
161
+ eventsById: new Map(),
162
+ };
163
+ }
164
+ const files = readdirSync(checked.target).filter((name) => name.endsWith('.json')).sort();
126
165
  const errors = [];
166
+ const eventIds = new Set();
167
+ const eventsById = new Map();
127
168
  for (const name of files) {
128
- const path = join(dir, name);
169
+ const path = join(checked.target, name);
129
170
  try {
130
- const event = JSON.parse(readFileSync(path, 'utf8'));
171
+ const file = assertVaultPathSafe(vaultBase, path, {
172
+ allowMissing: false, expectedType: 'file', label: `evento ${name} da outbox`,
173
+ });
174
+ const event = JSON.parse(readFileSync(file.target, 'utf8'));
131
175
  const validation = validateMemoryEvent(event, projectId ? { projectId } : {});
132
176
  if (!validation.ok) errors.push(`${name}: ${validation.errors.join(' ')}`);
177
+ else {
178
+ eventIds.add(event.event_id);
179
+ eventsById.set(event.event_id, event);
180
+ }
133
181
  } catch (error) {
134
182
  errors.push(`${name}: JSON inválido: ${error.message}`);
135
183
  }
136
184
  }
137
- return { count: files.length, errors };
185
+ return {
186
+ count: files.length, errors, eventIds, eventsById,
187
+ };
188
+ }
189
+
190
+ function memoryMetrics() {
191
+ return {
192
+ schemaVersion: null,
193
+ revision: null,
194
+ eventCursor: null,
195
+ stateHash: null,
196
+ ledgerEvents: 0,
197
+ pendingOutbox: 0,
198
+ candidates: 0,
199
+ activeConflicts: 0,
200
+ };
201
+ }
202
+
203
+ function blockedMemoryBoundary(error) {
204
+ return {
205
+ ok: false,
206
+ status: 'blocked',
207
+ failures: [`Boundary física da memória insegura: ${error?.message || error}`],
208
+ warnings: [],
209
+ metrics: memoryMetrics(),
210
+ };
211
+ }
212
+
213
+ function preflightMemoryBundle(vaultBase) {
214
+ const brain = join(vaultBase, '.brain');
215
+ assertVaultPathSafe(vaultBase, brain, {
216
+ expectedType: 'directory', label: 'raiz .brain da memória',
217
+ });
218
+ assertVaultPathsSafe(vaultBase, [
219
+ 'PROJECT.json', 'CORE.md', 'MEMORY_EVENTS.jsonl', 'SHARED_MEMORY.md',
220
+ 'MEMORY_CANDIDATES.jsonl',
221
+ ].map((name) => ({
222
+ path: join(brain, name), expectedType: 'file', label: `${name} ilegível ou inseguro`,
223
+ })));
224
+ const outbox = assertVaultPathSafe(vaultBase, join(brain, 'memory-outbox'), {
225
+ expectedType: 'directory', label: 'outbox de memória',
226
+ });
227
+ if (!outbox.exists) return;
228
+ const entries = readdirSync(outbox.target);
229
+ for (const name of entries) {
230
+ assertVaultPathSafe(vaultBase, join(outbox.target, name), {
231
+ allowMissing: false, label: `entrada ${name} da outbox de memória`,
232
+ });
233
+ }
234
+ }
235
+
236
+ function checkpointMatchesLedgerPrefix(checkpoint, eventIds, ledgerEvents, vaultBase) {
237
+ if (!checkpoint || typeof checkpoint !== 'object') return false;
238
+ if (!Number.isInteger(checkpoint.revision) || checkpoint.revision < 0) return false;
239
+ if (typeof checkpoint.event_cursor !== 'string' || !checkpoint.event_cursor) return false;
240
+ if (typeof checkpoint.state_hash !== 'string' || !checkpoint.state_hash) return false;
241
+
242
+ const cursorIndex = ledgerEvents.findIndex((event) => event?.event_id === checkpoint.event_cursor);
243
+ if (cursorIndex < 0) return false;
244
+ const prefix = ledgerEvents.slice(0, cursorIndex + 1);
245
+ const prefixIds = new Set(prefix.map((event) => event.event_id));
246
+ if (eventIds.some((eventId) => !prefixIds.has(eventId))) return false;
247
+
248
+ try {
249
+ const replay = deriveMemoryProjection(vaultBase, prefix);
250
+ const causalMatches = checkpoint.causal_event_cursor === undefined
251
+ || checkpoint.causal_event_cursor === replay.eventCursor;
252
+ return checkpoint.revision === replay.checkpoint.revision
253
+ && checkpoint.event_cursor === replay.checkpoint.event_cursor
254
+ && checkpoint.state_hash === replay.checkpoint.state_hash
255
+ && causalMatches;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function checkMemoryAttempts(registry, {
262
+ vaultBase, ledgerEvents = [], outboxEventIds = new Set(), outboxEventsById = new Map(),
263
+ } = {}) {
264
+ const failures = [];
265
+ const warnings = [];
266
+ const ledgerEventIds = new Set(ledgerEvents.map((event) => event?.event_id).filter(Boolean));
267
+ const ledgerById = new Map(ledgerEvents
268
+ .filter((event) => event?.event_id)
269
+ .map((event) => [event.event_id, event]));
270
+ const attempts = Object.entries(registry?.sessions || {})
271
+ .map(([sessionId, entry]) => [sessionId, entry?.last_memory_attempt])
272
+ .filter(([, attempt]) => attempt && typeof attempt === 'object' && attempt.memory_mode === 'v2');
273
+
274
+ for (const [sessionId, attempt] of attempts) {
275
+ const state = String(attempt.state || '');
276
+ const disposition = String(attempt.disposition || '');
277
+ const eventIds = Array.isArray(attempt.event_ids)
278
+ ? [...new Set(attempt.event_ids.filter((eventId) => typeof eventId === 'string' && eventId))]
279
+ : [];
280
+
281
+ if (state === 'skipped' && disposition === 'ambiguous') {
282
+ failures.push(`Lifecycle de memória v2 ambíguo: Stop pulou a publicação sem identidade causal suficiente. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
283
+ continue;
284
+ }
285
+
286
+ if (state === 'skipped' && ['stale_turn', 'superseded'].includes(disposition)) {
287
+ if (eventIds.length) {
288
+ failures.push(`Stop stale/superseded emitiu event_ids apesar da rejeição causal. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
289
+ } else {
290
+ warnings.push('Stop stale/superseded foi descartado sem publicar memória.');
291
+ }
292
+ continue;
293
+ }
294
+
295
+ if (disposition !== 'applied') {
296
+ if (state === 'skipped') warnings.push('Attempt de memória v2 foi descartado sem publicação.');
297
+ else failures.push(`Attempt de memória v2 possui disposition não reconhecida para o estado informado. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
298
+ continue;
299
+ }
300
+
301
+ if (!eventIds.length) {
302
+ failures.push(`Attempt v2 aplicado não declarou event_ids; publicação perdida. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
303
+ continue;
304
+ }
305
+
306
+ const identity = {
307
+ canonical_session_id: sessionId,
308
+ activation_id: attempt.activation_id,
309
+ activation_epoch: attempt.activation_epoch,
310
+ source_turn_id: attempt.turn_id,
311
+ turn_sequence: attempt.turn_sequence,
312
+ };
313
+ const invalidAttemptFields = [];
314
+ if (attempt.canonical_session_id !== sessionId) invalidAttemptFields.push('canonical_session_id');
315
+ if (typeof attempt.activation_id !== 'string' || !attempt.activation_id) invalidAttemptFields.push('activation_id');
316
+ if (!Number.isInteger(attempt.activation_epoch) || attempt.activation_epoch < 0) invalidAttemptFields.push('activation_epoch');
317
+ if (typeof attempt.turn_id !== 'string' || !attempt.turn_id) invalidAttemptFields.push('turn_id');
318
+ if (!Number.isInteger(attempt.turn_sequence) || attempt.turn_sequence < 0) invalidAttemptFields.push('turn_sequence');
319
+ if (invalidAttemptFields.length) {
320
+ failures.push(`Attempt v2 da sessão ${sessionId} possui identidade causal inválida (${invalidAttemptFields.join(', ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
321
+ continue;
322
+ }
323
+ const causalMismatches = [];
324
+ for (const eventId of eventIds) {
325
+ const event = ledgerById.get(eventId) || outboxEventsById.get(eventId);
326
+ if (!event) continue;
327
+ const fields = Object.entries(identity)
328
+ .filter(([field, expected]) => event[field] !== expected)
329
+ .map(([field]) => field);
330
+ if (fields.length) causalMismatches.push(`${eventId}: ${fields.join(', ')}`);
331
+ }
332
+ if (causalMismatches.length) {
333
+ failures.push(`Attempt v2 da sessão ${sessionId} referencia evento(s) com identidade causal divergente (${causalMismatches.join('; ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
334
+ continue;
335
+ }
336
+
337
+ if (state === 'enqueued' || state === 'degraded') {
338
+ const missing = eventIds.filter((eventId) => !ledgerEventIds.has(eventId) && !outboxEventIds.has(eventId));
339
+ if (missing.length) {
340
+ failures.push(`Attempt v2 perdeu ${missing.length} evento(s): ausentes do ledger e da outbox. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
341
+ } else {
342
+ warnings.push(`Attempt de memória v2 ${state} permanece recuperável: ${eventIds.length} evento(s) durável(is) no ledger e/ou outbox.`);
343
+ }
344
+ continue;
345
+ }
346
+
347
+ if (state === 'projected') {
348
+ const outsideLedger = eventIds.filter((eventId) => !ledgerEventIds.has(eventId));
349
+ if (outsideLedger.length) {
350
+ failures.push(`Attempt projetado perdeu ${outsideLedger.length} evento(s) no ledger. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
351
+ } else if (!checkpointMatchesLedgerPrefix(attempt.checkpoint, eventIds, ledgerEvents, vaultBase)) {
352
+ failures.push(`Checkpoint do attempt projetado diverge do prefixo rederivado do ledger. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
353
+ }
354
+ continue;
355
+ }
356
+
357
+ failures.push(`Attempt de memória v2 possui state inválido. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
358
+ }
359
+
360
+ return { failures, warnings };
138
361
  }
139
362
 
140
363
  /**
141
364
  * Read-only consistency check for the local memory-v2 bundle. It intentionally
142
365
  * does not acquire MEMORY.lock or invoke the projector/repair paths.
143
366
  */
144
- export function checkMemoryBundle(vaultBase) {
367
+ export function checkMemoryBundle(vaultBase, { registry } = {}) {
368
+ if (!existsSync(vaultBase)) {
369
+ return {
370
+ ok: false,
371
+ status: 'blocked',
372
+ failures: [`Vault not found: ${vaultBase}`],
373
+ warnings: [],
374
+ metrics: memoryMetrics(),
375
+ };
376
+ }
377
+ try { preflightMemoryBundle(vaultBase); }
378
+ catch (error) { return blockedMemoryBoundary(error); }
145
379
  const brain = join(vaultBase, '.brain');
146
- const mode = detectMemoryMode(vaultBase);
380
+ let mode;
381
+ try { mode = detectMemoryMode(vaultBase); }
382
+ catch (error) { return blockedMemoryBoundary(error); }
147
383
  if (mode.mode === 'legacy') {
148
384
  return {
149
385
  ok: true,
150
386
  status: 'legacy',
151
387
  failures: [],
152
388
  warnings: [LEGACY_MEMORY_WARNING],
153
- metrics: {
154
- schemaVersion: null,
155
- revision: null,
156
- eventCursor: null,
157
- stateHash: null,
158
- ledgerEvents: 0,
159
- pendingOutbox: 0,
160
- candidates: 0,
161
- activeConflicts: 0,
162
- },
389
+ metrics: memoryMetrics(),
163
390
  };
164
391
  }
165
392
  const bundle = validateMemoryBundle(vaultBase);
@@ -170,7 +397,9 @@ export function checkMemoryBundle(vaultBase) {
170
397
  : { metadata: {} };
171
398
  const metadata = parsedShared.metadata || {};
172
399
  const outbox = inspectOutbox(vaultBase, bundle.project?.projectId);
173
- const candidates = readJsonLines(join(brain, 'MEMORY_CANDIDATES.jsonl'), 'MEMORY_CANDIDATES.jsonl');
400
+ const candidates = readJsonLines(
401
+ vaultBase, join(brain, 'MEMORY_CANDIDATES.jsonl'), 'MEMORY_CANDIDATES.jsonl',
402
+ );
174
403
 
175
404
  const ledgerCorrupt = (bundle.ledger?.errors || []).length > 0;
176
405
  if (ledgerCorrupt) {
@@ -192,7 +421,7 @@ export function checkMemoryBundle(vaultBase) {
192
421
 
193
422
  let replay = null;
194
423
  if (bundle.ledger?.ok) {
195
- try { replay = reduceMemoryEvents(bundle.ledger.events); }
424
+ try { replay = deriveMemoryProjection(vaultBase, bundle.ledger.events); }
196
425
  catch (error) {
197
426
  failures.push(`Ledger não pode ser reduzido: ${error.message}. Execute com segurança: ${MEMORY_REPAIR_COMMAND}.`);
198
427
  }
@@ -200,13 +429,27 @@ export function checkMemoryBundle(vaultBase) {
200
429
  if (replay && bundle.shared?.ok) {
201
430
  const divergences = [];
202
431
  if (metadata.revision !== replay.revision) divergences.push(`revision ${metadata.revision} != ${replay.revision}`);
203
- if (metadata.event_cursor !== replay.eventCursor) divergences.push(`event_cursor ${metadata.event_cursor} != ${replay.eventCursor}`);
432
+ if (metadata.event_cursor !== replay.ledgerCursor) divergences.push(`event_cursor ${metadata.event_cursor} != ${replay.ledgerCursor}`);
204
433
  if (metadata.state_hash !== replay.stateHash) divergences.push(`state_hash ${metadata.state_hash} != ${replay.stateHash}`);
205
434
  if (divergences.length) {
206
435
  failures.push(`Projeção SHARED stale/lag (${divergences.join('; ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
207
436
  }
208
437
  }
209
438
 
439
+ let effectiveRegistry = registry;
440
+ if (!effectiveRegistry) {
441
+ try { effectiveRegistry = readSessionRegistry(vaultBase); }
442
+ catch (error) { failures.push(`SESSION_REGISTRY.json inseguro ou ilegível: ${error?.message || error}.`); }
443
+ }
444
+ const lifecycle = checkMemoryAttempts(effectiveRegistry || { version: 2, sessions: {} }, {
445
+ vaultBase,
446
+ ledgerEvents: bundle.ledger?.events || [],
447
+ outboxEventIds: outbox.eventIds,
448
+ outboxEventsById: outbox.eventsById,
449
+ });
450
+ failures.push(...lifecycle.failures);
451
+ warnings.push(...lifecycle.warnings);
452
+
210
453
  const unresolved = candidates.items.filter((item) => !['resolved', 'rejected', 'superseded'].includes(item?.status));
211
454
  const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
212
455
  const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
@@ -332,7 +575,7 @@ export function runVaultHealth({ vaultBase, session = '' }) {
332
575
  ];
333
576
  let memory = { status: 'legacy', metrics: {} };
334
577
  if (memoryMarkers.some((path) => existsSync(path))) {
335
- memory = checkMemoryBundle(vaultBase);
578
+ memory = checkMemoryBundle(vaultBase, { registry });
336
579
  failures.push(...memory.failures.map((item) => `Memória: ${item}`));
337
580
  warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
338
581
  } else {