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
@@ -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,7 @@ 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';
19
20
  import {
20
21
  projectStopMemoryAttempt,
21
22
  recordStopMemoryOutcome,
@@ -552,12 +553,38 @@ export function resolveTurnIdentity(transcript, requestedTurnId = '') {
552
553
  };
553
554
  }
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
+
555
577
  function compactText(text, max = 600) {
556
578
  const clean = redactSecrets(String(text || ''))
557
579
  .replace(/\r/g, '\n')
558
580
  .replace(/\n{3,}/g, '\n\n')
559
581
  .trim();
560
- 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;
561
588
  }
562
589
 
563
590
  function selectTurn(tx, turnId) {
@@ -801,7 +828,7 @@ function relocateOrphanIterations(content) {
801
828
  return insertIntoIteracoes(head, `\n${demoted}`);
802
829
  }
803
830
 
804
- export function insertIteration(sessionPath, block, turnId, tx) {
831
+ export function insertIteration(sessionPath, block, turnId, tx, vaultBase = '') {
805
832
  let inserted = false;
806
833
  // Sob lock: outro hook (subagent-stop) pode estar reescrevendo a mesma nota agora.
807
834
  mutateSessionNote(sessionPath, (original) => {
@@ -815,7 +842,7 @@ export function insertIteration(sessionPath, block, turnId, tx) {
815
842
  content = insertIntoIteracoes(content, block);
816
843
  inserted = true;
817
844
  return applyDedicatedSections(content, tx);
818
- });
845
+ }, { vaultBase });
819
846
  return inserted;
820
847
  }
821
848
 
@@ -846,11 +873,13 @@ export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} }
846
873
  status: 'projected',
847
874
  eventCount: events.length,
848
875
  eventIds,
849
- checkpoint: {
850
- revision: projection.revision,
851
- event_cursor: projection.eventCursor,
852
- state_hash: projection.stateHash,
853
- },
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
+ },
854
883
  };
855
884
  } catch (error) {
856
885
  return {
@@ -981,7 +1010,7 @@ function replaceClosingSection(content, closing) {
981
1010
  return `${content.slice(0, index).trimEnd()}\n\n${closing}\n`;
982
1011
  }
983
1012
 
984
- export function finalizeSessionFile(sessionPath, tx, created, endedAt) {
1013
+ export function finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase = '') {
985
1014
  const pending = extractPending(tx.rawTextForDetection);
986
1015
  const links = (items) => items.length ? items.map((rel) => ` - ${wikilinkFromRel(rel)}`).join('\n') : ' - Nenhuma';
987
1016
  const summary = sessionFinalSummary(tx);
@@ -1009,7 +1038,7 @@ ${formatPendingClosing(pending)}
1009
1038
  created,
1010
1039
  ),
1011
1040
  closing,
1012
- ));
1041
+ ), { vaultBase });
1013
1042
  }
1014
1043
 
1015
1044
  export function sessionFinalSummary(tx) {
@@ -1098,7 +1127,7 @@ function applyLinearLinks(sessionPath, tx, vaultBase, sessionRel) {
1098
1127
 
1099
1128
  mutateSessionNote(sessionPath, (original) => (
1100
1129
  upsertListSection(ensureSection(original, 'Issues Linear', '\n## Encerramento'), 'Issues Linear', lines, null)
1101
- ));
1130
+ ), { vaultBase });
1102
1131
  }
1103
1132
 
1104
1133
  // Triggers Obsidian Local REST API to re-index the vault after file writes.
@@ -1162,11 +1191,14 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1162
1191
  return;
1163
1192
  }
1164
1193
 
1165
- const sessionPath = join(vaultBase, sessionRel);
1166
- 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) {
1167
1198
  writeHookOutput({});
1168
1199
  return;
1169
1200
  }
1201
+ const sessionPath = checkedSession.target;
1170
1202
 
1171
1203
  const tx = parseTranscript(identity.transcriptPath || input.transcript_path || input.transcriptPath);
1172
1204
  const requestedTurnId = String(input.turn_id || input.turnId || '');
@@ -1237,8 +1269,20 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1237
1269
  if (finalizing) {
1238
1270
  let projectId = '';
1239
1271
  try {
1240
- projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '';
1241
- } catch { /* the staging validator exposes an observable failure below */ }
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
+ }
1242
1286
  const finalSummary = sessionFinalSummary(tx);
1243
1287
  const memoryEvidence = collectLifecycleEvidence(vaultBase, {
1244
1288
  changeSlug: entry.change_slug,
@@ -1274,7 +1318,7 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1274
1318
  writeHookOutput({ systemMessage: message });
1275
1319
  return;
1276
1320
  }
1277
- const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1321
+ const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx, vaultBase);
1278
1322
 
1279
1323
  try {
1280
1324
  applyLinearLinks(sessionPath, tx, vaultBase, sessionRel);
@@ -1283,7 +1327,9 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1283
1327
  }
1284
1328
 
1285
1329
  try {
1286
- updateSessionObservability({ sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId });
1330
+ updateSessionObservability({
1331
+ vaultBase, sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId,
1332
+ });
1287
1333
  } catch (error) {
1288
1334
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1289
1335
  }
@@ -1318,7 +1364,7 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1318
1364
  createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
1319
1365
  findLinkedDerivedNotes(vaultBase, sessionRel),
1320
1366
  );
1321
- finalizeSessionFile(sessionPath, tx, created, endedAt);
1367
+ finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase);
1322
1368
  // Link durável sessão↔change: uma seção "Mudanças" ANTES de `## Encerramento`. O append antigo
1323
1369
  // (após o Encerramento) era apagado a cada reopen por stripClosingSection, perdendo a aresta do
1324
1370
  // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
@@ -1331,7 +1377,7 @@ export function main({ stageMemory = stageStopMemoryAttempt } = {}) {
1331
1377
  if (wl) {
1332
1378
  mutateSessionNote(sessionPath, (cur) => (
1333
1379
  upsertListSection(ensureSection(cur, 'Mudanças', '\n## Encerramento'), 'Mudanças', [`- ${wl}`], null)
1334
- ));
1380
+ ), { vaultBase });
1335
1381
  }
1336
1382
  } catch { /* nunca derruba o Stop */ }
1337
1383
  writeControl(vaultBase, {
@@ -1,10 +1,12 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
3
  import { createHash } from 'node:crypto';
4
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { ensureDir } from './obsidian-common.mjs';
7
6
  import { getLocale } from './locale.mjs';
7
+ import {
8
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, writeVaultFileSync,
9
+ } from './vault-path-safety.mjs';
8
10
 
9
11
  // Short stable fingerprint of tarefas.md — freshness check between package/verdict and gate.
10
12
  export function tasksHashOf(md) {
@@ -122,8 +124,14 @@ export function livingSpecCapabilities(vaultBase) {
122
124
 
123
125
  export function adoptSpecsState(vaultBase) {
124
126
  const state = { version: 1, generatedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
125
- ensureDir(join(vaultBase, '.brain'));
126
- writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
127
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz do estado de specs' });
128
+ writeVaultFileSync(
129
+ vaultBase,
130
+ join(vaultBase, SPECS_STATE_FILE),
131
+ `${JSON.stringify(state, null, 2)}\n`,
132
+ 'utf8',
133
+ { label: 'estado consolidado de specs' },
134
+ );
127
135
  return state;
128
136
  }
129
137
 
@@ -150,17 +158,32 @@ function recordPromotedSpecs(vaultBase, capabilities) {
150
158
  else delete specs[capability];
151
159
  }
152
160
  const state = { version: 1, generatedAt: new Date().toISOString(), specs };
153
- writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
161
+ writeVaultFileSync(
162
+ vaultBase,
163
+ join(vaultBase, SPECS_STATE_FILE),
164
+ `${JSON.stringify(state, null, 2)}\n`,
165
+ 'utf8',
166
+ { label: 'estado consolidado de specs' },
167
+ );
154
168
  return state;
155
169
  }
156
170
 
157
171
  export function captureSpecBaseline(vaultBase, changeDir, { refresh = false } = {}) {
158
172
  const path = join(changeDir, SPEC_BASELINE_FILE);
159
- if (!refresh && existsSync(path)) {
173
+ const checked = assertVaultPathSafe(vaultBase, path, {
174
+ expectedType: 'file', label: 'baseline de specs da change',
175
+ });
176
+ if (!refresh && checked.exists) {
160
177
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { /* rebuild malformed baseline */ }
161
178
  }
162
179
  const baseline = { version: 1, capturedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
163
- writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
180
+ writeVaultFileSync(
181
+ vaultBase,
182
+ path,
183
+ `${JSON.stringify(baseline, null, 2)}\n`,
184
+ 'utf8',
185
+ { label: 'baseline de specs da change' },
186
+ );
164
187
  return baseline;
165
188
  }
166
189
 
@@ -325,7 +348,7 @@ export function ensureSpecsReadme(vaultBase) {
325
348
  const loc = getLocale(vaultBase);
326
349
  const en = loc.id === 'en';
327
350
  const dir = join(vaultBase, loc.folders.specs);
328
- ensureDir(dir);
351
+ mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
329
352
  const body = en
330
353
  ? `# Specs — generated living contract
331
354
 
@@ -355,7 +378,48 @@ Pense como código-fonte vs commits: esta pasta é o *código atual* de cada cap
355
378
  \`wendkeep change archive\` promove para esta pasta.
356
379
  - Histórico por mudança → \`${loc.folders.changes}/_arquivo/\`. Contrato atual → aqui.
357
380
  `;
358
- writeFileSync(join(dir, 'README.md'), body, 'utf8');
381
+ writeVaultFileSync(vaultBase, join(dir, 'README.md'), body, 'utf8', { label: 'README de specs' });
382
+ }
383
+
384
+ export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
385
+ const loc = getLocale(vaultBase);
386
+ const specsRoot = join(vaultBase, loc.folders.specs);
387
+ const checkedRoot = assertVaultPathSafe(vaultBase, specsRoot, {
388
+ expectedType: 'directory', label: 'raiz de specs consolidadas',
389
+ });
390
+ const targets = [
391
+ { path: join(specsRoot, 'README.md'), expectedType: 'file', label: 'README de specs' },
392
+ { path: join(vaultBase, '.brain'), expectedType: 'directory', label: 'raiz do estado de specs' },
393
+ { path: join(vaultBase, SPECS_STATE_FILE), expectedType: 'file', label: 'estado consolidado de specs' },
394
+ ];
395
+ if (checkedRoot.exists) {
396
+ for (const name of readdirSync(checkedRoot.target)) {
397
+ if (!name.endsWith('.md')) continue;
398
+ targets.push({
399
+ path: join(checkedRoot.target, name),
400
+ allowMissing: false,
401
+ expectedType: 'file',
402
+ label: `spec consolidada existente ${name}`,
403
+ });
404
+ }
405
+ }
406
+ for (const capability of specs) {
407
+ targets.push(
408
+ {
409
+ path: join(changeDir, 'specs', capability, 'spec.md'),
410
+ allowMissing: false,
411
+ expectedType: 'file',
412
+ label: `delta da spec ${capability}`,
413
+ },
414
+ {
415
+ path: join(specsRoot, `${capability}.md`),
416
+ expectedType: 'file',
417
+ label: `spec consolidada ${capability}`,
418
+ },
419
+ );
420
+ }
421
+ assertVaultPathsSafe(vaultBase, targets);
422
+ return { specsRoot: checkedRoot.target };
359
423
  }
360
424
 
361
425
  // Merge each capability's delta (in the change) into the living spec in 07-Specs.
@@ -364,6 +428,7 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
364
428
  const specsDir = loc.folders.specs;
365
429
  const promoted = [];
366
430
  const warnings = [];
431
+ const { specsRoot } = assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs);
367
432
  const state = checkSpecsState(vaultBase);
368
433
  const unmanaged = state.missing ? [] : state.changed.filter((capability) => specs.includes(capability));
369
434
  if (unmanaged.length) {
@@ -371,6 +436,7 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
371
436
  }
372
437
  const conflicts = specConflicts(vaultBase, changeDir, specs);
373
438
  if (conflicts.length) throw new Error(`conflito de spec: ${conflicts.join('; ')} — reconcilie o delta e rode \`wendkeep spec rebase --change <slug> --accept-current\``);
439
+ const materialized = [];
374
440
  for (const cap of specs) {
375
441
  let deltaMd;
376
442
  try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
@@ -382,10 +448,23 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
382
448
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
383
449
  const applied = applyDelta(current, delta);
384
450
  warnings.push(...applied.warnings.map((w) => `${cap}: ${w}`));
385
- ensureDir(join(vaultBase, specsDir));
386
451
  const footer = changeWikilink ? `Atualizado por ${changeWikilink} em ${dateStr}.` : '';
387
- writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }), 'utf8');
388
- promoted.push(cap);
452
+ materialized.push({
453
+ capability: cap,
454
+ livePath,
455
+ content: renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }),
456
+ });
457
+ }
458
+ mkdirVaultPath(vaultBase, specsRoot, { label: 'raiz de specs consolidadas' });
459
+ for (const item of materialized) {
460
+ writeVaultFileSync(
461
+ vaultBase,
462
+ item.livePath,
463
+ item.content,
464
+ 'utf8',
465
+ { label: `spec consolidada ${item.capability}` },
466
+ );
467
+ promoted.push(item.capability);
389
468
  }
390
469
  recordPromotedSpecs(vaultBase, promoted);
391
470
  ensureSpecsReadme(vaultBase); // self-heal the explainer so existing vaults get it on archive
@@ -20,7 +20,10 @@ export function refreshSubagents(vaultBase, input) {
20
20
  if (!sessionRel) return false;
21
21
  const sessionPath = join(vaultBase, sessionRel);
22
22
  if (!existsSync(sessionPath)) return false;
23
- updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
23
+ updateSessionObservability({
24
+ vaultBase, sessionPath, transcriptPath, caller: 'subagent-stop',
25
+ canonicalConversationId: identity.canonicalConversationId,
26
+ });
24
27
  return true;
25
28
  }
26
29
 
@@ -340,7 +340,7 @@ function upsertSection(content, heading, body) {
340
340
  }
341
341
 
342
342
  // Stop-hook entry: scan the session's subagents/workflows, fold into the note. Fail-open.
343
- export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs } = {}) {
343
+ export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs, vaultBase = '' } = {}) {
344
344
  if (!sessionPath || !existsSync(sessionPath)) return false;
345
345
  const collected = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
346
346
  if (!collected) return false;
@@ -377,6 +377,6 @@ export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs
377
377
  };
378
378
  content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
379
379
  return upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
380
- }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
380
+ }, { ...(lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {}), vaultBase });
381
381
  return outcome.written;
382
382
  }
@@ -52,7 +52,9 @@ export function logTask(vaultBase, input) {
52
52
 
53
53
  const heading = getLocale(vaultBase).id === 'en' ? 'Plan progress' : 'Progresso do plano';
54
54
  const line = `- [x] ${formatHourMinute(new Date()).replace('-', ':')} ${text}`;
55
- return mutateSessionNote(sessionPath, (content) => appendProgress(content, line, heading)).written;
55
+ return mutateSessionNote(sessionPath, (content) => appendProgress(content, line, heading), {
56
+ vaultBase,
57
+ }).written;
56
58
  }
57
59
 
58
60
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -986,7 +986,7 @@ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcr
986
986
  result = collectSessionUsage({ sessionContent, transcriptPath });
987
987
  if (!result) return null; // sem usage OU conteúdo corrompido: não grava
988
988
  return upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
989
- }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
989
+ }, { ...(lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {}), vaultBase });
990
990
  return outcome.written || outcome.reason === 'unchanged' ? result : null;
991
991
  }
992
992