wendkeep 0.58.3 → 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 (68) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.en.md +41 -3
  3. package/README.md +41 -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 +558 -0
  53. package/hooks/vault-runtime-store.mjs +558 -0
  54. package/package.json +3 -3
  55. package/src/change.mjs +2 -1
  56. package/src/flow.mjs +232 -0
  57. package/src/init.mjs +26 -3
  58. package/src/memory.mjs +785 -35
  59. package/src/operating-profile.mjs +133 -0
  60. package/src/profile.mjs +224 -0
  61. package/src/project-vault.mjs +110 -5
  62. package/src/rebuild-costs.mjs +11 -4
  63. package/src/skills-seed.mjs +38 -16
  64. package/src/sync-defs.mjs +16 -7
  65. package/src/sync.mjs +9 -1
  66. package/src/taxonomy.mjs +8 -0
  67. package/src/validate-memory.mjs +21 -8
  68. package/src/verify.mjs +12 -2
@@ -5,8 +5,8 @@
5
5
  // ledger, then atomically publishes SHARED_MEMORY.md and MEMORY_CANDIDATES.jsonl.
6
6
  import { createHash } from 'node:crypto';
7
7
  import {
8
- closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync,
9
- readdirSync, unlinkSync, writeFileSync,
8
+ closeSync, constants as fsConstants, copyFileSync, fstatSync, fsyncSync, openSync,
9
+ readFileSync, readdirSync, statSync, writeFileSync,
10
10
  } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import {
@@ -14,7 +14,10 @@ import {
14
14
  sanitizeMemoryText,
15
15
  validateMemoryEvent,
16
16
  } from './memory-schema.mjs';
17
- import { LOCK_BUSY, withPathLock, writeFileAtomic } from './session-note-io.mjs';
17
+ import {
18
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
19
+ VAULT_LOCK_BUSY, withVaultPathLock, writeVaultFileAtomic,
20
+ } from './vault-path-safety.mjs';
18
21
 
19
22
  const EVENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
20
23
 
@@ -74,6 +77,59 @@ function lockTarget(vaultBase) {
74
77
  return join(brainDir(vaultBase), 'MEMORY');
75
78
  }
76
79
 
80
+ function unsafeMemoryPath(message) {
81
+ const error = new Error(message);
82
+ error.code = 'VAULT_PATH_UNSAFE';
83
+ return error;
84
+ }
85
+
86
+ function checkedMemoryFile(vaultBase, path, label, {
87
+ allowMissing = true,
88
+ mustNotExist = false,
89
+ } = {}) {
90
+ return assertVaultPathSafe(vaultBase, path, {
91
+ allowMissing,
92
+ expectedType: 'file',
93
+ mustNotExist,
94
+ label,
95
+ });
96
+ }
97
+
98
+ function checkedMemoryDirectory(vaultBase, path, label, { allowMissing = true } = {}) {
99
+ return assertVaultPathSafe(vaultBase, path, {
100
+ allowMissing,
101
+ expectedType: 'directory',
102
+ label,
103
+ });
104
+ }
105
+
106
+ function readCheckedMemoryFile(vaultBase, path, encoding, label, { allowMissing = false } = {}) {
107
+ let checked = checkedMemoryFile(vaultBase, path, label, { allowMissing });
108
+ if (!checked.exists) return null;
109
+ // Deliberately adjacent to readFileSync: aliases created since the first policy check
110
+ // are rejected before Node opens the artifact.
111
+ checked = checkedMemoryFile(vaultBase, checked.target, label, { allowMissing: false });
112
+ return readFileSync(checked.target, encoding);
113
+ }
114
+
115
+ function assertOpenedMemoryFile(vaultBase, path, fd, label) {
116
+ const checked = checkedMemoryFile(vaultBase, path, label, { allowMissing: false });
117
+ const descriptor = fstatSync(fd);
118
+ const target = statSync(checked.target);
119
+ if (!descriptor.isFile() || descriptor.nlink > 1 || target.nlink > 1
120
+ || descriptor.dev !== target.dev || descriptor.ino !== target.ino) {
121
+ throw unsafeMemoryPath(`${label} mudou de inode ou possui hardlink antes da mutação: ${checked.target}`);
122
+ }
123
+ return checked.target;
124
+ }
125
+
126
+ function preflightProjectionTargets(vaultBase) {
127
+ return assertVaultPathsSafe(vaultBase, [
128
+ { path: sharedPath(vaultBase), expectedType: 'file', label: 'projeção SHARED_MEMORY.md' },
129
+ { path: candidatesPath(vaultBase), expectedType: 'file', label: 'projeção MEMORY_CANDIDATES.jsonl' },
130
+ ]);
131
+ }
132
+
77
133
  function canonicalize(value) {
78
134
  if (Array.isArray(value)) return value.map(canonicalize);
79
135
  if (value && typeof value === 'object') {
@@ -112,8 +168,13 @@ function sanitizeEvent(event) {
112
168
  }
113
169
 
114
170
  function projectIdForVault(vaultBase) {
171
+ const path = join(brainDir(vaultBase), 'PROJECT.json');
172
+ const raw = readCheckedMemoryFile(vaultBase, path, 'utf8', 'autoridade PROJECT.json', {
173
+ allowMissing: true,
174
+ });
175
+ if (raw === null) return undefined;
115
176
  try {
116
- const project = JSON.parse(readFileSync(join(brainDir(vaultBase), 'PROJECT.json'), 'utf8'));
177
+ const project = JSON.parse(raw);
117
178
  return typeof project.projectId === 'string' && project.projectId ? project.projectId : undefined;
118
179
  } catch {
119
180
  return undefined;
@@ -142,6 +203,34 @@ function eventHash(event) {
142
203
  return sha256(canonicalMemoryJson(event));
143
204
  }
144
205
 
206
+ const OUTBOX_PUBLICATION_WAIT_MS = 500;
207
+ const OUTBOX_PUBLICATION_POLL_MS = 5;
208
+ const OUTBOX_PUBLICATION_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
209
+
210
+ function readConcurrentOutboxEvent(vaultBase, path, eventId) {
211
+ const deadline = Date.now() + OUTBOX_PUBLICATION_WAIT_MS;
212
+ while (true) {
213
+ let raw;
214
+ try {
215
+ raw = readCheckedMemoryFile(
216
+ vaultBase, path, 'utf8', 'evento imutável preexistente do outbox',
217
+ );
218
+ } catch (cause) {
219
+ if (cause?.code === 'VAULT_PATH_UNSAFE') throw cause;
220
+ throw new MemoryEventCollision(eventId, `Existing outbox event is unreadable: ${eventId}`);
221
+ }
222
+
223
+ try {
224
+ return JSON.parse(raw);
225
+ } catch (cause) {
226
+ if (!(cause instanceof SyntaxError) || Date.now() >= deadline) {
227
+ throw new MemoryEventCollision(eventId, `Existing outbox event is unreadable: ${eventId}`);
228
+ }
229
+ Atomics.wait(OUTBOX_PUBLICATION_SIGNAL, 0, 0, OUTBOX_PUBLICATION_POLL_MS);
230
+ }
231
+ }
232
+ }
233
+
145
234
  /**
146
235
  * Persist one immutable producer event using exclusive creation. A retry with the same
147
236
  * canonical payload is a no-op; reusing the ID for different bytes is an observable error.
@@ -149,25 +238,26 @@ function eventHash(event) {
149
238
  export function enqueueMemoryEvent(vaultBase, event) {
150
239
  const checked = assertValidEvent(event, vaultBase);
151
240
  const dir = outboxDir(vaultBase);
152
- mkdirSync(dir, { recursive: true });
241
+ mkdirVaultPath(vaultBase, dir, { label: 'outbox de memória' });
153
242
  const path = join(dir, `${checked.event_id}.json`);
154
243
  const payload = `${canonicalMemoryJson(checked)}\n`;
155
244
  const hash = eventHash(checked);
156
245
 
157
246
  let fd;
158
247
  try {
248
+ checkedMemoryFile(vaultBase, path, 'evento imutável do outbox');
249
+ // The final lstat is deliberately adjacent to the exclusive open. A pre-created
250
+ // symlink/hardlink loses the wx race and is revalidated in the EEXIST branch.
251
+ checkedMemoryFile(vaultBase, path, 'evento imutável do outbox');
159
252
  fd = openSync(path, 'wx');
253
+ assertOpenedMemoryFile(vaultBase, path, fd, 'evento imutável do outbox');
160
254
  writeFileSync(fd, payload, 'utf8');
161
255
  fsyncSync(fd);
256
+ assertOpenedMemoryFile(vaultBase, path, fd, 'evento imutável do outbox');
162
257
  return { status: 'enqueued', path, eventId: checked.event_id, hash };
163
258
  } catch (error) {
164
259
  if (error?.code !== 'EEXIST') throw error;
165
- let existing;
166
- try {
167
- existing = JSON.parse(readFileSync(path, 'utf8'));
168
- } catch (cause) {
169
- throw new MemoryEventCollision(checked.event_id, `Existing outbox event is unreadable: ${checked.event_id}`);
170
- }
260
+ const existing = readConcurrentOutboxEvent(vaultBase, path, checked.event_id);
171
261
  if (eventHash(existing) !== hash || canonicalMemoryJson(existing) !== canonicalMemoryJson(checked)) {
172
262
  throw new MemoryEventCollision(checked.event_id);
173
263
  }
@@ -184,10 +274,13 @@ function ledgerError(line, message, partial = false) {
184
274
  /** Read a ledger without hiding a valid prefix when its tail is corrupt or partial. */
185
275
  export function readMemoryLedger(vaultBase) {
186
276
  const path = ledgerPath(vaultBase);
187
- if (!existsSync(path)) {
277
+ const checked = checkedMemoryFile(vaultBase, path, 'ledger MEMORY_EVENTS.jsonl');
278
+ if (!checked.exists) {
188
279
  return { status: 'ok', path, raw: '', events: [], eventIds: new Set(), errors: [] };
189
280
  }
190
- const raw = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
281
+ const raw = readCheckedMemoryFile(
282
+ vaultBase, checked.target, 'utf8', 'ledger MEMORY_EVENTS.jsonl',
283
+ ).replace(/\r\n/g, '\n');
191
284
  const lines = raw.split('\n');
192
285
  const hasPartialTail = raw.length > 0 && !raw.endsWith('\n');
193
286
  if (!hasPartialTail) lines.pop();
@@ -245,8 +338,14 @@ function eventOrder(left, right) {
245
338
  || String(left.event_id).localeCompare(String(right.event_id));
246
339
  }
247
340
 
341
+ function sameCausalActivation(left, right) {
342
+ return Boolean(left?.canonical_session_id)
343
+ && left.canonical_session_id === right?.canonical_session_id
344
+ && left.activation_id === right?.activation_id;
345
+ }
346
+
248
347
  function comparable(left, right) {
249
- if (left.activation_id === right.activation_id) return true;
348
+ if (sameCausalActivation(left, right)) return true;
250
349
  const leftSupersedes = left.supersedes_event_id || left.supersedes;
251
350
  const rightSupersedes = right.supersedes_event_id || right.supersedes;
252
351
  return leftSupersedes === right.event_id || rightSupersedes === left.event_id
@@ -268,12 +367,10 @@ function candidateId(reason, memoryKey, eventIds) {
268
367
  // participates in precedence so the projector never guesses meaning from human text:
269
368
  // <!-- wk-memory: release.push="manual-only" -->
270
369
  function readCoreInvariants(vaultBase) {
271
- let core;
272
- try {
273
- core = readFileSync(corePath(vaultBase), 'utf8').replace(/\r\n/g, '\n');
274
- } catch {
275
- return new Map();
276
- }
370
+ const core = readCheckedMemoryFile(
371
+ vaultBase, corePath(vaultBase), 'utf8', 'autoridade CORE.md', { allowMissing: true },
372
+ );
373
+ if (core === null) return new Map();
277
374
  const invariants = new Map();
278
375
  const marker = /^<!--\s*wk-memory:\s*([A-Za-z0-9][A-Za-z0-9._-]*)=(.+)\s*-->$/;
279
376
  for (const line of core.split('\n')) {
@@ -333,7 +430,7 @@ function currentEventFromRecord(record) {
333
430
 
334
431
  function isCausallyOlder(event, current) {
335
432
  if (!current) return false;
336
- if (event.activation_id === current.activation_id) {
433
+ if (sameCausalActivation(event, current)) {
337
434
  return Number(event.turn_sequence) < Number(current.turn_sequence);
338
435
  }
339
436
  if (Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
@@ -419,6 +516,15 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
419
516
 
420
517
  if (item.operation === 'assert') {
421
518
  if (current && hashMemoryValue(current.value) !== hashMemoryValue(item.value)) {
519
+ if (sameCausalActivation(item, currentSource)
520
+ && Number(item.turn_sequence) > Number(currentSource.turn_sequence)) {
521
+ records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
522
+ tombstones.delete(item.memory_key);
523
+ superseded.push({ event_id: currentSource.event_id, by_event_id: item.event_id });
524
+ revision += 1;
525
+ appliedEventIds.push(item.event_id);
526
+ continue;
527
+ }
422
528
  candidates.push(conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]));
423
529
  continue;
424
530
  }
@@ -524,42 +630,107 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
524
630
  };
525
631
  }
526
632
 
633
+ /**
634
+ * Re-derive one ledger projection using the explicit invariants declared by CORE.
635
+ * `eventCursor` is the reducer's deterministic causal cursor; `ledgerCursor` is the
636
+ * physical prefix boundary used by durable checkpoints.
637
+ */
638
+ export function deriveMemoryProjection(vaultBase, inputEvents = []) {
639
+ const reduced = reduceMemoryEvents(inputEvents, { coreInvariants: readCoreInvariants(vaultBase) });
640
+ const ledgerCursor = inputEvents.at(-1)?.event_id || 'none';
641
+ const checkpoint = {
642
+ revision: reduced.revision,
643
+ event_cursor: ledgerCursor,
644
+ state_hash: reduced.stateHash,
645
+ };
646
+ if (reduced.eventCursor !== ledgerCursor) {
647
+ checkpoint.causal_event_cursor = reduced.eventCursor;
648
+ }
649
+ return {
650
+ ...reduced,
651
+ ledgerCursor,
652
+ checkpoint,
653
+ };
654
+ }
655
+
527
656
  function readOutbox(vaultBase) {
528
657
  const dir = outboxDir(vaultBase);
529
- if (!existsSync(dir)) return [];
530
- return readdirSync(dir, { withFileTypes: true })
531
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
658
+ let checkedDir = checkedMemoryDirectory(vaultBase, dir, 'outbox de memória');
659
+ if (!checkedDir.exists) return [];
660
+ checkedDir = checkedMemoryDirectory(vaultBase, checkedDir.target, 'outbox de memória', {
661
+ allowMissing: false,
662
+ });
663
+ const entries = readdirSync(checkedDir.target, { withFileTypes: true });
664
+ for (const entry of entries) {
665
+ assertVaultPathSafe(vaultBase, join(checkedDir.target, entry.name), {
666
+ allowMissing: false,
667
+ label: `entrada ${entry.name} do outbox de memória`,
668
+ });
669
+ }
670
+ return entries
671
+ .filter((entry) => entry.name.endsWith('.json'))
532
672
  .sort((left, right) => left.name.localeCompare(right.name))
533
673
  .map((entry) => {
534
- const path = join(dir, entry.name);
674
+ const path = join(checkedDir.target, entry.name);
535
675
  try {
536
- const event = assertValidEvent(JSON.parse(readFileSync(path, 'utf8')), vaultBase);
676
+ const event = assertValidEvent(JSON.parse(readCheckedMemoryFile(
677
+ vaultBase, path, 'utf8', `evento ${entry.name} do outbox de memória`,
678
+ )), vaultBase);
537
679
  if (`${event.event_id}.json` !== entry.name) throw new Error('filename does not match event_id');
538
680
  return { event, path };
539
681
  } catch (error) {
682
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
540
683
  throw new MemoryOutboxCorruption(path, error);
541
684
  }
542
685
  });
543
686
  }
544
687
 
545
- function appendLedgerDurably(path, events) {
688
+ function countPendingOutbox(vaultBase) {
689
+ const dir = outboxDir(vaultBase);
690
+ let checked = checkedMemoryDirectory(vaultBase, dir, 'outbox de memória');
691
+ if (!checked.exists) return 0;
692
+ checked = checkedMemoryDirectory(vaultBase, checked.target, 'outbox de memória', {
693
+ allowMissing: false,
694
+ });
695
+ return readdirSync(checked.target, { withFileTypes: true })
696
+ .filter((entry) => entry.name.endsWith('.json'))
697
+ .map((entry) => {
698
+ assertVaultPathSafe(vaultBase, join(checked.target, entry.name), {
699
+ allowMissing: false,
700
+ label: `entrada ${entry.name} do outbox de memória`,
701
+ });
702
+ return entry;
703
+ }).length;
704
+ }
705
+
706
+ function appendLedgerDurably(vaultBase, path, events) {
546
707
  if (!events.length) return;
547
708
  let fd;
548
709
  try {
549
- fd = openSync(path, 'a');
710
+ let checked = checkedMemoryFile(vaultBase, path, 'ledger MEMORY_EVENTS.jsonl');
711
+ checked = checkedMemoryFile(vaultBase, checked.target, 'ledger MEMORY_EVENTS.jsonl');
712
+ fd = openSync(checked.target, 'a');
713
+ assertOpenedMemoryFile(vaultBase, checked.target, fd, 'ledger MEMORY_EVENTS.jsonl');
550
714
  const payload = events.map((item) => canonicalMemoryJson(item)).join('\n') + '\n';
551
715
  writeFileSync(fd, payload, 'utf8');
552
716
  fsyncSync(fd);
717
+ assertOpenedMemoryFile(vaultBase, checked.target, fd, 'ledger MEMORY_EVENTS.jsonl');
553
718
  } finally {
554
719
  if (fd !== undefined) closeSync(fd);
555
720
  }
556
721
  }
557
722
 
558
- function writeAtomicIfChanged(path, content) {
559
- try {
560
- if (readFileSync(path, 'utf8') === content) return false;
561
- } catch { /* missing/unreadable projections are replaced atomically */ }
562
- writeFileAtomic(path, content);
723
+ function writeAtomicIfChanged(vaultBase, path, content, label) {
724
+ const checked = checkedMemoryFile(vaultBase, path, label);
725
+ if (checked.exists) {
726
+ try {
727
+ if (readCheckedMemoryFile(vaultBase, checked.target, 'utf8', label) === content) return false;
728
+ } catch (error) {
729
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
730
+ // A contained but unreadable projection may be atomically replaced.
731
+ }
732
+ }
733
+ writeVaultFileAtomic(vaultBase, checked.target, content, 'utf8', { label });
563
734
  return true;
564
735
  }
565
736
 
@@ -567,6 +738,64 @@ function injectFault(faultAt, boundary) {
567
738
  if (faultAt === boundary) throw new Error(`Injected memory-store fault: ${boundary}`);
568
739
  }
569
740
 
741
+ /** Build every derived byte from an immutable authority snapshot without publishing it. */
742
+ export function prepareMemoryProjection(vaultBase, allEvents) {
743
+ const reduced = deriveMemoryProjection(vaultBase, allEvents);
744
+ const updatedAt = allEvents
745
+ .map((item) => item.observed_at)
746
+ .filter(Boolean)
747
+ .sort()
748
+ .at(-1);
749
+ const shared = renderSharedMemory({
750
+ revision: reduced.revision,
751
+ eventCursor: reduced.ledgerCursor,
752
+ events: reduced.activeEvents,
753
+ stateHash: reduced.stateHash,
754
+ updatedAt,
755
+ });
756
+ const candidates = reduced.candidates.map((item) => canonicalMemoryJson(item)).join('\n')
757
+ + (reduced.candidates.length ? '\n' : '');
758
+ return {
759
+ sharedContent: shared,
760
+ candidatesContent: candidates,
761
+ revision: reduced.revision,
762
+ eventCursor: reduced.eventCursor,
763
+ ledgerCursor: reduced.ledgerCursor,
764
+ stateHash: reduced.stateHash,
765
+ checkpoint: reduced.checkpoint,
766
+ candidates: reduced.candidates.length,
767
+ };
768
+ }
769
+
770
+ /** Publish a projection prepared from the same locked authority snapshot. */
771
+ export function publishMemoryProjection(vaultBase, prepared) {
772
+ const {
773
+ sharedContent, candidatesContent, ...projection
774
+ } = prepared;
775
+ // Validate the complete publication set before the first rename: a bad candidates
776
+ // alias cannot leave SHARED partially advanced (and vice versa).
777
+ preflightProjectionTargets(vaultBase);
778
+ const sharedWritten = writeAtomicIfChanged(
779
+ vaultBase, sharedPath(vaultBase), sharedContent, 'projeção SHARED_MEMORY.md',
780
+ );
781
+ const candidatesWritten = writeAtomicIfChanged(
782
+ vaultBase, candidatesPath(vaultBase), candidatesContent, 'projeção MEMORY_CANDIDATES.jsonl',
783
+ );
784
+ return { ...projection, projectionsWritten: sharedWritten || candidatesWritten };
785
+ }
786
+
787
+ function publishDerivedProjection(vaultBase, allEvents) {
788
+ return publishMemoryProjection(vaultBase, prepareMemoryProjection(vaultBase, allEvents));
789
+ }
790
+
791
+ // Reconciliation needs to validate proof, perform registry CAS, and publish the exact
792
+ // prepared bytes inside one MEMORY critical section. This primitive deliberately does
793
+ // not create `.brain`: callers must complete their read-only Vault preflight first.
794
+ export const MEMORY_LOCK_BUSY = VAULT_LOCK_BUSY;
795
+ export function withMemoryLock(vaultBase, fn, options = {}) {
796
+ return withVaultPathLock(vaultBase, lockTarget(vaultBase), fn, options);
797
+ }
798
+
570
799
  function projectLocked(vaultBase, { faultAt } = {}) {
571
800
  const ledger = readMemoryLedger(vaultBase);
572
801
  if (ledger.status !== 'ok') throw new MemoryLedgerCorruption(ledger.errors);
@@ -584,70 +813,81 @@ function projectLocked(vaultBase, { faultAt } = {}) {
584
813
  newEvents.push(item);
585
814
  }
586
815
 
587
- appendLedgerDurably(ledger.path, newEvents);
816
+ const allEvents = [...ledger.events, ...newEvents];
817
+ const prepared = prepareMemoryProjection(vaultBase, allEvents);
818
+ // CORE and both sidecars are checked before the append, so an unsafe derived target
819
+ // cannot partially advance the authoritative ledger.
820
+ preflightProjectionTargets(vaultBase);
821
+ appendLedgerDurably(vaultBase, ledger.path, newEvents);
588
822
  injectFault(faultAt, 'after-ledger');
589
823
 
590
- const allEvents = [...ledger.events, ...newEvents];
591
- const reduced = reduceMemoryEvents(allEvents, { coreInvariants: readCoreInvariants(vaultBase) });
592
- const updatedAt = allEvents
593
- .map((item) => item.observed_at)
594
- .filter(Boolean)
595
- .sort()
596
- .at(-1);
597
- const shared = renderSharedMemory({
598
- revision: reduced.revision,
599
- eventCursor: reduced.eventCursor,
600
- events: reduced.activeEvents,
601
- stateHash: reduced.stateHash,
602
- updatedAt,
603
- });
604
- const candidates = reduced.candidates.map((item) => canonicalMemoryJson(item)).join('\n')
605
- + (reduced.candidates.length ? '\n' : '');
606
- const sharedWritten = writeAtomicIfChanged(sharedPath(vaultBase), shared);
607
- const candidatesWritten = writeAtomicIfChanged(candidatesPath(vaultBase), candidates);
824
+ const projection = publishMemoryProjection(vaultBase, prepared);
608
825
  injectFault(faultAt, 'after-projection');
609
826
 
610
- for (const entry of outbox) unlinkSync(entry.path);
827
+ for (const entry of outbox) {
828
+ unlinkVaultFile(vaultBase, entry.path, {
829
+ missingOk: false, label: 'evento consumido do outbox de memória',
830
+ });
831
+ }
611
832
  return {
612
833
  status: 'projected',
613
834
  appended: newEvents.length,
614
835
  consumed: outbox.length,
615
836
  pending: 0,
616
- revision: reduced.revision,
617
- eventCursor: reduced.eventCursor,
618
- stateHash: reduced.stateHash,
619
- candidates: reduced.candidates.length,
620
- projectionsWritten: sharedWritten || candidatesWritten,
837
+ ...projection,
621
838
  };
622
839
  }
623
840
 
624
841
  /** Serialize ledger append + full deterministic replay under .brain/MEMORY.lock. */
625
842
  export function projectMemoryOutbox(vaultBase, options = {}) {
626
- mkdirSync(brainDir(vaultBase), { recursive: true });
627
- const pending = existsSync(outboxDir(vaultBase))
628
- ? readdirSync(outboxDir(vaultBase)).filter((name) => name.endsWith('.json')).length
629
- : 0;
630
- const result = withPathLock(
843
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
844
+ const pending = countPendingOutbox(vaultBase);
845
+ const result = withVaultPathLock(
846
+ vaultBase,
631
847
  lockTarget(vaultBase),
632
848
  () => projectLocked(vaultBase, options),
633
849
  options.lock || {},
634
850
  );
635
- if (result === LOCK_BUSY) return { status: 'busy', pending };
851
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy', pending };
852
+ return result;
853
+ }
854
+
855
+ /**
856
+ * Rebuild generated projections from the existing ledger only. This path never
857
+ * enumerates, reads, acknowledges, or consumes producer outbox files.
858
+ */
859
+ export function reprojectMemoryLedger(vaultBase, options = {}) {
860
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
861
+ const result = withVaultPathLock(vaultBase, lockTarget(vaultBase), () => {
862
+ const ledger = readMemoryLedger(vaultBase);
863
+ if (ledger.status !== 'ok') throw new MemoryLedgerCorruption(ledger.errors);
864
+ return {
865
+ status: 'reprojected',
866
+ ...publishDerivedProjection(vaultBase, ledger.events),
867
+ };
868
+ }, options.lock || {});
869
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy' };
636
870
  return result;
637
871
  }
638
872
 
639
873
  /** Explicit repair: preserve exact corrupt bytes, then retain every independently valid line. */
640
874
  export function repairMemoryLedger(vaultBase, options = {}) {
641
- mkdirSync(brainDir(vaultBase), { recursive: true });
642
- const result = withPathLock(lockTarget(vaultBase), () => {
875
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
876
+ const result = withVaultPathLock(vaultBase, lockTarget(vaultBase), () => {
643
877
  const ledger = readMemoryLedger(vaultBase);
644
878
  if (ledger.status === 'ok') return { status: 'unchanged', repairedLines: 0, backupPath: null };
645
879
  const backupPath = `${ledger.path}.corrupt-${Date.now()}.bak`;
646
880
  // copyFileSync preserves the exact evidence before any canonical rewrite.
647
- copyFileSync(ledger.path, backupPath);
881
+ checkedMemoryFile(vaultBase, ledger.path, 'ledger corrompido', { allowMissing: false });
882
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { mustNotExist: true });
883
+ // Revalidate immediately before the exclusive copy, then verify the created inode.
884
+ checkedMemoryFile(vaultBase, ledger.path, 'ledger corrompido', { allowMissing: false });
885
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { mustNotExist: true });
886
+ copyFileSync(ledger.path, backupPath, fsConstants.COPYFILE_EXCL);
887
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { allowMissing: false });
648
888
  const repaired = ledger.events.map((item) => canonicalMemoryJson(item)).join('\n')
649
889
  + (ledger.events.length ? '\n' : '');
650
- writeFileAtomic(ledger.path, repaired);
890
+ writeVaultFileAtomic(vaultBase, ledger.path, repaired, 'utf8', { label: 'ledger reparado' });
651
891
  return {
652
892
  status: 'repaired',
653
893
  repairedLines: ledger.errors.length,
@@ -655,6 +895,6 @@ export function repairMemoryLedger(vaultBase, options = {}) {
655
895
  backupPath,
656
896
  };
657
897
  }, options.lock || {});
658
- if (result === LOCK_BUSY) return { status: 'busy', repairedLines: 0, backupPath: null };
898
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy', repairedLines: 0, backupPath: null };
659
899
  return result;
660
900
  }