taskledger 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. taskledger-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Literal, cast
7
+
8
+ from taskledger.errors import LaunchError
9
+
10
+ if TYPE_CHECKING:
11
+ from taskledger.storage.paths import ProjectPaths
12
+
13
+ try:
14
+ tomllib = importlib.import_module("tomllib")
15
+ except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10
16
+ tomllib = importlib.import_module("tomli")
17
+
18
+ LOCATION_CONFIG_KEYS = frozenset({"config_version", "taskledger_dir"})
19
+ WORKFLOW_CONFIG_KEYS = frozenset(
20
+ {
21
+ "default_memory_update_mode",
22
+ "default_file_render_mode",
23
+ "default_save_run_reports",
24
+ "default_source_max_chars",
25
+ "default_total_source_max_chars",
26
+ "default_source_head_lines",
27
+ "default_source_tail_lines",
28
+ "default_context_order",
29
+ "workflow_schema",
30
+ "project_context",
31
+ "artifact_rules",
32
+ "default_artifact_order",
33
+ }
34
+ )
35
+ SUPPORTED_PROJECT_CONFIG_KEYS = LOCATION_CONFIG_KEYS | WORKFLOW_CONFIG_KEYS
36
+
37
+ MemoryUpdateMode = Literal["replace", "append", "prepend"]
38
+ FileRenderMode = Literal["content", "reference"]
39
+ DEFAULT_PROJECT_SOURCE_MAX_CHARS = 12000
40
+ DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS = 48000
41
+ DEFAULT_PROJECT_SOURCE_HEAD_LINES = 200
42
+ DEFAULT_PROJECT_SOURCE_TAIL_LINES = 50
43
+ ARTIFACT_MEMORY_REF_FIELDS = (
44
+ "analysis_memory_ref",
45
+ "state_memory_ref",
46
+ "plan_memory_ref",
47
+ "implementation_memory_ref",
48
+ "validation_memory_ref",
49
+ "save_target_ref",
50
+ )
51
+
52
+
53
+ @dataclass(slots=True, frozen=True)
54
+ class ProjectArtifactRule:
55
+ name: str
56
+ depends_on: tuple[str, ...] = ()
57
+ memory_ref_field: str | None = None
58
+ label: str | None = None
59
+ description: str | None = None
60
+
61
+ def to_dict(self) -> dict[str, object]:
62
+ return {
63
+ "name": self.name,
64
+ "depends_on": list(self.depends_on),
65
+ "memory_ref_field": self.memory_ref_field,
66
+ "label": self.label,
67
+ "description": self.description,
68
+ }
69
+
70
+
71
+ @dataclass(slots=True, frozen=True)
72
+ class ProjectConfig:
73
+ default_memory_update_mode: MemoryUpdateMode = "replace"
74
+ default_file_render_mode: FileRenderMode = "content"
75
+ default_save_run_reports: bool = True
76
+ default_source_max_chars: int | None = DEFAULT_PROJECT_SOURCE_MAX_CHARS
77
+ default_total_source_max_chars: int | None = DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS
78
+ default_source_head_lines: int | None = DEFAULT_PROJECT_SOURCE_HEAD_LINES
79
+ default_source_tail_lines: int | None = DEFAULT_PROJECT_SOURCE_TAIL_LINES
80
+ default_context_order: tuple[str, ...] = (
81
+ "memory",
82
+ "file",
83
+ "item",
84
+ "inline",
85
+ "loop_artifact",
86
+ )
87
+ workflow_schema: str | None = None
88
+ project_context: str | None = None
89
+ artifact_rules: tuple[ProjectArtifactRule, ...] = ()
90
+ default_artifact_order: tuple[str, ...] = ()
91
+
92
+
93
+ def render_default_taskledger_toml(taskledger_dir: str = ".taskledger") -> str:
94
+ return f"""# Project-local taskledger configuration.
95
+ # This file lives in the source project root.
96
+ config_version = 1
97
+ taskledger_dir = {taskledger_dir!r}
98
+
99
+ # Project-local taskledger overrides.
100
+ # The source-budget settings below are the active composition defaults.
101
+ # Lower them for stricter prompts, or raise them when a run needs more context.
102
+ # Supported keys:
103
+ # default_memory_update_mode = "replace"
104
+ # default_file_render_mode = "content"
105
+ # default_save_run_reports = true
106
+ # default_source_max_chars = {DEFAULT_PROJECT_SOURCE_MAX_CHARS}
107
+ # default_total_source_max_chars = {DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS}
108
+ # default_source_head_lines = {DEFAULT_PROJECT_SOURCE_HEAD_LINES}
109
+ # default_source_tail_lines = {DEFAULT_PROJECT_SOURCE_TAIL_LINES}
110
+ # default_context_order = ["memory", "file", "item", "inline", "loop_artifact"]
111
+ # workflow_schema = "opsx-lite"
112
+ # project_context = "Project-specific workflow guidance."
113
+ # default_artifact_order = ["analysis", "plan", "implementation", "validation"]
114
+ """
115
+
116
+
117
+ DEFAULT_TASKLEDGER_TOML = render_default_taskledger_toml()
118
+ DEFAULT_PROJECT_TOML = DEFAULT_TASKLEDGER_TOML
119
+
120
+
121
+ def load_project_config_overrides(paths: ProjectPaths) -> dict[str, object]:
122
+ data = load_project_config_document(paths.config_path)
123
+ return {key: value for key, value in data.items() if key in WORKFLOW_CONFIG_KEYS}
124
+
125
+
126
+ def load_project_config_document(path: Path) -> dict[str, object]:
127
+ if not path.exists():
128
+ return {}
129
+ try:
130
+ text = path.read_text(encoding="utf-8").strip()
131
+ except OSError as exc:
132
+ raise LaunchError(f"Failed to read {path}: {exc}") from exc
133
+ if not text:
134
+ return {}
135
+ try:
136
+ data = tomllib.loads(text)
137
+ except Exception as exc: # pragma: no cover - tomllib type varies by runtime
138
+ raise LaunchError(f"Invalid project config {path}: {exc}") from exc
139
+ if not isinstance(data, dict):
140
+ raise LaunchError(f"Invalid project config {path}: expected a TOML table.")
141
+ _validate_project_config_overrides(data, path)
142
+ return data
143
+
144
+
145
+ def merge_project_config(
146
+ base: ProjectConfig, overrides: dict[str, object]
147
+ ) -> ProjectConfig:
148
+ default_memory_update_mode = overrides.get(
149
+ "default_memory_update_mode", base.default_memory_update_mode
150
+ )
151
+ default_file_render_mode = overrides.get(
152
+ "default_file_render_mode",
153
+ base.default_file_render_mode,
154
+ )
155
+ default_save_run_reports = overrides.get(
156
+ "default_save_run_reports", base.default_save_run_reports
157
+ )
158
+ default_source_max_chars = overrides.get(
159
+ "default_source_max_chars", base.default_source_max_chars
160
+ )
161
+ default_total_source_max_chars = overrides.get(
162
+ "default_total_source_max_chars", base.default_total_source_max_chars
163
+ )
164
+ default_source_head_lines = overrides.get(
165
+ "default_source_head_lines", base.default_source_head_lines
166
+ )
167
+ default_source_tail_lines = overrides.get(
168
+ "default_source_tail_lines", base.default_source_tail_lines
169
+ )
170
+ default_context_order = overrides.get(
171
+ "default_context_order", list(base.default_context_order)
172
+ )
173
+ workflow_schema = overrides.get("workflow_schema", base.workflow_schema)
174
+ project_context = overrides.get("project_context", base.project_context)
175
+ artifact_rules = _artifact_rules_from_overrides(
176
+ overrides.get("artifact_rules"),
177
+ base.artifact_rules,
178
+ )
179
+ default_artifact_order = overrides.get(
180
+ "default_artifact_order",
181
+ list(base.default_artifact_order),
182
+ )
183
+ if not isinstance(default_memory_update_mode, str):
184
+ raise LaunchError("Project config default_memory_update_mode must be a string.")
185
+ if default_memory_update_mode not in {"replace", "append", "prepend"}:
186
+ raise LaunchError(
187
+ "Project config default_memory_update_mode must be "
188
+ "replace, append, or prepend."
189
+ )
190
+ if not isinstance(default_file_render_mode, str):
191
+ raise LaunchError("Project config default_file_render_mode must be a string.")
192
+ if default_file_render_mode not in {"content", "reference"}:
193
+ raise LaunchError(
194
+ "Project config default_file_render_mode must be content or reference."
195
+ )
196
+ if not isinstance(default_save_run_reports, bool):
197
+ raise LaunchError("Project config default_save_run_reports must be a boolean.")
198
+ for value, label in (
199
+ (default_source_max_chars, "default_source_max_chars"),
200
+ (default_total_source_max_chars, "default_total_source_max_chars"),
201
+ (default_source_head_lines, "default_source_head_lines"),
202
+ (default_source_tail_lines, "default_source_tail_lines"),
203
+ ):
204
+ if value is not None and (not isinstance(value, int) or value <= 0):
205
+ raise LaunchError(f"Project config {label} must be a positive integer.")
206
+ if not isinstance(default_context_order, list) or not all(
207
+ isinstance(item, str) for item in default_context_order
208
+ ):
209
+ raise LaunchError(
210
+ "Project config default_context_order must be a list of strings."
211
+ )
212
+ if workflow_schema is not None and not isinstance(workflow_schema, str):
213
+ raise LaunchError("Project config workflow_schema must be a string.")
214
+ if project_context is not None and not isinstance(project_context, str):
215
+ raise LaunchError("Project config project_context must be a string.")
216
+ if not isinstance(default_artifact_order, list) or not all(
217
+ isinstance(item, str) for item in default_artifact_order
218
+ ):
219
+ raise LaunchError(
220
+ "Project config default_artifact_order must be a list of strings."
221
+ )
222
+ _validate_artifact_order_and_dependencies(
223
+ artifact_rules,
224
+ default_artifact_order=tuple(default_artifact_order),
225
+ )
226
+ return ProjectConfig(
227
+ default_memory_update_mode=cast(
228
+ MemoryUpdateMode,
229
+ default_memory_update_mode,
230
+ ),
231
+ default_file_render_mode=cast(FileRenderMode, default_file_render_mode),
232
+ default_save_run_reports=default_save_run_reports,
233
+ default_source_max_chars=cast(int | None, default_source_max_chars),
234
+ default_total_source_max_chars=cast(int | None, default_total_source_max_chars),
235
+ default_source_head_lines=cast(int | None, default_source_head_lines),
236
+ default_source_tail_lines=cast(int | None, default_source_tail_lines),
237
+ default_context_order=tuple(default_context_order),
238
+ workflow_schema=workflow_schema,
239
+ project_context=project_context,
240
+ artifact_rules=artifact_rules,
241
+ default_artifact_order=tuple(default_artifact_order),
242
+ )
243
+
244
+
245
+ def _validate_project_config_overrides(data: dict[str, object], path: Path) -> None:
246
+ for key in data:
247
+ if key not in SUPPORTED_PROJECT_CONFIG_KEYS:
248
+ raise LaunchError(f"Unsupported project config key '{key}' in {path}")
249
+ config_version = data.get("config_version")
250
+ if config_version is not None and config_version != 1:
251
+ raise LaunchError(f"Project config key 'config_version' must be 1 in {path}")
252
+ taskledger_dir = data.get("taskledger_dir")
253
+ if taskledger_dir is not None and not isinstance(taskledger_dir, str):
254
+ raise LaunchError(
255
+ f"Project config key 'taskledger_dir' must be a string in {path}"
256
+ )
257
+ artifact_rules = data.get("artifact_rules")
258
+ if artifact_rules is not None:
259
+ if not isinstance(artifact_rules, dict):
260
+ raise LaunchError(
261
+ f"Project config key 'artifact_rules' must be a table in {path}"
262
+ )
263
+ parsed_rules = _artifact_rules_from_mapping(artifact_rules, path=path)
264
+ default_order = data.get("default_artifact_order")
265
+ default_artifact_order = (
266
+ tuple(default_order) if isinstance(default_order, list) else ()
267
+ )
268
+ _validate_artifact_order_and_dependencies(
269
+ parsed_rules,
270
+ default_artifact_order=default_artifact_order,
271
+ path=path,
272
+ )
273
+
274
+
275
+ def _artifact_rules_from_overrides(
276
+ raw_rules: object,
277
+ base_rules: tuple[ProjectArtifactRule, ...],
278
+ ) -> tuple[ProjectArtifactRule, ...]:
279
+ if raw_rules is None:
280
+ return base_rules
281
+ if not isinstance(raw_rules, dict):
282
+ raise LaunchError("Project config artifact_rules must be a table.")
283
+ return _artifact_rules_from_mapping(raw_rules)
284
+
285
+
286
+ def _artifact_rules_from_mapping(
287
+ raw_rules: dict[str, object],
288
+ *,
289
+ path: Path | None = None,
290
+ ) -> tuple[ProjectArtifactRule, ...]:
291
+ rules: list[ProjectArtifactRule] = []
292
+ for name, value in raw_rules.items():
293
+ if not isinstance(value, dict):
294
+ raise LaunchError(_artifact_rule_error(name, "must be a table", path=path))
295
+ for key in value:
296
+ if key not in {"depends_on", "memory_ref_field", "label", "description"}:
297
+ raise LaunchError(
298
+ _artifact_rule_error(
299
+ name,
300
+ f"has unsupported key '{key}'",
301
+ path=path,
302
+ )
303
+ )
304
+ depends_on = value.get("depends_on", [])
305
+ if not isinstance(depends_on, list) or not all(
306
+ isinstance(item, str) for item in depends_on
307
+ ):
308
+ raise LaunchError(
309
+ _artifact_rule_error(
310
+ name,
311
+ "depends_on must be a list of strings",
312
+ path=path,
313
+ )
314
+ )
315
+ memory_ref_field = value.get("memory_ref_field")
316
+ if memory_ref_field is not None and (
317
+ not isinstance(memory_ref_field, str)
318
+ or memory_ref_field not in ARTIFACT_MEMORY_REF_FIELDS
319
+ ):
320
+ allowed = ", ".join(ARTIFACT_MEMORY_REF_FIELDS)
321
+ raise LaunchError(
322
+ _artifact_rule_error(
323
+ name,
324
+ f"memory_ref_field must be one of: {allowed}",
325
+ path=path,
326
+ )
327
+ )
328
+ label = value.get("label")
329
+ if label is not None and not isinstance(label, str):
330
+ raise LaunchError(
331
+ _artifact_rule_error(name, "label must be a string", path=path)
332
+ )
333
+ description = value.get("description")
334
+ if description is not None and not isinstance(description, str):
335
+ raise LaunchError(
336
+ _artifact_rule_error(name, "description must be a string", path=path)
337
+ )
338
+ rules.append(
339
+ ProjectArtifactRule(
340
+ name=name,
341
+ depends_on=tuple(depends_on),
342
+ memory_ref_field=memory_ref_field,
343
+ label=label,
344
+ description=description,
345
+ )
346
+ )
347
+ return tuple(rules)
348
+
349
+
350
+ def _validate_artifact_order_and_dependencies(
351
+ artifact_rules: tuple[ProjectArtifactRule, ...],
352
+ *,
353
+ default_artifact_order: tuple[str, ...],
354
+ path: Path | None = None,
355
+ ) -> None:
356
+ if not artifact_rules:
357
+ return
358
+ names = {rule.name for rule in artifact_rules}
359
+ if len(names) != len(artifact_rules):
360
+ raise LaunchError(
361
+ _project_config_error("Artifact rule names must be unique.", path)
362
+ )
363
+ for rule in artifact_rules:
364
+ for dependency in rule.depends_on:
365
+ if dependency not in names:
366
+ raise LaunchError(
367
+ _project_config_error(
368
+ "Artifact rule "
369
+ f"'{rule.name}' depends on unknown artifact "
370
+ f"'{dependency}'.",
371
+ path,
372
+ )
373
+ )
374
+ if default_artifact_order:
375
+ unknown = [name for name in default_artifact_order if name not in names]
376
+ if unknown:
377
+ raise LaunchError(
378
+ _project_config_error(
379
+ "default_artifact_order references unknown artifacts: "
380
+ + ", ".join(sorted(unknown)),
381
+ path,
382
+ )
383
+ )
384
+
385
+
386
+ def _artifact_rule_error(name: str, message: str, *, path: Path | None) -> str:
387
+ return _project_config_error(f"Artifact rule '{name}' {message}.", path)
388
+
389
+
390
+ def _project_config_error(message: str, path: Path | None) -> str:
391
+ if path is None:
392
+ return message
393
+ return f"{message} in {path}"
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+ from typing import Literal, cast
6
+
7
+ from taskledger.errors import LaunchError
8
+ from taskledger.ids import (
9
+ slugify_project_ref as _slugify,
10
+ )
11
+ from taskledger.storage.common import load_json_array as _load_json_array
12
+ from taskledger.storage.common import (
13
+ relative_to_workspace as _relative_to_workspace,
14
+ )
15
+ from taskledger.storage.common import write_json as _write_json
16
+ from taskledger.storage.paths import ProjectPaths
17
+ from taskledger.timeutils import utc_now_iso
18
+
19
+ ProjectRepoKind = Literal["odoo", "enterprise", "custom", "shared", "generic"]
20
+
21
+
22
+ @dataclass(slots=True, frozen=True)
23
+ class ProjectRepo:
24
+ name: str
25
+ slug: str
26
+ path: str
27
+ kind: ProjectRepoKind
28
+ branch: str | None
29
+ notes: str | None
30
+ created_at: str
31
+ updated_at: str
32
+ role: Literal["read", "write", "both"] = "read"
33
+ preferred_for_execution: bool = False
34
+
35
+ def to_dict(self) -> dict[str, object]:
36
+ return {
37
+ "name": self.name,
38
+ "slug": self.slug,
39
+ "path": self.path,
40
+ "kind": self.kind,
41
+ "branch": self.branch,
42
+ "notes": self.notes,
43
+ "created_at": self.created_at,
44
+ "updated_at": self.updated_at,
45
+ "role": self.role,
46
+ "preferred_for_execution": self.preferred_for_execution,
47
+ }
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, object]) -> ProjectRepo:
51
+ kind = _string_value(data, "kind")
52
+ if kind not in {"odoo", "enterprise", "custom", "shared", "generic"}:
53
+ raise ValueError(f"Unsupported project repo kind: {kind}")
54
+ role = _optional_string_value(data, "role") or "read"
55
+ if role not in {"read", "write", "both"}:
56
+ raise ValueError(f"Unsupported project repo role: {role}")
57
+ return cls(
58
+ name=_string_value(data, "name"),
59
+ slug=_string_value(data, "slug"),
60
+ path=_string_value(data, "path"),
61
+ kind=cast(ProjectRepoKind, kind),
62
+ branch=_optional_string_value(data, "branch"),
63
+ notes=_optional_string_value(data, "notes"),
64
+ created_at=_string_value(data, "created_at"),
65
+ updated_at=_string_value(data, "updated_at"),
66
+ role=cast(Literal["read", "write", "both"], role),
67
+ preferred_for_execution=bool(data.get("preferred_for_execution", False)),
68
+ )
69
+
70
+
71
+ def load_repos(paths: ProjectPaths) -> list[ProjectRepo]:
72
+ return [
73
+ ProjectRepo.from_dict(item)
74
+ for item in _load_json_array(paths.repo_index_path, "project repo index")
75
+ ]
76
+
77
+
78
+ def save_repos(paths: ProjectPaths, repos: list[ProjectRepo]) -> None:
79
+ _write_json(paths.repo_index_path, [item.to_dict() for item in repos])
80
+
81
+
82
+ def add_repo(
83
+ paths: ProjectPaths,
84
+ *,
85
+ name: str,
86
+ path: Path,
87
+ kind: str = "generic",
88
+ branch: str | None = None,
89
+ notes: str | None = None,
90
+ role: str = "read",
91
+ preferred_for_execution: bool = False,
92
+ ) -> ProjectRepo:
93
+ if kind not in {"odoo", "enterprise", "custom", "shared", "generic"}:
94
+ raise LaunchError(f"Unsupported project repo kind: {kind}")
95
+ if role not in {"read", "write", "both"}:
96
+ raise LaunchError(f"Unsupported project repo role: {role}")
97
+ if preferred_for_execution and role == "read":
98
+ raise LaunchError(
99
+ "Read-only repos cannot be marked as the default execution repo."
100
+ )
101
+ repos = load_repos(paths)
102
+ slug = _slugify(name)
103
+ _ensure_unique_repo_identity(repos, name=name, slug=slug)
104
+ repo_path = path.expanduser()
105
+ if not repo_path.is_absolute():
106
+ repo_path = paths.workspace_root / repo_path
107
+ repo_path = repo_path.resolve()
108
+ if not repo_path.exists() or not repo_path.is_dir():
109
+ raise LaunchError(f"Project repo path does not exist: {path}")
110
+ now = utc_now_iso()
111
+ repo = ProjectRepo(
112
+ name=name,
113
+ slug=slug,
114
+ path=_relative_to_workspace(paths, repo_path),
115
+ kind=cast(ProjectRepoKind, kind),
116
+ branch=branch,
117
+ notes=notes,
118
+ created_at=now,
119
+ updated_at=now,
120
+ role=cast(Literal["read", "write", "both"], role),
121
+ preferred_for_execution=preferred_for_execution,
122
+ )
123
+ repos.append(repo)
124
+ repos.sort(key=lambda item: item.slug)
125
+ save_repos(
126
+ paths,
127
+ _preferred_repo_state(repos, preferred_slug=repo.slug)
128
+ if preferred_for_execution
129
+ else repos,
130
+ )
131
+ return repo
132
+
133
+
134
+ def resolve_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
135
+ repos = load_repos(paths)
136
+ normalized_ref = _slugify(ref)
137
+ candidates = [
138
+ item
139
+ for item in repos
140
+ if item.name == ref or item.slug == ref or item.slug == normalized_ref
141
+ ]
142
+ if not candidates:
143
+ raise LaunchError(f"Unknown project repo: {ref}")
144
+ if len(candidates) > 1:
145
+ raise LaunchError(f"Ambiguous project repo ref: {ref}")
146
+ return candidates[0]
147
+
148
+
149
+ def resolve_repo_root(paths: ProjectPaths, ref: str) -> Path:
150
+ repo = resolve_repo(paths, ref)
151
+ candidate = Path(repo.path)
152
+ if not candidate.is_absolute():
153
+ candidate = paths.workspace_root / candidate
154
+ candidate = candidate.resolve()
155
+ if not candidate.exists() or not candidate.is_dir():
156
+ raise LaunchError(f"Project repo path does not exist: {repo.path}")
157
+ return candidate
158
+
159
+
160
+ def remove_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
161
+ repos = load_repos(paths)
162
+ repo = resolve_repo(paths, ref)
163
+ remaining = [item for item in repos if item.slug != repo.slug]
164
+ save_repos(paths, remaining)
165
+ return repo
166
+
167
+
168
+ def set_repo_role(paths: ProjectPaths, ref: str, *, role: str) -> ProjectRepo:
169
+ if role not in {"read", "write", "both"}:
170
+ raise LaunchError(f"Unsupported project repo role: {role}")
171
+ repos = load_repos(paths)
172
+ repo = resolve_repo(paths, ref)
173
+ if role == "read" and repo.preferred_for_execution:
174
+ raise LaunchError(
175
+ "Default execution repo cannot be changed to read-only. "
176
+ "Set a different default first or clear the default execution repo."
177
+ )
178
+ updated = replace(
179
+ repo,
180
+ role=cast(Literal["read", "write", "both"], role),
181
+ updated_at=utc_now_iso(),
182
+ )
183
+ for index, item in enumerate(repos):
184
+ if item.slug == repo.slug:
185
+ repos[index] = updated
186
+ break
187
+ save_repos(paths, repos)
188
+ return updated
189
+
190
+
191
+ def set_default_execution_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
192
+ repos = load_repos(paths)
193
+ repo = resolve_repo(paths, ref)
194
+ if repo.role == "read":
195
+ raise LaunchError(
196
+ f"Project repo {repo.name} is read-only and cannot be the default "
197
+ "execution repo."
198
+ )
199
+ updated_repos = _preferred_repo_state(repos, preferred_slug=repo.slug)
200
+ save_repos(paths, updated_repos)
201
+ return next(item for item in updated_repos if item.slug == repo.slug)
202
+
203
+
204
+ def clear_default_execution_repo(paths: ProjectPaths) -> list[ProjectRepo]:
205
+ repos = load_repos(paths)
206
+ updated_repos = _preferred_repo_state(repos, preferred_slug=None)
207
+ save_repos(paths, updated_repos)
208
+ return updated_repos
209
+
210
+
211
+ def _ensure_unique_repo_identity(
212
+ repos: list[ProjectRepo], *, name: str, slug: str
213
+ ) -> None:
214
+ for item in repos:
215
+ if item.name == name:
216
+ raise LaunchError(f"Project repo already exists with name: {name}")
217
+ if item.slug == slug:
218
+ raise LaunchError(f"Project repo already exists with slug: {slug}")
219
+
220
+
221
+ def _preferred_repo_state(
222
+ repos: list[ProjectRepo], *, preferred_slug: str | None
223
+ ) -> list[ProjectRepo]:
224
+ changed = False
225
+ updated: list[ProjectRepo] = []
226
+ timestamp = utc_now_iso()
227
+ for item in repos:
228
+ preferred = item.slug == preferred_slug if preferred_slug is not None else False
229
+ if item.preferred_for_execution == preferred:
230
+ updated.append(item)
231
+ continue
232
+ updated.append(
233
+ replace(
234
+ item,
235
+ preferred_for_execution=preferred,
236
+ updated_at=timestamp,
237
+ )
238
+ )
239
+ changed = True
240
+ return updated if changed else repos
241
+
242
+
243
+ def _string_value(data: dict[str, object], key: str) -> str:
244
+ value = data.get(key)
245
+ if not isinstance(value, str) or not value:
246
+ raise ValueError(f"Missing or invalid {key}")
247
+ return value
248
+
249
+
250
+ def _optional_string_value(data: dict[str, object], key: str) -> str | None:
251
+ value = data.get(key)
252
+ if value is None:
253
+ return None
254
+ if not isinstance(value, str):
255
+ raise ValueError(f"Invalid {key}")
256
+ return value