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
|
@@ -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,11 +329,11 @@ 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 = {}) {
|
|
@@ -756,13 +740,13 @@ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs =
|
|
|
756
740
|
// já fechada com o mesmo `endedAt`). Devolve true se gravou.
|
|
757
741
|
export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
|
|
758
742
|
if (!sessionFileRel) return false;
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
743
|
+
const checked = assertVaultPathSafe(vaultBase, join(vaultBase, sessionFileRel), {
|
|
744
|
+
expectedType: 'file', label: 'nota de sessão encerrada',
|
|
745
|
+
});
|
|
746
|
+
if (!checked.exists) return false;
|
|
747
|
+
return mutateSessionNote(checked.target, (content) => closeSessionNote(content, endedAt), {
|
|
748
|
+
vaultBase,
|
|
749
|
+
}).written;
|
|
766
750
|
}
|
|
767
751
|
|
|
768
752
|
// Marca de sessão ainda aberta no corpo da nota (template do hook de início).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_OPERATING_PROFILE,
|
|
6
|
+
normalizeOperatingProfile,
|
|
7
|
+
operatingProfilePolicy,
|
|
8
|
+
resolveOperatingProfile,
|
|
9
|
+
} from '../src/operating-profile.mjs';
|
|
10
|
+
import { findProjectBinding, resolveProjectVault } from '../src/project-vault.mjs';
|
|
11
|
+
import { readSessionRegistry } from './obsidian-common.mjs';
|
|
12
|
+
import { resolveSessionIdentity } from './session-identity.mjs';
|
|
13
|
+
|
|
14
|
+
function bindingDiagnostic(error, configPath = '') {
|
|
15
|
+
return {
|
|
16
|
+
code: error?.code || 'WENDKEEP_VAULT_CONFIG_INVALID',
|
|
17
|
+
message: error?.message
|
|
18
|
+
|| `Configuração WendKeep inválida${configPath ? ` em "${configPath}"` : ''}.`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readResolvedConfig(resolution, suppliedConfig) {
|
|
23
|
+
if (suppliedConfig && typeof suppliedConfig === 'object' && !Array.isArray(suppliedConfig)) {
|
|
24
|
+
return { config: suppliedConfig, bindingError: null };
|
|
25
|
+
}
|
|
26
|
+
if (resolution?.config && typeof resolution.config === 'object' && !Array.isArray(resolution.config)) {
|
|
27
|
+
return { config: resolution.config, bindingError: null };
|
|
28
|
+
}
|
|
29
|
+
if (!resolution?.configPath || !existsSync(resolution.configPath)) {
|
|
30
|
+
return { config: {}, bindingError: null };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(resolution.configPath, 'utf8'));
|
|
34
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
35
|
+
return { config: parsed, bindingError: null };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
config: {},
|
|
39
|
+
bindingError: bindingDiagnostic(null, resolution.configPath),
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return {
|
|
43
|
+
config: {},
|
|
44
|
+
bindingError: bindingDiagnostic(error, resolution.configPath),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sessionOverride(entry) {
|
|
50
|
+
if (!entry || !Object.prototype.hasOwnProperty.call(entry, 'operating_profile')) return null;
|
|
51
|
+
const raw = entry.operating_profile;
|
|
52
|
+
try {
|
|
53
|
+
return {
|
|
54
|
+
profile: normalizeOperatingProfile(raw, { strict: true }),
|
|
55
|
+
source: 'session-override',
|
|
56
|
+
valid: true,
|
|
57
|
+
configured: true,
|
|
58
|
+
raw,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return {
|
|
62
|
+
profile: DEFAULT_OPERATING_PROFILE,
|
|
63
|
+
source: 'session-override-invalid',
|
|
64
|
+
valid: false,
|
|
65
|
+
configured: true,
|
|
66
|
+
raw,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function canonicalPath(value) {
|
|
72
|
+
const path = resolve(value).replaceAll('\\', '/');
|
|
73
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function matchingProjectBinding(vaultResolution, input) {
|
|
77
|
+
if (vaultResolution?.config) {
|
|
78
|
+
return { binding: null, bindingError: vaultResolution.bindingError || null };
|
|
79
|
+
}
|
|
80
|
+
if (vaultResolution?.bindingError) {
|
|
81
|
+
return { binding: null, bindingError: vaultResolution.bindingError };
|
|
82
|
+
}
|
|
83
|
+
const start = input?.cwd
|
|
84
|
+
|| input?.project_dir
|
|
85
|
+
|| input?.projectDir
|
|
86
|
+
|| input?.workspace?.cwd
|
|
87
|
+
|| vaultResolution?.projectRoot
|
|
88
|
+
|| process.cwd();
|
|
89
|
+
try {
|
|
90
|
+
const binding = findProjectBinding(start);
|
|
91
|
+
return {
|
|
92
|
+
binding: binding && canonicalPath(binding.base) === canonicalPath(vaultResolution.base)
|
|
93
|
+
? binding
|
|
94
|
+
: null,
|
|
95
|
+
bindingError: vaultResolution.bindingError || null,
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// An explicitly supplied vault is authoritative even when an unrelated
|
|
99
|
+
// nearby project config is malformed, but corruption remains observable.
|
|
100
|
+
if (input?.obsidian_vault_path
|
|
101
|
+
|| ['explicit', 'payload', 'legacy-project-settings'].includes(vaultResolution?.source)) {
|
|
102
|
+
return { binding: null, bindingError: bindingDiagnostic(error) };
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resolution precedence for hooks: explicit session override -> project binding -> GOVERN.
|
|
109
|
+
// Binding corruption is never interpreted as OFF: an authoritative Vault keeps the Keep Core
|
|
110
|
+
// alive under GOVERN and carries a visible diagnostic to each entrypoint.
|
|
111
|
+
export function resolveHookOperatingProfile({
|
|
112
|
+
input = {},
|
|
113
|
+
resolution = null,
|
|
114
|
+
config = null,
|
|
115
|
+
provider,
|
|
116
|
+
} = {}) {
|
|
117
|
+
const initialResolution = resolution || resolveProjectVault({ input });
|
|
118
|
+
const matched = matchingProjectBinding(initialResolution, input);
|
|
119
|
+
const binding = matched.binding;
|
|
120
|
+
const vaultResolution = binding ? {
|
|
121
|
+
...initialResolution,
|
|
122
|
+
projectRoot: binding.projectRoot,
|
|
123
|
+
projectId: binding.config.projectId,
|
|
124
|
+
configPath: binding.configPath,
|
|
125
|
+
config: binding.config,
|
|
126
|
+
} : initialResolution;
|
|
127
|
+
const configState = readResolvedConfig(vaultResolution, config);
|
|
128
|
+
const bindingError = initialResolution.bindingError
|
|
129
|
+
|| matched.bindingError
|
|
130
|
+
|| configState.bindingError
|
|
131
|
+
|| null;
|
|
132
|
+
const project = resolveOperatingProfile(bindingError ? {} : configState.config);
|
|
133
|
+
const identity = resolveSessionIdentity(vaultResolution.base, input, provider);
|
|
134
|
+
const entry = identity.state === 'resolved'
|
|
135
|
+
? readSessionRegistry(vaultResolution.base).sessions?.[identity.canonicalConversationId] || null
|
|
136
|
+
: null;
|
|
137
|
+
const selected = sessionOverride(entry) || project;
|
|
138
|
+
return {
|
|
139
|
+
...selected,
|
|
140
|
+
policy: operatingProfilePolicy(selected.profile),
|
|
141
|
+
vaultBase: vaultResolution.base,
|
|
142
|
+
projectRoot: vaultResolution.projectRoot,
|
|
143
|
+
identity,
|
|
144
|
+
entry,
|
|
145
|
+
resolution: vaultResolution,
|
|
146
|
+
...(bindingError ? { bindingError } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function hookProfilePolicy(profile = DEFAULT_OPERATING_PROFILE) {
|
|
151
|
+
return operatingProfilePolicy(profile);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Prefix the profile so change-core's 64-char filename cap can never truncate it away.
|
|
155
|
+
export function profileSentinelId(sessionId, profile = DEFAULT_OPERATING_PROFILE) {
|
|
156
|
+
return `${normalizeOperatingProfile(profile).toLowerCase()}--${sessionId || 'nosession'}`;
|
|
157
|
+
}
|
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;
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -214,7 +214,7 @@ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
|
|
|
214
214
|
const sessionPath = join(vaultBase, nextRelPath);
|
|
215
215
|
const outcome = mutateSessionNote(sessionPath, (content) => (
|
|
216
216
|
updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
|
|
217
|
-
));
|
|
217
|
+
), { vaultBase });
|
|
218
218
|
|
|
219
219
|
return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || outcome.written };
|
|
220
220
|
}
|
|
@@ -226,8 +226,10 @@ function stripClosingSection(content) {
|
|
|
226
226
|
return `${content.slice(0, index).trimEnd()}\n`;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
function reopenSessionFile(sessionPath) {
|
|
230
|
-
mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content))
|
|
229
|
+
function reopenSessionFile(vaultBase, sessionPath) {
|
|
230
|
+
mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)), {
|
|
231
|
+
vaultBase,
|
|
232
|
+
});
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
function findSessionForInput(vaultBase, input, control) {
|
|
@@ -260,7 +262,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
260
262
|
const sessionPath = join(vaultBase, relPath);
|
|
261
263
|
if (!existsSync(sessionPath)) return false;
|
|
262
264
|
|
|
263
|
-
reopenSessionFile(sessionPath);
|
|
265
|
+
reopenSessionFile(vaultBase, sessionPath);
|
|
264
266
|
const nextStartedAt = startedAt || formatLocalIso(now);
|
|
265
267
|
writeControl(vaultBase, {
|
|
266
268
|
status: 'active',
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { hasTurnMarker, normalizeTurnMarkers, turnMarker } from './obsidian-common.mjs';
|
|
2
|
+
import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
|
|
3
|
+
|
|
4
|
+
const ITERATION_ANCHORS = [
|
|
5
|
+
'\n## Agentes, tokens e custos',
|
|
6
|
+
'\n## Uso de tokens e custos',
|
|
7
|
+
'\n## Decisões geradas nesta sessão',
|
|
8
|
+
'\n## Bugs gerados nesta sessão',
|
|
9
|
+
'\n## Aprendizados gerados nesta sessão',
|
|
10
|
+
'\n## Arquivos consultados',
|
|
11
|
+
'\n## Arquivos criados ou alterados',
|
|
12
|
+
'\n## Pendências',
|
|
13
|
+
'\n## Encerramento',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function insertIterationContent(original, { markerId, block }) {
|
|
17
|
+
if (!markerId) throw new TypeError('markerId é obrigatório');
|
|
18
|
+
let content = normalizeTurnMarkers(String(original || ''));
|
|
19
|
+
if (hasTurnMarker(content, markerId)) return { content, inserted: false };
|
|
20
|
+
|
|
21
|
+
const rendered = `\n${String(block || '').trim()}\n${turnMarker(markerId)}\n`;
|
|
22
|
+
const iterations = content.indexOf('\n## Iterações');
|
|
23
|
+
if (iterations !== -1) {
|
|
24
|
+
const anchors = ITERATION_ANCHORS
|
|
25
|
+
.map((anchor) => content.indexOf(anchor, iterations + 1))
|
|
26
|
+
.filter((index) => index !== -1)
|
|
27
|
+
.sort((left, right) => left - right);
|
|
28
|
+
if (anchors.length) {
|
|
29
|
+
const at = anchors[0];
|
|
30
|
+
content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
|
|
31
|
+
} else {
|
|
32
|
+
const lineEnd = content.indexOf('\n', iterations + 1);
|
|
33
|
+
const at = lineEnd === -1 ? content.length : lineEnd + 1;
|
|
34
|
+
content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
|
|
35
|
+
}
|
|
36
|
+
return { content, inserted: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const closing = content.indexOf('\n## Encerramento');
|
|
40
|
+
if (closing !== -1) {
|
|
41
|
+
content = `${content.slice(0, closing).trimEnd()}\n\n## Iterações\n${rendered}\n${content.slice(closing).replace(/^\n+/, '')}`;
|
|
42
|
+
} else {
|
|
43
|
+
content = `${content.trimEnd()}\n\n## Iterações\n${rendered}`;
|
|
44
|
+
}
|
|
45
|
+
return { content, inserted: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function projectSessionIteration(sessionPath, input, options = {}) {
|
|
49
|
+
let inserted = false;
|
|
50
|
+
let invalidFrontmatter = false;
|
|
51
|
+
const outcome = mutateSessionNote(sessionPath, (content) => {
|
|
52
|
+
if (!hasSessionFrontmatter(content)) {
|
|
53
|
+
invalidFrontmatter = true;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const result = insertIterationContent(content, input);
|
|
57
|
+
inserted = result.inserted;
|
|
58
|
+
return result.content;
|
|
59
|
+
}, options);
|
|
60
|
+
return {
|
|
61
|
+
inserted,
|
|
62
|
+
written: outcome.written,
|
|
63
|
+
reason: invalidFrontmatter ? 'invalid-frontmatter' : outcome.reason,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -246,11 +246,16 @@ export function projectStopMemoryAttempt(vaultBase, attempt, overrides = {}) {
|
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
248
|
return outcome(attempt, 'projected', {
|
|
249
|
-
checkpoint:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
249
|
+
checkpoint: projection.checkpoint && typeof projection.checkpoint === 'object'
|
|
250
|
+
? { ...projection.checkpoint }
|
|
251
|
+
: {
|
|
252
|
+
revision: projection.revision,
|
|
253
|
+
event_cursor: projection.ledgerCursor || projection.eventCursor,
|
|
254
|
+
state_hash: projection.stateHash,
|
|
255
|
+
...(projection.ledgerCursor && projection.eventCursor !== projection.ledgerCursor
|
|
256
|
+
? { causal_event_cursor: projection.eventCursor }
|
|
257
|
+
: {}),
|
|
258
|
+
},
|
|
254
259
|
});
|
|
255
260
|
} catch (error) {
|
|
256
261
|
return outcome(attempt, 'degraded', {
|