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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- 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)
|