wendkeep 0.57.2 → 0.58.1

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.
@@ -338,9 +338,12 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
338
338
 
339
339
  try {
340
340
  const registry = readSessionRegistry(vaultBase);
341
+ const before = JSON.stringify(registry);
341
342
  registry.version = 2;
342
343
  const result = mutator(registry);
343
- writeSessionRegistry(vaultBase, registry);
344
+ if (JSON.stringify(registry) !== before) {
345
+ writeSessionRegistry(vaultBase, registry);
346
+ }
344
347
  return result;
345
348
  } finally {
346
349
  // rmSync recursivo não remove diretório em caminho não-ASCII no Windows — ver
@@ -350,14 +353,204 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
350
353
  }
351
354
 
352
355
  function meaningfulPatch(patch = {}) {
353
- const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug']);
356
+ const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug', 'activation_id']);
354
357
  return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
358
+ if (key === 'advance_turn_sequence' || key === 'turn_sequence') return false;
355
359
  if (value === undefined || value === null) return false;
356
360
  if (value === '' && protectedNonEmpty.has(key)) return false;
357
361
  return true;
358
362
  }));
359
363
  }
360
364
 
365
+ function nonNegativeSequence(value, fallback = null) {
366
+ const parsed = Number(value);
367
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
368
+ }
369
+
370
+ function cloneRegistry(registry = {}) {
371
+ const sessions = Object.fromEntries(Object.entries(registry.sessions || {}).map(([id, entry]) => [
372
+ id,
373
+ {
374
+ ...(entry || {}),
375
+ ...(entry?.activations && typeof entry.activations === 'object'
376
+ ? { activations: Object.fromEntries(Object.entries(entry.activations).map(([activationId, activation]) => [activationId, { ...(activation || {}) }])) }
377
+ : {}),
378
+ },
379
+ ]));
380
+ return { ...registry, version: Math.max(2, registry.version || 1), sessions };
381
+ }
382
+
383
+ // Pure causal helpers. Keeping them independent from filesystem I/O lets Stop perform its
384
+ // compare-and-swap under the same registry lock used by the existing upsert path.
385
+ export function openActivation(registry, session = {}, explicitActivationId = '') {
386
+ const next = cloneRegistry(registry);
387
+ const sessionId = session.session_id || session.canonical_session_id || session.id || '';
388
+ const activationId = explicitActivationId || session.activation_id || '';
389
+ if (!sessionId || !activationId) throw new TypeError('session_id and activation_id are required');
390
+
391
+ const current = next.sessions[sessionId] || {};
392
+ const activations = { ...(current.activations || {}) };
393
+ const requestedSequence = nonNegativeSequence(
394
+ session.turn_sequence ?? session.last_turn_sequence,
395
+ 0,
396
+ );
397
+
398
+ if (current.active_activation_id === activationId && activations[activationId]?.status === 'active') {
399
+ const sequence = Math.max(nonNegativeSequence(current.last_turn_sequence, 0), requestedSequence);
400
+ activations[activationId] = {
401
+ ...activations[activationId],
402
+ last_turn_sequence: sequence,
403
+ };
404
+ next.sessions[sessionId] = {
405
+ ...current,
406
+ activation_id: activationId,
407
+ active_activation_id: activationId,
408
+ last_turn_sequence: sequence,
409
+ activations,
410
+ };
411
+ return next;
412
+ }
413
+
414
+ const previousId = current.active_activation_id || '';
415
+ if (previousId && activations[previousId]?.status === 'active') {
416
+ activations[previousId] = {
417
+ ...activations[previousId],
418
+ status: 'superseded',
419
+ superseded_by: activationId,
420
+ superseded_at: session.started_at || session.activation_started_at || '',
421
+ };
422
+ }
423
+
424
+ const epoch = Math.max(
425
+ nonNegativeSequence(current.activation_epoch, 0),
426
+ ...Object.values(activations).map((activation) => nonNegativeSequence(activation?.epoch, 0)),
427
+ ) + 1;
428
+ activations[activationId] = {
429
+ activation_id: activationId,
430
+ epoch,
431
+ status: 'active',
432
+ started_at: session.activation_started_at || session.started_at || '',
433
+ last_turn_sequence: requestedSequence,
434
+ ...(session.transcript_id ? { transcript_id: session.transcript_id } : {}),
435
+ ...(session.transcript_path ? { transcript_path: session.transcript_path } : {}),
436
+ ...(session.provider ? { provider: session.provider } : {}),
437
+ };
438
+ next.sessions[sessionId] = {
439
+ ...current,
440
+ activation_id: activationId,
441
+ active_activation_id: activationId,
442
+ activation_epoch: epoch,
443
+ activation_started_at: session.activation_started_at || session.started_at || '',
444
+ last_turn_sequence: requestedSequence,
445
+ activations,
446
+ };
447
+ return next;
448
+ }
449
+
450
+ export function advanceActivationTurn(registry, turn = {}) {
451
+ const next = cloneRegistry(registry);
452
+ const sessionId = turn.session_id || turn.canonical_session_id || '';
453
+ const current = next.sessions[sessionId];
454
+ if (!current) return next;
455
+
456
+ const activeId = current.active_activation_id || '';
457
+ if (turn.activation_id && activeId && turn.activation_id !== activeId) return next;
458
+ const previous = nonNegativeSequence(current.last_turn_sequence, 0);
459
+ const explicit = nonNegativeSequence(turn.turn_sequence ?? turn.last_turn_sequence);
460
+ const sequence = explicit === null ? previous + 1 : Math.max(previous, explicit);
461
+ const activations = { ...(current.activations || {}) };
462
+ if (activeId && activations[activeId]) {
463
+ activations[activeId] = {
464
+ ...activations[activeId],
465
+ last_turn_sequence: Math.max(
466
+ nonNegativeSequence(activations[activeId].last_turn_sequence, 0),
467
+ sequence,
468
+ ),
469
+ };
470
+ }
471
+ next.sessions[sessionId] = { ...current, last_turn_sequence: sequence, activations };
472
+ return next;
473
+ }
474
+
475
+ export function resolveStopActivation(registry, stop = {}) {
476
+ const sessionId = stop.session_id || stop.canonical_session_id || '';
477
+ const entry = registry?.sessions?.[sessionId];
478
+ if (!entry) return '';
479
+ if (stop.activation_id) return String(stop.activation_id);
480
+
481
+ const transcriptId = String(stop.transcript_id || '');
482
+ const transcriptPath = String(stop.transcript_path || '');
483
+ if (!transcriptId && !transcriptPath) return '';
484
+ const matches = Object.entries(entry.activations || {}).filter(([, activation]) => {
485
+ if (!activation) return false;
486
+ if (transcriptId && activation.transcript_id && activation.transcript_id !== transcriptId) return false;
487
+ const paths = [
488
+ ...(Array.isArray(activation.transcript_paths) ? activation.transcript_paths : []),
489
+ activation.transcript_path,
490
+ ].filter(Boolean);
491
+ if (transcriptPath && paths.length && !paths.some((path) => transcriptsMatch(path, transcriptPath))) return false;
492
+ return Boolean(
493
+ (transcriptId && activation.transcript_id === transcriptId)
494
+ || (transcriptPath && paths.some((path) => transcriptsMatch(path, transcriptPath))),
495
+ );
496
+ });
497
+ return matches.length === 1 ? matches[0][0] : '';
498
+ }
499
+
500
+ function stopResult(registry, stopDisposition, canPromoteMemory = false) {
501
+ return {
502
+ ...registry,
503
+ registry,
504
+ stopDisposition,
505
+ canPromoteMemory,
506
+ };
507
+ }
508
+
509
+ export function applyStopActivation(registry, stop = {}) {
510
+ const next = cloneRegistry(registry);
511
+ const sessionId = stop.session_id || stop.canonical_session_id || '';
512
+ const current = next.sessions[sessionId];
513
+ const activeId = current?.active_activation_id || '';
514
+ const stopActivationId = stop.activation_id || '';
515
+ if (!current || !activeId || !stopActivationId) return stopResult(next, 'ambiguous');
516
+ if (current.activations?.[activeId]?.status !== 'active') return stopResult(next, 'ambiguous');
517
+
518
+ if (activeId !== stopActivationId) {
519
+ const activations = { ...(current.activations || {}) };
520
+ if (activations[stopActivationId]?.status === 'active') {
521
+ activations[stopActivationId] = {
522
+ ...activations[stopActivationId],
523
+ status: 'superseded',
524
+ superseded_by: activeId,
525
+ };
526
+ next.sessions[sessionId] = { ...current, activations };
527
+ }
528
+ return stopResult(next, 'superseded');
529
+ }
530
+
531
+ const stopSequence = nonNegativeSequence(stop.turn_sequence ?? stop.last_turn_sequence);
532
+ const lastSequence = nonNegativeSequence(current.last_turn_sequence, 0);
533
+ if (stopSequence === null) return stopResult(next, 'ambiguous');
534
+ if (stopSequence < lastSequence) return stopResult(next, 'stale_turn');
535
+
536
+ const activations = { ...(current.activations || {}) };
537
+ activations[activeId] = {
538
+ ...(activations[activeId] || { activation_id: activeId, epoch: current.activation_epoch }),
539
+ status: 'done',
540
+ ended_at: stop.ended_at || '',
541
+ last_turn_sequence: stopSequence,
542
+ };
543
+ next.sessions[sessionId] = {
544
+ ...current,
545
+ status: 'done',
546
+ ended_at: stop.ended_at || current.ended_at || '',
547
+ active_activation_id: '',
548
+ last_turn_sequence: stopSequence,
549
+ activations,
550
+ };
551
+ return stopResult(next, 'applied', true);
552
+ }
553
+
361
554
  // Remove one registry entry, but ONLY when its transcript matches the given path — this is
362
555
  // self-healing for entries wendkeep itself mis-wrote (a subagent rollout registered as a
363
556
  // top-level session by import <=0.46.1), never generic registry cleanup. An entry with the
@@ -382,16 +575,58 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
382
575
  const clean = meaningfulPatch(patch);
383
576
  const next = mutateSessionRegistry(vaultBase, (registry) => {
384
577
  const current = registry.sessions[sessionId] || {};
578
+ let causalCurrent = current;
579
+ if (clean.activation_id) {
580
+ causalCurrent = openActivation(
581
+ { version: registry.version, sessions: { [sessionId]: current } },
582
+ {
583
+ session_id: sessionId,
584
+ activation_id: clean.activation_id,
585
+ activation_started_at: clean.activation_started_at,
586
+ started_at: clean.activation_started_at || clean.started_at,
587
+ last_turn_sequence: clean.last_turn_sequence,
588
+ transcript_id: clean.transcript_id || current.transcript_id,
589
+ transcript_path: clean.transcript_path || current.transcript_path,
590
+ provider: clean.provider || current.provider,
591
+ },
592
+ ).sessions[sessionId];
593
+ }
594
+ if (patch?.advance_turn_sequence === true || patch?.turn_sequence !== undefined) {
595
+ causalCurrent = advanceActivationTurn(
596
+ { version: registry.version, sessions: { [sessionId]: causalCurrent } },
597
+ {
598
+ session_id: sessionId,
599
+ activation_id: causalCurrent.active_activation_id || '',
600
+ turn_sequence: patch.turn_sequence,
601
+ },
602
+ ).sessions[sessionId];
603
+ }
604
+ const activeId = causalCurrent.active_activation_id || '';
605
+ if (activeId && causalCurrent.activations?.[activeId]) {
606
+ const active = causalCurrent.activations[activeId];
607
+ causalCurrent = {
608
+ ...causalCurrent,
609
+ activations: {
610
+ ...causalCurrent.activations,
611
+ [activeId]: {
612
+ ...active,
613
+ ...(!active.transcript_id && clean.transcript_id ? { transcript_id: clean.transcript_id } : {}),
614
+ ...(!active.transcript_path && clean.transcript_path ? { transcript_path: clean.transcript_path } : {}),
615
+ ...(!active.provider && clean.provider ? { provider: clean.provider } : {}),
616
+ },
617
+ },
618
+ };
619
+ }
385
620
  const transcriptPaths = [...new Set([
386
- ...(Array.isArray(current.transcript_paths) ? current.transcript_paths : []),
387
- current.transcript_path,
621
+ ...(Array.isArray(causalCurrent.transcript_paths) ? causalCurrent.transcript_paths : []),
622
+ causalCurrent.transcript_path,
388
623
  ...(Array.isArray(clean.transcript_paths) ? clean.transcript_paths : []),
389
624
  clean.transcript_path,
390
625
  ].filter(Boolean))];
391
626
  const value = {
392
- ...current,
627
+ ...causalCurrent,
393
628
  ...clean,
394
- ...(transcriptPaths.length ? { transcript_paths: transcriptPaths, transcript_path: clean.transcript_path || current.transcript_path || transcriptPaths.at(-1) } : {}),
629
+ ...(transcriptPaths.length ? { transcript_paths: transcriptPaths, transcript_path: clean.transcript_path || causalCurrent.transcript_path || transcriptPaths.at(-1) } : {}),
395
630
  last_seen: clean.last_seen || clean.updated_at || formatLocalIso(new Date()),
396
631
  updated_at: clean.updated_at || formatLocalIso(new Date()),
397
632
  };
@@ -37,6 +37,12 @@ function sessionIdFromInput(input) {
37
37
  return input.session_id || input.sessionId || input.codex_session_id || '';
38
38
  }
39
39
 
40
+ function turnSequenceFromInput(input = {}) {
41
+ const value = input.turn_sequence ?? input.turnSequence;
42
+ const parsed = Number(value);
43
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
44
+ }
45
+
40
46
  function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
41
47
  const date = formatDate(now);
42
48
  const startedAt = formatLocalIso(now);
@@ -262,6 +268,8 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
262
268
  transcript_path: identity.transcriptPath,
263
269
  transcript_id: identity.transcriptId,
264
270
  provider: identity.provider,
271
+ advance_turn_sequence: true,
272
+ turn_sequence: turnSequenceFromInput(input),
265
273
  });
266
274
  return true;
267
275
  }
@@ -288,6 +296,8 @@ function createSession({ vaultBase, sessionId, input, now, identity }) {
288
296
  transcript_path: identity.transcriptPath,
289
297
  transcript_id: identity.transcriptId,
290
298
  provider: identity.provider,
299
+ advance_turn_sequence: true,
300
+ turn_sequence: turnSequenceFromInput(input),
291
301
  });
292
302
  return { relPath, startedAt };
293
303
  }
@@ -333,6 +343,14 @@ function main() {
333
343
  existsSync(join(vaultBase, ctrl.session_file)) &&
334
344
  ctrl.session_id === sessionId
335
345
  ) {
346
+ upsertSessionRegistry(vaultBase, sessionId, {
347
+ status: 'active',
348
+ transcript_path: identity.transcriptPath,
349
+ transcript_id: identity.transcriptId,
350
+ provider: identity.provider,
351
+ advance_turn_sequence: true,
352
+ turn_sequence: turnSequenceFromInput(input),
353
+ });
336
354
  writeHookOutput({});
337
355
  return;
338
356
  }
@@ -375,6 +393,8 @@ function main() {
375
393
  transcript_path: identity.transcriptPath,
376
394
  transcript_id: identity.transcriptId,
377
395
  provider: identity.provider,
396
+ advance_turn_sequence: true,
397
+ turn_sequence: turnSequenceFromInput(input),
378
398
  });
379
399
  writeHookOutput({});
380
400
  return;
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, writeFileSync } from 'fs';
3
+ import { randomUUID } from 'crypto';
3
4
  import { basename, join } from 'path';
4
5
  import { pathToFileURL } from 'url';
5
6
  import {
@@ -169,6 +170,14 @@ function main() {
169
170
  const sessionId = identity.canonicalConversationId;
170
171
  const transcriptPath = identity.transcriptPath;
171
172
  const control = readControl(vaultBase);
173
+ const activationId = input.activation_id || input.activationId || randomUUID();
174
+ const activationStartedAt = formatLocalIso(now);
175
+ const registerActivation = (canonicalSessionId, patch) => upsertSessionRegistry(vaultBase, canonicalSessionId, {
176
+ ...patch,
177
+ activation_id: activationId,
178
+ activation_started_at: activationStartedAt,
179
+ last_turn_sequence: 0,
180
+ });
172
181
 
173
182
  // Fecha sessões `active` órfãs (sem evento de fim — janela fechada/crash) antes
174
183
  // de seguir. Preserva a deste transcript: pode ser reaproveitada logo abaixo.
@@ -186,7 +195,7 @@ function main() {
186
195
  if (control.status === 'active' && control.session_file && control.session_id === sessionId) {
187
196
  const activePath = join(vaultBase, control.session_file);
188
197
  if (existsSync(activePath)) {
189
- upsertSessionRegistry(vaultBase, sessionId, {
198
+ registerActivation(sessionId, {
190
199
  session_file: control.session_file,
191
200
  status: 'active',
192
201
  started_at: control.started_at,
@@ -232,7 +241,7 @@ function main() {
232
241
  session_id: sessionId,
233
242
  last_logged_turn_id: control.last_logged_turn_id || '',
234
243
  });
235
- upsertSessionRegistry(vaultBase, sessionId, {
244
+ registerActivation(sessionId, {
236
245
  session_file: known.session_file,
237
246
  status: 'active',
238
247
  started_at: startedAt,
@@ -275,7 +284,7 @@ function main() {
275
284
  session_id: sessionId || match.sessionId,
276
285
  last_logged_turn_id: control.last_logged_turn_id || '',
277
286
  });
278
- upsertSessionRegistry(vaultBase, sessionId || match.sessionId, {
287
+ registerActivation(sessionId || match.sessionId, {
279
288
  session_file: match.session_file,
280
289
  status: 'active',
281
290
  started_at: startedAt,
@@ -305,7 +314,7 @@ function main() {
305
314
  started_at: startedAt,
306
315
  session_id: sessionId,
307
316
  });
308
- upsertSessionRegistry(vaultBase, sessionId, {
317
+ registerActivation(sessionId, {
309
318
  session_file: relPath,
310
319
  status: 'active',
311
320
  started_at: startedAt,
@@ -12,6 +12,10 @@ import { updateSessionObservability } from './session-observability.mjs';
12
12
  import { resolveSessionEntry } from './session-identity.mjs';
13
13
  import { mutateSessionNote } from './session-note-io.mjs';
14
14
  import { applyDerivedSections, provenanceSessions } from './derived-sections.mjs';
15
+ import { buildSessionMemoryEvents, collectLifecycleEvidence } from './memory-handoff.mjs';
16
+ import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
17
+ import { detectMemoryMode } from './memory-mode.mjs';
18
+ import { sanitizeMemoryText } from './memory-schema.mjs';
15
19
  import {
16
20
  ensureDir,
17
21
  findActiveSessionByTranscript,
@@ -37,6 +41,9 @@ import {
37
41
  turnMarker,
38
42
  hasTurnMarker,
39
43
  normalizeTurnMarkers,
44
+ mutateSessionRegistry,
45
+ resolveStopActivation,
46
+ applyStopActivation,
40
47
  } from './obsidian-common.mjs';
41
48
 
42
49
  function extractContentText(content) {
@@ -792,6 +799,45 @@ function shouldFinalizeSession() {
792
799
  return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
793
800
  }
794
801
 
802
+ export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
803
+ if (detectMemoryMode(vaultBase).mode === 'legacy') {
804
+ return { status: 'legacy', eventCount: 0, eventIds: [], checkpoint: null };
805
+ }
806
+ const events = buildSessionMemoryEvents(handoff);
807
+ const eventIds = events.map((event) => event.event_id);
808
+ try {
809
+ for (const event of events) enqueueMemoryEvent(vaultBase, event);
810
+ const projection = projectMemoryOutbox(vaultBase, projectOptions);
811
+ if (projection.status === 'busy') {
812
+ return {
813
+ status: 'degraded',
814
+ error: 'memory projector busy; outbox preserved for replay',
815
+ eventCount: events.length,
816
+ eventIds,
817
+ checkpoint: null,
818
+ };
819
+ }
820
+ return {
821
+ status: 'projected',
822
+ eventCount: events.length,
823
+ eventIds,
824
+ checkpoint: {
825
+ revision: projection.revision,
826
+ event_cursor: projection.eventCursor,
827
+ state_hash: projection.stateHash,
828
+ },
829
+ };
830
+ } catch (error) {
831
+ return {
832
+ status: 'degraded',
833
+ error: sanitizeMemoryText(error?.message || String(error)),
834
+ eventCount: events.length,
835
+ eventIds,
836
+ checkpoint: null,
837
+ };
838
+ }
839
+ }
840
+
795
841
  // Só captura checkboxes de tarefa reais (`- [ ] ...`). Antes casava as palavras
796
842
  // `todo`/`pendência`/`pendente` em prosa (ex.: "todo" dentro de "todos"), o que
797
843
  // despejava trechos de conversa na seção Pendências.
@@ -913,9 +959,7 @@ function replaceClosingSection(content, closing) {
913
959
  export function finalizeSessionFile(sessionPath, tx, created, endedAt) {
914
960
  const pending = extractPending(tx.rawTextForDetection);
915
961
  const links = (items) => items.length ? items.map((rel) => ` - ${wikilinkFromRel(rel)}`).join('\n') : ' - Nenhuma';
916
- const summary = tx.latestAssistantMessage
917
- ? truncate(tx.latestAssistantMessage, 500)
918
- : `Sessão encerrada com ${tx.userPrompts.length} prompts e ${tx.tools.length} ferramentas registradas.`;
962
+ const summary = sessionFinalSummary(tx);
919
963
 
920
964
  const closing = `## Encerramento
921
965
 
@@ -943,6 +987,12 @@ ${formatPendingClosing(pending)}
943
987
  ));
944
988
  }
945
989
 
990
+ export function sessionFinalSummary(tx) {
991
+ return tx.latestAssistantMessage
992
+ ? truncate(tx.latestAssistantMessage, 500)
993
+ : `Sessão encerrada com ${tx.userPrompts.length} prompts e ${tx.tools.length} ferramentas registradas.`;
994
+ }
995
+
946
996
  // --- Vínculo Sessão ↔ Issues Linear (03-Linear) -------------------------------
947
997
  // Coleta IDs `NUT-\d+` citados na conversa, resolve as notas em 03-Linear e, ao
948
998
  // ler cada nota, descobre NUTs conectadas (1 salto) mencionadas no corpo dela.
@@ -1085,6 +1135,53 @@ function main() {
1085
1135
  const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1086
1136
  const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1087
1137
  const sessionId = identity.canonicalConversationId;
1138
+ const finalizing = shouldFinalizeSession();
1139
+ const now = finalizing ? new Date() : null;
1140
+ const endedAt = finalizing ? formatLocalIso(now) : '';
1141
+ const stopTurnSequence = Number.isSafeInteger(Number(input.turn_sequence))
1142
+ ? Number(input.turn_sequence)
1143
+ : Number(entry.last_turn_sequence || 0);
1144
+ const causalStop = finalizing
1145
+ ? mutateSessionRegistry(vaultBase, (registry) => {
1146
+ const activationId = resolveStopActivation(registry, {
1147
+ session_id: sessionId,
1148
+ activation_id: input.activation_id || input.activationId || '',
1149
+ transcript_id: identity.transcriptId,
1150
+ transcript_path: identity.transcriptPath || transcriptPath,
1151
+ });
1152
+ const cas = applyStopActivation(registry, {
1153
+ session_id: sessionId,
1154
+ activation_id: activationId,
1155
+ turn_sequence: stopTurnSequence,
1156
+ ended_at: endedAt,
1157
+ });
1158
+ const activation = cas.registry.sessions[sessionId]?.activations?.[activationId] || null;
1159
+ if (cas.canPromoteMemory) {
1160
+ registry.version = cas.registry.version;
1161
+ registry.sessions = cas.registry.sessions;
1162
+ registry.sessions[sessionId] = {
1163
+ ...registry.sessions[sessionId],
1164
+ session_file: sessionRel,
1165
+ last_turn_id: turnId,
1166
+ transcript_path: transcriptPath,
1167
+ transcript_id: identity.transcriptId,
1168
+ provider: identity.provider,
1169
+ };
1170
+ }
1171
+ return {
1172
+ activationId,
1173
+ activation,
1174
+ stopDisposition: cas.stopDisposition,
1175
+ canPromoteMemory: cas.canPromoteMemory,
1176
+ };
1177
+ })
1178
+ : null;
1179
+ if (causalStop && !causalStop.canPromoteMemory) {
1180
+ const message = `wendkeep: Stop ${causalStop.stopDisposition}; uma activation mais nova foi preservada e a memória não foi promovida.`;
1181
+ process.stderr.write(`[wendkeep] ${message}\n`);
1182
+ writeHookOutput({ systemMessage: message });
1183
+ return;
1184
+ }
1088
1185
  const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1089
1186
 
1090
1187
  try {
@@ -1099,7 +1196,7 @@ function main() {
1099
1196
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1100
1197
  }
1101
1198
 
1102
- if (!shouldFinalizeSession()) {
1199
+ if (!finalizing) {
1103
1200
  writeControl(vaultBase, {
1104
1201
  ...control,
1105
1202
  status: 'active',
@@ -1125,8 +1222,6 @@ function main() {
1125
1222
  return;
1126
1223
  }
1127
1224
 
1128
- const now = new Date();
1129
- const endedAt = formatLocalIso(now);
1130
1225
  const created = mergeCreatedNotes(
1131
1226
  createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
1132
1227
  findLinkedDerivedNotes(vaultBase, sessionRel),
@@ -1156,15 +1251,40 @@ function main() {
1156
1251
  session_id: sessionId,
1157
1252
  last_logged_turn_id: turnId,
1158
1253
  });
1159
- upsertSessionRegistry(vaultBase, sessionId, {
1160
- session_file: sessionRel,
1161
- status: 'done',
1162
- // started_at omitido: preserva o da própria entry (ver branch acima).
1163
- ended_at: endedAt,
1164
- last_turn_id: turnId,
1165
- transcript_path: transcriptPath,
1166
- transcript_id: identity.transcriptId,
1167
- provider: identity.provider,
1254
+
1255
+ let projectId = '';
1256
+ try {
1257
+ projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '';
1258
+ } catch { /* store validator reports a degraded handoff below */ }
1259
+ const finalSummary = sessionFinalSummary(tx);
1260
+ const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1261
+ changeSlug: entry.change_slug,
1262
+ summary: finalSummary,
1263
+ noteRel: sessionRel,
1264
+ });
1265
+ const memoryResult = commitSessionMemory(vaultBase, {
1266
+ projectId,
1267
+ identity,
1268
+ activation: {
1269
+ id: causalStop.activationId,
1270
+ epoch: Number(causalStop.activation?.epoch || entry.activation_epoch || 0),
1271
+ },
1272
+ turn: { id: turnId, sequence: stopTurnSequence },
1273
+ noteRel: sessionRel,
1274
+ observedAt: new Date().toISOString(),
1275
+ summary: finalSummary,
1276
+ evidence: memoryEvidence,
1277
+ });
1278
+ mutateSessionRegistry(vaultBase, (registry) => {
1279
+ const current = registry.sessions[sessionId];
1280
+ if (!current) return null;
1281
+ registry.sessions[sessionId] = {
1282
+ ...current,
1283
+ memory_status: memoryResult.status,
1284
+ memory_activation_id: causalStop.activationId,
1285
+ ...(memoryResult.checkpoint ? { memory_checkpoint: memoryResult.checkpoint } : {}),
1286
+ };
1287
+ return null;
1168
1288
  });
1169
1289
 
1170
1290
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
@@ -1179,7 +1299,9 @@ function main() {
1179
1299
  try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
1180
1300
 
1181
1301
  pingObsidianVault(input.obsidian_api_key);
1182
- writeHookOutput({});
1302
+ writeHookOutput(memoryResult.status === 'degraded'
1303
+ ? { systemMessage: `wendkeep: sessão salva; memória compartilhada degradada (${memoryResult.error}). Outbox preservada para replay.` }
1304
+ : {});
1183
1305
  }
1184
1306
 
1185
1307
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {