pcode 0.1.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 (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,482 @@
1
+ """Persistent isolated workers; results are integrated into their owning checkout only.
2
+
3
+ Records live in the shared Git directory. A parent lock serializes snapshots,
4
+ record transitions and merges across processes (and refuses concurrent callers).
5
+ Setup and execution deliberately belong to the caller, after create returns.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import shlex
16
+ import tempfile
17
+ import uuid
18
+ from contextlib import contextmanager
19
+ from dataclasses import asdict, dataclass
20
+ from pathlib import Path
21
+
22
+ import psutil
23
+
24
+ from pcode import worktree as wt
25
+
26
+ _STATUSES = {"running", "completed", "failed", "cancelled", "conflicted", "integrated", "discarded"}
27
+
28
+
29
+ @dataclass
30
+ class TaskRecord:
31
+ task_id: str
32
+ parent: str
33
+ parent_branch: str
34
+ base_commit: str
35
+ head_commit: str | None
36
+ branch: str
37
+ worktree: str
38
+ status: str
39
+ dirty: bool
40
+ summary: str = ""
41
+ pid: int = 0
42
+ process_started: float = 0
43
+
44
+
45
+ def _branch(path: Path) -> str:
46
+ result = wt._git(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
47
+ if result.returncode:
48
+ raise wt.WorktreeError(f"{path} has detached HEAD; check out the expected branch first")
49
+ return result.stdout.strip()
50
+
51
+
52
+ def _head(path: Path) -> str:
53
+ return wt._git(path, "rev-parse", "HEAD").stdout.strip()
54
+
55
+
56
+ def _dirty(path: Path, *, tracked: bool = False) -> bool:
57
+ args = ["status", "--porcelain", "--untracked-files=no" if tracked else "--untracked-files=all"]
58
+ return bool(wt._git(path, *args).stdout.strip())
59
+
60
+
61
+ def _idle(path: Path) -> None:
62
+ for name in (
63
+ "MERGE_HEAD",
64
+ "rebase-merge",
65
+ "rebase-apply",
66
+ "CHERRY_PICK_HEAD",
67
+ "REVERT_HEAD",
68
+ "sequencer",
69
+ ):
70
+ location = wt._git(path, "rev-parse", "--path-format=absolute", "--git-path", name)
71
+ if Path(location.stdout.strip()).exists():
72
+ raise wt.WorktreeError(f"Git operation in progress in {path}; finish or abort it first")
73
+
74
+
75
+ def _ancestor(repo: Path, older: str, newer: str) -> bool:
76
+ return wt._git(repo, "merge-base", "--is-ancestor", older, newer, check=False).returncode == 0
77
+
78
+
79
+ def _alive(record: TaskRecord) -> bool:
80
+ try:
81
+ process = psutil.Process(record.pid)
82
+ return process.create_time() == record.process_started and process.is_running()
83
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
84
+ return False
85
+
86
+
87
+ class TaskWorktrees:
88
+ def __init__(self, parent: Path):
89
+ self.parent = parent.resolve()
90
+ self.main = wt.main_checkout(self.parent)
91
+ if self.main is None:
92
+ raise wt.WorktreeError(f"{self.parent} is not a Git checkout")
93
+ # Metadata and locks also serve bare repositories and removed parents.
94
+ # Only create() requires a live checkout root.
95
+ common = wt._git(self.parent, "rev-parse", "--path-format=absolute", "--git-common-dir")
96
+ self.directory = Path(common.stdout.strip()) / "pcode-tasks"
97
+ self.directory.mkdir(exist_ok=True)
98
+
99
+ @contextmanager
100
+ def _lock(self, *, wait: bool = False):
101
+ key = hashlib.sha256(str(self.parent).encode()).hexdigest()
102
+ with (self.directory / f"parent-{key}.lock").open("a") as lock:
103
+ try:
104
+ fcntl.flock(lock, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
105
+ except BlockingIOError as error:
106
+ raise wt.WorktreeError(
107
+ f"Another task operation is using parent {self.parent}; retry when it finishes"
108
+ ) from error
109
+ try:
110
+ yield
111
+ finally:
112
+ fcntl.flock(lock, fcntl.LOCK_UN)
113
+
114
+ def _path(self, task_id: str) -> Path:
115
+ if not re.fullmatch(r"[0-9a-f]{32}", task_id):
116
+ raise wt.WorktreeError("Invalid task id; use an id from the task list")
117
+ return self.directory / f"{task_id}.json"
118
+
119
+ def _read(self, task_id: str) -> TaskRecord:
120
+ path = self._path(task_id)
121
+ try:
122
+ if path.is_symlink():
123
+ raise ValueError("record is a symlink")
124
+ record = TaskRecord(**json.loads(path.read_text()))
125
+ expected = self.main / wt.WORKTREES_DIR / f"task-{task_id}"
126
+ if (
127
+ record.task_id != task_id
128
+ or record.branch != f"task-{task_id}"
129
+ or record.worktree != str(expected.resolve())
130
+ or expected.is_symlink()
131
+ or not Path(record.parent).is_absolute()
132
+ or record.status not in _STATUSES
133
+ or not re.fullmatch(r"[0-9a-f]{40,64}", record.base_commit)
134
+ or (
135
+ record.head_commit is not None
136
+ and not re.fullmatch(r"[0-9a-f]{40,64}", record.head_commit)
137
+ )
138
+ or wt._git(
139
+ self.main, "check-ref-format", f"refs/heads/{record.parent_branch}", check=False
140
+ ).returncode
141
+ ):
142
+ raise ValueError("invalid record paths, refs or status")
143
+ return record
144
+ except (OSError, TypeError, ValueError) as error:
145
+ raise wt.WorktreeError(
146
+ f"Cannot read task {task_id}: {error}; inspect {path}"
147
+ ) from error
148
+
149
+ def _save(self, record: TaskRecord) -> None:
150
+ path = self._path(record.task_id)
151
+ fd, temporary = tempfile.mkstemp(prefix=".task-", dir=self.directory)
152
+ try:
153
+ with os.fdopen(fd, "w") as file:
154
+ json.dump(asdict(record), file, indent=2)
155
+ file.flush()
156
+ os.fsync(file.fileno())
157
+ os.replace(temporary, path)
158
+ finally:
159
+ Path(temporary).unlink(missing_ok=True)
160
+
161
+ def _owned(self, task_id: str) -> TaskRecord:
162
+ record = self._read(task_id)
163
+ if record.parent != str(self.parent):
164
+ raise wt.WorktreeError(
165
+ f"Task {task_id} belongs to parent {record.parent}, not {self.parent}"
166
+ )
167
+ if record.status == "running" and not _alive(record):
168
+ record.status = "failed"
169
+ record.summary = (
170
+ record.summary + "\nWorker interrupted: owning process exited; "
171
+ "partial work is preserved. Inspect before discarding."
172
+ ).strip()
173
+ self._observe(record)
174
+ self._save(record)
175
+ return record
176
+
177
+ def _observe(self, record: TaskRecord) -> None:
178
+ path = Path(record.worktree)
179
+ # Observe these independently: even a missing HEAD must not hide dirty files.
180
+ errors = []
181
+ for field, operation in (("head_commit", _head), ("dirty", _dirty)):
182
+ try:
183
+ setattr(record, field, operation(path))
184
+ except (wt.WorktreeError, OSError) as error:
185
+ errors.append(str(error))
186
+ try:
187
+ if _branch(path) != record.branch:
188
+ errors.append("Worker changed branch; restore " + record.branch)
189
+ except (wt.WorktreeError, OSError) as error:
190
+ errors.append(str(error))
191
+ if errors:
192
+ record.status = "failed"
193
+ record.summary += "\n" + "; ".join(errors)
194
+ record.dirty = True # unknown is never safe for automatic deletion
195
+
196
+ def create(self, *, wait: bool = False) -> TaskRecord:
197
+ with self._lock(wait=wait):
198
+ # A manager may outlive its checkout. Recheck under the same lock used
199
+ # by generic removal, before writing any record or creating a child.
200
+ if not self.parent.is_dir():
201
+ raise wt.WorktreeError(f"Task parent {self.parent} no longer exists")
202
+ root = Path(wt._git(self.parent, "rev-parse", "--show-toplevel").stdout.strip())
203
+ if root.resolve() != self.parent or wt.main_checkout(self.parent) != self.main:
204
+ raise wt.WorktreeError(f"Task parent {self.parent} is no longer its checkout root")
205
+ branch = _branch(self.parent)
206
+ _idle(self.parent)
207
+ if _dirty(self.parent, tracked=True):
208
+ raise wt.WorktreeError(
209
+ "Parent has tracked changes; commit them before starting a task"
210
+ )
211
+ base = _head(self.parent)
212
+ task_id = uuid.uuid4().hex
213
+ name = f"task-{task_id}"
214
+ record = TaskRecord(
215
+ task_id,
216
+ str(self.parent),
217
+ branch,
218
+ base,
219
+ base,
220
+ name,
221
+ str(self.main / wt.WORKTREES_DIR / name),
222
+ "running",
223
+ False,
224
+ pid=os.getpid(),
225
+ process_started=psutil.Process().create_time(),
226
+ )
227
+ self._save(record) # also protects an interrupted worktree add
228
+ try:
229
+ wt.create(self.parent, name, base=base)
230
+ except Exception as error:
231
+ record.status = "failed"
232
+ record.summary = f"Worktree creation failed: {error}; inspect before discarding"
233
+ self._save(record)
234
+ raise
235
+ return record
236
+
237
+ def finish(
238
+ self, task_id: str, status: str, summary: str = "", *, wait: bool = False
239
+ ) -> TaskRecord:
240
+ """Persist a stopped worker's outcome; the caller must first await its exit.
241
+
242
+ Use wait=True for terminal persistence under contention. Async runtimes
243
+ must run this in asyncio.to_thread and shield/join that thread on cancellation,
244
+ so neither the event loop nor the terminal record is abandoned.
245
+ """
246
+ if status not in {"completed", "failed", "cancelled"}:
247
+ raise wt.WorktreeError("Finish status must be completed, failed or cancelled")
248
+ with self._lock(wait=wait):
249
+ record = self._owned(task_id)
250
+ if record.status == "running" and record.pid != os.getpid():
251
+ raise wt.WorktreeError("Task worker belongs to another active process")
252
+ if record.status in {"integrated", "discarded", "conflicted"}:
253
+ raise wt.WorktreeError(
254
+ f"Task is {record.status}; inspect it rather than finishing again"
255
+ )
256
+ record.status, record.summary = status, summary
257
+ self._observe(record)
258
+ self._save(record)
259
+ return record
260
+
261
+ def get(self, task_id: str) -> TaskRecord:
262
+ with self._lock():
263
+ return self._owned(task_id)
264
+
265
+ def list(self) -> list[TaskRecord]:
266
+ with self._lock():
267
+ return [
268
+ self._owned(path.stem)
269
+ for path in sorted(self.directory.glob("*.json"))
270
+ if self._read(path.stem).parent == str(self.parent)
271
+ ]
272
+
273
+ def integrate(self, task_id: str) -> TaskRecord:
274
+ with self._lock():
275
+ record = self._owned(task_id)
276
+ if _branch(self.parent) != record.parent_branch:
277
+ raise wt.WorktreeError(
278
+ f"Parent changed branch; restore {record.parent_branch} first"
279
+ )
280
+ if record.status not in {"completed", "conflicted", "integrated"}:
281
+ raise wt.WorktreeError(
282
+ f"Task is {record.status}; only successful committed results integrate"
283
+ )
284
+ child = Path(record.worktree)
285
+ for path in (self.parent, child):
286
+ _idle(path)
287
+ if _dirty(path):
288
+ raise wt.WorktreeError(
289
+ f"{path} is dirty (including untracked files); clean it first"
290
+ )
291
+ if _branch(child) != record.branch:
292
+ raise wt.WorktreeError(f"Worker changed branch; restore {record.branch} first")
293
+ if _head(child) != record.head_commit:
294
+ raise wt.WorktreeError(
295
+ "Worker HEAD changed since finish; inspect and finish the task again"
296
+ )
297
+ if not record.head_commit or not _ancestor(
298
+ self.parent, record.base_commit, record.head_commit
299
+ ):
300
+ raise wt.WorktreeError(
301
+ "Task result does not descend from its recorded base; inspect its history"
302
+ )
303
+ if not _ancestor(self.parent, record.base_commit, "HEAD"):
304
+ raise wt.WorktreeError(
305
+ "Parent no longer descends from the task base; restore its history"
306
+ )
307
+ if not _ancestor(self.parent, record.head_commit, "HEAD"):
308
+ result = wt._git(
309
+ self.parent,
310
+ "merge",
311
+ "--no-squash",
312
+ "--commit",
313
+ "--no-edit",
314
+ record.head_commit,
315
+ check=False,
316
+ )
317
+ if result.returncode:
318
+ record.status = "conflicted"
319
+ self._save(record)
320
+ raise wt.WorktreeError(
321
+ f"Task integration failed in parent {self.parent}: "
322
+ f"{(result.stderr or result.stdout).strip()}. Resolve and commit there, "
323
+ "then retry integration; the child is preserved."
324
+ )
325
+ # Exit zero alone also describes a squash or an uncommitted merge.
326
+ # Never publish integration until Git is idle and HEAD contains the result.
327
+ try:
328
+ _idle(self.parent)
329
+ if _dirty(self.parent) or not _ancestor(self.parent, record.head_commit, "HEAD"):
330
+ raise wt.WorktreeError(
331
+ "Parent HEAD does not contain a clean committed task result"
332
+ )
333
+ except wt.WorktreeError as error:
334
+ record.status = "conflicted"
335
+ self._save(record)
336
+ raise wt.WorktreeError(
337
+ f"Task integration is incomplete in parent {self.parent}: {error}. "
338
+ "Finish or abort the merge there, then retry integration; "
339
+ "the child is preserved."
340
+ ) from error
341
+ record.status, record.dirty = "integrated", False
342
+ self._save(record)
343
+ return record
344
+
345
+ def discard(self, task_id: str, confirm: bool = False) -> TaskRecord:
346
+ with self._lock():
347
+ record = self._owned(task_id)
348
+ return self._discard(record, confirm)
349
+
350
+ def _discard(self, record: TaskRecord, confirm: bool) -> TaskRecord:
351
+ with parent_operation(Path(record.worktree), self.main):
352
+ if record.status == "running":
353
+ raise wt.WorktreeError(
354
+ "Task worker is active; cancel and await its completion first"
355
+ )
356
+ if record.status == "discarded":
357
+ return record
358
+ child = Path(record.worktree)
359
+ if child.exists():
360
+ if record.status == "integrated" and not confirm:
361
+ tree = wt.Worktree(child, record.branch, self.main)
362
+ if reason := keep_reason(tree):
363
+ raise wt.WorktreeError(reason)
364
+ if _branch(child) != record.branch:
365
+ raise wt.WorktreeError(
366
+ f"Worker changed branch; restore {record.branch} before discard"
367
+ )
368
+ if reason := children_reason(child):
369
+ raise wt.WorktreeError(reason)
370
+ self._observe(record)
371
+ safe = record.status == "integrated" and not record.dirty
372
+ if safe and child.exists():
373
+ safe = _head(child) == record.head_commit and _ancestor(
374
+ self.main, _head(child), f"refs/heads/{record.parent_branch}"
375
+ )
376
+ if not safe and not confirm:
377
+ raise wt.WorktreeError(
378
+ "Task has unintegrated or dirty work; pass confirm=True to discard it"
379
+ )
380
+ if child.exists():
381
+ wt._git(
382
+ self.main, "worktree", "remove", *(["--force"] if confirm else []), str(child)
383
+ )
384
+ # Git itself refuses deletion when the branch is checked out anywhere else.
385
+ exists = wt._git(
386
+ self.main,
387
+ "show-ref",
388
+ "--verify",
389
+ "--quiet",
390
+ f"refs/heads/{record.branch}",
391
+ check=False,
392
+ )
393
+ if exists.returncode == 0:
394
+ wt._git(self.main, "branch", "-D", record.branch)
395
+ record.status = "discarded"
396
+ self._save(record)
397
+ return record
398
+
399
+
400
+ @contextmanager
401
+ def parent_operation(parent: Path, repo: Path):
402
+ """Nonblocking lifecycle lock, stored in repo's shared Git directory.
403
+
404
+ Resolve storage through a surviving checkout, not the potentially removed
405
+ parent. Do not call a locking operation for the same parent inside this scope.
406
+ """
407
+ manager = TaskWorktrees(repo)
408
+ manager.parent = parent.resolve()
409
+ with manager._lock():
410
+ yield
411
+
412
+
413
+ def discard_integrated(tree: wt.Worktree, record: TaskRecord) -> None:
414
+ """Cleanup still works after the integrated task's parent checkout was removed."""
415
+ manager = TaskWorktrees(tree.main)
416
+ manager.parent = Path(record.parent)
417
+ manager.discard(record.task_id)
418
+
419
+
420
+ def records(repo: Path) -> list[TaskRecord]:
421
+ """Repository-wide metadata for generic lifecycle guards (including other parents)."""
422
+ manager = TaskWorktrees(wt.main_checkout(repo) or repo)
423
+ return [manager._read(path.stem) for path in sorted(manager.directory.glob("*.json"))]
424
+
425
+
426
+ def task_for(tree: wt.Worktree) -> TaskRecord | None:
427
+ return next(
428
+ (
429
+ record
430
+ for record in records(tree.main)
431
+ if (record.worktree == str(tree.path.resolve()) or record.branch == tree.branch)
432
+ and record.status != "discarded"
433
+ ),
434
+ None,
435
+ )
436
+
437
+
438
+ def moved_reason(tree: wt.Worktree, record: TaskRecord) -> str:
439
+ if record.worktree != str(tree.path.resolve()):
440
+ return (
441
+ f"task {record.task_id} moved to {tree.path}; restore it with "
442
+ f"git worktree move {shlex.quote(str(tree.path))} {shlex.quote(record.worktree)} "
443
+ "before task integration or removal"
444
+ )
445
+ return ""
446
+
447
+
448
+ def children_reason(parent: Path) -> str:
449
+ for record in records(parent):
450
+ if record.parent == str(parent.resolve()) and record.status not in {
451
+ "integrated",
452
+ "discarded",
453
+ }:
454
+ return (
455
+ f"owns task {record.task_id} ({record.status}), "
456
+ "awaiting integration or explicit discard"
457
+ )
458
+ return ""
459
+
460
+
461
+ def keep_reason(tree: wt.Worktree) -> str | None:
462
+ """None means an ordinary worktree; empty means a safely integrated task."""
463
+ if reason := children_reason(tree.path):
464
+ return reason
465
+ record = task_for(tree)
466
+ if record is None:
467
+ return None
468
+ if reason := moved_reason(tree, record):
469
+ return reason
470
+ if record.status != "integrated":
471
+ return f"task {record.task_id} ({record.status}), awaiting integration or explicit discard"
472
+ try:
473
+ _idle(tree.path)
474
+ if _dirty(tree.path) or _branch(tree.path) != record.branch:
475
+ return "integrated task has dirty files or a changed branch"
476
+ if _head(tree.path) != record.head_commit or not _ancestor(
477
+ tree.main, _head(tree.path), f"refs/heads/{record.parent_branch}"
478
+ ):
479
+ return "task result changed or is no longer integrated into its parent"
480
+ except wt.WorktreeError as error:
481
+ return str(error)
482
+ return ""