wendkeep 0.78.0 → 0.80.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.en.md +58 -3
  3. package/README.md +58 -3
  4. package/docs/en/commands/changes-and-verification.md +116 -1
  5. package/docs/en/commands/operating-profiles.md +49 -5
  6. package/docs/en/commands/sessions-and-import.md +6 -0
  7. package/docs/en/commands/verify.md +54 -0
  8. package/docs/en/commands/worktrees.md +39 -4
  9. package/docs/pt-BR/commands/changes-and-verification.md +115 -1
  10. package/docs/pt-BR/commands/operating-profiles.md +51 -5
  11. package/docs/pt-BR/commands/sessions-and-import.md +7 -0
  12. package/docs/pt-BR/commands/verify.md +53 -0
  13. package/docs/pt-BR/commands/worktrees.md +38 -3
  14. package/hooks/active-context-store.mjs +530 -2
  15. package/hooks/change-core.mjs +220 -123
  16. package/hooks/obsidian-common.mjs +175 -9
  17. package/hooks/session-stop.mjs +40 -1
  18. package/hooks/spec-core.mjs +93 -29
  19. package/package.json +2 -2
  20. package/packages/cli/src/index.mjs +7 -0
  21. package/packages/vault/src/memory-handoff.mjs +15 -0
  22. package/schema/artifact-manifest-v1.schema.json +35 -0
  23. package/schema/handoff-contract-v1.schema.json +37 -0
  24. package/schema/task-contract-v1.schema.json +57 -0
  25. package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
  26. package/src/archive-operation-lock.mjs +235 -0
  27. package/src/change.mjs +1780 -79
  28. package/src/delivery.mjs +724 -67
  29. package/src/memory.mjs +2 -1
  30. package/src/provenance-gate.mjs +575 -0
  31. package/src/provenance-sources.mjs +547 -0
  32. package/src/receipt-ledger.mjs +841 -0
  33. package/src/release-provenance.mjs +48 -0
  34. package/src/task-contracts.mjs +510 -0
  35. package/src/task-leases.mjs +105 -0
  36. package/src/task.mjs +115 -0
  37. package/src/verify.mjs +32 -0
  38. package/src/worktree-cleanup.mjs +1733 -118
  39. package/src/worktree.mjs +94 -5
@@ -1,7 +1,13 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { mutateSessionRegistry, readSessionRegistry } from './obsidian-common.mjs';
4
+ import {
5
+ cleanupReservationForWorktree,
6
+ cleanupTombstoneForWorktree,
7
+ comparableCleanupPath,
8
+ mutateSessionRegistry,
9
+ readSessionRegistry,
10
+ } from './obsidian-common.mjs';
5
11
  import { mkdirVaultPath, writeVaultFileSync } from './vault-path-safety.mjs';
6
12
 
7
13
  export const ACTIVE_CONTEXTS_SCHEMA_VERSION = 1;
@@ -23,6 +29,17 @@ function contextError(code, message) {
23
29
  return error;
24
30
  }
25
31
 
32
+ function ownerProcessAlive(ownerPid) {
33
+ const pid = Number(ownerPid);
34
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (error) {
39
+ return error?.code === 'EPERM';
40
+ }
41
+ }
42
+
26
43
  function requiredId(value, label) {
27
44
  const normalized = String(value || '').trim();
28
45
  if (!ID_PATTERN.test(normalized)) {
@@ -36,6 +53,74 @@ function optionalText(value, maxLength = 240) {
36
53
  return normalized.length <= maxLength ? normalized : normalized.slice(0, maxLength);
37
54
  }
38
55
 
56
+ function subjectIds(value) {
57
+ return [...new Set(Array.isArray(value) ? value : [])].map(String).sort();
58
+ }
59
+
60
+ function subjectSnapshot(value) {
61
+ return (Array.isArray(value) ? value : [])
62
+ .map((item) => Object.fromEntries(
63
+ Object.entries(item || {}).sort(([left], [right]) => left.localeCompare(right)),
64
+ ))
65
+ .sort((left, right) => String(left.key || '').localeCompare(String(right.key || '')));
66
+ }
67
+
68
+ function sameSubjectField(left, right) {
69
+ return String(left || '') === String(right || '');
70
+ }
71
+
72
+ function sameCleanupSubject(left, right) {
73
+ return sameSubjectField(left?.project_id, right?.project_id)
74
+ && sameSubjectField(left?.repository_id, right?.repository_id)
75
+ && sameSubjectField(left?.worktree_id, right?.worktree_id)
76
+ && sameSubjectField(left?.work_session_id, right?.work_session_id)
77
+ && sameSubjectField(left?.change_slug, right?.change_slug)
78
+ && JSON.stringify(subjectIds(left?.target_context_ids))
79
+ === JSON.stringify(subjectIds(right?.target_context_ids))
80
+ && JSON.stringify(subjectIds(left?.target_change_slugs))
81
+ === JSON.stringify(subjectIds(right?.target_change_slugs))
82
+ && JSON.stringify(subjectSnapshot(left?.target_context_snapshot))
83
+ === JSON.stringify(subjectSnapshot(right?.target_context_snapshot))
84
+ && sameSubjectField(left?.actor_context_id, right?.actor_context_id)
85
+ && sameSubjectField(left?.worktree_path, right?.worktree_path)
86
+ && sameSubjectField(left?.mode, right?.mode)
87
+ && sameSubjectField(left?.authority, right?.authority)
88
+ && sameSubjectField(left?.head, right?.head)
89
+ && sameSubjectField(left?.slug, right?.slug)
90
+ && sameSubjectField(left?.pull_request_number, right?.pull_request_number)
91
+ && sameSubjectField(left?.pull_request_repository, right?.pull_request_repository)
92
+ && sameSubjectField(left?.head_ref_oid, right?.head_ref_oid)
93
+ && sameSubjectField(left?.merge_commit_oid, right?.merge_commit_oid);
94
+ }
95
+
96
+ function cleanupSubjectFromOptions({
97
+ projectId = '', repositoryId = '', worktreeId = '', workSessionId = '', changeSlug = '',
98
+ targetContextIds = [], targetChangeSlugs = [], targetContextSnapshot = [], actorContextId = '',
99
+ worktreePath = '', mode = '', authority = '', head = '', slug = '',
100
+ pullRequestNumber = '', pullRequestRepository = '', headRefOid = '', mergeCommitOid = '',
101
+ } = {}) {
102
+ return {
103
+ project_id: String(projectId || ''),
104
+ repository_id: String(repositoryId || ''),
105
+ worktree_id: String(worktreeId || ''),
106
+ work_session_id: String(workSessionId || ''),
107
+ change_slug: String(changeSlug || ''),
108
+ target_context_ids: subjectIds(targetContextIds),
109
+ target_change_slugs: subjectIds(targetChangeSlugs),
110
+ target_context_snapshot: subjectSnapshot(targetContextSnapshot),
111
+ actor_context_id: String(actorContextId || ''),
112
+ worktree_path: String(worktreePath || ''),
113
+ mode: String(mode || ''),
114
+ authority: String(authority || ''),
115
+ head: String(head || ''),
116
+ slug: String(slug || ''),
117
+ pull_request_number: String(pullRequestNumber || ''),
118
+ pull_request_repository: String(pullRequestRepository || ''),
119
+ head_ref_oid: String(headRefOid || ''),
120
+ merge_commit_oid: String(mergeCommitOid || ''),
121
+ };
122
+ }
123
+
39
124
  function normalizeIdentity(identity = {}, { requireWorkSession = true } = {}) {
40
125
  const workSessionId = requireWorkSession
41
126
  ? requiredId(identity.workSessionId ?? identity.work_session_id, 'work_session_id')
@@ -75,6 +160,22 @@ function activeContexts(registry) {
75
160
  return Object.entries(contextsOf(registry)).filter(([, context]) => context?.state === 'active');
76
161
  }
77
162
 
163
+ function contextCausalSnapshot(entries = []) {
164
+ return entries.map(([key, context]) => ({
165
+ key: String(key),
166
+ project_id: String(context?.project_id || ''),
167
+ repository_id: String(context?.repository_id || ''),
168
+ worktree_id: String(context?.worktree_id || ''),
169
+ work_session_id: String(context?.work_session_id || ''),
170
+ change_slug: String(context?.change_slug || ''),
171
+ branch: String(context?.branch || ''),
172
+ head_sha: String(context?.head_sha || ''),
173
+ delivery_id: String(context?.delivery_id || ''),
174
+ state: String(context?.state || ''),
175
+ revision: Number(context?.revision || 0),
176
+ })).sort((left, right) => left.key.localeCompare(right.key));
177
+ }
178
+
78
179
  function legacyProjection(registry) {
79
180
  const active = activeContexts(registry);
80
181
  if (active.length !== 1) return '';
@@ -146,10 +247,31 @@ export function mutateActiveContext(vaultBase, identity, updater, {
146
247
  now = new Date().toISOString(),
147
248
  mutateRegistry = mutateSessionRegistry,
148
249
  projectLegacy = true,
250
+ cleanupOperationId = '',
149
251
  } = {}) {
150
252
  const normalized = normalizeIdentity(identity);
151
253
  const key = activeContextKey(normalized);
152
254
  const result = mutateRegistry(vaultBase, (registry) => {
255
+ const terminal = cleanupTombstoneForWorktree(
256
+ registry, normalized.worktreeId, normalized.repositoryId,
257
+ ) || cleanupTombstoneForWorktree(registry, normalized.worktreeId);
258
+ if (terminal
259
+ && String(terminal.operation_id || '') !== String(cleanupOperationId || '')) {
260
+ throw contextError(
261
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL',
262
+ 'a worktree já foi finalizada por um cleanup anterior',
263
+ );
264
+ }
265
+ const cleanupReservation = cleanupReservationForWorktree(
266
+ registry, normalized.worktreeId, normalized.repositoryId,
267
+ ) || cleanupReservationForWorktree(registry, normalized.worktreeId);
268
+ if (cleanupReservation
269
+ && String(cleanupReservation.operation_id || '') !== String(cleanupOperationId || '')) {
270
+ throw contextError(
271
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
272
+ 'active context está reservado por um cleanup em andamento',
273
+ );
274
+ }
153
275
  const contexts = contextsOf(registry);
154
276
  const existing = contexts[key] || null;
155
277
  const revision = currentContextRevision(existing);
@@ -197,7 +319,7 @@ export function mutateActiveContext(vaultBase, identity, updater, {
197
319
  registry.active_contexts_revision = currentGlobalRevision(registry) + 1;
198
320
  registry.active_contexts = { ...contexts, [key]: next };
199
321
  return { key, context: structuredClone(next), registryRevision: registry.active_contexts_revision };
200
- });
322
+ }, { cleanupOperationId });
201
323
  if (projectLegacy) {
202
324
  projectLegacyActiveChange(vaultBase);
203
325
  projectLegacyActiveDelivery(vaultBase);
@@ -205,6 +327,412 @@ export function mutateActiveContext(vaultBase, identity, updater, {
205
327
  return result;
206
328
  }
207
329
 
330
+ export function reserveActiveContextCleanup(vaultBase, {
331
+ operationId,
332
+ projectId,
333
+ repositoryId,
334
+ worktreeId,
335
+ workSessionId = '',
336
+ changeSlug = '',
337
+ targetContextIds = [],
338
+ targetChangeSlugs = [],
339
+ targetContextSnapshot = [],
340
+ allowClosedContexts = false,
341
+ allowActiveSessions = false,
342
+ actorContextId = '',
343
+ worktreePath = '',
344
+ mode = '',
345
+ authority = '',
346
+ head = '',
347
+ slug = '',
348
+ pullRequestNumber = '',
349
+ pullRequestRepository = '',
350
+ headRefOid = '',
351
+ mergeCommitOid = '',
352
+ ownerPid = process.pid,
353
+ attemptToken = '',
354
+ phase = 'reserved',
355
+ now = new Date().toISOString(),
356
+ } = {}) {
357
+ const operation = requiredId(operationId, 'cleanup operation_id');
358
+ const project = requiredId(projectId, 'project_id');
359
+ const repository = requiredId(repositoryId, 'repository_id');
360
+ const worktree = requiredId(worktreeId, 'worktree_id');
361
+ return mutateSessionRegistry(vaultBase, (registry) => {
362
+ const terminal = cleanupTombstoneForWorktree(registry, worktree, repository)
363
+ || cleanupTombstoneForWorktree(registry, worktree);
364
+ if (terminal && String(terminal.repository_id || '') !== repository) {
365
+ throw contextError(
366
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
367
+ 'o tombstone terminal pertence a outro repositório',
368
+ );
369
+ }
370
+ if (terminal && String(terminal.operation_id || '') !== operation) {
371
+ throw contextError(
372
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL',
373
+ 'a worktree já foi finalizada por um cleanup anterior',
374
+ );
375
+ }
376
+ const existing = cleanupReservationForWorktree(registry, worktree, repository);
377
+ const foreignReservation = cleanupReservationForWorktree(registry, worktree);
378
+ if (foreignReservation && (!existing || foreignReservation !== existing)) {
379
+ throw contextError(
380
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
381
+ 'já existe um cleanup reservado para esta worktree',
382
+ );
383
+ }
384
+ if (existing && String(existing.operation_id || '') !== operation) {
385
+ throw contextError(
386
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
387
+ 'já existe um cleanup reservado para esta worktree',
388
+ );
389
+ }
390
+ if (existing && String(existing.operation_id || '') === operation) {
391
+ const storedAttempt = String(existing.attempt_token || '');
392
+ const requestedAttempt = String(attemptToken || '');
393
+ const sameAttempt = requestedAttempt && storedAttempt
394
+ && requestedAttempt === storedAttempt;
395
+ const terminalPhase = ['failed', 'finalized'].includes(String(existing.phase || ''));
396
+ if (!sameAttempt && !terminalPhase && ownerProcessAlive(existing.owner_pid)) {
397
+ throw contextError(
398
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
399
+ 'já existe uma tentativa ativa para esta operação de cleanup',
400
+ );
401
+ }
402
+ }
403
+ const active = Object.entries(contextsOf(registry))
404
+ .filter(([, context]) => context?.state === 'active'
405
+ && context?.worktree_id === worktree);
406
+ if (active.some(([, context]) => String(context?.repository_id || '') !== repository)) {
407
+ throw contextError(
408
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED',
409
+ 'há um active context de outro repositório com o mesmo worktree_id',
410
+ );
411
+ }
412
+ const scopedActive = active.filter(([, context]) => context?.repository_id === repository);
413
+ const actualContextIds = scopedActive.map(([key]) => String(key)).sort();
414
+ const actualChangeSlugs = [...new Set(scopedActive
415
+ .map(([, context]) => String(context?.change_slug || '').trim())
416
+ .filter(Boolean))].sort();
417
+ const expectedContextIds = subjectIds(targetContextIds);
418
+ const expectedChangeSlugs = subjectIds(targetChangeSlugs);
419
+ const expectedSnapshot = contextCausalSnapshot(
420
+ (targetContextSnapshot || []).map((context) => [context.key, context]),
421
+ );
422
+ const actualSnapshot = contextCausalSnapshot(scopedActive);
423
+ const closedForRetry = allowClosedContexts
424
+ && actualContextIds.length === 0
425
+ && expectedContextIds.length > 0;
426
+ if ((!closedForRetry && JSON.stringify(actualContextIds) !== JSON.stringify(expectedContextIds))
427
+ || (!closedForRetry && JSON.stringify(actualChangeSlugs) !== JSON.stringify(expectedChangeSlugs))
428
+ || (!closedForRetry && expectedSnapshot.length
429
+ && JSON.stringify(actualSnapshot) !== JSON.stringify(expectedSnapshot))) {
430
+ throw contextError(
431
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH',
432
+ 'active contexts mudaram antes da reserva do cleanup',
433
+ );
434
+ }
435
+ const comparablePath = comparableCleanupPath(worktreePath);
436
+ const activeSessions = Object.entries(registry.sessions || {}).filter(([, session]) => {
437
+ if (session?.status !== 'active') return false;
438
+ const sameSession = workSessionId && String(session.work_session_id || '') === String(workSessionId);
439
+ const sessionPath = comparableCleanupPath(session?.project_scope?.repoRoot);
440
+ return sameSession || (comparablePath && sessionPath === comparablePath);
441
+ });
442
+ if (activeSessions.length && !allowActiveSessions) {
443
+ throw contextError(
444
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_SESSION_RESERVED',
445
+ 'sessão ativa está reservada por um cleanup em andamento',
446
+ );
447
+ }
448
+ if (allowActiveSessions) {
449
+ // A path ausente só é retomável quando a sessão causal é fechada na
450
+ // mesma transação que reinstala a reserva. O retry não pode deixar uma
451
+ // sessão ativa apontando para uma worktree já removida.
452
+ for (const [sessionId, session] of activeSessions) {
453
+ registry.sessions[sessionId] = {
454
+ ...session,
455
+ status: 'done',
456
+ active_activation_id: '',
457
+ ended_at: String(now),
458
+ };
459
+ }
460
+ }
461
+ const reservations = {
462
+ ...(registry.cleanup_reservations || {}),
463
+ [`${repository}:${worktree}`]: {
464
+ state: 'cleaning',
465
+ operation_id: operation,
466
+ project_id: project,
467
+ repository_id: repository,
468
+ worktree_id: worktree,
469
+ work_session_id: optionalText(workSessionId, 160),
470
+ change_slug: optionalText(changeSlug, 160),
471
+ target_context_ids: expectedContextIds,
472
+ target_change_slugs: expectedChangeSlugs,
473
+ target_context_snapshot: subjectSnapshot(expectedSnapshot),
474
+ actor_context_id: optionalText(actorContextId, 160),
475
+ worktree_path: optionalText(worktreePath, 1024),
476
+ mode: optionalText(mode, 32),
477
+ authority: optionalText(authority, 240),
478
+ head: optionalText(head, 80),
479
+ slug: optionalText(slug, 160),
480
+ pull_request_number: optionalText(pullRequestNumber, 80),
481
+ pull_request_repository: optionalText(pullRequestRepository, 240),
482
+ head_ref_oid: optionalText(headRefOid, 160),
483
+ merge_commit_oid: optionalText(mergeCommitOid, 160),
484
+ owner_pid: Number.isSafeInteger(Number(ownerPid)) ? Number(ownerPid) : process.pid,
485
+ phase: optionalText(phase, 48),
486
+ ...(attemptToken ? { attempt_token: optionalText(attemptToken, 160) } : {}),
487
+ updated_at: String(now),
488
+ },
489
+ };
490
+ registry.cleanup_reservations = reservations;
491
+ return structuredClone(reservations[`${repository}:${worktree}`]);
492
+ }, { cleanupOperationId: operation });
493
+ }
494
+
495
+ export function releaseActiveContextCleanup(vaultBase, operationId, {
496
+ repositoryId = '', worktreeId = '', attemptToken = '',
497
+ } = {}) {
498
+ const operation = String(operationId || '').trim();
499
+ if (!operation) return false;
500
+ let released = false;
501
+ mutateSessionRegistry(vaultBase, (registry) => {
502
+ const reservations = { ...(registry.cleanup_reservations || {}) };
503
+ const matchingKeys = Object.keys(reservations).filter((key) => (
504
+ String(reservations[key]?.operation_id || '') === operation
505
+ ));
506
+ if ((!repositoryId || !worktreeId) && matchingKeys.length !== 1) return false;
507
+ const candidates = repositoryId && worktreeId
508
+ ? [`${String(repositoryId).trim()}:${String(worktreeId).trim()}`]
509
+ : matchingKeys;
510
+ for (const key of candidates) {
511
+ const reservation = reservations[key];
512
+ if (String(reservation?.operation_id || '') !== operation) continue;
513
+ if (reservation?.attempt_token
514
+ && String(reservation.attempt_token) !== String(attemptToken || '')) continue;
515
+ delete reservations[key];
516
+ released = true;
517
+ }
518
+ if (Object.keys(reservations).length) registry.cleanup_reservations = reservations;
519
+ else delete registry.cleanup_reservations;
520
+ return released;
521
+ }, { cleanupOperationId: operation });
522
+ return released;
523
+ }
524
+
525
+ export function updateActiveContextCleanupPhase(vaultBase, {
526
+ operationId,
527
+ repositoryId,
528
+ worktreeId,
529
+ attemptToken = '',
530
+ phase,
531
+ now = new Date().toISOString(),
532
+ } = {}) {
533
+ const operation = requiredId(operationId, 'cleanup operation_id');
534
+ const repository = requiredId(repositoryId, 'repository_id');
535
+ const worktree = requiredId(worktreeId, 'worktree_id');
536
+ const requestedPhase = optionalText(phase, 48);
537
+ let updated = false;
538
+ mutateSessionRegistry(vaultBase, (registry) => {
539
+ const key = `${repository}:${worktree}`;
540
+ const reservations = { ...(registry.cleanup_reservations || {}) };
541
+ const reservation = reservations[key];
542
+ if (String(reservation?.operation_id || '') !== operation
543
+ || (attemptToken && String(reservation?.attempt_token || '') !== String(attemptToken))) {
544
+ throw contextError(
545
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
546
+ 'a tentativa de cleanup não possui mais a reserva CAS esperada',
547
+ );
548
+ }
549
+ reservations[key] = {
550
+ ...reservation,
551
+ phase: requestedPhase,
552
+ updated_at: String(now),
553
+ };
554
+ registry.cleanup_reservations = reservations;
555
+ updated = true;
556
+ return structuredClone(reservations[key]);
557
+ }, { cleanupOperationId: operation });
558
+ return updated;
559
+ }
560
+
561
+ export function markActiveContextCleanupTerminal(vaultBase, {
562
+ operationId,
563
+ projectId,
564
+ repositoryId,
565
+ worktreeId,
566
+ workSessionId = '',
567
+ changeSlug = '',
568
+ targetContextIds = [],
569
+ targetChangeSlugs = [],
570
+ targetContextSnapshot = [],
571
+ worktreePath = '',
572
+ subjectHash = '',
573
+ actorContextId = '',
574
+ mode = '',
575
+ authority = '',
576
+ head = '',
577
+ slug = '',
578
+ pullRequestNumber = '',
579
+ pullRequestRepository = '',
580
+ headRefOid = '',
581
+ mergeCommitOid = '',
582
+ attemptToken = '',
583
+ allowMissingReservation = false,
584
+ now = new Date().toISOString(),
585
+ } = {}) {
586
+ const operation = requiredId(operationId, 'cleanup operation_id');
587
+ const project = requiredId(projectId, 'project_id');
588
+ const repository = requiredId(repositoryId, 'repository_id');
589
+ const worktree = requiredId(worktreeId, 'worktree_id');
590
+ const subject = String(subjectHash || '').trim();
591
+ if (!subject) {
592
+ throw contextError('WENDKEEP_ACTIVE_CONTEXT_CLEANUP_SUBJECT_INVALID', 'cleanup subject_hash inválido ou ausente');
593
+ }
594
+ const requestedSubject = cleanupSubjectFromOptions({
595
+ projectId, repositoryId, worktreeId, workSessionId, changeSlug,
596
+ targetContextIds, targetChangeSlugs, targetContextSnapshot, actorContextId,
597
+ worktreePath, mode, authority, head, slug,
598
+ pullRequestNumber, pullRequestRepository, headRefOid, mergeCommitOid,
599
+ });
600
+ return mutateSessionRegistry(vaultBase, (registry) => {
601
+ const existing = cleanupTombstoneForWorktree(registry, worktree, repository)
602
+ || cleanupTombstoneForWorktree(registry, worktree);
603
+ if (existing && String(existing.operation_id || '') !== operation) {
604
+ throw contextError(
605
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
606
+ 'o tombstone terminal pertence a outra operação',
607
+ );
608
+ }
609
+ const reservationKey = `${repository}:${worktree}`;
610
+ const reservation = registry.cleanup_reservations?.[reservationKey]
611
+ || cleanupReservationForWorktree(registry, worktree);
612
+ if (!reservation && !allowMissingReservation && !existing) {
613
+ throw contextError(
614
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
615
+ 'a reserva owner da operação não está mais disponível',
616
+ );
617
+ }
618
+ const reservationSubject = reservation && cleanupSubjectFromOptions({
619
+ projectId: reservation.project_id,
620
+ repositoryId: reservation.repository_id,
621
+ worktreeId: reservation.worktree_id,
622
+ workSessionId: reservation.work_session_id,
623
+ changeSlug: reservation.change_slug,
624
+ targetContextIds: reservation.target_context_ids,
625
+ targetChangeSlugs: reservation.target_change_slugs,
626
+ targetContextSnapshot: reservation.target_context_snapshot,
627
+ actorContextId: reservation.actor_context_id,
628
+ worktreePath: reservation.worktree_path,
629
+ mode: reservation.mode,
630
+ authority: reservation.authority,
631
+ head: reservation.head,
632
+ slug: reservation.slug,
633
+ pullRequestNumber: reservation.pull_request_number,
634
+ pullRequestRepository: reservation.pull_request_repository,
635
+ headRefOid: reservation.head_ref_oid,
636
+ mergeCommitOid: reservation.merge_commit_oid,
637
+ });
638
+ if (reservation) {
639
+ const sameOperation = String(reservation.operation_id || '') === operation;
640
+ const sameOwner = String(reservation.attempt_token || '') === String(attemptToken || '');
641
+ if (!sameOperation || (reservation.attempt_token && !sameOwner)) {
642
+ throw contextError(
643
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_BUSY',
644
+ 'a reserva owner da operação mudou antes do tombstone terminal',
645
+ );
646
+ }
647
+ if (!sameCleanupSubject(reservationSubject, requestedSubject)) {
648
+ throw contextError(
649
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CAS_CONFLICT',
650
+ 'o subject reservado mudou antes do tombstone terminal',
651
+ );
652
+ }
653
+ }
654
+ let adoptTombstoneAttempt = false;
655
+ if (existing) {
656
+ const existingSubject = cleanupSubjectFromOptions({
657
+ projectId: existing.project_id,
658
+ repositoryId: existing.repository_id,
659
+ worktreeId: existing.worktree_id,
660
+ workSessionId: existing.work_session_id,
661
+ changeSlug: existing.change_slug,
662
+ targetContextIds: existing.target_context_ids,
663
+ targetChangeSlugs: existing.target_change_slugs,
664
+ targetContextSnapshot: existing.target_context_snapshot,
665
+ actorContextId: existing.actor_context_id,
666
+ worktreePath: existing.worktree_path,
667
+ mode: existing.mode,
668
+ authority: existing.authority,
669
+ head: existing.head,
670
+ slug: existing.slug,
671
+ pullRequestNumber: existing.pull_request_number,
672
+ pullRequestRepository: existing.pull_request_repository,
673
+ headRefOid: existing.head_ref_oid,
674
+ mergeCommitOid: existing.merge_commit_oid,
675
+ });
676
+ const sameSubject = String(existing.subject_hash || '') === subject
677
+ && sameCleanupSubject(existingSubject, requestedSubject);
678
+ const sameOwner = !existing.attempt_token
679
+ || String(existing.attempt_token) === String(attemptToken || '');
680
+ const canAdopt = Boolean(
681
+ reservation
682
+ && String(reservation.operation_id || '') === operation
683
+ && String(reservation.attempt_token || '') === String(attemptToken || '')
684
+ && sameSubject
685
+ && sameCleanupSubject(existingSubject, reservationSubject),
686
+ );
687
+ adoptTombstoneAttempt = canAdopt && !sameOwner;
688
+ if (!sameSubject || (!sameOwner && !adoptTombstoneAttempt)) {
689
+ throw contextError(
690
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL_CONFLICT',
691
+ 'o tombstone terminal não corresponde ao owner/subject reservado',
692
+ );
693
+ }
694
+ }
695
+ const active = Object.values(contextsOf(registry)).filter((context) => (
696
+ context?.state === 'active' && String(context?.worktree_id || '') === worktree
697
+ ));
698
+ if (active.length) {
699
+ throw contextError(
700
+ 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH',
701
+ 'não é seguro criar tombstone enquanto há active context',
702
+ );
703
+ }
704
+ registry.cleanup_tombstones = {
705
+ ...(registry.cleanup_tombstones || {}),
706
+ [`${repository}:${worktree}`]: {
707
+ state: 'cleaned',
708
+ operation_id: operation,
709
+ project_id: project,
710
+ repository_id: repository,
711
+ worktree_id: worktree,
712
+ work_session_id: optionalText(workSessionId, 160),
713
+ change_slug: optionalText(changeSlug, 160),
714
+ target_context_ids: subjectIds(targetContextIds),
715
+ target_change_slugs: subjectIds(targetChangeSlugs),
716
+ target_context_snapshot: subjectSnapshot(targetContextSnapshot),
717
+ worktree_path: optionalText(worktreePath, 1024),
718
+ subject_hash: optionalText(subject, 240),
719
+ actor_context_id: optionalText(actorContextId, 160),
720
+ mode: optionalText(mode, 32),
721
+ authority: optionalText(authority, 240),
722
+ head: optionalText(head, 80),
723
+ slug: optionalText(slug, 160),
724
+ pull_request_number: optionalText(pullRequestNumber, 80),
725
+ pull_request_repository: optionalText(pullRequestRepository, 240),
726
+ head_ref_oid: optionalText(headRefOid, 160),
727
+ merge_commit_oid: optionalText(mergeCommitOid, 160),
728
+ attempt_token: optionalText(attemptToken, 160),
729
+ updated_at: String(now),
730
+ },
731
+ };
732
+ return structuredClone(registry.cleanup_tombstones[`${repository}:${worktree}`]);
733
+ }, { cleanupOperationId: operation });
734
+ }
735
+
208
736
  export function setActiveContextChange(vaultBase, identity, slug, options = {}) {
209
737
  const normalizedSlug = String(slug || '').trim();
210
738
  if (!SLUG_PATTERN.test(normalizedSlug)) {