delegate-agent-cli 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,782 @@
1
+ """Persistent worktree management facade.
2
+
3
+ This module owns the worktree status/inspection layer (status detection, dirty
4
+ and merged checks, ahead/behind, record decoration, ``list``/``show``) and the
5
+ shared error envelope, and re-exports the record model (``worktree_records``),
6
+ the removal pipeline (``worktree_remove``), and the prune/gc pipelines
7
+ (``worktree_gc``) so callers — ``worktree_commands``, ``cli``, and the test
8
+ suite — keep importing the full surface from ``worktree_mgmt`` unchanged.
9
+
10
+ The seam functions tests monkeypatch via ``worktree_mgmt.<name>`` (e.g.
11
+ ``_run_git``, ``porcelain_status``, ``merged_into_source``,
12
+ ``detect_worktree_status``, ``_remove_branch``, ``prune_worktrees``) are either
13
+ defined here or re-exported here; the moved pipeline functions read them back
14
+ through this module so those patches keep taking effect.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from contextlib import suppress
20
+ from pathlib import Path
21
+
22
+ from delegate_agent import run_registry, worktree_summary
23
+ from delegate_agent.git_utils import (
24
+ GIT_QUICK_TIMEOUT_SECONDS,
25
+ rev_parse_verify,
26
+ )
27
+ from delegate_agent.git_utils import (
28
+ run_git as _run_git,
29
+ )
30
+ from delegate_agent.json_types import JsonObject
31
+ from delegate_agent.worktree_records import ( # noqa: F401 # re-exported
32
+ MAX_DIRTY_PATHS_REPORTED,
33
+ SCHEMA_GC,
34
+ SCHEMA_LIST,
35
+ SCHEMA_PRUNE,
36
+ SCHEMA_REMOVE,
37
+ SCHEMA_SHOW,
38
+ STATUS_MISSING,
39
+ STATUS_PRESENT,
40
+ STATUS_REMOVED,
41
+ STATUS_UNKNOWN,
42
+ VALID_STATUSES,
43
+ WORKTREE_ERROR_EXIT_CODE,
44
+ PersistentWorktreeRecord,
45
+ _branch_from,
46
+ _creation_context,
47
+ _execution_cwd_from,
48
+ _get_dict,
49
+ _get_str,
50
+ _is_persistent_worktree_run,
51
+ _record_for_run,
52
+ _registered_worktree_path_matches,
53
+ _registry_worktree_status,
54
+ _reload_record,
55
+ _shell,
56
+ _source_git_root_from,
57
+ _utc_now_iso,
58
+ latest_persistent_record_for_harness,
59
+ load_persistent_records,
60
+ )
61
+
62
+
63
+ class WorktreeManagementError(Exception):
64
+ def __init__(self, payload: JsonObject) -> None:
65
+ code = payload.get("code")
66
+ message = payload.get("message")
67
+ if not isinstance(code, str) or not code:
68
+ raise ValueError("worktree error payload requires non-empty string code")
69
+ if not isinstance(message, str) or not message:
70
+ raise ValueError("worktree error payload requires non-empty string message")
71
+ normalized = dict(payload)
72
+ normalized["ok"] = False
73
+ normalized["code"] = code
74
+ normalized["error"] = str(normalized.get("error") or code)
75
+ normalized["message"] = message
76
+ exit_code = normalized.get("exitCode")
77
+ normalized["exitCode"] = (
78
+ exit_code if isinstance(exit_code, int) else WORKTREE_ERROR_EXIT_CODE
79
+ )
80
+ super().__init__(message)
81
+ self.payload = normalized
82
+ self.code = code
83
+ self.message = message
84
+
85
+
86
+ def _branch_ref(branch: str) -> str:
87
+ return branch if branch.startswith("refs/heads/") else f"refs/heads/{branch}"
88
+
89
+
90
+ def _branch_exists(source_git_root: str, branch: str) -> bool | None:
91
+ result = _run_git(
92
+ source_git_root,
93
+ ["rev-parse", "--verify", "--quiet", _branch_ref(branch)],
94
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
95
+ )
96
+ if result.returncode == 0:
97
+ return True
98
+ if result.returncode == 1:
99
+ return False
100
+ return None
101
+
102
+
103
+ def detect_worktree_status(record: PersistentWorktreeRecord) -> tuple[str, list[str]]:
104
+ warnings: list[str] = []
105
+ registry_status = record.get("registryWorktreeStatus")
106
+ if registry_status == STATUS_REMOVED:
107
+ return STATUS_REMOVED, warnings
108
+ execution_cwd = record.get("executionCwd")
109
+ source_git_root = record.get("sourceGitRoot")
110
+ branch = record.get("branch")
111
+ if not isinstance(execution_cwd, str) or not execution_cwd:
112
+ return STATUS_UNKNOWN, ["missing executionCwd metadata"]
113
+ if not Path(execution_cwd).exists():
114
+ return STATUS_MISSING, warnings
115
+ if not isinstance(source_git_root, str) or not source_git_root:
116
+ return STATUS_UNKNOWN, ["missing sourceGitRoot metadata"]
117
+ if not isinstance(branch, str) or not branch:
118
+ return STATUS_UNKNOWN, ["missing branch metadata"]
119
+ listed_paths, list_warning = _worktree_list_paths_with_warning(source_git_root)
120
+ if listed_paths is None:
121
+ return STATUS_UNKNOWN, [list_warning or "could not list registered git worktrees"]
122
+ if not _registered_worktree_path_matches(listed_paths, execution_cwd):
123
+ return STATUS_UNKNOWN, ["worktree path is not registered with git"]
124
+ branch_exists = _branch_exists(source_git_root, branch)
125
+ if branch_exists is True:
126
+ return STATUS_PRESENT, warnings
127
+ if branch_exists is False:
128
+ return STATUS_UNKNOWN, ["branch does not resolve"]
129
+ return STATUS_UNKNOWN, ["could not determine branch status"]
130
+
131
+
132
+ def porcelain_status(
133
+ execution_cwd: str,
134
+ *,
135
+ limit: int | None = None,
136
+ ) -> tuple[list[str] | None, int | None, list[str]]:
137
+ result = _run_git(
138
+ execution_cwd,
139
+ ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=none"],
140
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
141
+ )
142
+ if result.returncode != 0:
143
+ return None, None, [f"git status failed: {result.stderr.strip()}"]
144
+ lines = result.stdout.splitlines()
145
+ if limit is None:
146
+ return lines, len(lines), []
147
+ return lines[:limit], len(lines), []
148
+
149
+
150
+ def dirty_info(
151
+ record: PersistentWorktreeRecord, status: str
152
+ ) -> tuple[bool | None, list[str], int | None, list[str]]:
153
+ if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
154
+ return None, [], None, []
155
+ execution_cwd = record.get("executionCwd")
156
+ if not isinstance(execution_cwd, str) or not execution_cwd:
157
+ return None, [], None, ["missing executionCwd metadata"]
158
+ lines, total, warnings = porcelain_status(execution_cwd)
159
+ if lines is None:
160
+ return None, [], total, warnings
161
+ return bool(lines), lines, total, warnings
162
+
163
+
164
+ def _merge_base_is_ancestor(source_git_root: str, branch: str) -> bool | None:
165
+ result = _run_git(
166
+ source_git_root,
167
+ ["merge-base", "--is-ancestor", _branch_ref(branch), "HEAD"],
168
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
169
+ )
170
+ if result.returncode == 0:
171
+ return True
172
+ if result.returncode == 1:
173
+ return False
174
+ return None
175
+
176
+
177
+ def merged_into_source(
178
+ record: PersistentWorktreeRecord,
179
+ status: str,
180
+ *,
181
+ include_detached: bool = False,
182
+ ) -> tuple[bool | None, list[str]]:
183
+ if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
184
+ return None, []
185
+ creation = record.get("creationContext")
186
+ if (
187
+ isinstance(creation, dict)
188
+ and creation.get("sourceHeadRef") is None
189
+ and not include_detached
190
+ ):
191
+ return None, ["source was detached at creation; integration target unknown"]
192
+ source_git_root = record.get("sourceGitRoot")
193
+ branch = record.get("branch")
194
+ if not isinstance(source_git_root, str) or not isinstance(branch, str):
195
+ return None, ["missing sourceGitRoot or branch metadata"]
196
+ value = _merge_base_is_ancestor(source_git_root, branch)
197
+ if value is None:
198
+ return None, ["could not determine whether branch is merged into current source HEAD"]
199
+ return value, []
200
+
201
+
202
+ def _rev_parse(source_git_root: str, rev: str) -> str | None:
203
+ return rev_parse_verify(
204
+ source_git_root,
205
+ rev,
206
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
207
+ git_runner=_run_git,
208
+ )
209
+
210
+
211
+ def _symbolic_ref(source_git_root: str, rev: str) -> str | None:
212
+ """Return the symbolic ref (e.g. `refs/heads/main`) or None if detached/missing."""
213
+ result = _run_git(
214
+ source_git_root,
215
+ ["symbolic-ref", "--quiet", rev],
216
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
217
+ )
218
+ if result.returncode != 0:
219
+ return None
220
+ return result.stdout.strip() or None
221
+
222
+
223
+ def _ahead_behind(source_git_root: str, branch: str, base: str) -> JsonObject | None:
224
+ result = _run_git(
225
+ source_git_root,
226
+ ["rev-list", "--left-right", "--count", f"{base}...{branch}"],
227
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
228
+ )
229
+ if result.returncode != 0:
230
+ return None
231
+ parts = result.stdout.strip().split()
232
+ if len(parts) != 2:
233
+ return None
234
+ try:
235
+ behind = int(parts[0])
236
+ ahead = int(parts[1])
237
+ except ValueError:
238
+ return None
239
+ base_oid = _rev_parse(source_git_root, base) or base
240
+ return {"ahead": ahead, "behind": behind, "baseOid": base_oid}
241
+
242
+
243
+ def ahead_behind(record: PersistentWorktreeRecord, status: str) -> JsonObject | None:
244
+ if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
245
+ return None
246
+ source_git_root = record.get("sourceGitRoot")
247
+ branch = record.get("branch")
248
+ creation = record.get("creationContext")
249
+ if (
250
+ not isinstance(source_git_root, str)
251
+ or not isinstance(branch, str)
252
+ or not isinstance(creation, dict)
253
+ ):
254
+ return None
255
+ creation_base = creation.get("sourceHeadOid")
256
+ vs_creation = (
257
+ _ahead_behind(source_git_root, branch, creation_base)
258
+ if isinstance(creation_base, str) and creation_base
259
+ else None
260
+ )
261
+ current_head = _rev_parse(source_git_root, "HEAD")
262
+ vs_current = _ahead_behind(source_git_root, branch, current_head) if current_head else None
263
+ return {
264
+ "vsCreationBase": vs_creation,
265
+ "vsCurrentHead": vs_current,
266
+ }
267
+
268
+
269
+ def _integration_status(
270
+ *,
271
+ branch_merged: bool | None,
272
+ has_uncommitted_changes: bool | None,
273
+ ) -> str | None:
274
+ if branch_merged is None or has_uncommitted_changes is None:
275
+ return "unknown"
276
+ if branch_merged:
277
+ return "branch-merged-worktree-dirty" if has_uncommitted_changes else "fully-integrated"
278
+ return "branch-unmerged-worktree-dirty" if has_uncommitted_changes else "branch-unmerged"
279
+
280
+
281
+ def integration_fields(
282
+ *,
283
+ branch_merged: bool | None,
284
+ has_uncommitted_changes: bool | None,
285
+ ) -> JsonObject:
286
+ fully_integrated = (
287
+ branch_merged is True and has_uncommitted_changes is False
288
+ if branch_merged is not None and has_uncommitted_changes is not None
289
+ else None
290
+ )
291
+ uncommitted_changes_integrated = (
292
+ None if has_uncommitted_changes is None else not has_uncommitted_changes
293
+ )
294
+ return {
295
+ "branchMergedIntoSource": branch_merged,
296
+ "hasUncommittedChanges": has_uncommitted_changes,
297
+ # Backward compatibility: v1 exposed mergedIntoSource as the branch
298
+ # ancestry check. Keep that meaning and expose the aggregate state under
299
+ # a new field instead of silently repurposing the old one.
300
+ "mergedIntoSource": branch_merged,
301
+ "fullyIntegrated": fully_integrated,
302
+ "integrationStatus": _integration_status(
303
+ branch_merged=branch_merged,
304
+ has_uncommitted_changes=has_uncommitted_changes,
305
+ ),
306
+ "uncommittedChangesIntegrated": uncommitted_changes_integrated,
307
+ }
308
+
309
+
310
+ def _suppress_merge_suggestions(
311
+ *,
312
+ status: str,
313
+ ahead_behind_payload: JsonObject | None,
314
+ ) -> bool:
315
+ if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
316
+ return False
317
+ if not isinstance(ahead_behind_payload, dict):
318
+ return False
319
+ vs_current = ahead_behind_payload.get("vsCurrentHead")
320
+ if not isinstance(vs_current, dict):
321
+ return False
322
+ ahead = vs_current.get("ahead")
323
+ return ahead == 0
324
+
325
+
326
+ def suggested_commands(
327
+ record: PersistentWorktreeRecord,
328
+ status: str,
329
+ *,
330
+ ahead_behind_payload: JsonObject | None = None,
331
+ ) -> JsonObject:
332
+ alias = record.get("alias") or record.get("runId") or "<handle>"
333
+ execution_cwd = record.get("executionCwd")
334
+ source_git_root = record.get("sourceGitRoot")
335
+ branch = record.get("branch")
336
+ creation = record.get("creationContext")
337
+ base = creation.get("sourceHeadOid") if isinstance(creation, dict) else None
338
+ review_diff = None
339
+ review_diff_base = None
340
+ merge = None
341
+ cherry = None
342
+ if isinstance(execution_cwd, str) and status in (STATUS_PRESENT, STATUS_UNKNOWN):
343
+ review_diff = _shell(["git", "-C", execution_cwd, "diff", "--stat", "HEAD"])
344
+ if isinstance(base, str) and base:
345
+ review_diff_base = _shell(["git", "-C", execution_cwd, "diff", "--stat", base])
346
+ suppress_merge = _suppress_merge_suggestions(
347
+ status=status,
348
+ ahead_behind_payload=ahead_behind_payload,
349
+ )
350
+ if not suppress_merge and isinstance(source_git_root, str) and isinstance(branch, str):
351
+ merge = _shell(["git", "-C", source_git_root, "merge", "--no-ff", branch])
352
+ if isinstance(base, str) and base:
353
+ cherry = _shell(["git", "-C", source_git_root, "cherry-pick", f"{base}..{branch}"])
354
+ return {
355
+ "reviewDiff": review_diff,
356
+ "reviewDiffVsCreationBase": review_diff_base,
357
+ "mergeIntoSource": merge,
358
+ "cherryPickRange": cherry,
359
+ "safeRemove": f"delegate worktree remove {alias}",
360
+ "discardAndRemove": f"delegate worktree remove {alias} --discard-uncommitted",
361
+ }
362
+
363
+
364
+ def decorate_record(
365
+ record: PersistentWorktreeRecord,
366
+ *,
367
+ include_detached: bool = False,
368
+ include_work_summary: bool = False,
369
+ include_porcelain_cache: bool = False,
370
+ ) -> JsonObject:
371
+ status, warnings = detect_worktree_status(record)
372
+ dirty, dirty_paths, dirty_total, dirty_warnings = dirty_info(record, status)
373
+ branch_merged, merged_warnings = merged_into_source(
374
+ record,
375
+ status,
376
+ include_detached=include_detached,
377
+ )
378
+ output: JsonObject = {
379
+ "alias": record.get("alias"),
380
+ "runId": record.get("runId"),
381
+ "harness": record.get("harness"),
382
+ "group": record.get("group"),
383
+ "branch": record.get("branch"),
384
+ "executionCwd": record.get("executionCwd"),
385
+ "sourceGitRoot": record.get("sourceGitRoot"),
386
+ "createdAt": record.get("createdAt"),
387
+ "lastActivityAt": record.get("lastActivityAt"),
388
+ "computedAt": _utc_now_iso(),
389
+ "registryWorktreeStatus": record.get("registryWorktreeStatus"),
390
+ "worktreeStatus": status,
391
+ "dirty": dirty,
392
+ **integration_fields(
393
+ branch_merged=branch_merged,
394
+ has_uncommitted_changes=dirty,
395
+ ),
396
+ }
397
+ if record.get("resolutionKind") in {"latest", "latest_model"}:
398
+ output["requestedHandle"] = record.get("requestedHandle")
399
+ output["resolvedHandle"] = record.get("resolvedHandle")
400
+ output["resolutionKind"] = record.get("resolutionKind")
401
+ registry_status = record.get("registryWorktreeStatus")
402
+ if isinstance(registry_status, str) and registry_status != status:
403
+ output["registryStatusDiffers"] = True
404
+ all_warnings = [*warnings, *dirty_warnings, *merged_warnings]
405
+ if all_warnings:
406
+ output["warnings"] = all_warnings
407
+ if dirty_paths:
408
+ output["dirtyPaths"] = dirty_paths[:MAX_DIRTY_PATHS_REPORTED]
409
+ output["dirtyPathsTotal"] = dirty_total
410
+ if include_porcelain_cache:
411
+ output["_porcelainStatusLines"] = dirty_paths if dirty is not None else None
412
+ output["_porcelainStatusTotalLines"] = dirty_total
413
+ output["_porcelainStatusWarnings"] = dirty_warnings
414
+ if include_work_summary and status in (STATUS_PRESENT, STATUS_UNKNOWN):
415
+ prefetched_changed_files = (
416
+ worktree_summary.changed_files_from_porcelain_lines(
417
+ dirty_paths,
418
+ dirty_total if isinstance(dirty_total, int) else len(dirty_paths),
419
+ )
420
+ if dirty is not None
421
+ else None
422
+ )
423
+ summary = worktree_summary.build_work_summary(
424
+ source_git_root=record.get("sourceGitRoot")
425
+ if isinstance(record.get("sourceGitRoot"), str)
426
+ else None,
427
+ execution_cwd=record.get("executionCwd")
428
+ if isinstance(record.get("executionCwd"), str)
429
+ else "",
430
+ branch=record.get("branch") if isinstance(record.get("branch"), str) else None,
431
+ creation_context=record.get("creationContext")
432
+ if isinstance(record.get("creationContext"), dict)
433
+ else None,
434
+ prefetched_changed_files=prefetched_changed_files,
435
+ )
436
+ if summary is not None:
437
+ output["workSummary"] = summary
438
+ return output
439
+
440
+
441
+ def list_worktrees(
442
+ registry_root: Path,
443
+ *,
444
+ harness: str | None = None,
445
+ group: str | None = None,
446
+ status: str | None = None,
447
+ limit: int = run_registry.DEFAULT_RUNS_LIMIT,
448
+ include_detached: bool = False,
449
+ ) -> JsonObject:
450
+ entries: list[JsonObject] = []
451
+ all_status_counts: dict[str, int] = {status_key: 0 for status_key in sorted(VALID_STATUSES)}
452
+ records = load_persistent_records(registry_root)
453
+ total_persistent = len(records)
454
+ for record in records:
455
+ if harness is not None and record.get("harness") != harness:
456
+ continue
457
+ if group is not None and record.get("group") != group:
458
+ continue
459
+ unfiltered_entry = decorate_record(record, include_detached=include_detached)
460
+ unfiltered_status = unfiltered_entry.get("worktreeStatus")
461
+ if isinstance(unfiltered_status, str):
462
+ all_status_counts[unfiltered_status] = all_status_counts.get(unfiltered_status, 0) + 1
463
+ entry = unfiltered_entry
464
+ if status is not None and entry.get("worktreeStatus") != status:
465
+ continue
466
+ entries.append(entry)
467
+ entries.sort(key=lambda item: str(item.get("lastActivityAt", "")), reverse=True)
468
+ visible_status_counts: dict[str, int] = {}
469
+ warning_count = 0
470
+ registry_drift_count = 0
471
+ for entry in entries:
472
+ entry_status = entry.get("worktreeStatus")
473
+ if isinstance(entry_status, str):
474
+ visible_status_counts[entry_status] = visible_status_counts.get(entry_status, 0) + 1
475
+ entry_warnings = entry.get("warnings")
476
+ if isinstance(entry_warnings, list):
477
+ warning_count += len(entry_warnings)
478
+ if entry.get("registryStatusDiffers") is True:
479
+ registry_drift_count += 1
480
+ return {
481
+ "schema": SCHEMA_LIST,
482
+ "ok": True,
483
+ "entries": entries[:limit],
484
+ "limit": limit,
485
+ "summary": {
486
+ # Registry-wide count, independent of --harness/--status filters.
487
+ "totalPersistentWorktrees": total_persistent,
488
+ "visible": min(len(entries), limit),
489
+ "matched": len(entries),
490
+ "statusCounts": visible_status_counts,
491
+ # Pre-status-filter counts within the --harness scope (contrast
492
+ # with statusCounts, which reflects the visible filtered entries).
493
+ "allStatusCounts": all_status_counts,
494
+ "warningCount": warning_count,
495
+ "registryStatusDriftCount": registry_drift_count,
496
+ "readOnly": True,
497
+ },
498
+ }
499
+
500
+
501
+ def _error_payload(
502
+ code: str,
503
+ message: str,
504
+ *,
505
+ record: PersistentWorktreeRecord | JsonObject | None = None,
506
+ dirty_paths: list[str] | None = None,
507
+ next_actions: list[str] | None = None,
508
+ retry_safe: bool = False,
509
+ warnings: list[str] | None = None,
510
+ suggestions: list[str] | None = None,
511
+ suggestion_scope: str | None = None,
512
+ list_command: str | None = None,
513
+ ) -> JsonObject:
514
+ payload: JsonObject = {
515
+ "ok": False,
516
+ "code": code,
517
+ "error": code,
518
+ "message": message,
519
+ "exitCode": WORKTREE_ERROR_EXIT_CODE,
520
+ "retrySafe": retry_safe,
521
+ }
522
+ if record is not None:
523
+ for key in ("alias", "runId", "branch", "executionCwd", "sourceGitRoot"):
524
+ value = record.get(key)
525
+ if isinstance(value, str) and value:
526
+ payload[key] = value
527
+ if dirty_paths is not None:
528
+ capped = dirty_paths[:MAX_DIRTY_PATHS_REPORTED]
529
+ if len(dirty_paths) > MAX_DIRTY_PATHS_REPORTED:
530
+ capped.append("...")
531
+ payload["dirtyPathsTotal"] = len(dirty_paths)
532
+ payload["dirtyPaths"] = capped
533
+ if next_actions:
534
+ payload["nextActions"] = next_actions
535
+ if warnings is not None:
536
+ payload["warnings"] = warnings
537
+ if suggestions is not None:
538
+ payload["suggestions"] = suggestions
539
+ if suggestion_scope is not None:
540
+ payload["suggestionScope"] = suggestion_scope
541
+ if list_command is not None:
542
+ payload["listCommand"] = list_command
543
+ return payload
544
+
545
+
546
+ def suggest_worktree_handles(
547
+ registry_root: Path,
548
+ handle: str,
549
+ *,
550
+ limit: int = 8,
551
+ ) -> list[str]:
552
+ records = load_persistent_records(registry_root)
553
+ scoped_index: JsonObject = {"aliases": {}, "runs": {}}
554
+ aliases = scoped_index["aliases"]
555
+ runs = scoped_index["runs"]
556
+ if not isinstance(aliases, dict) or not isinstance(runs, dict):
557
+ return []
558
+ for record in records:
559
+ alias = record.get("alias")
560
+ run_id = record.get("runId")
561
+ if not isinstance(alias, str) or not alias:
562
+ continue
563
+ if not isinstance(run_id, str) or not run_id:
564
+ continue
565
+ aliases[alias] = run_id
566
+ runs[run_id] = {"alias": alias}
567
+ return run_registry.suggest_handles(scoped_index, handle, limit=limit)
568
+
569
+
570
+ def resolve_record(
571
+ registry_root: Path,
572
+ *,
573
+ handle: str | None,
574
+ latest_harness: str | None = None,
575
+ ) -> PersistentWorktreeRecord:
576
+ index = run_registry.load_index(registry_root)
577
+ if latest_harness is not None:
578
+ latest_record = latest_persistent_record_for_harness(registry_root, latest_harness)
579
+ if latest_record is None:
580
+ raise WorktreeManagementError(
581
+ _error_payload(
582
+ "unknown_handle",
583
+ f"No persistent worktree runs found for harness: {latest_harness}",
584
+ next_actions=["delegate worktree list"],
585
+ )
586
+ )
587
+ latest_record.update(
588
+ {
589
+ "requestedHandle": latest_harness,
590
+ "resolvedHandle": latest_record.get("alias") or latest_record.get("runId"),
591
+ "resolutionKind": "latest",
592
+ }
593
+ )
594
+ return latest_record
595
+ if handle is None:
596
+ raise WorktreeManagementError(
597
+ _error_payload(
598
+ "missing_handle",
599
+ "A worktree handle is required.",
600
+ next_actions=["delegate worktree list"],
601
+ )
602
+ )
603
+ if handle in run_registry.HARNESS_NAMES:
604
+ latest_record = latest_persistent_record_for_harness(registry_root, handle)
605
+ if latest_record is None:
606
+ raise WorktreeManagementError(
607
+ _error_payload(
608
+ "unknown_handle",
609
+ f"No persistent worktree runs found for harness: {handle}",
610
+ next_actions=["delegate worktree list"],
611
+ )
612
+ )
613
+ latest_record.update(
614
+ {
615
+ "requestedHandle": handle,
616
+ "resolvedHandle": latest_record.get("alias") or latest_record.get("runId"),
617
+ "resolutionKind": "latest",
618
+ }
619
+ )
620
+ return latest_record
621
+ resolved = run_registry.resolve_handle(index, handle)
622
+ if resolved.run_id is None:
623
+ suggestions = suggest_worktree_handles(registry_root, handle)
624
+ next_actions = (
625
+ [f"delegate worktree show {suggestions[0]}"]
626
+ if suggestions
627
+ else ["delegate worktree list"]
628
+ )
629
+ raise WorktreeManagementError(
630
+ _error_payload(
631
+ "unknown_handle",
632
+ (
633
+ f"Unknown run handle: {handle}. Suggestions: "
634
+ f"{', '.join(suggestions) if suggestions else '(none)'}"
635
+ ),
636
+ next_actions=next_actions,
637
+ suggestions=suggestions,
638
+ suggestion_scope="worktrees",
639
+ list_command="delegate worktree list",
640
+ )
641
+ )
642
+ run_id = resolved.run_id
643
+ index_entry = index.get("runs", {}).get(run_id)
644
+ record = _record_for_run(
645
+ registry_root,
646
+ run_id,
647
+ index_entry if isinstance(index_entry, dict) else {},
648
+ )
649
+ if record is None:
650
+ alias = run_registry.alias_for_run(index, run_id)
651
+ raise WorktreeManagementError(
652
+ _error_payload(
653
+ "not_worktree_run",
654
+ f"Run is not a persistent worktree run: {alias or run_id}",
655
+ record={"alias": alias, "runId": run_id},
656
+ next_actions=[f"delegate snapshot {alias or run_id}"],
657
+ )
658
+ )
659
+ return record
660
+
661
+
662
+ def show_worktree(
663
+ registry_root: Path,
664
+ *,
665
+ handle: str | None,
666
+ latest_harness: str | None = None,
667
+ include_detached: bool = False,
668
+ ) -> JsonObject:
669
+ record = resolve_record(registry_root, handle=handle, latest_harness=latest_harness)
670
+ entry = decorate_record(
671
+ record,
672
+ include_detached=include_detached,
673
+ include_work_summary=True,
674
+ include_porcelain_cache=True,
675
+ )
676
+ execution_cwd = entry.get("executionCwd")
677
+ cached_porcelain = entry.pop("_porcelainStatusLines", None)
678
+ cached_porcelain_total = entry.pop("_porcelainStatusTotalLines", None)
679
+ cached_porcelain_warnings = entry.pop("_porcelainStatusWarnings", [])
680
+ porcelain: list[str] | None = None
681
+ porcelain_total = 0
682
+ porcelain_truncated = False
683
+ if entry.get("worktreeStatus") in (STATUS_PRESENT, STATUS_UNKNOWN) and isinstance(
684
+ execution_cwd, str
685
+ ):
686
+ if isinstance(cached_porcelain, list):
687
+ porcelain = cached_porcelain[:50]
688
+ porcelain_total = (
689
+ cached_porcelain_total
690
+ if isinstance(cached_porcelain_total, int)
691
+ else len(cached_porcelain)
692
+ )
693
+ porcelain_truncated = porcelain_total > len(porcelain)
694
+ elif isinstance(cached_porcelain_warnings, list) and cached_porcelain_warnings:
695
+ entry["warnings"] = [*(entry.get("warnings") or []), *cached_porcelain_warnings]
696
+ entry["schema"] = SCHEMA_SHOW
697
+ entry["ok"] = True
698
+ entry["creationContext"] = record.get("creationContext") or {}
699
+ entry["porcelainStatus"] = porcelain
700
+ entry["porcelainStatusTotalLines"] = porcelain_total
701
+ entry["porcelainStatusTruncated"] = porcelain_truncated
702
+ entry["aheadBehind"] = ahead_behind(record, str(entry.get("worktreeStatus")))
703
+ entry["suggestedCommands"] = suggested_commands(
704
+ record,
705
+ str(entry.get("worktreeStatus")),
706
+ ahead_behind_payload=entry.get("aheadBehind")
707
+ if isinstance(entry.get("aheadBehind"), dict)
708
+ else None,
709
+ )
710
+ source_git_root = record.get("sourceGitRoot")
711
+ entry["currentSourceHeadRef"] = (
712
+ _symbolic_ref(source_git_root, "HEAD") if isinstance(source_git_root, str) else None
713
+ )
714
+ creation = record.get("creationContext")
715
+ if isinstance(creation, dict) and creation.get("sourceHeadRef") is None:
716
+ warnings = list(entry.get("warnings") or [])
717
+ warning = "source was detached at creation; integration target unknown"
718
+ if warning not in warnings:
719
+ warnings.append(warning)
720
+ entry["warnings"] = warnings
721
+ return entry
722
+
723
+
724
+ def _worktree_list_paths_with_warning(source_git_root: str) -> tuple[set[str] | None, str | None]:
725
+ result = _run_git(
726
+ source_git_root,
727
+ ["worktree", "list", "--porcelain"],
728
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
729
+ )
730
+ if result.returncode != 0:
731
+ detail = result.stderr.strip() or f"git worktree list failed with exit {result.returncode}"
732
+ return None, detail
733
+ paths: set[str] = set()
734
+ for line in result.stdout.splitlines():
735
+ if line.startswith("worktree "):
736
+ path = line[len("worktree ") :]
737
+ paths.add(path)
738
+ with suppress(OSError):
739
+ paths.add(str(Path(path).resolve()))
740
+ return paths, None
741
+
742
+
743
+ # Re-export the removal pipeline so ``worktree_mgmt.<name>`` keeps resolving and
744
+ # the seam patches that target this module continue to reach the moved
745
+ # functions (which read their cross-module seams back through this module).
746
+ # Re-export the prune/gc pipelines for the same reason.
747
+ from delegate_agent.worktree_gc import ( # noqa: E402, F401 # re-exported
748
+ GcFreshAction,
749
+ _entry_ref,
750
+ _gc_missing_entry,
751
+ _gc_orphan_entry,
752
+ _gc_reconcile_list_failure,
753
+ _gc_reconcile_missing_branch,
754
+ _gc_reconcile_missing_metadata,
755
+ _gc_reconcile_missing_path,
756
+ _older_than,
757
+ _reload_gc_candidate,
758
+ _with_locked_fresh_gc_candidate,
759
+ gc_worktrees,
760
+ maybe_auto_prune,
761
+ prune_worktrees,
762
+ )
763
+ from delegate_agent.worktree_remove import ( # noqa: E402, F401 # re-exported
764
+ BranchRemovalResult,
765
+ RemoveWorktreeOptions,
766
+ RemoveWorktreePlan,
767
+ _apply_branch_removal_result,
768
+ _build_remove_worktree_plan,
769
+ _mark_worktree_removed,
770
+ _normalize_remove_options,
771
+ _raise_if_dirty_without_discard,
772
+ _raise_if_unmerged_without_override,
773
+ _remove_already_removed,
774
+ _remove_branch,
775
+ _remove_branch_if_requested,
776
+ _remove_missing_worktree_path,
777
+ _remove_payload,
778
+ _remove_present_worktree_path,
779
+ _remove_worktree_path,
780
+ _require_removal_metadata,
781
+ remove_worktree,
782
+ )