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