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,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from delegate_agent import command_errors, redaction, run_registry, snapshot_view
|
|
8
|
+
from delegate_agent import rendering as delegate_rendering
|
|
9
|
+
from delegate_agent.json_types import JsonObject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SnapshotCommand:
|
|
14
|
+
handle: str | None
|
|
15
|
+
latest_harness: str | None = None
|
|
16
|
+
no_redact: bool = False
|
|
17
|
+
json_mode: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RunsCommand:
|
|
22
|
+
active: bool = False
|
|
23
|
+
running: bool = False
|
|
24
|
+
stale: bool = False
|
|
25
|
+
harness: str | None = None
|
|
26
|
+
group: str | None = None
|
|
27
|
+
limit: int | None = None
|
|
28
|
+
json_mode: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InspectionError(command_errors.CommandError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def emit_snapshot(command: SnapshotCommand, *, workspace_path: str, stdout: TextIO) -> int:
|
|
36
|
+
workspace = Path(workspace_path)
|
|
37
|
+
registry_root = run_registry.registry_root_if_exists(workspace)
|
|
38
|
+
if registry_root is None:
|
|
39
|
+
if command.latest_harness is None and command.handle is None:
|
|
40
|
+
raise InspectionError("missing_handle", "snapshot requires a run handle or --latest.")
|
|
41
|
+
registry_root = run_registry.registry_root(workspace)
|
|
42
|
+
target = run_registry.resolve_run_target(
|
|
43
|
+
registry_root,
|
|
44
|
+
handle=command.handle,
|
|
45
|
+
latest_harness=command.latest_harness,
|
|
46
|
+
)
|
|
47
|
+
if isinstance(target, run_registry.RunTargetLookupError):
|
|
48
|
+
raise InspectionError(target.error, target.message)
|
|
49
|
+
run_id, alias = target.run_id, target.alias
|
|
50
|
+
snapshot = run_registry.load_run_snapshot(registry_root, run_id)
|
|
51
|
+
view = snapshot_view.merge_snapshot_view(
|
|
52
|
+
registry_root,
|
|
53
|
+
run_id,
|
|
54
|
+
snapshot,
|
|
55
|
+
redact=not command.no_redact,
|
|
56
|
+
)
|
|
57
|
+
if target.resolution_kind != "literal":
|
|
58
|
+
view["requestedHandle"] = target.requested_handle
|
|
59
|
+
view["resolvedHandle"] = target.resolved_handle or alias or run_id
|
|
60
|
+
view["resolutionKind"] = target.resolution_kind
|
|
61
|
+
if command.json_mode:
|
|
62
|
+
delegate_rendering.print_json(snapshot_view.snapshot_json_payload(view), stdout)
|
|
63
|
+
else:
|
|
64
|
+
delegate_rendering.render_snapshot_text(view, stdout)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def emit_runs(command: RunsCommand, *, workspace_path: str, stdout: TextIO) -> int:
|
|
69
|
+
registry_root = run_registry.registry_root_if_exists(Path(workspace_path))
|
|
70
|
+
limit = command.limit or run_registry.DEFAULT_RUNS_LIMIT
|
|
71
|
+
if command.running:
|
|
72
|
+
mode = "running"
|
|
73
|
+
status_filter = run_registry.STATUS_FILTER_RUNNING
|
|
74
|
+
elif command.stale:
|
|
75
|
+
mode = "stale"
|
|
76
|
+
status_filter = run_registry.STATUS_FILTER_STALE
|
|
77
|
+
elif command.active:
|
|
78
|
+
mode = "active"
|
|
79
|
+
status_filter = None
|
|
80
|
+
else:
|
|
81
|
+
mode = "recent"
|
|
82
|
+
status_filter = None
|
|
83
|
+
if registry_root is None:
|
|
84
|
+
summaries: list[JsonObject] = []
|
|
85
|
+
else:
|
|
86
|
+
index = run_registry.load_index(registry_root)
|
|
87
|
+
summaries = run_registry.list_run_summaries(
|
|
88
|
+
registry_root,
|
|
89
|
+
index,
|
|
90
|
+
active=command.active,
|
|
91
|
+
status_filter=status_filter,
|
|
92
|
+
harness=command.harness,
|
|
93
|
+
group=command.group,
|
|
94
|
+
limit=limit,
|
|
95
|
+
)
|
|
96
|
+
summaries = [redaction.redact_value(summary) for summary in summaries]
|
|
97
|
+
if command.json_mode:
|
|
98
|
+
delegate_rendering.print_json(
|
|
99
|
+
delegate_rendering.runs_json_payload(summaries, limit=limit, mode=mode),
|
|
100
|
+
stdout,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
delegate_rendering.render_runs_text(summaries, stdout, mode=mode)
|
|
104
|
+
return 0
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Worktree isolation planning and creation helpers.
|
|
2
|
+
|
|
3
|
+
This module owns effective-isolation resolution, source Git validation,
|
|
4
|
+
persistent worktree creation, and prompt context injection. Worktree removal
|
|
5
|
+
lives in worktree_mgmt.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
import subprocess # nosec B404 - isolation helpers intentionally run fixed git argv with shell=False.
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Re-export isolation constants from config for convenience
|
|
17
|
+
from delegate_agent.config import ( # noqa: F401 # re-exported
|
|
18
|
+
ISOLATION_AUTO,
|
|
19
|
+
ISOLATION_NONE,
|
|
20
|
+
ISOLATION_WORKTREE,
|
|
21
|
+
VALID_ISOLATION_VALUES,
|
|
22
|
+
ConfigError,
|
|
23
|
+
)
|
|
24
|
+
from delegate_agent.constants import KNOWN_ENGINES
|
|
25
|
+
from delegate_agent.git_utils import (
|
|
26
|
+
GIT_QUICK_TIMEOUT_SECONDS,
|
|
27
|
+
GIT_TIMEOUT_RETURN_CODE,
|
|
28
|
+
)
|
|
29
|
+
from delegate_agent.git_utils import (
|
|
30
|
+
run_git as _run_git,
|
|
31
|
+
)
|
|
32
|
+
from delegate_agent.json_types import JsonObject
|
|
33
|
+
from delegate_agent.prompt_instructions import SKILL_REVIEW_PREFIX
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class IsolationContext:
|
|
38
|
+
"""Resolved isolation metadata shared by dry-run output, run metadata, and launch."""
|
|
39
|
+
|
|
40
|
+
source_workspace: str
|
|
41
|
+
effective_isolation: str
|
|
42
|
+
isolation_mode: str
|
|
43
|
+
isolation_lifecycle: str
|
|
44
|
+
preserved_workspace: bool
|
|
45
|
+
planned_branch: str | None = None
|
|
46
|
+
planned_execution_cwd: str | None = None
|
|
47
|
+
source_git_root: str | None = None
|
|
48
|
+
source_git_common_dir: str | None = None
|
|
49
|
+
source_head_oid: str | None = None
|
|
50
|
+
source_head_ref: str | None = None
|
|
51
|
+
source_branch: str | None = None
|
|
52
|
+
safe_workspace_method: str | None = None
|
|
53
|
+
include_dirty: bool = False
|
|
54
|
+
warnings: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_repo_fingerprint_from_common_dir(git_common_dir: str) -> str:
|
|
58
|
+
"""Return a stable 12-char hash of the resolved Git common directory path."""
|
|
59
|
+
resolved = Path(git_common_dir).resolve(strict=True).as_posix()
|
|
60
|
+
return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:12]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _raise_if_git_timed_out(result: subprocess.CompletedProcess[str], action: str) -> None:
|
|
64
|
+
if result.returncode == GIT_TIMEOUT_RETURN_CODE:
|
|
65
|
+
raise IsolationExecutionError("git_timeout", f"{action} timed out: {result.stderr}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def short_run_id(run_id: str) -> str:
|
|
69
|
+
"""Return a run-id segment safe for branch and path names."""
|
|
70
|
+
s = run_id
|
|
71
|
+
if s.startswith("del_"):
|
|
72
|
+
s = s[4:]
|
|
73
|
+
s = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in s)
|
|
74
|
+
return s[:32]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def branch_label(engine: str, model_alias: str | None) -> str:
|
|
78
|
+
"""Return the engine/model segment used in Delegate worktree branch names."""
|
|
79
|
+
if engine == "cursor":
|
|
80
|
+
return "cursor"
|
|
81
|
+
if engine == "codex":
|
|
82
|
+
return "codex"
|
|
83
|
+
if engine == "kimi":
|
|
84
|
+
return "kimi"
|
|
85
|
+
if engine == "claude":
|
|
86
|
+
return "claude"
|
|
87
|
+
if engine == "droid":
|
|
88
|
+
if not model_alias:
|
|
89
|
+
return "droid"
|
|
90
|
+
slug = model_alias.lower()
|
|
91
|
+
slug = re.sub(r"[^a-z0-9-]", "-", slug)
|
|
92
|
+
slug = re.sub(r"-+", "-", slug)
|
|
93
|
+
slug = slug.strip("-")
|
|
94
|
+
return f"droid-{slug}" if slug else "droid"
|
|
95
|
+
return engine
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def plan_branch_name(label: str, short_id: str) -> str:
|
|
99
|
+
return f"delegate/{label}-{short_id}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plan_worktree_path(data_home: Path, fingerprint: str, label: str, short_id: str) -> Path:
|
|
103
|
+
return data_home / fingerprint / f"{label}-{short_id}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def worktrees_data_home(config: JsonObject) -> Path:
|
|
107
|
+
"""Return the persistent worktree root, defaulting to ~/.delegate/worktrees."""
|
|
108
|
+
cfg = config.get("worktrees", {})
|
|
109
|
+
if not isinstance(cfg, dict):
|
|
110
|
+
return Path.home() / ".delegate" / "worktrees"
|
|
111
|
+
data_home = cfg.get("dataHome")
|
|
112
|
+
if isinstance(data_home, str) and data_home:
|
|
113
|
+
expanded = Path(data_home).expanduser()
|
|
114
|
+
if not expanded.is_absolute():
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
"invalid_worktrees_config",
|
|
117
|
+
"worktrees.dataHome must be an absolute path or start with ~/.",
|
|
118
|
+
)
|
|
119
|
+
return expanded
|
|
120
|
+
return Path.home() / ".delegate" / "worktrees"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _map_auto_isolation(engine: str, mode: str) -> str:
|
|
124
|
+
"""Preserve safe-mode isolation for local engines while leaving work mode in-place."""
|
|
125
|
+
if mode == "safe" and engine in KNOWN_ENGINES:
|
|
126
|
+
return ISOLATION_WORKTREE
|
|
127
|
+
return ISOLATION_NONE
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _isolation_lifecycle(isolation_mode: str, mode: str) -> str:
|
|
131
|
+
if isolation_mode == ISOLATION_NONE:
|
|
132
|
+
return "none"
|
|
133
|
+
if isolation_mode == ISOLATION_WORKTREE:
|
|
134
|
+
if mode == "work":
|
|
135
|
+
return "persistent"
|
|
136
|
+
return "temporary"
|
|
137
|
+
return "none"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_isolation_context(
|
|
141
|
+
source_workspace: str,
|
|
142
|
+
resolved_isolation: str,
|
|
143
|
+
*,
|
|
144
|
+
engine: str = "",
|
|
145
|
+
mode: str = "",
|
|
146
|
+
model_alias: str | None = None,
|
|
147
|
+
source_git_root: str | None = None,
|
|
148
|
+
source_git_common_dir: str | None = None,
|
|
149
|
+
source_head_oid: str | None = None,
|
|
150
|
+
source_head_ref: str | None = None,
|
|
151
|
+
source_branch: str | None = None,
|
|
152
|
+
config: JsonObject | None = None,
|
|
153
|
+
run_short_id: str | None = None,
|
|
154
|
+
include_dirty: bool = False,
|
|
155
|
+
) -> IsolationContext:
|
|
156
|
+
"""Resolve isolation into launch metadata and planned persistent-worktree paths."""
|
|
157
|
+
# isolation_mode preserves the raw resolved value (auto/none/worktree).
|
|
158
|
+
# effective_isolation is the mapped behavior (none/worktree, never auto).
|
|
159
|
+
isolation_mode_raw = resolved_isolation
|
|
160
|
+
if resolved_isolation == ISOLATION_AUTO:
|
|
161
|
+
effective = _map_auto_isolation(engine, mode)
|
|
162
|
+
else:
|
|
163
|
+
effective = resolved_isolation
|
|
164
|
+
|
|
165
|
+
lifecycle = _isolation_lifecycle(effective, mode)
|
|
166
|
+
preserved = lifecycle == "persistent"
|
|
167
|
+
|
|
168
|
+
# Compute named paths only for persistent worktree runs. Temporary safe-mode
|
|
169
|
+
# isolation uses an ephemeral detached worktree or directory copy, not a
|
|
170
|
+
# Delegate-managed branch under the persistent worktree data home.
|
|
171
|
+
planned_branch: str | None = None
|
|
172
|
+
planned_execution_cwd: str | None = None
|
|
173
|
+
if lifecycle == "persistent" and source_git_common_dir is not None:
|
|
174
|
+
try:
|
|
175
|
+
fp = compute_repo_fingerprint_from_common_dir(source_git_common_dir)
|
|
176
|
+
except (FileNotFoundError, OSError):
|
|
177
|
+
fp = None
|
|
178
|
+
if fp is not None:
|
|
179
|
+
short_id = run_short_id if run_short_id else "<short-run-id-placeholder>"
|
|
180
|
+
label = branch_label(engine, model_alias)
|
|
181
|
+
planned_branch = plan_branch_name(label, short_id)
|
|
182
|
+
dh = worktrees_data_home(config or {})
|
|
183
|
+
planned_execution_cwd = str(plan_worktree_path(dh, fp, label, short_id))
|
|
184
|
+
|
|
185
|
+
return IsolationContext(
|
|
186
|
+
source_workspace=source_workspace,
|
|
187
|
+
effective_isolation=effective,
|
|
188
|
+
isolation_mode=isolation_mode_raw,
|
|
189
|
+
isolation_lifecycle=lifecycle,
|
|
190
|
+
preserved_workspace=preserved,
|
|
191
|
+
planned_branch=planned_branch,
|
|
192
|
+
planned_execution_cwd=planned_execution_cwd,
|
|
193
|
+
source_git_root=source_git_root,
|
|
194
|
+
source_git_common_dir=source_git_common_dir,
|
|
195
|
+
source_head_oid=source_head_oid,
|
|
196
|
+
source_head_ref=source_head_ref,
|
|
197
|
+
source_branch=source_branch,
|
|
198
|
+
include_dirty=include_dirty,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class IsolationExecutionError(Exception):
|
|
203
|
+
"""Machine-readable isolation failure converted to DelegateError by cli.py."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, error: str, message: str) -> None:
|
|
206
|
+
super().__init__(message)
|
|
207
|
+
self.error = error
|
|
208
|
+
self.message = message
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def require_valid_head(source_git_root: str) -> str:
|
|
212
|
+
"""Return HEAD's OID or raise missing_git_head for an unborn repository."""
|
|
213
|
+
result = _run_git(
|
|
214
|
+
source_git_root,
|
|
215
|
+
["rev-parse", "--verify", "HEAD"],
|
|
216
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
217
|
+
)
|
|
218
|
+
_raise_if_git_timed_out(result, "git rev-parse HEAD")
|
|
219
|
+
if result.returncode != 0:
|
|
220
|
+
raise IsolationExecutionError(
|
|
221
|
+
"missing_git_head",
|
|
222
|
+
"--isolation worktree requires a Git workspace with at least one commit.",
|
|
223
|
+
)
|
|
224
|
+
return result.stdout.strip()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def require_clean_source(source_git_root: str) -> None:
|
|
228
|
+
"""Require no staged, unstaged, untracked, or submodule dirtiness."""
|
|
229
|
+
result = _run_git(
|
|
230
|
+
source_git_root,
|
|
231
|
+
["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=none"],
|
|
232
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
233
|
+
)
|
|
234
|
+
_raise_if_git_timed_out(result, "git status")
|
|
235
|
+
if result.returncode != 0:
|
|
236
|
+
raise IsolationExecutionError(
|
|
237
|
+
"dirty_source_check_failed",
|
|
238
|
+
f"git status failed with code {result.returncode}: {result.stderr.strip()}",
|
|
239
|
+
)
|
|
240
|
+
if result.stdout.strip():
|
|
241
|
+
raise IsolationExecutionError(
|
|
242
|
+
"dirty_source_workspace",
|
|
243
|
+
(
|
|
244
|
+
"--isolation worktree for work mode requires a clean source workspace. "
|
|
245
|
+
"Commit/stash/delete local changes, run in-place with --isolation none, "
|
|
246
|
+
"or relaunch with --include-dirty to copy tracked edits and untracked "
|
|
247
|
+
"non-ignored files into the new worktree."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def create_persistent_worktree(
|
|
253
|
+
source_git_root: str,
|
|
254
|
+
branch: str,
|
|
255
|
+
worktree_path: str,
|
|
256
|
+
base_oid: str,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Create a persistent Git worktree on a new branch from base_oid."""
|
|
259
|
+
branch_probe = _run_git(
|
|
260
|
+
source_git_root,
|
|
261
|
+
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
262
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
263
|
+
)
|
|
264
|
+
_raise_if_git_timed_out(branch_probe, "git branch availability check")
|
|
265
|
+
if branch_probe.returncode == 0:
|
|
266
|
+
raise IsolationExecutionError(
|
|
267
|
+
"branch_collision",
|
|
268
|
+
f"Branch '{branch}' already exists. This indicates registry corruption or a clock anomaly.",
|
|
269
|
+
)
|
|
270
|
+
if branch_probe.returncode != 1:
|
|
271
|
+
raise IsolationExecutionError(
|
|
272
|
+
"worktree_create_failed",
|
|
273
|
+
f"Failed to verify branch availability for '{branch}': {branch_probe.stderr.strip()}",
|
|
274
|
+
)
|
|
275
|
+
Path(worktree_path).parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
result = _run_git(
|
|
277
|
+
source_git_root,
|
|
278
|
+
["worktree", "add", "-b", branch, worktree_path, base_oid],
|
|
279
|
+
)
|
|
280
|
+
_raise_if_git_timed_out(result, "git worktree add")
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
stderr = result.stderr.strip()
|
|
283
|
+
raise IsolationExecutionError(
|
|
284
|
+
"worktree_create_failed",
|
|
285
|
+
f"Failed to create git worktree: {stderr}",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Keep this text stable; child prompts and tests rely on the exact wording.
|
|
290
|
+
PERSISTENT_WORKTREE_CONTEXT_NOTE = (
|
|
291
|
+
"You are running in a Delegate-created isolated Git worktree. "
|
|
292
|
+
"Make changes in this execution workspace only. "
|
|
293
|
+
"Do not attempt to modify, merge into, or clean the source checkout. "
|
|
294
|
+
"Do not delete, rename, or `git worktree remove` this workspace; "
|
|
295
|
+
"the orchestrator manages worktree lifecycle. "
|
|
296
|
+
"Report changed files, verification, and suggested integration steps. "
|
|
297
|
+
"Your orchestrator can inspect this run via `delegate worktree show <alias>` "
|
|
298
|
+
"and retire it via `delegate worktree remove <alias>` "
|
|
299
|
+
"(refuses on dirty or unmerged), "
|
|
300
|
+
"`delegate worktree remove <alias> --force-branch` "
|
|
301
|
+
"(allow unmerged-branch deletion), "
|
|
302
|
+
"`delegate worktree remove <alias> --discard-uncommitted` "
|
|
303
|
+
"(DISCARDS uncommitted edits), "
|
|
304
|
+
"`delegate worktree remove <alias> --force` "
|
|
305
|
+
"(shorthand for both destructive overrides), "
|
|
306
|
+
"or `delegate worktree prune --merged` for bulk integrated entries."
|
|
307
|
+
"\n\n"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def prepend_persistent_worktree_context(prompt: str) -> str:
|
|
312
|
+
"""Insert the worktree note after the skill prefix so the user prompt stays last."""
|
|
313
|
+
if prompt.startswith(SKILL_REVIEW_PREFIX):
|
|
314
|
+
insert_at = len(SKILL_REVIEW_PREFIX)
|
|
315
|
+
return prompt[:insert_at] + PERSISTENT_WORKTREE_CONTEXT_NOTE + prompt[insert_at:]
|
|
316
|
+
return PERSISTENT_WORKTREE_CONTEXT_NOTE + prompt
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeAlias
|
|
4
|
+
|
|
5
|
+
JsonScalar: TypeAlias = str | int | float | bool | None
|
|
6
|
+
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
|
7
|
+
JsonObject: TypeAlias = dict[str, JsonValue]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_non_negative_int(value: object) -> bool:
|
|
11
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def first_string(*values: object) -> str | None:
|
|
15
|
+
for value in values:
|
|
16
|
+
if isinstance(value, str) and value:
|
|
17
|
+
return value
|
|
18
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
RUN_OUTPUT_DEFAULT_MAX_CHARS = 60_000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LogOutput:
|
|
11
|
+
content: str
|
|
12
|
+
truncated: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CharCapResult:
|
|
17
|
+
content: str
|
|
18
|
+
char_truncated: bool
|
|
19
|
+
returned_chars: int
|
|
20
|
+
omitted_chars: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cap_content_by_chars(content: str, max_chars: int) -> CharCapResult:
|
|
24
|
+
if max_chars < 1:
|
|
25
|
+
raise ValueError("max_chars must be at least 1")
|
|
26
|
+
if len(content) <= max_chars:
|
|
27
|
+
return CharCapResult(
|
|
28
|
+
content=content,
|
|
29
|
+
char_truncated=False,
|
|
30
|
+
returned_chars=len(content),
|
|
31
|
+
omitted_chars=0,
|
|
32
|
+
)
|
|
33
|
+
omitted = len(content) - max_chars
|
|
34
|
+
return CharCapResult(
|
|
35
|
+
content=content[-max_chars:],
|
|
36
|
+
char_truncated=True,
|
|
37
|
+
returned_chars=max_chars,
|
|
38
|
+
omitted_chars=omitted,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tail_file_output(path: Path, lines: int) -> LogOutput:
|
|
43
|
+
if lines < 1:
|
|
44
|
+
raise ValueError("tail lines must be at least 1")
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return LogOutput(content="", truncated=False)
|
|
47
|
+
with path.open("rb") as handle:
|
|
48
|
+
handle.seek(0, 2)
|
|
49
|
+
end = handle.tell()
|
|
50
|
+
if end == 0:
|
|
51
|
+
return LogOutput(content="", truncated=False)
|
|
52
|
+
block = 4096
|
|
53
|
+
chunks: list[bytes] = []
|
|
54
|
+
position = end
|
|
55
|
+
newline_count = 0
|
|
56
|
+
while position > 0 and newline_count <= lines:
|
|
57
|
+
read_size = min(block, position)
|
|
58
|
+
position -= read_size
|
|
59
|
+
handle.seek(position)
|
|
60
|
+
chunk = handle.read(read_size)
|
|
61
|
+
chunks.insert(0, chunk)
|
|
62
|
+
newline_count += chunk.count(b"\n")
|
|
63
|
+
text = b"".join(chunks).decode("utf-8", errors="replace")
|
|
64
|
+
split = text.splitlines()
|
|
65
|
+
content = "\n".join(split[-lines:]) + ("\n" if split else "")
|
|
66
|
+
return LogOutput(content=content, truncated=position > 0 or len(split) > lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_log_output(path: Path, *, tail: int | None, raw: bool) -> LogOutput:
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return LogOutput(content="", truncated=False)
|
|
72
|
+
if raw:
|
|
73
|
+
return LogOutput(
|
|
74
|
+
content=path.read_text(encoding="utf-8", errors="replace"), truncated=False
|
|
75
|
+
)
|
|
76
|
+
if tail is None:
|
|
77
|
+
raise ValueError("add --tail N or --raw to read stdout/stderr log output")
|
|
78
|
+
return tail_file_output(path, tail)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from delegate_agent.json_types import JsonObject, JsonValue
|
|
10
|
+
|
|
11
|
+
PRIVATE_DIR_MODE = 0o700
|
|
12
|
+
PRIVATE_FILE_MODE = 0o600
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegistryJsonError(ValueError):
|
|
16
|
+
"""Raised when an existing registry JSON file cannot be trusted."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def supports_private_modes() -> bool:
|
|
20
|
+
"""Return whether chmod-style private mode hardening is available."""
|
|
21
|
+
return os.name == "posix"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_private_dir(path: Path) -> None:
|
|
25
|
+
"""Create a registry directory and make it owner-only on POSIX."""
|
|
26
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
if supports_private_modes():
|
|
28
|
+
os.chmod(path, PRIVATE_DIR_MODE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_private_file(path: Path) -> None:
|
|
32
|
+
"""Make an existing registry file owner-read/write on POSIX."""
|
|
33
|
+
if supports_private_modes():
|
|
34
|
+
os.chmod(path, PRIVATE_FILE_MODE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_private_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
|
|
38
|
+
"""Create/truncate a registry text file with owner-only permissions."""
|
|
39
|
+
ensure_private_dir(path.parent)
|
|
40
|
+
fd = os.open(path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, PRIVATE_FILE_MODE)
|
|
41
|
+
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
|
42
|
+
handle.write(text)
|
|
43
|
+
ensure_private_file(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_private_bytes(path: Path, payload: bytes) -> None:
|
|
47
|
+
"""Create/truncate a registry byte file with owner-only permissions."""
|
|
48
|
+
ensure_private_dir(path.parent)
|
|
49
|
+
fd = os.open(path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, PRIVATE_FILE_MODE)
|
|
50
|
+
with os.fdopen(fd, "wb") as handle:
|
|
51
|
+
handle.write(payload)
|
|
52
|
+
ensure_private_file(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_text_atomic(path: Path, text: str, *, encoding: str = "utf-8") -> None:
|
|
56
|
+
"""Atomically replace a non-private text file while preserving existing mode."""
|
|
57
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
|
58
|
+
existing_mode: int | None = None
|
|
59
|
+
try:
|
|
60
|
+
existing_mode = path.stat().st_mode & 0o777
|
|
61
|
+
except OSError:
|
|
62
|
+
existing_mode = None
|
|
63
|
+
fd = os.open(temp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, existing_mode or 0o666)
|
|
64
|
+
try:
|
|
65
|
+
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
|
66
|
+
handle.write(text)
|
|
67
|
+
if existing_mode is not None:
|
|
68
|
+
os.chmod(temp, existing_mode)
|
|
69
|
+
os.replace(temp, path)
|
|
70
|
+
except OSError:
|
|
71
|
+
with suppress(OSError):
|
|
72
|
+
os.unlink(temp)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_json_atomic(path: Path, payload: JsonObject) -> None:
|
|
77
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
|
78
|
+
try:
|
|
79
|
+
write_private_text(
|
|
80
|
+
temp,
|
|
81
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
82
|
+
)
|
|
83
|
+
os.replace(temp, path)
|
|
84
|
+
ensure_private_file(path)
|
|
85
|
+
except OSError:
|
|
86
|
+
with suppress(OSError):
|
|
87
|
+
os.unlink(temp)
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_json_object(path: Path) -> JsonObject | None:
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
data: JsonValue = json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except json.JSONDecodeError as exc:
|
|
97
|
+
raise RegistryJsonError(f"invalid JSON in {path}: {exc}") from exc
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
raise RegistryJsonError(f"could not read JSON file {path}: {exc}") from exc
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise RegistryJsonError(f"{path} root must be a JSON object")
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_json_object_or_none(path: Path) -> JsonObject | None:
|
|
106
|
+
try:
|
|
107
|
+
return read_json_object(path)
|
|
108
|
+
except RegistryJsonError:
|
|
109
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TextIO
|
|
5
|
+
|
|
6
|
+
from delegate_agent import redaction, rendering
|
|
7
|
+
from delegate_agent.json_types import JsonObject
|
|
8
|
+
from delegate_agent.profiles import ProfileResolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ProfilesCommand:
|
|
13
|
+
json_mode: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def emit(
|
|
17
|
+
command: ProfilesCommand,
|
|
18
|
+
*,
|
|
19
|
+
resolution: ProfileResolution,
|
|
20
|
+
config_source: str,
|
|
21
|
+
stdout: TextIO,
|
|
22
|
+
) -> int:
|
|
23
|
+
env = redaction.redact_env_map(resolution.env)
|
|
24
|
+
payload: JsonObject = {
|
|
25
|
+
"ok": True,
|
|
26
|
+
"profile": resolution.name,
|
|
27
|
+
"source": resolution.source,
|
|
28
|
+
"envKeys": sorted(env),
|
|
29
|
+
"env": env,
|
|
30
|
+
"warnings": list(resolution.warnings),
|
|
31
|
+
"configSource": config_source,
|
|
32
|
+
}
|
|
33
|
+
if command.json_mode:
|
|
34
|
+
rendering.print_json(payload, stdout)
|
|
35
|
+
else:
|
|
36
|
+
print(f"profile: {resolution.name or '(none)'}", file=stdout)
|
|
37
|
+
print(f"source: {resolution.source or '(none)'}", file=stdout)
|
|
38
|
+
keys = ", ".join(payload["envKeys"]) if payload["envKeys"] else "(none)"
|
|
39
|
+
print(f"envKeys: {keys}", file=stdout)
|
|
40
|
+
for warning in resolution.warnings:
|
|
41
|
+
print(f"warning: {warning}", file=stdout)
|
|
42
|
+
return 0
|