open-codev-workflow 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 (47) hide show
  1. codev_workflow/__init__.py +5 -0
  2. codev_workflow/__main__.py +4 -0
  3. codev_workflow/bundle/.agents/skills/build-change/SKILL.md +96 -0
  4. codev_workflow/bundle/.agents/skills/build-change/agents/openai.yaml +4 -0
  5. codev_workflow/bundle/.agents/skills/build-change/assets/implementation-plan.template.md +51 -0
  6. codev_workflow/bundle/.agents/skills/define-product/SKILL.md +79 -0
  7. codev_workflow/bundle/.agents/skills/define-product/agents/openai.yaml +4 -0
  8. codev_workflow/bundle/.agents/skills/define-product/assets/brief.template.md +50 -0
  9. codev_workflow/bundle/.agents/skills/design-solution/SKILL.md +75 -0
  10. codev_workflow/bundle/.agents/skills/design-solution/agents/openai.yaml +4 -0
  11. codev_workflow/bundle/.agents/skills/design-solution/assets/decision.template.md +26 -0
  12. codev_workflow/bundle/.agents/skills/design-solution/assets/design.template.md +76 -0
  13. codev_workflow/bundle/.agents/skills/launch-product/SKILL.md +66 -0
  14. codev_workflow/bundle/.agents/skills/launch-product/agents/openai.yaml +4 -0
  15. codev_workflow/bundle/.agents/skills/launch-product/assets/launch-plan.template.md +48 -0
  16. codev_workflow/bundle/.agents/skills/plan-delivery/SKILL.md +140 -0
  17. codev_workflow/bundle/.agents/skills/plan-delivery/agents/openai.yaml +4 -0
  18. codev_workflow/bundle/.agents/skills/plan-delivery/assets/delivery-plan.template.md +41 -0
  19. codev_workflow/bundle/.agents/skills/review-change/SKILL.md +48 -0
  20. codev_workflow/bundle/.agents/skills/review-change/agents/openai.yaml +4 -0
  21. codev_workflow/bundle/.agents/skills/specify-project/SKILL.md +205 -0
  22. codev_workflow/bundle/.agents/skills/specify-project/agents/openai.yaml +4 -0
  23. codev_workflow/bundle/.agents/skills/specify-project/assets/specification.template.md +151 -0
  24. codev_workflow/bundle/.agents/skills/specify-project/references/interview-coverage.md +303 -0
  25. codev_workflow/bundle/.agents/skills/specify-project/scripts/validate_specification.py +143 -0
  26. codev_workflow/bundle/.opencode/agents/builder.md +54 -0
  27. codev_workflow/bundle/.opencode/agents/orchestrator.md +72 -0
  28. codev_workflow/bundle/.opencode/agents/reviewer.md +35 -0
  29. codev_workflow/bundle/AGENTS.md +23 -0
  30. codev_workflow/bundle/docs/AI-WORKFLOW-PROMPTS.md +318 -0
  31. codev_workflow/bundle/docs/WORKFLOW-COOKBOOK.md +419 -0
  32. codev_workflow/bundle/docs/WORKFLOW-HUMAN.md +212 -0
  33. codev_workflow/bundle/docs/for-ai/WORKFLOW-AGENTS.md +171 -0
  34. codev_workflow/bundle/docs/handbooks/IDEA-TO-PRODUCTION-HANDBOOK.md +1190 -0
  35. codev_workflow/bundle/docs/handbooks/LANGUAGE-AGNOSTIC-PROJECT-HANDBOOK.md +745 -0
  36. codev_workflow/bundle/docs/handbooks/PYTHON-PROJECT-HANDBOOK.md +960 -0
  37. codev_workflow/bundle/evals/development-workflow/scenarios.json +132 -0
  38. codev_workflow/bundle/scripts/evaluate-development-workflow.py +352 -0
  39. codev_workflow/bundle/scripts/validate-development-workflow.py +213 -0
  40. codev_workflow/cli.py +140 -0
  41. codev_workflow/installer.py +891 -0
  42. open_codev_workflow-0.1.0.dist-info/METADATA +150 -0
  43. open_codev_workflow-0.1.0.dist-info/RECORD +47 -0
  44. open_codev_workflow-0.1.0.dist-info/WHEEL +5 -0
  45. open_codev_workflow-0.1.0.dist-info/entry_points.txt +2 -0
  46. open_codev_workflow-0.1.0.dist-info/licenses/LICENSE +28 -0
  47. open_codev_workflow-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,891 @@
1
+ """Conflict-aware installation of the CoDev workflow bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass, field
10
+ from importlib import resources
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any
13
+
14
+ from codev_workflow import __version__
15
+
16
+ LOCK_SCHEMA_VERSION = 1
17
+ LOCK_PATH = PurePosixPath(".codev/lock.json")
18
+ AGENTS_START = "<!-- codev:start -->"
19
+ AGENTS_END = "<!-- codev:end -->"
20
+ VALID_PLATFORMS = frozenset({"codex", "opencode"})
21
+ OPENCODE_AGENT_CONFIGS: dict[str, dict[str, str]] = {
22
+ "orchestrator": {
23
+ "model": "openai/gpt-5.6-luna",
24
+ "description": "Human-controlled workflow and work-item orchestrator",
25
+ },
26
+ "builder": {
27
+ "model": "openai/gpt-5.6-luna",
28
+ "description": "Bounded implementation subagent",
29
+ },
30
+ "reviewer": {
31
+ "model": "openai/gpt-5.6-luna",
32
+ "description": "Independent evidence-based code reviewer",
33
+ },
34
+ }
35
+
36
+ AGENTS_BLOCK = """<!-- codev:start -->
37
+ ## CoDev human-AI delivery
38
+
39
+ Read `docs/for-ai/WORKFLOW-AGENTS.md` before planning or implementing product
40
+ work. Route requests internally through the installed skills and describe the
41
+ current human-facing step as `Understand`, `Build`, `Review`, or `Ship`.
42
+
43
+ Use the lightest safe path. Inspect repository facts before prescribing code,
44
+ keep changes bounded and reviewable, run proportionate validation, and stop for
45
+ material decisions instead of inventing them. Humans retain authority for
46
+ acceptance, merge, deployment, migration, publication, and rollout expansion.
47
+ <!-- codev:end -->"""
48
+
49
+
50
+ class CoDevError(RuntimeError):
51
+ """Raised when an installation cannot be evaluated safely."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Operation:
56
+ """One observable action in an installation plan."""
57
+
58
+ kind: str
59
+ path: str
60
+ detail: str = ""
61
+
62
+
63
+ @dataclass
64
+ class Plan:
65
+ """A completely preflighted repository mutation."""
66
+
67
+ operations: list[Operation] = field(default_factory=list)
68
+ writes: dict[Path, bytes] = field(default_factory=dict, repr=False)
69
+ deletions: set[Path] = field(default_factory=set, repr=False)
70
+ lock: dict[str, Any] | None = field(default=None, repr=False)
71
+ remove_lock: bool = False
72
+
73
+ @property
74
+ def conflicts(self) -> list[Operation]:
75
+ return [item for item in self.operations if item.kind == "conflict"]
76
+
77
+ @property
78
+ def changed(self) -> list[Operation]:
79
+ return [
80
+ item
81
+ for item in self.operations
82
+ if item.kind in {"add", "update", "integrate", "remove", "retire"}
83
+ ]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class CheckResult:
88
+ """Health of one installed target."""
89
+
90
+ version: str
91
+ issues: tuple[str, ...]
92
+ managed_files: int
93
+
94
+ @property
95
+ def ok(self) -> bool:
96
+ return not self.issues
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class OpenCodePreparation:
101
+ """The result of safely integrating the OpenCode configuration."""
102
+
103
+ content: bytes | None
104
+ default_agent_managed: bool
105
+ managed_agents: dict[str, str]
106
+ schema_managed: bool
107
+ agent_container_managed: bool
108
+ config_file_managed: bool
109
+ detail: str
110
+
111
+
112
+ def normalize_platforms(platforms: Iterable[str]) -> tuple[str, ...]:
113
+ selected = set(platforms)
114
+ if "all" in selected:
115
+ selected = set(VALID_PLATFORMS)
116
+ unknown = selected - VALID_PLATFORMS
117
+ if unknown:
118
+ raise CoDevError("unknown platform: " + ", ".join(sorted(unknown)))
119
+ if not selected:
120
+ selected = set(VALID_PLATFORMS)
121
+ return tuple(sorted(selected))
122
+
123
+
124
+ def _sha256(content: bytes) -> str:
125
+ return hashlib.sha256(content).hexdigest()
126
+
127
+
128
+ def _normalise_newlines(value: str) -> str:
129
+ return value.replace("\r\n", "\n").replace("\r", "\n")
130
+
131
+
132
+ def _block_hash(value: str) -> str:
133
+ return _sha256(_normalise_newlines(value).encode("utf-8"))
134
+
135
+
136
+ def _json_hash(value: Any) -> str:
137
+ rendered = json.dumps(
138
+ value,
139
+ ensure_ascii=False,
140
+ sort_keys=True,
141
+ separators=(",", ":"),
142
+ )
143
+ return _sha256(rendered.encode("utf-8"))
144
+
145
+
146
+ def _walk_bundle() -> dict[str, bytes]:
147
+ root = resources.files("codev_workflow").joinpath("bundle")
148
+ found: dict[str, bytes] = {}
149
+
150
+ def visit(node: Any, prefix: PurePosixPath) -> None:
151
+ for child in sorted(node.iterdir(), key=lambda item: item.name):
152
+ if child.name == "__pycache__" or child.name.endswith(".pyc"):
153
+ continue
154
+ relative = prefix / child.name
155
+ if child.is_dir():
156
+ visit(child, relative)
157
+ elif child.is_file():
158
+ found[relative.as_posix()] = child.read_bytes()
159
+
160
+ visit(root, PurePosixPath())
161
+ return found
162
+
163
+
164
+ def _bundle_files(platforms: tuple[str, ...]) -> dict[str, bytes]:
165
+ files = _walk_bundle()
166
+ # The validator needs a complete policy fixture at the bundle root, while
167
+ # target repositories receive the conflict-safe managed block instead.
168
+ files.pop("AGENTS.md", None)
169
+ if "opencode" not in platforms:
170
+ files = {
171
+ path: content
172
+ for path, content in files.items()
173
+ if not path.startswith(".opencode/")
174
+ }
175
+ return files
176
+
177
+
178
+ def _read_lock(target: Path) -> dict[str, Any]:
179
+ path = target / Path(LOCK_PATH.as_posix())
180
+ try:
181
+ raw = json.loads(path.read_text(encoding="utf-8"))
182
+ except FileNotFoundError as error:
183
+ raise CoDevError(f"CoDev is not installed in {target}") from error
184
+ except (OSError, json.JSONDecodeError) as error:
185
+ raise CoDevError(f"cannot read {path}: {error}") from error
186
+ if not isinstance(raw, dict):
187
+ raise CoDevError(f"{path} must contain a JSON object")
188
+ if raw.get("schema_version") != LOCK_SCHEMA_VERSION:
189
+ raise CoDevError(
190
+ f"unsupported lock schema {raw.get('schema_version')!r}; "
191
+ "install a compatible CoDev version"
192
+ )
193
+ if not isinstance(raw.get("files"), dict):
194
+ raise CoDevError(f"{path} has no valid files map")
195
+ return raw
196
+
197
+
198
+ def _atomic_write(path: Path, content: bytes) -> None:
199
+ path.parent.mkdir(parents=True, exist_ok=True)
200
+ temporary = path.with_name(f".{path.name}.codev.tmp")
201
+ try:
202
+ temporary.write_bytes(content)
203
+ os.replace(temporary, path)
204
+ finally:
205
+ if temporary.exists():
206
+ temporary.unlink()
207
+
208
+
209
+ def _remove_empty_parent_dirs(path: Path, target: Path) -> None:
210
+ """Remove empty managed-file parents without touching the target repository."""
211
+
212
+ current = path.parent
213
+ while current != target:
214
+ try:
215
+ current.rmdir()
216
+ except OSError:
217
+ return
218
+ current = current.parent
219
+
220
+
221
+ def _agent_block_from(text: str) -> str | None:
222
+ start = text.find(AGENTS_START)
223
+ end = text.find(AGENTS_END)
224
+ if start < 0 and end < 0:
225
+ return None
226
+ if start < 0 or end < start:
227
+ raise CoDevError("AGENTS.md contains incomplete CoDev markers")
228
+ end += len(AGENTS_END)
229
+ if text.find(AGENTS_START, start + len(AGENTS_START)) >= 0:
230
+ raise CoDevError("AGENTS.md contains more than one CoDev block")
231
+ return text[start:end]
232
+
233
+
234
+ def _with_agent_block(text: str, block: str) -> str:
235
+ current = _agent_block_from(text)
236
+ newline = "\r\n" if "\r\n" in text else "\n"
237
+ rendered = block.replace("\n", newline)
238
+ if current is None:
239
+ prefix = text.rstrip("\r\n")
240
+ if prefix:
241
+ return prefix + newline * 2 + rendered + newline
242
+ return rendered + newline
243
+ return text.replace(current, rendered, 1)
244
+
245
+
246
+ def _without_agent_block(text: str) -> str:
247
+ current = _agent_block_from(text)
248
+ if current is None:
249
+ return text
250
+ start = text.find(current)
251
+ end = start + len(current)
252
+ newline = "\r\n" if "\r\n" in text else "\n"
253
+ prefix = text[:start]
254
+ suffix = text[end:]
255
+ if prefix.endswith(newline * 2):
256
+ prefix = prefix[: -len(newline * 2)]
257
+ if suffix.startswith(newline):
258
+ suffix = suffix[len(newline) :]
259
+ return prefix + suffix
260
+
261
+
262
+ def _prepare_opencode(
263
+ target: Path,
264
+ managed_agents: dict[str, str] | None = None,
265
+ *,
266
+ schema_managed: bool = False,
267
+ agent_container_managed: bool = False,
268
+ config_file_managed: bool = False,
269
+ ) -> OpenCodePreparation:
270
+ path = target / ".opencode" / "opencode.json"
271
+ if path.exists():
272
+ try:
273
+ config = json.loads(path.read_text(encoding="utf-8"))
274
+ except (OSError, json.JSONDecodeError) as error:
275
+ raise CoDevError(f"cannot merge {path}: {error}") from error
276
+ if not isinstance(config, dict):
277
+ raise CoDevError(f"{path} must contain a JSON object")
278
+ else:
279
+ config = {}
280
+ config_file_managed = True
281
+
282
+ managed_agents = dict(managed_agents or {})
283
+ changed = False
284
+ if "$schema" not in config:
285
+ config["$schema"] = "https://opencode.ai/config.json"
286
+ schema_managed = True
287
+ changed = True
288
+
289
+ default_managed = False
290
+ detail = "existing default agent preserved"
291
+ if "default_agent" not in config:
292
+ config["default_agent"] = "orchestrator"
293
+ default_managed = True
294
+ changed = True
295
+ detail = "set orchestrator as the default agent"
296
+ elif config.get("default_agent") == "orchestrator":
297
+ detail = "orchestrator already configured"
298
+
299
+ agents = config.get("agent")
300
+ if agents is None:
301
+ agents = {}
302
+ config["agent"] = agents
303
+ agent_container_managed = True
304
+ changed = True
305
+ elif not isinstance(agents, dict):
306
+ raise CoDevError(f"cannot merge {path}: agent must contain a JSON object")
307
+
308
+ integrated_agents: list[str] = []
309
+ for name, expected in OPENCODE_AGENT_CONFIGS.items():
310
+ current = agents.get(name)
311
+ expected_hash = _json_hash(expected)
312
+ old_hash = managed_agents.get(name)
313
+ if old_hash is not None:
314
+ if not isinstance(current, dict) or _json_hash(current) != old_hash:
315
+ raise CoDevError(
316
+ f"managed OpenCode agent {name!r} was modified or removed"
317
+ )
318
+ if current != expected:
319
+ agents[name] = expected
320
+ changed = True
321
+ managed_agents[name] = expected_hash
322
+ continue
323
+ if name not in agents:
324
+ agents[name] = expected
325
+ managed_agents[name] = expected_hash
326
+ integrated_agents.append(name)
327
+ changed = True
328
+
329
+ if integrated_agents:
330
+ detail = "integrated OpenCode agents: " + ", ".join(integrated_agents)
331
+
332
+ if not changed:
333
+ return OpenCodePreparation(
334
+ None,
335
+ default_managed,
336
+ managed_agents,
337
+ schema_managed,
338
+ agent_container_managed,
339
+ config_file_managed,
340
+ detail,
341
+ )
342
+ content = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
343
+ return OpenCodePreparation(
344
+ content,
345
+ default_managed,
346
+ managed_agents,
347
+ schema_managed,
348
+ agent_container_managed,
349
+ config_file_managed,
350
+ detail,
351
+ )
352
+
353
+
354
+ def _new_lock(
355
+ platforms: tuple[str, ...],
356
+ files: dict[str, bytes],
357
+ *,
358
+ default_agent_managed: bool,
359
+ managed_opencode_agents: dict[str, str],
360
+ opencode_schema_managed: bool,
361
+ opencode_agent_container_managed: bool,
362
+ opencode_config_file_managed: bool,
363
+ ) -> dict[str, Any]:
364
+ return {
365
+ "schema_version": LOCK_SCHEMA_VERSION,
366
+ "bundle_version": __version__,
367
+ "platforms": list(platforms),
368
+ "files": {path: _sha256(files[path]) for path in sorted(files)},
369
+ "integrations": {
370
+ "agents_block_hash": _block_hash(AGENTS_BLOCK),
371
+ "opencode_default_agent_managed": default_agent_managed,
372
+ "opencode_agent_hashes": dict(sorted(managed_opencode_agents.items())),
373
+ "opencode_schema_managed": opencode_schema_managed,
374
+ "opencode_agent_container_managed": opencode_agent_container_managed,
375
+ "opencode_config_file_managed": opencode_config_file_managed,
376
+ },
377
+ }
378
+
379
+
380
+ def plan_init(target: Path, platforms: Iterable[str] = ("all",)) -> Plan:
381
+ target = target.resolve()
382
+ if (target / Path(LOCK_PATH.as_posix())).exists():
383
+ raise CoDevError("CoDev is already installed; use diff or update")
384
+ selected = normalize_platforms(platforms)
385
+ files = _bundle_files(selected)
386
+ plan = Plan()
387
+
388
+ for relative, content in sorted(files.items()):
389
+ destination = target / Path(relative)
390
+ if not destination.exists():
391
+ plan.operations.append(Operation("add", relative))
392
+ plan.writes[destination] = content
393
+ elif destination.is_file() and destination.read_bytes() == content:
394
+ plan.operations.append(
395
+ Operation("keep", relative, "identical file adopted")
396
+ )
397
+ else:
398
+ plan.operations.append(
399
+ Operation("conflict", relative, "different file already exists")
400
+ )
401
+
402
+ agents_path = target / "AGENTS.md"
403
+ existing_agents = (
404
+ agents_path.read_text(encoding="utf-8") if agents_path.exists() else ""
405
+ )
406
+ try:
407
+ existing_block = _agent_block_from(existing_agents)
408
+ except CoDevError as error:
409
+ plan.operations.append(Operation("conflict", "AGENTS.md", str(error)))
410
+ else:
411
+ if existing_block is None:
412
+ merged = _with_agent_block(existing_agents, AGENTS_BLOCK)
413
+ plan.writes[agents_path] = merged.encode("utf-8")
414
+ plan.operations.append(
415
+ Operation("integrate", "AGENTS.md", "append managed policy block")
416
+ )
417
+ elif _block_hash(existing_block) == _block_hash(AGENTS_BLOCK):
418
+ plan.operations.append(
419
+ Operation("keep", "AGENTS.md", "policy block exists")
420
+ )
421
+ else:
422
+ plan.operations.append(
423
+ Operation("conflict", "AGENTS.md", "different CoDev block exists")
424
+ )
425
+
426
+ default_agent_managed = False
427
+ managed_opencode_agents: dict[str, str] = {}
428
+ opencode_schema_managed = False
429
+ opencode_agent_container_managed = False
430
+ opencode_config_file_managed = False
431
+ if "opencode" in selected:
432
+ try:
433
+ opencode = _prepare_opencode(target)
434
+ except CoDevError as error:
435
+ plan.operations.append(
436
+ Operation("conflict", ".opencode/opencode.json", str(error))
437
+ )
438
+ else:
439
+ default_agent_managed = opencode.default_agent_managed
440
+ managed_opencode_agents = opencode.managed_agents
441
+ opencode_schema_managed = opencode.schema_managed
442
+ opencode_agent_container_managed = opencode.agent_container_managed
443
+ opencode_config_file_managed = opencode.config_file_managed
444
+ if opencode.content is not None:
445
+ plan.writes[target / ".opencode" / "opencode.json"] = opencode.content
446
+ plan.operations.append(
447
+ Operation("integrate", ".opencode/opencode.json", opencode.detail)
448
+ )
449
+ else:
450
+ plan.operations.append(
451
+ Operation("keep", ".opencode/opencode.json", opencode.detail)
452
+ )
453
+
454
+ plan.lock = _new_lock(
455
+ selected,
456
+ files,
457
+ default_agent_managed=default_agent_managed,
458
+ managed_opencode_agents=managed_opencode_agents,
459
+ opencode_schema_managed=opencode_schema_managed,
460
+ opencode_agent_container_managed=opencode_agent_container_managed,
461
+ opencode_config_file_managed=opencode_config_file_managed,
462
+ )
463
+ return plan
464
+
465
+
466
+ def _replace_agent_block_for_update(target: Path, old_hash: str, plan: Plan) -> None:
467
+ agents_path = target / "AGENTS.md"
468
+ if not agents_path.exists():
469
+ plan.operations.append(
470
+ Operation("conflict", "AGENTS.md", "managed policy block is missing")
471
+ )
472
+ return
473
+ text = agents_path.read_text(encoding="utf-8")
474
+ try:
475
+ current = _agent_block_from(text)
476
+ except CoDevError as error:
477
+ plan.operations.append(Operation("conflict", "AGENTS.md", str(error)))
478
+ return
479
+ if current is None:
480
+ plan.operations.append(
481
+ Operation("conflict", "AGENTS.md", "managed policy block is missing")
482
+ )
483
+ return
484
+ current_hash = _block_hash(current)
485
+ new_hash = _block_hash(AGENTS_BLOCK)
486
+ if current_hash == new_hash:
487
+ plan.operations.append(Operation("keep", "AGENTS.md", "policy block current"))
488
+ elif current_hash == old_hash:
489
+ plan.operations.append(Operation("update", "AGENTS.md", "policy block"))
490
+ plan.writes[agents_path] = _with_agent_block(text, AGENTS_BLOCK).encode("utf-8")
491
+ else:
492
+ plan.operations.append(
493
+ Operation("conflict", "AGENTS.md", "managed policy block was modified")
494
+ )
495
+
496
+
497
+ def plan_update(target: Path) -> Plan:
498
+ target = target.resolve()
499
+ lock = _read_lock(target)
500
+ selected = normalize_platforms(lock.get("platforms", []))
501
+ new_files = _bundle_files(selected)
502
+ old_files = lock["files"]
503
+ valid_entries = all(
504
+ isinstance(path, str) and isinstance(value, str)
505
+ for path, value in old_files.items()
506
+ )
507
+ if not valid_entries:
508
+ raise CoDevError("lock file contains an invalid managed-file entry")
509
+
510
+ plan = Plan()
511
+ for relative in sorted(set(old_files) | set(new_files)):
512
+ destination = target / Path(relative)
513
+ old_hash = old_files.get(relative)
514
+ content = new_files.get(relative)
515
+ if content is None:
516
+ plan.operations.append(
517
+ Operation("retire", relative, "upstream removed; retained locally")
518
+ )
519
+ continue
520
+ new_hash = _sha256(content)
521
+ if old_hash is None:
522
+ if not destination.exists():
523
+ plan.operations.append(Operation("add", relative, "new bundle file"))
524
+ plan.writes[destination] = content
525
+ elif (
526
+ destination.is_file() and _sha256(destination.read_bytes()) == new_hash
527
+ ):
528
+ plan.operations.append(Operation("keep", relative, "new file adopted"))
529
+ else:
530
+ plan.operations.append(
531
+ Operation("conflict", relative, "new bundle file collides locally")
532
+ )
533
+ continue
534
+ if not destination.is_file():
535
+ plan.operations.append(
536
+ Operation("conflict", relative, "managed file is missing or not a file")
537
+ )
538
+ continue
539
+ current_hash = _sha256(destination.read_bytes())
540
+ if current_hash == new_hash:
541
+ plan.operations.append(Operation("keep", relative))
542
+ elif current_hash == old_hash:
543
+ plan.operations.append(Operation("update", relative))
544
+ plan.writes[destination] = content
545
+ elif new_hash == old_hash:
546
+ plan.operations.append(
547
+ Operation("conflict", relative, "managed file has local changes")
548
+ )
549
+ else:
550
+ plan.operations.append(
551
+ Operation("conflict", relative, "local and upstream changes overlap")
552
+ )
553
+
554
+ integrations = lock.get("integrations")
555
+ if not isinstance(integrations, dict):
556
+ raise CoDevError("lock file contains invalid integrations")
557
+ old_block_hash = integrations.get("agents_block_hash")
558
+ if not isinstance(old_block_hash, str):
559
+ raise CoDevError("lock file has no valid AGENTS.md block hash")
560
+ _replace_agent_block_for_update(target, old_block_hash, plan)
561
+
562
+ default_managed = bool(integrations.get("opencode_default_agent_managed"))
563
+ schema_managed = bool(integrations.get("opencode_schema_managed"))
564
+ agent_container_managed = bool(integrations.get("opencode_agent_container_managed"))
565
+ config_file_managed = bool(integrations.get("opencode_config_file_managed"))
566
+ managed_opencode_agents = integrations.get("opencode_agent_hashes", {})
567
+ if not isinstance(managed_opencode_agents, dict) or not all(
568
+ isinstance(name, str) and isinstance(value, str)
569
+ for name, value in managed_opencode_agents.items()
570
+ ):
571
+ raise CoDevError("lock file has invalid OpenCode agent hashes")
572
+ if "opencode" in selected:
573
+ try:
574
+ opencode = _prepare_opencode(
575
+ target,
576
+ managed_opencode_agents,
577
+ schema_managed=schema_managed,
578
+ agent_container_managed=agent_container_managed,
579
+ config_file_managed=config_file_managed,
580
+ )
581
+ except CoDevError as error:
582
+ plan.operations.append(
583
+ Operation("conflict", ".opencode/opencode.json", str(error))
584
+ )
585
+ else:
586
+ default_managed = default_managed or opencode.default_agent_managed
587
+ managed_opencode_agents = opencode.managed_agents
588
+ schema_managed = opencode.schema_managed
589
+ agent_container_managed = opencode.agent_container_managed
590
+ config_file_managed = opencode.config_file_managed
591
+ if opencode.content is not None:
592
+ plan.writes[target / ".opencode" / "opencode.json"] = opencode.content
593
+ plan.operations.append(
594
+ Operation("integrate", ".opencode/opencode.json", opencode.detail)
595
+ )
596
+ else:
597
+ plan.operations.append(
598
+ Operation("keep", ".opencode/opencode.json", opencode.detail)
599
+ )
600
+ plan.lock = _new_lock(
601
+ selected,
602
+ new_files,
603
+ default_agent_managed=default_managed,
604
+ managed_opencode_agents=managed_opencode_agents,
605
+ opencode_schema_managed=schema_managed,
606
+ opencode_agent_container_managed=agent_container_managed,
607
+ opencode_config_file_managed=config_file_managed,
608
+ )
609
+ return plan
610
+
611
+
612
+ def _prepare_opencode_removal(
613
+ target: Path, integrations: dict[str, Any]
614
+ ) -> tuple[bytes | None, bool, str]:
615
+ path = target / ".opencode" / "opencode.json"
616
+ if not path.exists():
617
+ return None, False, "OpenCode config already absent"
618
+ try:
619
+ config = json.loads(path.read_text(encoding="utf-8"))
620
+ except (OSError, json.JSONDecodeError) as error:
621
+ raise CoDevError(
622
+ f"cannot remove managed values from {path}: {error}"
623
+ ) from error
624
+ if not isinstance(config, dict):
625
+ raise CoDevError(f"{path} must contain a JSON object")
626
+
627
+ managed_agents = integrations.get("opencode_agent_hashes", {})
628
+ if not isinstance(managed_agents, dict) or not all(
629
+ isinstance(name, str) and isinstance(value, str)
630
+ for name, value in managed_agents.items()
631
+ ):
632
+ raise CoDevError("lock file has invalid OpenCode agent hashes")
633
+
634
+ changed = False
635
+ if integrations.get("opencode_default_agent_managed"):
636
+ current_default = config.get("default_agent")
637
+ if current_default == "orchestrator":
638
+ del config["default_agent"]
639
+ changed = True
640
+ elif current_default is not None:
641
+ raise CoDevError("managed OpenCode default_agent has local changes")
642
+
643
+ agents = config.get("agent")
644
+ if agents is not None and not isinstance(agents, dict):
645
+ if managed_agents:
646
+ raise CoDevError("managed OpenCode agent configuration has local changes")
647
+ elif isinstance(agents, dict):
648
+ for name, expected_hash in sorted(managed_agents.items()):
649
+ if name not in agents:
650
+ continue
651
+ current = agents[name]
652
+ if not isinstance(current, dict) or _json_hash(current) != expected_hash:
653
+ raise CoDevError(f"managed OpenCode agent has local changes: {name}")
654
+ del agents[name]
655
+ changed = True
656
+ if integrations.get("opencode_agent_container_managed") and not agents:
657
+ del config["agent"]
658
+ changed = True
659
+
660
+ if integrations.get("opencode_schema_managed"):
661
+ schema = config.get("$schema")
662
+ if schema == "https://opencode.ai/config.json":
663
+ del config["$schema"]
664
+ changed = True
665
+ elif schema is not None:
666
+ raise CoDevError("managed OpenCode schema has local changes")
667
+
668
+ if not changed:
669
+ return None, False, "no managed OpenCode values to remove"
670
+ if integrations.get("opencode_config_file_managed") and not config:
671
+ return None, True, "remove managed OpenCode config"
672
+ content = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
673
+ return content, False, "remove managed OpenCode values"
674
+
675
+
676
+ def plan_remove(target: Path) -> Plan:
677
+ """Preflight removal of the installed CoDev bundle and integrations."""
678
+
679
+ target = target.resolve()
680
+ lock = _read_lock(target)
681
+ files = lock["files"]
682
+ if not all(
683
+ isinstance(path, str) and isinstance(value, str)
684
+ for path, value in files.items()
685
+ ):
686
+ raise CoDevError("lock file contains an invalid managed-file entry")
687
+ integrations = lock.get("integrations")
688
+ if not isinstance(integrations, dict):
689
+ raise CoDevError("lock file contains invalid integrations")
690
+
691
+ plan = Plan(remove_lock=True)
692
+ for relative, expected_hash in sorted(files.items()):
693
+ destination = target / Path(relative)
694
+ if not destination.exists():
695
+ continue
696
+ if not destination.is_file():
697
+ plan.operations.append(
698
+ Operation("conflict", relative, "managed path is not a file")
699
+ )
700
+ elif _sha256(destination.read_bytes()) != expected_hash:
701
+ plan.operations.append(
702
+ Operation("conflict", relative, "managed file has local changes")
703
+ )
704
+ else:
705
+ plan.deletions.add(destination)
706
+ plan.operations.append(Operation("remove", relative))
707
+
708
+ agents_path = target / "AGENTS.md"
709
+ if agents_path.exists():
710
+ try:
711
+ block = _agent_block_from(agents_path.read_text(encoding="utf-8"))
712
+ except CoDevError as error:
713
+ plan.operations.append(Operation("conflict", "AGENTS.md", str(error)))
714
+ else:
715
+ expected_hash = integrations.get("agents_block_hash")
716
+ if not isinstance(expected_hash, str):
717
+ raise CoDevError("lock file has no valid AGENTS.md block hash")
718
+ if block is not None:
719
+ if _block_hash(block) != expected_hash:
720
+ plan.operations.append(
721
+ Operation(
722
+ "conflict", "AGENTS.md", "managed policy block was modified"
723
+ )
724
+ )
725
+ else:
726
+ plan.writes[agents_path] = _without_agent_block(
727
+ agents_path.read_text(encoding="utf-8")
728
+ ).encode("utf-8")
729
+ plan.operations.append(
730
+ Operation(
731
+ "integrate", "AGENTS.md", "remove managed policy block"
732
+ )
733
+ )
734
+
735
+ selected = normalize_platforms(lock.get("platforms", []))
736
+ if "opencode" in selected:
737
+ try:
738
+ opencode_content, remove_opencode_config, detail = (
739
+ _prepare_opencode_removal(target, integrations)
740
+ )
741
+ except CoDevError as error:
742
+ plan.operations.append(
743
+ Operation("conflict", ".opencode/opencode.json", str(error))
744
+ )
745
+ else:
746
+ if opencode_content is not None:
747
+ plan.writes[target / ".opencode" / "opencode.json"] = opencode_content
748
+ plan.operations.append(
749
+ Operation("integrate", ".opencode/opencode.json", detail)
750
+ )
751
+ elif remove_opencode_config:
752
+ plan.deletions.add(target / ".opencode" / "opencode.json")
753
+ plan.operations.append(Operation("remove", ".opencode/opencode.json"))
754
+ return plan
755
+
756
+
757
+ def apply_plan(target: Path, plan: Plan) -> None:
758
+ if plan.conflicts:
759
+ raise CoDevError("cannot apply a plan that contains conflicts")
760
+ if plan.lock is None and not plan.remove_lock:
761
+ raise CoDevError("installation plan has no lock state")
762
+ target = target.resolve()
763
+ for path, content in sorted(plan.writes.items(), key=lambda item: str(item[0])):
764
+ _atomic_write(path, content)
765
+ for path in sorted(plan.deletions, key=str):
766
+ path.unlink()
767
+ for path in sorted(plan.deletions, key=str):
768
+ _remove_empty_parent_dirs(path, target)
769
+ if plan.remove_lock:
770
+ lock_path = target / Path(LOCK_PATH.as_posix())
771
+ lock_path.unlink(missing_ok=True)
772
+ _remove_empty_parent_dirs(lock_path, target)
773
+ return
774
+ assert plan.lock is not None
775
+ lock_content = (json.dumps(plan.lock, indent=2, ensure_ascii=False) + "\n").encode(
776
+ "utf-8"
777
+ )
778
+ _atomic_write(target / Path(LOCK_PATH.as_posix()), lock_content)
779
+
780
+
781
+ def check_project(target: Path) -> CheckResult:
782
+ target = target.resolve()
783
+ lock = _read_lock(target)
784
+ issues: list[str] = []
785
+ files = lock["files"]
786
+ for relative, expected in sorted(files.items()):
787
+ destination = target / Path(relative)
788
+ if not destination.is_file():
789
+ issues.append(f"missing managed file: {relative}")
790
+ continue
791
+ if _sha256(destination.read_bytes()) != expected:
792
+ issues.append(f"managed file has local changes: {relative}")
793
+
794
+ integrations = lock.get("integrations", {})
795
+ agents_path = target / "AGENTS.md"
796
+ if not agents_path.is_file():
797
+ issues.append("AGENTS.md is missing")
798
+ else:
799
+ try:
800
+ block = _agent_block_from(agents_path.read_text(encoding="utf-8"))
801
+ except CoDevError as error:
802
+ issues.append(str(error))
803
+ else:
804
+ if block is None:
805
+ issues.append("AGENTS.md has no managed CoDev block")
806
+ elif _block_hash(block) != integrations.get("agents_block_hash"):
807
+ issues.append("the managed AGENTS.md block has local changes")
808
+
809
+ managed_opencode_agents = integrations.get("opencode_agent_hashes", {})
810
+ if not isinstance(managed_opencode_agents, dict) or not all(
811
+ isinstance(name, str) and isinstance(value, str)
812
+ for name, value in managed_opencode_agents.items()
813
+ ):
814
+ issues.append("lock file has invalid OpenCode agent hashes")
815
+ managed_opencode_agents = {}
816
+ if integrations.get("opencode_default_agent_managed") or managed_opencode_agents:
817
+ config_path = target / ".opencode" / "opencode.json"
818
+ try:
819
+ config = json.loads(config_path.read_text(encoding="utf-8"))
820
+ except (OSError, json.JSONDecodeError) as error:
821
+ issues.append(f"cannot read .opencode/opencode.json: {error}")
822
+ else:
823
+ if config.get("default_agent") != "orchestrator":
824
+ issues.append("managed OpenCode default_agent is not orchestrator")
825
+ agents = config.get("agent")
826
+ if not isinstance(agents, dict):
827
+ issues.append("managed OpenCode agent configuration is missing")
828
+ else:
829
+ for name, expected_hash in sorted(managed_opencode_agents.items()):
830
+ current = agents.get(name)
831
+ if (
832
+ not isinstance(current, dict)
833
+ or _json_hash(current) != expected_hash
834
+ ):
835
+ issues.append(
836
+ f"managed OpenCode agent has local changes: {name}"
837
+ )
838
+
839
+ issues.extend(_validate_installed_skills(target, files))
840
+ return CheckResult(
841
+ version=str(lock.get("bundle_version", "unknown")),
842
+ issues=tuple(issues),
843
+ managed_files=len(files),
844
+ )
845
+
846
+
847
+ def _validate_installed_skills(target: Path, files: dict[str, str]) -> list[str]:
848
+ issues: list[str] = []
849
+ skill_paths = [
850
+ path
851
+ for path in files
852
+ if path.startswith(".agents/skills/") and path.endswith("/SKILL.md")
853
+ ]
854
+ for relative in sorted(skill_paths):
855
+ path = target / Path(relative)
856
+ if not path.is_file():
857
+ continue
858
+ text = path.read_text(encoding="utf-8")
859
+ lines = text.splitlines()
860
+ if len(lines) < 4 or lines[0].strip() != "---":
861
+ issues.append(f"invalid skill frontmatter: {relative}")
862
+ continue
863
+ try:
864
+ end = lines.index("---", 1)
865
+ except ValueError:
866
+ issues.append(f"unterminated skill frontmatter: {relative}")
867
+ continue
868
+ fields: dict[str, str] = {}
869
+ for line in lines[1:end]:
870
+ if ":" in line:
871
+ key, value = line.split(":", 1)
872
+ fields[key.strip()] = value.strip()
873
+ expected_name = PurePosixPath(relative).parent.name
874
+ if fields.get("name") != expected_name:
875
+ issues.append(f"skill name does not match its folder: {relative}")
876
+ if not fields.get("description"):
877
+ issues.append(f"skill description is empty: {relative}")
878
+ return issues
879
+
880
+
881
+ def format_plan(plan: Plan) -> str:
882
+ if not plan.operations:
883
+ return "No managed files found."
884
+ visible = [item for item in plan.operations if item.kind != "keep"]
885
+ if not visible:
886
+ return "No changes."
887
+ lines = []
888
+ for item in visible:
889
+ suffix = f" — {item.detail}" if item.detail else ""
890
+ lines.append(f"{item.kind.upper():9} {item.path}{suffix}")
891
+ return "\n".join(lines)