wendkeep 0.68.1 → 0.68.5
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 +51 -0
- package/README.en.md +14 -6
- package/README.md +14 -6
- package/docs/en/commands/changes-and-verification.md +12 -0
- package/docs/en/commands/memory.md +3 -1
- package/docs/en/commands/operating-profiles.md +11 -0
- package/docs/en/commands/sessions-and-import.md +15 -0
- package/docs/pt-BR/commands/changes-and-verification.md +12 -0
- package/docs/pt-BR/commands/memory.md +3 -1
- package/docs/pt-BR/commands/operating-profiles.md +11 -0
- package/docs/pt-BR/commands/sessions-and-import.md +15 -0
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-guard.mjs +142 -103
- package/hooks/git-snapshot.mjs +25 -6
- package/hooks/obsidian-common.mjs +40 -0
- package/hooks/project-scope.mjs +435 -0
- package/hooks/session-backfill.mjs +1 -1
- package/hooks/session-ensure.mjs +23 -4
- package/hooks/session-iteration-outcome.mjs +143 -0
- package/hooks/session-start.mjs +15 -0
- package/hooks/session-stop.mjs +263 -42
- package/hooks/token-usage.mjs +13 -11
- package/package.json +2 -2
- package/packages/integrations/src/host-hooks.mjs +7 -7
- package/packages/integrations/src/prompt-content.mjs +12 -0
- package/packages/integrations/src/transcripts.mjs +37 -8
- package/packages/vault/src/memory-store.mjs +63 -1
- package/src/init.mjs +3 -0
- package/src/memory.mjs +10 -3
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const TOOL_CWD_FIELDS = ['cwd', 'workdir', 'work_dir', 'working_directory', 'directory'];
|
|
6
|
+
const SAFE_GIT_COMMANDS = new Set([
|
|
7
|
+
'status', 'diff', 'log', 'show', 'reflog', 'describe', 'ls-files', 'ls-tree', 'cat-file',
|
|
8
|
+
'rev-parse', 'symbolic-ref', 'for-each-ref', 'for-each-repo', 'shortlog', 'whatchanged',
|
|
9
|
+
]);
|
|
10
|
+
const MUTABLE_TOOL_NAMES = new Set([
|
|
11
|
+
'apply_patch', 'ApplyPatch', 'write_file', 'Write', 'Edit', 'MultiEdit', 'delete_file',
|
|
12
|
+
'remove_file', 'move_file', 'rename_file',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function canonicalPath(value) {
|
|
16
|
+
const candidate = resolve(String(value || process.cwd()));
|
|
17
|
+
let physical = candidate;
|
|
18
|
+
try { physical = realpathSync.native(candidate); } catch { /* target may not exist yet */ }
|
|
19
|
+
const normalized = physical.replaceAll('\\', '/');
|
|
20
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function displayPath(value) {
|
|
24
|
+
return String(value || '').replaceAll('\\', '/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function runGit(cwd, args, spawn = spawnSync) {
|
|
28
|
+
const result = spawn('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
29
|
+
if (result.error || result.status !== 0) {
|
|
30
|
+
const detail = String(result.stderr || result.error?.message || 'falhou').trim();
|
|
31
|
+
const error = new Error(`git ${args.join(' ')}: ${detail}`);
|
|
32
|
+
error.code = 'WENDKEEP_SCOPE_GIT_ERROR';
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
return String(result.stdout || '').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function optionalGit(cwd, args, spawn = spawnSync) {
|
|
39
|
+
try { return runGit(cwd, args, spawn); } catch { return ''; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeRemote(value) {
|
|
43
|
+
let raw = String(value || '').trim();
|
|
44
|
+
if (!raw) return '';
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(raw);
|
|
47
|
+
url.username = '';
|
|
48
|
+
url.password = '';
|
|
49
|
+
url.hash = '';
|
|
50
|
+
url.hostname = url.hostname.toLowerCase();
|
|
51
|
+
raw = url.toString().replace(/\/$/, '');
|
|
52
|
+
return raw;
|
|
53
|
+
} catch { /* scp-like remotes and local paths */ }
|
|
54
|
+
raw = raw.replace(/^[^/\\]+@(?=[^/:]+[:/])/, '');
|
|
55
|
+
return raw.replaceAll('\\', '/').replace(/\/$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function extractToolCommand(input = {}) {
|
|
59
|
+
const value = input?.tool_input ?? input?.toolInput ?? input;
|
|
60
|
+
if (typeof value === 'string') return value;
|
|
61
|
+
if (Array.isArray(value)) return value.map((part) => String(part)).join(' ');
|
|
62
|
+
if (!value || typeof value !== 'object') return '';
|
|
63
|
+
if (typeof value.command === 'string') return value.command;
|
|
64
|
+
if (Array.isArray(value.command)) return value.command.map((part) => String(part)).join(' ');
|
|
65
|
+
if (Array.isArray(value.argv)) return value.argv.map((part) => String(part)).join(' ');
|
|
66
|
+
if (Array.isArray(value.args)) return value.args.map((part) => String(part)).join(' ');
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function requestedToolCwd(input = {}) {
|
|
71
|
+
const tool = input?.tool_input ?? input?.toolInput;
|
|
72
|
+
if (tool && typeof tool === 'object' && !Array.isArray(tool)) {
|
|
73
|
+
for (const field of TOOL_CWD_FIELDS) {
|
|
74
|
+
if (typeof tool[field] === 'string' && tool[field].trim()) return tool[field].trim();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const field of ['cwd', 'project_dir', 'projectDir', 'workspace']) {
|
|
78
|
+
const value = field === 'workspace' ? input?.workspace?.cwd : input?.[field];
|
|
79
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shellSegments(command) {
|
|
85
|
+
const tokens = String(command || '').match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|&&|\|\||[;|\n]|&|[^\s;&|]+/g) || [];
|
|
86
|
+
const segments = [];
|
|
87
|
+
let current = [];
|
|
88
|
+
const flush = () => { if (current.length) segments.push(current); current = []; };
|
|
89
|
+
for (const token of tokens) {
|
|
90
|
+
if (['&&', '||', ';', '|', '\n'].includes(token) || (token === '&' && current.length)) {
|
|
91
|
+
flush();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
current.push(token);
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
return segments;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unquote(token) {
|
|
101
|
+
const value = String(token || '');
|
|
102
|
+
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"')
|
|
103
|
+
|| (value[0] === "'" && value.at(-1) === "'"))) return value.slice(1, -1);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function executableName(token) {
|
|
108
|
+
return unquote(token).replaceAll('\\', '/').split('/').at(-1).toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function invocationOf(segment) {
|
|
112
|
+
let index = 0;
|
|
113
|
+
while (segment[index] === '&' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[index] || '')) index += 1;
|
|
114
|
+
const executable = executableName(segment[index]);
|
|
115
|
+
if (executable === 'git' || executable === 'git.exe' || executable === 'git.cmd') {
|
|
116
|
+
return { kind: 'git', args: segment.slice(index + 1).map(unquote) };
|
|
117
|
+
}
|
|
118
|
+
if (['rm', 'rm.exe', 'del', 'erase', 'remove-item', 'move-item', 'set-content', 'out-file', 'copy-item', 'new-item'].includes(executable)) {
|
|
119
|
+
return { kind: 'filesystem', args: segment.slice(index + 1).map(unquote) };
|
|
120
|
+
}
|
|
121
|
+
if (isPublicationInvocation(executable, segment.slice(index + 1).map(unquote))) {
|
|
122
|
+
return { kind: 'publication', args: segment.slice(index + 1).map(unquote) };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function firstNonOption(args) {
|
|
128
|
+
return args.find((arg) => !String(arg).startsWith('-'))?.toLowerCase() || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isWendKeepPublication(args) {
|
|
132
|
+
const command = firstNonOption(args);
|
|
133
|
+
return command === 'publish' || command === 'release';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isPublicationInvocation(executable, args) {
|
|
137
|
+
if (['npm', 'npm.cmd', 'pnpm', 'pnpm.cmd', 'yarn', 'yarn.cmd', 'bun', 'bun.exe'].includes(executable)) {
|
|
138
|
+
const command = firstNonOption(args);
|
|
139
|
+
if (command === 'publish') return true;
|
|
140
|
+
const commandIndex = args.findIndex((arg) => String(arg).toLowerCase() === command);
|
|
141
|
+
return command === 'run' && String(args[commandIndex + 1] || '').toLowerCase() === 'release';
|
|
142
|
+
}
|
|
143
|
+
if (executable === 'gh' || executable === 'gh.exe') {
|
|
144
|
+
const command = firstNonOption(args);
|
|
145
|
+
const commandIndex = args.findIndex((arg) => String(arg).toLowerCase() === command);
|
|
146
|
+
const operation = String(args[commandIndex + 1] || '').toLowerCase();
|
|
147
|
+
return command === 'release' && ['create', 'edit', 'upload', 'delete'].includes(operation);
|
|
148
|
+
}
|
|
149
|
+
if (executable === 'wendkeep' || executable === 'wendkeep.cmd' || executable === 'wk' || executable === 'wk.cmd') {
|
|
150
|
+
return isWendKeepPublication(args);
|
|
151
|
+
}
|
|
152
|
+
if (executable === 'npx' || executable === 'npx.cmd') {
|
|
153
|
+
const packageIndex = args.findIndex((arg) => ['wendkeep', 'wk'].includes(String(arg).toLowerCase()));
|
|
154
|
+
return packageIndex >= 0 && isWendKeepPublication(args.slice(packageIndex + 1));
|
|
155
|
+
}
|
|
156
|
+
if (executable === 'corepack' || executable === 'corepack.cmd') {
|
|
157
|
+
const packageIndex = args.findIndex((arg) => ['npm', 'pnpm', 'yarn'].includes(String(arg).toLowerCase()));
|
|
158
|
+
return packageIndex >= 0 && isPublicationInvocation(
|
|
159
|
+
String(args[packageIndex]).toLowerCase(),
|
|
160
|
+
args.slice(packageIndex + 1),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function commandInvocations(command) {
|
|
167
|
+
return shellSegments(command).map(invocationOf).filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function firstGitSubcommand(args) {
|
|
171
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
172
|
+
const arg = String(args[index] || '');
|
|
173
|
+
if (arg === '--') return '';
|
|
174
|
+
if (arg === '-C' || arg === '--git-dir' || arg === '--work-tree' || arg === '--exec-path') {
|
|
175
|
+
index += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (arg.startsWith('-')) continue;
|
|
179
|
+
return arg.toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function gitAction(args) {
|
|
185
|
+
const subcommand = firstGitSubcommand(args);
|
|
186
|
+
if (!subcommand || SAFE_GIT_COMMANDS.has(subcommand)) return null;
|
|
187
|
+
if (subcommand === 'config') {
|
|
188
|
+
return args.some((arg) => ['--get', '--get-all', '--get-regexp', '--list', '-l', '--show-origin'].includes(arg))
|
|
189
|
+
? null : 'git:destructive';
|
|
190
|
+
}
|
|
191
|
+
if (subcommand === 'remote') {
|
|
192
|
+
return args.some((arg) => ['-v', '--verbose', 'get-url', 'show'].includes(arg)) ? null : 'git:destructive';
|
|
193
|
+
}
|
|
194
|
+
if (subcommand === 'branch') {
|
|
195
|
+
return args.some((arg) => ['--show-current', '--list', '-a', '-r', '-vv'].includes(arg))
|
|
196
|
+
? null : 'git:destructive';
|
|
197
|
+
}
|
|
198
|
+
if (subcommand === 'tag') return args.includes('-l') || args.includes('--list') ? null : 'git:destructive';
|
|
199
|
+
if (subcommand === 'worktree') return args[1] === 'list' ? null : 'git:destructive';
|
|
200
|
+
if (['add', 'commit', 'push', 'pull', 'fetch', 'merge', 'rebase', 'cherry-pick', 'stash'].includes(subcommand)) {
|
|
201
|
+
return `git:${subcommand}`;
|
|
202
|
+
}
|
|
203
|
+
if (['checkout', 'switch', 'reset', 'restore', 'revert', 'clean', 'rm', 'mv', 'update-index', 'init', 'clone'].includes(subcommand)) {
|
|
204
|
+
return 'git:destructive';
|
|
205
|
+
}
|
|
206
|
+
return 'git:destructive';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function scopeActionForCommand(command) {
|
|
210
|
+
return scopeActionsForCommand(command)[0] || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function scopeActionsForCommand(command) {
|
|
214
|
+
const actions = [];
|
|
215
|
+
for (const invocation of commandInvocations(command)) {
|
|
216
|
+
if (invocation.kind === 'git') actions.push(gitAction(invocation.args));
|
|
217
|
+
if (invocation.kind === 'filesystem') actions.push('filesystem:mutation');
|
|
218
|
+
if (invocation.kind === 'publication') actions.push('publish');
|
|
219
|
+
}
|
|
220
|
+
return [...new Set(actions.filter(Boolean))];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function commandChangesDirectory(command) {
|
|
224
|
+
return shellSegments(command).some((segment) => {
|
|
225
|
+
const executable = executableName(segment[0]);
|
|
226
|
+
return ['cd', 'chdir', 'pushd', 'set-location', 'sl', 'set-location.exe'].includes(executable);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function commandHasUnprovenTarget(command) {
|
|
231
|
+
if (commandChangesDirectory(command)) return true;
|
|
232
|
+
return commandInvocations(command).some((invocation) => invocation.kind === 'git'
|
|
233
|
+
&& invocation.args.some((arg) => ['-C', '--git-dir', '--work-tree'].includes(arg)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function captureProjectScope({
|
|
237
|
+
input = {},
|
|
238
|
+
projectRoot = '',
|
|
239
|
+
projectId = '',
|
|
240
|
+
provider = '',
|
|
241
|
+
sessionId = '',
|
|
242
|
+
targetCwd = '',
|
|
243
|
+
spawn = spawnSync,
|
|
244
|
+
} = {}) {
|
|
245
|
+
const envelopeCwd = input?.cwd || input?.project_dir || input?.projectDir || input?.workspace?.cwd || process.cwd();
|
|
246
|
+
const requested = targetCwd || requestedToolCwd(input) || envelopeCwd;
|
|
247
|
+
const target = isAbsolute(requested) ? requested : resolve(envelopeCwd, requested);
|
|
248
|
+
const repoRootRaw = optionalGit(target, ['rev-parse', '--show-toplevel'], spawn);
|
|
249
|
+
if (!repoRootRaw) {
|
|
250
|
+
return {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
complete: false,
|
|
253
|
+
errorCode: 'WENDKEEP_SCOPE_REPO_UNRESOLVED',
|
|
254
|
+
projectId: String(projectId || ''),
|
|
255
|
+
projectRoot: canonicalPath(projectRoot || target),
|
|
256
|
+
repoRoot: '',
|
|
257
|
+
remote: '',
|
|
258
|
+
branch: '',
|
|
259
|
+
worktree: '',
|
|
260
|
+
head: '',
|
|
261
|
+
provider: String(provider || ''),
|
|
262
|
+
sessionId: String(sessionId || ''),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const repoRoot = canonicalPath(repoRootRaw);
|
|
266
|
+
const head = optionalGit(repoRoot, ['rev-parse', '--verify', 'HEAD'], spawn);
|
|
267
|
+
const symbolicBranch = optionalGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], spawn);
|
|
268
|
+
const branch = symbolicBranch || (head ? `detached:${head}` : '');
|
|
269
|
+
const gitDirRaw = optionalGit(repoRoot, ['rev-parse', '--git-dir'], spawn);
|
|
270
|
+
const worktree = gitDirRaw ? canonicalPath(isAbsolute(gitDirRaw) ? gitDirRaw : resolve(repoRoot, gitDirRaw)) : '';
|
|
271
|
+
const remote = normalizeRemote(optionalGit(repoRoot, ['config', '--get', 'remote.origin.url'], spawn));
|
|
272
|
+
const scope = {
|
|
273
|
+
schemaVersion: 1,
|
|
274
|
+
complete: Boolean(projectId && projectRoot && repoRoot && remote && branch && worktree && provider && sessionId),
|
|
275
|
+
projectId: String(projectId || ''),
|
|
276
|
+
projectRoot: canonicalPath(projectRoot || target),
|
|
277
|
+
repoRoot,
|
|
278
|
+
remote,
|
|
279
|
+
branch,
|
|
280
|
+
worktree,
|
|
281
|
+
head,
|
|
282
|
+
provider: String(provider || ''),
|
|
283
|
+
sessionId: String(sessionId || ''),
|
|
284
|
+
};
|
|
285
|
+
return scope;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const SCOPE_FIELDS = ['projectId', 'projectRoot', 'repoRoot', 'remote', 'branch', 'worktree', 'provider', 'sessionId'];
|
|
289
|
+
|
|
290
|
+
export function compareProjectScopes(expected, actual) {
|
|
291
|
+
if (!expected || typeof expected !== 'object') return { ok: false, mismatches: ['scope.missing'] };
|
|
292
|
+
if (!actual || typeof actual !== 'object') return { ok: false, mismatches: ['scope.actual_missing'] };
|
|
293
|
+
const mismatches = [];
|
|
294
|
+
for (const field of SCOPE_FIELDS) {
|
|
295
|
+
const left = field.endsWith('Root') || field === 'worktree'
|
|
296
|
+
? canonicalPath(expected[field]) : String(expected[field] || '');
|
|
297
|
+
const right = field.endsWith('Root') || field === 'worktree'
|
|
298
|
+
? canonicalPath(actual[field]) : String(actual[field] || '');
|
|
299
|
+
if (!left || !right || left !== right) mismatches.push(`scope.${field}`);
|
|
300
|
+
}
|
|
301
|
+
if (expected.complete !== true || actual.complete !== true) mismatches.push('scope.incomplete');
|
|
302
|
+
return { ok: mismatches.length === 0, mismatches: [...new Set(mismatches)] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function decision(host, reason) {
|
|
306
|
+
return {
|
|
307
|
+
permissionDecision: host === 'claude' ? 'ask' : 'deny',
|
|
308
|
+
permissionDecisionReason: reason,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function comparableScopeValue(scope, field) {
|
|
313
|
+
const value = scope?.[field];
|
|
314
|
+
if (!value) return '';
|
|
315
|
+
return field === 'repoRoot' || field === 'projectRoot' || field === 'worktree'
|
|
316
|
+
? canonicalPath(value)
|
|
317
|
+
: String(value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The registry is the lease ledger, not just a display index. An active entry without a
|
|
321
|
+
// project snapshot cannot prove that it is unrelated to the current mutation, so it is a
|
|
322
|
+
// conservative blocker. Distinct worktrees are the one mechanically provable exception.
|
|
323
|
+
export function concurrentScopeConflicts(expectedScope, activeSessions = [], currentSessionId = '') {
|
|
324
|
+
if (!expectedScope || typeof expectedScope !== 'object') return [];
|
|
325
|
+
const rows = Array.isArray(activeSessions)
|
|
326
|
+
? activeSessions.map((entry, index) => Array.isArray(entry) && entry.length === 2
|
|
327
|
+
? [entry[0], entry[1]]
|
|
328
|
+
: [entry?.sessionId || entry?.session_id || String(index), entry])
|
|
329
|
+
: Object.entries(activeSessions || {});
|
|
330
|
+
const current = currentSessionId || expectedScope.sessionId || '';
|
|
331
|
+
const conflicts = [];
|
|
332
|
+
for (const [sessionId, entry] of rows) {
|
|
333
|
+
if (!entry || String(sessionId) === String(current) || String(entry.sessionId || '') === String(current)) continue;
|
|
334
|
+
if (entry.status && entry.status !== 'active') continue;
|
|
335
|
+
const other = entry.project_scope || entry.projectScope || (entry.complete !== undefined ? entry : null);
|
|
336
|
+
if (!other || other.complete !== true) {
|
|
337
|
+
conflicts.push({ sessionId: String(sessionId), reason: 'scope-unavailable' });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const sameRepositoryBranch = ['repoRoot', 'remote', 'branch'].every((field) => {
|
|
341
|
+
const left = comparableScopeValue(expectedScope, field);
|
|
342
|
+
const right = comparableScopeValue(other, field);
|
|
343
|
+
return Boolean(left && right && left === right);
|
|
344
|
+
});
|
|
345
|
+
if (!sameRepositoryBranch) continue;
|
|
346
|
+
const leftWorktree = comparableScopeValue(expectedScope, 'worktree');
|
|
347
|
+
const rightWorktree = comparableScopeValue(other, 'worktree');
|
|
348
|
+
if (!leftWorktree || !rightWorktree || leftWorktree === rightWorktree) {
|
|
349
|
+
conflicts.push({ sessionId: String(sessionId), reason: 'same-repository-branch' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return conflicts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function toolIsMutable(input) {
|
|
356
|
+
const name = input?.tool_name || input?.toolName || '';
|
|
357
|
+
if (/^mcp__/i.test(name)) return true;
|
|
358
|
+
return MUTABLE_TOOL_NAMES.has(name) || /^mcp__.*(?:write|edit|delete|move|rename|apply)/i.test(name);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function scopeDecision({
|
|
362
|
+
command = '',
|
|
363
|
+
input = {},
|
|
364
|
+
expectedScope = null,
|
|
365
|
+
actualScope = null,
|
|
366
|
+
host = 'codex',
|
|
367
|
+
commandTargetKnown = true,
|
|
368
|
+
activeSessions = [],
|
|
369
|
+
currentSessionId = '',
|
|
370
|
+
} = {}) {
|
|
371
|
+
const actions = scopeActionsForCommand(command);
|
|
372
|
+
const action = actions[0] || (toolIsMutable(input) ? 'tool:mutation' : null);
|
|
373
|
+
if (!action) return null;
|
|
374
|
+
if (commandChangesDirectory(command) && !commandTargetKnown) {
|
|
375
|
+
return decision(host, 'WENDKEEP_SCOPE_DIRECTORY_UNKNOWN: a mutação tentou alterar o diretório sem prova da raiz final.');
|
|
376
|
+
}
|
|
377
|
+
if (!expectedScope || !actualScope) {
|
|
378
|
+
return decision(host, `WENDKEEP_SCOPE_MISSING: escopo ausente para ${actions.join(', ') || action}; selecione explicitamente o projeto antes da mutação.`);
|
|
379
|
+
}
|
|
380
|
+
if (expectedScope.conflict === true || expectedScope.project_scope_conflict === true) {
|
|
381
|
+
return decision(host, `WENDKEEP_SCOPE_CONFLICT: a sessão observou mais de um escopo de projeto; selecione explicitamente o projeto antes da mutação.`);
|
|
382
|
+
}
|
|
383
|
+
const comparison = compareProjectScopes(expectedScope, actualScope);
|
|
384
|
+
if (!comparison.ok) {
|
|
385
|
+
return decision(host, `WENDKEEP_SCOPE_MISMATCH: alvo fora do escopo reservado (${comparison.mismatches.join(', ')}).`);
|
|
386
|
+
}
|
|
387
|
+
const concurrent = concurrentScopeConflicts(expectedScope, activeSessions, currentSessionId);
|
|
388
|
+
if (concurrent.length) {
|
|
389
|
+
return decision(host, `WENDKEEP_SCOPE_CONFLICT: há ${concurrent.length} sessão(ões) ativa(s) com a mesma raiz Git/branch ou escopo não comprovado; use um worktree distinto ou selecione explicitamente o projeto para criar uma nova lease.`);
|
|
390
|
+
}
|
|
391
|
+
const authorized = Array.isArray(expectedScope.authorizedActions)
|
|
392
|
+
? expectedScope.authorizedActions
|
|
393
|
+
: null;
|
|
394
|
+
const unauthorized = authorized
|
|
395
|
+
? (actions.length ? actions : [action]).filter((candidate) => (
|
|
396
|
+
!authorized.includes(candidate) && !authorized.includes('git:write')
|
|
397
|
+
))
|
|
398
|
+
: [];
|
|
399
|
+
if (unauthorized.length) {
|
|
400
|
+
return decision(host, `WENDKEEP_SCOPE_AUTH_REQUIRED: as capacidades ${unauthorized.join(', ')} não estão autorizadas nesta lease.`);
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function scopeForRegistry(scope, { authorizedActions } = {}) {
|
|
406
|
+
if (!scope || typeof scope !== 'object') return null;
|
|
407
|
+
return {
|
|
408
|
+
schemaVersion: 1,
|
|
409
|
+
projectId: scope.projectId || '',
|
|
410
|
+
projectRoot: displayPath(scope.projectRoot),
|
|
411
|
+
repoRoot: displayPath(scope.repoRoot),
|
|
412
|
+
remote: scope.remote || '',
|
|
413
|
+
branch: scope.branch || '',
|
|
414
|
+
worktree: displayPath(scope.worktree),
|
|
415
|
+
head: scope.head || '',
|
|
416
|
+
provider: scope.provider || '',
|
|
417
|
+
sessionId: scope.sessionId || '',
|
|
418
|
+
complete: scope.complete === true,
|
|
419
|
+
...(Array.isArray(authorizedActions) ? { authorizedActions: [...new Set(authorizedActions)] } : {}),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function projectScopePatch(existingScope, currentScope) {
|
|
424
|
+
if (!currentScope || typeof currentScope !== 'object') return {};
|
|
425
|
+
if (!existingScope || typeof existingScope !== 'object') {
|
|
426
|
+
return { project_scope: scopeForRegistry(currentScope) };
|
|
427
|
+
}
|
|
428
|
+
const comparison = compareProjectScopes(existingScope, currentScope);
|
|
429
|
+
if (comparison.ok) return {};
|
|
430
|
+
return {
|
|
431
|
+
project_scope_conflict: true,
|
|
432
|
+
project_scope_conflict_fields: comparison.mismatches,
|
|
433
|
+
project_scope_observed: scopeForRegistry(currentScope),
|
|
434
|
+
};
|
|
435
|
+
}
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
readControl,
|
|
16
16
|
readHookInput,
|
|
17
17
|
readSessionRegistry,
|
|
18
|
+
resolveVault,
|
|
18
19
|
sessionFileName,
|
|
19
20
|
sessionFolderRel,
|
|
20
21
|
sessionSummaryFromInput,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
import { resolveSessionIdentity } from './session-identity.mjs';
|
|
35
36
|
import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
|
|
36
37
|
import { mutateSessionNote } from './session-note-io.mjs';
|
|
38
|
+
import { captureProjectScope, projectScopePatch } from './project-scope.mjs';
|
|
37
39
|
|
|
38
40
|
function sessionIdFromInput(input) {
|
|
39
41
|
return input.session_id || input.sessionId || input.codex_session_id || '';
|
|
@@ -265,7 +267,7 @@ function findSessionForInput(vaultBase, input, control) {
|
|
|
265
267
|
return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now, identity }) {
|
|
270
|
+
function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now, identity, scopePatch = {} }) {
|
|
269
271
|
const sessionPath = join(vaultBase, relPath);
|
|
270
272
|
if (!existsSync(sessionPath)) return false;
|
|
271
273
|
|
|
@@ -288,12 +290,13 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
288
290
|
transcript_path: identity.transcriptPath,
|
|
289
291
|
transcript_id: identity.transcriptId,
|
|
290
292
|
provider: identity.provider,
|
|
293
|
+
...scopePatch,
|
|
291
294
|
...causalTurnPatch(input, now),
|
|
292
295
|
});
|
|
293
296
|
return true;
|
|
294
297
|
}
|
|
295
298
|
|
|
296
|
-
function createSession({ vaultBase, sessionId, input, now, identity }) {
|
|
299
|
+
function createSession({ vaultBase, sessionId, input, now, identity, scopePatch = {} }) {
|
|
297
300
|
const summary = sessionSummaryFromInput(input);
|
|
298
301
|
const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
|
|
299
302
|
const startedAt = formatLocalIso(now);
|
|
@@ -315,6 +318,7 @@ function createSession({ vaultBase, sessionId, input, now, identity }) {
|
|
|
315
318
|
transcript_path: identity.transcriptPath,
|
|
316
319
|
transcript_id: identity.transcriptId,
|
|
317
320
|
provider: identity.provider,
|
|
321
|
+
...scopePatch,
|
|
318
322
|
...causalTurnPatch(input, now),
|
|
319
323
|
});
|
|
320
324
|
return { relPath, startedAt };
|
|
@@ -363,6 +367,19 @@ function main() {
|
|
|
363
367
|
return;
|
|
364
368
|
}
|
|
365
369
|
|
|
370
|
+
const projectResolution = resolveVault(input);
|
|
371
|
+
const currentScope = captureProjectScope({
|
|
372
|
+
input,
|
|
373
|
+
projectRoot: projectResolution.projectRoot,
|
|
374
|
+
projectId: projectResolution.projectId,
|
|
375
|
+
provider: identity.provider,
|
|
376
|
+
sessionId,
|
|
377
|
+
});
|
|
378
|
+
const scopePatch = projectScopePatch(
|
|
379
|
+
readSessionRegistry(vaultBase).sessions?.[sessionId]?.project_scope,
|
|
380
|
+
currentScope,
|
|
381
|
+
);
|
|
382
|
+
|
|
366
383
|
// Fast path: skip all writes if control file touched < 5 min ago and session matches
|
|
367
384
|
try {
|
|
368
385
|
const ctrlPath = controlPath(vaultBase);
|
|
@@ -381,6 +398,7 @@ function main() {
|
|
|
381
398
|
transcript_path: identity.transcriptPath,
|
|
382
399
|
transcript_id: identity.transcriptId,
|
|
383
400
|
provider: identity.provider,
|
|
401
|
+
...scopePatch,
|
|
384
402
|
...causalTurnPatch(input, now),
|
|
385
403
|
});
|
|
386
404
|
writeHookOutput({});
|
|
@@ -425,6 +443,7 @@ function main() {
|
|
|
425
443
|
transcript_path: identity.transcriptPath,
|
|
426
444
|
transcript_id: identity.transcriptId,
|
|
427
445
|
provider: identity.provider,
|
|
446
|
+
...scopePatch,
|
|
428
447
|
...causalTurnPatch(input, now),
|
|
429
448
|
});
|
|
430
449
|
writeHookOutput({});
|
|
@@ -436,7 +455,7 @@ function main() {
|
|
|
436
455
|
const resolvedTarget = registered?.session_file
|
|
437
456
|
? { sessionId, relPath: registered.session_file, startedAt: registered.started_at || '' }
|
|
438
457
|
: { sessionId, relPath: '', startedAt: '' };
|
|
439
|
-
if (resolvedTarget.relPath && activateExistingSession({ vaultBase, relPath: resolvedTarget.relPath, startedAt: resolvedTarget.startedAt, sessionId, input, now, identity })) {
|
|
458
|
+
if (resolvedTarget.relPath && activateExistingSession({ vaultBase, relPath: resolvedTarget.relPath, startedAt: resolvedTarget.startedAt, sessionId, input, now, identity, scopePatch })) {
|
|
440
459
|
outputActiveContext({
|
|
441
460
|
relPath: resolvedTarget.relPath,
|
|
442
461
|
startedAt: resolvedTarget.startedAt || formatLocalIso(now),
|
|
@@ -446,7 +465,7 @@ function main() {
|
|
|
446
465
|
return;
|
|
447
466
|
}
|
|
448
467
|
|
|
449
|
-
const created = createSession({ vaultBase, sessionId, input, now, identity });
|
|
468
|
+
const created = createSession({ vaultBase, sessionId, input, now, identity, scopePatch });
|
|
450
469
|
outputActiveContext({
|
|
451
470
|
relPath: created.relPath,
|
|
452
471
|
startedAt: created.startedAt,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { redactSecrets } from '../packages/integrations/src/prompt-content.mjs';
|
|
4
|
+
import {
|
|
5
|
+
assertVaultPathSafe,
|
|
6
|
+
VAULT_LOCK_BUSY,
|
|
7
|
+
withVaultPathLock,
|
|
8
|
+
writeVaultFileAtomic,
|
|
9
|
+
} from '../packages/vault/src/vault-path-safety.mjs';
|
|
10
|
+
|
|
11
|
+
export const ITERATION_OUTCOME_LEDGER = '.brain/SESSION_ITERATION_OUTCOMES.jsonl';
|
|
12
|
+
export const OUTCOME_RESULTS = Object.freeze([
|
|
13
|
+
'inserted', 'duplicate', 'ambiguous', 'aborted', 'skipped', 'busy', 'failed',
|
|
14
|
+
'published', 'degraded', 'stale', 'missing', 'conflict',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const RESULT_SET = new Set(OUTCOME_RESULTS);
|
|
18
|
+
const STAGES = new Set(['iteration', 'observability']);
|
|
19
|
+
const LOCK_STATUSES = new Set(['acquired', 'busy', 'not_required', 'unknown']);
|
|
20
|
+
|
|
21
|
+
function safeId(value, fallback = 'unknown') {
|
|
22
|
+
const clean = String(value ?? '')
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '')
|
|
26
|
+
.slice(0, 160);
|
|
27
|
+
return clean || fallback;
|
|
28
|
+
}
|
|
29
|
+
function safeReason(value) {
|
|
30
|
+
return redactSecrets(String(value ?? ''))
|
|
31
|
+
.replace(/[\r\n]+/g, ' ')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim()
|
|
34
|
+
.slice(0, 240);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeTimestamp(value) {
|
|
38
|
+
const candidate = String(value ?? '').trim();
|
|
39
|
+
if (candidate && !Number.isNaN(Date.parse(candidate))) return new Date(candidate).toISOString();
|
|
40
|
+
return new Date().toISOString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeInteger(value, fallback = 0) {
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function iterationOutcomePath(vaultBase) {
|
|
49
|
+
return join(vaultBase, ITERATION_OUTCOME_LEDGER);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function outcomeKey(input = {}) {
|
|
53
|
+
return [
|
|
54
|
+
safeId(input.session_id),
|
|
55
|
+
safeId(input.turn_id),
|
|
56
|
+
STAGES.has(input.stage) ? input.stage : 'iteration',
|
|
57
|
+
].join(':');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeIterationOutcome(input = {}) {
|
|
61
|
+
const sessionId = safeId(input.session_id);
|
|
62
|
+
const turnId = safeId(input.turn_id);
|
|
63
|
+
const stage = STAGES.has(input.stage) ? input.stage : 'iteration';
|
|
64
|
+
const result = RESULT_SET.has(input.result) ? input.result : 'failed';
|
|
65
|
+
const lockStatus = LOCK_STATUSES.has(input.lock_status) ? input.lock_status : 'unknown';
|
|
66
|
+
const normalized = {
|
|
67
|
+
schema_version: 1,
|
|
68
|
+
outcome_id: `${sessionId}:${turnId}:${stage}`,
|
|
69
|
+
session_id: sessionId,
|
|
70
|
+
transcript_id: safeId(input.transcript_id, ''),
|
|
71
|
+
turn_id: turnId,
|
|
72
|
+
turn_sequence: safeInteger(input.turn_sequence),
|
|
73
|
+
hook: safeId(input.hook, 'Stop'),
|
|
74
|
+
stage,
|
|
75
|
+
result,
|
|
76
|
+
lock_status: lockStatus,
|
|
77
|
+
duration_ms: safeInteger(input.duration_ms),
|
|
78
|
+
occurred_at: safeTimestamp(input.occurred_at),
|
|
79
|
+
reason: safeReason(input.reason),
|
|
80
|
+
};
|
|
81
|
+
if (!normalized.transcript_id) delete normalized.transcript_id;
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readRawLedger(vaultBase) {
|
|
86
|
+
const path = iterationOutcomePath(vaultBase);
|
|
87
|
+
const checked = assertVaultPathSafe(vaultBase, path, {
|
|
88
|
+
expectedType: 'file', label: 'SESSION_ITERATION_OUTCOMES.jsonl',
|
|
89
|
+
});
|
|
90
|
+
return checked.exists ? readFileSync(checked.target, 'utf8') : '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseLedger(raw) {
|
|
94
|
+
return String(raw || '').split('\n').filter(Boolean).flatMap((line) => {
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(line);
|
|
97
|
+
return value && typeof value === 'object' ? [value] : [];
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readIterationOutcomes(vaultBase) {
|
|
105
|
+
return parseLedger(readRawLedger(vaultBase));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendOnce(vaultBase, outcome, timeoutMs) {
|
|
109
|
+
const path = iterationOutcomePath(vaultBase);
|
|
110
|
+
const result = withVaultPathLock(vaultBase, path, () => {
|
|
111
|
+
const raw = readRawLedger(vaultBase);
|
|
112
|
+
const existing = parseLedger(raw);
|
|
113
|
+
const duplicate = existing.find((entry) => entry.outcome_id === outcome.outcome_id);
|
|
114
|
+
if (duplicate) {
|
|
115
|
+
return { written: false, result: 'duplicate', reason: 'already-recorded', outcome: duplicate };
|
|
116
|
+
}
|
|
117
|
+
const separator = raw && !raw.endsWith('\n') ? '\n' : '';
|
|
118
|
+
writeVaultFileAtomic(vaultBase, path, `${raw}${separator}${JSON.stringify(outcome)}\n`, 'utf8', {
|
|
119
|
+
label: 'SESSION_ITERATION_OUTCOMES.jsonl',
|
|
120
|
+
});
|
|
121
|
+
return { written: true, result: outcome.result, reason: 'ok', outcome };
|
|
122
|
+
}, { timeoutMs });
|
|
123
|
+
if (result === VAULT_LOCK_BUSY) return null;
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function appendIterationOutcome(vaultBase, input, {
|
|
128
|
+
timeoutMs = 50,
|
|
129
|
+
retries = 2,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const outcome = normalizeIterationOutcome(input);
|
|
132
|
+
const maxAttempts = Math.max(1, Number(retries) + 1);
|
|
133
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
134
|
+
const result = appendOnce(vaultBase, outcome, timeoutMs);
|
|
135
|
+
if (result) return result;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
written: false,
|
|
139
|
+
result: 'busy',
|
|
140
|
+
reason: 'ledger-lock-busy',
|
|
141
|
+
outcome: { ...outcome, result: 'busy', lock_status: 'busy' },
|
|
142
|
+
};
|
|
143
|
+
}
|