wendkeep 0.78.0 → 0.79.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.
@@ -1,32 +1,239 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import {
4
- appendFileSync,
5
4
  existsSync,
6
- mkdirSync,
7
5
  readFileSync,
8
6
  readdirSync,
9
7
  } from 'node:fs';
10
- import { dirname, join, resolve } from 'node:path';
8
+ import { join, resolve } from 'node:path';
11
9
 
12
10
  import {
13
11
  discoverWorktreeRepository,
14
12
  mutateWorktreeRegistry,
15
13
  readWorktreeRegistry,
16
- withWorktreeRegistryLock,
17
14
  } from '../packages/vault/src/worktree-metadata.mjs';
18
- import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
19
- import { mutateActiveContext } from '../hooks/active-context-store.mjs';
15
+ import {
16
+ cleanupReservationForWorktree,
17
+ comparableCleanupPath,
18
+ readSessionRegistry,
19
+ } from '../hooks/obsidian-common.mjs';
20
+ import {
21
+ markActiveContextCleanupTerminal,
22
+ mutateActiveContext,
23
+ releaseActiveContextCleanup,
24
+ reserveActiveContextCleanup,
25
+ updateActiveContextCleanupPhase,
26
+ } from '../hooks/active-context-store.mjs';
27
+ import {
28
+ appendReceipt as appendLedgerReceipt,
29
+ createFileReceiptStore,
30
+ readReceiptLedger,
31
+ } from './receipt-ledger.mjs';
32
+ import {
33
+ classifyReceipt,
34
+ evaluateProvenanceGate,
35
+ } from './provenance-gate.mjs';
36
+
37
+ const RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v2.jsonl';
38
+ const LEGACY_RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v1.jsonl';
39
+ const activeCleanupOperations = new Set();
20
40
 
21
- const RECEIPT_REL = 'wendkeep/worktree-cleanup-receipts-v1.jsonl';
41
+ function cleanupOperationKey(repository, slug) {
42
+ return `${String(repository?.commonDir || '')}\u0000${String(slug || '')}`;
43
+ }
44
+
45
+ function processIsAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch (error) {
50
+ return error?.code === 'EPERM';
51
+ }
52
+ }
53
+
54
+ function cleanupReservationIsActive(repository, slug, entry) {
55
+ const key = cleanupOperationKey(repository, slug);
56
+ if (activeCleanupOperations.has(key)) return true;
57
+ // A failed entry is explicitly retryable. The shared SESSION_REGISTRY
58
+ // reservation performs the owner-token CAS; do not re-open the worktree
59
+ // registry lock here (reserve() already holds it).
60
+ if (String(entry?.cleanup?.state || '') === 'failed') return false;
61
+ const pathExists = Boolean(entry?.path && existsSync(entry.path));
62
+ const ownerPid = Number(entry?.cleanup?.ownerPid);
63
+ if (!Number.isSafeInteger(ownerPid) || ownerPid < 1) return pathExists;
64
+ return processIsAlive(ownerPid);
65
+ }
66
+
67
+ function orphanedReservationOperationId(report, identity, {
68
+ mode, authority, head, slug, resumed,
69
+ } = {}) {
70
+ if (resumed) return '';
71
+ const marker = cleanupReservationForWorktree(
72
+ readSessionRegistry(report.registry.vaultPath),
73
+ identity.worktree_id,
74
+ identity.repository_id,
75
+ );
76
+ if (!marker) return '';
77
+ const ownerPid = Number(marker.owner_pid);
78
+ if (Number.isSafeInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) return '';
79
+ const sortedIds = (value) => [...new Set(value || [])].map(String).sort();
80
+ const snapshotsEqual = JSON.stringify(
81
+ (Array.isArray(marker.target_context_snapshot) ? marker.target_context_snapshot : [])
82
+ .slice().sort((left, right) => String(left?.key || '').localeCompare(String(right?.key || ''))),
83
+ ) === JSON.stringify(
84
+ (Array.isArray(identity.target_context_snapshot) ? identity.target_context_snapshot : [])
85
+ .slice().sort((left, right) => String(left?.key || '').localeCompare(String(right?.key || ''))),
86
+ );
87
+ const sameSubject = marker.mode === mode
88
+ && marker.authority === authority
89
+ && marker.head === head
90
+ && marker.slug === slug
91
+ && marker.pull_request_number === identity.pull_request_number
92
+ && marker.pull_request_repository === identity.pull_request_repository
93
+ && marker.head_ref_oid === identity.head_ref_oid
94
+ && marker.merge_commit_oid === identity.merge_commit_oid
95
+ && marker.project_id === identity.project_id
96
+ && marker.repository_id === identity.repository_id
97
+ && marker.worktree_id === identity.worktree_id
98
+ && marker.work_session_id === identity.work_session_id
99
+ && marker.change_slug === identity.change_slug
100
+ && marker.actor_context_id === identity.actor_context_id
101
+ && JSON.stringify(sortedIds(marker.target_context_ids))
102
+ === JSON.stringify(sortedIds(identity.target_context_ids))
103
+ && JSON.stringify(sortedIds(marker.target_change_slugs))
104
+ === JSON.stringify(sortedIds(identity.target_change_slugs))
105
+ && snapshotsEqual
106
+ && comparableCleanupPath(marker.worktree_path) === comparableCleanupPath(identity.worktree_path);
107
+ return sameSubject ? String(marker.operation_id || '') : '';
108
+ }
109
+
110
+ function releaseCleanupOperation(repository, slug) {
111
+ activeCleanupOperations.delete(cleanupOperationKey(repository, slug));
112
+ }
113
+
114
+ function sanitizeDiagnosticText(value) {
115
+ let text = String(value || '');
116
+ text = text
117
+ .replace(/\b(?:ghp|github_pat|npm_|sk-|xox[baprs]-)[A-Za-z0-9_\-]+/gi, '[redacted-token]')
118
+ .replace(/\b(?:token|authorization|bearer|password|secret|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '[redacted-token]')
119
+ .replace(/\bBearer\s+[^\s,;]+/gi, '[redacted-token]')
120
+ .replace(/[A-Za-z]:\\[^\r\n"'`;|&]+/g, '[redacted-path]')
121
+ .replace(/(?:^|\s)\/[^\s"'`;|&]+/g, ' [redacted-path]')
122
+ .replace(/[\r\n\t]+/g, ' ')
123
+ .replace(/[;&|`$<>(){}[\]]/g, ' ')
124
+ .replace(/\s{2,}/g, ' ')
125
+ .trim();
126
+ return text.slice(0, 240);
127
+ }
128
+
129
+ function sanitizeDiagnosticValue(value) {
130
+ if (typeof value === 'string') return sanitizeDiagnosticText(value);
131
+ if (Array.isArray(value)) return value.map((item) => sanitizeDiagnosticValue(item));
132
+ if (value && typeof value === 'object') {
133
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
134
+ key, sanitizeDiagnosticValue(item),
135
+ ]));
136
+ }
137
+ return value;
138
+ }
139
+
140
+ function recoverySegment(value, fallback) {
141
+ const segment = sanitizeDiagnosticText(value)
142
+ .replace(/[^\p{L}\p{N}._-]+/gu, '-')
143
+ .replace(/-{2,}/g, '-')
144
+ .replace(/^-+|-+$/g, '')
145
+ .slice(0, 80);
146
+ return segment || fallback;
147
+ }
22
148
 
23
149
  function cleanupError(code, message, details = {}) {
24
- const error = new Error(message);
150
+ const error = new Error(sanitizeDiagnosticText(message));
151
+ Object.assign(error, sanitizeDiagnosticValue(details));
25
152
  error.code = code;
26
- Object.assign(error, details);
27
153
  return error;
28
154
  }
29
155
 
156
+ function sha256(value) {
157
+ return createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
158
+ }
159
+
160
+ function canonicalPrAuthority(proof) {
161
+ const repository = githubRepository(proof?.url || proof?.repository || '');
162
+ const number = Number(proof?.number);
163
+ if (!repository || !Number.isSafeInteger(number) || number < 1) {
164
+ throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR não possui autoridade canônica comprovada.');
165
+ }
166
+ return `${repository}#${number}`;
167
+ }
168
+
169
+ function canonicalPrSnapshot(proof) {
170
+ if (!proof) return null;
171
+ return {
172
+ repository: githubRepository(proof.url || proof.repository || ''),
173
+ number: Number(proof.number),
174
+ head_ref_name: String(proof.headRefName || ''),
175
+ head_ref_oid: String(proof.headRefOid || ''),
176
+ merge_commit_oid: String(proof.mergeCommitOid || ''),
177
+ base_ref_name: String(proof.baseRefName || ''),
178
+ merge_mode: String(proof.mergeMode || 'github'),
179
+ };
180
+ }
181
+
182
+ function normalizedReason(value) {
183
+ const label = recoverySegment(value, 'motivo-nao-informado');
184
+ const digest = sha256(label);
185
+ return { label, digest, authority: `reason:${digest}` };
186
+ }
187
+
188
+ function invokeFault(faultInjection, phase, context = {}) {
189
+ const snake = phase.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
190
+ const callback = typeof faultInjection === 'function'
191
+ ? faultInjection
192
+ : faultInjection?.[phase] || faultInjection?.[snake];
193
+ if (typeof callback !== 'function') return;
194
+ const result = callback({ phase, ...context });
195
+ if (result instanceof Error) throw result;
196
+ }
197
+
198
+ function storedPullRequestProof(entry) {
199
+ const proof = entry?.pullRequest;
200
+ if (!proof || typeof proof !== 'object') return null;
201
+ const complete = Boolean(
202
+ proof.number
203
+ && proof.url
204
+ && proof.mergedAt
205
+ && proof.headRefName
206
+ && proof.headRefOid
207
+ && proof.mergeCommitOid
208
+ && proof.baseRefName,
209
+ );
210
+ if (!complete) return null;
211
+ if (String(proof.headRefName) !== String(entry.branch)) {
212
+ throw cleanupError(
213
+ 'WENDKEEP_WORKTREE_PR_MISMATCH',
214
+ 'A prova reservada do PR não corresponde à branch da worktree.',
215
+ );
216
+ }
217
+ return {
218
+ ...proof,
219
+ repository: githubRepository(proof.url || proof.repository || ''),
220
+ authority: canonicalPrAuthority(proof),
221
+ };
222
+ }
223
+
224
+ function isResumableCleanup(entry, mode) {
225
+ return ['cleaning', 'failed'].includes(String(entry?.cleanup?.state || ''))
226
+ && entry?.cleanup?.mode === mode
227
+ && Boolean(String(entry?.cleanup?.operationId || '').trim());
228
+ }
229
+
230
+ function actorContextId(value) {
231
+ if (value && typeof value === 'object') {
232
+ return String(value.id || value.context_id || value.contextId || value.key || '').trim();
233
+ }
234
+ return String(value || '').trim();
235
+ }
236
+
30
237
  function git(cwd, args, { ok = true, spawn = spawnSync } = {}) {
31
238
  const result = spawn('git', args, {
32
239
  cwd,
@@ -36,7 +243,7 @@ function git(cwd, args, { ok = true, spawn = spawnSync } = {}) {
36
243
  if (ok && result.status !== 0) {
37
244
  throw cleanupError(
38
245
  'WENDKEEP_WORKTREE_GIT_FAILED',
39
- String(result.stderr || result.error?.message || `git ${args[0]} falhou`).trim(),
246
+ sanitizeDiagnosticText(result.stderr || result.error?.message || `git ${args[0]} falhou`),
40
247
  { gitArgs: [...args], status: result.status },
41
248
  );
42
249
  }
@@ -90,7 +297,7 @@ async function defaultGithub({ cwd, pullRequest }) {
90
297
  if (result.status !== 0) {
91
298
  throw cleanupError(
92
299
  'WENDKEEP_WORKTREE_PR_UNAVAILABLE',
93
- String(result.stderr || result.error?.message || 'GitHub indisponível.').trim(),
300
+ sanitizeDiagnosticText(result.stderr || result.error?.message || 'GitHub indisponível.'),
94
301
  );
95
302
  }
96
303
  const value = JSON.parse(result.stdout || '{}');
@@ -138,6 +345,22 @@ export async function verifyMergedPullRequest({
138
345
  if (value?.isCrossRepository === true || value?.headRefName !== entry.branch) {
139
346
  throw cleanupError('WENDKEEP_WORKTREE_PR_MISMATCH', 'PR não corresponde à branch da worktree.');
140
347
  }
348
+ const managedHeadResult = git(startDir, [
349
+ 'rev-parse', '--verify', `refs/heads/${entry.branch}`,
350
+ ], { ok: false, spawn });
351
+ const managedHead = managedHeadResult.status === 0
352
+ ? String(managedHeadResult.stdout || '').trim() : '';
353
+ if (!mergeCommitOid || !managedHead || String(value?.headRefOid || '').trim() !== managedHead) {
354
+ throw cleanupError(
355
+ 'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
356
+ 'PR não corresponde ao commit head comprovado da worktree.',
357
+ {
358
+ managedHead,
359
+ receivedHead: String(value?.headRefOid || '').trim(),
360
+ branch: String(entry.branch || ''),
361
+ },
362
+ );
363
+ }
141
364
  const baseRefName = String(value?.baseRefName || entry.base || '').trim();
142
365
  if (!baseRefName || git(startDir, [
143
366
  'merge-base', '--is-ancestor', mergeCommitOid, baseRefName,
@@ -150,6 +373,8 @@ export async function verifyMergedPullRequest({
150
373
  return {
151
374
  number: Number(value.number || normalized.number),
152
375
  url: String(value.url || normalized.reference),
376
+ repository: returnedRepository,
377
+ authority: `${returnedRepository}#${Number(value.number || normalized.number)}`,
153
378
  state: 'MERGED',
154
379
  mergedAt: String(value.mergedAt),
155
380
  headRefName: String(value.headRefName),
@@ -179,6 +404,22 @@ function contextsForWorktree(registry, entry) {
179
404
  .filter(([, context]) => context?.state === 'active' && context?.worktree_id === entry.worktreeId);
180
405
  }
181
406
 
407
+ function contextCausalSnapshot(entries = []) {
408
+ return entries.map(([key, context]) => ({
409
+ key: String(key),
410
+ project_id: String(context?.project_id || ''),
411
+ repository_id: String(context?.repository_id || ''),
412
+ worktree_id: String(context?.worktree_id || ''),
413
+ work_session_id: String(context?.work_session_id || ''),
414
+ change_slug: String(context?.change_slug || ''),
415
+ branch: String(context?.branch || ''),
416
+ head_sha: String(context?.head_sha || ''),
417
+ delivery_id: String(context?.delivery_id || ''),
418
+ state: String(context?.state || ''),
419
+ revision: Number(context?.revision || 0),
420
+ })).sort((left, right) => left.key.localeCompare(right.key));
421
+ }
422
+
182
423
  export function inspectWorktreeCleanup({
183
424
  startDir = process.cwd(), slug, spawn = spawnSync,
184
425
  } = {}) {
@@ -197,14 +438,26 @@ export function inspectWorktreeCleanup({
197
438
  if (status.status !== 0 || String(status.stdout || '').trim()) {
198
439
  blockers.push({
199
440
  code: 'WENDKEEP_WORKTREE_DIRTY',
200
- recovery: `limpe o checkout e rode wendkeep worktree finish ${slug} novamente`,
441
+ recovery: `limpe o checkout e rode wendkeep worktree finish ${recoverySegment(slug, 'worktree')} novamente`,
201
442
  });
202
443
  }
203
444
  }
204
445
  const sessionRegistry = readSessionRegistry(registry.vaultPath);
205
- const contexts = contextsForWorktree(sessionRegistry, entry);
206
- const workSessions = new Set(contexts.map(([, context]) => String(context.work_session_id || '')));
207
- const activeSessions = Object.entries(sessionRegistry.sessions || {}).filter(([, session]) => (
446
+ const matchingContexts = contextsForWorktree(sessionRegistry, entry);
447
+ const foreignContexts = matchingContexts.filter(([, context]) => (
448
+ context?.project_id !== registry.projectId
449
+ || context?.repository_id !== registry.repositoryId
450
+ ));
451
+ const foreignKeys = new Set(foreignContexts.map(([key]) => key));
452
+ const contexts = matchingContexts.filter(([key]) => !foreignKeys.has(key));
453
+ if (foreignContexts.length) {
454
+ blockers.push({
455
+ code: 'WENDKEEP_WORKTREE_CONTEXT_MISMATCH',
456
+ recovery: 'feche o contexto ativo estrangeiro antes do cleanup',
457
+ });
458
+ }
459
+ const workSessions = new Set(matchingContexts.map(([, context]) => String(context.work_session_id || '')));
460
+ const activeSessions = Object.entries(sessionRegistry.sessions || {}).filter(([, session]) => (
208
461
  session?.status === 'active'
209
462
  && (workSessions.has(String(session.work_session_id || ''))
210
463
  || (String(session.project_scope?.repoRoot || '').trim()
@@ -247,6 +500,7 @@ export function inspectWorktreeCleanup({
247
500
  registry,
248
501
  entry: structuredClone(entry),
249
502
  contexts: contexts.map(([key, context]) => ({ key, context: structuredClone(context) })),
503
+ contextSnapshot: contextCausalSnapshot(matchingContexts),
250
504
  };
251
505
  }
252
506
 
@@ -254,27 +508,407 @@ export function cleanupReceiptPath(repository) {
254
508
  return join(repository.commonDir, ...RECEIPT_REL.split('/'));
255
509
  }
256
510
 
511
+ export function cleanupReceiptLegacyPath(repository) {
512
+ return join(repository.commonDir, ...LEGACY_RECEIPT_REL.split('/'));
513
+ }
514
+
515
+ export function cleanupReceiptCheckpointPath(repository) {
516
+ return `${cleanupReceiptPath(repository)}.checkpoint.json`;
517
+ }
518
+
519
+ function receiptStore(repository) {
520
+ return createFileReceiptStore({
521
+ ledgerPath: cleanupReceiptPath(repository),
522
+ checkpointPath: cleanupReceiptCheckpointPath(repository),
523
+ legacyPath: cleanupReceiptLegacyPath(repository),
524
+ });
525
+ }
526
+
527
+ function cleanupReceiptFromRecord(record) {
528
+ const claims = record?.claims || {};
529
+ const observations = record?.observations || {};
530
+ return {
531
+ schema_version: record.schema_version,
532
+ schemaVersion: record.schema_version,
533
+ id: record.receipt_id,
534
+ receipt_id: record.receipt_id,
535
+ receipt_hash: record.receipt_hash,
536
+ sequence: record.sequence,
537
+ previous_hash: record.previous_hash,
538
+ kind: record.kind,
539
+ repository_id: record.subject?.repository_id,
540
+ project_id: record.subject?.project_id,
541
+ worktree_id: record.subject?.worktree_id,
542
+ work_session_id: record.subject?.work_session_id,
543
+ change_slug: record.subject?.change_slug,
544
+ target_context_ids: record.subject?.target_context_ids || [],
545
+ target_change_slugs: record.subject?.target_change_slugs || [],
546
+ target_context_snapshot: record.subject?.target_context_snapshot || [],
547
+ actor_context_id: record.subject?.actor_context_id || '',
548
+ slug: record.subject?.slug,
549
+ mode: record.subject?.mode,
550
+ authority: record.subject?.authority,
551
+ pull_request_number: record.subject?.pull_request_number || '',
552
+ pull_request_repository: record.subject?.pull_request_repository || '',
553
+ head_ref_oid: record.subject?.head_ref_oid || '',
554
+ merge_commit_oid: record.subject?.merge_commit_oid || '',
555
+ worktree_path: record.subject?.worktree_path || '',
556
+ phase: record.subject?.phase || 'finalized',
557
+ operationId: observations.operation_id,
558
+ reservationId: observations.reservation_id || observations.operation_id,
559
+ observations,
560
+ finished_at: record.recorded_at,
561
+ ...claims,
562
+ head_sha: claims.head || record.subject?.head || '',
563
+ };
564
+ }
565
+
257
566
  function readReceipts(repository) {
258
- const path = cleanupReceiptPath(repository);
259
- if (!existsSync(path)) return [];
260
- return readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
567
+ const ledger = readReceiptLedger({ store: receiptStore(repository) });
568
+ if (existsSync(cleanupReceiptPath(repository))
569
+ && !existsSync(cleanupReceiptCheckpointPath(repository))) {
570
+ throw cleanupError(
571
+ 'WENDKEEP_RECEIPT_LEDGER_TRUNCATED',
572
+ 'O checkpoint do ledger de cleanup está ausente; a cauda não é comprovável.',
573
+ );
574
+ }
575
+ return ledger.records
576
+ .filter((record) => record.kind === 'worktree-cleanup')
577
+ .map(cleanupReceiptFromRecord);
261
578
  }
262
579
 
263
- function receiptId(repositoryId, slug, mode, authority) {
264
- return createHash('sha256')
265
- .update(`${repositoryId}\n${slug}\n${mode}\n${authority}\n`)
266
- .digest('hex').slice(0, 32);
580
+ function cleanupReceiptDraft(receipt) {
581
+ return {
582
+ kind: 'worktree-cleanup',
583
+ subject: {
584
+ project_id: String(receipt.project_id || ''),
585
+ repository_id: String(receipt.repository_id || ''),
586
+ worktree_id: String(receipt.worktree_id || ''),
587
+ work_session_id: String(receipt.work_session_id || ''),
588
+ change_slug: String(receipt.change_slug || ''),
589
+ target_context_ids: [...new Set(receipt.target_context_ids || [])].map(String).sort(),
590
+ target_change_slugs: [...new Set(receipt.target_change_slugs || [])].map(String).sort(),
591
+ target_context_snapshot: structuredClone(receipt.target_context_snapshot || []),
592
+ actor_context_id: String(receipt.actor_context_id || ''),
593
+ slug: String(receipt.slug || ''),
594
+ mode: String(receipt.mode || ''),
595
+ authority: String(receipt.authority || ''),
596
+ head: String(receipt.head || ''),
597
+ pull_request_number: String(receipt.pull_request_number || ''),
598
+ pull_request_repository: String(receipt.pull_request_repository || ''),
599
+ head_ref_oid: String(receipt.head_ref_oid || ''),
600
+ merge_commit_oid: String(receipt.merge_commit_oid || ''),
601
+ worktree_path: String(receipt.worktree_path || ''),
602
+ phase: String(receipt.phase || 'finalized'),
603
+ },
604
+ claims: {
605
+ outcome: String(receipt.outcome || ''),
606
+ branch: String(receipt.branch || ''),
607
+ head: String(receipt.head || ''),
608
+ ...(receipt.pull_request ? { pull_request: canonicalPrSnapshot(receipt.pull_request) } : {}),
609
+ ...(receipt.reason ? { reason: recoverySegment(receipt.reason, 'motivo-nao-informado') } : {}),
610
+ ...(receipt.reason_digest ? { reason_digest: String(receipt.reason_digest) } : {}),
611
+ local_branch_deleted: Boolean(receipt.local_branch_deleted),
612
+ remote_branch_deleted: Boolean(receipt.remote_branch_deleted),
613
+ },
614
+ observations: {
615
+ status: 'verified',
616
+ project_id: String(receipt.project_id || ''),
617
+ repository_id: String(receipt.repository_id || ''),
618
+ worktree_id: String(receipt.worktree_id || ''),
619
+ work_session_id: String(receipt.work_session_id || ''),
620
+ change_slug: String(receipt.change_slug || ''),
621
+ target_context_ids: [...new Set(receipt.target_context_ids || [])].map(String).sort(),
622
+ target_change_slugs: [...new Set(receipt.target_change_slugs || [])].map(String).sort(),
623
+ target_context_snapshot: structuredClone(receipt.target_context_snapshot || []),
624
+ actor_context_id: String(receipt.actor_context_id || ''),
625
+ branch: String(receipt.branch || ''),
626
+ head_sha: String(receipt.head || ''),
627
+ authority: String(receipt.authority || ''),
628
+ pull_request_number: String(receipt.pull_request_number || ''),
629
+ pull_request_repository: String(receipt.pull_request_repository || ''),
630
+ head_ref_oid: String(receipt.head_ref_oid || ''),
631
+ merge_commit_oid: String(receipt.merge_commit_oid || ''),
632
+ worktree_path: String(receipt.worktree_path || ''),
633
+ phase: String(receipt.phase || 'finalized'),
634
+ operation_id: String(receipt.operationId || ''),
635
+ reservation_id: String(receipt.reservationId || receipt.operationId || ''),
636
+ },
637
+ recorded_at: String(receipt.finished_at || new Date().toISOString()),
638
+ };
267
639
  }
268
640
 
269
641
  function appendReceipt(repository, receipt) {
270
- const path = cleanupReceiptPath(repository);
271
- return withWorktreeRegistryLock(path, () => {
272
- const existing = readReceipts(repository).find((item) => item.id === receipt.id);
273
- if (existing) return existing;
274
- mkdirSync(dirname(path), { recursive: true });
275
- appendFileSync(path, `${JSON.stringify(receipt)}\n`, { encoding: 'utf8', flag: 'a' });
276
- return receipt;
642
+ const result = appendLedgerReceipt({
643
+ store: receiptStore(repository),
644
+ draft: cleanupReceiptDraft(receipt),
645
+ });
646
+ return cleanupReceiptFromRecord(result.record);
647
+ }
648
+
649
+ function receiptForOperation(repository, operationId, { slug, mode, authority } = {}) {
650
+ if (!operationId) return null;
651
+ const receipt = readReceipts(repository).find((item) => (
652
+ item.operationId === operationId
653
+ && item.slug === slug
654
+ && item.mode === mode
655
+ && item.authority === authority
656
+ ));
657
+ return receipt || null;
658
+ }
659
+
660
+ function cleanupIdentity(report, {
661
+ authority = '', proof = null, actorContext = '', prior = null,
662
+ } = {}) {
663
+ const contexts = [...(report.contexts || [])]
664
+ .sort((left, right) => String(left.key).localeCompare(String(right.key)));
665
+ const context = contexts[0]?.context;
666
+ const targetContextIds = contexts.map(({ key, context: item }) => (
667
+ String(item?.context_id || item?.contextId || key)
668
+ )).filter(Boolean).sort();
669
+ const targetChangeSlugs = [...new Set(contexts
670
+ .map(({ context: item }) => String(item?.change_slug || '').trim())
671
+ .filter(Boolean))].sort();
672
+ const targetContextSnapshot = reportContextSnapshot(report);
673
+ const snapshot = canonicalPrSnapshot(proof);
674
+ const hasPrior = prior && typeof prior === 'object';
675
+ const priorHas = (field) => hasPrior && Object.hasOwn(prior, field);
676
+ return {
677
+ project_id: report.registry.projectId,
678
+ repository_id: report.registry.repositoryId,
679
+ worktree_id: report.entry.worktreeId,
680
+ work_session_id: priorHas('workSessionId')
681
+ ? String(prior.workSessionId || '') : String(context?.work_session_id || ''),
682
+ change_slug: priorHas('changeSlug')
683
+ ? String(prior.changeSlug || '') : String(context?.change_slug || ''),
684
+ target_context_ids: priorHas('targetContextIds')
685
+ ? [...(prior.targetContextIds || [])].map(String).sort() : targetContextIds,
686
+ target_change_slugs: priorHas('targetChangeSlugs')
687
+ ? [...(prior.targetChangeSlugs || [])].map(String).sort() : targetChangeSlugs,
688
+ target_context_snapshot: priorHas('targetContextSnapshot')
689
+ ? structuredClone(prior.targetContextSnapshot || []) : targetContextSnapshot,
690
+ // The first attempt is authoritative. A retry may run under a different
691
+ // caller, but must not rewrite the causal actor bound to the receipt.
692
+ actor_context_id: priorHas('actorContextId')
693
+ ? String(prior.actorContextId || '') : actorContextId(actorContext),
694
+ worktree_path: priorHas('worktreePath')
695
+ ? String(prior.worktreePath || '') : String(report.entry.path || ''),
696
+ authority,
697
+ pull_request_number: snapshot?.number ? String(snapshot.number) : '',
698
+ pull_request_repository: snapshot?.repository || '',
699
+ head_ref_oid: snapshot?.head_ref_oid || '',
700
+ merge_commit_oid: snapshot?.merge_commit_oid || '',
701
+ };
702
+ }
703
+
704
+ function identityCausalSnapshot(identity = {}) {
705
+ return {
706
+ project_id: String(identity.project_id || ''),
707
+ repository_id: String(identity.repository_id || ''),
708
+ worktree_id: String(identity.worktree_id || ''),
709
+ work_session_id: String(identity.work_session_id || ''),
710
+ change_slug: String(identity.change_slug || ''),
711
+ target_context_ids: [...new Set(identity.target_context_ids || [])].map(String).sort(),
712
+ target_change_slugs: [...new Set(identity.target_change_slugs || [])].map(String).sort(),
713
+ target_context_snapshot: structuredClone(identity.target_context_snapshot || []),
714
+ actor_context_id: String(identity.actor_context_id || ''),
715
+ worktree_path: String(identity.worktree_path || ''),
716
+ };
717
+ }
718
+
719
+ function reportContextSnapshot(report) {
720
+ if (Array.isArray(report?.contextSnapshot)) return report.contextSnapshot;
721
+ return contextCausalSnapshot((report?.contexts || []).map(({ key, context }) => [key, context]));
722
+ }
723
+
724
+ function cleanupCausalConflict(expected, observed) {
725
+ return cleanupProvenanceError({
726
+ state: 'conflict',
727
+ reasonCodes: ['WENDKEEP_PROVENANCE_CONTEXT_MISMATCH'],
728
+ diagnostics: [{
729
+ blocker: 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH', expected, observed,
730
+ }],
731
+ repair: { command: 'reconcile active contexts, then retry cleanup' },
732
+ }, expected, observed);
733
+ }
734
+
735
+ function assertCleanupCausalSnapshot({
736
+ expectedReport, observedReport, expectedIdentity, observedIdentity,
737
+ } = {}) {
738
+ const expected = {
739
+ contexts: reportContextSnapshot(expectedReport),
740
+ identity: identityCausalSnapshot(expectedIdentity),
741
+ worktree_path: String(expectedReport?.entry?.path || ''),
742
+ };
743
+ const observed = {
744
+ contexts: reportContextSnapshot(observedReport),
745
+ identity: identityCausalSnapshot(observedIdentity),
746
+ worktree_path: String(observedReport?.entry?.path || ''),
747
+ };
748
+ if (JSON.stringify(expected) !== JSON.stringify(observed)) {
749
+ throw cleanupCausalConflict(expected, observed);
750
+ }
751
+ }
752
+
753
+ function revalidateCleanupBeforeMutation({
754
+ repository,
755
+ slug,
756
+ baselineReport,
757
+ baselineIdentity,
758
+ priorCleanup,
759
+ proof,
760
+ authority,
761
+ actorContext,
762
+ expectedHead,
763
+ spawn,
764
+ provenanceGate,
765
+ } = {}) {
766
+ const observedReport = inspectWorktreeCleanup({
767
+ startDir: repository.mainWorktree, slug, spawn,
768
+ });
769
+ if (!observedReport.ok) throw blockerError(observedReport);
770
+ const observedIdentity = cleanupIdentity(observedReport, {
771
+ authority, proof, actorContext, prior: priorCleanup,
772
+ });
773
+ assertCleanupCausalSnapshot({
774
+ expectedReport: baselineReport,
775
+ observedReport,
776
+ expectedIdentity: baselineIdentity,
777
+ observedIdentity,
778
+ });
779
+ const derivedHead = branchHead(repository, observedReport.entry.branch, spawn);
780
+ const effectiveHead = derivedHead || String(expectedHead || '');
781
+ if (!effectiveHead) {
782
+ throw cleanupError(
783
+ 'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
784
+ 'Não foi possível provar o head atual da branch antes do cleanup.',
785
+ );
786
+ }
787
+ if (derivedHead && expectedHead && derivedHead !== expectedHead) {
788
+ throw cleanupError(
789
+ 'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
790
+ 'O head atual da branch mudou antes da mutação.',
791
+ );
792
+ }
793
+ requireCleanupOperationGate({
794
+ mode: proof ? 'finish' : 'remove',
795
+ report: observedReport,
796
+ proof,
797
+ authority,
798
+ head: effectiveHead,
799
+ identity: observedIdentity,
800
+ provenanceGate,
277
801
  });
802
+ const postGateReport = inspectWorktreeCleanup({
803
+ startDir: repository.mainWorktree, slug, spawn,
804
+ });
805
+ if (!postGateReport.ok) throw blockerError(postGateReport);
806
+ const postGateIdentity = cleanupIdentity(postGateReport, {
807
+ authority, proof, actorContext, prior: priorCleanup,
808
+ });
809
+ assertCleanupCausalSnapshot({
810
+ expectedReport: observedReport,
811
+ observedReport: postGateReport,
812
+ expectedIdentity: observedIdentity,
813
+ observedIdentity: postGateIdentity,
814
+ });
815
+ return { report: postGateReport, identity: postGateIdentity, head: effectiveHead };
816
+ }
817
+
818
+ function assertPostRemovalCausalSnapshot({
819
+ beforeReport, beforeIdentity, repository, slug, proof, authority, actorContext, spawn, priorCleanup,
820
+ } = {}) {
821
+ const afterReport = inspectWorktreeCleanup({
822
+ startDir: repository.mainWorktree, slug, spawn,
823
+ });
824
+ if (!afterReport.ok) throw blockerError(afterReport);
825
+ const afterIdentity = cleanupIdentity(afterReport, {
826
+ authority, proof, actorContext, prior: priorCleanup,
827
+ });
828
+ assertCleanupCausalSnapshot({
829
+ expectedReport: beforeReport,
830
+ observedReport: afterReport,
831
+ expectedIdentity: beforeIdentity,
832
+ observedIdentity: afterIdentity,
833
+ });
834
+ return { report: afterReport, identity: afterIdentity };
835
+ }
836
+
837
+ function assertNoActiveCleanupContexts(vaultPath, entry) {
838
+ const registry = readSessionRegistry(vaultPath);
839
+ const observed = contextCausalSnapshot(contextsForWorktree(registry, entry));
840
+ if (observed.length) {
841
+ throw cleanupCausalConflict({ contexts: [] }, { contexts: observed });
842
+ }
843
+ }
844
+
845
+ function reserveContextCausally({
846
+ vaultPath,
847
+ reserveOptions,
848
+ repository,
849
+ slug,
850
+ baselineReport,
851
+ baselineIdentity,
852
+ priorCleanup,
853
+ proof,
854
+ authority,
855
+ actorContext,
856
+ spawn,
857
+ } = {}) {
858
+ try {
859
+ return reserveActiveContextCleanup(vaultPath, reserveOptions);
860
+ } catch (error) {
861
+ if (error?.code !== 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_CONTEXT_MISMATCH') throw error;
862
+ const observedReport = inspectWorktreeCleanup({
863
+ startDir: repository.mainWorktree, slug, spawn,
864
+ });
865
+ const observedIdentity = cleanupIdentity(observedReport, {
866
+ authority, proof, actorContext, prior: priorCleanup,
867
+ });
868
+ throw cleanupCausalConflict({
869
+ contexts: reportContextSnapshot(baselineReport),
870
+ identity: identityCausalSnapshot(baselineIdentity),
871
+ }, {
872
+ contexts: reportContextSnapshot(observedReport),
873
+ identity: identityCausalSnapshot(observedIdentity),
874
+ });
875
+ }
876
+ }
877
+
878
+ function recordPreReservationFailure({
879
+ repository,
880
+ slug,
881
+ mode,
882
+ authority,
883
+ proof,
884
+ reason,
885
+ reasonDigest,
886
+ identity,
887
+ head,
888
+ now,
889
+ operationId,
890
+ error,
891
+ } = {}) {
892
+ try {
893
+ const reservation = reserve(repository, slug, {
894
+ mode,
895
+ authority,
896
+ proof,
897
+ reason,
898
+ reasonDigest,
899
+ identity,
900
+ head,
901
+ now,
902
+ operationId,
903
+ });
904
+ const failedRegistry = failReservation(repository, slug, error, now);
905
+ return {
906
+ operationId: reservation.operationId,
907
+ entry: failedRegistry?.entries?.[slug],
908
+ };
909
+ } finally {
910
+ releaseCleanupOperation(repository, slug);
911
+ }
278
912
  }
279
913
 
280
914
  function blockerError(report) {
@@ -282,8 +916,32 @@ function blockerError(report) {
282
916
  return cleanupError(first.code, first.recovery, { blockers: report.blockers });
283
917
  }
284
918
 
285
- function reserve(repository, slug, { mode, authority, proof, reason, now }) {
286
- const operationId = randomUUID();
919
+ function reserve(repository, slug, {
920
+ mode, authority, proof, reason, reasonDigest, identity = {}, head = '', now,
921
+ operationId = randomUUID(), attemptToken = '',
922
+ }) {
923
+ const has = (field) => Object.hasOwn(identity, field);
924
+ const subjectMetadata = {
925
+ ...(has('project_id') ? { projectId: String(identity.project_id || '') } : {}),
926
+ ...(has('repository_id') ? { repositoryId: String(identity.repository_id || '') } : {}),
927
+ ...(has('worktree_id') ? { worktreeId: String(identity.worktree_id || '') } : {}),
928
+ ...(has('work_session_id') ? { workSessionId: String(identity.work_session_id || '') } : {}),
929
+ ...(has('change_slug') ? { changeSlug: String(identity.change_slug || '') } : {}),
930
+ ...(has('target_context_ids')
931
+ ? { targetContextIds: [...new Set(identity.target_context_ids || [])].map(String).sort() } : {}),
932
+ ...(has('target_change_slugs')
933
+ ? { targetChangeSlugs: [...new Set(identity.target_change_slugs || [])].map(String).sort() } : {}),
934
+ ...(has('target_context_snapshot')
935
+ ? { targetContextSnapshot: structuredClone(identity.target_context_snapshot || []) } : {}),
936
+ ...(has('actor_context_id') ? { actorContextId: String(identity.actor_context_id || '') } : {}),
937
+ ...(has('pull_request_number')
938
+ ? { pullRequestNumber: String(identity.pull_request_number || '') } : {}),
939
+ ...(has('pull_request_repository')
940
+ ? { pullRequestRepository: String(identity.pull_request_repository || '') } : {}),
941
+ ...(has('head_ref_oid') ? { headRefOid: String(identity.head_ref_oid || '') } : {}),
942
+ ...(has('merge_commit_oid') ? { mergeCommitOid: String(identity.merge_commit_oid || '') } : {}),
943
+ ...(has('worktree_path') ? { worktreePath: String(identity.worktree_path || '') } : {}),
944
+ };
287
945
  let previous = null;
288
946
  let resumed = false;
289
947
  mutateWorktreeRegistry(repository, (registry) => {
@@ -293,14 +951,38 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
293
951
  previous = entry;
294
952
  return registry;
295
953
  }
296
- if (entry.cleanup?.state === 'cleaning') {
297
- if (existsSync(entry.path)
954
+ if (['cleaning', 'failed'].includes(entry.cleanup?.state)) {
955
+ const storedAuthority = String(entry.cleanup.authority || '');
956
+ const compatibleLegacyAuthority = mode === 'finish'
957
+ && proof
958
+ && canonicalPrAuthority(proof) === authority
959
+ && storedAuthority === String(proof.url || '');
960
+ if ((entry.cleanup.state === 'cleaning'
961
+ && cleanupReservationIsActive(repository, slug, entry))
298
962
  || entry.cleanup.mode !== mode
299
- || entry.cleanup.authority !== authority) {
963
+ || (storedAuthority !== authority && !compatibleLegacyAuthority)) {
300
964
  throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
301
965
  }
302
966
  previous = entry;
303
967
  resumed = true;
968
+ registry.entries[slug] = {
969
+ ...entry,
970
+ state: 'cleaning',
971
+ ...(proof ? { pullRequest: proof } : {}),
972
+ cleanup: {
973
+ ...entry.cleanup,
974
+ state: 'cleaning',
975
+ ...(reason ? { reason } : {}),
976
+ ...(reasonDigest ? { reason_digest: reasonDigest } : {}),
977
+ ...(mode === 'finish' ? { authority } : {}),
978
+ ...subjectMetadata,
979
+ ...(head ? { head } : {}),
980
+ ...(attemptToken ? { attemptToken } : {}),
981
+ ownerPid: process.pid,
982
+ updatedAt: now,
983
+ },
984
+ updatedAt: now,
985
+ };
304
986
  return registry;
305
987
  }
306
988
  registry.entries[slug] = {
@@ -314,12 +996,20 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
314
996
  mode,
315
997
  authority,
316
998
  ...(reason ? { reason } : {}),
999
+ ...(reasonDigest ? { reason_digest: reasonDigest } : {}),
1000
+ ...subjectMetadata,
1001
+ ...(head ? { head } : {}),
1002
+ ...(attemptToken ? { attemptToken } : {}),
1003
+ ownerPid: process.pid,
317
1004
  startedAt: now,
318
1005
  },
319
1006
  updatedAt: now,
320
1007
  };
321
1008
  return registry;
322
1009
  });
1010
+ if (previous?.state !== 'cleaned') {
1011
+ activeCleanupOperations.add(cleanupOperationKey(repository, slug));
1012
+ }
323
1013
  return {
324
1014
  operationId: resumed ? previous.cleanup.operationId : operationId,
325
1015
  previous,
@@ -327,24 +1017,8 @@ function reserve(repository, slug, { mode, authority, proof, reason, now }) {
327
1017
  };
328
1018
  }
329
1019
 
330
- function associatePullRequest(repository, slug, proof, now) {
331
- mutateWorktreeRegistry(repository, (registry) => {
332
- const entry = registry.entries?.[slug];
333
- if (!entry) throw cleanupError('WENDKEEP_WORKTREE_NOT_FOUND', `Worktree "${slug}" ausente.`);
334
- if (entry.cleanup?.state === 'cleaning') {
335
- const authority = proof.url || `pr:${proof.number}`;
336
- if (entry.cleanup.authority !== authority) {
337
- throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
338
- }
339
- return registry;
340
- }
341
- registry.entries[slug] = { ...entry, pullRequest: proof, updatedAt: now };
342
- return registry;
343
- });
344
- }
345
-
346
1020
  function failReservation(repository, slug, error, now) {
347
- mutateWorktreeRegistry(repository, (registry) => {
1021
+ return mutateWorktreeRegistry(repository, (registry) => {
348
1022
  const entry = registry.entries?.[slug];
349
1023
  if (!entry || entry.cleanup?.state !== 'cleaning') return registry;
350
1024
  registry.entries[slug] = {
@@ -356,7 +1030,7 @@ function failReservation(repository, slug, error, now) {
356
1030
  failedAt: now,
357
1031
  error: {
358
1032
  code: String(error?.code || 'WENDKEEP_WORKTREE_CLEANUP_FAILED'),
359
- message: String(error?.message || 'Cleanup falhou.'),
1033
+ message: sanitizeDiagnosticText(error?.message || 'Cleanup falhou.'),
360
1034
  },
361
1035
  },
362
1036
  updatedAt: now,
@@ -365,7 +1039,7 @@ function failReservation(repository, slug, error, now) {
365
1039
  });
366
1040
  }
367
1041
 
368
- function closeContexts(vaultBase, contexts, now) {
1042
+ function closeContexts(vaultBase, contexts, now, cleanupOperationId = '') {
369
1043
  for (const { context } of contexts) {
370
1044
  mutateActiveContext(vaultBase, {
371
1045
  projectId: context.project_id,
@@ -378,7 +1052,7 @@ function closeContexts(vaultBase, contexts, now) {
378
1052
  ...current,
379
1053
  state: 'closed',
380
1054
  delivery_id: '',
381
- }), { expectedRevision: context.revision, now });
1055
+ }), { expectedRevision: context.revision, now, cleanupOperationId });
382
1056
  }
383
1057
  }
384
1058
 
@@ -426,9 +1100,26 @@ function deleteRemoteBranch(repository, branch, expectedHead, spawn) {
426
1100
  return true;
427
1101
  }
428
1102
 
429
- function finalize(repository, slug, { receipt, now }) {
1103
+ function finalize(repository, slug, {
1104
+ receipt, now, expectedOperationId = '', expectedAttemptToken = '', expectedSubjectHash = '',
1105
+ }) {
430
1106
  mutateWorktreeRegistry(repository, (registry) => {
431
1107
  const entry = registry.entries[slug];
1108
+ const subject = cleanupReceiptSubject(registry, entry);
1109
+ const subjectMismatches = cleanupReceiptCausalMismatches(receipt, subject);
1110
+ if (!entry
1111
+ || entry.state !== 'cleaning'
1112
+ || entry.cleanup?.state !== 'cleaning'
1113
+ || String(entry.cleanup?.operationId || '') !== String(expectedOperationId || '')
1114
+ || String(entry.cleanup?.attemptToken || '') !== String(expectedAttemptToken || '')
1115
+ || (expectedSubjectHash && String(receipt?.id || '') !== String(expectedSubjectHash))
1116
+ || subjectMismatches.length
1117
+ || !cleanupReceiptMatches(receipt, registry, entry)) {
1118
+ throw cleanupError(
1119
+ 'WENDKEEP_WORKTREE_CLEANUP_CAS_CONFLICT',
1120
+ 'O subject ou owner do cleanup mudou antes da finalização; nenhuma conclusão foi publicada.',
1121
+ );
1122
+ }
432
1123
  registry.entries[slug] = {
433
1124
  ...entry,
434
1125
  state: 'cleaned',
@@ -436,6 +1127,22 @@ function finalize(repository, slug, { receipt, now }) {
436
1127
  ...entry.cleanup,
437
1128
  state: 'completed',
438
1129
  receiptId: receipt.id,
1130
+ projectId: receipt.project_id,
1131
+ worktreeId: receipt.worktree_id,
1132
+ workSessionId: receipt.work_session_id,
1133
+ changeSlug: receipt.change_slug,
1134
+ targetContextIds: receipt.target_context_ids,
1135
+ targetChangeSlugs: receipt.target_change_slugs,
1136
+ targetContextSnapshot: structuredClone(receipt.target_context_snapshot || []),
1137
+ actorContextId: receipt.actor_context_id,
1138
+ authority: receipt.authority,
1139
+ pullRequestNumber: receipt.pull_request_number,
1140
+ pullRequestRepository: receipt.pull_request_repository,
1141
+ headRefOid: receipt.head_ref_oid,
1142
+ mergeCommitOid: receipt.merge_commit_oid,
1143
+ head: receipt.head,
1144
+ subjectHash: receipt.id,
1145
+ phase: 'finalized',
439
1146
  finishedAt: now,
440
1147
  },
441
1148
  updatedAt: now,
@@ -444,10 +1151,322 @@ function finalize(repository, slug, { receipt, now }) {
444
1151
  });
445
1152
  }
446
1153
 
447
- function existingCompletion(repository, entry) {
1154
+ function cleanupReceiptSubject(registry, entry) {
1155
+ const cleanup = entry?.cleanup || {};
1156
+ return {
1157
+ project_id: String(registry?.projectId || ''),
1158
+ repository_id: String(registry?.repositoryId || ''),
1159
+ worktree_id: String(entry?.worktreeId || ''),
1160
+ work_session_id: String(cleanup.workSessionId || entry?.workSessionId || ''),
1161
+ change_slug: String(cleanup.changeSlug || entry?.changeSlug || ''),
1162
+ target_context_ids: [...new Set(cleanup.targetContextIds || [])].map(String).sort(),
1163
+ target_change_slugs: [...new Set(cleanup.targetChangeSlugs || [])].map(String).sort(),
1164
+ target_context_snapshot: structuredClone(cleanup.targetContextSnapshot || []),
1165
+ actor_context_id: String(cleanup.actorContextId || ''),
1166
+ branch: String(entry?.branch || ''),
1167
+ head_sha: String(cleanup.head || entry?.head || ''),
1168
+ authority: String(cleanup.authority || ''),
1169
+ pull_request_number: String(cleanup.pullRequestNumber || ''),
1170
+ pull_request_repository: String(cleanup.pullRequestRepository || ''),
1171
+ head_ref_oid: String(cleanup.headRefOid || ''),
1172
+ merge_commit_oid: String(cleanup.mergeCommitOid || ''),
1173
+ worktree_path: String(entry?.path || ''),
1174
+ phase: String(cleanup.phase || 'finalized'),
1175
+ };
1176
+ }
1177
+
1178
+ function comparableContextSnapshot(value = []) {
1179
+ return (Array.isArray(value) ? value : [])
1180
+ .map((item) => Object.fromEntries(
1181
+ Object.entries(item || {}).sort(([left], [right]) => left.localeCompare(right)),
1182
+ ))
1183
+ .sort((left, right) => String(left.key || '').localeCompare(String(right.key || '')));
1184
+ }
1185
+
1186
+ function cleanupReceiptCausalMismatches(item, subject) {
1187
+ const mismatches = [];
1188
+ for (const key of [
1189
+ 'target_context_ids', 'target_change_slugs',
1190
+ ]) {
1191
+ const expected = [...new Set(subject?.[key] || [])].map(String).sort();
1192
+ const observed = [...new Set(item?.[key] || [])].map(String).sort();
1193
+ if (JSON.stringify(expected) !== JSON.stringify(observed)) mismatches.push(`${key} mismatch`);
1194
+ }
1195
+ if (JSON.stringify(comparableContextSnapshot(item?.target_context_snapshot))
1196
+ !== JSON.stringify(comparableContextSnapshot(subject?.target_context_snapshot))) {
1197
+ mismatches.push('target_context_snapshot mismatch');
1198
+ }
1199
+ for (const key of [
1200
+ 'actor_context_id', 'authority', 'pull_request_number', 'pull_request_repository',
1201
+ 'head_ref_oid', 'merge_commit_oid', 'worktree_path', 'phase',
1202
+ ]) {
1203
+ if (String(item?.[key] || '') !== String(subject?.[key] || '')) mismatches.push(`${key} mismatch`);
1204
+ }
1205
+ return mismatches;
1206
+ }
1207
+
1208
+ function cleanupReceiptMatches(item, registry, entry) {
1209
+ const cleanup = entry?.cleanup || {};
1210
+ return item.repository_id === registry?.repositoryId
1211
+ && item.project_id === registry?.projectId
1212
+ && item.worktree_id === entry.worktreeId
1213
+ && item.work_session_id === (cleanup.workSessionId || '')
1214
+ && item.change_slug === (cleanup.changeSlug || '')
1215
+ && JSON.stringify([...new Set(item.target_context_ids || [])].map(String).sort())
1216
+ === JSON.stringify([...new Set(cleanup.targetContextIds || [])].map(String).sort())
1217
+ && JSON.stringify([...new Set(item.target_change_slugs || [])].map(String).sort())
1218
+ === JSON.stringify([...new Set(cleanup.targetChangeSlugs || [])].map(String).sort())
1219
+ && JSON.stringify(comparableContextSnapshot(item.target_context_snapshot))
1220
+ === JSON.stringify(comparableContextSnapshot(cleanup.targetContextSnapshot))
1221
+ && item.actor_context_id === (cleanup.actorContextId || '')
1222
+ && item.slug === entry.slug
1223
+ && item.branch === entry.branch
1224
+ && item.mode === cleanup.mode
1225
+ && item.authority === cleanup.authority
1226
+ && item.pull_request_number === (cleanup.pullRequestNumber || '')
1227
+ && item.pull_request_repository === (cleanup.pullRequestRepository || '')
1228
+ && item.head_ref_oid === (cleanup.headRefOid || '')
1229
+ && item.merge_commit_oid === (cleanup.mergeCommitOid || '')
1230
+ && item.worktree_path === (entry.path || '')
1231
+ && item.phase === (cleanup.phase || 'finalized')
1232
+ && item.head === cleanup.head;
1233
+ }
1234
+
1235
+ function cleanupReceiptAssessment(
1236
+ item,
1237
+ registry,
1238
+ entry,
1239
+ { receiptClassifier = classifyReceipt, provenanceGate = evaluateProvenanceGate } = {},
1240
+ ) {
1241
+ const subject = cleanupReceiptSubject(registry, entry);
1242
+ const observation = {
1243
+ ...(item.observations || {}),
1244
+ status: item.observations?.status || 'reported',
1245
+ receipt_id: item.id,
1246
+ };
1247
+ const assessment = receiptClassifier({ receipt: item, observation, subject });
1248
+ const gate = provenanceGate({
1249
+ purpose: 'worktree-cleanup',
1250
+ assessments: [assessment],
1251
+ requiredKinds: ['worktree-cleanup'],
1252
+ });
1253
+ const causalMismatches = cleanupReceiptCausalMismatches(item, subject);
1254
+ if (!causalMismatches.length) return { gate, subject, observation };
1255
+ return {
1256
+ gate: {
1257
+ ...gate,
1258
+ ok: false,
1259
+ state: 'conflict',
1260
+ reasonCodes: [...new Set([...(gate.reasonCodes || []), 'PROV_RECEIPT_CONFLICT'])],
1261
+ diagnostics: [{
1262
+ kind: 'worktree-cleanup',
1263
+ state: 'conflict',
1264
+ blocker: 'PROV_RECEIPT_CONFLICT',
1265
+ expected: subject,
1266
+ observed: item,
1267
+ }],
1268
+ repair: gate.repair || { command: 'wendkeep verify --deep' },
1269
+ },
1270
+ subject,
1271
+ observation,
1272
+ };
1273
+ }
1274
+
1275
+ function cleanupOperationAssessment({ mode, report, proof, authority, head, identity = {} }) {
1276
+ const snapshot = canonicalPrSnapshot(proof);
1277
+ const subject = {
1278
+ project_id: String(report?.registry?.projectId || ''),
1279
+ repository_id: String(report?.registry?.repositoryId || ''),
1280
+ worktree_id: String(report?.entry?.worktreeId || ''),
1281
+ work_session_id: String(identity.work_session_id || ''),
1282
+ change_slug: String(identity.change_slug || ''),
1283
+ target_context_ids: [...new Set(identity.target_context_ids || [])].map(String).sort(),
1284
+ target_change_slugs: [...new Set(identity.target_change_slugs || [])].map(String).sort(),
1285
+ target_context_snapshot: structuredClone(identity.target_context_snapshot || []),
1286
+ actor_context_id: String(identity.actor_context_id || ''),
1287
+ worktree_path: String(identity.worktree_path || report?.entry?.path || ''),
1288
+ branch: String(report?.entry?.branch || ''),
1289
+ head_sha: String(head || ''),
1290
+ authority: String(authority || ''),
1291
+ pull_request_number: snapshot?.number ? String(snapshot.number) : '',
1292
+ pull_request_repository: snapshot?.repository || '',
1293
+ head_ref_oid: snapshot?.head_ref_oid || '',
1294
+ merge_commit_oid: snapshot?.merge_commit_oid || '',
1295
+ };
1296
+ const identityValid = [
1297
+ subject.project_id,
1298
+ subject.repository_id,
1299
+ subject.worktree_id,
1300
+ subject.branch,
1301
+ subject.head_sha,
1302
+ subject.authority,
1303
+ ].every(Boolean);
1304
+ const proofValid = mode === 'remove'
1305
+ ? /^reason:[a-f0-9]{64}$/.test(subject.authority)
1306
+ : Boolean(
1307
+ proof?.state === 'MERGED'
1308
+ && proof?.headRefName === subject.branch
1309
+ && proof?.headRefOid === subject.head_sha
1310
+ && proof?.mergeCommitOid
1311
+ && canonicalPrAuthority(proof) === subject.authority,
1312
+ );
1313
+ const state = identityValid && proofValid ? 'verified' : 'unproven';
1314
+ const reasonCodes = state === 'verified' ? [] : ['PROV_RECEIPT_INVALID'];
1315
+ return {
1316
+ kind: 'worktree-cleanup',
1317
+ state,
1318
+ reasonCodes,
1319
+ diagnostics: [{
1320
+ kind: 'worktree-cleanup',
1321
+ state,
1322
+ blocker: reasonCodes[0] || null,
1323
+ expected: subject,
1324
+ observed: {
1325
+ proof: canonicalPrSnapshot(proof),
1326
+ mode,
1327
+ authority: subject.authority,
1328
+ head_sha: subject.head_sha,
1329
+ work_session_id: subject.work_session_id,
1330
+ change_slug: subject.change_slug,
1331
+ target_context_ids: subject.target_context_ids,
1332
+ target_change_slugs: subject.target_change_slugs,
1333
+ actor_context_id: subject.actor_context_id,
1334
+ },
1335
+ }],
1336
+ };
1337
+ }
1338
+
1339
+ function requireCleanupOperationGate({
1340
+ mode, report, proof, authority, head, identity = {},
1341
+ provenanceGate = evaluateProvenanceGate,
1342
+ } = {}) {
1343
+ const assessment = cleanupOperationAssessment({
1344
+ mode, report, proof, authority, head, identity,
1345
+ });
1346
+ const gate = provenanceGate({
1347
+ purpose: 'worktree-cleanup',
1348
+ assessments: [assessment],
1349
+ requiredKinds: ['worktree-cleanup'],
1350
+ });
1351
+ if (gate?.ok !== true || gate?.state !== 'verified') {
1352
+ throw cleanupProvenanceError(
1353
+ gate || { state: 'unproven', reasonCodes: ['PROV_RECEIPT_INVALID'], diagnostics: [] },
1354
+ assessment.diagnostics[0].expected,
1355
+ assessment.diagnostics[0].observed,
1356
+ );
1357
+ }
1358
+ return gate;
1359
+ }
1360
+
1361
+ function requireCleanupReceiptGate(
1362
+ receipt,
1363
+ repository,
1364
+ slug,
1365
+ { receiptClassifier = classifyReceipt, provenanceGate = evaluateProvenanceGate } = {},
1366
+ ) {
1367
+ const { registry } = readWorktreeRegistry(repository);
1368
+ const entry = registry?.entries?.[slug];
1369
+ const result = cleanupReceiptAssessment(receipt, registry, entry, {
1370
+ receiptClassifier,
1371
+ provenanceGate,
1372
+ });
1373
+ if (result.gate?.ok !== true || result.gate?.state !== 'verified') {
1374
+ throw cleanupProvenanceError(result.gate, result.subject, receipt);
1375
+ }
1376
+ return result.gate;
1377
+ }
1378
+
1379
+ function cleanupProvenanceError(gate, subject, observed) {
1380
+ const diagnostic = gate?.diagnostics?.[0] || {};
1381
+ return cleanupError(
1382
+ 'WENDKEEP_PROVENANCE_GATE_BLOCKED',
1383
+ 'O receipt de cleanup não satisfaz o gate de proveniência comum.',
1384
+ {
1385
+ operation: 'worktree-cleanup',
1386
+ state: gate?.state || 'unproven',
1387
+ blocker: diagnostic.blocker || gate?.reasonCodes?.[0] || 'PROV_RECEIPT_INVALID',
1388
+ reasonCodes: gate?.reasonCodes || [],
1389
+ expected: diagnostic.expected || subject,
1390
+ observed: diagnostic.observed || observed,
1391
+ recovery: gate?.repair?.command || 'wendkeep verify --deep',
1392
+ },
1393
+ );
1394
+ }
1395
+
1396
+ function existingCompletion(repository, entry, options = {}) {
448
1397
  if (entry?.state !== 'cleaned') return null;
449
- const receipt = readReceipts(repository).find((item) => item.id === entry.cleanup?.receiptId)
450
- || readReceipts(repository).find((item) => item.slug === entry.slug);
1398
+ const { registry } = readWorktreeRegistry(repository);
1399
+ const receipts = readReceipts(repository);
1400
+ const receipt = receipts.find((item) => item.id === entry.cleanup?.receiptId);
1401
+ if (!receipt) {
1402
+ const legacy = hasValidLegacyReceiptAnchor(repository);
1403
+ const subject = cleanupReceiptSubject(registry, entry);
1404
+ throw cleanupProvenanceError({
1405
+ state: legacy ? 'legacy-unbound' : 'unproven',
1406
+ reasonCodes: [legacy ? 'PROV_RECEIPT_LEGACY' : 'PROV_RECEIPT_OBSERVATION_MISSING'],
1407
+ diagnostics: [{
1408
+ blocker: legacy ? 'PROV_RECEIPT_LEGACY' : 'PROV_RECEIPT_OBSERVATION_MISSING',
1409
+ expected: subject,
1410
+ observed: null,
1411
+ }],
1412
+ repair: { command: legacy ? 'wendkeep verify --deep' : 'revise o registry e o receipt store' },
1413
+ }, subject, null);
1414
+ }
1415
+ const { gate, subject } = cleanupReceiptAssessment(receipt, registry, entry, options);
1416
+ if (!gate.ok) throw cleanupProvenanceError(gate, subject, receipt);
1417
+ if (!cleanupReceiptMatches(receipt, registry, entry)) {
1418
+ throw cleanupProvenanceError({
1419
+ state: 'conflict',
1420
+ reasonCodes: ['PROV_RECEIPT_CONFLICT'],
1421
+ diagnostics: [{
1422
+ blocker: 'PROV_RECEIPT_CONFLICT', expected: subject, observed: receipt,
1423
+ }],
1424
+ repair: { command: 'wendkeep verify --deep' },
1425
+ }, subject, receipt);
1426
+ }
1427
+ const sessionRegistry = readSessionRegistry(registry.vaultPath);
1428
+ const cleanupReservation = cleanupReservationForWorktree(
1429
+ sessionRegistry,
1430
+ entry?.worktreeId,
1431
+ registry?.repositoryId,
1432
+ );
1433
+ if (receipt.operationId) {
1434
+ // Reconcile a crash between finalize and release. The tombstone is
1435
+ // durable, so a later writer cannot reopen the cleaned worktree while
1436
+ // this idempotent completion is being repaired.
1437
+ markActiveContextCleanupTerminal(registry.vaultPath, {
1438
+ operationId: receipt.operationId,
1439
+ projectId: receipt.project_id,
1440
+ repositoryId: receipt.repository_id,
1441
+ worktreeId: receipt.worktree_id,
1442
+ workSessionId: receipt.work_session_id,
1443
+ changeSlug: receipt.change_slug,
1444
+ targetContextIds: receipt.target_context_ids,
1445
+ targetChangeSlugs: receipt.target_change_slugs,
1446
+ targetContextSnapshot: receipt.target_context_snapshot,
1447
+ worktreePath: entry.path,
1448
+ subjectHash: receipt.id,
1449
+ actorContextId: receipt.actor_context_id,
1450
+ mode: receipt.mode,
1451
+ authority: receipt.authority,
1452
+ head: receipt.head,
1453
+ slug: receipt.slug,
1454
+ pullRequestNumber: receipt.pull_request_number,
1455
+ pullRequestRepository: receipt.pull_request_repository,
1456
+ headRefOid: receipt.head_ref_oid,
1457
+ mergeCommitOid: receipt.merge_commit_oid,
1458
+ attemptToken: entry.cleanup?.attemptToken || cleanupReservation?.attempt_token || '',
1459
+ allowMissingReservation: true,
1460
+ });
1461
+ }
1462
+ if (cleanupReservation
1463
+ && String(cleanupReservation.operation_id || '') === String(receipt.operationId || '')) {
1464
+ releaseActiveContextCleanup(registry.vaultPath, receipt.operationId, {
1465
+ repositoryId: registry.repositoryId,
1466
+ worktreeId: entry.worktreeId,
1467
+ attemptToken: cleanupReservation.attempt_token || entry.cleanup?.attemptToken || '',
1468
+ });
1469
+ }
451
1470
  return receipt ? { state: 'completed', idempotent: true, receipt } : null;
452
1471
  }
453
1472
 
@@ -459,66 +1478,327 @@ export async function finishManagedWorktree({
459
1478
  github = defaultGithub,
460
1479
  spawn = spawnSync,
461
1480
  now = () => new Date().toISOString(),
1481
+ actorContext = '',
1482
+ faultInjection = {},
1483
+ provenanceGate = evaluateProvenanceGate,
1484
+ receiptClassifier = classifyReceipt,
462
1485
  } = {}) {
463
1486
  const repository = discoverWorktreeRepository({ startDir, spawn });
464
1487
  const initial = requiredEntry(repository, slug);
465
- const completed = existingCompletion(repository, initial.entry);
466
- if (completed) return completed;
467
- const pullRequestReference = pullRequest
468
- || initial.entry.pullRequest?.number
469
- || initial.entry.pullRequest?.url;
470
- if (git(repository.mainWorktree, ['remote', 'get-url', 'origin'], {
471
- ok: false, spawn,
472
- }).status === 0) {
473
- git(repository.mainWorktree, ['fetch', '--prune', 'origin'], { spawn });
474
- }
475
- const proof = await verifyMergedPullRequest({
476
- startDir: repository.mainWorktree,
477
- entry: initial.entry,
478
- pullRequest: pullRequestReference,
479
- github,
480
- spawn,
1488
+ // Validate the append-only receipt chain before any fetch, reservation, or
1489
+ // irreversible worktree mutation. A corrupt/truncated ledger is a hard stop.
1490
+ readReceipts(repository);
1491
+ const completed = existingCompletion(repository, initial.entry, {
1492
+ provenanceGate, receiptClassifier,
481
1493
  });
1494
+ if (completed) return completed;
1495
+ if (initial.entry.cleanup?.state === 'cleaning'
1496
+ && cleanupReservationIsActive(repository, slug, initial.entry)) {
1497
+ throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', `Cleanup de "${slug}" já está em andamento.`);
1498
+ }
1499
+ const resumed = isResumableCleanup(initial.entry, 'finish');
1500
+ let proof = resumed ? storedPullRequestProof(initial.entry) : null;
1501
+ if (resumed && !proof) {
1502
+ throw cleanupError(
1503
+ 'WENDKEEP_WORKTREE_PR_UNAVAILABLE',
1504
+ 'A prova reservada do PR está incompleta; não é seguro refazer a prova após a mutação.',
1505
+ { operationId: initial.entry.cleanup.operationId },
1506
+ );
1507
+ }
1508
+ if (!proof) {
1509
+ const pullRequestReference = pullRequest
1510
+ || initial.entry.pullRequest?.number
1511
+ || initial.entry.pullRequest?.url;
1512
+ if (git(repository.mainWorktree, ['remote', 'get-url', 'origin'], {
1513
+ ok: false, spawn,
1514
+ }).status === 0) {
1515
+ git(repository.mainWorktree, ['fetch', '--prune', 'origin'], { spawn });
1516
+ }
1517
+ proof = await verifyMergedPullRequest({
1518
+ startDir: repository.mainWorktree,
1519
+ entry: initial.entry,
1520
+ pullRequest: pullRequestReference,
1521
+ github,
1522
+ spawn,
1523
+ });
1524
+ }
482
1525
  const at = String(now());
483
- associatePullRequest(repository, slug, proof, at);
484
1526
  const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
485
1527
  if (!report.ok) throw blockerError(report);
486
- const authority = proof.url || `pr:${proof.number}`;
487
- const reservation = reserve(repository, slug, {
488
- mode: 'finish', authority, proof, now: at,
1528
+ // The PR proof is awaited above. A concurrent caller may therefore have
1529
+ // completed the cleanup while this invocation was waiting. Reconcile the
1530
+ // current registry/receipt before deriving the branch head again; otherwise
1531
+ // the already-deleted branch is misreported as an unprovable fresh cleanup.
1532
+ const racedCompletion = existingCompletion(repository, requiredEntry(repository, slug).entry, {
1533
+ provenanceGate, receiptClassifier,
1534
+ });
1535
+ if (racedCompletion) return racedCompletion;
1536
+ const authority = canonicalPrAuthority(proof);
1537
+ const initialDerivedHead = branchHead(repository, report.entry.branch, spawn);
1538
+ const initialHead = initialDerivedHead || (resumed
1539
+ ? String(initial.entry.cleanup?.head || proof.headRefOid || '') : '');
1540
+ if (!initialHead) {
1541
+ throw cleanupError(
1542
+ 'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
1543
+ 'Não foi possível provar o head atual da branch antes do cleanup.',
1544
+ );
1545
+ }
1546
+ if (proof.headRefOid && initialDerivedHead && proof.headRefOid !== initialDerivedHead) {
1547
+ throw cleanupError(
1548
+ 'WENDKEEP_WORKTREE_PR_HEAD_MISMATCH',
1549
+ 'O head atual da branch mudou desde a prova do PR.',
1550
+ );
1551
+ }
1552
+ const identity = cleanupIdentity(report, {
1553
+ authority, proof, actorContext,
1554
+ prior: initial.entry.cleanup,
1555
+ });
1556
+ requireCleanupOperationGate({
1557
+ mode: 'finish', report, proof, authority, head: initialHead, identity, provenanceGate,
1558
+ });
1559
+ const orphanedOperationId = orphanedReservationOperationId(report, identity, {
1560
+ mode: 'finish', authority, head: initialHead, slug, resumed,
489
1561
  });
490
- if (reservation.previous?.state === 'cleaned') {
491
- return existingCompletion(repository, requiredEntry(repository, slug).entry);
1562
+ const reservationOperationId = resumed
1563
+ ? String(initial.entry.cleanup.operationId)
1564
+ : (orphanedOperationId || randomUUID());
1565
+ const attemptToken = randomUUID();
1566
+ let reservation;
1567
+ let contextReservationAcquired = false;
1568
+ try {
1569
+ reserveContextCausally({
1570
+ vaultPath: report.registry.vaultPath,
1571
+ repository,
1572
+ slug,
1573
+ baselineReport: report,
1574
+ baselineIdentity: identity,
1575
+ priorCleanup: initial.entry.cleanup,
1576
+ proof,
1577
+ authority,
1578
+ actorContext,
1579
+ spawn,
1580
+ reserveOptions: {
1581
+ operationId: reservationOperationId,
1582
+ projectId: identity.project_id,
1583
+ repositoryId: identity.repository_id,
1584
+ worktreeId: identity.worktree_id,
1585
+ workSessionId: identity.work_session_id,
1586
+ changeSlug: identity.change_slug,
1587
+ targetContextIds: identity.target_context_ids,
1588
+ targetChangeSlugs: identity.target_change_slugs,
1589
+ targetContextSnapshot: identity.target_context_snapshot,
1590
+ allowClosedContexts: resumed,
1591
+ allowActiveSessions: resumed && !existsSync(report.entry.path),
1592
+ actorContextId: identity.actor_context_id,
1593
+ worktreePath: report.entry.path,
1594
+ mode: 'finish',
1595
+ authority,
1596
+ head: initialHead,
1597
+ slug,
1598
+ pullRequestNumber: identity.pull_request_number,
1599
+ pullRequestRepository: identity.pull_request_repository,
1600
+ headRefOid: identity.head_ref_oid,
1601
+ mergeCommitOid: identity.merge_commit_oid,
1602
+ ownerPid: process.pid,
1603
+ attemptToken,
1604
+ phase: 'reserved-before-worktree',
1605
+ now: at,
1606
+ },
1607
+ });
1608
+ contextReservationAcquired = true;
1609
+ reservation = reserve(repository, slug, {
1610
+ mode: 'finish', authority, proof, identity, head: initialHead, now: at,
1611
+ operationId: reservationOperationId, attemptToken,
1612
+ });
1613
+ if (reservation.previous?.state === 'cleaned') {
1614
+ releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
1615
+ repositoryId: identity.repository_id,
1616
+ worktreeId: identity.worktree_id,
1617
+ attemptToken,
1618
+ });
1619
+ contextReservationAcquired = false;
1620
+ return existingCompletion(repository, requiredEntry(repository, slug).entry, {
1621
+ provenanceGate, receiptClassifier,
1622
+ });
1623
+ }
1624
+ } catch (error) {
1625
+ if (contextReservationAcquired) {
1626
+ try {
1627
+ releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
1628
+ repositoryId: identity.repository_id,
1629
+ worktreeId: identity.worktree_id,
1630
+ attemptToken,
1631
+ });
1632
+ } catch { /* preserve root */ }
1633
+ }
1634
+ if (!contextReservationAcquired
1635
+ && error?.code === 'WENDKEEP_PROVENANCE_GATE_BLOCKED'
1636
+ && error?.blocker === 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH') {
1637
+ try {
1638
+ const failed = recordPreReservationFailure({
1639
+ repository,
1640
+ slug,
1641
+ mode: 'finish',
1642
+ authority,
1643
+ proof,
1644
+ identity,
1645
+ head: initialHead,
1646
+ now: at,
1647
+ operationId: reservationOperationId,
1648
+ error,
1649
+ });
1650
+ error.operationId = failed.operationId;
1651
+ error.state = failed.entry?.cleanup?.state || 'failed';
1652
+ error.recovery = cleanupRecovery(failed.entry || initial.entry);
1653
+ } catch { /* preserve the causal conflict */ }
1654
+ }
1655
+ throw error;
492
1656
  }
493
1657
  const { operationId } = reservation;
1658
+ let irreversibleStarted = false;
494
1659
  try {
495
- const expectedHead = branchHead(repository, report.entry.branch, spawn);
496
- removePath(repository, report.entry, spawn);
497
- closeContexts(report.registry.vaultPath, report.contexts, at);
1660
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
1661
+ operationId, repositoryId: identity.repository_id, worktreeId: identity.worktree_id,
1662
+ attemptToken, phase: 'ready', now: at,
1663
+ });
1664
+ invokeFault(faultInjection, 'beforePathRemoval', { operationId, slug });
1665
+ const mutation = revalidateCleanupBeforeMutation({
1666
+ repository,
1667
+ slug,
1668
+ baselineReport: report,
1669
+ baselineIdentity: identity,
1670
+ priorCleanup: initial.entry.cleanup,
1671
+ proof,
1672
+ authority,
1673
+ actorContext,
1674
+ expectedHead: initialHead,
1675
+ spawn,
1676
+ provenanceGate,
1677
+ });
1678
+ const expectedHead = mutation.head;
1679
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
1680
+ operationId, repositoryId: identity.repository_id, worktreeId: identity.worktree_id,
1681
+ attemptToken, phase: 'removing', now: at,
1682
+ });
1683
+ irreversibleStarted = true;
1684
+ removePath(repository, mutation.report.entry, spawn);
1685
+ invokeFault(faultInjection, 'afterPathRemoval', { operationId, slug });
1686
+ const postRemoval = assertPostRemovalCausalSnapshot({
1687
+ beforeReport: mutation.report,
1688
+ beforeIdentity: mutation.identity,
1689
+ repository,
1690
+ slug,
1691
+ proof,
1692
+ authority,
1693
+ actorContext,
1694
+ spawn,
1695
+ priorCleanup: initial.entry.cleanup,
1696
+ });
1697
+ closeContexts(postRemoval.report.registry.vaultPath, postRemoval.report.contexts, at, operationId);
1698
+ assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
498
1699
  const remoteBranchDeleted = deleteRemote
499
- ? deleteRemoteBranch(repository, report.entry.branch, expectedHead, spawn)
1700
+ ? deleteRemoteBranch(repository, mutation.report.entry.branch, expectedHead, spawn)
500
1701
  : false;
501
1702
  const localBranchDeleted = deleteLocalBranch(
502
- repository, report.entry.branch, expectedHead, spawn,
1703
+ repository, mutation.report.entry.branch, expectedHead, spawn,
503
1704
  );
504
- const receipt = appendReceipt(repository, {
505
- schemaVersion: 1,
506
- id: receiptId(report.registry.repositoryId, slug, 'finish', authority),
1705
+ invokeFault(faultInjection, 'afterBranchDeletion', { operationId, slug });
1706
+ invokeFault(faultInjection, 'beforeAppend', { operationId, slug });
1707
+ const receipt = receiptForOperation(repository, operationId, {
1708
+ slug, mode: 'finish', authority,
1709
+ }) || appendReceipt(repository, {
1710
+ ...identity,
507
1711
  operationId,
1712
+ reservationId: operationId,
508
1713
  slug,
509
1714
  mode: 'finish',
1715
+ authority,
510
1716
  outcome: 'completed',
511
- branch: report.entry.branch,
512
- head: expectedHead || report.entry.head,
1717
+ branch: mutation.report.entry.branch,
1718
+ head: expectedHead || mutation.report.entry.head,
513
1719
  pull_request: proof,
514
1720
  local_branch_deleted: localBranchDeleted,
515
1721
  remote_branch_deleted: remoteBranchDeleted,
1722
+ worktree_path: mutation.report.entry.path,
1723
+ phase: 'finalized',
516
1724
  finished_at: at,
517
1725
  });
518
- finalize(repository, slug, { receipt, now: at });
1726
+ requireCleanupReceiptGate(receipt, repository, slug, {
1727
+ receiptClassifier, provenanceGate,
1728
+ });
1729
+ invokeFault(faultInjection, 'afterAppend', { operationId, receipt, slug });
1730
+ invokeFault(faultInjection, 'beforeFinalize', { operationId, receipt, slug });
1731
+ assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
1732
+ markActiveContextCleanupTerminal(mutation.report.registry.vaultPath, {
1733
+ operationId,
1734
+ projectId: receipt.project_id,
1735
+ repositoryId: receipt.repository_id,
1736
+ worktreeId: receipt.worktree_id,
1737
+ workSessionId: receipt.work_session_id,
1738
+ changeSlug: receipt.change_slug,
1739
+ targetContextIds: receipt.target_context_ids,
1740
+ targetChangeSlugs: receipt.target_change_slugs,
1741
+ targetContextSnapshot: receipt.target_context_snapshot,
1742
+ worktreePath: mutation.report.entry.path,
1743
+ subjectHash: receipt.id,
1744
+ actorContextId: receipt.actor_context_id,
1745
+ mode: receipt.mode,
1746
+ authority: receipt.authority,
1747
+ head: receipt.head,
1748
+ slug: receipt.slug,
1749
+ pullRequestNumber: receipt.pull_request_number,
1750
+ pullRequestRepository: receipt.pull_request_repository,
1751
+ headRefOid: receipt.head_ref_oid,
1752
+ mergeCommitOid: receipt.merge_commit_oid,
1753
+ attemptToken,
1754
+ now: at,
1755
+ });
1756
+ invokeFault(faultInjection, 'afterTerminal', { operationId, receipt, slug });
1757
+ finalize(repository, slug, {
1758
+ receipt,
1759
+ now: at,
1760
+ expectedOperationId: operationId,
1761
+ expectedAttemptToken: attemptToken,
1762
+ expectedSubjectHash: receipt.id,
1763
+ });
1764
+ invokeFault(faultInjection, 'afterFinalize', { operationId, receipt, slug });
1765
+ releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
1766
+ repositoryId: identity.repository_id,
1767
+ worktreeId: identity.worktree_id,
1768
+ attemptToken,
1769
+ });
1770
+ contextReservationAcquired = false;
1771
+ releaseCleanupOperation(repository, slug);
519
1772
  return { state: 'completed', idempotent: false, receipt };
520
1773
  } catch (error) {
521
- failReservation(repository, slug, error, String(now()));
1774
+ let failedRegistry = null;
1775
+ try { failedRegistry = failReservation(repository, slug, error, String(now())); } catch { /* preserve root error */ }
1776
+ error.operationId = error.operationId || operationId;
1777
+ const failedEntry = failedRegistry?.entries?.[slug];
1778
+ error.state = failedEntry?.cleanup?.state || 'failed';
1779
+ if (error.state === 'failed') error.recovery = cleanupRecovery(failedEntry || initial.entry);
1780
+ if (irreversibleStarted) {
1781
+ try {
1782
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
1783
+ operationId,
1784
+ repositoryId: identity.repository_id,
1785
+ worktreeId: identity.worktree_id,
1786
+ attemptToken,
1787
+ phase: failedEntry?.cleanup?.state === 'completed' ? 'finalized' : 'failed',
1788
+ now: String(now()),
1789
+ });
1790
+ } catch { /* preserve root */ }
1791
+ }
1792
+ if (contextReservationAcquired && !irreversibleStarted) {
1793
+ try {
1794
+ releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
1795
+ repositoryId: identity.repository_id,
1796
+ worktreeId: identity.worktree_id,
1797
+ attemptToken,
1798
+ });
1799
+ } catch { /* preserve root */ }
1800
+ }
1801
+ releaseCleanupOperation(repository, slug);
522
1802
  throw error;
523
1803
  }
524
1804
  }
@@ -529,43 +1809,289 @@ export async function removeManagedWorktree({
529
1809
  reason,
530
1810
  spawn = spawnSync,
531
1811
  now = () => new Date().toISOString(),
1812
+ actorContext = '',
1813
+ faultInjection = {},
1814
+ provenanceGate = evaluateProvenanceGate,
1815
+ receiptClassifier = classifyReceipt,
532
1816
  } = {}) {
533
- const normalizedReason = String(reason || '').trim();
534
- if (!normalizedReason) {
1817
+ const reasonEvidence = normalizedReason(reason);
1818
+ if (!String(reason || '').trim()) {
535
1819
  throw cleanupError('WENDKEEP_WORKTREE_REASON_REQUIRED', '`worktree remove` exige --reason.');
536
1820
  }
537
1821
  const repository = discoverWorktreeRepository({ startDir, spawn });
538
1822
  const initial = requiredEntry(repository, slug);
539
- const completed = existingCompletion(repository, initial.entry);
1823
+ readReceipts(repository);
1824
+ const completed = existingCompletion(repository, initial.entry, {
1825
+ provenanceGate, receiptClassifier,
1826
+ });
540
1827
  if (completed) return completed;
541
1828
  const report = inspectWorktreeCleanup({ startDir: repository.mainWorktree, slug, spawn });
542
1829
  if (!report.ok) throw blockerError(report);
543
1830
  const at = String(now());
544
- const authority = `reason:${normalizedReason}`;
545
- const { operationId } = reserve(repository, slug, {
546
- mode: 'remove', authority, reason: normalizedReason, now: at,
1831
+ const authority = reasonEvidence.authority;
1832
+ const resumed = isResumableCleanup(initial.entry, 'remove');
1833
+ if (resumed && initial.entry.cleanup.authority !== authority) {
1834
+ throw cleanupError('WENDKEEP_WORKTREE_CLEANUP_BUSY', 'O motivo não corresponde à operação reservada.');
1835
+ }
1836
+ const initialDerivedHead = branchHead(repository, report.entry.branch, spawn);
1837
+ const initialHead = initialDerivedHead || (resumed
1838
+ ? String(initial.entry.cleanup?.head || report.entry.head || '') : '');
1839
+ if (!initialHead) {
1840
+ throw cleanupError(
1841
+ 'WENDKEEP_WORKTREE_BRANCH_UNPROVEN',
1842
+ 'Não foi possível provar o head atual da branch antes do cleanup.',
1843
+ );
1844
+ }
1845
+ const identity = cleanupIdentity(report, {
1846
+ authority, actorContext, prior: initial.entry.cleanup,
1847
+ });
1848
+ requireCleanupOperationGate({
1849
+ mode: 'remove', report, authority, head: initialHead, identity, provenanceGate,
1850
+ });
1851
+ const orphanedOperationId = orphanedReservationOperationId(report, identity, {
1852
+ mode: 'remove', authority, head: initialHead, slug, resumed,
547
1853
  });
1854
+ const reservationOperationId = resumed
1855
+ ? String(initial.entry.cleanup.operationId)
1856
+ : (orphanedOperationId || randomUUID());
1857
+ const attemptToken = randomUUID();
1858
+ let reservation;
1859
+ let contextReservationAcquired = false;
1860
+ try {
1861
+ reserveContextCausally({
1862
+ vaultPath: report.registry.vaultPath,
1863
+ repository,
1864
+ slug,
1865
+ baselineReport: report,
1866
+ baselineIdentity: identity,
1867
+ priorCleanup: initial.entry.cleanup,
1868
+ authority,
1869
+ actorContext,
1870
+ spawn,
1871
+ reserveOptions: {
1872
+ operationId: reservationOperationId,
1873
+ projectId: identity.project_id,
1874
+ repositoryId: identity.repository_id,
1875
+ worktreeId: identity.worktree_id,
1876
+ workSessionId: identity.work_session_id,
1877
+ changeSlug: identity.change_slug,
1878
+ targetContextIds: identity.target_context_ids,
1879
+ targetChangeSlugs: identity.target_change_slugs,
1880
+ targetContextSnapshot: identity.target_context_snapshot,
1881
+ allowClosedContexts: resumed,
1882
+ allowActiveSessions: resumed && !existsSync(report.entry.path),
1883
+ actorContextId: identity.actor_context_id,
1884
+ worktreePath: report.entry.path,
1885
+ mode: 'remove',
1886
+ authority,
1887
+ head: initialHead,
1888
+ slug,
1889
+ pullRequestNumber: identity.pull_request_number,
1890
+ pullRequestRepository: identity.pull_request_repository,
1891
+ headRefOid: identity.head_ref_oid,
1892
+ mergeCommitOid: identity.merge_commit_oid,
1893
+ ownerPid: process.pid,
1894
+ attemptToken,
1895
+ phase: 'reserved-before-worktree',
1896
+ now: at,
1897
+ },
1898
+ });
1899
+ contextReservationAcquired = true;
1900
+ reservation = reserve(repository, slug, {
1901
+ mode: 'remove', authority, reason: reasonEvidence.label,
1902
+ reasonDigest: reasonEvidence.digest, identity, head: initialHead, now: at,
1903
+ operationId: reservationOperationId, attemptToken,
1904
+ });
1905
+ if (reservation.previous?.state === 'cleaned') {
1906
+ releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
1907
+ repositoryId: identity.repository_id,
1908
+ worktreeId: identity.worktree_id,
1909
+ attemptToken,
1910
+ });
1911
+ contextReservationAcquired = false;
1912
+ return existingCompletion(repository, requiredEntry(repository, slug).entry, {
1913
+ provenanceGate, receiptClassifier,
1914
+ });
1915
+ }
1916
+ } catch (error) {
1917
+ if (contextReservationAcquired) {
1918
+ try {
1919
+ releaseActiveContextCleanup(report.registry.vaultPath, reservationOperationId, {
1920
+ repositoryId: identity.repository_id,
1921
+ worktreeId: identity.worktree_id,
1922
+ attemptToken,
1923
+ });
1924
+ } catch { /* preserve root */ }
1925
+ }
1926
+ if (!contextReservationAcquired
1927
+ && error?.code === 'WENDKEEP_PROVENANCE_GATE_BLOCKED'
1928
+ && error?.blocker === 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH') {
1929
+ try {
1930
+ const failed = recordPreReservationFailure({
1931
+ repository,
1932
+ slug,
1933
+ mode: 'remove',
1934
+ authority,
1935
+ reason: reasonEvidence.label,
1936
+ reasonDigest: reasonEvidence.digest,
1937
+ identity,
1938
+ head: initialHead,
1939
+ now: at,
1940
+ operationId: reservationOperationId,
1941
+ error,
1942
+ });
1943
+ error.operationId = failed.operationId;
1944
+ error.state = failed.entry?.cleanup?.state || 'failed';
1945
+ error.recovery = cleanupRecovery(failed.entry || initial.entry);
1946
+ } catch { /* preserve the causal conflict */ }
1947
+ }
1948
+ throw error;
1949
+ }
1950
+ const { operationId } = reservation;
1951
+ let irreversibleStarted = false;
548
1952
  try {
549
- removePath(repository, report.entry, spawn);
550
- closeContexts(report.registry.vaultPath, report.contexts, at);
551
- const receipt = appendReceipt(repository, {
552
- schemaVersion: 1,
553
- id: receiptId(report.registry.repositoryId, slug, 'remove', authority),
1953
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
1954
+ operationId: operationId || reservationOperationId,
1955
+ repositoryId: identity.repository_id,
1956
+ worktreeId: identity.worktree_id,
1957
+ attemptToken,
1958
+ phase: 'ready',
1959
+ now: at,
1960
+ });
1961
+ invokeFault(faultInjection, 'beforePathRemoval', { operationId, slug });
1962
+ const mutation = revalidateCleanupBeforeMutation({
1963
+ repository,
1964
+ slug,
1965
+ baselineReport: report,
1966
+ baselineIdentity: identity,
1967
+ priorCleanup: initial.entry.cleanup,
1968
+ authority,
1969
+ actorContext,
1970
+ expectedHead: initialHead,
1971
+ spawn,
1972
+ provenanceGate,
1973
+ });
1974
+ const expectedHead = mutation.head;
1975
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
1976
+ operationId: reservation.operationId,
1977
+ repositoryId: identity.repository_id,
1978
+ worktreeId: identity.worktree_id,
1979
+ attemptToken,
1980
+ phase: 'removing',
1981
+ now: at,
1982
+ });
1983
+ irreversibleStarted = true;
1984
+ removePath(repository, mutation.report.entry, spawn);
1985
+ invokeFault(faultInjection, 'afterPathRemoval', { operationId, slug });
1986
+ const postRemoval = assertPostRemovalCausalSnapshot({
1987
+ beforeReport: mutation.report,
1988
+ beforeIdentity: mutation.identity,
1989
+ repository,
1990
+ slug,
1991
+ authority,
1992
+ actorContext,
1993
+ spawn,
1994
+ priorCleanup: initial.entry.cleanup,
1995
+ });
1996
+ closeContexts(postRemoval.report.registry.vaultPath, postRemoval.report.contexts, at, operationId);
1997
+ assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
1998
+ invokeFault(faultInjection, 'beforeAppend', { operationId, slug });
1999
+ const receipt = receiptForOperation(repository, operationId, {
2000
+ slug, mode: 'remove', authority,
2001
+ }) || appendReceipt(repository, {
2002
+ ...identity,
554
2003
  operationId,
2004
+ reservationId: operationId,
555
2005
  slug,
556
2006
  mode: 'remove',
2007
+ authority,
557
2008
  outcome: 'completed',
558
- branch: report.entry.branch,
559
- head: report.entry.head,
560
- reason: normalizedReason,
2009
+ branch: mutation.report.entry.branch,
2010
+ head: expectedHead,
2011
+ reason: reasonEvidence.label,
2012
+ reason_digest: reasonEvidence.digest,
561
2013
  local_branch_deleted: false,
562
2014
  remote_branch_deleted: false,
2015
+ worktree_path: mutation.report.entry.path,
2016
+ phase: 'finalized',
563
2017
  finished_at: at,
564
2018
  });
565
- finalize(repository, slug, { receipt, now: at });
2019
+ requireCleanupReceiptGate(receipt, repository, slug, {
2020
+ receiptClassifier, provenanceGate,
2021
+ });
2022
+ invokeFault(faultInjection, 'afterAppend', { operationId, receipt, slug });
2023
+ invokeFault(faultInjection, 'beforeFinalize', { operationId, receipt, slug });
2024
+ assertNoActiveCleanupContexts(mutation.report.registry.vaultPath, mutation.report.entry);
2025
+ markActiveContextCleanupTerminal(mutation.report.registry.vaultPath, {
2026
+ operationId,
2027
+ projectId: receipt.project_id,
2028
+ repositoryId: receipt.repository_id,
2029
+ worktreeId: receipt.worktree_id,
2030
+ workSessionId: receipt.work_session_id,
2031
+ changeSlug: receipt.change_slug,
2032
+ targetContextIds: receipt.target_context_ids,
2033
+ targetChangeSlugs: receipt.target_change_slugs,
2034
+ targetContextSnapshot: receipt.target_context_snapshot,
2035
+ worktreePath: mutation.report.entry.path,
2036
+ subjectHash: receipt.id,
2037
+ actorContextId: receipt.actor_context_id,
2038
+ mode: receipt.mode,
2039
+ authority: receipt.authority,
2040
+ head: receipt.head,
2041
+ slug: receipt.slug,
2042
+ pullRequestNumber: receipt.pull_request_number,
2043
+ pullRequestRepository: receipt.pull_request_repository,
2044
+ headRefOid: receipt.head_ref_oid,
2045
+ mergeCommitOid: receipt.merge_commit_oid,
2046
+ attemptToken,
2047
+ now: at,
2048
+ });
2049
+ invokeFault(faultInjection, 'afterTerminal', { operationId, receipt, slug });
2050
+ finalize(repository, slug, {
2051
+ receipt,
2052
+ now: at,
2053
+ expectedOperationId: operationId,
2054
+ expectedAttemptToken: attemptToken,
2055
+ expectedSubjectHash: receipt.id,
2056
+ });
2057
+ invokeFault(faultInjection, 'afterFinalize', { operationId, receipt, slug });
2058
+ releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
2059
+ repositoryId: identity.repository_id,
2060
+ worktreeId: identity.worktree_id,
2061
+ attemptToken,
2062
+ });
2063
+ contextReservationAcquired = false;
2064
+ releaseCleanupOperation(repository, slug);
566
2065
  return { state: 'completed', idempotent: false, receipt };
567
2066
  } catch (error) {
568
- failReservation(repository, slug, error, String(now()));
2067
+ let failedRegistry = null;
2068
+ try { failedRegistry = failReservation(repository, slug, error, String(now())); } catch { /* preserve root error */ }
2069
+ error.operationId = error.operationId || operationId;
2070
+ const failedEntry = failedRegistry?.entries?.[slug];
2071
+ error.state = failedEntry?.cleanup?.state || 'failed';
2072
+ if (error.state === 'failed') error.recovery = cleanupRecovery(failedEntry || initial.entry);
2073
+ if (irreversibleStarted) {
2074
+ try {
2075
+ updateActiveContextCleanupPhase(report.registry.vaultPath, {
2076
+ operationId,
2077
+ repositoryId: identity.repository_id,
2078
+ worktreeId: identity.worktree_id,
2079
+ attemptToken,
2080
+ phase: failedEntry?.cleanup?.state === 'completed' ? 'finalized' : 'failed',
2081
+ now: String(now()),
2082
+ });
2083
+ } catch { /* preserve root */ }
2084
+ }
2085
+ if (contextReservationAcquired && !irreversibleStarted) {
2086
+ try {
2087
+ releaseActiveContextCleanup(report.registry.vaultPath, operationId, {
2088
+ repositoryId: identity.repository_id,
2089
+ worktreeId: identity.worktree_id,
2090
+ attemptToken,
2091
+ });
2092
+ } catch { /* preserve root */ }
2093
+ }
2094
+ releaseCleanupOperation(repository, slug);
569
2095
  throw error;
570
2096
  }
571
2097
  }
@@ -576,13 +2102,37 @@ export async function cleanupMergedWorktrees({
576
2102
  github = defaultGithub,
577
2103
  spawn = spawnSync,
578
2104
  now = () => new Date().toISOString(),
2105
+ actorContext = '',
2106
+ faultInjection = {},
2107
+ provenanceGate = evaluateProvenanceGate,
2108
+ receiptClassifier = classifyReceipt,
579
2109
  } = {}) {
580
2110
  const repository = discoverWorktreeRepository({ startDir, spawn });
2111
+ readReceipts(repository);
581
2112
  const { registry } = readWorktreeRegistry(repository);
582
2113
  const actions = [];
583
2114
  for (const slug of Object.keys(registry.entries || {}).sort()) {
584
2115
  const entry = registry.entries[slug];
585
- if (entry.state === 'cleaned') continue;
2116
+ if (entry.state === 'cleaned') {
2117
+ try {
2118
+ const completed = existingCompletion(repository, entry, {
2119
+ provenanceGate, receiptClassifier,
2120
+ });
2121
+ if (completed) continue;
2122
+ } catch (error) {
2123
+ actions.push({
2124
+ slug,
2125
+ outcome: 'blocked',
2126
+ blockers: [String(error?.code || 'WENDKEEP_PROVENANCE_GATE_BLOCKED')],
2127
+ state: error?.state || 'unproven',
2128
+ blocker: error?.blocker || '',
2129
+ recovery: error?.recovery || 'wendkeep verify --deep',
2130
+ expected: error?.expected || null,
2131
+ observed: error?.observed || null,
2132
+ });
2133
+ }
2134
+ continue;
2135
+ }
586
2136
  const pullRequest = entry.pullRequest?.number || entry.pullRequest?.url;
587
2137
  if (!pullRequest) {
588
2138
  actions.push({
@@ -609,6 +2159,10 @@ export async function cleanupMergedWorktrees({
609
2159
  github,
610
2160
  spawn,
611
2161
  now,
2162
+ actorContext,
2163
+ faultInjection,
2164
+ provenanceGate,
2165
+ receiptClassifier,
612
2166
  });
613
2167
  actions.push({ slug, outcome: result.state, receipt: result.receipt });
614
2168
  }
@@ -623,6 +2177,10 @@ export function pruneManagedWorktrees({
623
2177
  startDir = process.cwd(), apply = false, spawn = spawnSync,
624
2178
  } = {}) {
625
2179
  const repository = discoverWorktreeRepository({ startDir, spawn });
2180
+ // Even metadata-only pruning mutates the shared Git worktree state. Validate
2181
+ // the receipt chain first so a corrupt/truncated ledger never gets hidden by
2182
+ // a successful prune.
2183
+ readReceipts(repository);
626
2184
  const { registry } = readWorktreeRegistry(repository);
627
2185
  const listed = git(repository.mainWorktree, ['worktree', 'list', '--porcelain'], { spawn });
628
2186
  const registeredPaths = new Set(String(listed.stdout || '').split(/\r?\n/)
@@ -642,11 +2200,33 @@ export function pruneManagedWorktrees({
642
2200
 
643
2201
  function cleanupRecovery(entry) {
644
2202
  if (entry.cleanup?.mode === 'remove') {
645
- const reason = String(entry.cleanup.reason || 'confirme o abandono').replaceAll('"', '\\"');
646
- return `wendkeep worktree remove ${entry.slug} --reason "${reason}"`;
2203
+ const reason = recoverySegment(entry.cleanup.reason, 'confirme-o-abandono');
2204
+ return `wendkeep worktree remove ${recoverySegment(entry.slug, 'worktree')} --reason ${reason}`;
647
2205
  }
648
- const pullRequest = entry.pullRequest?.number || entry.pullRequest?.url || '<PR>';
649
- return `wendkeep worktree finish ${entry.slug} --pr ${pullRequest}`;
2206
+ const pullRequest = /^\d+$/.test(String(entry.pullRequest?.number || ''))
2207
+ ? String(entry.pullRequest.number) : 'pr';
2208
+ return `wendkeep worktree finish ${recoverySegment(entry.slug, 'worktree')} --pr ${pullRequest}`;
2209
+ }
2210
+
2211
+ function hasValidLegacyReceiptAnchor(repository) {
2212
+ const path = cleanupReceiptLegacyPath(repository);
2213
+ if (!existsSync(path)) return false;
2214
+ let text;
2215
+ try { text = readFileSync(path, 'utf8'); } catch { return false; }
2216
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2217
+ if (!lines.length) return false;
2218
+ return lines.every((line) => {
2219
+ try {
2220
+ const value = JSON.parse(line);
2221
+ const schemaVersion = Number(value?.schema_version ?? value?.schemaVersion ?? 0);
2222
+ return value && typeof value === 'object' && !Array.isArray(value)
2223
+ && String(value.id || value.receipt_id || '').trim()
2224
+ && ((schemaVersion === 1 && String(value.outcome || '').trim())
2225
+ || (String(value.slug || '').trim()
2226
+ && String(value.mode || '').trim()
2227
+ && String(value.outcome || '').trim()));
2228
+ } catch { return false; }
2229
+ });
650
2230
  }
651
2231
 
652
2232
  export function diagnoseManagedWorktreeCleanups({
@@ -667,17 +2247,24 @@ export function diagnoseManagedWorktreeCleanups({
667
2247
  let receipts = [];
668
2248
  try {
669
2249
  receipts = readReceipts(repository);
670
- } catch {
2250
+ } catch (error) {
2251
+ const ledgerCode = [
2252
+ 'WENDKEEP_RECEIPT_LEDGER_CORRUPT',
2253
+ 'WENDKEEP_RECEIPT_LEDGER_TRUNCATED',
2254
+ 'WENDKEEP_RECEIPT_LEDGER_BUSY',
2255
+ 'WENDKEEP_RECEIPT_LEDGER_CONFLICT',
2256
+ ].includes(error?.code) ? error.code : 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_INVALID';
671
2257
  return {
672
2258
  initialized: true,
673
2259
  issues: [{
674
2260
  slug: '*',
675
2261
  state: 'receipt-invalid',
676
- errorCode: 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_INVALID',
2262
+ errorCode: ledgerCode,
677
2263
  repair: 'revise o receipt store append-only antes de retomar o cleanup',
678
2264
  }],
679
2265
  };
680
2266
  }
2267
+ const hasLegacyReceipts = hasValidLegacyReceiptAnchor(repository);
681
2268
  const receiptIds = new Set(receipts.map((receipt) => receipt.id));
682
2269
  const issues = [];
683
2270
  for (const slug of Object.keys(registry.entries || {}).sort()) {
@@ -702,10 +2289,38 @@ export function diagnoseManagedWorktreeCleanups({
702
2289
  && (!entry.cleanup?.receiptId || !receiptIds.has(entry.cleanup.receiptId))) {
703
2290
  issues.push({
704
2291
  slug,
705
- state: entry.state,
706
- errorCode: 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_MISSING',
707
- repair: 'revise o registry e o receipt store; não invente um receipt retroativo',
2292
+ state: hasLegacyReceipts ? 'legacy-unbound' : entry.state,
2293
+ errorCode: hasLegacyReceipts
2294
+ ? 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_LEGACY_UNBOUND'
2295
+ : 'WENDKEEP_WORKTREE_CLEANUP_RECEIPT_MISSING',
2296
+ repair: hasLegacyReceipts
2297
+ ? 'rode wendkeep verify novamente para emitir receipts v2'
2298
+ : 'revise o registry e o receipt store; não invente um receipt retroativo',
708
2299
  });
2300
+ } else if (entry.state === 'cleaned' && entry.cleanup?.receiptId) {
2301
+ const receipt = receipts.find((item) => item.id === entry.cleanup.receiptId);
2302
+ if (receipt) {
2303
+ const { gate, subject } = cleanupReceiptAssessment(receipt, registry, entry);
2304
+ const matches = cleanupReceiptMatches(receipt, registry, entry);
2305
+ if (!gate.ok || !matches) {
2306
+ const effectiveGate = gate.ok ? {
2307
+ state: 'conflict',
2308
+ reasonCodes: ['PROV_RECEIPT_CONFLICT'],
2309
+ diagnostics: [{ blocker: 'PROV_RECEIPT_CONFLICT', expected: subject, observed: receipt }],
2310
+ repair: { command: 'wendkeep verify --deep' },
2311
+ } : gate;
2312
+ issues.push({
2313
+ slug,
2314
+ state: effectiveGate.state,
2315
+ errorCode: 'WENDKEEP_PROVENANCE_GATE_BLOCKED',
2316
+ reasonCodes: effectiveGate.reasonCodes || [],
2317
+ blocker: effectiveGate.diagnostics?.[0]?.blocker || null,
2318
+ expected: effectiveGate.diagnostics?.[0]?.expected || subject,
2319
+ observed: effectiveGate.diagnostics?.[0]?.observed || receipt,
2320
+ repair: effectiveGate.repair?.command || 'wendkeep verify --deep',
2321
+ });
2322
+ }
2323
+ }
709
2324
  }
710
2325
  }
711
2326
  return { initialized: true, issues };