wendkeep 0.68.0 → 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.
@@ -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
+ }
@@ -137,7 +137,7 @@ export function backfillSessions({ vaultBase, write = false, limit = 0, session
137
137
  tx,
138
138
  vaultBase,
139
139
  );
140
- if (inserted) {
140
+ if (inserted.result === 'inserted') {
141
141
  report.inserted += 1;
142
142
  sessionReport.inserted += 1;
143
143
  }
@@ -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
+ }