wendkeep 0.66.0 → 0.66.3

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.
@@ -1,13 +1,13 @@
1
- // .agent/hooks/brain-reindex.mjs
2
- // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
- // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
-
8
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
- const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
- const rows = buildBrainIndex(vaultBase);
11
- const digest = buildBrainDigest(vaultBase, rows);
12
- process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
- }
1
+ // .agent/hooks/brain-reindex.mjs
2
+ // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
+ // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
+
8
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
+ const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
+ const rows = buildBrainIndex(vaultBase);
11
+ const digest = buildBrainDigest(vaultBase, rows);
12
+ process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.66.0",
3
+ "version": "0.66.3",
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": [
@@ -101,7 +101,7 @@ Usage:
101
101
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
102
102
  wendkeep memory <sub> Shared memory v2: status | migrate [--apply] | repair |
103
103
  reconcile <session> --by-session <session> --reason <text> [--apply] |
104
- promote <candidate> | reject <candidate>. --vault P.
104
+ promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
105
105
  Reconcile is dry-run by default; the original attempt remains audited.
106
106
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
107
107
  protocol (cap 25, 3 sections, no secrets/PII).
@@ -108,6 +108,31 @@ export function validateMemoryEvent(event, { projectId } = {}) {
108
108
  errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
109
109
  }
110
110
 
111
+ if (event.candidate_decision !== undefined) {
112
+ const decision = event.candidate_decision;
113
+ if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
114
+ errors.push('candidate_decision deve ser objeto.');
115
+ } else {
116
+ if (typeof decision.candidate_id !== 'string' || !decision.candidate_id) {
117
+ errors.push('candidate_decision.candidate_id deve ser string não vazia.');
118
+ }
119
+ if (!['promote', 'reject'].includes(decision.action)) {
120
+ errors.push('candidate_decision.action deve ser promote ou reject.');
121
+ }
122
+ if (!Array.isArray(decision.event_ids)
123
+ || decision.event_ids.some((item) => typeof item !== 'string' || !item)) {
124
+ errors.push('candidate_decision.event_ids deve ser array de strings não vazias.');
125
+ }
126
+ if (decision.action === 'promote' && decision.selected_event_id !== undefined
127
+ && (typeof decision.selected_event_id !== 'string' || !decision.selected_event_id)) {
128
+ errors.push('candidate_decision.selected_event_id deve ser string não vazia.');
129
+ }
130
+ if (decision.action === 'reject' && decision.selected_event_id !== undefined) {
131
+ errors.push('candidate_decision.selected_event_id não é permitido em reject.');
132
+ }
133
+ }
134
+ }
135
+
111
136
  for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
112
137
  return { ok: errors.length === 0, errors, warnings };
113
138
  }
@@ -344,6 +344,22 @@ function sameCausalActivation(left, right) {
344
344
  && left.activation_id === right?.activation_id;
345
345
  }
346
346
 
347
+ function hasCompleteCausalIdentity(event) {
348
+ return Boolean(event?.canonical_session_id)
349
+ && Boolean(event?.activation_id)
350
+ && Boolean(event?.source_turn_id)
351
+ && Number.isInteger(event?.activation_epoch)
352
+ && Number.isInteger(event?.turn_sequence);
353
+ }
354
+
355
+ function sameCompleteCausalLineage(left, right) {
356
+ return hasCompleteCausalIdentity(left)
357
+ && hasCompleteCausalIdentity(right)
358
+ && left.canonical_session_id === right.canonical_session_id
359
+ && left.activation_id === right.activation_id
360
+ && left.activation_epoch === right.activation_epoch;
361
+ }
362
+
347
363
  function comparable(left, right) {
348
364
  if (sameCausalActivation(left, right)) return true;
349
365
  const leftSupersedes = left.supersedes_event_id || left.supersedes;
@@ -447,7 +463,9 @@ function isCausallyOlder(event, current) {
447
463
  * Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
448
464
  * never turns one concurrent writer into an accidental winner.
449
465
  */
450
- export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map() } = {}) {
466
+ export function reduceMemoryEvents(inputEvents = [], {
467
+ coreInvariants = new Map(), resolveDeferredAsserts = true,
468
+ } = {}) {
451
469
  const protectedValues = coreInvariants instanceof Map
452
470
  ? coreInvariants
453
471
  : new Map(Object.entries(coreInvariants || {}));
@@ -461,6 +479,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
461
479
  if (!existing) unique.set(event.event_id, event);
462
480
  }
463
481
  const events = [...unique.values()].sort(eventOrder);
482
+ const candidateDecisions = new Map();
483
+ for (const item of events) {
484
+ const decision = item.candidate_decision;
485
+ if (!decision) continue;
486
+ const existing = candidateDecisions.get(decision.candidate_id);
487
+ if (existing && canonicalMemoryJson(existing.decision) !== canonicalMemoryJson(decision)) {
488
+ throw new MemoryEventCollision(
489
+ decision.candidate_id,
490
+ `Ledger contains incompatible decisions for candidate ${decision.candidate_id}`,
491
+ );
492
+ }
493
+ if (!existing) candidateDecisions.set(decision.candidate_id, { decision, event: item });
494
+ }
464
495
 
465
496
  const peerGroups = new Map();
466
497
  for (const item of events) {
@@ -482,12 +513,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
482
513
  const records = new Map();
483
514
  const tombstones = new Map();
484
515
  const candidates = [];
516
+ const pendingAssertConflicts = [];
517
+ const resolvedCandidateIds = new Set();
485
518
  const emittedGroups = new Set();
486
519
  const appliedEventIds = [];
487
520
  const superseded = [];
488
521
  let revision = 0;
489
522
 
490
523
  for (const item of events) {
524
+ if (item.candidate_decision && item.candidate_decision.action === 'reject') {
525
+ appliedEventIds.push(item.event_id);
526
+ continue;
527
+ }
528
+
491
529
  if (protectedValues.has(item.memory_key)) {
492
530
  const coreValue = protectedValues.get(item.memory_key);
493
531
  const agreesWithCore = item.operation === 'assert'
@@ -525,7 +563,9 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
525
563
  appliedEventIds.push(item.event_id);
526
564
  continue;
527
565
  }
528
- candidates.push(conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]));
566
+ const candidate = conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]);
567
+ candidates.push(candidate);
568
+ pendingAssertConflicts.push({ candidate, event: item });
529
569
  continue;
530
570
  }
531
571
  if (!current) {
@@ -598,13 +638,56 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
598
638
  appliedEventIds.push(item.event_id);
599
639
  }
600
640
 
641
+ if (resolveDeferredAsserts) {
642
+ // A physically late assert can sort before a corrective promotion because effective time
643
+ // precedes CLI decision time. Revisit only scalar assert conflicts left without an explicit
644
+ // decision, against the final complete causal source; the ledger and global ordering stay put.
645
+ const deferredAsserts = pendingAssertConflicts
646
+ .filter((pending) => !candidateDecisions.has(pending.candidate.candidate_id))
647
+ .sort((left, right) => String(left.event.memory_key).localeCompare(String(right.event.memory_key))
648
+ || String(left.event.canonical_session_id || '').localeCompare(String(right.event.canonical_session_id || ''))
649
+ || String(left.event.activation_id || '').localeCompare(String(right.event.activation_id || ''))
650
+ || Number(left.event.activation_epoch ?? -1) - Number(right.event.activation_epoch ?? -1)
651
+ || Number(left.event.turn_sequence ?? -1) - Number(right.event.turn_sequence ?? -1)
652
+ || eventOrder(left.event, right.event));
653
+ let advanced = true;
654
+ while (advanced) {
655
+ advanced = false;
656
+ for (const pending of deferredAsserts) {
657
+ if (resolvedCandidateIds.has(pending.candidate.candidate_id)) continue;
658
+ const current = records.get(pending.event.memory_key);
659
+ const currentSource = current?.source;
660
+ if (!sameCompleteCausalLineage(pending.event, currentSource)) continue;
661
+ if (pending.event.turn_sequence > currentSource.turn_sequence) {
662
+ records.set(pending.event.memory_key, {
663
+ value: pending.event.value,
664
+ revision: current.revision + 1,
665
+ source: pending.event,
666
+ });
667
+ tombstones.delete(pending.event.memory_key);
668
+ superseded.push({ event_id: currentSource.event_id, by_event_id: pending.event.event_id });
669
+ revision += 1;
670
+ appliedEventIds.push(pending.event.event_id);
671
+ resolvedCandidateIds.add(pending.candidate.candidate_id);
672
+ advanced = true;
673
+ } else if (pending.event.turn_sequence < currentSource.turn_sequence) {
674
+ superseded.push({ event_id: pending.event.event_id, by_event_id: currentSource.event_id });
675
+ resolvedCandidateIds.add(pending.candidate.candidate_id);
676
+ }
677
+ }
678
+ }
679
+ }
680
+
601
681
  const stateEntries = [...records].map(([key, record]) => [key, record.value]);
602
682
  const recordEntries = [...records].map(([key, record]) => [key, record]);
603
683
  const tombstoneEntries = [...tombstones];
604
684
  const state = sortedObject(stateEntries);
605
685
  const recordObject = sortedObject(recordEntries);
606
686
  const tombstoneObject = sortedObject(tombstoneEntries);
607
- candidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
687
+ const unresolvedCandidates = candidates
688
+ .filter((item) => !candidateDecisions.has(item.candidate_id)
689
+ && !resolvedCandidateIds.has(item.candidate_id));
690
+ unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
608
691
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
609
692
  const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
610
693
  ...record.source,
@@ -618,7 +701,7 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
618
701
  return {
619
702
  state,
620
703
  records: recordObject,
621
- candidates,
704
+ candidates: unresolvedCandidates,
622
705
  tombstones: tombstoneObject,
623
706
  superseded,
624
707
  appliedEventIds,
@@ -635,8 +718,12 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
635
718
  * `eventCursor` is the reducer's deterministic causal cursor; `ledgerCursor` is the
636
719
  * physical prefix boundary used by durable checkpoints.
637
720
  */
638
- export function deriveMemoryProjection(vaultBase, inputEvents = []) {
639
- const reduced = reduceMemoryEvents(inputEvents, { coreInvariants: readCoreInvariants(vaultBase) });
721
+ export function deriveMemoryProjection(vaultBase, inputEvents = [], {
722
+ resolveDeferredAsserts = true,
723
+ } = {}) {
724
+ const reduced = reduceMemoryEvents(inputEvents, {
725
+ coreInvariants: readCoreInvariants(vaultBase), resolveDeferredAsserts,
726
+ });
640
727
  const ledgerCursor = inputEvents.at(-1)?.event_id || 'none';
641
728
  const checkpoint = {
642
729
  revision: reduced.revision,
package/src/memory.mjs CHANGED
@@ -237,37 +237,272 @@ function readCandidates(vault) {
237
237
  .split('\n').filter(Boolean).map((line) => JSON.parse(line));
238
238
  }
239
239
 
240
- export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
240
+ function priorCandidateDecision(vault, candidateId) {
241
+ return readMemoryLedger(vault).events.find(
242
+ (event) => event.candidate_decision?.candidate_id === candidateId,
243
+ ) || null;
244
+ }
245
+
246
+ function candidateEvent(candidate, eventId) {
247
+ const events = Array.isArray(candidate.events) ? candidate.events : [];
248
+ if (candidate.reason === 'conflict' && !eventId) {
249
+ throw new Error('Candidate de conflito exige eventId (--event na CLI).');
250
+ }
251
+ if (!eventId) return events.length === 1 ? events[0] : null;
252
+ const selected = events.find((event) => event.event_id === eventId);
253
+ if (!selected) throw new Error(`event_id ${eventId} não pertence ao candidate ${candidate.candidate_id}.`);
254
+ return selected;
255
+ }
256
+
257
+ function assertCompatibleDecision(prior, { action, eventId }) {
258
+ const decision = prior.candidate_decision;
259
+ const sameSelection = action !== 'promote'
260
+ || (decision.selected_event_id || null) === (eventId || null);
261
+ if (decision.action !== action || !sameSelection) {
262
+ throw new Error(`Candidate ${decision.candidate_id} já possui decisão incompatível (${decision.action}).`);
263
+ }
264
+ }
265
+
266
+ function promotedMemoryValue(value) {
267
+ return typeof value === 'string' ? sanitizeMemoryText(value) : cloneJson(value);
268
+ }
269
+
270
+ function eventSupersedes(event) {
271
+ if (Array.isArray(event?.supersedes)) return event.supersedes;
272
+ return event?.supersedes_event_id ? [event.supersedes_event_id] : [];
273
+ }
274
+
275
+ function hasCompleteCausalIdentity(event) {
276
+ return Boolean(event?.canonical_session_id && event?.activation_id && event?.source_turn_id)
277
+ && Number.isInteger(event.activation_epoch)
278
+ && Number.isInteger(event.turn_sequence);
279
+ }
280
+
281
+ function sameCausalLineage(left, right) {
282
+ return hasCompleteCausalIdentity(left) && hasCompleteCausalIdentity(right)
283
+ && left.canonical_session_id === right.canonical_session_id
284
+ && left.activation_id === right.activation_id
285
+ && left.activation_epoch === right.activation_epoch;
286
+ }
287
+
288
+ function promotedSupersedes(vault, candidate, selected) {
289
+ const memberIds = new Set(Array.isArray(candidate.event_ids) ? candidate.event_ids : []);
290
+ const ledger = readMemoryLedger(vault);
291
+ if (ledger.status !== 'ok') throw new Error('Ledger de memória inválido durante a promoção.');
292
+ const current = deriveMemoryProjection(vault, ledger.events)
293
+ .records?.[candidate.memory_key]?.source;
294
+ if (!current?.event_id || memberIds.has(current.event_id)) return [...memberIds].sort();
295
+
296
+ const currentSelectedId = current.candidate_decision?.selected_event_id;
297
+ const currentSelected = currentSelectedId
298
+ ? ledger.events.find((event) => event.event_id === currentSelectedId)
299
+ : null;
300
+ const selectedLedger = ledger.events.find((event) => event.event_id === selected.event_id);
301
+ const currentAncestors = eventSupersedes(current);
302
+ const decisionMembers = Array.isArray(current.candidate_decision?.event_ids)
303
+ ? current.candidate_decision.event_ids
304
+ : [];
305
+ const canonicalAncestors = [...new Set(currentAncestors)].sort();
306
+ const canonicalDecisionMembers = [...new Set(decisionMembers)].sort();
307
+ const currentObserved = String(current.effective_at || current.observed_at || '');
308
+ const selectedObserved = String(selectedLedger?.effective_at || selectedLedger?.observed_at || '');
309
+ const currentPhysicalIndex = ledger.events.findIndex((event) => event.event_id === current.event_id);
310
+ const selectedPhysicalIndex = ledger.events.findIndex((event) => event.event_id === selected.event_id);
311
+ const bridgeChecks = {
312
+ legacyShape: current.operation === 'replace' && current.authority === 'verified'
313
+ && !current.canonical_session_id && !current.source_turn_id,
314
+ promotion: current.candidate_decision?.action === 'promote',
315
+ selectedPresent: Boolean(currentSelected),
316
+ selectedPersisted: Boolean(selectedLedger),
317
+ exactDecisionMembers: canonicalAncestors.length === currentAncestors.length
318
+ && canonicalDecisionMembers.length === decisionMembers.length
319
+ && canonicalAncestors.length === canonicalDecisionMembers.length
320
+ && canonicalAncestors.every((eventId, index) => eventId === canonicalDecisionMembers[index]),
321
+ selectedSuperseded: currentAncestors.includes(currentSelectedId)
322
+ && decisionMembers.includes(currentSelectedId),
323
+ candidateAncestor: currentAncestors.some((eventId) => memberIds.has(eventId)),
324
+ sameLineage: sameCausalLineage(currentSelected, selectedLedger),
325
+ laterTurn: selectedLedger?.turn_sequence > currentSelected?.turn_sequence,
326
+ appendedAfterCurrent: selectedPhysicalIndex > currentPhysicalIndex && currentPhysicalIndex >= 0,
327
+ observedBeforeCurrent: selectedObserved < currentObserved,
328
+ };
329
+ const bridgesObservedOrder = Object.values(bridgeChecks).every(Boolean);
330
+ if (!bridgesObservedOrder) {
331
+ throw new Error(
332
+ `Candidate ${candidate.candidate_id} não corresponde mais à projeção causal atual; `
333
+ + 'recarregue os candidates antes de promover.',
334
+ );
335
+ }
336
+ memberIds.add(current.event_id);
337
+ return [...memberIds].sort();
338
+ }
339
+
340
+ function matchesPromotedAttempt(attempt, selected) {
341
+ if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
342
+ || !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
343
+ if (attempt.activation_id && selected.activation_id
344
+ && attempt.activation_id !== selected.activation_id) return false;
345
+ if (Number.isInteger(attempt.activation_epoch) && Number.isInteger(selected.activation_epoch)
346
+ && attempt.activation_epoch !== selected.activation_epoch) return false;
347
+ if (Number.isInteger(attempt.turn_sequence) && Number.isInteger(selected.turn_sequence)
348
+ && attempt.turn_sequence !== selected.turn_sequence) return false;
349
+ if (attempt.canonical_session_id && selected.canonical_session_id
350
+ && attempt.canonical_session_id !== selected.canonical_session_id) return false;
351
+ return true;
352
+ }
353
+
354
+ function snapshotPromotedAttemptCheckpoints(vault, selected) {
355
+ if (!selected) return new Map();
356
+ const registry = readSessionRegistry(vault);
357
+ return new Map(Object.entries(registry.sessions || {})
358
+ .filter(([, entry]) => matchesPromotedAttempt(entry?.last_memory_attempt, selected))
359
+ .map(([sessionId, entry]) => [sessionId, {
360
+ attempt: attemptFingerprint(entry.last_memory_attempt),
361
+ checkpoint: memoryCheckpointFingerprint(entry),
362
+ }]));
363
+ }
364
+
365
+ function refreshPromotedAttemptCheckpoint(vault, {
366
+ candidateId, decisionEventId, selected, checkpoint, decidedAt, expectedAttempts,
367
+ }) {
368
+ if (!selected || !checkpoint || !expectedAttempts?.size) return 0;
369
+ return mutateSessionRegistry(vault, (registry) => {
370
+ let refreshed = 0;
371
+ for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
372
+ const expected = expectedAttempts.get(sessionId);
373
+ if (!expected) continue;
374
+ const attempt = entry?.last_memory_attempt;
375
+ if (attemptFingerprint(attempt) !== expected.attempt
376
+ || memoryCheckpointFingerprint(entry) !== expected.checkpoint) continue;
377
+
378
+ const alreadyAudited = (entry.memory_candidate_decisions || [])
379
+ .some((audit) => audit.decision_event_id === decisionEventId);
380
+ if (alreadyAudited && sameCheckpoint(attempt.checkpoint, checkpoint)
381
+ && sameCheckpoint(entry.memory_checkpoint, checkpoint)) continue;
382
+ const originalCheckpoint = cloneJson(attempt.checkpoint || entry.memory_checkpoint || null);
383
+ attempt.checkpoint = cloneJson(checkpoint);
384
+ entry.memory_checkpoint = cloneJson(checkpoint);
385
+ entry.memory_status = 'projected';
386
+ if (!alreadyAudited) {
387
+ entry.memory_candidate_decisions = [
388
+ ...(Array.isArray(entry.memory_candidate_decisions) ? entry.memory_candidate_decisions : []),
389
+ {
390
+ v: 1,
391
+ type: 'candidate_checkpoint_refreshed',
392
+ candidate_id: candidateId,
393
+ decision_event_id: decisionEventId,
394
+ selected_event_id: selected.event_id,
395
+ decided_at: decidedAt,
396
+ original_checkpoint: originalCheckpoint,
397
+ checkpoint: cloneJson(checkpoint),
398
+ },
399
+ ];
400
+ }
401
+ refreshed += 1;
402
+ }
403
+ return refreshed;
404
+ });
405
+ }
406
+
407
+ export function decideMemoryCandidate(vault, {
408
+ action, candidateId, value, eventId, beforeCheckpointRefresh,
409
+ } = {}) {
241
410
  if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
242
411
  if (!candidateId) throw new TypeError('candidateId é obrigatório.');
412
+ const preflight = projectMemoryOutbox(vault);
413
+ if (preflight.status === 'busy') return { status: 'busy', candidateId };
414
+ const prior = priorCandidateDecision(vault, candidateId);
415
+ if (prior) {
416
+ assertCompatibleDecision(prior, { action, eventId });
417
+ const selected = action === 'promote' && prior.candidate_decision.selected_event_id
418
+ ? readMemoryLedger(vault).events.find(
419
+ (event) => event.event_id === prior.candidate_decision.selected_event_id,
420
+ )
421
+ : null;
422
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
423
+ beforeCheckpointRefresh?.();
424
+ const checkpointRefreshed = action === 'promote'
425
+ ? refreshPromotedAttemptCheckpoint(vault, {
426
+ candidateId,
427
+ decisionEventId: prior.event_id,
428
+ selected,
429
+ checkpoint: preflight.checkpoint,
430
+ decidedAt: prior.observed_at,
431
+ expectedAttempts,
432
+ })
433
+ : 0;
434
+ return {
435
+ status: action === 'promote' ? 'promoted' : 'rejected',
436
+ candidateId,
437
+ eventId: prior.event_id,
438
+ alreadyApplied: true,
439
+ checkpointRefreshed,
440
+ projection: preflight,
441
+ };
442
+ }
243
443
  const candidates = readCandidates(vault);
244
444
  const candidate = candidates.find((item) => item.candidate_id === candidateId);
245
445
  if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
446
+ if (action === 'promote' && candidate.reason === 'blocked_by_core') {
447
+ throw new Error(`Candidate ${candidateId} está blocked_by_core; edite CORE ou rejeite o candidate.`);
448
+ }
449
+ const selected = action === 'promote' ? candidateEvent(candidate, eventId) : null;
450
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
451
+ const selectedValue = selected?.value ?? value ?? candidate.value ?? candidate.proposed_value;
452
+ if (action === 'promote' && selectedValue === undefined) {
453
+ throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
454
+ }
455
+ const supersedes = action === 'promote' && selected
456
+ ? promotedSupersedes(vault, candidate, selected)
457
+ : [];
246
458
  const now = new Date().toISOString();
459
+ const decision = {
460
+ candidate_id: candidateId,
461
+ action,
462
+ event_ids: Array.isArray(candidate.event_ids) ? [...candidate.event_ids].sort() : [],
463
+ ...(selected ? { selected_event_id: selected.event_id } : {}),
464
+ };
247
465
  const event = {
248
466
  v: 1,
249
- event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
467
+ event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
250
468
  project_id: projectId(vault),
251
- memory_key: action === 'promote' ? candidate.memory_key : `candidate.rejected.${candidateId}`,
252
- operation: 'assert',
253
- value: sanitizeMemoryText(action === 'promote' ? (value ?? candidate.value ?? candidate.values?.[0] ?? '') : 'rejected'),
469
+ memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
470
+ operation: action === 'promote' && selected ? 'replace' : 'assert',
471
+ value: action === 'promote' ? promotedMemoryValue(selectedValue) : 'rejected',
254
472
  authority: 'verified',
255
- activation_id: 'wendkeep-memory-cli',
256
- turn_sequence: 0,
473
+ ...(selected?.canonical_session_id
474
+ ? { canonical_session_id: selected.canonical_session_id }
475
+ : {}),
476
+ activation_id: selected?.activation_id || 'wendkeep-memory-cli',
477
+ ...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
478
+ turn_sequence: selected?.turn_sequence ?? 0,
479
+ ...(selected?.source_turn_id ? { source_turn_id: selected.source_turn_id } : {}),
257
480
  observed_at: now,
258
481
  evidence: [`candidate:${candidateId}`],
482
+ candidate_decision: decision,
483
+ ...(selected ? { supersedes } : {}),
259
484
  };
260
485
  enqueueMemoryEvent(vault, event);
261
486
  const projection = projectMemoryOutbox(vault);
262
487
  if (projection.status === 'busy') return { status: 'busy', candidateId };
263
- const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
264
- const projected = readCandidates(vault);
265
- const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
266
- writeVaultFileAtomic(
267
- vault, brainPath(vault, CANDIDATES), candidateText([...merged.values()]), 'utf8',
268
- { label: 'candidates após decisão humana' },
269
- );
270
- return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
488
+ beforeCheckpointRefresh?.();
489
+ const checkpointRefreshed = action === 'promote'
490
+ ? refreshPromotedAttemptCheckpoint(vault, {
491
+ candidateId,
492
+ decisionEventId: event.event_id,
493
+ selected,
494
+ checkpoint: projection.checkpoint,
495
+ decidedAt: now,
496
+ expectedAttempts,
497
+ })
498
+ : 0;
499
+ return {
500
+ status: action === 'promote' ? 'promoted' : 'rejected',
501
+ candidateId,
502
+ eventId: event.event_id,
503
+ checkpointRefreshed,
504
+ projection,
505
+ };
271
506
  }
272
507
 
273
508
  function cloneJson(value) {
@@ -778,6 +1013,67 @@ function historicalAssertOnlyCheckpoint(vault, attempt, authority, checkpoint) {
778
1013
  return currentPrefix.checkpoint;
779
1014
  }
780
1015
 
1016
+ function deferredAssertReplayCheckpointMigration(
1017
+ sessionId, entry, authority, legacyReplay, fullReplay,
1018
+ ) {
1019
+ const attempt = entry?.last_memory_attempt;
1020
+ const checkpoint = attempt?.checkpoint;
1021
+ if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
1022
+ || attempt?.disposition !== 'applied' || !checkpointShape(checkpoint)) return null;
1023
+ if (!Object.prototype.hasOwnProperty.call(entry, 'memory_checkpoint')
1024
+ || !sameCheckpoint(entry.memory_checkpoint, checkpoint)
1025
+ || !sameCheckpoint(checkpoint, legacyReplay.checkpoint)
1026
+ || sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
1027
+
1028
+ const requiredEventIds = Array.isArray(attempt.event_ids) ? [...attempt.event_ids] : [];
1029
+ if (!requiredEventIds.length || new Set(requiredEventIds).size !== requiredEventIds.length) return null;
1030
+ const current = fullReplay.records?.['handoff.latest']?.source;
1031
+ if (!current?.event_id || !requiredEventIds.includes(current.event_id)) return null;
1032
+ const relevantLegacyCandidate = legacyReplay.candidates.some(
1033
+ (candidate) => candidate.memory_key === 'handoff.latest'
1034
+ && candidate.event_ids.includes(current.event_id),
1035
+ );
1036
+ const relevantCandidateStillReal = fullReplay.candidates.some(
1037
+ (candidate) => candidate.memory_key === 'handoff.latest',
1038
+ );
1039
+ if (!relevantLegacyCandidate || relevantCandidateStillReal) return null;
1040
+
1041
+ const currentIdentity = {
1042
+ canonical_session_id: current.canonical_session_id,
1043
+ activation_id: current.activation_id,
1044
+ activation_epoch: current.activation_epoch,
1045
+ source_turn_id: current.source_turn_id,
1046
+ turn_sequence: current.turn_sequence,
1047
+ };
1048
+ if (currentIdentity.canonical_session_id !== sessionId
1049
+ || attempt.canonical_session_id !== currentIdentity.canonical_session_id
1050
+ || attempt.activation_id !== currentIdentity.activation_id
1051
+ || attempt.activation_epoch !== currentIdentity.activation_epoch
1052
+ || attempt.turn_id !== currentIdentity.source_turn_id
1053
+ || attempt.turn_sequence !== currentIdentity.turn_sequence) return null;
1054
+
1055
+ const newerAttemptEvent = authority.ledgerEvents.some((event) => (
1056
+ event.canonical_session_id === currentIdentity.canonical_session_id
1057
+ && event.activation_id === currentIdentity.activation_id
1058
+ && event.activation_epoch === currentIdentity.activation_epoch
1059
+ && Number.isInteger(event.turn_sequence)
1060
+ && event.turn_sequence > currentIdentity.turn_sequence
1061
+ ));
1062
+ if (newerAttemptEvent) return null;
1063
+
1064
+ const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
1065
+ return {
1066
+ sessionId,
1067
+ expectedFingerprint: attemptFingerprint(attempt),
1068
+ expectedMemoryCheckpointFingerprint: memoryCheckpointFingerprint(entry),
1069
+ originalCheckpoint: cloneJson(checkpoint),
1070
+ checkpoint: cloneJson(fullReplay.checkpoint),
1071
+ proof,
1072
+ migrationType: 'deferred_assert_replay_migrated',
1073
+ eventIds: requiredEventIds,
1074
+ };
1075
+ }
1076
+
781
1077
  function legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay) {
782
1078
  const attempt = entry?.last_memory_attempt;
783
1079
  const checkpoint = attempt?.checkpoint;
@@ -807,6 +1103,8 @@ function legacyCheckpointMigration(vault, sessionId, entry, authority, fullRepla
807
1103
  originalCheckpoint: cloneJson(checkpoint),
808
1104
  checkpoint: cloneJson(nextCheckpoint),
809
1105
  proof,
1106
+ migrationType: 'legacy_causal_checkpoint_migrated',
1107
+ eventIds: requiredEventIds,
810
1108
  };
811
1109
  }
812
1110
 
@@ -818,11 +1116,14 @@ export function migrateLegacyMemoryCheckpoints(vault, {
818
1116
  const authority = readMemoryAuthority(vault);
819
1117
  assertAuthorityMatches(expectedAuthority, authority);
820
1118
  const inspected = readSessionRegistry(vault);
1119
+ const legacyReplay = deriveMemoryProjection(vault, authority.ledgerEvents, {
1120
+ resolveDeferredAsserts: false,
1121
+ });
821
1122
  const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
822
1123
  const plans = Object.entries(inspected.sessions || {})
823
- .map(([sessionId, entry]) => legacyCheckpointMigration(
824
- vault, sessionId, entry, authority, fullReplay,
825
- ))
1124
+ .map(([sessionId, entry]) => deferredAssertReplayCheckpointMigration(
1125
+ sessionId, entry, authority, legacyReplay, fullReplay,
1126
+ ) || legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay))
826
1127
  .filter(Boolean);
827
1128
  assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
828
1129
  if (!plans.length) return {
@@ -861,14 +1162,20 @@ export function migrateLegacyMemoryCheckpoints(vault, {
861
1162
 
862
1163
  for (const plan of plans) {
863
1164
  const entry = registry.sessions[plan.sessionId];
864
- const reconciliationId = `memcp-${hash(`${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`).slice(0, 20)}`;
1165
+ const reconciliationSeed = plan.migrationType === 'legacy_causal_checkpoint_migrated'
1166
+ ? `${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`
1167
+ : `${plan.migrationType}\0${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`;
1168
+ const reconciliationId = `memcp-${hash(reconciliationSeed).slice(0, 20)}`;
865
1169
  entry.memory_reconciliations = [
866
1170
  ...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
867
1171
  {
868
1172
  v: 1,
869
1173
  reconciliation_id: reconciliationId,
870
- type: 'legacy_causal_checkpoint_migrated',
1174
+ type: plan.migrationType,
871
1175
  reconciled_at: now,
1176
+ ...(plan.migrationType === 'deferred_assert_replay_migrated'
1177
+ ? { event_ids: [...plan.eventIds] }
1178
+ : {}),
872
1179
  causal_proof: cloneJson(plan.proof),
873
1180
  original_checkpoint: cloneJson(plan.originalCheckpoint),
874
1181
  checkpoint: cloneJson(plan.checkpoint),
@@ -1038,8 +1345,14 @@ export function runMemory(argv) {
1038
1345
  apply: reconcileArgs.apply,
1039
1346
  });
1040
1347
  }
1041
- else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
1042
- else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
1348
+ else if (sub === 'promote' || sub === 'reject') {
1349
+ const eventId = option(argv, '--event');
1350
+ if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
1351
+ result = decideMemoryCandidate(vault, {
1352
+ action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
1353
+ });
1354
+ }
1355
+ else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
1043
1356
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1044
1357
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
1045
1358
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;