wendkeep 0.58.3 → 0.60.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 (77) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.en.md +45 -3
  3. package/README.md +45 -3
  4. package/bin/wendkeep.mjs +54 -6
  5. package/docs/en/commands/changes-and-verification.md +9 -3
  6. package/docs/en/commands/getting-started.md +7 -3
  7. package/docs/en/commands/memory.md +20 -2
  8. package/docs/en/commands/operating-profiles.md +173 -0
  9. package/docs/en/commands/sessions-and-import.md +8 -4
  10. package/docs/en/commands/verify.md +12 -6
  11. package/docs/pt-BR/commands/changes-and-verification.md +9 -4
  12. package/docs/pt-BR/commands/getting-started.md +7 -3
  13. package/docs/pt-BR/commands/memory.md +18 -2
  14. package/docs/pt-BR/commands/operating-profiles.md +171 -0
  15. package/docs/pt-BR/commands/sessions-and-import.md +7 -3
  16. package/docs/pt-BR/commands/verify.md +11 -5
  17. package/hooks/brain-core.mjs +159 -159
  18. package/hooks/brain-inject.mjs +83 -26
  19. package/hooks/brain-recall.mjs +32 -32
  20. package/hooks/brain-reindex.mjs +13 -13
  21. package/hooks/change-context.mjs +24 -10
  22. package/hooks/change-core.mjs +174 -37
  23. package/hooks/change-guard.mjs +115 -16
  24. package/hooks/change-nag.mjs +20 -5
  25. package/hooks/change-warn.mjs +27 -9
  26. package/hooks/decision-capture.mjs +1 -1
  27. package/hooks/derived-sections.mjs +1 -1
  28. package/hooks/flow-core.mjs +891 -0
  29. package/hooks/flow-protected-policy.mjs +218 -0
  30. package/hooks/frontmatter-repair.mjs +3 -1
  31. package/hooks/git-snapshot.mjs +722 -0
  32. package/hooks/import-sessions.mjs +10 -5
  33. package/hooks/memory-mode.mjs +63 -13
  34. package/hooks/memory-store.mjs +309 -69
  35. package/hooks/obsidian-common.mjs +39 -55
  36. package/hooks/operating-profile-runtime.mjs +157 -0
  37. package/hooks/plan-capture.mjs +14 -3
  38. package/hooks/sensors-core.mjs +15 -3
  39. package/hooks/session-backfill.mjs +7 -2
  40. package/hooks/session-ensure.mjs +6 -4
  41. package/hooks/session-iteration.mjs +65 -0
  42. package/hooks/session-memory-lifecycle.mjs +10 -5
  43. package/hooks/session-note-io.mjs +130 -15
  44. package/hooks/session-observability.mjs +4 -2
  45. package/hooks/session-stop.mjs +65 -19
  46. package/hooks/spec-core.mjs +91 -12
  47. package/hooks/subagent-stop.mjs +4 -1
  48. package/hooks/subagent-usage.mjs +2 -2
  49. package/hooks/task-log.mjs +3 -1
  50. package/hooks/token-usage.mjs +1 -1
  51. package/hooks/vault-health.mjs +183 -37
  52. package/hooks/vault-path-safety.mjs +2 -0
  53. package/hooks/vault-runtime-store.mjs +558 -0
  54. package/package.json +10 -3
  55. package/packages/cli/package.json +5 -0
  56. package/packages/harness/package.json +5 -0
  57. package/packages/integrations/package.json +5 -0
  58. package/packages/mcp/package.json +5 -0
  59. package/packages/pi/package.json +5 -0
  60. package/packages/vault/package.json +6 -0
  61. package/packages/vault/src/index.mjs +2 -0
  62. package/packages/vault/src/project-vault.mjs +327 -0
  63. package/packages/vault/src/vault-path-safety.mjs +558 -0
  64. package/src/change.mjs +2 -1
  65. package/src/flow.mjs +232 -0
  66. package/src/init.mjs +26 -3
  67. package/src/memory.mjs +785 -35
  68. package/src/operating-profile.mjs +133 -0
  69. package/src/profile.mjs +224 -0
  70. package/src/project-vault.mjs +2 -221
  71. package/src/rebuild-costs.mjs +11 -4
  72. package/src/skills-seed.mjs +38 -16
  73. package/src/sync-defs.mjs +16 -7
  74. package/src/sync.mjs +9 -1
  75. package/src/taxonomy.mjs +8 -0
  76. package/src/validate-memory.mjs +21 -8
  77. package/src/verify.mjs +12 -2
package/src/memory.mjs CHANGED
@@ -1,14 +1,24 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import {
3
- copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync,
3
+ constants as fsConstants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
4
+ rmSync, writeFileSync,
4
5
  } from 'node:fs';
5
6
  import { dirname, join } from 'node:path';
6
7
  import { sanitizeMemoryText, renderSharedMemory, validateSharedMemory } from '../hooks/memory-schema.mjs';
7
8
  import {
8
- enqueueMemoryEvent, projectMemoryOutbox, repairMemoryLedger,
9
+ canonicalMemoryJson, deriveMemoryProjection, enqueueMemoryEvent, projectMemoryOutbox,
10
+ MEMORY_LOCK_BUSY, prepareMemoryProjection, publishMemoryProjection, readMemoryLedger,
11
+ repairMemoryLedger, withMemoryLock,
9
12
  } from '../hooks/memory-store.mjs';
10
- import { writeFileAtomic } from '../hooks/session-note-io.mjs';
11
- import { validateMemoryBundle } from './validate-memory.mjs';
13
+ import { mutateSessionRegistry, readSessionRegistry, registryPath } from '../hooks/obsidian-common.mjs';
14
+ import {
15
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
16
+ writeVaultFileAtomic,
17
+ } from '../hooks/vault-path-safety.mjs';
18
+ import {
19
+ readLedgerForValidation, readProjectForValidation, validateMemoryBundle,
20
+ } from './validate-memory.mjs';
21
+ import { validateCore } from './validate-core.mjs';
12
22
  import { checkMemoryBundle } from '../hooks/vault-health.mjs';
13
23
 
14
24
  const BRAIN = '.brain';
@@ -18,9 +28,69 @@ const CANDIDATES = 'MEMORY_CANDIDATES.jsonl';
18
28
 
19
29
  function brainPath(vault, name) { return join(vault, BRAIN, name); }
20
30
  function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
31
+ function byteHash(value) { return createHash('sha256').update(value).digest('hex'); }
32
+
33
+ function checkedVaultFile(vault, path, label, {
34
+ allowMissing = true, mustNotExist = false,
35
+ } = {}) {
36
+ return assertVaultPathSafe(vault, path, {
37
+ allowMissing, expectedType: 'file', mustNotExist, label,
38
+ });
39
+ }
40
+
41
+ function readVaultFile(vault, path, encoding, label, { allowMissing = false } = {}) {
42
+ let checked = checkedVaultFile(vault, path, label, { allowMissing });
43
+ if (!checked.exists) return null;
44
+ checked = checkedVaultFile(vault, checked.target, label, { allowMissing: false });
45
+ return readFileSync(checked.target, encoding);
46
+ }
47
+
48
+ function snapshotVaultFile(vault, path, label, encoding = undefined) {
49
+ const checked = checkedVaultFile(vault, path, label);
50
+ return checked.exists
51
+ ? { exists: true, bytes: readVaultFile(vault, checked.target, encoding, label) }
52
+ : { exists: false, bytes: null };
53
+ }
54
+
55
+ function copyVaultFileToExternal(vault, sourcePath, destinationPath, label) {
56
+ let source = checkedVaultFile(vault, sourcePath, label, { allowMissing: false });
57
+ source = checkedVaultFile(vault, source.target, label, { allowMissing: false });
58
+ copyFileSync(source.target, destinationPath, fsConstants.COPYFILE_EXCL);
59
+ }
60
+
61
+ function copyVaultFileExclusive(vault, sourcePath, destinationPath, label) {
62
+ checkedVaultFile(vault, sourcePath, `origem de ${label}`, { allowMissing: false });
63
+ checkedVaultFile(vault, destinationPath, `destino de ${label}`, { mustNotExist: true });
64
+ const source = checkedVaultFile(vault, sourcePath, `origem de ${label}`, { allowMissing: false });
65
+ const destination = checkedVaultFile(vault, destinationPath, `destino de ${label}`, {
66
+ mustNotExist: true,
67
+ });
68
+ copyFileSync(source.target, destination.target, fsConstants.COPYFILE_EXCL);
69
+ checkedVaultFile(vault, destination.target, `destino de ${label}`, { allowMissing: false });
70
+ }
71
+
72
+ function writeVaultArtifact(vault, path, content, label, publisher = null) {
73
+ const checked = checkedVaultFile(vault, path, label);
74
+ if (!publisher) {
75
+ writeVaultFileAtomic(vault, checked.target, content, 'utf8', { label });
76
+ return;
77
+ }
78
+ // Test/fault-injection publishers keep their old (path, content) contract, but may
79
+ // only run after the real Vault target has been checked immediately before mutation.
80
+ checkedVaultFile(vault, checked.target, label);
81
+ publisher(checked.target, content);
82
+ checkedVaultFile(vault, checked.target, label, { allowMissing: false });
83
+ }
84
+
85
+ function restoreVaultFile(vault, path, snapshot, label) {
86
+ if (snapshot.exists) writeVaultFileAtomic(vault, path, snapshot.bytes, 'utf8', { label });
87
+ else unlinkVaultFile(vault, path, { label });
88
+ }
21
89
 
22
90
  function projectId(vault) {
23
- const marker = JSON.parse(readFileSync(brainPath(vault, 'PROJECT.json'), 'utf8'));
91
+ const marker = JSON.parse(readVaultFile(
92
+ vault, brainPath(vault, 'PROJECT.json'), 'utf8', 'autoridade PROJECT.json',
93
+ ));
24
94
  if (!marker?.projectId) throw new Error('PROJECT.json inválido: projectId ausente.');
25
95
  return marker.projectId;
26
96
  }
@@ -54,17 +124,21 @@ function candidateText(candidates) {
54
124
 
55
125
  export function seedMemoryV2(vault) {
56
126
  const brain = join(vault, BRAIN);
57
- mkdirSync(brain, { recursive: true });
127
+ mkdirVaultPath(vault, brain, { label: 'raiz .brain da memória' });
58
128
  const created = [];
59
129
  const artifacts = [
60
130
  [LEDGER, ''],
61
131
  [CANDIDATES, ''],
62
132
  [SHARED, renderSharedMemory()],
63
133
  ];
64
- for (const [name, content] of artifacts) {
65
- const path = join(brain, name);
66
- if (!existsSync(path)) {
67
- writeFileSync(path, content, 'utf8');
134
+ const checked = assertVaultPathsSafe(vault, artifacts.map(([name]) => ({
135
+ path: join(brain, name), expectedType: 'file', label: `artefato inicial ${name}`,
136
+ })));
137
+ for (let index = 0; index < artifacts.length; index += 1) {
138
+ const [name, content] = artifacts[index];
139
+ const path = checked[index].target;
140
+ if (!checked[index].exists) {
141
+ writeVaultFileAtomic(vault, path, content, 'utf8', { label: `artefato inicial ${name}` });
68
142
  created.push(name);
69
143
  }
70
144
  }
@@ -78,12 +152,13 @@ export function memoryStatus(vault) {
78
152
  export function migrateMemory(vault, {
79
153
  apply = false,
80
154
  validateBundle = validateMemoryBundle,
81
- publishArtifact = writeFileAtomic,
155
+ publishArtifact = null,
82
156
  } = {}) {
83
157
  projectId(vault);
84
158
  const sharedPath = brainPath(vault, SHARED);
85
- const hadShared = existsSync(sharedPath);
86
- const legacy = hadShared ? readFileSync(sharedPath, 'utf8') : '';
159
+ const shared = checkedVaultFile(vault, sharedPath, 'SHARED legado');
160
+ const hadShared = shared.exists;
161
+ const legacy = hadShared ? readVaultFile(vault, shared.target, 'utf8', 'SHARED legado') : '';
87
162
  const parsed = validateSharedMemory(legacy);
88
163
  const alreadyV2 = parsed.ok && parsed.metadata.schema_version === 2;
89
164
  const candidates = alreadyV2 ? [] : legacyCandidates(legacy);
@@ -98,7 +173,10 @@ export function migrateMemory(vault, {
98
173
  const emptyShared = renderSharedMemory();
99
174
  const sharedValidation = validateSharedMemory(emptyShared, { eventIds: new Set() });
100
175
  if (!sharedValidation.ok) throw new Error(`Migração inválida: ${sharedValidation.errors.join(' ')}`);
101
- if (backupPath && !existsSync(backupPath)) copyFileSync(sharedPath, backupPath);
176
+ if (backupPath) {
177
+ const backup = checkedVaultFile(vault, backupPath, 'backup do SHARED legado');
178
+ if (!backup.exists) copyVaultFileExclusive(vault, sharedPath, backupPath, 'backup do SHARED legado');
179
+ }
102
180
 
103
181
  // Build and validate a complete candidate vault away from the live paths. This makes
104
182
  // the validation callback incapable of observing a half-published live bundle.
@@ -107,8 +185,12 @@ export function migrateMemory(vault, {
107
185
  let stagedValidation;
108
186
  try {
109
187
  mkdirSync(stagingBrain, { recursive: true });
110
- copyFileSync(brainPath(vault, 'CORE.md'), join(stagingBrain, 'CORE.md'));
111
- copyFileSync(brainPath(vault, 'PROJECT.json'), join(stagingBrain, 'PROJECT.json'));
188
+ copyVaultFileToExternal(
189
+ vault, brainPath(vault, 'CORE.md'), join(stagingBrain, 'CORE.md'), 'autoridade CORE.md',
190
+ );
191
+ copyVaultFileToExternal(
192
+ vault, brainPath(vault, 'PROJECT.json'), join(stagingBrain, 'PROJECT.json'), 'autoridade PROJECT.json',
193
+ );
112
194
  writeFileSync(join(stagingBrain, LEDGER), '', 'utf8');
113
195
  writeFileSync(join(stagingBrain, SHARED), emptyShared, 'utf8');
114
196
  writeFileSync(join(stagingBrain, CANDIDATES), candidateText(candidates), 'utf8');
@@ -121,21 +203,27 @@ export function migrateMemory(vault, {
121
203
  }
122
204
 
123
205
  const targets = [LEDGER, SHARED, CANDIDATES].map((name) => brainPath(vault, name));
124
- const before = new Map(targets.map((path) => [path, {
125
- existed: existsSync(path),
126
- content: existsSync(path) ? readFileSync(path, 'utf8') : '',
127
- }]));
206
+ const before = new Map(targets.map((path) => [path, snapshotVaultFile(
207
+ vault, path, `snapshot de ${path}`, 'utf8',
208
+ )]));
209
+ assertVaultPathsSafe(vault, targets.map((path) => ({
210
+ path, expectedType: 'file', label: `target de migração ${path}`,
211
+ })));
128
212
  try {
129
- if (!existsSync(brainPath(vault, LEDGER))) publishArtifact(brainPath(vault, LEDGER), '');
130
- publishArtifact(sharedPath, emptyShared);
131
- publishArtifact(brainPath(vault, CANDIDATES), candidateText(candidates));
213
+ if (!before.get(brainPath(vault, LEDGER)).exists) {
214
+ writeVaultArtifact(vault, brainPath(vault, LEDGER), '', 'ledger migrado', publishArtifact);
215
+ }
216
+ writeVaultArtifact(vault, sharedPath, emptyShared, 'SHARED migrado', publishArtifact);
217
+ writeVaultArtifact(
218
+ vault, brainPath(vault, CANDIDATES), candidateText(candidates),
219
+ 'candidates migrados', publishArtifact,
220
+ );
132
221
  const validation = validateMemoryBundle(vault);
133
222
  if (!validation.ok) throw new Error(`Bundle migrado inválido: ${validation.errors.join(' ')}`);
134
223
  return { status: 'migrated', alreadyV2: false, candidates: candidates.length, backupPath, validation };
135
224
  } catch (error) {
136
225
  for (const [path, snapshot] of before) {
137
- if (snapshot.existed) writeFileAtomic(path, snapshot.content);
138
- else if (existsSync(path)) unlinkSync(path);
226
+ restoreVaultFile(vault, path, snapshot, `rollback da migração ${path}`);
139
227
  }
140
228
  throw error;
141
229
  }
@@ -143,8 +231,10 @@ export function migrateMemory(vault, {
143
231
 
144
232
  function readCandidates(vault) {
145
233
  const path = brainPath(vault, CANDIDATES);
146
- if (!existsSync(path)) return [];
147
- return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
234
+ const checked = checkedVaultFile(vault, path, 'MEMORY_CANDIDATES.jsonl');
235
+ if (!checked.exists) return [];
236
+ return readVaultFile(vault, checked.target, 'utf8', 'MEMORY_CANDIDATES.jsonl')
237
+ .split('\n').filter(Boolean).map((line) => JSON.parse(line));
148
238
  }
149
239
 
150
240
  export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
@@ -173,15 +263,595 @@ export function decideMemoryCandidate(vault, { action, candidateId, value } = {}
173
263
  const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
174
264
  const projected = readCandidates(vault);
175
265
  const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
176
- writeFileAtomic(brainPath(vault, CANDIDATES), candidateText([...merged.values()]));
266
+ writeVaultFileAtomic(
267
+ vault, brainPath(vault, CANDIDATES), candidateText([...merged.values()]), 'utf8',
268
+ { label: 'candidates após decisão humana' },
269
+ );
177
270
  return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
178
271
  }
179
272
 
180
- export function repairMemory(vault) {
273
+ function cloneJson(value) {
274
+ return value == null ? value : JSON.parse(JSON.stringify(value));
275
+ }
276
+
277
+ function sameCheckpoint(left, right) {
278
+ return Boolean(left && right) && canonicalMemoryJson(left) === canonicalMemoryJson(right);
279
+ }
280
+
281
+ function normalizeReconciliationRequest({ sessionId, bySessionId, reason } = {}) {
282
+ const normalizedSessionId = String(sessionId || '').trim();
283
+ const normalizedBySessionId = String(bySessionId || '').trim();
284
+ const normalizedReason = sanitizeMemoryText(reason || '').trim().slice(0, 500);
285
+ if (!normalizedSessionId) throw new TypeError('sessionId é obrigatório para reconciliar attempt ambíguo.');
286
+ if (!normalizedBySessionId) throw new TypeError('bySessionId é obrigatório para reconciliar attempt ambíguo.');
287
+ if (!normalizedReason) throw new TypeError('reason é obrigatório para reconciliar attempt ambíguo.');
288
+ if (normalizedSessionId === normalizedBySessionId) {
289
+ throw new TypeError('A sessão reconciliadora deve ser diferente da sessão superseded.');
290
+ }
291
+ return { sessionId: normalizedSessionId, bySessionId: normalizedBySessionId, reason: normalizedReason };
292
+ }
293
+
294
+ function attemptFingerprint(attempt) {
295
+ return hash(canonicalMemoryJson(attempt || null));
296
+ }
297
+
298
+ function matchingAppliedReconciliation(entry, request) {
299
+ const attempt = entry?.last_memory_attempt;
300
+ if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'skipped' || attempt?.disposition !== 'superseded') return null;
301
+ if (attempt.reconciled_by_session_id !== request.bySessionId
302
+ || attempt.reconciliation_reason !== request.reason) return null;
303
+ return (Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : [])
304
+ .find((item) => item?.type === 'ambiguous_attempt_superseded'
305
+ && item.reconciled_by_session_id === request.bySessionId
306
+ && item.reason === request.reason) || null;
307
+ }
308
+
309
+ function validationError(label, result) {
310
+ const details = (result?.errors || []).join(' ');
311
+ return new Error(`${label} inválido para reconciliação${details ? `: ${details}` : '.'}`);
312
+ }
313
+
314
+ function readMemoryAuthority(vault) {
315
+ assertVaultPathsSafe(vault, [
316
+ ['PROJECT.json', 'autoridade PROJECT.json'],
317
+ ['CORE.md', 'autoridade CORE.md'],
318
+ [LEDGER, 'autoridade MEMORY_EVENTS.jsonl'],
319
+ ].map(([name, label]) => ({
320
+ path: brainPath(vault, name), expectedType: 'file', label,
321
+ })));
322
+ const project = readProjectForValidation(vault);
323
+ if (!project.ok) throw validationError('PROJECT.json', project);
324
+
325
+ const projectBytes = readVaultFile(vault, project.path, undefined, 'autoridade PROJECT.json');
326
+ const corePath = brainPath(vault, 'CORE.md');
327
+ let coreBytes;
328
+ try {
329
+ coreBytes = readVaultFile(vault, corePath, undefined, 'autoridade CORE.md');
330
+ } catch (error) {
331
+ throw new Error(`CORE.md ausente ou ilegível para reconciliação: ${error?.message || error}`);
332
+ }
333
+ const core = validateCore(coreBytes.toString('utf8'));
334
+ if (!core.ok) throw validationError('CORE.md', core);
335
+
336
+ const ledgerPath = brainPath(vault, LEDGER);
337
+ let ledgerBefore;
338
+ try {
339
+ ledgerBefore = readVaultFile(vault, ledgerPath, undefined, 'autoridade MEMORY_EVENTS.jsonl');
340
+ } catch (error) {
341
+ throw new Error(`MEMORY_EVENTS.jsonl ausente ou ilegível para reconciliação: ${error?.message || error}`);
342
+ }
343
+ const ledger = readLedgerForValidation(vault, { projectId: project.projectId });
344
+ if (!ledger.ok) throw validationError('Ledger', ledger);
345
+ const ledgerAfter = readVaultFile(vault, ledgerPath, undefined, 'autoridade MEMORY_EVENTS.jsonl');
346
+ if (!ledgerBefore.equals(ledgerAfter)) {
347
+ throw new Error('Snapshot do ledger mudou durante o preflight de reconciliação.');
348
+ }
349
+
350
+ return {
351
+ projectId: project.projectId,
352
+ projectHash: byteHash(projectBytes),
353
+ coreHash: byteHash(coreBytes),
354
+ ledgerHash: byteHash(ledgerAfter),
355
+ ledgerEvents: ledger.events,
356
+ ledgerById: new Map(ledger.events.map((event) => [event.event_id, event])),
357
+ };
358
+ }
359
+
360
+ function assertAuthorityMatches(expected, actual) {
361
+ const changed = [
362
+ ['PROJECT.json', 'projectHash'],
363
+ ['CORE.md', 'coreHash'],
364
+ ['ledger', 'ledgerHash'],
365
+ ].find(([, key]) => expected?.[key] !== actual?.[key]);
366
+ if (expected?.projectId !== actual?.projectId || changed) {
367
+ const label = expected?.projectId !== actual?.projectId ? 'PROJECT.json' : changed[0];
368
+ throw new Error(`Snapshot de autoridade mudou após o preflight (${label}); reconciliação abortada.`);
369
+ }
370
+ }
371
+
372
+ function proofValue(value) {
373
+ return typeof value === 'string' ? value.trim() : value;
374
+ }
375
+
376
+ function validateSuccessorProof(request, attempt, authority) {
377
+ if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
378
+ || attempt?.disposition !== 'applied') {
379
+ throw new Error(`Sessão reconciliadora ${request.bySessionId} não possui attempt projected/applied.`);
380
+ }
381
+ if (!Array.isArray(attempt.event_ids) || attempt.event_ids.length === 0) {
382
+ throw new Error(`Attempt da sessão reconciliadora ${request.bySessionId} não possui event_ids.`);
383
+ }
384
+ if (new Set(attempt.event_ids).size !== attempt.event_ids.length) {
385
+ throw new Error('Identidade causal inválida: successor attempt possui event_ids duplicados.');
386
+ }
387
+
388
+ const expected = {
389
+ canonical_session_id: request.bySessionId,
390
+ activation_id: proofValue(attempt.activation_id),
391
+ activation_epoch: attempt.activation_epoch,
392
+ source_turn_id: proofValue(attempt.turn_id),
393
+ turn_sequence: attempt.turn_sequence,
394
+ };
395
+ if (proofValue(attempt.canonical_session_id) !== request.bySessionId) {
396
+ throw new Error(`Identidade causal inválida: canonical_session_id do successor attempt deve ser ${request.bySessionId}.`);
397
+ }
398
+ if (!expected.activation_id) throw new Error('Identidade causal inválida: activation_id ausente no successor attempt.');
399
+ if (!Number.isInteger(expected.activation_epoch) || expected.activation_epoch < 0) {
400
+ throw new Error('Identidade causal inválida: activation_epoch do successor attempt deve ser inteiro não negativo.');
401
+ }
402
+ if (!expected.source_turn_id) throw new Error('Identidade causal inválida: turn_id ausente no successor attempt.');
403
+ if (!Number.isInteger(expected.turn_sequence) || expected.turn_sequence < 0) {
404
+ throw new Error('Identidade causal inválida: turn_sequence do successor attempt deve ser inteiro não negativo.');
405
+ }
406
+
407
+ const ledgerById = authority.ledgerById
408
+ || new Map(authority.ledgerEvents.map((event) => [event.event_id, event]));
409
+ const missing = attempt.event_ids.filter((eventId) => !ledgerById.has(eventId));
410
+ if (missing.length) {
411
+ throw new Error('Attempt da sessão reconciliadora referencia event_ids ausentes do ledger.');
412
+ }
413
+ for (const eventId of attempt.event_ids) {
414
+ const event = ledgerById.get(eventId);
415
+ for (const [field, expectedValue] of Object.entries(expected)) {
416
+ if (event?.[field] !== expectedValue) {
417
+ throw new Error(`Identidade causal inválida no evento ${eventId}: ${field} não corresponde ao successor attempt.`);
418
+ }
419
+ }
420
+ }
421
+
422
+ return {
423
+ v: 1,
424
+ project_id: authority.projectId,
425
+ registry_session_id: request.bySessionId,
426
+ canonical_session_id: request.bySessionId,
427
+ activation_id: expected.activation_id,
428
+ activation_epoch: expected.activation_epoch,
429
+ turn_id: expected.source_turn_id,
430
+ turn_sequence: expected.turn_sequence,
431
+ required_event_ids: [...attempt.event_ids],
432
+ expected_ledger_sha256: authority.ledgerHash,
433
+ expected_core_sha256: authority.coreHash,
434
+ };
435
+ }
436
+
437
+ function assertStoredProofMatches(stored, current) {
438
+ if (!stored || typeof stored !== 'object') {
439
+ throw new Error('Audit matching inválido: causal_proof ausente; retry idempotente recusado.');
440
+ }
441
+ const causalFields = [
442
+ 'v', 'project_id', 'registry_session_id', 'canonical_session_id', 'activation_id',
443
+ 'activation_epoch', 'turn_id', 'turn_sequence', 'required_event_ids',
444
+ ];
445
+ const select = (proof) => Object.fromEntries(causalFields.map((field) => [field, proof?.[field]]));
446
+ const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
447
+ if (!validHash(stored.expected_ledger_sha256) || !validHash(stored.expected_core_sha256)
448
+ || canonicalMemoryJson(select(stored)) !== canonicalMemoryJson(select(current))) {
449
+ throw new Error('Prova causal armazenada diverge da identidade, dos event_ids ou dos hashes autorizados.');
450
+ }
451
+ }
452
+
453
+ function assertHealthyReconciledBundle(vault, registry) {
454
+ const health = checkMemoryBundle(vault, { registry });
455
+ if (!health.ok || health.status === 'blocked') {
456
+ throw new Error(`Memory health bloqueado durante reconciliação: ${(health.failures || []).join(' ')}`);
457
+ }
458
+ return health;
459
+ }
460
+
461
+ function assertPublishedProjection(vault, projection) {
462
+ const shared = readVaultFile(
463
+ vault, brainPath(vault, SHARED), 'utf8', 'projeção SHARED_MEMORY.md publicada',
464
+ );
465
+ const candidates = readVaultFile(
466
+ vault, brainPath(vault, CANDIDATES), 'utf8', 'projeção MEMORY_CANDIDATES.jsonl publicada',
467
+ );
468
+ if (shared !== projection.sharedContent || candidates !== projection.candidatesContent) {
469
+ throw new Error('Publicação derivada divergiu dos bytes preparados sob MEMORY.lock.');
470
+ }
471
+ const bundle = validateMemoryBundle(vault);
472
+ if (!bundle.ok) throw new Error(`Bundle reprojetado inválido: ${bundle.errors.join(' ')}`);
473
+ }
474
+
475
+ function prepareReconciliation(vault, rawRequest, { expectedAuthority } = {}) {
476
+ const request = normalizeReconciliationRequest(rawRequest);
477
+ const authority = readMemoryAuthority(vault);
478
+ if (expectedAuthority) assertAuthorityMatches(expectedAuthority, authority);
479
+ const registry = readSessionRegistry(vault);
480
+ const target = registry.sessions[request.sessionId];
481
+ const successor = registry.sessions[request.bySessionId];
482
+ if (!target) throw new Error(`Sessão não encontrada para reconciliação: ${request.sessionId}`);
483
+ if (!successor) throw new Error(`Sessão reconciliadora não encontrada: ${request.bySessionId}`);
484
+
485
+ const priorAudit = matchingAppliedReconciliation(target, request);
486
+ if (priorAudit) {
487
+ const proof = validateSuccessorProof(request, successor.last_memory_attempt, authority);
488
+ assertStoredProofMatches(priorAudit.causal_proof, proof);
489
+ const health = assertHealthyReconciledBundle(vault, registry);
490
+ return {
491
+ request,
492
+ authority,
493
+ alreadyApplied: true,
494
+ health,
495
+ reconciliationId: priorAudit.reconciliation_id || null,
496
+ };
497
+ }
498
+
499
+ const targetAttempt = target.last_memory_attempt;
500
+ if (targetAttempt?.memory_mode !== 'v2' || targetAttempt?.state !== 'skipped'
501
+ || targetAttempt?.disposition !== 'ambiguous') {
502
+ throw new Error(`Attempt da sessão ${request.sessionId} não está ambiguous/skipped em memória v2.`);
503
+ }
504
+ if (!Array.isArray(targetAttempt.event_ids) || targetAttempt.event_ids.length !== 0) {
505
+ throw new Error(`Attempt ambíguo da sessão ${request.sessionId} deve ter event_ids vazio.`);
506
+ }
507
+
508
+ const successorAttempt = successor.last_memory_attempt;
509
+ const proof = validateSuccessorProof(request, successorAttempt, authority);
510
+ const derived = deriveMemoryProjection(vault, authority.ledgerEvents);
511
+ return {
512
+ request,
513
+ authority,
514
+ proof,
515
+ alreadyApplied: false,
516
+ targetFingerprint: attemptFingerprint(targetAttempt),
517
+ successorFingerprint: attemptFingerprint(successorAttempt),
518
+ targetAttempt: cloneJson(targetAttempt),
519
+ successorCheckpoint: cloneJson(successorAttempt.checkpoint || successor.memory_checkpoint || null),
520
+ nextCheckpoint: cloneJson(derived.checkpoint),
521
+ ledgerEvents: authority.ledgerEvents.length,
522
+ };
523
+ }
524
+
525
+ function snapshotArtifact(vault, path, label) {
526
+ return snapshotVaultFile(vault, path, label);
527
+ }
528
+
529
+ function restoreArtifact(vault, path, snapshot, label) {
530
+ restoreVaultFile(vault, path, snapshot, label);
531
+ }
532
+
533
+ export function reconcileMemory(vault, {
534
+ sessionId,
535
+ bySessionId,
536
+ reason,
537
+ apply = false,
538
+ now = new Date().toISOString(),
539
+ beforeRegistryMutation,
540
+ memoryLock = {},
541
+ } = {}) {
542
+ const prepared = prepareReconciliation(vault, { sessionId, bySessionId, reason });
543
+ if (prepared.alreadyApplied && !apply) {
544
+ return {
545
+ status: 'unchanged',
546
+ sessionId: prepared.request.sessionId,
547
+ bySessionId: prepared.request.bySessionId,
548
+ reconciliationId: prepared.reconciliationId,
549
+ };
550
+ }
551
+ if (!apply) {
552
+ return {
553
+ status: 'dry-run',
554
+ sessionId: prepared.request.sessionId,
555
+ bySessionId: prepared.request.bySessionId,
556
+ reason: prepared.request.reason,
557
+ ledgerEvents: prepared.ledgerEvents,
558
+ checkpointBefore: prepared.successorCheckpoint,
559
+ checkpointAfter: prepared.nextCheckpoint,
560
+ };
561
+ }
562
+
563
+ const outcome = withMemoryLock(vault, () => {
564
+ const locked = prepareReconciliation(vault, prepared.request, { expectedAuthority: prepared.authority });
565
+ if (locked.alreadyApplied) {
566
+ return {
567
+ status: 'unchanged',
568
+ sessionId: locked.request.sessionId,
569
+ bySessionId: locked.request.bySessionId,
570
+ reconciliationId: locked.reconciliationId,
571
+ health: locked.health,
572
+ };
573
+ }
574
+ if (locked.targetFingerprint !== prepared.targetFingerprint
575
+ || locked.successorFingerprint !== prepared.successorFingerprint) {
576
+ throw new Error('CAS perdido: attempt mudou antes da região crítica de reconciliação.');
577
+ }
578
+
579
+ if (typeof beforeRegistryMutation === 'function') beforeRegistryMutation();
580
+ const finalAuthority = readMemoryAuthority(vault);
581
+ assertAuthorityMatches(prepared.authority, finalAuthority);
582
+ const preparedProjection = prepareMemoryProjection(vault, finalAuthority.ledgerEvents);
583
+ assertAuthorityMatches(prepared.authority, readMemoryAuthority(vault));
584
+
585
+ const sharedPath = brainPath(vault, SHARED);
586
+ const candidatesPath = brainPath(vault, CANDIDATES);
587
+ const sharedBefore = snapshotArtifact(vault, sharedPath, 'snapshot SHARED de reconciliação');
588
+ const candidatesBefore = snapshotArtifact(
589
+ vault, candidatesPath, 'snapshot CANDIDATES de reconciliação',
590
+ );
591
+ let projectionTouched = false;
592
+ let backupCreated = false;
593
+ let backupPath = null;
594
+ let projection;
595
+ let committedHealth;
596
+
597
+ try {
598
+ // Intentional global order: MEMORY.lock -> SESSION_REGISTRY lock. The Stop
599
+ // lifecycle releases its registry lock before projection, so no inverse nested
600
+ // acquisition exists; keeping both here closes the CAS-to-sidecar race window.
601
+ const registryResult = mutateSessionRegistry(vault, (registry) => {
602
+ const target = registry.sessions?.[locked.request.sessionId];
603
+ const successor = registry.sessions?.[locked.request.bySessionId];
604
+ if (!target || !successor
605
+ || attemptFingerprint(target.last_memory_attempt) !== locked.targetFingerprint
606
+ || attemptFingerprint(successor.last_memory_attempt) !== locked.successorFingerprint) {
607
+ throw new Error('CAS perdido: attempt mudou durante a reconciliação; nenhuma reclassificação aplicada.');
608
+ }
609
+
610
+ const nextCheckpoint = cloneJson(preparedProjection.checkpoint);
611
+ const reconciliationId = `memrec-${hash(`${locked.request.sessionId}\0${locked.request.bySessionId}\0${locked.targetFingerprint}\0${locked.request.reason}`).slice(0, 20)}`;
612
+ const targetAudit = {
613
+ v: 1,
614
+ reconciliation_id: reconciliationId,
615
+ type: 'ambiguous_attempt_superseded',
616
+ reconciled_at: now,
617
+ reconciled_by_session_id: locked.request.bySessionId,
618
+ reason: locked.request.reason,
619
+ causal_proof: cloneJson(locked.proof),
620
+ original_attempt: cloneJson(locked.targetAttempt),
621
+ };
622
+ target.memory_reconciliations = [
623
+ ...(Array.isArray(target.memory_reconciliations) ? target.memory_reconciliations : []),
624
+ targetAudit,
625
+ ];
626
+ target.last_memory_attempt = {
627
+ ...cloneJson(locked.targetAttempt),
628
+ disposition: 'superseded',
629
+ reconciled_at: now,
630
+ reconciled_by_session_id: locked.request.bySessionId,
631
+ reconciliation_reason: locked.request.reason,
632
+ };
633
+ target.memory_status = 'skipped';
634
+
635
+ const checkpointBefore = cloneJson(successor.last_memory_attempt.checkpoint || successor.memory_checkpoint || null);
636
+ const refreshedCheckpoint = !sameCheckpoint(checkpointBefore, nextCheckpoint);
637
+ if (refreshedCheckpoint) {
638
+ successor.memory_reconciliations = [
639
+ ...(Array.isArray(successor.memory_reconciliations) ? successor.memory_reconciliations : []),
640
+ {
641
+ v: 1,
642
+ reconciliation_id: reconciliationId,
643
+ type: 'checkpoint_refreshed',
644
+ reconciled_at: now,
645
+ event_ids: [...successor.last_memory_attempt.event_ids],
646
+ causal_proof: cloneJson(locked.proof),
647
+ original_checkpoint: checkpointBefore,
648
+ checkpoint: cloneJson(nextCheckpoint),
649
+ },
650
+ ];
651
+ }
652
+ successor.last_memory_attempt = { ...successor.last_memory_attempt, checkpoint: cloneJson(nextCheckpoint) };
653
+ successor.memory_checkpoint = cloneJson(nextCheckpoint);
654
+
655
+ projectionTouched = true;
656
+ projection = publishMemoryProjection(vault, preparedProjection);
657
+ assertPublishedProjection(vault, preparedProjection);
658
+ assertAuthorityMatches(prepared.authority, readMemoryAuthority(vault));
659
+ // Policy blockers unrelated to this target (for example another ambiguous Stop
660
+ // or a candidate conflict) remain visible, but do not roll back a valid scoped CAS.
661
+ committedHealth = checkMemoryBundle(vault, { registry });
662
+
663
+ const path = registryPath(vault);
664
+ const registryArtifact = checkedVaultFile(vault, path, 'SESSION_REGISTRY da reconciliação');
665
+ if (registryArtifact.exists) {
666
+ const exact = readVaultFile(
667
+ vault, registryArtifact.target, undefined, 'SESSION_REGISTRY da reconciliação',
668
+ );
669
+ backupPath = `${path}.reconcile-${hash(exact).slice(0, 12)}.bak`;
670
+ const backup = checkedVaultFile(vault, backupPath, 'backup da reconciliação');
671
+ if (!backup.exists) {
672
+ copyVaultFileExclusive(vault, path, backupPath, 'backup da reconciliação');
673
+ backupCreated = true;
674
+ }
675
+ }
676
+ return {
677
+ status: 'reconciled',
678
+ reconciliationId,
679
+ refreshedCheckpoint,
680
+ supersededSession: locked.request.sessionId,
681
+ backupPath,
682
+ };
683
+ });
684
+ return {
685
+ status: 'reconciled', projection, registry: registryResult, health: committedHealth,
686
+ };
687
+ } catch (error) {
688
+ const rollbackErrors = [];
689
+ if (projectionTouched) {
690
+ try {
691
+ restoreArtifact(vault, sharedPath, sharedBefore, 'rollback SHARED da reconciliação');
692
+ } catch (rollbackError) { rollbackErrors.push(rollbackError); }
693
+ try {
694
+ restoreArtifact(vault, candidatesPath, candidatesBefore, 'rollback CANDIDATES da reconciliação');
695
+ } catch (rollbackError) { rollbackErrors.push(rollbackError); }
696
+ }
697
+ if (backupCreated && backupPath) {
698
+ try {
699
+ unlinkVaultFile(vault, backupPath, {
700
+ missingOk: false, label: 'backup revertido da reconciliação',
701
+ });
702
+ } catch (rollbackError) { rollbackErrors.push(rollbackError); }
703
+ }
704
+ if (rollbackErrors.length) {
705
+ throw new AggregateError([error, ...rollbackErrors], `Reconciliação falhou e o rollback dos sidecars também falhou: ${error.message}`);
706
+ }
707
+ throw error;
708
+ }
709
+ }, memoryLock);
710
+
711
+ if (outcome === MEMORY_LOCK_BUSY) {
712
+ const error = new Error('MEMORY.lock indisponível; reconciliação não aplicada e ambiguidade preservada.');
713
+ error.code = 'WENDKEEP_MEMORY_LOCK_BUSY';
714
+ throw error;
715
+ }
716
+ return outcome;
717
+ }
718
+
719
+ function checkpointShape(checkpoint) {
720
+ return checkpoint && typeof checkpoint === 'object'
721
+ && Number.isInteger(checkpoint.revision) && checkpoint.revision >= 0
722
+ && typeof checkpoint.event_cursor === 'string' && checkpoint.event_cursor
723
+ && typeof checkpoint.state_hash === 'string' && checkpoint.state_hash;
724
+ }
725
+
726
+ function legacyCheckpointMigration(sessionId, attempt, authority, fullReplay) {
727
+ const checkpoint = attempt?.checkpoint;
728
+ const requiredEventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
729
+ if (!checkpointShape(checkpoint) || !requiredEventIds.length) return null;
730
+ // The pre-physical format had no explicit causal_event_cursor: event_cursor itself
731
+ // named the reducer's causal tail while revision/hash described the full authority
732
+ // snapshot. Restricting automatic migration to that exact snapshot is both O(n) and
733
+ // fail-closed: historical/ambiguous prefixes require explicit human reconciliation.
734
+ if (checkpoint.causal_event_cursor !== undefined) return null;
735
+ if (requiredEventIds.some((eventId) => !authority.ledgerById.has(eventId))) return null;
736
+ if (checkpoint.revision !== fullReplay.revision
737
+ || checkpoint.state_hash !== fullReplay.stateHash
738
+ || checkpoint.event_cursor !== fullReplay.eventCursor) return null;
739
+ if (sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
740
+
741
+ const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
742
+ return {
743
+ sessionId,
744
+ expectedFingerprint: attemptFingerprint(attempt),
745
+ originalCheckpoint: cloneJson(checkpoint),
746
+ checkpoint: cloneJson(fullReplay.checkpoint),
747
+ proof,
748
+ };
749
+ }
750
+
751
+ export function migrateLegacyMemoryCheckpoints(vault, {
752
+ now = new Date().toISOString(), memoryLock = {},
753
+ } = {}) {
754
+ const expectedAuthority = readMemoryAuthority(vault);
755
+ const outcome = withMemoryLock(vault, () => {
756
+ const authority = readMemoryAuthority(vault);
757
+ assertAuthorityMatches(expectedAuthority, authority);
758
+ const inspected = readSessionRegistry(vault);
759
+ const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
760
+ const plans = Object.entries(inspected.sessions || {})
761
+ .map(([sessionId, entry]) => legacyCheckpointMigration(
762
+ sessionId, entry?.last_memory_attempt, authority, fullReplay,
763
+ ))
764
+ .filter(Boolean);
765
+ assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
766
+ if (!plans.length) return {
767
+ status: 'unchanged', migrated: 0, sessions: [], backupPath: null,
768
+ };
769
+
770
+ let backupPath = null;
771
+ let backupCreated = false;
772
+ try {
773
+ return mutateSessionRegistry(vault, (registry) => {
774
+ // Validate every CAS before changing the first entry, making the batch atomic.
775
+ for (const plan of plans) {
776
+ const attempt = registry.sessions?.[plan.sessionId]?.last_memory_attempt;
777
+ if (attemptFingerprint(attempt) !== plan.expectedFingerprint) {
778
+ throw new Error(`CAS perdido: checkpoint da sessão ${plan.sessionId} mudou durante a migração estrutural.`);
779
+ }
780
+ }
781
+
782
+ const path = registryPath(vault);
783
+ const registryArtifact = checkedVaultFile(vault, path, 'SESSION_REGISTRY da migração');
784
+ if (registryArtifact.exists) {
785
+ const exact = readVaultFile(
786
+ vault, registryArtifact.target, undefined, 'SESSION_REGISTRY da migração',
787
+ );
788
+ backupPath = `${path}.checkpoint-migrate-${hash(exact).slice(0, 12)}.bak`;
789
+ const backup = checkedVaultFile(vault, backupPath, 'backup da migração de checkpoint');
790
+ if (!backup.exists) {
791
+ copyVaultFileExclusive(vault, path, backupPath, 'backup da migração de checkpoint');
792
+ backupCreated = true;
793
+ }
794
+ }
795
+
796
+ for (const plan of plans) {
797
+ const entry = registry.sessions[plan.sessionId];
798
+ const reconciliationId = `memcp-${hash(`${plan.sessionId}\0${plan.expectedFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`).slice(0, 20)}`;
799
+ entry.memory_reconciliations = [
800
+ ...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
801
+ {
802
+ v: 1,
803
+ reconciliation_id: reconciliationId,
804
+ type: 'legacy_causal_checkpoint_migrated',
805
+ reconciled_at: now,
806
+ causal_proof: cloneJson(plan.proof),
807
+ original_checkpoint: cloneJson(plan.originalCheckpoint),
808
+ checkpoint: cloneJson(plan.checkpoint),
809
+ },
810
+ ];
811
+ entry.last_memory_attempt = {
812
+ ...entry.last_memory_attempt,
813
+ checkpoint: cloneJson(plan.checkpoint),
814
+ };
815
+ entry.memory_checkpoint = cloneJson(plan.checkpoint);
816
+ }
817
+ return {
818
+ status: 'migrated',
819
+ migrated: plans.length,
820
+ sessions: plans.map((plan) => plan.sessionId),
821
+ backupPath,
822
+ };
823
+ });
824
+ } catch (error) {
825
+ if (backupCreated && backupPath) {
826
+ try {
827
+ unlinkVaultFile(vault, backupPath, {
828
+ missingOk: false, label: 'backup revertido da migração de checkpoint',
829
+ });
830
+ }
831
+ catch (cleanupError) {
832
+ throw new AggregateError([error, cleanupError], `Migração de checkpoint falhou e o backup novo não pôde ser removido: ${error.message}`);
833
+ }
834
+ }
835
+ throw error;
836
+ }
837
+ }, memoryLock);
838
+ if (outcome === MEMORY_LOCK_BUSY) {
839
+ const error = new Error('MEMORY.lock indisponível; checkpoints legados não foram migrados.');
840
+ error.code = 'WENDKEEP_MEMORY_LOCK_BUSY';
841
+ throw error;
842
+ }
843
+ return outcome;
844
+ }
845
+
846
+ export function repairMemory(vault, options = {}) {
181
847
  const repaired = repairMemoryLedger(vault);
182
848
  if (repaired.status === 'busy') return repaired;
183
849
  const projection = projectMemoryOutbox(vault);
184
- return { status: projection.status === 'busy' ? 'busy' : 'repaired', repaired, projection };
850
+ if (projection.status === 'busy') return { status: 'busy', repaired, projection };
851
+ const checkpointMigration = migrateLegacyMemoryCheckpoints(vault, options);
852
+ return {
853
+ status: 'repaired', repaired, projection, checkpointMigration,
854
+ };
185
855
  }
186
856
 
187
857
  export function runValidateMemoryBundle(argv) {
@@ -213,21 +883,101 @@ function option(argv, name) {
213
883
  return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
214
884
  }
215
885
 
886
+ function memoryUsageError(message) {
887
+ const error = new TypeError(message);
888
+ error.code = 'WENDKEEP_MEMORY_USAGE';
889
+ return error;
890
+ }
891
+
892
+ function parseReconcileArgs(argv) {
893
+ const sessionId = argv[1];
894
+ if (!sessionId || sessionId.startsWith('--')) {
895
+ throw memoryUsageError('memory reconcile requer exatamente um <session> posicional.');
896
+ }
897
+
898
+ const values = new Map();
899
+ const seen = new Set();
900
+ let apply = false;
901
+ const valueOptions = new Set(['--by-session', '--reason', '--vault']);
902
+
903
+ for (let index = 2; index < argv.length; index += 1) {
904
+ const token = argv[index];
905
+ if (!token.startsWith('--')) {
906
+ throw memoryUsageError(`memory reconcile recebeu argumento posicional extra: ${token}.`);
907
+ }
908
+
909
+ const equalAt = token.indexOf('=');
910
+ const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
911
+ if (name !== '--apply' && !valueOptions.has(name)) {
912
+ throw memoryUsageError(`memory reconcile recebeu opção desconhecida: ${name}.`);
913
+ }
914
+ if (seen.has(name)) throw memoryUsageError(`memory reconcile recebeu opção duplicada: ${name}.`);
915
+ seen.add(name);
916
+
917
+ if (name === '--apply') {
918
+ if (equalAt >= 0) throw memoryUsageError('--apply não aceita valor.');
919
+ apply = true;
920
+ continue;
921
+ }
922
+
923
+ const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
924
+ if (!value || value.startsWith('--')) {
925
+ throw memoryUsageError(`${name} requer valor não vazio que não comece com --.`);
926
+ }
927
+ values.set(name, value);
928
+ if (equalAt < 0) index += 1;
929
+ }
930
+
931
+ if (!values.get('--by-session')?.trim()) {
932
+ throw memoryUsageError('memory reconcile requer --by-session <session>.');
933
+ }
934
+ if (!values.get('--reason')?.trim()) {
935
+ throw memoryUsageError('memory reconcile requer --reason <text>.');
936
+ }
937
+
938
+ return {
939
+ sessionId,
940
+ bySessionId: values.get('--by-session'),
941
+ reason: values.get('--reason'),
942
+ vault: values.get('--vault') || '',
943
+ apply,
944
+ };
945
+ }
946
+
216
947
  export function runMemory(argv) {
217
948
  const [sub, positional] = argv;
218
- const vault = option(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
949
+ let reconcileArgs = null;
950
+ if (sub === 'reconcile') {
951
+ try {
952
+ reconcileArgs = parseReconcileArgs(argv);
953
+ } catch (error) {
954
+ process.stderr.write(`wendkeep memory: ${error.message}\n`);
955
+ process.exitCode = error.code === 'WENDKEEP_MEMORY_USAGE' ? 2 : 1;
956
+ return;
957
+ }
958
+ }
959
+ const vault = (reconcileArgs?.vault || option(argv, '--vault')) || process.env.OBSIDIAN_VAULT_PATH;
219
960
  if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
961
+ if (!existsSync(vault)) { process.stderr.write(`wendkeep memory: not found: ${vault}\n`); process.exitCode = 2; return; }
220
962
  try {
221
963
  let result;
222
964
  if (sub === 'status') result = memoryStatus(vault);
223
965
  else if (sub === 'migrate') result = migrateMemory(vault, { apply: argv.includes('--apply') });
224
966
  else if (sub === 'repair') result = repairMemory(vault);
967
+ else if (sub === 'reconcile') {
968
+ result = reconcileMemory(vault, {
969
+ sessionId: reconcileArgs.sessionId,
970
+ bySessionId: reconcileArgs.bySessionId,
971
+ reason: reconcileArgs.reason,
972
+ apply: reconcileArgs.apply,
973
+ });
974
+ }
225
975
  else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
226
- else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
976
+ else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
227
977
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
228
- process.exitCode = sub === 'status' && argv.includes('--gate')
229
- ? (result.status === 'blocked' ? 1 : 0)
230
- : (result.ok === false ? 1 : 0);
978
+ if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
979
+ else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
980
+ else process.exitCode = result.ok === false ? 1 : 0;
231
981
  } catch (error) {
232
982
  process.stderr.write(`wendkeep memory: ${error.message}\n`);
233
983
  process.exitCode = 1;