wendkeep 0.76.2 → 0.76.4

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/src/context.mjs CHANGED
@@ -10,16 +10,23 @@ import {
10
10
  concurrentScopeConflicts,
11
11
  scopeForRegistry,
12
12
  } from '../hooks/project-scope.mjs';
13
+ import { readVaultMarker } from './project-vault.mjs';
14
+ import { sanitizeMemoryText } from '../packages/vault/src/memory-schema.mjs';
13
15
 
14
16
  export const CONTEXT_HELP = `wendkeep context <subcommand>
15
17
 
16
18
  switch <branch> [--create] [--session <id>] [--project <path>] [--vault <path>] [--json]
19
+ status --session <id> [--project <path>] [--vault <path>] [--json]
20
+ recover --session <id> --select <reserved|observed> --revision <n> --reason <text>
21
+ [--project <path>] [--vault <path>] [--json]
17
22
 
18
23
  Switches Git branch and the causal session scope together inside the same worktree.
19
24
  Without --session, exactly one active session must match the current scope.
25
+ Status inventories reserved/observed recovery candidates without selecting one.
26
+ Recover resolves a quarantined conflict only when the selected candidate still matches the checkout.
20
27
  `;
21
28
 
22
- const VALUE_OPTIONS = new Set(['--project', '--vault', '--session']);
29
+ const VALUE_OPTIONS = new Set(['--project', '--vault', '--session', '--select', '--revision', '--reason']);
23
30
  const FLAG_OPTIONS = new Set(['--create', '--json']);
24
31
 
25
32
  function contextError(code, message) {
@@ -121,6 +128,247 @@ function contextRevision(entry) {
121
128
  ? entry.context_revision : 0;
122
129
  }
123
130
 
131
+ function activeSessionEntry(registry, sessionId) {
132
+ const id = String(sessionId || '').trim();
133
+ if (!id) throw contextError('WENDKEEP_CONTEXT_SESSION', 'status requer --session <id>');
134
+ const entry = registry.sessions?.[id];
135
+ if (!entry || entry.status !== 'active') {
136
+ throw contextError('WENDKEEP_CONTEXT_SESSION', `sessão ativa não encontrada: ${id}`);
137
+ }
138
+ return { id, entry };
139
+ }
140
+
141
+ function validCandidateScope(scope, label) {
142
+ const required = [
143
+ 'projectId', 'projectRoot', 'repoRoot', 'remote', 'branch', 'worktree',
144
+ 'head', 'provider', 'sessionId',
145
+ ];
146
+ const complete = scope && typeof scope === 'object' && scope.complete === true
147
+ && required.every((field) => typeof scope[field] === 'string' && scope[field].trim());
148
+ const head = complete ? scope.head.trim() : '';
149
+ const branch = complete ? scope.branch.trim() : '';
150
+ const safeHead = /^[0-9a-f]{40,64}$/i.test(head);
151
+ const safeBranch = branch === `detached:${head}` || (
152
+ branch.length <= 255
153
+ && !branch.startsWith('/')
154
+ && !branch.endsWith('/')
155
+ && !branch.endsWith('.')
156
+ && !branch.endsWith('.lock')
157
+ && !branch.includes('..')
158
+ && !branch.includes('@{')
159
+ && !branch.includes('//')
160
+ && !/[\u0000-\u0020~^:?*[\]\\]/u.test(branch)
161
+ );
162
+ if (!complete || !safeHead || !safeBranch) {
163
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', `scope ${label} ausente ou incompleta`);
164
+ }
165
+ return scope;
166
+ }
167
+
168
+ function scopeMatchesActual(candidate, actual) {
169
+ return compareProjectScopes(candidate, actual).ok
170
+ && Boolean(candidate.head)
171
+ && candidate.head === actual.head;
172
+ }
173
+
174
+ function candidateSummary(id, candidate, actual) {
175
+ return {
176
+ id,
177
+ branch: candidate.branch || '',
178
+ head: candidate.head || '',
179
+ complete: candidate.complete === true,
180
+ matches_actual: scopeMatchesActual(candidate, actual),
181
+ };
182
+ }
183
+
184
+ function requiredRevision(value) {
185
+ const raw = String(value ?? '').trim();
186
+ const revision = Number(raw);
187
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(revision)) {
188
+ throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --revision <inteiro não negativo>');
189
+ }
190
+ return revision;
191
+ }
192
+
193
+ function recoveryReason(value) {
194
+ const raw = String(value || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
195
+ if (!raw) throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --reason <texto>');
196
+ if (raw.length > 240) throw contextError('WENDKEEP_CONTEXT_ARGS', '--reason excede 240 caracteres');
197
+ return sanitizeMemoryText(raw).replace(/\s+/g, ' ').trim();
198
+ }
199
+
200
+ function recoverySelection(value) {
201
+ const selected = String(value || '').trim();
202
+ if (!['reserved', 'observed'].includes(selected)) {
203
+ throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --select <reserved|observed>');
204
+ }
205
+ return selected;
206
+ }
207
+
208
+ function scopeIdentityMismatches(reserved, observed) {
209
+ const fields = ['projectId', 'remote', 'provider', 'sessionId'];
210
+ return fields.filter((field) => String(reserved?.[field] || '') !== String(observed?.[field] || ''));
211
+ }
212
+
213
+ function validateRecoveryIdentity(vaultBase, entry, sessionId, reserved, observed = null) {
214
+ const mismatches = observed ? scopeIdentityMismatches(reserved, observed) : [];
215
+ const candidates = observed ? [reserved, observed] : [reserved];
216
+ if (candidates.some((candidate) => candidate.sessionId !== sessionId)) mismatches.push('sessionId');
217
+ if (entry.provider && candidates.some((candidate) => candidate.provider !== entry.provider)) {
218
+ mismatches.push('provider');
219
+ }
220
+ let marker = null;
221
+ try { marker = readVaultMarker(vaultBase)?.marker || null; } catch { /* fail closed below */ }
222
+ if (!marker?.projectId || candidates.some((candidate) => candidate.projectId !== marker.projectId)) {
223
+ mismatches.push('projectId');
224
+ }
225
+ if (mismatches.length) {
226
+ throw contextError(
227
+ 'WENDKEEP_CONTEXT_IDENTITY_CHANGED',
228
+ `candidatas divergem na identidade causal (${[...new Set(mismatches)].join(', ')})`,
229
+ );
230
+ }
231
+ }
232
+
233
+ function recoveryActualScope(projectRoot, expected, sessionId, spawn) {
234
+ return captureProjectScope({
235
+ input: { cwd: projectRoot },
236
+ projectRoot,
237
+ projectId: expected.projectId,
238
+ provider: expected.provider,
239
+ sessionId,
240
+ targetCwd: projectRoot,
241
+ spawn,
242
+ });
243
+ }
244
+
245
+ const CONFLICT_FIELDS = new Set([
246
+ 'scope.projectId', 'scope.projectRoot', 'scope.repoRoot', 'scope.remote', 'scope.branch',
247
+ 'scope.worktree', 'scope.provider', 'scope.sessionId', 'scope.incomplete',
248
+ ]);
249
+
250
+ function sanitizedConflictFields(value) {
251
+ if (!Array.isArray(value) || value.some((field) => !CONFLICT_FIELDS.has(String(field)))) {
252
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', 'campos de conflito ausentes ou inválidos');
253
+ }
254
+ return [...new Set(value.map(String))].sort();
255
+ }
256
+
257
+ function receiptScope(candidate) {
258
+ return { branch: candidate.branch || '', head: candidate.head || '' };
259
+ }
260
+
261
+ export function inspectSessionContext({
262
+ vaultBase,
263
+ projectRoot = process.cwd(),
264
+ sessionId = '',
265
+ spawn = spawnSync,
266
+ } = {}) {
267
+ const registry = readSessionRegistry(vaultBase);
268
+ const selected = activeSessionEntry(registry, sessionId);
269
+ const reserved = validCandidateScope(selected.entry.project_scope, 'reserved');
270
+ const conflict = selected.entry.project_scope_conflict === true;
271
+ const observed = conflict
272
+ ? validCandidateScope(selected.entry.project_scope_observed, 'observed')
273
+ : null;
274
+ validateRecoveryIdentity(vaultBase, selected.entry, selected.id, reserved, observed);
275
+ const actual = recoveryActualScope(projectRoot, reserved, selected.id, spawn);
276
+ const candidates = [candidateSummary('reserved', reserved, actual)];
277
+ if (observed) candidates.push(candidateSummary('observed', observed, actual));
278
+ return {
279
+ status: conflict ? 'conflict' : 'healthy',
280
+ session_id: selected.id,
281
+ revision: contextRevision(selected.entry),
282
+ conflict,
283
+ conflict_fields: conflict ? sanitizedConflictFields(selected.entry.project_scope_conflict_fields) : [],
284
+ candidates,
285
+ };
286
+ }
287
+
288
+ export function recoverSessionContext({
289
+ vaultBase,
290
+ projectRoot = process.cwd(),
291
+ sessionId = '',
292
+ select = '',
293
+ revision,
294
+ reason = '',
295
+ spawn = spawnSync,
296
+ mutateRegistry = mutateSessionRegistry,
297
+ now = () => new Date(),
298
+ } = {}) {
299
+ const requestedSessionId = String(sessionId || '').trim();
300
+ if (!requestedSessionId) throw contextError('WENDKEEP_CONTEXT_SESSION', 'recover requer --session <id>');
301
+ const selectedId = recoverySelection(select);
302
+ const expectedRevision = requiredRevision(revision);
303
+ const safeReason = recoveryReason(reason);
304
+
305
+ return mutateRegistry(vaultBase, (registry) => {
306
+ const selected = activeSessionEntry(registry, requestedSessionId);
307
+ const entry = selected.entry;
308
+ if (entry.project_scope_conflict !== true) {
309
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', 'a sessão não possui conflito de scope ativo');
310
+ }
311
+ const currentRevision = contextRevision(entry);
312
+ if (currentRevision !== expectedRevision) {
313
+ throw contextError(
314
+ 'WENDKEEP_CONTEXT_CAS_MISMATCH',
315
+ `context_revision mudou de ${expectedRevision} para ${currentRevision}; inspecione o status novamente`,
316
+ );
317
+ }
318
+ const reserved = validCandidateScope(entry.project_scope, 'reserved');
319
+ const observed = validCandidateScope(entry.project_scope_observed, 'observed');
320
+ validateRecoveryIdentity(vaultBase, entry, selected.id, reserved, observed);
321
+ const candidate = selectedId === 'reserved' ? reserved : observed;
322
+ const actual = recoveryActualScope(projectRoot, candidate, selected.id, spawn);
323
+ if (!scopeMatchesActual(candidate, actual)) {
324
+ throw contextError(
325
+ 'WENDKEEP_CONTEXT_SCOPE_MISMATCH',
326
+ `a candidata ${selectedId} não corresponde integralmente ao checkout atual`,
327
+ );
328
+ }
329
+
330
+ const nextRevision = currentRevision + 1;
331
+ const at = now().toISOString();
332
+ const receipt = {
333
+ revision: nextRevision,
334
+ operation: 'recover',
335
+ selected: selectedId,
336
+ from: {
337
+ reserved: receiptScope(reserved),
338
+ observed: receiptScope(observed),
339
+ },
340
+ to: receiptScope(actual),
341
+ actor: { provider: candidate.provider || entry.provider || '', session_id: selected.id },
342
+ reason: safeReason,
343
+ at,
344
+ };
345
+ const {
346
+ project_scope_conflict: _conflict,
347
+ project_scope_conflict_fields: _conflictFields,
348
+ project_scope_observed: _observed,
349
+ ...preserved
350
+ } = entry;
351
+ registry.sessions[selected.id] = {
352
+ ...preserved,
353
+ project_scope: scopeForRegistry(actual, { authorizedActions: reserved.authorizedActions }),
354
+ context_revision: nextRevision,
355
+ context_recoveries: [
356
+ ...(Array.isArray(entry.context_recoveries) ? entry.context_recoveries : []),
357
+ receipt,
358
+ ],
359
+ last_seen: at,
360
+ updated_at: at,
361
+ };
362
+ return {
363
+ status: 'recovered',
364
+ session_id: selected.id,
365
+ selected: selectedId,
366
+ revision: nextRevision,
367
+ receipt,
368
+ };
369
+ });
370
+ }
371
+
124
372
  function resolveSessionId(vaultBase, projectRoot, requested, spawn) {
125
373
  const registry = readSessionRegistry(vaultBase);
126
374
  if (requested) {
@@ -271,6 +519,12 @@ export function switchSessionContext({
271
519
 
272
520
  function output(result, json) {
273
521
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
522
+ else if (Array.isArray(result.candidates)) {
523
+ process.stdout.write(`context ${result.status}: session ${result.session_id}; revision ${result.revision}; candidates ${result.candidates.map((candidate) => candidate.id).join(', ')}\n`);
524
+ }
525
+ else if (result.status === 'recovered') {
526
+ process.stdout.write(`context recovered: ${result.selected} selected (session ${result.session_id}; revision ${result.revision})\n`);
527
+ }
274
528
  else process.stdout.write(`context ${result.status}: ${result.branch} (session ${result.session_id}; revision ${result.revision})\n`);
275
529
  }
276
530
 
@@ -278,6 +532,27 @@ export function runContext(argv = []) {
278
532
  try {
279
533
  validateArgv(argv);
280
534
  const [sub, branch, ...extra] = positionals(argv);
535
+ if (sub === 'status' && !branch && !extra.length) {
536
+ const result = inspectSessionContext({
537
+ vaultBase: vaultOf(argv),
538
+ projectRoot: projectOf(argv),
539
+ sessionId: optionValue(argv, '--session'),
540
+ });
541
+ output(result, argv.includes('--json'));
542
+ return 0;
543
+ }
544
+ if (sub === 'recover' && !branch && !extra.length) {
545
+ const result = recoverSessionContext({
546
+ vaultBase: vaultOf(argv),
547
+ projectRoot: projectOf(argv),
548
+ sessionId: optionValue(argv, '--session'),
549
+ select: optionValue(argv, '--select'),
550
+ revision: optionValue(argv, '--revision'),
551
+ reason: optionValue(argv, '--reason'),
552
+ });
553
+ output(result, argv.includes('--json'));
554
+ return 0;
555
+ }
281
556
  if (sub !== 'switch' || !branch || extra.length) {
282
557
  throw contextError('WENDKEEP_CONTEXT_ARGS', 'use: wendkeep context switch <branch> [--create] [--session <id>]');
283
558
  }
package/src/spec.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from '../hooks/spec-core.mjs';
12
12
  import { activeChange, parseTasks } from '../hooks/change-core.mjs';
13
13
  import { getLocale } from '../hooks/locale.mjs';
14
+ import { resolveCommandActiveContext } from './active-context-runtime.mjs';
14
15
 
15
16
  function resolveVault(argv) {
16
17
  let vault;
@@ -38,9 +39,21 @@ export function runSpec(argv) {
38
39
  const entry = rest.find((a) => a.startsWith(`${name}=`));
39
40
  return entry ? entry.slice(name.length + 1) : undefined;
40
41
  };
42
+ const commandContext = () => {
43
+ try {
44
+ return resolveCommandActiveContext({
45
+ vaultBase,
46
+ projectRoot: resolve(option('--project') || process.cwd()),
47
+ sessionId: option('--session') || process.env.CODEX_THREAD_ID || process.env.CLAUDE_SESSION_ID || '',
48
+ });
49
+ } catch (error) {
50
+ process.stderr.write(`wendkeep spec: ${error.code || 'WENDKEEP_ACTIVE_CONTEXT_FAILED'}: ${error.message}\n`);
51
+ process.exit(2);
52
+ }
53
+ };
41
54
 
42
55
  if (sub === 'effective') {
43
- const slug = option('--change') || activeChange(vaultBase);
56
+ const slug = option('--change') || activeChange(vaultBase, { context: commandContext() });
44
57
  if (!slug) { process.stderr.write('wendkeep spec effective: no change (--change or current)\n'); process.exit(2); }
45
58
  const changeDir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
46
59
  let tasks = [];
@@ -74,7 +87,7 @@ export function runSpec(argv) {
74
87
  }
75
88
 
76
89
  if (sub === 'rebase') {
77
- const slug = option('--change') || activeChange(vaultBase);
90
+ const slug = option('--change') || activeChange(vaultBase, { context: commandContext() });
78
91
  if (!slug) { process.stderr.write('wendkeep spec rebase: no change (--change or current)\n'); process.exit(2); }
79
92
  const changeDir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
80
93
  try { readFileSync(join(changeDir, 'proposta.md'), 'utf8'); }
package/src/verify.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from '../hooks/spec-core.mjs';
21
21
  import { addLesson } from '../hooks/lessons-core.mjs';
22
22
  import { getLocale } from '../hooks/locale.mjs';
23
+ import { resolveCommandActiveContext } from './active-context-runtime.mjs';
23
24
 
24
25
  function today() {
25
26
  const d = new Date();
@@ -40,7 +41,20 @@ export function runVerify(argv) {
40
41
  // --project wins; otherwise climb from cwd to the nearest project marker (agent shells
41
42
  // keep their cwd across commands, so verify from a subdirectory is a recurring miss).
42
43
  const projectRoot = resolve(opt(argv, '--project') || findProjectRoot(process.cwd()) || process.cwd());
43
- const slug = opt(argv, '--change') || activeChange(vaultBase);
44
+ let commandContext = null;
45
+ if (!opt(argv, '--change')) {
46
+ try {
47
+ commandContext = resolveCommandActiveContext({
48
+ vaultBase,
49
+ projectRoot,
50
+ sessionId: opt(argv, '--session') || process.env.CODEX_THREAD_ID || process.env.CLAUDE_SESSION_ID || '',
51
+ });
52
+ } catch (error) {
53
+ process.stderr.write(`wendkeep verify: ${error.code || 'WENDKEEP_ACTIVE_CONTEXT_FAILED'}: ${error.message}\n`);
54
+ process.exit(2);
55
+ }
56
+ }
57
+ const slug = opt(argv, '--change') || activeChange(vaultBase, { context: commandContext });
44
58
  if (!slug) { process.stderr.write('wendkeep verify: no change (--change or active).\n'); process.exit(2); }
45
59
 
46
60
  const changeDir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);