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,573 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from typing import TextIO
6
+
7
+ from delegate_agent import run_registry
8
+ from delegate_agent.json_types import JsonObject
9
+ from delegate_agent.run_registry import parse_utc_timestamp as parse_timestamp
10
+ from delegate_agent.snapshot_view import SnapshotView
11
+
12
+
13
+ def format_age(started_at: str | None, *, now: datetime | None = None) -> str:
14
+ start = parse_timestamp(started_at)
15
+ if start is None:
16
+ return "unknown"
17
+ moment = now or datetime.now(UTC)
18
+ delta = moment - start
19
+ total_seconds = max(int(delta.total_seconds()), 0)
20
+ minutes, seconds = divmod(total_seconds, 60)
21
+ hours, minutes = divmod(minutes, 60)
22
+ days, hours = divmod(hours, 24)
23
+ if days:
24
+ return f"{days}d{hours}h"
25
+ if hours:
26
+ return f"{hours}h{minutes}m"
27
+ if minutes:
28
+ return f"{minutes}m{seconds}s"
29
+ return f"{seconds}s"
30
+
31
+
32
+ SnapshotTextField = tuple[str, str]
33
+
34
+
35
+ SNAPSHOT_CONTEXT_FIELDS: tuple[SnapshotTextField, ...] = (
36
+ ("cwd", "cwd"),
37
+ ("executionCwd", "execution cwd"),
38
+ ("model", "model"),
39
+ ("mode", "mode"),
40
+ )
41
+
42
+
43
+ def _render_snapshot_string_fields(
44
+ view: SnapshotView,
45
+ fields: tuple[SnapshotTextField, ...],
46
+ stdout: TextIO,
47
+ ) -> None:
48
+ for key, label in fields:
49
+ value = view.get(key)
50
+ if isinstance(value, str) and value:
51
+ print(f"{label}: {value}", file=stdout)
52
+
53
+
54
+ def _render_snapshot_header(view: SnapshotView, stdout: TextIO) -> None:
55
+ alias = view.get("alias") or view.get("runId") or "?"
56
+ status = view.get("status") or "unknown"
57
+ started_at = view.get("startedAt")
58
+ age = format_age(started_at if isinstance(started_at, str) else None)
59
+ print(f"{alias} · {status} · {age} elapsed", file=stdout)
60
+
61
+
62
+ def _render_snapshot_status_detail(view: SnapshotView, stdout: TextIO) -> None:
63
+ raw_status = view.get("rawStatus")
64
+ effective_status = view.get("effectiveStatus")
65
+ stale_reason = view.get("staleReason")
66
+ if (
67
+ raw_status != effective_status
68
+ and isinstance(raw_status, str)
69
+ and isinstance(effective_status, str)
70
+ ):
71
+ print(f"status detail: raw={raw_status} effective={effective_status}", file=stdout)
72
+ if isinstance(stale_reason, str) and stale_reason:
73
+ print(f"stale reason: {stale_reason}", file=stdout)
74
+ requested = view.get("requestedHandle")
75
+ resolved = view.get("resolvedHandle")
76
+ kind = view.get("resolutionKind")
77
+ if isinstance(requested, str) and isinstance(resolved, str) and isinstance(kind, str):
78
+ print(f"resolved handle: {requested} -> {resolved} ({kind})", file=stdout)
79
+
80
+
81
+ def _render_snapshot_isolation(view: SnapshotView, stdout: TextIO) -> None:
82
+ isolation_lifecycle = view.get("isolationLifecycle")
83
+ if isolation_lifecycle == "persistent":
84
+ print("isolation: worktree persistent", file=stdout)
85
+ elif isolation_lifecycle == "temporary":
86
+ print("isolation: worktree temporary", file=stdout)
87
+ elif isolation_lifecycle:
88
+ print(f"isolation: {isolation_lifecycle}", file=stdout)
89
+ branch = view.get("branch")
90
+ if isinstance(branch, str) and branch:
91
+ print(f"branch: {branch}", file=stdout)
92
+ source_git_root = view.get("sourceGitRoot")
93
+ if isinstance(source_git_root, str) and source_git_root:
94
+ print(f"source git root: {source_git_root}", file=stdout)
95
+ worktree_status = view.get("worktreeStatus")
96
+ if isinstance(worktree_status, str):
97
+ print(f"worktree status: {worktree_status}", file=stdout)
98
+ safe_method = view.get("safeWorkspaceMethod")
99
+ if isinstance(safe_method, str) and safe_method:
100
+ print(f"safe workspace method: {safe_method}", file=stdout)
101
+
102
+
103
+ def _render_snapshot_reasoning(view: SnapshotView, stdout: TextIO) -> None:
104
+ resolved_reasoning = view.get("resolvedReasoningEffort")
105
+ if isinstance(resolved_reasoning, str) and resolved_reasoning:
106
+ transport = view.get("reasoningTransport")
107
+ source = view.get("reasoningCapabilitySource")
108
+ detail = []
109
+ if isinstance(transport, str) and transport:
110
+ detail.append(transport)
111
+ if isinstance(source, str) and source:
112
+ detail.append(f"capability={source}")
113
+ suffix = f" ({', '.join(detail)})" if detail else ""
114
+ print(f"reasoning effort: {resolved_reasoning}{suffix}", file=stdout)
115
+
116
+
117
+ def _render_snapshot_current(view: SnapshotView, stdout: TextIO) -> None:
118
+ current = view.get("current")
119
+ if isinstance(current, str) and current:
120
+ print(f"current: {current}", file=stdout)
121
+
122
+
123
+ def _render_snapshot_cleanup(view: SnapshotView, stdout: TextIO) -> None:
124
+ cleanup = view.get("worktreeCleanupCommands")
125
+ if isinstance(cleanup, dict):
126
+ render_worktree_cleanup_commands(cleanup, stdout)
127
+
128
+
129
+ def _render_snapshot_assistant_text(view: SnapshotView, stdout: TextIO) -> None:
130
+ assistant_text = view.get("assistantText")
131
+ if isinstance(assistant_text, str) and assistant_text:
132
+ print("assistant text:", file=stdout)
133
+ print(assistant_text, file=stdout)
134
+
135
+
136
+ def _render_snapshot_recent_events(view: SnapshotView, stdout: TextIO) -> None:
137
+ recent_events = view.get("recentEvents")
138
+ if isinstance(recent_events, list) and recent_events:
139
+ print("recent:", file=stdout)
140
+ for event in recent_events[-20:]:
141
+ if not isinstance(event, dict):
142
+ continue
143
+ kind = event.get("kind", "event")
144
+ tool = event.get("tool")
145
+ path = event.get("path") or event.get("target")
146
+ if tool and path:
147
+ print(f" - {kind}: {tool} {path}", file=stdout)
148
+ elif tool:
149
+ print(f" - {kind}: {tool}", file=stdout)
150
+ else:
151
+ print(f" - {kind}", file=stdout)
152
+
153
+
154
+ def _render_snapshot_string_list(
155
+ view: SnapshotView,
156
+ key: str,
157
+ heading: str,
158
+ stdout: TextIO,
159
+ ) -> None:
160
+ values = view.get(key)
161
+ if isinstance(values, list) and values:
162
+ print(f"{heading}:", file=stdout)
163
+ for value in values:
164
+ if isinstance(value, str):
165
+ print(f" - {value}", file=stdout)
166
+
167
+
168
+ def _render_snapshot_completion(view: SnapshotView, stdout: TextIO) -> None:
169
+ completion = view.get("completionReport")
170
+ if isinstance(completion, dict):
171
+ command = completion.get("command")
172
+ if isinstance(command, str):
173
+ print(f"completion report: {command}", file=stdout)
174
+
175
+
176
+ def render_snapshot_text(view: SnapshotView, stdout: TextIO) -> None:
177
+ _render_snapshot_header(view, stdout)
178
+ _render_snapshot_status_detail(view, stdout)
179
+ _render_snapshot_string_fields(view, SNAPSHOT_CONTEXT_FIELDS, stdout)
180
+ _render_snapshot_isolation(view, stdout)
181
+ _render_snapshot_reasoning(view, stdout)
182
+ _render_snapshot_current(view, stdout)
183
+ _render_snapshot_cleanup(view, stdout)
184
+ _render_snapshot_assistant_text(view, stdout)
185
+ _render_snapshot_recent_events(view, stdout)
186
+ _render_snapshot_string_list(view, "warnings", "warnings", stdout)
187
+ _render_snapshot_string_list(view, "nextActions", "next actions", stdout)
188
+ _render_snapshot_completion(view, stdout)
189
+
190
+
191
+ def runs_json_payload(
192
+ summaries: list[JsonObject],
193
+ *,
194
+ limit: int,
195
+ mode: str,
196
+ ) -> JsonObject:
197
+ return {
198
+ "schema": run_registry.RUNS_SCHEMA,
199
+ "ok": True,
200
+ "mode": mode,
201
+ "limit": limit,
202
+ "runs": summaries,
203
+ }
204
+
205
+
206
+ def render_runs_text(summaries: list[JsonObject], stdout: TextIO, *, mode: str) -> None:
207
+ print(f"mode: {mode}", file=stdout)
208
+ show_group = any(isinstance(summary.get("group"), str) for summary in summaries)
209
+ if show_group:
210
+ print(
211
+ "alias status harness age iso group current", file=stdout
212
+ )
213
+ else:
214
+ print("alias status harness age iso current", file=stdout)
215
+ for summary in summaries:
216
+ alias = summary.get("alias") or summary.get("runId") or "?"
217
+ status = summary.get("status", "unknown")
218
+ harness = summary.get("harness", "?")
219
+ activity = summary.get("activityAt")
220
+ age = format_age(activity if isinstance(activity, str) else None)
221
+ isolation = summary.get("isolationLifecycle", "")
222
+ if isolation == "persistent":
223
+ iso_label = "persistent"
224
+ elif isolation == "temporary":
225
+ iso_label = "temporary"
226
+ else:
227
+ iso_label = ""
228
+ current = summary.get("current", "")
229
+ if isinstance(current, str) and len(current) > 40:
230
+ current = current[:37] + "..."
231
+ if show_group:
232
+ group = summary.get("group") if isinstance(summary.get("group"), str) else ""
233
+ print(
234
+ f"{alias:<10} {status:<9} {harness:<8} {age:<8} {iso_label:<11} {group:<11} {current}",
235
+ file=stdout,
236
+ )
237
+ else:
238
+ print(
239
+ f"{alias:<10} {status:<9} {harness:<8} {age:<8} {iso_label:<11} {current}",
240
+ file=stdout,
241
+ )
242
+
243
+
244
+ def run_output_json_payload(
245
+ *,
246
+ alias: str | None,
247
+ run_id: str,
248
+ sections: JsonObject,
249
+ resolution: JsonObject | None = None,
250
+ warnings: list[str] | None = None,
251
+ ) -> JsonObject:
252
+ payload: JsonObject = {
253
+ "schema": run_registry.RUN_OUTPUT_SCHEMA,
254
+ "ok": True,
255
+ "runId": run_id,
256
+ "sections": sections,
257
+ }
258
+ if alias:
259
+ payload["alias"] = alias
260
+ if resolution:
261
+ payload.update(resolution)
262
+ if warnings:
263
+ payload["warnings"] = warnings
264
+ return payload
265
+
266
+
267
+ def render_worktree_cleanup_commands(cleanup: JsonObject, stdout: TextIO) -> None:
268
+ safe_cmd = cleanup.get("safe")
269
+ force_branch = cleanup.get("forceBranch")
270
+ discard = cleanup.get("discardUncommitted")
271
+ force = cleanup.get("force")
272
+ raw_git = cleanup.get("rawGit")
273
+ if safe_cmd:
274
+ print(f"cleanup (refuses dirty / unmerged): {safe_cmd}", file=stdout)
275
+ if force_branch:
276
+ print(f"cleanup (allow unmerged branch deletion): {force_branch}", file=stdout)
277
+ if discard:
278
+ print(f"cleanup (DISCARD uncommitted edits): {discard}", file=stdout)
279
+ if force:
280
+ print(f"cleanup (DISCARD edits + delete branch): {force}", file=stdout)
281
+ if raw_git:
282
+ print(f"raw git equivalent: {raw_git}", file=stdout)
283
+
284
+
285
+ def _run_output_section_header(name: str, meta: JsonObject) -> str:
286
+ # Text mode needs in-band cues for anything JSON mode flags in section
287
+ # metadata: a recovered (synthetic) completion report is not a child-written
288
+ # report, and a tailed log is not the whole log.
289
+ details: list[str] = []
290
+ if meta.get("synthetic") is True:
291
+ details.append("synthetic: recovered from stdout.log tail")
292
+ tail_lines = meta.get("tailLines")
293
+ if meta.get("truncated") is True and isinstance(tail_lines, int):
294
+ details.append(f"last {tail_lines} lines; full log {meta.get('bytes', '?')} bytes")
295
+ if meta.get("charTruncated") is True:
296
+ returned = meta.get("returnedChars", "?")
297
+ omitted = meta.get("omittedChars", "?")
298
+ details.append(f"last {returned} chars; {omitted} omitted")
299
+ suffix = f" ({'; '.join(details)})" if details else ""
300
+ return f"{name}{suffix}"
301
+
302
+
303
+ def render_run_output_text(
304
+ sections: dict[str, str],
305
+ stdout: TextIO,
306
+ *,
307
+ section_meta: JsonObject | None = None,
308
+ resolution: JsonObject | None = None,
309
+ warnings: list[str] | None = None,
310
+ ) -> None:
311
+ if resolution:
312
+ requested = resolution.get("requestedHandle")
313
+ resolved = resolution.get("resolvedHandle")
314
+ kind = resolution.get("resolutionKind")
315
+ if isinstance(requested, str) and isinstance(resolved, str) and isinstance(kind, str):
316
+ print(f"resolved handle: {requested} -> {resolved} ({kind})", file=stdout)
317
+ if warnings:
318
+ print("warnings:", file=stdout)
319
+ for warning in warnings:
320
+ print(f" - {warning}", file=stdout)
321
+ for name in ("completionReport", "stdout", "stderr", "diagnostics"):
322
+ content = sections.get(name)
323
+ if not content:
324
+ continue
325
+ meta = (section_meta or {}).get(name)
326
+ header = _run_output_section_header(name, meta if isinstance(meta, dict) else {})
327
+ print(f"=== {header} ===", file=stdout)
328
+ print(content, end="" if content.endswith("\n") else "\n", file=stdout)
329
+
330
+
331
+ def _render_tri_state_flag(label: str, value: object, stdout: TextIO) -> None:
332
+ if value is True:
333
+ print(f"{label}: yes", file=stdout)
334
+ elif value is False:
335
+ print(f"{label}: no", file=stdout)
336
+ else:
337
+ print(f"{label}: unknown", file=stdout)
338
+
339
+
340
+ def render_worktree_list_text(payload: JsonObject, stdout: TextIO) -> None:
341
+ entries = payload.get("entries")
342
+ print(
343
+ "alias status harness age branch dirty branch-merged integrated",
344
+ file=stdout,
345
+ )
346
+ if isinstance(entries, list):
347
+ for entry in entries:
348
+ if not isinstance(entry, dict):
349
+ continue
350
+ alias = entry.get("alias") or entry.get("runId") or "?"
351
+ status = entry.get("worktreeStatus") or "unknown"
352
+ harness = entry.get("harness") or "?"
353
+ age = format_age(
354
+ entry.get("lastActivityAt")
355
+ if isinstance(entry.get("lastActivityAt"), str)
356
+ else None
357
+ )
358
+ branch = entry.get("branch") or "-"
359
+ branch_label = str(branch)
360
+ if len(branch_label) > 42:
361
+ branch_label = branch_label[:39] + "..."
362
+ dirty_value = entry.get("dirty")
363
+ dirty = "yes" if dirty_value is True else "no" if dirty_value is False else "-"
364
+ branch_merged_value = entry.get("branchMergedIntoSource")
365
+ branch_merged = (
366
+ "yes"
367
+ if branch_merged_value is True
368
+ else "no"
369
+ if branch_merged_value is False
370
+ else "-"
371
+ )
372
+ integrated_value = entry.get("fullyIntegrated")
373
+ integrated = (
374
+ "yes" if integrated_value is True else "no" if integrated_value is False else "-"
375
+ )
376
+ print(
377
+ f"{alias!s:<12} {status!s:<8} {harness!s:<8} {age:<8} {branch_label:<43} {dirty:<5} {branch_merged:<13} {integrated}",
378
+ file=stdout,
379
+ )
380
+ auto_prune = payload.get("autoPrune")
381
+ if isinstance(auto_prune, dict):
382
+ if auto_prune.get("skipped") is True:
383
+ reason = auto_prune.get("reason") or "unknown"
384
+ print(f"auto-prune: skipped ({reason})", file=stdout)
385
+ elif auto_prune.get("ok") is False:
386
+ code = auto_prune.get("code") or "failed"
387
+ errors = auto_prune.get("errors")
388
+ error_count = len(errors) if isinstance(errors, list) else 0
389
+ suffix = f", errors={error_count}" if error_count else ""
390
+ print(f"auto-prune: failed ({code}{suffix})", file=stdout)
391
+ else:
392
+ removed = auto_prune.get("removed")
393
+ skipped = auto_prune.get("skipped")
394
+ errors = auto_prune.get("errors")
395
+ removed_count = len(removed) if isinstance(removed, list) else 0
396
+ skipped_count = len(skipped) if isinstance(skipped, list) else 0
397
+ error_count = len(errors) if isinstance(errors, list) else 0
398
+ print(
399
+ f"auto-prune: removed {removed_count}, skipped {skipped_count}, errors {error_count}",
400
+ file=stdout,
401
+ )
402
+
403
+
404
+ def _short_ref(ref: str | None) -> str:
405
+ if ref is None:
406
+ return "(detached)"
407
+ if ref.startswith("refs/heads/"):
408
+ return ref[len("refs/heads/") :]
409
+ return ref
410
+
411
+
412
+ def _short_oid(oid: str | None) -> str | None:
413
+ if not isinstance(oid, str) or not oid:
414
+ return None
415
+ return oid[:7]
416
+
417
+
418
+ def render_worktree_show_text(payload: JsonObject, stdout: TextIO) -> None:
419
+ alias = payload.get("alias") or payload.get("runId") or "?"
420
+ status = payload.get("worktreeStatus", "unknown")
421
+ print(f"{alias} · {status}", file=stdout)
422
+ requested = payload.get("requestedHandle")
423
+ resolved = payload.get("resolvedHandle")
424
+ kind = payload.get("resolutionKind")
425
+ if isinstance(requested, str) and isinstance(resolved, str) and isinstance(kind, str):
426
+ print(f"resolved handle: {requested} -> {resolved} ({kind})", file=stdout)
427
+
428
+ # Creation-context line: created from <ref>@<oid>; source now at <ref>@<oid>
429
+ creation = payload.get("creationContext")
430
+ if isinstance(creation, dict):
431
+ src_ref = _short_ref(creation.get("sourceHeadRef"))
432
+ src_oid = _short_oid(creation.get("sourceHeadOid"))
433
+ if src_oid is not None:
434
+ # Current source HEAD: ref from currentSourceHeadRef (re-read by show_worktree),
435
+ # oid from vsCurrentHead.baseOid (computed by ahead_behind).
436
+ current_ref = _short_ref(payload.get("currentSourceHeadRef"))
437
+ ahead = payload.get("aheadBehind")
438
+ current_oid: str | None = None
439
+ if isinstance(ahead, dict):
440
+ vs_current = ahead.get("vsCurrentHead")
441
+ if isinstance(vs_current, dict):
442
+ current_oid = _short_oid(vs_current.get("baseOid"))
443
+ if current_oid is None:
444
+ current_oid = "(unknown)"
445
+ print(
446
+ f"created from {src_ref}@{src_oid}; source now at {current_ref}@{current_oid}",
447
+ file=stdout,
448
+ )
449
+
450
+ # Dirty flag (tri-state: yes / no / unknown)
451
+ _render_tri_state_flag("dirty", payload.get("dirty"), stdout)
452
+
453
+ # Backward-compatible branch merge flag, followed by more explicit integration fields.
454
+ _render_tri_state_flag("merged", payload.get("mergedIntoSource"), stdout)
455
+ _render_tri_state_flag("branch merged", payload.get("branchMergedIntoSource"), stdout)
456
+ _render_tri_state_flag("fully integrated", payload.get("fullyIntegrated"), stdout)
457
+ integration_status = payload.get("integrationStatus")
458
+ if isinstance(integration_status, str) and integration_status:
459
+ print(f"integration status: {integration_status}", file=stdout)
460
+
461
+ ahead = payload.get("aheadBehind")
462
+ if isinstance(ahead, dict):
463
+ for key, label in (
464
+ ("vsCreationBase", "vs creation base"),
465
+ ("vsCurrentHead", "vs current HEAD"),
466
+ ):
467
+ pair = ahead.get(key)
468
+ if isinstance(pair, dict):
469
+ print(
470
+ f"{label}: ahead {pair.get('ahead')} / behind {pair.get('behind')} ({pair.get('baseOid')})",
471
+ file=stdout,
472
+ )
473
+ porcelain = payload.get("porcelainStatus")
474
+ if isinstance(porcelain, list) and porcelain:
475
+ print("status:", file=stdout)
476
+ for line in porcelain:
477
+ print(f" {line}", file=stdout)
478
+ if payload.get("porcelainStatusTruncated"):
479
+ print(" ...", file=stdout)
480
+ elif isinstance(porcelain, list):
481
+ # Empty porcelainStatus means the worktree is clean.
482
+ print("porcelain: clean", file=stdout)
483
+ commands = payload.get("suggestedCommands")
484
+ if isinstance(commands, dict):
485
+ print("suggested commands:", file=stdout)
486
+ for key, value in commands.items():
487
+ if isinstance(value, str) and value:
488
+ print(f" {key}: {value}", file=stdout)
489
+
490
+ # Trailing metadata block (spec L621: rendered after suggested-commands).
491
+ for key, label in (
492
+ ("executionCwd", "execution"),
493
+ ("sourceGitRoot", "source"),
494
+ ("branch", "branch"),
495
+ ("harness", "harness"),
496
+ ):
497
+ value = payload.get(key)
498
+ if isinstance(value, str) and value:
499
+ print(f"{label}: {value}", file=stdout)
500
+
501
+ warnings = payload.get("warnings")
502
+ if isinstance(warnings, list) and warnings:
503
+ print("warnings:", file=stdout)
504
+ for warning in warnings:
505
+ print(f" - {warning}", file=stdout)
506
+
507
+
508
+ def render_worktree_remove_text(payload: JsonObject, stdout: TextIO) -> None:
509
+ if isinstance(payload.get("removed"), list):
510
+ print(
511
+ f"group: {payload.get('group')} matched={payload.get('matched')} removed={len(payload.get('removed') or [])} errors={len(payload.get('errors') or [])}",
512
+ file=stdout,
513
+ )
514
+ return
515
+ alias = payload.get("alias") or payload.get("runId") or "?"
516
+ print(
517
+ f"{alias}: removed={payload.get('removed')} pathRemoved={payload.get('pathRemoved')} branchRemoved={payload.get('branchRemoved')}",
518
+ file=stdout,
519
+ )
520
+ if payload.get("ok") is False or payload.get("branchRemovalError"):
521
+ error = payload.get("branchRemovalError") or payload.get("message") or payload.get("code")
522
+ if error:
523
+ print(f"error: {error}", file=stdout)
524
+ if payload.get("branchKept"):
525
+ print(f"branch kept: {payload['branchKept']}", file=stdout)
526
+ if payload.get("noop"):
527
+ print("noop: already removed", file=stdout)
528
+ actions = payload.get("nextActions")
529
+ if isinstance(actions, list) and actions:
530
+ print("next actions:", file=stdout)
531
+ for action in actions:
532
+ print(f" - {action}", file=stdout)
533
+
534
+
535
+ def render_worktree_prune_text(payload: JsonObject, stdout: TextIO) -> None:
536
+ for section in ("planned", "removed", "skipped", "errors"):
537
+ items = payload.get(section)
538
+ count = len(items) if isinstance(items, list) else 0
539
+ print(f"{section}: {count}", file=stdout)
540
+ if isinstance(items, list):
541
+ for item in items[:20]:
542
+ if isinstance(item, dict):
543
+ label = item.get("alias") or item.get("runId") or "?"
544
+ detail = (
545
+ item.get("reason") or item.get("code") or item.get("worktreeStatus") or ""
546
+ )
547
+ print(f" - {label} {detail}", file=stdout)
548
+
549
+
550
+ def render_worktree_gc_text(payload: JsonObject, stdout: TextIO) -> None:
551
+ print(f"reconciled: {payload.get('reconciled', 0)}", file=stdout)
552
+ print(f"pruned source roots: {payload.get('prunedSourceRoots', 0)}", file=stdout)
553
+ warnings = payload.get("warnings")
554
+ if isinstance(warnings, list) and warnings:
555
+ print("warnings:", file=stdout)
556
+ for warning in warnings:
557
+ if isinstance(warning, dict):
558
+ print(f" - {warning.get('sourceGitRoot')}: {warning.get('message')}", file=stdout)
559
+ else:
560
+ print(f" - {warning}", file=stdout)
561
+ orphans = payload.get("orphans")
562
+ if isinstance(orphans, list) and orphans:
563
+ print("orphans:", file=stdout)
564
+ for orphan in orphans:
565
+ if isinstance(orphan, dict):
566
+ print(
567
+ f" - {orphan.get('alias') or orphan.get('runId')} {orphan.get('reason')}",
568
+ file=stdout,
569
+ )
570
+
571
+
572
+ def print_json(payload: JsonObject, stdout: TextIO) -> None:
573
+ print(json.dumps(payload, sort_keys=True), file=stdout)