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