wendkeep 0.58.1 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.en.md +70 -40
  3. package/README.md +70 -40
  4. package/bin/wendkeep.mjs +54 -6
  5. package/docs/en/commands/changes-and-verification.md +85 -0
  6. package/docs/en/commands/costs-and-observability.md +65 -0
  7. package/docs/en/commands/getting-started.md +86 -0
  8. package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
  9. package/docs/en/commands/memory-migration.md +73 -0
  10. package/docs/en/commands/memory.md +102 -0
  11. package/docs/en/commands/notes-and-knowledge.md +70 -0
  12. package/docs/en/commands/operating-profiles.md +173 -0
  13. package/docs/en/commands/retroactive-import.md +67 -0
  14. package/docs/en/commands/sessions-and-import.md +89 -0
  15. package/docs/en/commands/verify.md +92 -0
  16. package/docs/pt-BR/commands/changes-and-verification.md +85 -0
  17. package/docs/pt-BR/commands/costs-and-observability.md +65 -0
  18. package/docs/pt-BR/commands/getting-started.md +87 -0
  19. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
  20. package/docs/pt-BR/commands/memory-migration.md +73 -0
  21. package/docs/pt-BR/commands/memory.md +99 -0
  22. package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
  23. package/docs/pt-BR/commands/operating-profiles.md +171 -0
  24. package/docs/pt-BR/commands/retroactive-import.md +67 -0
  25. package/docs/pt-BR/commands/sessions-and-import.md +89 -0
  26. package/docs/pt-BR/commands/verify.md +93 -0
  27. package/hooks/brain-core.mjs +159 -159
  28. package/hooks/brain-inject.mjs +83 -26
  29. package/hooks/brain-recall.mjs +32 -32
  30. package/hooks/brain-reindex.mjs +13 -13
  31. package/hooks/change-context.mjs +24 -10
  32. package/hooks/change-core.mjs +174 -37
  33. package/hooks/change-guard.mjs +115 -16
  34. package/hooks/change-nag.mjs +20 -5
  35. package/hooks/change-warn.mjs +27 -9
  36. package/hooks/decision-capture.mjs +1 -1
  37. package/hooks/derived-sections.mjs +1 -1
  38. package/hooks/flow-core.mjs +891 -0
  39. package/hooks/flow-protected-policy.mjs +218 -0
  40. package/hooks/frontmatter-repair.mjs +3 -1
  41. package/hooks/git-snapshot.mjs +722 -0
  42. package/hooks/import-sessions.mjs +10 -5
  43. package/hooks/memory-mode.mjs +63 -13
  44. package/hooks/memory-store.mjs +309 -69
  45. package/hooks/obsidian-common.mjs +119 -84
  46. package/hooks/operating-profile-runtime.mjs +157 -0
  47. package/hooks/plan-capture.mjs +14 -3
  48. package/hooks/sensors-core.mjs +15 -3
  49. package/hooks/session-backfill.mjs +7 -2
  50. package/hooks/session-ensure.mjs +21 -12
  51. package/hooks/session-iteration.mjs +65 -0
  52. package/hooks/session-memory-lifecycle.mjs +335 -0
  53. package/hooks/session-note-io.mjs +130 -15
  54. package/hooks/session-observability.mjs +4 -2
  55. package/hooks/session-stop.mjs +181 -59
  56. package/hooks/spec-core.mjs +91 -12
  57. package/hooks/subagent-stop.mjs +4 -1
  58. package/hooks/subagent-usage.mjs +2 -2
  59. package/hooks/task-log.mjs +3 -1
  60. package/hooks/token-usage.mjs +1 -1
  61. package/hooks/vault-health.mjs +268 -25
  62. package/hooks/vault-path-safety.mjs +558 -0
  63. package/hooks/vault-runtime-store.mjs +558 -0
  64. package/package.json +5 -3
  65. package/src/change.mjs +2 -1
  66. package/src/flow.mjs +232 -0
  67. package/src/init.mjs +26 -3
  68. package/src/memory.mjs +785 -35
  69. package/src/operating-profile.mjs +133 -0
  70. package/src/profile.mjs +224 -0
  71. package/src/project-vault.mjs +110 -5
  72. package/src/rebuild-costs.mjs +11 -4
  73. package/src/skills-seed.mjs +38 -16
  74. package/src/sync-defs.mjs +16 -7
  75. package/src/sync.mjs +9 -1
  76. package/src/taxonomy.mjs +9 -0
  77. package/src/validate-memory.mjs +21 -8
  78. package/src/verify.mjs +12 -2
@@ -7,32 +7,105 @@
7
7
  //
8
8
  // `obsidian-common.mjs` já resolvia isso para o SESSION_REGISTRY.json; aqui o mesmo par
9
9
  // (tmp + rename, lock por mkdir) fica disponível para a nota de sessão.
10
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
10
+ import { randomUUID } from 'node:crypto';
11
+ import {
12
+ existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import {
17
+ assertVaultPathSafe, VAULT_LOCK_BUSY, withVaultPathLock, writeVaultFileAtomic,
18
+ } from './vault-path-safety.mjs';
11
19
 
12
20
  export const LOCK_BUSY = Symbol('wendkeep:lock-busy');
21
+ export const LOCK_OWNER_FILE = '.owner.json';
22
+
23
+ function lockOwnerPath(lock) {
24
+ return `${lock}/${LOCK_OWNER_FILE}`;
25
+ }
26
+
27
+ function readLockOwner(lock) {
28
+ try {
29
+ const owner = JSON.parse(readFileSync(lockOwnerPath(lock), 'utf8'));
30
+ if (owner?.v !== 1 || !Number.isInteger(owner.pid) || owner.pid <= 0
31
+ || typeof owner.token !== 'string' || !owner.token) return null;
32
+ return owner;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function processIsAlive(pid) {
39
+ if (pid === process.pid) return true;
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (error) {
44
+ // EPERM means the process exists but this user cannot signal it.
45
+ return error?.code === 'EPERM';
46
+ }
47
+ }
48
+
49
+ function releaseUnownedLockDir(lock) {
50
+ try { unlinkSync(lockOwnerPath(lock)); }
51
+ catch (error) { if (error?.code !== 'ENOENT') return false; }
52
+ return releaseLockDir(lock);
53
+ }
13
54
 
14
55
  // ATENÇÃO: no Windows (Node 24), `rmSync(dir, { recursive: true, force: true })` é um NO-OP
15
56
  // SILENCIOSO quando o caminho contém caractere não-ASCII — não remove e não lança. Medido:
16
57
  // 20/20 falhas em `02-Sessões`, `ação`, `Mudanças`; 0/20 em caminho ASCII. Como TODA nota de
17
58
  // sessão vive sob `02-Sessões/`, usar rmSync aqui deixaria o lock preso para sempre e o
18
59
  // segundo escritor desistiria de gravar — perdendo turnos em silêncio.
19
- // O lock é sempre um diretório vazio, então `rmdirSync` basta e funciona em qualquer caminho.
20
- export function releaseLockDir(lock) {
60
+ // Locks owner-aware contêm apenas `.owner.json`; locks legados continuam vazios.
61
+ // Quando `expectedToken` é informado, um finally antigo jamais remove um lock que já
62
+ // foi substituído por outro dono (proteção contra ABA).
63
+ export function releaseLockDir(lock, expectedToken = '') {
21
64
  try {
65
+ const ownerPath = lockOwnerPath(lock);
66
+ if (expectedToken) {
67
+ const owner = readLockOwner(lock);
68
+ if (owner?.token !== expectedToken) return false;
69
+ unlinkSync(ownerPath);
70
+ } else if (existsSync(ownerPath)) {
71
+ return false;
72
+ }
22
73
  rmdirSync(lock);
74
+ return true;
23
75
  } catch (error) {
24
- if (error?.code === 'ENOENT') return;
25
- try { rmSync(lock, { recursive: true, force: true }); } catch { /* lock preso: melhor seguir */ }
76
+ if (error?.code === 'ENOENT') return true;
77
+ return false;
26
78
  }
27
79
  }
28
80
 
29
81
  const FRONTMATTER = /^---\n[\s\S]*?\n---/;
30
82
 
83
+ function inferVaultBase(path) {
84
+ if (!path) return '';
85
+ let cursor = resolve(dirname(path));
86
+ while (true) {
87
+ try {
88
+ const brain = lstatSync(join(cursor, '.brain'));
89
+ if (brain.isDirectory() || brain.isSymbolicLink()) return cursor;
90
+ } catch (error) {
91
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
92
+ }
93
+ const parent = dirname(cursor);
94
+ if (parent === cursor) return '';
95
+ cursor = parent;
96
+ }
97
+ }
98
+
31
99
  export function hasSessionFrontmatter(content) {
32
100
  return typeof content === 'string' && FRONTMATTER.test(content);
33
101
  }
34
102
 
35
- export function writeFileAtomic(path, content, encoding = 'utf-8') {
103
+ export function writeFileAtomic(path, content, encoding = 'utf-8', { vaultBase = '' } = {}) {
104
+ if (vaultBase) {
105
+ return writeVaultFileAtomic(vaultBase, path, content, encoding, {
106
+ label: 'nota de sessão atômica',
107
+ });
108
+ }
36
109
  // rename é atômico no mesmo volume: ou o leitor vê o arquivo antigo inteiro, ou o novo.
37
110
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
38
111
  writeFileSync(tmp, content, encoding);
@@ -46,19 +119,46 @@ function waitBriefly(ms) {
46
119
 
47
120
  // Roda `fn` com o lock do arquivo tomado. Devolve LOCK_BUSY quando o lock não veio dentro
48
121
  // do timeout — o chamador desiste da gravação em vez de gravar sem lock.
49
- export function withPathLock(path, fn, { timeoutMs = 2000, staleMs = 10_000 } = {}) {
122
+ export function withPathLock(path, fn, {
123
+ timeoutMs = 2000,
124
+ staleMs = 10_000,
125
+ vaultBase = '',
126
+ } = {}) {
127
+ if (vaultBase) {
128
+ const outcome = withVaultPathLock(vaultBase, path, fn, { timeoutMs, staleMs });
129
+ return outcome === VAULT_LOCK_BUSY ? LOCK_BUSY : outcome;
130
+ }
50
131
  const lock = `${path}.lock`;
51
132
  const deadline = Date.now() + timeoutMs;
133
+ const token = randomUUID();
52
134
 
53
135
  while (true) {
54
136
  try {
55
137
  mkdirSync(lock);
138
+ try {
139
+ writeFileSync(lockOwnerPath(lock), `${JSON.stringify({
140
+ v: 1,
141
+ pid: process.pid,
142
+ token,
143
+ created_at: new Date().toISOString(),
144
+ })}\n`, { encoding: 'utf8', flag: 'wx' });
145
+ } catch (error) {
146
+ // No owner was published, so this is still a legacy-empty directory owned by us.
147
+ releaseUnownedLockDir(lock);
148
+ throw error;
149
+ }
56
150
  break;
57
151
  } catch (error) {
58
152
  if (error?.code !== 'EEXIST') throw error;
59
153
  try {
60
- // Lock morto (processo caiu antes do finally) não pode travar a sessão inteira.
61
- if (Date.now() - statSync(lock).mtimeMs > staleMs) releaseLockDir(lock);
154
+ if (Date.now() - statSync(lock).mtimeMs > staleMs) {
155
+ const owner = readLockOwner(lock);
156
+ // An old mtime is not proof of death: synchronous critical sections cannot
157
+ // heartbeat. Reap only a legacy-empty lock or a verified dead PID.
158
+ if (owner) {
159
+ if (!processIsAlive(owner.pid)) releaseLockDir(lock, owner.token);
160
+ } else releaseUnownedLockDir(lock);
161
+ }
62
162
  } catch { /* outro processo pode ter liberado o lock no meio da checagem */ }
63
163
  // O deadline é checado SEMPRE, inclusive depois de tentar remover um lock morto:
64
164
  // `releaseLockDir` engole a falha, então um `continue` direto giraria para sempre.
@@ -70,7 +170,7 @@ export function withPathLock(path, fn, { timeoutMs = 2000, staleMs = 10_000 } =
70
170
  try {
71
171
  return fn();
72
172
  } finally {
73
- releaseLockDir(lock);
173
+ releaseLockDir(lock, token);
74
174
  }
75
175
  }
76
176
 
@@ -78,16 +178,31 @@ export function withPathLock(path, fn, { timeoutMs = 2000, staleMs = 10_000 } =
78
178
  // O mutator devolve o conteúdo novo, ou `null` para abortar sem gravar (o caminho
79
179
  // fail-closed de quem leu uma nota corrompida).
80
180
  export function mutateSessionNote(path, mutator, options = {}) {
81
- if (!path || !existsSync(path)) return { written: false, reason: 'missing', content: null };
181
+ const vaultBase = options.vaultBase || inferVaultBase(path);
182
+ let target = path;
183
+ if (vaultBase) {
184
+ const checked = assertVaultPathSafe(vaultBase, path, {
185
+ expectedType: 'file', label: 'nota de sessão',
186
+ });
187
+ if (!checked.exists) return { written: false, reason: 'missing', content: null };
188
+ target = checked.target;
189
+ } else if (!path || !existsSync(path)) {
190
+ return { written: false, reason: 'missing', content: null };
191
+ }
82
192
 
83
- const outcome = withPathLock(path, () => {
84
- const original = readFileSync(path, 'utf-8');
193
+ const outcome = withPathLock(target, () => {
194
+ if (vaultBase) {
195
+ assertVaultPathSafe(vaultBase, target, {
196
+ allowMissing: false, expectedType: 'file', label: 'nota de sessão',
197
+ });
198
+ }
199
+ const original = readFileSync(target, 'utf-8');
85
200
  const next = mutator(original);
86
201
  if (next === null || next === undefined) return { written: false, reason: 'aborted', content: original };
87
202
  if (next === original) return { written: false, reason: 'unchanged', content: original };
88
- writeFileAtomic(path, next);
203
+ writeFileAtomic(target, next, 'utf-8', { vaultBase });
89
204
  return { written: true, reason: 'ok', content: next };
90
- }, options);
205
+ }, { ...options, vaultBase });
91
206
 
92
207
  if (outcome === LOCK_BUSY) return { written: false, reason: 'busy', content: null };
93
208
  return outcome;
@@ -159,7 +159,9 @@ export function buildSessionObservability({ sessionContent, transcriptPath }) {
159
159
  return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
160
160
  }
161
161
 
162
- export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '', lockTimeoutMs }) {
162
+ export function updateSessionObservability({
163
+ vaultBase = '', sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '', lockTimeoutMs,
164
+ }) {
163
165
  if (!sessionPath || !existsSync(sessionPath)) return null;
164
166
  const identity = inspectTranscriptIdentity(transcriptPath);
165
167
  let snapshot = null;
@@ -184,7 +186,7 @@ export function updateSessionObservability({ sessionPath, transcriptPath, caller
184
186
  if (!result) return null;
185
187
  snapshot = result.snapshot;
186
188
  return result.content;
187
- }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
189
+ }, { ...(lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {}), vaultBase });
188
190
 
189
191
  // 'unchanged' também é sucesso: a nota já estava em dia, o snapshot vale.
190
192
  return outcome.written || outcome.reason === 'unchanged' ? snapshot : null;
@@ -16,6 +16,12 @@ import { buildSessionMemoryEvents, collectLifecycleEvidence } from './memory-han
16
16
  import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
17
17
  import { detectMemoryMode } from './memory-mode.mjs';
18
18
  import { sanitizeMemoryText } from './memory-schema.mjs';
19
+ import { assertVaultPathSafe } from './vault-path-safety.mjs';
20
+ import {
21
+ projectStopMemoryAttempt,
22
+ recordStopMemoryOutcome,
23
+ stageStopMemoryAttempt,
24
+ } from './session-memory-lifecycle.mjs';
19
25
  import {
20
26
  ensureDir,
21
27
  findActiveSessionByTranscript,
@@ -527,12 +533,58 @@ export function parseTranscript(transcriptPath) {
527
533
  return parseCodexTranscript(transcriptPath);
528
534
  }
529
535
 
536
+ export function resolveTurnIdentity(transcript, requestedTurnId = '') {
537
+ const turns = Array.isArray(transcript?.turns) ? transcript.turns : [];
538
+ const requested = String(requestedTurnId || '');
539
+ let index = requested
540
+ ? turns.findIndex((turn) => String(turn?.turnId || '') === requested)
541
+ : -1;
542
+ if (requested && index < 0) return null;
543
+ if (index < 0 && transcript?.latestTurnId) {
544
+ index = turns.findIndex((turn) => String(turn?.turnId || '') === String(transcript.latestTurnId));
545
+ }
546
+ if (index < 0) index = turns.length - 1;
547
+ const turn = turns[index];
548
+ if (!turn?.turnId) return null;
549
+ return {
550
+ id: String(turn.turnId),
551
+ order: index + 1,
552
+ observedAt: String(turn.timestamp || ''),
553
+ };
554
+ }
555
+
556
+ function escapeMarkdownBackticks(text) {
557
+ let escaped = '';
558
+ let precedingBackslashes = 0;
559
+ for (const char of String(text || '')) {
560
+ if (char === '\\') {
561
+ escaped += char;
562
+ precedingBackslashes += 1;
563
+ continue;
564
+ }
565
+ if (char === '`') {
566
+ if (precedingBackslashes % 2 === 0) escaped += '\\';
567
+ escaped += char;
568
+ precedingBackslashes = 0;
569
+ continue;
570
+ }
571
+ escaped += char;
572
+ precedingBackslashes = 0;
573
+ }
574
+ return escaped;
575
+ }
576
+
530
577
  function compactText(text, max = 600) {
531
578
  const clean = redactSecrets(String(text || ''))
532
579
  .replace(/\r/g, '\n')
533
580
  .replace(/\n{3,}/g, '\n\n')
534
581
  .trim();
535
- return truncate(clean || 'Não capturado automaticamente.', max);
582
+ const source = clean || 'Não capturado automaticamente.';
583
+ const compact = source.replace(/\s+/g, ' ').trim();
584
+ const clipped = truncate(source, max);
585
+ // Um corte no meio de código inline/fence pode casar com backticks da próxima
586
+ // entrada gerada. Só snippets realmente truncados perdem a formatação incompleta.
587
+ return compact.length > max ? escapeMarkdownBackticks(clipped) : clipped;
536
588
  }
537
589
 
538
590
  function selectTurn(tx, turnId) {
@@ -776,7 +828,7 @@ function relocateOrphanIterations(content) {
776
828
  return insertIntoIteracoes(head, `\n${demoted}`);
777
829
  }
778
830
 
779
- export function insertIteration(sessionPath, block, turnId, tx) {
831
+ export function insertIteration(sessionPath, block, turnId, tx, vaultBase = '') {
780
832
  let inserted = false;
781
833
  // Sob lock: outro hook (subagent-stop) pode estar reescrevendo a mesma nota agora.
782
834
  mutateSessionNote(sessionPath, (original) => {
@@ -790,7 +842,7 @@ export function insertIteration(sessionPath, block, turnId, tx) {
790
842
  content = insertIntoIteracoes(content, block);
791
843
  inserted = true;
792
844
  return applyDedicatedSections(content, tx);
793
- });
845
+ }, { vaultBase });
794
846
  return inserted;
795
847
  }
796
848
 
@@ -821,11 +873,13 @@ export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} }
821
873
  status: 'projected',
822
874
  eventCount: events.length,
823
875
  eventIds,
824
- checkpoint: {
825
- revision: projection.revision,
826
- event_cursor: projection.eventCursor,
827
- state_hash: projection.stateHash,
828
- },
876
+ checkpoint: projection.checkpoint && typeof projection.checkpoint === 'object'
877
+ ? { ...projection.checkpoint }
878
+ : {
879
+ revision: projection.revision,
880
+ event_cursor: projection.eventCursor,
881
+ state_hash: projection.stateHash,
882
+ },
829
883
  };
830
884
  } catch (error) {
831
885
  return {
@@ -956,7 +1010,7 @@ function replaceClosingSection(content, closing) {
956
1010
  return `${content.slice(0, index).trimEnd()}\n\n${closing}\n`;
957
1011
  }
958
1012
 
959
- export function finalizeSessionFile(sessionPath, tx, created, endedAt) {
1013
+ export function finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase = '') {
960
1014
  const pending = extractPending(tx.rawTextForDetection);
961
1015
  const links = (items) => items.length ? items.map((rel) => ` - ${wikilinkFromRel(rel)}`).join('\n') : ' - Nenhuma';
962
1016
  const summary = sessionFinalSummary(tx);
@@ -984,7 +1038,7 @@ ${formatPendingClosing(pending)}
984
1038
  created,
985
1039
  ),
986
1040
  closing,
987
- ));
1041
+ ), { vaultBase });
988
1042
  }
989
1043
 
990
1044
  export function sessionFinalSummary(tx) {
@@ -1073,7 +1127,7 @@ function applyLinearLinks(sessionPath, tx, vaultBase, sessionRel) {
1073
1127
 
1074
1128
  mutateSessionNote(sessionPath, (original) => (
1075
1129
  upsertListSection(ensureSection(original, 'Issues Linear', '\n## Encerramento'), 'Issues Linear', lines, null)
1076
- ));
1130
+ ), { vaultBase });
1077
1131
  }
1078
1132
 
1079
1133
  // Triggers Obsidian Local REST API to re-index the vault after file writes.
@@ -1095,7 +1149,18 @@ function pingObsidianVault(apiKey) {
1095
1149
  } catch {}
1096
1150
  }
1097
1151
 
1098
- function main() {
1152
+ export function shouldAbortStopAfterStaging(causalStop, memoryAttempt) {
1153
+ const rejectedByV2Revalidation = memoryAttempt?.memory_mode === 'v2'
1154
+ && memoryAttempt?.state === 'skipped';
1155
+ if (rejectedByV2Revalidation) return true;
1156
+ return Boolean(
1157
+ causalStop
1158
+ && !causalStop.canPromoteMemory
1159
+ && memoryAttempt?.state !== 'enqueued'
1160
+ );
1161
+ }
1162
+
1163
+ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1099
1164
  const input = readHookInput();
1100
1165
  if (input.stop_hook_active) {
1101
1166
  writeHookOutput({});
@@ -1126,21 +1191,43 @@ function main() {
1126
1191
  return;
1127
1192
  }
1128
1193
 
1129
- const sessionPath = join(vaultBase, sessionRel);
1130
- if (!existsSync(sessionPath)) {
1194
+ const checkedSession = assertVaultPathSafe(vaultBase, join(vaultBase, sessionRel), {
1195
+ expectedType: 'file', label: 'nota de sessão do Stop',
1196
+ });
1197
+ if (!checkedSession.exists) {
1131
1198
  writeHookOutput({});
1132
1199
  return;
1133
1200
  }
1201
+ const sessionPath = checkedSession.target;
1134
1202
 
1135
- const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1136
- const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1203
+ const tx = parseTranscript(identity.transcriptPath || input.transcript_path || input.transcriptPath);
1204
+ const requestedTurnId = String(input.turn_id || input.turnId || '');
1137
1205
  const sessionId = identity.canonicalConversationId;
1138
1206
  const finalizing = shouldFinalizeSession();
1207
+ const turnIdentity = resolveTurnIdentity(tx, requestedTurnId);
1208
+ if (!turnIdentity) {
1209
+ if (finalizing) {
1210
+ const activeId = String(entry.active_activation_id || '');
1211
+ const active = entry.activations?.[activeId] || {};
1212
+ stageMemory(vaultBase, {
1213
+ sessionId,
1214
+ activationId: activeId,
1215
+ activationEpoch: Number(active.epoch || entry.activation_epoch || 0),
1216
+ turnId: requestedTurnId || 'unresolved-turn',
1217
+ turnSequence: Number(entry.last_turn_sequence || 0),
1218
+ disposition: 'ambiguous',
1219
+ observedAt: new Date(0).toISOString(),
1220
+ });
1221
+ }
1222
+ const message = 'wendkeep: Stop ambiguous; o turno solicitado não foi provado pelo transcript.';
1223
+ process.stderr.write(`[wendkeep] ${message}\n`);
1224
+ writeHookOutput({ systemMessage: message });
1225
+ return;
1226
+ }
1227
+ const turnId = turnIdentity.id;
1139
1228
  const now = finalizing ? new Date() : null;
1140
1229
  const endedAt = finalizing ? formatLocalIso(now) : '';
1141
- const stopTurnSequence = Number.isSafeInteger(Number(input.turn_sequence))
1142
- ? Number(input.turn_sequence)
1143
- : Number(entry.last_turn_sequence || 0);
1230
+ const stopTurnSequence = turnIdentity.order;
1144
1231
  const causalStop = finalizing
1145
1232
  ? mutateSessionRegistry(vaultBase, (registry) => {
1146
1233
  const activationId = resolveStopActivation(registry, {
@@ -1152,6 +1239,7 @@ function main() {
1152
1239
  const cas = applyStopActivation(registry, {
1153
1240
  session_id: sessionId,
1154
1241
  activation_id: activationId,
1242
+ turn_id: turnId,
1155
1243
  turn_sequence: stopTurnSequence,
1156
1244
  ended_at: endedAt,
1157
1245
  });
@@ -1176,13 +1264,61 @@ function main() {
1176
1264
  };
1177
1265
  })
1178
1266
  : null;
1179
- if (causalStop && !causalStop.canPromoteMemory) {
1180
- const message = `wendkeep: Stop ${causalStop.stopDisposition}; uma activation mais nova foi preservada e a memória não foi promovida.`;
1267
+ let memoryHandoff = null;
1268
+ let memoryAttempt = null;
1269
+ if (finalizing) {
1270
+ let projectId = '';
1271
+ try {
1272
+ const projectPath = join(vaultBase, '.brain', 'PROJECT.json');
1273
+ let checkedProject = assertVaultPathSafe(vaultBase, projectPath, {
1274
+ expectedType: 'file', label: 'autoridade PROJECT.json do Stop',
1275
+ });
1276
+ if (checkedProject.exists) {
1277
+ checkedProject = assertVaultPathSafe(vaultBase, checkedProject.target, {
1278
+ allowMissing: false, expectedType: 'file', label: 'autoridade PROJECT.json do Stop',
1279
+ });
1280
+ projectId = JSON.parse(readFileSync(checkedProject.target, 'utf8')).projectId || '';
1281
+ }
1282
+ } catch (error) {
1283
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
1284
+ /* the staging validator exposes ordinary missing/invalid PROJECT below */
1285
+ }
1286
+ const finalSummary = sessionFinalSummary(tx);
1287
+ const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1288
+ changeSlug: entry.change_slug,
1289
+ summary: finalSummary,
1290
+ noteRel: sessionRel,
1291
+ });
1292
+ memoryHandoff = {
1293
+ projectId,
1294
+ identity,
1295
+ activation: {
1296
+ id: causalStop?.activationId || '',
1297
+ epoch: Number(causalStop?.activation?.epoch || entry.activation_epoch || 0),
1298
+ },
1299
+ turn: { id: turnId, sequence: stopTurnSequence },
1300
+ noteRel: sessionRel,
1301
+ observedAt: turnIdentity.observedAt || new Date(0).toISOString(),
1302
+ summary: finalSummary,
1303
+ evidence: memoryEvidence,
1304
+ };
1305
+ memoryAttempt = stageMemory(vaultBase, {
1306
+ handoff: memoryHandoff,
1307
+ disposition: causalStop?.stopDisposition || 'ambiguous',
1308
+ });
1309
+ }
1310
+ if (shouldAbortStopAfterStaging(causalStop, memoryAttempt)) {
1311
+ const disposition = memoryAttempt?.disposition || causalStop?.stopDisposition || 'ambiguous';
1312
+ if (disposition === 'duplicate' && memoryAttempt?.state === 'duplicate') {
1313
+ writeHookOutput({});
1314
+ return;
1315
+ }
1316
+ const message = `wendkeep: Stop ${disposition}; uma activation mais nova foi preservada e a memória não foi promovida.`;
1181
1317
  process.stderr.write(`[wendkeep] ${message}\n`);
1182
1318
  writeHookOutput({ systemMessage: message });
1183
1319
  return;
1184
1320
  }
1185
- const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1321
+ const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx, vaultBase);
1186
1322
 
1187
1323
  try {
1188
1324
  applyLinearLinks(sessionPath, tx, vaultBase, sessionRel);
@@ -1191,7 +1327,9 @@ function main() {
1191
1327
  }
1192
1328
 
1193
1329
  try {
1194
- updateSessionObservability({ sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId });
1330
+ updateSessionObservability({
1331
+ vaultBase, sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId,
1332
+ });
1195
1333
  } catch (error) {
1196
1334
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1197
1335
  }
@@ -1226,7 +1364,7 @@ function main() {
1226
1364
  createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
1227
1365
  findLinkedDerivedNotes(vaultBase, sessionRel),
1228
1366
  );
1229
- finalizeSessionFile(sessionPath, tx, created, endedAt);
1367
+ finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase);
1230
1368
  // Link durável sessão↔change: uma seção "Mudanças" ANTES de `## Encerramento`. O append antigo
1231
1369
  // (após o Encerramento) era apagado a cada reopen por stripClosingSection, perdendo a aresta do
1232
1370
  // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
@@ -1239,7 +1377,7 @@ function main() {
1239
1377
  if (wl) {
1240
1378
  mutateSessionNote(sessionPath, (cur) => (
1241
1379
  upsertListSection(ensureSection(cur, 'Mudanças', '\n## Encerramento'), 'Mudanças', [`- ${wl}`], null)
1242
- ));
1380
+ ), { vaultBase });
1243
1381
  }
1244
1382
  } catch { /* nunca derruba o Stop */ }
1245
1383
  writeControl(vaultBase, {
@@ -1252,40 +1390,24 @@ function main() {
1252
1390
  last_logged_turn_id: turnId,
1253
1391
  });
1254
1392
 
1255
- let projectId = '';
1256
- try {
1257
- projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '';
1258
- } catch { /* store validator reports a degraded handoff below */ }
1259
- const finalSummary = sessionFinalSummary(tx);
1260
- const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1261
- changeSlug: entry.change_slug,
1262
- summary: finalSummary,
1263
- noteRel: sessionRel,
1264
- });
1265
- const memoryResult = commitSessionMemory(vaultBase, {
1266
- projectId,
1267
- identity,
1268
- activation: {
1269
- id: causalStop.activationId,
1270
- epoch: Number(causalStop.activation?.epoch || entry.activation_epoch || 0),
1271
- },
1272
- turn: { id: turnId, sequence: stopTurnSequence },
1273
- noteRel: sessionRel,
1274
- observedAt: new Date().toISOString(),
1275
- summary: finalSummary,
1276
- evidence: memoryEvidence,
1277
- });
1278
- mutateSessionRegistry(vaultBase, (registry) => {
1279
- const current = registry.sessions[sessionId];
1280
- if (!current) return null;
1281
- registry.sessions[sessionId] = {
1282
- ...current,
1283
- memory_status: memoryResult.status,
1284
- memory_activation_id: causalStop.activationId,
1285
- ...(memoryResult.checkpoint ? { memory_checkpoint: memoryResult.checkpoint } : {}),
1286
- };
1287
- return null;
1288
- });
1393
+ const memoryResult = projectStopMemoryAttempt(vaultBase, memoryAttempt);
1394
+ if (memoryResult.status === 'legacy') {
1395
+ mutateSessionRegistry(vaultBase, (registry) => {
1396
+ const current = registry.sessions[sessionId];
1397
+ const active = current?.activations?.[current.active_activation_id || ''];
1398
+ if (!current
1399
+ || current.active_activation_id !== memoryAttempt.activation_id
1400
+ || Number(active?.epoch || 0) !== Number(memoryAttempt.activation_epoch || 0)) return null;
1401
+ registry.sessions[sessionId] = {
1402
+ ...current,
1403
+ memory_status: 'legacy',
1404
+ memory_activation_id: memoryAttempt.activation_id,
1405
+ };
1406
+ return null;
1407
+ });
1408
+ } else {
1409
+ recordStopMemoryOutcome(vaultBase, memoryAttempt, memoryResult);
1410
+ }
1289
1411
 
1290
1412
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
1291
1413
  try {