wendkeep 0.57.2 → 0.58.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.
@@ -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,9 @@ 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 { sanitizeMemoryText } from './memory-schema.mjs';
15
18
  import {
16
19
  ensureDir,
17
20
  findActiveSessionByTranscript,
@@ -37,6 +40,9 @@ import {
37
40
  turnMarker,
38
41
  hasTurnMarker,
39
42
  normalizeTurnMarkers,
43
+ mutateSessionRegistry,
44
+ resolveStopActivation,
45
+ applyStopActivation,
40
46
  } from './obsidian-common.mjs';
41
47
 
42
48
  function extractContentText(content) {
@@ -792,6 +798,42 @@ function shouldFinalizeSession() {
792
798
  return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
793
799
  }
794
800
 
801
+ export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
802
+ const events = buildSessionMemoryEvents(handoff);
803
+ const eventIds = events.map((event) => event.event_id);
804
+ try {
805
+ for (const event of events) enqueueMemoryEvent(vaultBase, event);
806
+ const projection = projectMemoryOutbox(vaultBase, projectOptions);
807
+ if (projection.status === 'busy') {
808
+ return {
809
+ status: 'degraded',
810
+ error: 'memory projector busy; outbox preserved for replay',
811
+ eventCount: events.length,
812
+ eventIds,
813
+ checkpoint: null,
814
+ };
815
+ }
816
+ return {
817
+ status: 'projected',
818
+ eventCount: events.length,
819
+ eventIds,
820
+ checkpoint: {
821
+ revision: projection.revision,
822
+ event_cursor: projection.eventCursor,
823
+ state_hash: projection.stateHash,
824
+ },
825
+ };
826
+ } catch (error) {
827
+ return {
828
+ status: 'degraded',
829
+ error: sanitizeMemoryText(error?.message || String(error)),
830
+ eventCount: events.length,
831
+ eventIds,
832
+ checkpoint: null,
833
+ };
834
+ }
835
+ }
836
+
795
837
  // Só captura checkboxes de tarefa reais (`- [ ] ...`). Antes casava as palavras
796
838
  // `todo`/`pendência`/`pendente` em prosa (ex.: "todo" dentro de "todos"), o que
797
839
  // despejava trechos de conversa na seção Pendências.
@@ -913,9 +955,7 @@ function replaceClosingSection(content, closing) {
913
955
  export function finalizeSessionFile(sessionPath, tx, created, endedAt) {
914
956
  const pending = extractPending(tx.rawTextForDetection);
915
957
  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.`;
958
+ const summary = sessionFinalSummary(tx);
919
959
 
920
960
  const closing = `## Encerramento
921
961
 
@@ -943,6 +983,12 @@ ${formatPendingClosing(pending)}
943
983
  ));
944
984
  }
945
985
 
986
+ export function sessionFinalSummary(tx) {
987
+ return tx.latestAssistantMessage
988
+ ? truncate(tx.latestAssistantMessage, 500)
989
+ : `Sessão encerrada com ${tx.userPrompts.length} prompts e ${tx.tools.length} ferramentas registradas.`;
990
+ }
991
+
946
992
  // --- Vínculo Sessão ↔ Issues Linear (03-Linear) -------------------------------
947
993
  // Coleta IDs `NUT-\d+` citados na conversa, resolve as notas em 03-Linear e, ao
948
994
  // ler cada nota, descobre NUTs conectadas (1 salto) mencionadas no corpo dela.
@@ -1085,6 +1131,53 @@ function main() {
1085
1131
  const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1086
1132
  const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1087
1133
  const sessionId = identity.canonicalConversationId;
1134
+ const finalizing = shouldFinalizeSession();
1135
+ const now = finalizing ? new Date() : null;
1136
+ const endedAt = finalizing ? formatLocalIso(now) : '';
1137
+ const stopTurnSequence = Number.isSafeInteger(Number(input.turn_sequence))
1138
+ ? Number(input.turn_sequence)
1139
+ : Number(entry.last_turn_sequence || 0);
1140
+ const causalStop = finalizing
1141
+ ? mutateSessionRegistry(vaultBase, (registry) => {
1142
+ const activationId = resolveStopActivation(registry, {
1143
+ session_id: sessionId,
1144
+ activation_id: input.activation_id || input.activationId || '',
1145
+ transcript_id: identity.transcriptId,
1146
+ transcript_path: identity.transcriptPath || transcriptPath,
1147
+ });
1148
+ const cas = applyStopActivation(registry, {
1149
+ session_id: sessionId,
1150
+ activation_id: activationId,
1151
+ turn_sequence: stopTurnSequence,
1152
+ ended_at: endedAt,
1153
+ });
1154
+ const activation = cas.registry.sessions[sessionId]?.activations?.[activationId] || null;
1155
+ if (cas.canPromoteMemory) {
1156
+ registry.version = cas.registry.version;
1157
+ registry.sessions = cas.registry.sessions;
1158
+ registry.sessions[sessionId] = {
1159
+ ...registry.sessions[sessionId],
1160
+ session_file: sessionRel,
1161
+ last_turn_id: turnId,
1162
+ transcript_path: transcriptPath,
1163
+ transcript_id: identity.transcriptId,
1164
+ provider: identity.provider,
1165
+ };
1166
+ }
1167
+ return {
1168
+ activationId,
1169
+ activation,
1170
+ stopDisposition: cas.stopDisposition,
1171
+ canPromoteMemory: cas.canPromoteMemory,
1172
+ };
1173
+ })
1174
+ : null;
1175
+ if (causalStop && !causalStop.canPromoteMemory) {
1176
+ const message = `wendkeep: Stop ${causalStop.stopDisposition}; uma activation mais nova foi preservada e a memória não foi promovida.`;
1177
+ process.stderr.write(`[wendkeep] ${message}\n`);
1178
+ writeHookOutput({ systemMessage: message });
1179
+ return;
1180
+ }
1088
1181
  const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1089
1182
 
1090
1183
  try {
@@ -1099,7 +1192,7 @@ function main() {
1099
1192
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1100
1193
  }
1101
1194
 
1102
- if (!shouldFinalizeSession()) {
1195
+ if (!finalizing) {
1103
1196
  writeControl(vaultBase, {
1104
1197
  ...control,
1105
1198
  status: 'active',
@@ -1125,8 +1218,6 @@ function main() {
1125
1218
  return;
1126
1219
  }
1127
1220
 
1128
- const now = new Date();
1129
- const endedAt = formatLocalIso(now);
1130
1221
  const created = mergeCreatedNotes(
1131
1222
  createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
1132
1223
  findLinkedDerivedNotes(vaultBase, sessionRel),
@@ -1156,15 +1247,40 @@ function main() {
1156
1247
  session_id: sessionId,
1157
1248
  last_logged_turn_id: turnId,
1158
1249
  });
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,
1250
+
1251
+ let projectId = '';
1252
+ try {
1253
+ projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '';
1254
+ } catch { /* store validator reports a degraded handoff below */ }
1255
+ const finalSummary = sessionFinalSummary(tx);
1256
+ const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1257
+ changeSlug: entry.change_slug,
1258
+ summary: finalSummary,
1259
+ noteRel: sessionRel,
1260
+ });
1261
+ const memoryResult = commitSessionMemory(vaultBase, {
1262
+ projectId,
1263
+ identity,
1264
+ activation: {
1265
+ id: causalStop.activationId,
1266
+ epoch: Number(causalStop.activation?.epoch || entry.activation_epoch || 0),
1267
+ },
1268
+ turn: { id: turnId, sequence: stopTurnSequence },
1269
+ noteRel: sessionRel,
1270
+ observedAt: new Date().toISOString(),
1271
+ summary: finalSummary,
1272
+ evidence: memoryEvidence,
1273
+ });
1274
+ mutateSessionRegistry(vaultBase, (registry) => {
1275
+ const current = registry.sessions[sessionId];
1276
+ if (!current) return null;
1277
+ registry.sessions[sessionId] = {
1278
+ ...current,
1279
+ memory_status: memoryResult.status,
1280
+ memory_activation_id: causalStop.activationId,
1281
+ ...(memoryResult.checkpoint ? { memory_checkpoint: memoryResult.checkpoint } : {}),
1282
+ };
1283
+ return null;
1168
1284
  });
1169
1285
 
1170
1286
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
@@ -1179,7 +1295,9 @@ function main() {
1179
1295
  try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
1180
1296
 
1181
1297
  pingObsidianVault(input.obsidian_api_key);
1182
- writeHookOutput({});
1298
+ writeHookOutput(memoryResult.status === 'degraded'
1299
+ ? { systemMessage: `wendkeep: sessão salva; memória compartilhada degradada (${memoryResult.error}). Outbox preservada para replay.` }
1300
+ : {});
1183
1301
  }
1184
1302
 
1185
1303
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {