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.
- codev_workflow/__init__.py +5 -0
- codev_workflow/__main__.py +4 -0
- codev_workflow/bundle/.agents/skills/build-change/SKILL.md +96 -0
- codev_workflow/bundle/.agents/skills/build-change/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/build-change/assets/implementation-plan.template.md +51 -0
- codev_workflow/bundle/.agents/skills/define-product/SKILL.md +79 -0
- codev_workflow/bundle/.agents/skills/define-product/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/define-product/assets/brief.template.md +50 -0
- codev_workflow/bundle/.agents/skills/design-solution/SKILL.md +75 -0
- codev_workflow/bundle/.agents/skills/design-solution/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/design-solution/assets/decision.template.md +26 -0
- codev_workflow/bundle/.agents/skills/design-solution/assets/design.template.md +76 -0
- codev_workflow/bundle/.agents/skills/launch-product/SKILL.md +66 -0
- codev_workflow/bundle/.agents/skills/launch-product/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/launch-product/assets/launch-plan.template.md +48 -0
- codev_workflow/bundle/.agents/skills/plan-delivery/SKILL.md +140 -0
- codev_workflow/bundle/.agents/skills/plan-delivery/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/plan-delivery/assets/delivery-plan.template.md +41 -0
- codev_workflow/bundle/.agents/skills/review-change/SKILL.md +48 -0
- codev_workflow/bundle/.agents/skills/review-change/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/specify-project/SKILL.md +205 -0
- codev_workflow/bundle/.agents/skills/specify-project/agents/openai.yaml +4 -0
- codev_workflow/bundle/.agents/skills/specify-project/assets/specification.template.md +151 -0
- codev_workflow/bundle/.agents/skills/specify-project/references/interview-coverage.md +303 -0
- codev_workflow/bundle/.agents/skills/specify-project/scripts/validate_specification.py +143 -0
- codev_workflow/bundle/.opencode/agents/builder.md +54 -0
- codev_workflow/bundle/.opencode/agents/orchestrator.md +72 -0
- codev_workflow/bundle/.opencode/agents/reviewer.md +35 -0
- codev_workflow/bundle/AGENTS.md +23 -0
- codev_workflow/bundle/docs/AI-WORKFLOW-PROMPTS.md +318 -0
- codev_workflow/bundle/docs/WORKFLOW-COOKBOOK.md +419 -0
- codev_workflow/bundle/docs/WORKFLOW-HUMAN.md +212 -0
- codev_workflow/bundle/docs/for-ai/WORKFLOW-AGENTS.md +171 -0
- codev_workflow/bundle/docs/handbooks/IDEA-TO-PRODUCTION-HANDBOOK.md +1190 -0
- codev_workflow/bundle/docs/handbooks/LANGUAGE-AGNOSTIC-PROJECT-HANDBOOK.md +745 -0
- codev_workflow/bundle/docs/handbooks/PYTHON-PROJECT-HANDBOOK.md +960 -0
- codev_workflow/bundle/evals/development-workflow/scenarios.json +132 -0
- codev_workflow/bundle/scripts/evaluate-development-workflow.py +352 -0
- codev_workflow/bundle/scripts/validate-development-workflow.py +213 -0
- codev_workflow/cli.py +140 -0
- codev_workflow/installer.py +891 -0
- open_codev_workflow-0.1.0.dist-info/METADATA +150 -0
- open_codev_workflow-0.1.0.dist-info/RECORD +47 -0
- open_codev_workflow-0.1.0.dist-info/WHEEL +5 -0
- open_codev_workflow-0.1.0.dist-info/entry_points.txt +2 -0
- open_codev_workflow-0.1.0.dist-info/licenses/LICENSE +28 -0
- 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)
|