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,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { randomUUID } from 'crypto';
2
3
  import { existsSync, renameSync, statSync, writeFileSync } from 'fs';
3
4
  import { basename, dirname, join } from 'path';
4
5
  import {
@@ -43,6 +44,16 @@ function turnSequenceFromInput(input = {}) {
43
44
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
44
45
  }
45
46
 
47
+ function causalTurnPatch(input, now) {
48
+ return {
49
+ advance_turn_sequence: true,
50
+ turn_sequence: turnSequenceFromInput(input),
51
+ turn_id: input.turn_id || input.turnId || '',
52
+ recovery_activation_id: randomUUID(),
53
+ recovery_started_at: formatLocalIso(now),
54
+ };
55
+ }
56
+
46
57
  function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
47
58
  const date = formatDate(now);
48
59
  const startedAt = formatLocalIso(now);
@@ -203,7 +214,7 @@ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
203
214
  const sessionPath = join(vaultBase, nextRelPath);
204
215
  const outcome = mutateSessionNote(sessionPath, (content) => (
205
216
  updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
206
- ));
217
+ ), { vaultBase });
207
218
 
208
219
  return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || outcome.written };
209
220
  }
@@ -215,8 +226,10 @@ function stripClosingSection(content) {
215
226
  return `${content.slice(0, index).trimEnd()}\n`;
216
227
  }
217
228
 
218
- function reopenSessionFile(sessionPath) {
219
- mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)));
229
+ function reopenSessionFile(vaultBase, sessionPath) {
230
+ mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)), {
231
+ vaultBase,
232
+ });
220
233
  }
221
234
 
222
235
  function findSessionForInput(vaultBase, input, control) {
@@ -249,7 +262,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
249
262
  const sessionPath = join(vaultBase, relPath);
250
263
  if (!existsSync(sessionPath)) return false;
251
264
 
252
- reopenSessionFile(sessionPath);
265
+ reopenSessionFile(vaultBase, sessionPath);
253
266
  const nextStartedAt = startedAt || formatLocalIso(now);
254
267
  writeControl(vaultBase, {
255
268
  status: 'active',
@@ -268,8 +281,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
268
281
  transcript_path: identity.transcriptPath,
269
282
  transcript_id: identity.transcriptId,
270
283
  provider: identity.provider,
271
- advance_turn_sequence: true,
272
- turn_sequence: turnSequenceFromInput(input),
284
+ ...causalTurnPatch(input, now),
273
285
  });
274
286
  return true;
275
287
  }
@@ -296,8 +308,7 @@ function createSession({ vaultBase, sessionId, input, now, identity }) {
296
308
  transcript_path: identity.transcriptPath,
297
309
  transcript_id: identity.transcriptId,
298
310
  provider: identity.provider,
299
- advance_turn_sequence: true,
300
- turn_sequence: turnSequenceFromInput(input),
311
+ ...causalTurnPatch(input, now),
301
312
  });
302
313
  return { relPath, startedAt };
303
314
  }
@@ -348,8 +359,7 @@ function main() {
348
359
  transcript_path: identity.transcriptPath,
349
360
  transcript_id: identity.transcriptId,
350
361
  provider: identity.provider,
351
- advance_turn_sequence: true,
352
- turn_sequence: turnSequenceFromInput(input),
362
+ ...causalTurnPatch(input, now),
353
363
  });
354
364
  writeHookOutput({});
355
365
  return;
@@ -393,8 +403,7 @@ function main() {
393
403
  transcript_path: identity.transcriptPath,
394
404
  transcript_id: identity.transcriptId,
395
405
  provider: identity.provider,
396
- advance_turn_sequence: true,
397
- turn_sequence: turnSequenceFromInput(input),
406
+ ...causalTurnPatch(input, now),
398
407
  });
399
408
  writeHookOutput({});
400
409
  return;
@@ -0,0 +1,65 @@
1
+ import { hasTurnMarker, normalizeTurnMarkers, turnMarker } from './obsidian-common.mjs';
2
+ import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
3
+
4
+ const ITERATION_ANCHORS = [
5
+ '\n## Agentes, tokens e custos',
6
+ '\n## Uso de tokens e custos',
7
+ '\n## Decisões geradas nesta sessão',
8
+ '\n## Bugs gerados nesta sessão',
9
+ '\n## Aprendizados gerados nesta sessão',
10
+ '\n## Arquivos consultados',
11
+ '\n## Arquivos criados ou alterados',
12
+ '\n## Pendências',
13
+ '\n## Encerramento',
14
+ ];
15
+
16
+ export function insertIterationContent(original, { markerId, block }) {
17
+ if (!markerId) throw new TypeError('markerId é obrigatório');
18
+ let content = normalizeTurnMarkers(String(original || ''));
19
+ if (hasTurnMarker(content, markerId)) return { content, inserted: false };
20
+
21
+ const rendered = `\n${String(block || '').trim()}\n${turnMarker(markerId)}\n`;
22
+ const iterations = content.indexOf('\n## Iterações');
23
+ if (iterations !== -1) {
24
+ const anchors = ITERATION_ANCHORS
25
+ .map((anchor) => content.indexOf(anchor, iterations + 1))
26
+ .filter((index) => index !== -1)
27
+ .sort((left, right) => left - right);
28
+ if (anchors.length) {
29
+ const at = anchors[0];
30
+ content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
31
+ } else {
32
+ const lineEnd = content.indexOf('\n', iterations + 1);
33
+ const at = lineEnd === -1 ? content.length : lineEnd + 1;
34
+ content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
35
+ }
36
+ return { content, inserted: true };
37
+ }
38
+
39
+ const closing = content.indexOf('\n## Encerramento');
40
+ if (closing !== -1) {
41
+ content = `${content.slice(0, closing).trimEnd()}\n\n## Iterações\n${rendered}\n${content.slice(closing).replace(/^\n+/, '')}`;
42
+ } else {
43
+ content = `${content.trimEnd()}\n\n## Iterações\n${rendered}`;
44
+ }
45
+ return { content, inserted: true };
46
+ }
47
+
48
+ export function projectSessionIteration(sessionPath, input, options = {}) {
49
+ let inserted = false;
50
+ let invalidFrontmatter = false;
51
+ const outcome = mutateSessionNote(sessionPath, (content) => {
52
+ if (!hasSessionFrontmatter(content)) {
53
+ invalidFrontmatter = true;
54
+ return null;
55
+ }
56
+ const result = insertIterationContent(content, input);
57
+ inserted = result.inserted;
58
+ return result.content;
59
+ }, options);
60
+ return {
61
+ inserted,
62
+ written: outcome.written,
63
+ reason: invalidFrontmatter ? 'invalid-frontmatter' : outcome.reason,
64
+ };
65
+ }
@@ -0,0 +1,335 @@
1
+ // Durable SessionStop memory publication split into three explicit phases:
2
+ // registry-guarded outbox staging -> independent projection -> registry CAS outcome.
3
+ // Keeping the MEMORY lock out of the registry critical section avoids lock inversion,
4
+ // while the immutable outbox is the durable hand-off between both locks.
5
+ import { buildSessionMemoryEvents } from './memory-handoff.mjs';
6
+ import { detectMemoryMode } from './memory-mode.mjs';
7
+ import { sanitizeMemoryText } from './memory-schema.mjs';
8
+ import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
9
+ import { mutateSessionRegistry } from './obsidian-common.mjs';
10
+
11
+ const DEFAULT_OBSERVED_AT = '1970-01-01T00:00:00.000Z';
12
+
13
+ const DEFAULT_DEPS = Object.freeze({
14
+ buildSessionMemoryEvents,
15
+ detectMemoryMode,
16
+ enqueueMemoryEvent,
17
+ mutateSessionRegistry,
18
+ projectMemoryOutbox,
19
+ sanitizeMemoryText,
20
+ });
21
+
22
+ function dependencies(overrides = {}) {
23
+ return { ...DEFAULT_DEPS, ...(overrides || {}) };
24
+ }
25
+
26
+ function nonNegativeInteger(value, fallback = 0) {
27
+ const parsed = Number(value);
28
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
29
+ }
30
+
31
+ function normalizeContext(context = {}) {
32
+ const handoff = context.handoff && typeof context.handoff === 'object'
33
+ ? context.handoff
34
+ : context;
35
+ const identity = handoff.identity || context.identity || {};
36
+ const activation = handoff.activation || context.activation || {};
37
+ const turn = handoff.turn || context.turn || {};
38
+ const sessionId = String(
39
+ context.sessionId
40
+ || context.canonicalSessionId
41
+ || identity.canonicalConversationId
42
+ || '',
43
+ );
44
+ const activationId = String(context.activationId || activation.id || '');
45
+ const activationEpoch = nonNegativeInteger(
46
+ context.activationEpoch ?? activation.epoch,
47
+ 0,
48
+ );
49
+ const turnId = String(context.turnId || turn.id || '');
50
+ const turnSequence = nonNegativeInteger(context.turnSequence ?? turn.sequence, 0);
51
+ const observedAt = String(context.observedAt || handoff.observedAt || DEFAULT_OBSERVED_AT);
52
+ const disposition = String(
53
+ context.disposition
54
+ || context.stopDisposition
55
+ || 'applied',
56
+ );
57
+
58
+ return {
59
+ sessionId,
60
+ activationId,
61
+ activationEpoch,
62
+ turnId,
63
+ turnSequence,
64
+ observedAt,
65
+ disposition,
66
+ handoff: {
67
+ ...handoff,
68
+ identity: { ...identity, canonicalConversationId: sessionId },
69
+ activation: { ...activation, id: activationId, epoch: activationEpoch },
70
+ turn: { ...turn, id: turnId, sequence: turnSequence },
71
+ observedAt,
72
+ },
73
+ };
74
+ }
75
+
76
+ function attemptIdentity(context, memoryMode) {
77
+ return {
78
+ v: 1,
79
+ memory_mode: memoryMode,
80
+ canonical_session_id: context.sessionId,
81
+ activation_id: context.activationId,
82
+ activation_epoch: context.activationEpoch,
83
+ turn_id: context.turnId,
84
+ turn_sequence: context.turnSequence,
85
+ observed_at: context.observedAt,
86
+ };
87
+ }
88
+
89
+ function sameAttempt(left, right) {
90
+ if (!left || !right) return false;
91
+ return String(left.canonical_session_id || '') === String(right.canonical_session_id || '')
92
+ && String(left.activation_id || '') === String(right.activation_id || '')
93
+ && nonNegativeInteger(left.activation_epoch, -1) === nonNegativeInteger(right.activation_epoch, -1)
94
+ && String(left.turn_id || '') === String(right.turn_id || '')
95
+ && nonNegativeInteger(left.turn_sequence, -1) === nonNegativeInteger(right.turn_sequence, -1);
96
+ }
97
+
98
+ function skippedAttempt(context, memoryMode, disposition) {
99
+ return {
100
+ ...attemptIdentity(context, memoryMode),
101
+ disposition,
102
+ state: 'skipped',
103
+ event_ids: [],
104
+ checkpoint: null,
105
+ };
106
+ }
107
+
108
+ function causalDisposition(entry, context) {
109
+ if (!entry || !context.sessionId || !context.activationId || !context.turnId) return 'ambiguous';
110
+ if (['ambiguous', 'stale_turn', 'superseded'].includes(context.disposition)) {
111
+ return context.disposition;
112
+ }
113
+
114
+ const activeId = String(entry.active_activation_id || '');
115
+ const active = entry.activations?.[activeId];
116
+ if (!activeId || activeId !== context.activationId || active?.status !== 'active') {
117
+ return 'superseded';
118
+ }
119
+ if (nonNegativeInteger(active.epoch, -1) !== context.activationEpoch) return 'superseded';
120
+
121
+ const openedAfter = nonNegativeInteger(active.opened_after_turn_sequence, 0);
122
+ if (context.activationEpoch > 1 && context.turnSequence <= openedAfter) return 'superseded';
123
+
124
+ const lastStopSequence = nonNegativeInteger(
125
+ active.last_stop_turn_sequence,
126
+ nonNegativeInteger(entry.last_turn_sequence, 0),
127
+ );
128
+ const lastStopTurnId = String(active.last_stop_turn_id || entry.last_turn_id || '');
129
+ if (lastStopSequence > context.turnSequence) return 'stale_turn';
130
+ if (lastStopTurnId && lastStopTurnId !== context.turnId) return 'stale_turn';
131
+ return 'applied';
132
+ }
133
+
134
+ function retryAttempt(previous) {
135
+ if (previous.state === 'projected' || previous.state === 'duplicate') {
136
+ return { ...previous, disposition: 'duplicate', state: 'duplicate', retry: true };
137
+ }
138
+ if (previous.state === 'enqueued' || previous.state === 'degraded') {
139
+ return { ...previous, state: 'enqueued', retry: true };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function canPersistAmbiguousSkip(entry, candidate) {
145
+ if (candidate.disposition !== 'ambiguous') return false;
146
+ const entryEpoch = nonNegativeInteger(entry.activation_epoch, -1);
147
+ if (entryEpoch > candidate.activation_epoch) return false;
148
+ if (entryEpoch === candidate.activation_epoch
149
+ && nonNegativeInteger(entry.last_turn_sequence, -1) > candidate.turn_sequence) {
150
+ return false;
151
+ }
152
+
153
+ const previous = entry.last_memory_attempt;
154
+ if (!previous) return true;
155
+ const previousEpoch = nonNegativeInteger(previous.activation_epoch, -1);
156
+ if (previousEpoch !== candidate.activation_epoch) return previousEpoch < candidate.activation_epoch;
157
+ const previousTurn = nonNegativeInteger(previous.turn_sequence, -1);
158
+ if (previousTurn !== candidate.turn_sequence) return previousTurn < candidate.turn_sequence;
159
+ return sameAttempt(previous, candidate) && previous.state === 'skipped';
160
+ }
161
+
162
+ /**
163
+ * Revalidate the Stop under SESSION_REGISTRY.lock, durably enqueue every event, and only
164
+ * then acknowledge `last_memory_attempt.state = enqueued` in the same registry mutation.
165
+ */
166
+ export function stageStopMemoryAttempt(vaultBase, rawContext, overrides = {}) {
167
+ const deps = dependencies(overrides);
168
+ const context = normalizeContext(rawContext);
169
+ const mode = deps.detectMemoryMode(vaultBase).mode;
170
+ if (mode === 'legacy') return skippedAttempt(context, 'legacy', 'legacy');
171
+
172
+ const identity = attemptIdentity(context, 'v2');
173
+ let staged = null;
174
+ deps.mutateSessionRegistry(vaultBase, (registry) => {
175
+ const entry = registry.sessions?.[context.sessionId];
176
+ const disposition = causalDisposition(entry, context);
177
+ if (disposition !== 'applied') {
178
+ staged = skippedAttempt(context, 'v2', disposition);
179
+ if (entry && canPersistAmbiguousSkip(entry, staged)) {
180
+ entry.last_memory_attempt = staged;
181
+ entry.memory_status = 'skipped';
182
+ if (context.activationId) entry.memory_activation_id = context.activationId;
183
+ }
184
+ return staged;
185
+ }
186
+
187
+ const previous = entry.last_memory_attempt;
188
+ if (sameAttempt(previous, identity)) {
189
+ const retry = retryAttempt(previous);
190
+ if (retry) {
191
+ staged = retry;
192
+ return staged;
193
+ }
194
+ }
195
+
196
+ const events = deps.buildSessionMemoryEvents(context.handoff);
197
+ if (!Array.isArray(events) || events.length === 0) {
198
+ throw new TypeError('Session memory staging requires at least one event.');
199
+ }
200
+ for (const event of events) deps.enqueueMemoryEvent(vaultBase, event);
201
+
202
+ staged = {
203
+ ...identity,
204
+ disposition: 'applied',
205
+ state: 'enqueued',
206
+ event_ids: events.map((event) => String(event.event_id || '')),
207
+ checkpoint: null,
208
+ };
209
+ entry.last_memory_attempt = staged;
210
+ entry.memory_status = 'enqueued';
211
+ entry.memory_activation_id = context.activationId;
212
+ return staged;
213
+ });
214
+
215
+ return staged || skippedAttempt(context, 'v2', 'ambiguous');
216
+ }
217
+
218
+ function outcome(attempt, state, extra = {}) {
219
+ const eventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
220
+ return {
221
+ ...attempt,
222
+ ...extra,
223
+ state,
224
+ status: state,
225
+ event_ids: eventIds,
226
+ eventIds,
227
+ eventCount: eventIds.length,
228
+ };
229
+ }
230
+
231
+ /** Project outside the registry lock. The outbox remains the recovery authority on failure. */
232
+ export function projectStopMemoryAttempt(vaultBase, attempt, overrides = {}) {
233
+ const deps = dependencies(overrides);
234
+ if (attempt?.memory_mode === 'legacy') {
235
+ return { ...outcome(attempt, 'skipped'), status: 'legacy' };
236
+ }
237
+ if (attempt?.state === 'duplicate') return outcome(attempt, 'duplicate');
238
+ if (attempt?.state === 'skipped') return outcome(attempt, 'skipped');
239
+
240
+ try {
241
+ const projection = deps.projectMemoryOutbox(vaultBase, overrides.projectOptions || {});
242
+ if (projection?.status === 'busy') {
243
+ return outcome(attempt, 'degraded', {
244
+ error: 'memory projector busy; outbox preserved for replay',
245
+ checkpoint: null,
246
+ });
247
+ }
248
+ return outcome(attempt, 'projected', {
249
+ checkpoint: projection.checkpoint && typeof projection.checkpoint === 'object'
250
+ ? { ...projection.checkpoint }
251
+ : {
252
+ revision: projection.revision,
253
+ event_cursor: projection.ledgerCursor || projection.eventCursor,
254
+ state_hash: projection.stateHash,
255
+ ...(projection.ledgerCursor && projection.eventCursor !== projection.ledgerCursor
256
+ ? { causal_event_cursor: projection.eventCursor }
257
+ : {}),
258
+ },
259
+ });
260
+ } catch (error) {
261
+ return outcome(attempt, 'degraded', {
262
+ error: deps.sanitizeMemoryText(error?.message || String(error)),
263
+ checkpoint: null,
264
+ });
265
+ }
266
+ }
267
+
268
+ function activeContextMatches(entry, attempt) {
269
+ const activeId = String(entry?.active_activation_id || '');
270
+ const active = entry?.activations?.[activeId];
271
+ return activeId === String(attempt.activation_id || '')
272
+ && active?.status === 'active'
273
+ && nonNegativeInteger(active.epoch, -1) === nonNegativeInteger(attempt.activation_epoch, -1);
274
+ }
275
+
276
+ function storedAttempt(outcomeValue) {
277
+ const stored = {
278
+ v: 1,
279
+ memory_mode: 'v2',
280
+ canonical_session_id: String(outcomeValue.canonical_session_id || ''),
281
+ activation_id: String(outcomeValue.activation_id || ''),
282
+ activation_epoch: nonNegativeInteger(outcomeValue.activation_epoch, 0),
283
+ turn_id: String(outcomeValue.turn_id || ''),
284
+ turn_sequence: nonNegativeInteger(outcomeValue.turn_sequence, 0),
285
+ disposition: String(outcomeValue.disposition || 'applied'),
286
+ state: outcomeValue.state,
287
+ event_ids: Array.isArray(outcomeValue.event_ids) ? [...outcomeValue.event_ids] : [],
288
+ observed_at: String(outcomeValue.observed_at || DEFAULT_OBSERVED_AT),
289
+ };
290
+ if (outcomeValue.state === 'projected' && outcomeValue.checkpoint) {
291
+ stored.checkpoint = { ...outcomeValue.checkpoint };
292
+ }
293
+ if (outcomeValue.state === 'degraded' && outcomeValue.error) {
294
+ stored.error = String(outcomeValue.error);
295
+ }
296
+ return stored;
297
+ }
298
+
299
+ /** Persist a final outcome only while the exact staged activation/epoch/turn still owns it. */
300
+ export function recordStopMemoryOutcome(vaultBase, attempt, outcomeValue, overrides = {}) {
301
+ if (attempt?.memory_mode === 'legacy' || outcomeValue?.status === 'legacy') {
302
+ return { ...outcomeValue, persisted: false, reason: 'legacy' };
303
+ }
304
+ if (outcomeValue?.state === 'duplicate' || outcomeValue?.state === 'skipped') {
305
+ return { ...outcomeValue, persisted: false, reason: outcomeValue.state };
306
+ }
307
+ if (!sameAttempt(attempt, outcomeValue)) {
308
+ return { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
309
+ }
310
+ if (!['projected', 'degraded'].includes(outcomeValue?.state)) {
311
+ return { ...outcomeValue, persisted: false, reason: 'non-final-outcome' };
312
+ }
313
+
314
+ const deps = dependencies(overrides);
315
+ let result = { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
316
+ deps.mutateSessionRegistry(vaultBase, (registry) => {
317
+ const entry = registry.sessions?.[attempt.canonical_session_id];
318
+ if (!entry || !activeContextMatches(entry, attempt)) return result;
319
+ if (!sameAttempt(entry.last_memory_attempt, attempt)) return result;
320
+
321
+ entry.last_memory_attempt = storedAttempt(outcomeValue);
322
+ entry.memory_status = outcomeValue.state;
323
+ entry.memory_activation_id = attempt.activation_id;
324
+ if (outcomeValue.state === 'projected') {
325
+ entry.memory_checkpoint = { ...outcomeValue.checkpoint };
326
+ } else {
327
+ // This branch is reachable only after the exact attempt CAS above. A stale outcome
328
+ // cannot clear a checkpoint owned by a newer activation/turn.
329
+ delete entry.memory_checkpoint;
330
+ }
331
+ result = { ...outcomeValue, persisted: true, reason: 'recorded' };
332
+ return result;
333
+ });
334
+ return result;
335
+ }