patchshuttle 0.1.0a2__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 (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/config.py ADDED
@@ -0,0 +1,247 @@
1
+ """Typed loading and default rendering for ``patches/patchshuttle.toml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import stat
7
+ from enum import Enum
8
+ from os import PathLike
9
+ from pathlib import Path
10
+ from typing import Annotated, Literal, TypeAlias
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+
14
+ from patchshuttle.errors import WorkspaceError, WorkspaceErrorCode
15
+ from patchshuttle.identifiers import ProjectId
16
+
17
+ try:
18
+ import tomllib
19
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
20
+ import tomli as tomllib # type: ignore[no-redef]
21
+
22
+ StrictString: TypeAlias = Annotated[str, Field(strict=True, min_length=1)]
23
+ PositiveInteger: TypeAlias = Annotated[int, Field(strict=True, ge=1)]
24
+
25
+ DEFAULT_PROTECTED_PATHS = (
26
+ ".git/**",
27
+ ".env",
28
+ ".env.*",
29
+ "patches/**",
30
+ ".venv/**",
31
+ "venv/**",
32
+ "node_modules/**",
33
+ )
34
+ DEFAULT_PROTECTED_PATH_EXCEPTIONS = (
35
+ ".env.example",
36
+ ".env.sample",
37
+ ".env.template",
38
+ )
39
+ DEFAULT_IGNORED_PATHS = (
40
+ ".git/**",
41
+ "patches/backups/**",
42
+ "patches/logs/**",
43
+ "patches/state/**",
44
+ ".venv/**",
45
+ "venv/**",
46
+ "node_modules/**",
47
+ "**/__pycache__/**",
48
+ ".pytest_cache/**",
49
+ ".mypy_cache/**",
50
+ ".ruff_cache/**",
51
+ )
52
+
53
+
54
+ class ProjectOrigin(str, Enum):
55
+ """How a workspace was classified during its first initialization."""
56
+
57
+ EXISTING = "existing"
58
+ NEW = "new"
59
+
60
+
61
+ class _ConfigModel(BaseModel):
62
+ model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True)
63
+
64
+
65
+ class ProjectSettings(_ConfigModel):
66
+ project_id: ProjectId
67
+ origin: ProjectOrigin
68
+ protected_paths: tuple[StrictString, ...] = DEFAULT_PROTECTED_PATHS
69
+ protected_path_exceptions: tuple[StrictString, ...] = (
70
+ DEFAULT_PROTECTED_PATH_EXCEPTIONS
71
+ )
72
+ ignored_paths: tuple[StrictString, ...] = DEFAULT_IGNORED_PATHS
73
+
74
+
75
+ class ExecutionSettings(_ConfigModel):
76
+ confirm: bool = Field(default=True, strict=True)
77
+ auto_rollback: bool = Field(default=True, strict=True)
78
+ allow_keep_changes: bool = Field(default=True, strict=True)
79
+ default_timeout_seconds: PositiveInteger = 300
80
+ max_job_bytes: PositiveInteger = 2_000_000
81
+ max_actions: PositiveInteger = 100
82
+ max_single_file_bytes: PositiveInteger = 1_000_000
83
+ max_command_output_bytes: PositiveInteger = 2_000_000
84
+ max_inventory_entries: PositiveInteger = 50_000
85
+ max_inventory_bytes: PositiveInteger = 1_000_000_000
86
+
87
+
88
+ class FormattingSettings(_ConfigModel):
89
+ enabled: bool = Field(default=True, strict=True)
90
+ order: tuple[Literal["isort"], Literal["black"]] = ("isort", "black")
91
+ scope: Literal["changed_python_files"] = "changed_python_files"
92
+ rerun_checks: bool = Field(default=True, strict=True)
93
+
94
+
95
+ class LoggingSettings(_ConfigModel):
96
+ timezone: StrictString = "local"
97
+ include_command_output: bool = Field(default=True, strict=True)
98
+ redact_known_secrets: bool = Field(default=True, strict=True)
99
+
100
+
101
+ class CheckProfileSettings(_ConfigModel):
102
+ argv: Annotated[tuple[StrictString, ...], Field(min_length=1)]
103
+ timeout_seconds: PositiveInteger = 300
104
+ allow_job_args: bool = Field(default=False, strict=True)
105
+
106
+
107
+ class ChecksSettings(_ConfigModel):
108
+ require_at_least_one_for_patch: bool = Field(default=True, strict=True)
109
+ profiles: dict[str, CheckProfileSettings] = Field(default_factory=dict)
110
+
111
+
112
+ class PatchShuttleConfig(_ConfigModel):
113
+ """The complete local policy document used by one workspace."""
114
+
115
+ project: ProjectSettings
116
+ execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
117
+ formatting: FormattingSettings = Field(default_factory=FormattingSettings)
118
+ logging: LoggingSettings = Field(default_factory=LoggingSettings)
119
+ checks: ChecksSettings = Field(default_factory=ChecksSettings)
120
+
121
+
122
+ def load_config(path: str | PathLike[str]) -> PatchShuttleConfig:
123
+ """Load one UTF-8 TOML configuration with a closed typed schema."""
124
+
125
+ config_path = Path(path)
126
+ if config_path.is_symlink():
127
+ raise WorkspaceError(
128
+ WorkspaceErrorCode.CONFIG_NOT_REGULAR,
129
+ "configuration path must not be a symbolic link",
130
+ )
131
+
132
+ try:
133
+ file_stat = config_path.stat()
134
+ except FileNotFoundError as exc:
135
+ raise WorkspaceError(
136
+ WorkspaceErrorCode.CONFIG_NOT_FOUND,
137
+ "workspace configuration was not found",
138
+ ) from exc
139
+ except OSError as exc:
140
+ raise WorkspaceError(
141
+ WorkspaceErrorCode.CONFIG_READ_FAILED,
142
+ "configuration metadata could not be read",
143
+ ) from exc
144
+
145
+ if not stat.S_ISREG(file_stat.st_mode):
146
+ raise WorkspaceError(
147
+ WorkspaceErrorCode.CONFIG_NOT_REGULAR,
148
+ "configuration path must identify a regular file",
149
+ )
150
+
151
+ try:
152
+ raw = config_path.read_bytes()
153
+ except OSError as exc:
154
+ raise WorkspaceError(
155
+ WorkspaceErrorCode.CONFIG_READ_FAILED,
156
+ "configuration file could not be read",
157
+ ) from exc
158
+
159
+ try:
160
+ text = raw.decode("utf-8")
161
+ value = tomllib.loads(text)
162
+ return PatchShuttleConfig.model_validate(value)
163
+ except UnicodeDecodeError as exc:
164
+ raise WorkspaceError(
165
+ WorkspaceErrorCode.CONFIG_INVALID,
166
+ "configuration must be valid UTF-8 text",
167
+ ) from exc
168
+ except tomllib.TOMLDecodeError as exc:
169
+ raise WorkspaceError(
170
+ WorkspaceErrorCode.CONFIG_INVALID,
171
+ "configuration contains invalid TOML syntax",
172
+ ) from exc
173
+ except ValidationError as exc:
174
+ first_error = exc.errors(include_url=False)[0]
175
+ raise WorkspaceError(
176
+ WorkspaceErrorCode.CONFIG_INVALID,
177
+ str(first_error["msg"]),
178
+ path=_format_validation_path(first_error["loc"]),
179
+ ) from exc
180
+
181
+
182
+ def render_default_config(
183
+ project_id: str,
184
+ origin: ProjectOrigin | str,
185
+ ) -> str:
186
+ """Render the canonical default configuration for a new workspace."""
187
+
188
+ project = ProjectSettings(project_id=project_id, origin=origin)
189
+ return (
190
+ "[project]\n"
191
+ f"project_id = {json.dumps(project.project_id)}\n"
192
+ f"origin = {json.dumps(project.origin.value)}\n\n"
193
+ f"protected_paths = {_render_array(project.protected_paths)}\n\n"
194
+ "protected_path_exceptions = "
195
+ f"{_render_array(project.protected_path_exceptions)}\n\n"
196
+ f"ignored_paths = {_render_array(project.ignored_paths)}\n\n"
197
+ "[execution]\n"
198
+ "confirm = true\n"
199
+ "auto_rollback = true\n"
200
+ "allow_keep_changes = true\n"
201
+ "default_timeout_seconds = 300\n"
202
+ "max_job_bytes = 2000000\n"
203
+ "max_actions = 100\n"
204
+ "max_single_file_bytes = 1000000\n"
205
+ "max_command_output_bytes = 2000000\n"
206
+ "max_inventory_entries = 50000\n"
207
+ "max_inventory_bytes = 1000000000\n\n"
208
+ "[formatting]\n"
209
+ "enabled = true\n"
210
+ 'order = ["isort", "black"]\n'
211
+ 'scope = "changed_python_files"\n'
212
+ "rerun_checks = true\n\n"
213
+ "[logging]\n"
214
+ 'timezone = "local"\n'
215
+ "include_command_output = true\n"
216
+ "redact_known_secrets = true\n\n"
217
+ "[checks]\n"
218
+ "require_at_least_one_for_patch = true\n"
219
+ )
220
+
221
+
222
+ def _render_array(values: tuple[str, ...]) -> str:
223
+ lines = ["["]
224
+ lines.extend(f" {json.dumps(value)}," for value in values)
225
+ lines.append("]")
226
+ return "\n".join(lines)
227
+
228
+
229
+ def _format_validation_path(location: tuple[int | str, ...]) -> str:
230
+ path = "$"
231
+ for part in location:
232
+ path = f"{path}[{part}]" if isinstance(part, int) else f"{path}.{part}"
233
+ return path
234
+
235
+
236
+ __all__ = [
237
+ "CheckProfileSettings",
238
+ "ChecksSettings",
239
+ "ExecutionSettings",
240
+ "FormattingSettings",
241
+ "LoggingSettings",
242
+ "PatchShuttleConfig",
243
+ "ProjectOrigin",
244
+ "ProjectSettings",
245
+ "load_config",
246
+ "render_default_config",
247
+ ]
@@ -0,0 +1,370 @@
1
+ """Bounded project snapshots and upload-friendly AI handoffs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import shutil
8
+ import stat
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from patchshuttle._process import ProcessCommand, ProcessStatus, run_process
13
+ from patchshuttle._version import __version__
14
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode
15
+ from patchshuttle.inventory import (
16
+ InventoryEntryKind,
17
+ InventoryError,
18
+ WorkspaceInventory,
19
+ capture_inventory,
20
+ )
21
+ from patchshuttle.logging import current_run_clock, write_named_log
22
+ from patchshuttle.registry import Registry, load_registry
23
+ from patchshuttle.runner import acquire_workspace_lock
24
+ from patchshuttle.workspace import Workspace
25
+
26
+ _AUDIT_ACTIONS = (
27
+ "tree, read, search, find_files, file_info, hash, git_status, environment"
28
+ )
29
+ _CHANGE_ACTIONS = (
30
+ "create_directory, create_file, replace_exact, insert_before, insert_after, "
31
+ "delete_exact, apply_diff"
32
+ )
33
+ _CHECKS = (
34
+ "compileall, pytest, unittest, django_check, django_migrations_check, "
35
+ "django_test, import_check, profile"
36
+ )
37
+ _TREE_LIMIT = 500
38
+ _FILE_LIMIT = 2_000
39
+ _HISTORY_LIMIT = 20
40
+ _TRUNCATION = "\n[TRUNCATED BY PATCHSHUTTLE]\n"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class SnapshotResult:
45
+ """One generated read-only project snapshot."""
46
+
47
+ path: Path
48
+ inventory_entries: int
49
+ output_truncated: bool
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class HandoffResult:
54
+ """One generated AI-facing project handoff."""
55
+
56
+ path: Path
57
+ inventory_entries: int
58
+ recent_jobs: int
59
+ output_truncated: bool
60
+
61
+
62
+ def create_snapshot(workspace: Workspace) -> SnapshotResult:
63
+ """Capture bounded metadata without including source-file contents."""
64
+
65
+ clock = current_run_clock(workspace)
66
+ with acquire_workspace_lock(workspace):
67
+ inventory = _capture(workspace)
68
+ registry = load_registry(workspace)
69
+ content = _snapshot_text(workspace, inventory, registry, clock.iso_timestamp)
70
+ content, truncated = _bounded(
71
+ content,
72
+ workspace.config.execution.max_command_output_bytes,
73
+ )
74
+ path = write_named_log(
75
+ workspace,
76
+ clock=clock,
77
+ label="SNAPSHOT",
78
+ content=content,
79
+ )
80
+ return SnapshotResult(
81
+ path=path,
82
+ inventory_entries=len(inventory.entries),
83
+ output_truncated=truncated,
84
+ )
85
+
86
+
87
+ def create_handoff(workspace: Workspace) -> HandoffResult:
88
+ """Create one compact provider-neutral context file for an AI service."""
89
+
90
+ clock = current_run_clock(workspace)
91
+ with acquire_workspace_lock(workspace):
92
+ inventory = _capture(workspace)
93
+ registry = load_registry(workspace)
94
+ latest_summary, latest_handoff = _latest_run_context(workspace)
95
+ recent = _recent_records(registry)
96
+ content = _handoff_text(
97
+ workspace,
98
+ inventory,
99
+ recent,
100
+ clock.iso_timestamp,
101
+ latest_summary=latest_summary,
102
+ latest_handoff=latest_handoff,
103
+ )
104
+ content, truncated = _bounded(
105
+ content,
106
+ workspace.config.execution.max_command_output_bytes,
107
+ )
108
+ path = write_named_log(
109
+ workspace,
110
+ clock=clock,
111
+ label="HANDOFF",
112
+ content=content,
113
+ )
114
+ return HandoffResult(
115
+ path=path,
116
+ inventory_entries=len(inventory.entries),
117
+ recent_jobs=len(recent),
118
+ output_truncated=truncated,
119
+ )
120
+
121
+
122
+ def _snapshot_text(
123
+ workspace: Workspace,
124
+ inventory: WorkspaceInventory,
125
+ registry: Registry,
126
+ timestamp: str,
127
+ ) -> str:
128
+ tree_lines, tree_truncated = _tree_lines(inventory)
129
+ file_lines, files_truncated = _file_lines(inventory)
130
+ recent = _recent_records(registry)
131
+ return "\n".join(
132
+ (
133
+ "=== PATCHSHUTTLE_SNAPSHOT ===",
134
+ "protocol: 1",
135
+ f"timestamp: {timestamp}",
136
+ f"project_id: {workspace.project_id}",
137
+ f"patchshuttle_version: {__version__}",
138
+ f"python_version: {platform.python_version()}",
139
+ f"workspace_root: {workspace.root.as_posix()}",
140
+ f"inventory_entries: {len(inventory.entries)}",
141
+ f"inventory_hashed_bytes: {inventory.hashed_bytes}",
142
+ "",
143
+ "=== CAPABILITIES ===",
144
+ "job_kinds: audit, patch, verify",
145
+ f"audit_actions: {_AUDIT_ACTIONS}",
146
+ f"change_actions: {_CHANGE_ACTIONS}",
147
+ f"checks: {_CHECKS}",
148
+ "",
149
+ "=== POLICY_SUMMARY ===",
150
+ "ignored_paths: "
151
+ + json.dumps(
152
+ workspace.config.project.ignored_paths,
153
+ ensure_ascii=False,
154
+ ),
155
+ "protected_paths: "
156
+ + json.dumps(
157
+ workspace.config.project.protected_paths,
158
+ ensure_ascii=False,
159
+ ),
160
+ "",
161
+ "=== PROJECT_TREE ===",
162
+ *tree_lines,
163
+ f"tree_truncated: {str(tree_truncated).lower()}",
164
+ "",
165
+ "=== FILE_FINGERPRINTS ===",
166
+ *file_lines,
167
+ f"file_list_truncated: {str(files_truncated).lower()}",
168
+ "",
169
+ "=== GIT_STATUS ===",
170
+ _git_status(workspace),
171
+ "",
172
+ "=== RECENT_JOBS ===",
173
+ *(_record_line(item) for item in recent),
174
+ "=== END_PATCHSHUTTLE_SNAPSHOT ===",
175
+ )
176
+ )
177
+
178
+
179
+ def _handoff_text(
180
+ workspace: Workspace,
181
+ inventory: WorkspaceInventory,
182
+ recent: tuple,
183
+ timestamp: str,
184
+ *,
185
+ latest_summary: str,
186
+ latest_handoff: str,
187
+ ) -> str:
188
+ tree_lines, tree_truncated = _tree_lines(inventory)
189
+ return "\n".join(
190
+ (
191
+ "=== PATCHSHUTTLE_HANDOFF ===",
192
+ "AI_INSTRUCTION:",
193
+ "Inspect this context and the latest run result. Return exactly one "
194
+ ".psh.yaml job using protocol 1 and the project_id below. Use an "
195
+ "audit job when more evidence is needed. Do not return shell commands "
196
+ "or ask PatchShuttle to weaken local policy. After the user runs the "
197
+ "job, request the resulting PatchShuttle log before preparing another job.",
198
+ "",
199
+ "=== PROJECT ===",
200
+ "protocol: 1",
201
+ f"timestamp: {timestamp}",
202
+ f"project_id: {workspace.project_id}",
203
+ f"patchshuttle_version: {__version__}",
204
+ "",
205
+ "=== CAPABILITIES ===",
206
+ "job_kinds: audit, patch, verify",
207
+ f"audit_actions: {_AUDIT_ACTIONS}",
208
+ f"change_actions: {_CHANGE_ACTIONS}",
209
+ f"checks: {_CHECKS}",
210
+ "",
211
+ "=== LATEST_RUN_SUMMARY ===",
212
+ latest_summary,
213
+ "",
214
+ "=== LATEST_AI_HANDOFF ===",
215
+ latest_handoff,
216
+ "",
217
+ "=== BOUNDED_PROJECT_TREE ===",
218
+ *tree_lines,
219
+ f"tree_truncated: {str(tree_truncated).lower()}",
220
+ "",
221
+ "=== RECENT_JOB_HISTORY ===",
222
+ *(_record_line(item) for item in recent),
223
+ "",
224
+ "EXPECTED_RESPONSE: one .psh.yaml file only",
225
+ "=== END_PATCHSHUTTLE_HANDOFF ===",
226
+ )
227
+ )
228
+
229
+
230
+ def _capture(workspace: Workspace) -> WorkspaceInventory:
231
+ try:
232
+ return capture_inventory(workspace)
233
+ except InventoryError as exc:
234
+ raise ExecutionError(
235
+ ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
236
+ "project context inventory could not be captured",
237
+ path=exc.path.as_posix() if exc.path is not None else None,
238
+ ) from exc
239
+
240
+
241
+ def _tree_lines(
242
+ inventory: WorkspaceInventory,
243
+ ) -> tuple[tuple[str, ...], bool]:
244
+ selected = inventory.entries[:_TREE_LIMIT]
245
+ lines = tuple(
246
+ f"{entry.path.as_posix()}{'/' if entry.kind is InventoryEntryKind.DIRECTORY else ''} [{entry.kind.value.lower()}]"
247
+ for entry in selected
248
+ )
249
+ return lines or ("[EMPTY PROJECT]",), len(inventory.entries) > len(selected)
250
+
251
+
252
+ def _file_lines(
253
+ inventory: WorkspaceInventory,
254
+ ) -> tuple[tuple[str, ...], bool]:
255
+ files = tuple(
256
+ entry for entry in inventory.entries if entry.kind is InventoryEntryKind.FILE
257
+ )
258
+ selected = files[:_FILE_LIMIT]
259
+ lines = tuple(
260
+ f"{entry.path.as_posix()} size={entry.size} sha256={entry.sha256}"
261
+ for entry in selected
262
+ )
263
+ return lines or ("[NO FILES]",), len(files) > len(selected)
264
+
265
+
266
+ def _recent_records(registry: Registry) -> tuple:
267
+ return tuple(
268
+ sorted(
269
+ registry.jobs.values(),
270
+ key=lambda item: (item.latest_run_at, item.job_id),
271
+ reverse=True,
272
+ )[:_HISTORY_LIMIT]
273
+ )
274
+
275
+
276
+ def _record_line(record) -> str:
277
+ return (
278
+ f"{record.job_id} kind={record.kind} result={record.latest_result} "
279
+ f"hash={record.job_hash[:8]} at={record.latest_run_at}"
280
+ )
281
+
282
+
283
+ def _git_status(workspace: Workspace) -> str:
284
+ executable = shutil.which("git")
285
+ marker = workspace.root / ".git"
286
+ if executable is None or not marker.exists() or marker.is_symlink():
287
+ return "NOT_AVAILABLE"
288
+ process = run_process(
289
+ ProcessCommand(
290
+ argv=(
291
+ executable,
292
+ "-c",
293
+ "color.ui=false",
294
+ "status",
295
+ "--short",
296
+ "--branch",
297
+ "--untracked-files=normal",
298
+ ),
299
+ working_directory=workspace.root,
300
+ timeout_seconds=workspace.config.execution.default_timeout_seconds,
301
+ ),
302
+ maximum_output_bytes=workspace.config.execution.max_command_output_bytes,
303
+ )
304
+ if process.status is not ProcessStatus.PASSED:
305
+ return "NOT_AVAILABLE"
306
+ value = process.stdout.rstrip("\n") or "CLEAN"
307
+ return value + ("\n[TRUNCATED]" if process.stdout_truncated else "")
308
+
309
+
310
+ def _latest_run_context(workspace: Workspace) -> tuple[str, str]:
311
+ directory = workspace.patches_dir / "logs"
312
+ try:
313
+ candidates = sorted(
314
+ (
315
+ path
316
+ for path in directory.iterdir()
317
+ if path.name.startswith("log_") and path.suffix == ".log"
318
+ ),
319
+ key=lambda path: (path.lstat().st_mtime_ns, path.name),
320
+ reverse=True,
321
+ )
322
+ except OSError as exc:
323
+ raise ExecutionError(
324
+ ExecutionErrorCode.OPERATIONAL_RECORD_FAILED,
325
+ "run logs could not be inspected for handoff generation",
326
+ path="patches/logs",
327
+ ) from exc
328
+ maximum = workspace.config.execution.max_command_output_bytes
329
+ for path in candidates:
330
+ try:
331
+ metadata = path.lstat()
332
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
333
+ continue
334
+ text = path.read_text("utf-8")
335
+ except (OSError, UnicodeError):
336
+ continue
337
+ summary = _section(text, "SUMMARY")
338
+ handoff = _section(text, "PATCHSHUTTLE_AI_HANDOFF")
339
+ if summary is not None and handoff is not None:
340
+ return summary, handoff
341
+ return "NOT_AVAILABLE", "NOT_AVAILABLE"
342
+
343
+
344
+ def _section(value: str, name: str) -> str | None:
345
+ marker = f"=== {name} ===\n"
346
+ start = value.find(marker)
347
+ if start < 0:
348
+ return None
349
+ content_start = start + len(marker)
350
+ end = value.find("\n=== ", content_start)
351
+ return value[content_start : end if end >= 0 else None].strip()
352
+
353
+
354
+ def _bounded(value: str, maximum: int) -> tuple[str, bool]:
355
+ raw = value.encode("utf-8")
356
+ if len(raw) <= maximum:
357
+ return value, False
358
+ marker = _TRUNCATION.encode("utf-8")
359
+ if maximum <= len(marker):
360
+ return marker[:maximum].decode("utf-8"), True
361
+ retained = raw[: max(0, maximum - len(marker))]
362
+ return retained.decode("utf-8", errors="ignore") + _TRUNCATION, True
363
+
364
+
365
+ __all__ = [
366
+ "HandoffResult",
367
+ "SnapshotResult",
368
+ "create_handoff",
369
+ "create_snapshot",
370
+ ]