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
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
- import { releaseLockDir } from './session-note-io.mjs';
3
+ import { LOCK_BUSY, mutateSessionNote, withPathLock } from './session-note-io.mjs';
4
4
  import { basename, dirname, join, relative } from 'path';
5
5
  import { getLocale } from './locale.mjs';
6
6
  import { resolveProjectVault } from '../src/project-vault.mjs';
7
+ import {
8
+ assertVaultPathSafe, mkdirVaultPath, writeVaultFileAtomic,
9
+ } from './vault-path-safety.mjs';
7
10
 
8
11
  // Deprecated export kept for consumers that imported it before 0.39.0. Automatic
9
12
  // hooks never use this fallback: an unbound project fails closed.
@@ -212,9 +215,12 @@ export function yamlQuote(value = '') {
212
215
 
213
216
  export function readControl(vaultBase) {
214
217
  const path = controlPath(vaultBase);
215
- if (!existsSync(path)) return {};
218
+ const checked = assertVaultPathSafe(vaultBase, path, {
219
+ expectedType: 'file', label: 'CURRENT_SESSION.md',
220
+ });
221
+ if (!checked.exists) return {};
216
222
 
217
- const content = readFileSync(path, 'utf-8');
223
+ const content = readFileSync(checked.target, 'utf-8');
218
224
  const match = content.match(/^---\n([\s\S]*?)\n---/);
219
225
  if (!match) return {};
220
226
 
@@ -228,7 +234,10 @@ export function readControl(vaultBase) {
228
234
 
229
235
  export function writeControl(vaultBase, data) {
230
236
  const path = controlPath(vaultBase);
231
- ensureDir(dirname(path));
237
+ mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do CURRENT_SESSION' });
238
+ assertVaultPathSafe(vaultBase, path, {
239
+ expectedType: 'file', label: 'CURRENT_SESSION.md',
240
+ });
232
241
 
233
242
  const status = data.status || 'inactive';
234
243
  const sessionFile = data.session_file || '';
@@ -275,15 +284,20 @@ ${activeRows}
275
284
  Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
276
285
  `;
277
286
 
278
- writeFileSync(path, content, 'utf-8');
287
+ writeVaultFileAtomic(vaultBase, path, content, 'utf-8', {
288
+ label: 'CURRENT_SESSION.md',
289
+ });
279
290
  }
280
291
 
281
292
  export function readSessionRegistry(vaultBase) {
282
293
  const path = registryPath(vaultBase);
283
- if (!existsSync(path)) return { version: 2, sessions: {} };
294
+ const checked = assertVaultPathSafe(vaultBase, path, {
295
+ expectedType: 'file', label: 'SESSION_REGISTRY.json',
296
+ });
297
+ if (!checked.exists) return { version: 2, sessions: {} };
284
298
 
285
299
  try {
286
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
300
+ const parsed = JSON.parse(readFileSync(checked.target, 'utf-8'));
287
301
  return {
288
302
  version: Math.max(2, parsed.version || 1),
289
303
  sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
@@ -295,48 +309,18 @@ export function readSessionRegistry(vaultBase) {
295
309
 
296
310
  export function writeSessionRegistry(vaultBase, registry) {
297
311
  const path = registryPath(vaultBase);
298
- ensureDir(dirname(path));
312
+ mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do SESSION_REGISTRY' });
299
313
  // Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
300
314
  // evitando registry truncado/corrompido quando dois hooks gravam ao mesmo tempo.
301
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
302
- writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
303
- renameSync(tmp, path);
304
- }
305
-
306
- function registryLockPath(vaultBase) {
307
- return `${registryPath(vaultBase)}.lock`;
308
- }
309
-
310
- function waitBriefly(ms) {
311
- const signal = new Int32Array(new SharedArrayBuffer(4));
312
- Atomics.wait(signal, 0, 0, ms);
315
+ writeVaultFileAtomic(vaultBase, path, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8', {
316
+ label: 'SESSION_REGISTRY.json',
317
+ });
313
318
  }
314
319
 
315
320
  export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } = {}) {
316
- const lock = registryLockPath(vaultBase);
317
- ensureDir(dirname(lock));
318
- const deadline = Date.now() + timeoutMs;
319
- while (true) {
320
- try {
321
- mkdirSync(lock);
322
- break;
323
- } catch (error) {
324
- if (error?.code === 'EEXIST') {
325
- try {
326
- if (Date.now() - statSync(lock).mtimeMs > 10_000) {
327
- releaseLockDir(lock);
328
- continue;
329
- }
330
- } catch { /* outro processo pode ter liberado o lock */ }
331
- }
332
- if (error?.code !== 'EEXIST' || Date.now() >= deadline) {
333
- throw new Error(`SESSION_REGISTRY lock indisponível: ${error.message}`);
334
- }
335
- waitBriefly(10);
336
- }
337
- }
338
-
339
- try {
321
+ const path = registryPath(vaultBase);
322
+ mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do SESSION_REGISTRY' });
323
+ const outcome = withPathLock(path, () => {
340
324
  const registry = readSessionRegistry(vaultBase);
341
325
  const before = JSON.stringify(registry);
342
326
  registry.version = 2;
@@ -345,11 +329,11 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
345
329
  writeSessionRegistry(vaultBase, registry);
346
330
  }
347
331
  return result;
348
- } finally {
349
- // rmSync recursivo não remove diretório em caminho não-ASCII no Windows — ver
350
- // releaseLockDir. Vault sob pasta acentuada travaria o registry após a 1ª mutação.
351
- releaseLockDir(lock);
332
+ }, { timeoutMs, vaultBase });
333
+ if (outcome === LOCK_BUSY) {
334
+ throw new Error('SESSION_REGISTRY lock indisponível: lock ocupado até o timeout.');
352
335
  }
336
+ return outcome;
353
337
  }
354
338
 
355
339
  function meaningfulPatch(patch = {}) {
@@ -756,13 +740,13 @@ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs =
756
740
  // já fechada com o mesmo `endedAt`). Devolve true se gravou.
757
741
  export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
758
742
  if (!sessionFileRel) return false;
759
- const path = join(vaultBase, sessionFileRel);
760
- if (!existsSync(path)) return false;
761
- const content = readFileSync(path, 'utf-8');
762
- const next = closeSessionNote(content, endedAt);
763
- if (next === content) return false;
764
- writeFileSync(path, next, 'utf-8');
765
- return true;
743
+ const checked = assertVaultPathSafe(vaultBase, join(vaultBase, sessionFileRel), {
744
+ expectedType: 'file', label: 'nota de sessão encerrada',
745
+ });
746
+ if (!checked.exists) return false;
747
+ return mutateSessionNote(checked.target, (content) => closeSessionNote(content, endedAt), {
748
+ vaultBase,
749
+ }).written;
766
750
  }
767
751
 
768
752
  // Marca de sessão ainda aberta no corpo da nota (template do hook de início).
@@ -0,0 +1,157 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import {
5
+ DEFAULT_OPERATING_PROFILE,
6
+ normalizeOperatingProfile,
7
+ operatingProfilePolicy,
8
+ resolveOperatingProfile,
9
+ } from '../src/operating-profile.mjs';
10
+ import { findProjectBinding, resolveProjectVault } from '../src/project-vault.mjs';
11
+ import { readSessionRegistry } from './obsidian-common.mjs';
12
+ import { resolveSessionIdentity } from './session-identity.mjs';
13
+
14
+ function bindingDiagnostic(error, configPath = '') {
15
+ return {
16
+ code: error?.code || 'WENDKEEP_VAULT_CONFIG_INVALID',
17
+ message: error?.message
18
+ || `Configuração WendKeep inválida${configPath ? ` em "${configPath}"` : ''}.`,
19
+ };
20
+ }
21
+
22
+ function readResolvedConfig(resolution, suppliedConfig) {
23
+ if (suppliedConfig && typeof suppliedConfig === 'object' && !Array.isArray(suppliedConfig)) {
24
+ return { config: suppliedConfig, bindingError: null };
25
+ }
26
+ if (resolution?.config && typeof resolution.config === 'object' && !Array.isArray(resolution.config)) {
27
+ return { config: resolution.config, bindingError: null };
28
+ }
29
+ if (!resolution?.configPath || !existsSync(resolution.configPath)) {
30
+ return { config: {}, bindingError: null };
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(resolution.configPath, 'utf8'));
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
35
+ return { config: parsed, bindingError: null };
36
+ }
37
+ return {
38
+ config: {},
39
+ bindingError: bindingDiagnostic(null, resolution.configPath),
40
+ };
41
+ } catch (error) {
42
+ return {
43
+ config: {},
44
+ bindingError: bindingDiagnostic(error, resolution.configPath),
45
+ };
46
+ }
47
+ }
48
+
49
+ function sessionOverride(entry) {
50
+ if (!entry || !Object.prototype.hasOwnProperty.call(entry, 'operating_profile')) return null;
51
+ const raw = entry.operating_profile;
52
+ try {
53
+ return {
54
+ profile: normalizeOperatingProfile(raw, { strict: true }),
55
+ source: 'session-override',
56
+ valid: true,
57
+ configured: true,
58
+ raw,
59
+ };
60
+ } catch {
61
+ return {
62
+ profile: DEFAULT_OPERATING_PROFILE,
63
+ source: 'session-override-invalid',
64
+ valid: false,
65
+ configured: true,
66
+ raw,
67
+ };
68
+ }
69
+ }
70
+
71
+ function canonicalPath(value) {
72
+ const path = resolve(value).replaceAll('\\', '/');
73
+ return process.platform === 'win32' ? path.toLowerCase() : path;
74
+ }
75
+
76
+ function matchingProjectBinding(vaultResolution, input) {
77
+ if (vaultResolution?.config) {
78
+ return { binding: null, bindingError: vaultResolution.bindingError || null };
79
+ }
80
+ if (vaultResolution?.bindingError) {
81
+ return { binding: null, bindingError: vaultResolution.bindingError };
82
+ }
83
+ const start = input?.cwd
84
+ || input?.project_dir
85
+ || input?.projectDir
86
+ || input?.workspace?.cwd
87
+ || vaultResolution?.projectRoot
88
+ || process.cwd();
89
+ try {
90
+ const binding = findProjectBinding(start);
91
+ return {
92
+ binding: binding && canonicalPath(binding.base) === canonicalPath(vaultResolution.base)
93
+ ? binding
94
+ : null,
95
+ bindingError: vaultResolution.bindingError || null,
96
+ };
97
+ } catch (error) {
98
+ // An explicitly supplied vault is authoritative even when an unrelated
99
+ // nearby project config is malformed, but corruption remains observable.
100
+ if (input?.obsidian_vault_path
101
+ || ['explicit', 'payload', 'legacy-project-settings'].includes(vaultResolution?.source)) {
102
+ return { binding: null, bindingError: bindingDiagnostic(error) };
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ // Resolution precedence for hooks: explicit session override -> project binding -> GOVERN.
109
+ // Binding corruption is never interpreted as OFF: an authoritative Vault keeps the Keep Core
110
+ // alive under GOVERN and carries a visible diagnostic to each entrypoint.
111
+ export function resolveHookOperatingProfile({
112
+ input = {},
113
+ resolution = null,
114
+ config = null,
115
+ provider,
116
+ } = {}) {
117
+ const initialResolution = resolution || resolveProjectVault({ input });
118
+ const matched = matchingProjectBinding(initialResolution, input);
119
+ const binding = matched.binding;
120
+ const vaultResolution = binding ? {
121
+ ...initialResolution,
122
+ projectRoot: binding.projectRoot,
123
+ projectId: binding.config.projectId,
124
+ configPath: binding.configPath,
125
+ config: binding.config,
126
+ } : initialResolution;
127
+ const configState = readResolvedConfig(vaultResolution, config);
128
+ const bindingError = initialResolution.bindingError
129
+ || matched.bindingError
130
+ || configState.bindingError
131
+ || null;
132
+ const project = resolveOperatingProfile(bindingError ? {} : configState.config);
133
+ const identity = resolveSessionIdentity(vaultResolution.base, input, provider);
134
+ const entry = identity.state === 'resolved'
135
+ ? readSessionRegistry(vaultResolution.base).sessions?.[identity.canonicalConversationId] || null
136
+ : null;
137
+ const selected = sessionOverride(entry) || project;
138
+ return {
139
+ ...selected,
140
+ policy: operatingProfilePolicy(selected.profile),
141
+ vaultBase: vaultResolution.base,
142
+ projectRoot: vaultResolution.projectRoot,
143
+ identity,
144
+ entry,
145
+ resolution: vaultResolution,
146
+ ...(bindingError ? { bindingError } : {}),
147
+ };
148
+ }
149
+
150
+ export function hookProfilePolicy(profile = DEFAULT_OPERATING_PROFILE) {
151
+ return operatingProfilePolicy(profile);
152
+ }
153
+
154
+ // Prefix the profile so change-core's 64-char filename cap can never truncate it away.
155
+ export function profileSentinelId(sessionId, profile = DEFAULT_OPERATING_PROFILE) {
156
+ return `${normalizeOperatingProfile(profile).toLowerCase()}--${sessionId || 'nosession'}`;
157
+ }
@@ -9,7 +9,6 @@ import { join } from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import {
11
11
  formatDate,
12
- getVaultBase,
13
12
  readHookInput,
14
13
  slugify,
15
14
  upsertSessionRegistry,
@@ -18,6 +17,7 @@ import {
18
17
  } from './obsidian-common.mjs';
19
18
  import { activeChange, newChange } from './change-core.mjs';
20
19
  import { getLocale } from './locale.mjs';
20
+ import { hookProfilePolicy, resolveHookOperatingProfile } from './operating-profile-runtime.mjs';
21
21
  import { resolveSessionEntry } from './session-identity.mjs';
22
22
 
23
23
  // O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
@@ -87,7 +87,9 @@ export function planTasks(plan) {
87
87
  return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
88
88
  }
89
89
 
90
- export function capturePlan(vaultBase, input) {
90
+ export function capturePlan(vaultBase, input, { profile = 'GOVERN' } = {}) {
91
+ const policy = hookProfilePolicy(profile);
92
+ if (!policy.harness) return null;
91
93
  const plan = extractPlan(input);
92
94
  if (!plan) return null;
93
95
  const loc = getLocale(vaultBase);
@@ -111,6 +113,10 @@ export function capturePlan(vaultBase, input) {
111
113
  };
112
114
  }
113
115
 
116
+ // FLOW deliberately has no implicit change lifecycle. It may enrich a change the user
117
+ // explicitly opened, but an approved plan alone must not create one.
118
+ if (!policy.requiresChange) return null;
119
+
114
120
  const slug = planSlug(plan);
115
121
  newChange(vaultBase, slug, { dateStr, sessionRel });
116
122
  upsertSessionRegistry(vaultBase, identity.canonicalConversationId, { change_slug: slug });
@@ -156,7 +162,12 @@ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mod
156
162
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
157
163
  try {
158
164
  const input = readHookInput();
159
- const r = capturePlan(getVaultBase(input), input);
165
+ const runtime = resolveHookOperatingProfile({ input });
166
+ if (runtime.bindingError) {
167
+ const code = runtime.bindingError.code || 'WENDKEEP_VAULT_CONFIG_INVALID';
168
+ throw new Error(`${code}: ${runtime.bindingError.message || 'binding WendKeep inválido'}`);
169
+ }
170
+ const r = capturePlan(runtime.vaultBase, input, { profile: runtime.profile });
160
171
  if (!r) { writeHookOutput({}); }
161
172
  else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
162
173
  } catch (error) {
@@ -5,6 +5,16 @@ import { spawnSync } from 'node:child_process';
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { dirname, join, resolve } from 'node:path';
7
7
 
8
+ export const SENSOR_VAULT_ENV = 'WENDKEEP_SENSOR_VAULT';
9
+
10
+ export function sensorProcessEnv(vaultBase, inherited = process.env) {
11
+ return {
12
+ ...inherited,
13
+ OBSIDIAN_VAULT_PATH: vaultBase,
14
+ [SENSOR_VAULT_ENV]: vaultBase,
15
+ };
16
+ }
17
+
8
18
  export function loadSensors(projectRoot, file = 'wendkeep.sensors.json') {
9
19
  return loadSensorsDetailed(projectRoot, file).sensors;
10
20
  }
@@ -37,17 +47,19 @@ export function findProjectRoot(startDir) {
37
47
  }
38
48
 
39
49
  export function requiredSensors(tasks) {
40
- return [...new Set((tasks || []).map((t) => t.sensor).filter(Boolean))];
50
+ return [...new Set((tasks || []).flatMap((task) => (
51
+ Array.isArray(task.sensors) && task.sensors.length ? task.sensors : [task.sensor]
52
+ )).filter(Boolean))];
41
53
  }
42
54
 
43
- export function runSensors(sensors, ids, { spawn = spawnSync, cwd, now } = {}) {
55
+ export function runSensors(sensors, ids, { spawn = spawnSync, cwd, env, now } = {}) {
44
56
  const byId = Object.fromEntries((sensors || []).map((s) => [s.id, s]));
45
57
  const ts = now || new Date().toISOString();
46
58
  const evidence = [];
47
59
  for (const id of ids) {
48
60
  const s = byId[id];
49
61
  if (!s) { evidence.push({ id, status: 'red', ts, severity: 'critical', note: 'sensor não definido' }); continue; }
50
- const r = spawn(s.command, [], { cwd, shell: true, stdio: 'ignore' });
62
+ const r = spawn(s.command, [], { cwd, shell: true, stdio: 'ignore', ...(env ? { env } : {}) });
51
63
  const entry = { id, status: (r.status ?? 1) === 0 ? 'green' : 'red', ts, severity: s.severity || 'critical' };
52
64
  if (s.type === 'mutation' && s.report) {
53
65
  // Delegated mutation (Wave B): read the tool's mutation-testing-elements report and
@@ -12,6 +12,7 @@ import {
12
12
  readControl,
13
13
  readSessionRegistry,
14
14
  } from './obsidian-common.mjs';
15
+ import { assertVaultPathSafe } from './vault-path-safety.mjs';
15
16
 
16
17
  function parseArgs(argv) {
17
18
  const args = { write: false, limit: 0, session: '' };
@@ -71,8 +72,11 @@ export function backfillSessions({ vaultBase, write = false, limit = 0, session
71
72
  if (args.limit && report.scanned >= args.limit) break;
72
73
  report.scanned += 1;
73
74
 
74
- const sessionPath = join(vaultBase, entry.session_file);
75
- if (!existsSync(sessionPath) || !existsSync(entry.transcript_path)) {
75
+ const checkedSession = assertVaultPathSafe(vaultBase, join(vaultBase, entry.session_file), {
76
+ expectedType: 'file', label: 'nota de sessão do backfill',
77
+ });
78
+ const sessionPath = checkedSession.target;
79
+ if (!checkedSession.exists || !existsSync(entry.transcript_path)) {
76
80
  report.missing.push({
77
81
  session: entry.session_file,
78
82
  sessionExists: existsSync(sessionPath),
@@ -106,6 +110,7 @@ export function backfillSessions({ vaultBase, write = false, limit = 0, session
106
110
  buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp }),
107
111
  turn.turnId,
108
112
  tx,
113
+ vaultBase,
109
114
  );
110
115
  if (inserted) {
111
116
  report.inserted += 1;
@@ -214,7 +214,7 @@ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
214
214
  const sessionPath = join(vaultBase, nextRelPath);
215
215
  const outcome = mutateSessionNote(sessionPath, (content) => (
216
216
  updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
217
- ));
217
+ ), { vaultBase });
218
218
 
219
219
  return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || outcome.written };
220
220
  }
@@ -226,8 +226,10 @@ function stripClosingSection(content) {
226
226
  return `${content.slice(0, index).trimEnd()}\n`;
227
227
  }
228
228
 
229
- function reopenSessionFile(sessionPath) {
230
- mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)));
229
+ function reopenSessionFile(vaultBase, sessionPath) {
230
+ mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)), {
231
+ vaultBase,
232
+ });
231
233
  }
232
234
 
233
235
  function findSessionForInput(vaultBase, input, control) {
@@ -260,7 +262,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
260
262
  const sessionPath = join(vaultBase, relPath);
261
263
  if (!existsSync(sessionPath)) return false;
262
264
 
263
- reopenSessionFile(sessionPath);
265
+ reopenSessionFile(vaultBase, sessionPath);
264
266
  const nextStartedAt = startedAt || formatLocalIso(now);
265
267
  writeControl(vaultBase, {
266
268
  status: 'active',
@@ -0,0 +1,65 @@
1
+ import { hasTurnMarker, normalizeTurnMarkers, turnMarker } from './obsidian-common.mjs';
2
+ import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
3
+
4
+ const ITERATION_ANCHORS = [
5
+ '\n## Agentes, tokens e custos',
6
+ '\n## Uso de tokens e custos',
7
+ '\n## Decisões geradas nesta sessão',
8
+ '\n## Bugs gerados nesta sessão',
9
+ '\n## Aprendizados gerados nesta sessão',
10
+ '\n## Arquivos consultados',
11
+ '\n## Arquivos criados ou alterados',
12
+ '\n## Pendências',
13
+ '\n## Encerramento',
14
+ ];
15
+
16
+ export function insertIterationContent(original, { markerId, block }) {
17
+ if (!markerId) throw new TypeError('markerId é obrigatório');
18
+ let content = normalizeTurnMarkers(String(original || ''));
19
+ if (hasTurnMarker(content, markerId)) return { content, inserted: false };
20
+
21
+ const rendered = `\n${String(block || '').trim()}\n${turnMarker(markerId)}\n`;
22
+ const iterations = content.indexOf('\n## Iterações');
23
+ if (iterations !== -1) {
24
+ const anchors = ITERATION_ANCHORS
25
+ .map((anchor) => content.indexOf(anchor, iterations + 1))
26
+ .filter((index) => index !== -1)
27
+ .sort((left, right) => left - right);
28
+ if (anchors.length) {
29
+ const at = anchors[0];
30
+ content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
31
+ } else {
32
+ const lineEnd = content.indexOf('\n', iterations + 1);
33
+ const at = lineEnd === -1 ? content.length : lineEnd + 1;
34
+ content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
35
+ }
36
+ return { content, inserted: true };
37
+ }
38
+
39
+ const closing = content.indexOf('\n## Encerramento');
40
+ if (closing !== -1) {
41
+ content = `${content.slice(0, closing).trimEnd()}\n\n## Iterações\n${rendered}\n${content.slice(closing).replace(/^\n+/, '')}`;
42
+ } else {
43
+ content = `${content.trimEnd()}\n\n## Iterações\n${rendered}`;
44
+ }
45
+ return { content, inserted: true };
46
+ }
47
+
48
+ export function projectSessionIteration(sessionPath, input, options = {}) {
49
+ let inserted = false;
50
+ let invalidFrontmatter = false;
51
+ const outcome = mutateSessionNote(sessionPath, (content) => {
52
+ if (!hasSessionFrontmatter(content)) {
53
+ invalidFrontmatter = true;
54
+ return null;
55
+ }
56
+ const result = insertIterationContent(content, input);
57
+ inserted = result.inserted;
58
+ return result.content;
59
+ }, options);
60
+ return {
61
+ inserted,
62
+ written: outcome.written,
63
+ reason: invalidFrontmatter ? 'invalid-frontmatter' : outcome.reason,
64
+ };
65
+ }
@@ -246,11 +246,16 @@ export function projectStopMemoryAttempt(vaultBase, attempt, overrides = {}) {
246
246
  });
247
247
  }
248
248
  return outcome(attempt, 'projected', {
249
- checkpoint: {
250
- revision: projection.revision,
251
- event_cursor: projection.eventCursor,
252
- state_hash: projection.stateHash,
253
- },
249
+ checkpoint: projection.checkpoint && typeof projection.checkpoint === 'object'
250
+ ? { ...projection.checkpoint }
251
+ : {
252
+ revision: projection.revision,
253
+ event_cursor: projection.ledgerCursor || projection.eventCursor,
254
+ state_hash: projection.stateHash,
255
+ ...(projection.ledgerCursor && projection.eventCursor !== projection.ledgerCursor
256
+ ? { causal_event_cursor: projection.eventCursor }
257
+ : {}),
258
+ },
254
259
  });
255
260
  } catch (error) {
256
261
  return outcome(attempt, 'degraded', {