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.
- package/CHANGELOG.md +120 -0
- package/README.en.md +70 -40
- package/README.md +70 -40
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +85 -0
- package/docs/en/commands/costs-and-observability.md +65 -0
- package/docs/en/commands/getting-started.md +86 -0
- package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
- package/docs/en/commands/memory-migration.md +73 -0
- package/docs/en/commands/memory.md +102 -0
- package/docs/en/commands/notes-and-knowledge.md +70 -0
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/retroactive-import.md +67 -0
- package/docs/en/commands/sessions-and-import.md +89 -0
- package/docs/en/commands/verify.md +92 -0
- package/docs/pt-BR/commands/changes-and-verification.md +85 -0
- package/docs/pt-BR/commands/costs-and-observability.md +65 -0
- package/docs/pt-BR/commands/getting-started.md +87 -0
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
- package/docs/pt-BR/commands/memory-migration.md +73 -0
- package/docs/pt-BR/commands/memory.md +99 -0
- package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/retroactive-import.md +67 -0
- package/docs/pt-BR/commands/sessions-and-import.md +89 -0
- package/docs/pt-BR/commands/verify.md +93 -0
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +119 -84
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +21 -12
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +335 -0
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +181 -59
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +268 -25
- package/hooks/vault-path-safety.mjs +558 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +5 -3
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +110 -5
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +9 -0
- package/src/validate-memory.mjs +21 -8
- 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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
const
|
|
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
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
let next = cloneRegistry(registry);
|
|
452
442
|
const sessionId = turn.session_id || turn.canonical_session_id || '';
|
|
453
|
-
|
|
443
|
+
let current = next.sessions[sessionId];
|
|
454
444
|
if (!current) return next;
|
|
455
445
|
|
|
456
|
-
const
|
|
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] = {
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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: '
|
|
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: '
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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:
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
}
|
package/hooks/plan-capture.mjs
CHANGED
|
@@ -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
|
|
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) {
|
package/hooks/sensors-core.mjs
CHANGED
|
@@ -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 || []).
|
|
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
|
|
75
|
-
|
|
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;
|