wendkeep 0.58.0 → 0.58.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.en.md +28 -36
  3. package/README.md +28 -36
  4. package/docs/en/commands/changes-and-verification.md +79 -0
  5. package/docs/en/commands/costs-and-observability.md +65 -0
  6. package/docs/en/commands/getting-started.md +82 -0
  7. package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
  8. package/docs/en/commands/memory-migration.md +73 -0
  9. package/docs/en/commands/memory.md +84 -0
  10. package/docs/en/commands/notes-and-knowledge.md +70 -0
  11. package/docs/en/commands/retroactive-import.md +67 -0
  12. package/docs/en/commands/sessions-and-import.md +85 -0
  13. package/docs/en/commands/verify.md +86 -0
  14. package/docs/pt-BR/commands/changes-and-verification.md +80 -0
  15. package/docs/pt-BR/commands/costs-and-observability.md +65 -0
  16. package/docs/pt-BR/commands/getting-started.md +83 -0
  17. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
  18. package/docs/pt-BR/commands/memory-migration.md +73 -0
  19. package/docs/pt-BR/commands/memory.md +83 -0
  20. package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
  21. package/docs/pt-BR/commands/retroactive-import.md +67 -0
  22. package/docs/pt-BR/commands/sessions-and-import.md +85 -0
  23. package/docs/pt-BR/commands/verify.md +87 -0
  24. package/hooks/brain-inject.mjs +2 -1
  25. package/hooks/memory-mode.mjs +39 -0
  26. package/hooks/memory-schema.mjs +15 -0
  27. package/hooks/obsidian-common.mjs +80 -29
  28. package/hooks/session-ensure.mjs +15 -8
  29. package/hooks/session-memory-lifecycle.mjs +330 -0
  30. package/hooks/session-stop.mjs +122 -42
  31. package/hooks/vault-health.mjs +124 -5
  32. package/package.json +3 -1
  33. package/src/memory.mjs +32 -7
  34. package/src/taxonomy.mjs +1 -0
@@ -14,7 +14,13 @@ import { mutateSessionNote } from './session-note-io.mjs';
14
14
  import { applyDerivedSections, provenanceSessions } from './derived-sections.mjs';
15
15
  import { buildSessionMemoryEvents, collectLifecycleEvidence } from './memory-handoff.mjs';
16
16
  import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
17
+ import { detectMemoryMode } from './memory-mode.mjs';
17
18
  import { sanitizeMemoryText } from './memory-schema.mjs';
19
+ import {
20
+ projectStopMemoryAttempt,
21
+ recordStopMemoryOutcome,
22
+ stageStopMemoryAttempt,
23
+ } from './session-memory-lifecycle.mjs';
18
24
  import {
19
25
  ensureDir,
20
26
  findActiveSessionByTranscript,
@@ -526,6 +532,26 @@ export function parseTranscript(transcriptPath) {
526
532
  return parseCodexTranscript(transcriptPath);
527
533
  }
528
534
 
535
+ export function resolveTurnIdentity(transcript, requestedTurnId = '') {
536
+ const turns = Array.isArray(transcript?.turns) ? transcript.turns : [];
537
+ const requested = String(requestedTurnId || '');
538
+ let index = requested
539
+ ? turns.findIndex((turn) => String(turn?.turnId || '') === requested)
540
+ : -1;
541
+ if (requested && index < 0) return null;
542
+ if (index < 0 && transcript?.latestTurnId) {
543
+ index = turns.findIndex((turn) => String(turn?.turnId || '') === String(transcript.latestTurnId));
544
+ }
545
+ if (index < 0) index = turns.length - 1;
546
+ const turn = turns[index];
547
+ if (!turn?.turnId) return null;
548
+ return {
549
+ id: String(turn.turnId),
550
+ order: index + 1,
551
+ observedAt: String(turn.timestamp || ''),
552
+ };
553
+ }
554
+
529
555
  function compactText(text, max = 600) {
530
556
  const clean = redactSecrets(String(text || ''))
531
557
  .replace(/\r/g, '\n')
@@ -799,6 +825,9 @@ function shouldFinalizeSession() {
799
825
  }
800
826
 
801
827
  export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
828
+ if (detectMemoryMode(vaultBase).mode === 'legacy') {
829
+ return { status: 'legacy', eventCount: 0, eventIds: [], checkpoint: null };
830
+ }
802
831
  const events = buildSessionMemoryEvents(handoff);
803
832
  const eventIds = events.map((event) => event.event_id);
804
833
  try {
@@ -1091,7 +1120,18 @@ function pingObsidianVault(apiKey) {
1091
1120
  } catch {}
1092
1121
  }
1093
1122
 
1094
- function main() {
1123
+ export function shouldAbortStopAfterStaging(causalStop, memoryAttempt) {
1124
+ const rejectedByV2Revalidation = memoryAttempt?.memory_mode === 'v2'
1125
+ && memoryAttempt?.state === 'skipped';
1126
+ if (rejectedByV2Revalidation) return true;
1127
+ return Boolean(
1128
+ causalStop
1129
+ && !causalStop.canPromoteMemory
1130
+ && memoryAttempt?.state !== 'enqueued'
1131
+ );
1132
+ }
1133
+
1134
+ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1095
1135
  const input = readHookInput();
1096
1136
  if (input.stop_hook_active) {
1097
1137
  writeHookOutput({});
@@ -1128,15 +1168,34 @@ function main() {
1128
1168
  return;
1129
1169
  }
1130
1170
 
1131
- const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1132
- const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1171
+ const tx = parseTranscript(identity.transcriptPath || input.transcript_path || input.transcriptPath);
1172
+ const requestedTurnId = String(input.turn_id || input.turnId || '');
1133
1173
  const sessionId = identity.canonicalConversationId;
1134
1174
  const finalizing = shouldFinalizeSession();
1175
+ const turnIdentity = resolveTurnIdentity(tx, requestedTurnId);
1176
+ if (!turnIdentity) {
1177
+ if (finalizing) {
1178
+ const activeId = String(entry.active_activation_id || '');
1179
+ const active = entry.activations?.[activeId] || {};
1180
+ stageMemory(vaultBase, {
1181
+ sessionId,
1182
+ activationId: activeId,
1183
+ activationEpoch: Number(active.epoch || entry.activation_epoch || 0),
1184
+ turnId: requestedTurnId || 'unresolved-turn',
1185
+ turnSequence: Number(entry.last_turn_sequence || 0),
1186
+ disposition: 'ambiguous',
1187
+ observedAt: new Date(0).toISOString(),
1188
+ });
1189
+ }
1190
+ const message = 'wendkeep: Stop ambiguous; o turno solicitado não foi provado pelo transcript.';
1191
+ process.stderr.write(`[wendkeep] ${message}\n`);
1192
+ writeHookOutput({ systemMessage: message });
1193
+ return;
1194
+ }
1195
+ const turnId = turnIdentity.id;
1135
1196
  const now = finalizing ? new Date() : null;
1136
1197
  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);
1198
+ const stopTurnSequence = turnIdentity.order;
1140
1199
  const causalStop = finalizing
1141
1200
  ? mutateSessionRegistry(vaultBase, (registry) => {
1142
1201
  const activationId = resolveStopActivation(registry, {
@@ -1148,6 +1207,7 @@ function main() {
1148
1207
  const cas = applyStopActivation(registry, {
1149
1208
  session_id: sessionId,
1150
1209
  activation_id: activationId,
1210
+ turn_id: turnId,
1151
1211
  turn_sequence: stopTurnSequence,
1152
1212
  ended_at: endedAt,
1153
1213
  });
@@ -1172,8 +1232,44 @@ function main() {
1172
1232
  };
1173
1233
  })
1174
1234
  : 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.`;
1235
+ let memoryHandoff = null;
1236
+ let memoryAttempt = null;
1237
+ if (finalizing) {
1238
+ let projectId = '';
1239
+ try {
1240
+ projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '';
1241
+ } catch { /* the staging validator exposes an observable failure below */ }
1242
+ const finalSummary = sessionFinalSummary(tx);
1243
+ const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1244
+ changeSlug: entry.change_slug,
1245
+ summary: finalSummary,
1246
+ noteRel: sessionRel,
1247
+ });
1248
+ memoryHandoff = {
1249
+ projectId,
1250
+ identity,
1251
+ activation: {
1252
+ id: causalStop?.activationId || '',
1253
+ epoch: Number(causalStop?.activation?.epoch || entry.activation_epoch || 0),
1254
+ },
1255
+ turn: { id: turnId, sequence: stopTurnSequence },
1256
+ noteRel: sessionRel,
1257
+ observedAt: turnIdentity.observedAt || new Date(0).toISOString(),
1258
+ summary: finalSummary,
1259
+ evidence: memoryEvidence,
1260
+ };
1261
+ memoryAttempt = stageMemory(vaultBase, {
1262
+ handoff: memoryHandoff,
1263
+ disposition: causalStop?.stopDisposition || 'ambiguous',
1264
+ });
1265
+ }
1266
+ if (shouldAbortStopAfterStaging(causalStop, memoryAttempt)) {
1267
+ const disposition = memoryAttempt?.disposition || causalStop?.stopDisposition || 'ambiguous';
1268
+ if (disposition === 'duplicate' && memoryAttempt?.state === 'duplicate') {
1269
+ writeHookOutput({});
1270
+ return;
1271
+ }
1272
+ const message = `wendkeep: Stop ${disposition}; uma activation mais nova foi preservada e a memória não foi promovida.`;
1177
1273
  process.stderr.write(`[wendkeep] ${message}\n`);
1178
1274
  writeHookOutput({ systemMessage: message });
1179
1275
  return;
@@ -1248,40 +1344,24 @@ function main() {
1248
1344
  last_logged_turn_id: turnId,
1249
1345
  });
1250
1346
 
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;
1284
- });
1347
+ const memoryResult = projectStopMemoryAttempt(vaultBase, memoryAttempt);
1348
+ if (memoryResult.status === 'legacy') {
1349
+ mutateSessionRegistry(vaultBase, (registry) => {
1350
+ const current = registry.sessions[sessionId];
1351
+ const active = current?.activations?.[current.active_activation_id || ''];
1352
+ if (!current
1353
+ || current.active_activation_id !== memoryAttempt.activation_id
1354
+ || Number(active?.epoch || 0) !== Number(memoryAttempt.activation_epoch || 0)) return null;
1355
+ registry.sessions[sessionId] = {
1356
+ ...current,
1357
+ memory_status: 'legacy',
1358
+ memory_activation_id: memoryAttempt.activation_id,
1359
+ };
1360
+ return null;
1361
+ });
1362
+ } else {
1363
+ recordStopMemoryOutcome(vaultBase, memoryAttempt, memoryResult);
1364
+ }
1285
1365
 
1286
1366
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
1287
1367
  try {
@@ -12,6 +12,7 @@ import {
12
12
  } from './obsidian-common.mjs';
13
13
  import { getLocale } from './locale.mjs';
14
14
  import { parseSharedMemory, validateMemoryEvent } from './memory-schema.mjs';
15
+ import { detectMemoryMode, LEGACY_MEMORY_WARNING } from './memory-mode.mjs';
15
16
  import { reduceMemoryEvents } from './memory-store.mjs';
16
17
  import { validateMemoryBundle } from '../src/validate-memory.mjs';
17
18
 
@@ -101,7 +102,9 @@ const MEMORY_REPAIR_COMMAND = 'wendkeep memory repair --vault <vault>';
101
102
 
102
103
  function readJsonLines(path, label) {
103
104
  if (!existsSync(path)) return { items: [], errors: [] };
104
- const raw = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
105
+ let raw;
106
+ try { raw = readFileSync(path, 'utf8').replace(/\r\n/g, '\n'); }
107
+ catch (error) { return { items: [], errors: [`${label} ilegível: ${error?.message || error}`] }; }
105
108
  const lines = raw.endsWith('\n') ? raw.split('\n').slice(0, -1) : raw.split('\n');
106
109
  const items = [];
107
110
  const errors = [];
@@ -118,28 +121,137 @@ function readJsonLines(path, label) {
118
121
 
119
122
  function inspectOutbox(vaultBase, projectId) {
120
123
  const dir = join(vaultBase, '.brain', 'memory-outbox');
121
- if (!existsSync(dir)) return { count: 0, errors: [] };
124
+ if (!existsSync(dir)) return { count: 0, errors: [], eventIds: new Set() };
122
125
  const files = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
123
126
  const errors = [];
127
+ const eventIds = new Set();
124
128
  for (const name of files) {
125
129
  const path = join(dir, name);
126
130
  try {
127
131
  const event = JSON.parse(readFileSync(path, 'utf8'));
128
132
  const validation = validateMemoryEvent(event, projectId ? { projectId } : {});
129
133
  if (!validation.ok) errors.push(`${name}: ${validation.errors.join(' ')}`);
134
+ else eventIds.add(event.event_id);
130
135
  } catch (error) {
131
136
  errors.push(`${name}: JSON inválido: ${error.message}`);
132
137
  }
133
138
  }
134
- return { count: files.length, errors };
139
+ return { count: files.length, errors, eventIds };
140
+ }
141
+
142
+ function checkpointMatchesLedgerPrefix(checkpoint, eventIds, ledgerEvents) {
143
+ if (!checkpoint || typeof checkpoint !== 'object') return false;
144
+ if (!Number.isInteger(checkpoint.revision) || checkpoint.revision < 0) return false;
145
+ if (typeof checkpoint.event_cursor !== 'string' || !checkpoint.event_cursor) return false;
146
+ if (typeof checkpoint.state_hash !== 'string' || !checkpoint.state_hash) return false;
147
+
148
+ const cursorIndex = ledgerEvents.findIndex((event) => event?.event_id === checkpoint.event_cursor);
149
+ if (cursorIndex < 0) return false;
150
+ const prefix = ledgerEvents.slice(0, cursorIndex + 1);
151
+ const prefixIds = new Set(prefix.map((event) => event.event_id));
152
+ if (eventIds.some((eventId) => !prefixIds.has(eventId))) return false;
153
+
154
+ try {
155
+ const replay = reduceMemoryEvents(prefix);
156
+ return checkpoint.revision === replay.revision
157
+ && checkpoint.event_cursor === replay.eventCursor
158
+ && checkpoint.state_hash === replay.stateHash;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function checkMemoryAttempts(registry, { ledgerEvents = [], outboxEventIds = new Set() } = {}) {
165
+ const failures = [];
166
+ const warnings = [];
167
+ const ledgerEventIds = new Set(ledgerEvents.map((event) => event?.event_id).filter(Boolean));
168
+ const attempts = Object.values(registry?.sessions || {})
169
+ .map((entry) => entry?.last_memory_attempt)
170
+ .filter((attempt) => attempt && typeof attempt === 'object' && attempt.memory_mode === 'v2');
171
+
172
+ for (const attempt of attempts) {
173
+ const state = String(attempt.state || '');
174
+ const disposition = String(attempt.disposition || '');
175
+ const eventIds = Array.isArray(attempt.event_ids)
176
+ ? [...new Set(attempt.event_ids.filter((eventId) => typeof eventId === 'string' && eventId))]
177
+ : [];
178
+
179
+ if (state === 'skipped' && disposition === 'ambiguous') {
180
+ failures.push(`Lifecycle de memória v2 ambíguo: Stop pulou a publicação sem identidade causal suficiente. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
181
+ continue;
182
+ }
183
+
184
+ if (state === 'skipped' && ['stale_turn', 'superseded'].includes(disposition)) {
185
+ if (eventIds.length) {
186
+ failures.push(`Stop stale/superseded emitiu event_ids apesar da rejeição causal. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
187
+ } else {
188
+ warnings.push('Stop stale/superseded foi descartado sem publicar memória.');
189
+ }
190
+ continue;
191
+ }
192
+
193
+ if (disposition !== 'applied') {
194
+ if (state === 'skipped') warnings.push('Attempt de memória v2 foi descartado sem publicação.');
195
+ else failures.push(`Attempt de memória v2 possui disposition não reconhecida para o estado informado. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
196
+ continue;
197
+ }
198
+
199
+ if (!eventIds.length) {
200
+ failures.push(`Attempt v2 aplicado não declarou event_ids; publicação perdida. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
201
+ continue;
202
+ }
203
+
204
+ if (state === 'enqueued' || state === 'degraded') {
205
+ const missing = eventIds.filter((eventId) => !ledgerEventIds.has(eventId) && !outboxEventIds.has(eventId));
206
+ if (missing.length) {
207
+ failures.push(`Attempt v2 perdeu ${missing.length} evento(s): ausentes do ledger e da outbox. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
208
+ } else {
209
+ warnings.push(`Attempt de memória v2 ${state} permanece recuperável: ${eventIds.length} evento(s) durável(is) no ledger e/ou outbox.`);
210
+ }
211
+ continue;
212
+ }
213
+
214
+ if (state === 'projected') {
215
+ const outsideLedger = eventIds.filter((eventId) => !ledgerEventIds.has(eventId));
216
+ if (outsideLedger.length) {
217
+ failures.push(`Attempt projetado perdeu ${outsideLedger.length} evento(s) no ledger. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
218
+ } else if (!checkpointMatchesLedgerPrefix(attempt.checkpoint, eventIds, ledgerEvents)) {
219
+ failures.push(`Checkpoint do attempt projetado diverge do prefixo rederivado do ledger. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
220
+ }
221
+ continue;
222
+ }
223
+
224
+ failures.push(`Attempt de memória v2 possui state inválido. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
225
+ }
226
+
227
+ return { failures, warnings };
135
228
  }
136
229
 
137
230
  /**
138
231
  * Read-only consistency check for the local memory-v2 bundle. It intentionally
139
232
  * does not acquire MEMORY.lock or invoke the projector/repair paths.
140
233
  */
141
- export function checkMemoryBundle(vaultBase) {
234
+ export function checkMemoryBundle(vaultBase, { registry } = {}) {
142
235
  const brain = join(vaultBase, '.brain');
236
+ const mode = detectMemoryMode(vaultBase);
237
+ if (mode.mode === 'legacy') {
238
+ return {
239
+ ok: true,
240
+ status: 'legacy',
241
+ failures: [],
242
+ warnings: [LEGACY_MEMORY_WARNING],
243
+ metrics: {
244
+ schemaVersion: null,
245
+ revision: null,
246
+ eventCursor: null,
247
+ stateHash: null,
248
+ ledgerEvents: 0,
249
+ pendingOutbox: 0,
250
+ candidates: 0,
251
+ activeConflicts: 0,
252
+ },
253
+ };
254
+ }
143
255
  const bundle = validateMemoryBundle(vaultBase);
144
256
  const failures = [];
145
257
  const warnings = [];
@@ -185,6 +297,13 @@ export function checkMemoryBundle(vaultBase) {
185
297
  }
186
298
  }
187
299
 
300
+ const lifecycle = checkMemoryAttempts(registry || readSessionRegistry(vaultBase), {
301
+ ledgerEvents: bundle.ledger?.events || [],
302
+ outboxEventIds: outbox.eventIds,
303
+ });
304
+ failures.push(...lifecycle.failures);
305
+ warnings.push(...lifecycle.warnings);
306
+
188
307
  const unresolved = candidates.items.filter((item) => !['resolved', 'rejected', 'superseded'].includes(item?.status));
189
308
  const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
190
309
  const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
@@ -310,7 +429,7 @@ export function runVaultHealth({ vaultBase, session = '' }) {
310
429
  ];
311
430
  let memory = { status: 'legacy', metrics: {} };
312
431
  if (memoryMarkers.some((path) => existsSync(path))) {
313
- memory = checkMemoryBundle(vaultBase);
432
+ memory = checkMemoryBundle(vaultBase, { registry });
314
433
  failures.push(...memory.failures.map((item) => `Memória: ${item}`));
315
434
  warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
316
435
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.58.0",
3
+ "version": "0.58.3",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "src",
13
13
  "hooks",
14
14
  "schema",
15
+ "docs/pt-BR/commands/*.md",
16
+ "docs/en/commands/*.md",
15
17
  "README.md",
16
18
  "README.en.md",
17
19
  "CHANGELOG.md"
package/src/memory.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import {
3
- copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync,
3
+ copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync,
4
4
  } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { sanitizeMemoryText, renderSharedMemory, validateSharedMemory } from '../hooks/memory-schema.mjs';
7
7
  import {
8
8
  enqueueMemoryEvent, projectMemoryOutbox, repairMemoryLedger,
@@ -75,7 +75,11 @@ export function memoryStatus(vault) {
75
75
  return checkMemoryBundle(vault);
76
76
  }
77
77
 
78
- export function migrateMemory(vault, { apply = false, validateBundle = validateMemoryBundle } = {}) {
78
+ export function migrateMemory(vault, {
79
+ apply = false,
80
+ validateBundle = validateMemoryBundle,
81
+ publishArtifact = writeFileAtomic,
82
+ } = {}) {
79
83
  projectId(vault);
80
84
  const sharedPath = brainPath(vault, SHARED);
81
85
  const hadShared = existsSync(sharedPath);
@@ -95,16 +99,37 @@ export function migrateMemory(vault, { apply = false, validateBundle = validateM
95
99
  const sharedValidation = validateSharedMemory(emptyShared, { eventIds: new Set() });
96
100
  if (!sharedValidation.ok) throw new Error(`Migração inválida: ${sharedValidation.errors.join(' ')}`);
97
101
  if (backupPath && !existsSync(backupPath)) copyFileSync(sharedPath, backupPath);
102
+
103
+ // Build and validate a complete candidate vault away from the live paths. This makes
104
+ // the validation callback incapable of observing a half-published live bundle.
105
+ const stagingVault = mkdtempSync(join(dirname(vault), '.wendkeep-memory-stage-'));
106
+ const stagingBrain = join(stagingVault, BRAIN);
107
+ let stagedValidation;
108
+ try {
109
+ mkdirSync(stagingBrain, { recursive: true });
110
+ copyFileSync(brainPath(vault, 'CORE.md'), join(stagingBrain, 'CORE.md'));
111
+ copyFileSync(brainPath(vault, 'PROJECT.json'), join(stagingBrain, 'PROJECT.json'));
112
+ writeFileSync(join(stagingBrain, LEDGER), '', 'utf8');
113
+ writeFileSync(join(stagingBrain, SHARED), emptyShared, 'utf8');
114
+ writeFileSync(join(stagingBrain, CANDIDATES), candidateText(candidates), 'utf8');
115
+ stagedValidation = validateBundle(stagingVault);
116
+ if (!stagedValidation.ok) {
117
+ throw new Error(`Bundle migrado inválido: ${(stagedValidation.errors || []).join(' ')}`);
118
+ }
119
+ } finally {
120
+ rmSync(stagingVault, { recursive: true, force: true });
121
+ }
122
+
98
123
  const targets = [LEDGER, SHARED, CANDIDATES].map((name) => brainPath(vault, name));
99
124
  const before = new Map(targets.map((path) => [path, {
100
125
  existed: existsSync(path),
101
126
  content: existsSync(path) ? readFileSync(path, 'utf8') : '',
102
127
  }]));
103
128
  try {
104
- if (!existsSync(brainPath(vault, LEDGER))) writeFileAtomic(brainPath(vault, LEDGER), '');
105
- writeFileAtomic(sharedPath, emptyShared);
106
- writeFileAtomic(brainPath(vault, CANDIDATES), candidateText(candidates));
107
- const validation = validateBundle(vault);
129
+ if (!existsSync(brainPath(vault, LEDGER))) publishArtifact(brainPath(vault, LEDGER), '');
130
+ publishArtifact(sharedPath, emptyShared);
131
+ publishArtifact(brainPath(vault, CANDIDATES), candidateText(candidates));
132
+ const validation = validateMemoryBundle(vault);
108
133
  if (!validation.ok) throw new Error(`Bundle migrado inválido: ${validation.errors.join(' ')}`);
109
134
  return { status: 'migrated', alreadyV2: false, candidates: candidates.length, backupPath, validation };
110
135
  } catch (error) {
package/src/taxonomy.mjs CHANGED
@@ -37,6 +37,7 @@ export const HOOK_FILES = [
37
37
  'memory-schema.mjs',
38
38
  'memory-store.mjs',
39
39
  'memory-handoff.mjs',
40
+ 'session-memory-lifecycle.mjs',
40
41
  'change-core.mjs',
41
42
  'spec-core.mjs',
42
43
  'sensors-core.mjs',