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
@@ -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,17 +329,17 @@ 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 = {}) {
356
340
  const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug', 'activation_id']);
357
341
  return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
358
- if (key === 'advance_turn_sequence' || key === 'turn_sequence') return false;
342
+ if (['advance_turn_sequence', 'turn_sequence', 'turn_id', 'last_turn_sequence', 'recovery_activation_id', 'recovery_started_at'].includes(key)) return false;
359
343
  if (value === undefined || value === null) return false;
360
344
  if (value === '' && protectedNonEmpty.has(key)) return false;
361
345
  return true;
@@ -390,9 +374,13 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
390
374
 
391
375
  const current = next.sessions[sessionId] || {};
392
376
  const activations = { ...(current.activations || {}) };
377
+ const openedAfterSequence = nonNegativeSequence(
378
+ session.opened_after_turn_sequence,
379
+ nonNegativeSequence(current.last_turn_sequence, 0),
380
+ );
393
381
  const requestedSequence = nonNegativeSequence(
394
382
  session.turn_sequence ?? session.last_turn_sequence,
395
- 0,
383
+ openedAfterSequence,
396
384
  );
397
385
 
398
386
  if (current.active_activation_id === activationId && activations[activationId]?.status === 'active') {
@@ -430,7 +418,9 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
430
418
  epoch,
431
419
  status: 'active',
432
420
  started_at: session.activation_started_at || session.started_at || '',
433
- last_turn_sequence: requestedSequence,
421
+ opened_after_turn_sequence: openedAfterSequence,
422
+ ...(current.last_turn_id ? { opened_after_turn_id: current.last_turn_id } : {}),
423
+ last_turn_sequence: Math.max(openedAfterSequence, requestedSequence),
434
424
  ...(session.transcript_id ? { transcript_id: session.transcript_id } : {}),
435
425
  ...(session.transcript_path ? { transcript_path: session.transcript_path } : {}),
436
426
  ...(session.provider ? { provider: session.provider } : {}),
@@ -441,19 +431,40 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
441
431
  active_activation_id: activationId,
442
432
  activation_epoch: epoch,
443
433
  activation_started_at: session.activation_started_at || session.started_at || '',
444
- last_turn_sequence: requestedSequence,
434
+ last_turn_sequence: Math.max(openedAfterSequence, requestedSequence),
445
435
  activations,
446
436
  };
447
437
  return next;
448
438
  }
449
439
 
450
440
  export function advanceActivationTurn(registry, turn = {}) {
451
- const next = cloneRegistry(registry);
441
+ let next = cloneRegistry(registry);
452
442
  const sessionId = turn.session_id || turn.canonical_session_id || '';
453
- const current = next.sessions[sessionId];
443
+ let current = next.sessions[sessionId];
454
444
  if (!current) return next;
455
445
 
456
- const activeId = current.active_activation_id || '';
446
+ const turnId = String(turn.turn_id || '');
447
+ const knownTurn = Boolean(turnId && (
448
+ current.last_turn_id === turnId
449
+ || current.last_prompt_turn_id === turnId
450
+ || Object.prototype.hasOwnProperty.call(current.turn_sequences || {}, turnId)
451
+ ));
452
+ if (knownTurn) return next;
453
+
454
+ let activeId = current.active_activation_id || '';
455
+ if ((!activeId || current.activations?.[activeId]?.status !== 'active') && turn.recovery_activation_id) {
456
+ next = openActivation(next, {
457
+ session_id: sessionId,
458
+ activation_id: turn.recovery_activation_id,
459
+ activation_started_at: turn.recovery_started_at || turn.started_at || '',
460
+ turn_sequence: turn.turn_sequence ?? current.last_turn_sequence,
461
+ transcript_id: turn.transcript_id || current.transcript_id,
462
+ transcript_path: turn.transcript_path || current.transcript_path,
463
+ provider: turn.provider || current.provider,
464
+ });
465
+ current = next.sessions[sessionId];
466
+ activeId = current.active_activation_id || '';
467
+ }
457
468
  if (turn.activation_id && activeId && turn.activation_id !== activeId) return next;
458
469
  const previous = nonNegativeSequence(current.last_turn_sequence, 0);
459
470
  const explicit = nonNegativeSequence(turn.turn_sequence ?? turn.last_turn_sequence);
@@ -466,9 +477,18 @@ export function advanceActivationTurn(registry, turn = {}) {
466
477
  nonNegativeSequence(activations[activeId].last_turn_sequence, 0),
467
478
  sequence,
468
479
  ),
480
+ ...(turnId ? { last_prompt_turn_id: turnId } : {}),
469
481
  };
470
482
  }
471
- next.sessions[sessionId] = { ...current, last_turn_sequence: sequence, activations };
483
+ next.sessions[sessionId] = {
484
+ ...current,
485
+ last_turn_sequence: sequence,
486
+ ...(turnId ? {
487
+ last_prompt_turn_id: turnId,
488
+ turn_sequences: { ...(current.turn_sequences || {}), [turnId]: sequence },
489
+ } : {}),
490
+ activations,
491
+ };
472
492
  return next;
473
493
  }
474
494
 
@@ -478,23 +498,22 @@ export function resolveStopActivation(registry, stop = {}) {
478
498
  if (!entry) return '';
479
499
  if (stop.activation_id) return String(stop.activation_id);
480
500
 
501
+ const activeId = entry.active_activation_id || '';
502
+ const active = entry.activations?.[activeId];
503
+ if (!activeId || active?.status !== 'active') return '';
481
504
  const transcriptId = String(stop.transcript_id || '');
482
505
  const transcriptPath = String(stop.transcript_path || '');
483
506
  if (!transcriptId && !transcriptPath) return '';
484
- const matches = Object.entries(entry.activations || {}).filter(([, activation]) => {
485
- if (!activation) return false;
486
- if (transcriptId && activation.transcript_id && activation.transcript_id !== transcriptId) return false;
487
- const paths = [
488
- ...(Array.isArray(activation.transcript_paths) ? activation.transcript_paths : []),
489
- activation.transcript_path,
490
- ].filter(Boolean);
491
- if (transcriptPath && paths.length && !paths.some((path) => transcriptsMatch(path, transcriptPath))) return false;
492
- return Boolean(
493
- (transcriptId && activation.transcript_id === transcriptId)
494
- || (transcriptPath && paths.some((path) => transcriptsMatch(path, transcriptPath))),
495
- );
496
- });
497
- return matches.length === 1 ? matches[0][0] : '';
507
+ if (transcriptId && active.transcript_id && active.transcript_id !== transcriptId) return '';
508
+ const paths = [
509
+ ...(Array.isArray(active.transcript_paths) ? active.transcript_paths : []),
510
+ active.transcript_path,
511
+ ].filter(Boolean);
512
+ if (transcriptPath && paths.length && !paths.some((path) => transcriptsMatch(path, transcriptPath))) return '';
513
+ return (
514
+ (transcriptId && active.transcript_id === transcriptId)
515
+ || (transcriptPath && paths.some((path) => transcriptsMatch(path, transcriptPath)))
516
+ ) ? activeId : '';
498
517
  }
499
518
 
500
519
  function stopResult(registry, stopDisposition, canPromoteMemory = false) {
@@ -531,21 +550,31 @@ export function applyStopActivation(registry, stop = {}) {
531
550
  const stopSequence = nonNegativeSequence(stop.turn_sequence ?? stop.last_turn_sequence);
532
551
  const lastSequence = nonNegativeSequence(current.last_turn_sequence, 0);
533
552
  if (stopSequence === null) return stopResult(next, 'ambiguous');
553
+ const active = current.activations?.[activeId] || {};
554
+ const openedAfterSequence = nonNegativeSequence(active.opened_after_turn_sequence, 0);
555
+ if (stopSequence <= openedAfterSequence && active.epoch > 1) {
556
+ return stopResult(next, 'superseded');
557
+ }
558
+ const stopTurnId = String(stop.turn_id || '');
559
+ if (stopTurnId && active.last_stop_turn_id === stopTurnId) {
560
+ return stopResult(next, 'duplicate');
561
+ }
534
562
  if (stopSequence < lastSequence) return stopResult(next, 'stale_turn');
535
563
 
536
564
  const activations = { ...(current.activations || {}) };
537
565
  activations[activeId] = {
538
566
  ...(activations[activeId] || { activation_id: activeId, epoch: current.activation_epoch }),
539
- status: 'done',
540
- ended_at: stop.ended_at || '',
567
+ status: 'active',
541
568
  last_turn_sequence: stopSequence,
569
+ last_stop_turn_sequence: stopSequence,
570
+ ...(stopTurnId ? { last_stop_turn_id: stopTurnId } : {}),
542
571
  };
543
572
  next.sessions[sessionId] = {
544
573
  ...current,
545
- status: 'done',
546
- ended_at: stop.ended_at || current.ended_at || '',
547
- active_activation_id: '',
548
- last_turn_sequence: stopSequence,
574
+ status: 'active',
575
+ active_activation_id: activeId,
576
+ last_turn_sequence: Math.max(lastSequence, stopSequence),
577
+ ...(stopTurnId ? { last_turn_id: stopTurnId } : {}),
549
578
  activations,
550
579
  };
551
580
  return stopResult(next, 'applied', true);
@@ -584,7 +613,7 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
584
613
  activation_id: clean.activation_id,
585
614
  activation_started_at: clean.activation_started_at,
586
615
  started_at: clean.activation_started_at || clean.started_at,
587
- last_turn_sequence: clean.last_turn_sequence,
616
+ last_turn_sequence: patch.last_turn_sequence,
588
617
  transcript_id: clean.transcript_id || current.transcript_id,
589
618
  transcript_path: clean.transcript_path || current.transcript_path,
590
619
  provider: clean.provider || current.provider,
@@ -597,7 +626,13 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
597
626
  {
598
627
  session_id: sessionId,
599
628
  activation_id: causalCurrent.active_activation_id || '',
629
+ recovery_activation_id: patch.recovery_activation_id || '',
630
+ recovery_started_at: patch.recovery_started_at || '',
631
+ turn_id: patch.turn_id || '',
600
632
  turn_sequence: patch.turn_sequence,
633
+ transcript_id: clean.transcript_id || causalCurrent.transcript_id,
634
+ transcript_path: clean.transcript_path || causalCurrent.transcript_path,
635
+ provider: clean.provider || causalCurrent.provider,
601
636
  },
602
637
  ).sessions[sessionId];
603
638
  }
@@ -705,13 +740,13 @@ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs =
705
740
  // já fechada com o mesmo `endedAt`). Devolve true se gravou.
706
741
  export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
707
742
  if (!sessionFileRel) return false;
708
- const path = join(vaultBase, sessionFileRel);
709
- if (!existsSync(path)) return false;
710
- const content = readFileSync(path, 'utf-8');
711
- const next = closeSessionNote(content, endedAt);
712
- if (next === content) return false;
713
- writeFileSync(path, next, 'utf-8');
714
- 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;
715
750
  }
716
751
 
717
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;