open-xmen 0.1.0

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 (60) hide show
  1. package/.cerebro/.gitignore +27 -0
  2. package/.cerebro/cerebro-identity.md +76 -0
  3. package/.cerebro/docs/agent-mapping.md +54 -0
  4. package/.cerebro/docs/cerebro-workflow.md +115 -0
  5. package/.cerebro/docs/orchestration.md +28 -0
  6. package/.cerebro/docs/overview.md +44 -0
  7. package/.cerebro/docs/skill-policy.md +25 -0
  8. package/.cerebro/integrations/semble.md +30 -0
  9. package/.cerebro/opencode/model-routing.md +37 -0
  10. package/.cerebro/schemas/boulder.schema.json +143 -0
  11. package/.cerebro/schemas/team-run.schema.json +234 -0
  12. package/.cerebro/schemas/upgrade-manifest.schema.json +45 -0
  13. package/.cerebro/schemas/upgrade-state.schema.json +38 -0
  14. package/.cerebro/scripts/check-agent-teams-enabled.py +24 -0
  15. package/.cerebro/scripts/ensure-upgrade-cache-gitignored.py +27 -0
  16. package/.cerebro/scripts/fetch-upstream-ref.py +67 -0
  17. package/.cerebro/scripts/reset-runtime.py +125 -0
  18. package/.cerebro/scripts/setup-status.py +101 -0
  19. package/.cerebro/scripts/test-stop-hook.py +60 -0
  20. package/.cerebro/scripts/upgrade-latest-tag.py +34 -0
  21. package/.cerebro/scripts/validate-agent-frontmatter.py +87 -0
  22. package/.cerebro/scripts/validate-boulder.py +105 -0
  23. package/.cerebro/scripts/validate-opencode-runtime.py +94 -0
  24. package/.cerebro/scripts/validate-team-runs.py +310 -0
  25. package/.cerebro/scripts/validate-upgrade-metadata.py +104 -0
  26. package/.cerebro/scripts/write-upgrade-state.py +93 -0
  27. package/.cerebro/templates/customer-vision.md +58 -0
  28. package/.cerebro/templates/plan.md +35 -0
  29. package/.cerebro/templates/product-brief.md +110 -0
  30. package/.cerebro/templates/project-context.md +64 -0
  31. package/.cerebro/templates/requirements-brief.md +67 -0
  32. package/.cerebro/templates/team-run.json +22 -0
  33. package/.cerebro/upgrade-manifest.json +160 -0
  34. package/.opencode/.gitignore +5 -0
  35. package/.opencode/agents/beast.md +38 -0
  36. package/.opencode/agents/cerebro.md +22 -0
  37. package/.opencode/agents/cyclops.md +22 -0
  38. package/.opencode/agents/cypher.md +46 -0
  39. package/.opencode/agents/emma-frost.md +38 -0
  40. package/.opencode/agents/forge.md +22 -0
  41. package/.opencode/agents/legion.md +45 -0
  42. package/.opencode/agents/nightcrawler.md +22 -0
  43. package/.opencode/agents/professor-x.md +39 -0
  44. package/.opencode/agents/sage.md +22 -0
  45. package/.opencode/agents/storm.md +49 -0
  46. package/.opencode/agents/wolverine.md +22 -0
  47. package/.opencode/commands/cerebro-doctor.md +19 -0
  48. package/.opencode/commands/cerebro-index.md +22 -0
  49. package/.opencode/commands/cerebro-plan.md +21 -0
  50. package/.opencode/commands/cerebro-reset.md +20 -0
  51. package/.opencode/commands/cerebro-start-work.md +20 -0
  52. package/.opencode/commands/cerebro-upgrade.md +19 -0
  53. package/.opencode/commands/to-me-my-x-men.md +27 -0
  54. package/AGENTS.md +12 -0
  55. package/README.md +193 -0
  56. package/dist/cli.d.ts +2 -0
  57. package/dist/cli.js +597 -0
  58. package/dist/index.d.ts +4 -0
  59. package/dist/index.js +466 -0
  60. package/package.json +54 -0
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise OpenCode-native Cerebro pending-todo blocking behavior."""
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ TODO_FILE = Path(".cerebro/pending-todos/doctor/worker/task.txt")
8
+ PENDING_ROOT = Path(".cerebro/pending-todos")
9
+
10
+
11
+ def main() -> int:
12
+ previous = TODO_FILE.read_bytes() if TODO_FILE.exists() else None
13
+
14
+ TODO_FILE.parent.mkdir(parents=True, exist_ok=True)
15
+ TODO_FILE.write_text("doctor temporary todo\n", encoding="utf-8")
16
+
17
+ try:
18
+ pending = _pending_items(PENDING_ROOT / "doctor")
19
+ finally:
20
+ if previous is None:
21
+ TODO_FILE.unlink(missing_ok=True)
22
+ _remove_empty_parents(TODO_FILE.parent)
23
+ else:
24
+ TODO_FILE.write_bytes(previous)
25
+
26
+ if not pending:
27
+ print("expected pending todo scan to block final response")
28
+ return 1
29
+
30
+ print("pending todo block decision ok")
31
+ return 0
32
+
33
+
34
+ def _pending_items(root: Path) -> list[tuple[Path, str]]:
35
+ items = []
36
+ if not root.exists():
37
+ return items
38
+ for path in sorted(root.rglob("*")):
39
+ if not path.is_file():
40
+ continue
41
+ for line in path.read_text(errors="replace").splitlines():
42
+ if line.strip():
43
+ items.append((path, line.strip()))
44
+ return items
45
+
46
+
47
+ def _remove_empty_parents(path: Path) -> None:
48
+ current = path
49
+ while current != PENDING_ROOT.parent and current.exists():
50
+ try:
51
+ current.rmdir()
52
+ except OSError:
53
+ break
54
+ if current == PENDING_ROOT:
55
+ break
56
+ current = current.parent
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the latest version tag from the Cerebro upstream repository."""
3
+
4
+ import argparse
5
+ import subprocess
6
+
7
+
8
+ def main() -> int:
9
+ parser = argparse.ArgumentParser(description=__doc__)
10
+ parser.add_argument("--upstream", default="https://github.com/yelaco/claude-xmen.git")
11
+ args = parser.parse_args()
12
+
13
+ try:
14
+ result = subprocess.run(
15
+ ["git", "ls-remote", "--tags", "--sort=-version:refname", args.upstream, "refs/tags/v*"],
16
+ check=True,
17
+ capture_output=True,
18
+ text=True,
19
+ )
20
+ except (subprocess.SubprocessError, FileNotFoundError) as exc:
21
+ print(f"Could not resolve latest upstream tag from {args.upstream}: {exc}")
22
+ return 1
23
+
24
+ for line in result.stdout.splitlines():
25
+ if "refs/tags/" in line:
26
+ print(line.rsplit("refs/tags/", 1)[1].removesuffix("^{}"))
27
+ return 0
28
+
29
+ print(f"Could not resolve latest upstream tag from {args.upstream}: no v* tags found")
30
+ return 1
31
+
32
+
33
+ if __name__ == "__main__":
34
+ raise SystemExit(main())
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """Validate OpenCode agent frontmatter for the active Cerebro runtime."""
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ REQUIRED_AGENTS = {
8
+ "cerebro",
9
+ "legion",
10
+ "cypher",
11
+ "professor-x",
12
+ "cyclops",
13
+ "wolverine",
14
+ "storm",
15
+ "forge",
16
+ "nightcrawler",
17
+ "sage",
18
+ "beast",
19
+ "emma-frost",
20
+ }
21
+ REQUIRED_KEYS = {"description", "mode", "model", "permission"}
22
+ VALID_MODES = {"primary", "subagent"}
23
+
24
+
25
+ def main() -> int:
26
+ failed = []
27
+ seen = set()
28
+ agent_dir = Path(".opencode/agents")
29
+
30
+ if not agent_dir.is_dir():
31
+ print("missing .opencode/agents")
32
+ return 1
33
+
34
+ for path in sorted(agent_dir.glob("*.md")):
35
+ frontmatter, error = _frontmatter(path)
36
+ if error:
37
+ failed.append((str(path), error))
38
+ continue
39
+
40
+ name = path.stem
41
+ seen.add(name)
42
+ if name not in REQUIRED_AGENTS:
43
+ failed.append((str(path), f"unexpected agent {name}"))
44
+
45
+ missing = sorted(REQUIRED_KEYS - set(frontmatter))
46
+ if missing:
47
+ failed.append((str(path), f"missing {missing}"))
48
+ continue
49
+
50
+ if frontmatter["mode"] not in VALID_MODES:
51
+ failed.append((str(path), f"invalid mode {frontmatter['mode']}"))
52
+ if name == "cerebro" and frontmatter["mode"] != "primary":
53
+ failed.append((str(path), "cerebro must use mode: primary"))
54
+ if name != "cerebro" and frontmatter["mode"] != "subagent":
55
+ failed.append((str(path), f"{name} must use mode: subagent"))
56
+
57
+ missing_agents = sorted(REQUIRED_AGENTS - seen)
58
+ if missing_agents:
59
+ failed.append((".opencode/agents", f"missing agents {missing_agents}"))
60
+
61
+ if failed:
62
+ for item in failed:
63
+ print(item)
64
+ return 1
65
+
66
+ print("opencode agent frontmatter ok")
67
+ return 0
68
+
69
+
70
+ def _frontmatter(path: Path) -> tuple[dict[str, str], str | None]:
71
+ text = path.read_text(encoding="utf-8")
72
+ if not text.startswith("---\n"):
73
+ return {}, "missing frontmatter"
74
+ end = text.find("\n---\n", 4)
75
+ if end == -1:
76
+ return {}, "unterminated frontmatter"
77
+
78
+ frontmatter = {}
79
+ for line in text[4:end].splitlines():
80
+ if ":" in line and not line.startswith(" "):
81
+ key, value = line.split(":", 1)
82
+ frontmatter[key.strip()] = value.strip()
83
+ return frontmatter, None
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Cerebro boulder execution state using the local schema contract."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ STATE_PATH = Path(".cerebro/boulder.json")
9
+ SCHEMA_PATH = Path(".cerebro/schemas/boulder.schema.json")
10
+
11
+
12
+ def main() -> int:
13
+ if not SCHEMA_PATH.exists():
14
+ print("missing schema")
15
+ return 1
16
+ if not STATE_PATH.exists():
17
+ print("no active boulder state")
18
+ return 0
19
+
20
+ state = json.loads(STATE_PATH.read_text())
21
+ errors = []
22
+
23
+ allowed_top = {
24
+ "version",
25
+ "active_plan",
26
+ "plan_name",
27
+ "status",
28
+ "risk_level",
29
+ "team_name",
30
+ "started_at",
31
+ "updated_at",
32
+ "approval_gates",
33
+ "verification_history",
34
+ "decisions",
35
+ }
36
+ missing = sorted(allowed_top - set(state))
37
+ extra = sorted(set(state) - allowed_top)
38
+ if missing:
39
+ errors.append(f"missing top-level fields: {missing}")
40
+ if extra:
41
+ errors.append(f"unexpected top-level fields: {extra}")
42
+
43
+ if state.get("version") != 2:
44
+ errors.append(f"version must be 2, got {state.get('version')!r}")
45
+ if state.get("status") not in {"not_started", "in_progress", "blocked", "completed"}:
46
+ errors.append(f"invalid status: {state.get('status')!r}")
47
+ if state.get("risk_level") not in {"LOW", "MEDIUM", "HIGH"}:
48
+ errors.append(f"invalid risk_level: {state.get('risk_level')!r}")
49
+
50
+ if not isinstance(state.get("approval_gates"), list):
51
+ errors.append("approval_gates must be an array")
52
+ else:
53
+ gate_required = {"name", "status", "decided_at", "decision_by", "notes"}
54
+ for index, gate in enumerate(state["approval_gates"]):
55
+ if not isinstance(gate, dict):
56
+ errors.append(f"approval_gates[{index}] must be an object")
57
+ continue
58
+ errors.extend(_check_keys(f"approval_gates[{index}]", gate, gate_required))
59
+ if gate.get("status") not in {"pending", "approved", "rejected"}:
60
+ errors.append(f"approval_gates[{index}] invalid status {gate.get('status')!r}")
61
+
62
+ if not isinstance(state.get("verification_history"), list):
63
+ errors.append("verification_history must be an array")
64
+ else:
65
+ verify_required = {"command", "result", "verified_at", "notes"}
66
+ for index, item in enumerate(state["verification_history"]):
67
+ if not isinstance(item, dict):
68
+ errors.append(f"verification_history[{index}] must be an object")
69
+ continue
70
+ errors.extend(_check_keys(f"verification_history[{index}]", item, verify_required))
71
+ if item.get("result") not in {"PASS", "FAIL", "BLOCKED"}:
72
+ errors.append(f"verification_history[{index}] invalid result {item.get('result')!r}")
73
+
74
+ if not isinstance(state.get("decisions"), list):
75
+ errors.append("decisions must be an array")
76
+ else:
77
+ decision_required = {"at", "topic", "decision", "rationale"}
78
+ for index, item in enumerate(state["decisions"]):
79
+ if not isinstance(item, dict):
80
+ errors.append(f"decisions[{index}] must be an object")
81
+ continue
82
+ errors.extend(_check_keys(f"decisions[{index}]", item, decision_required))
83
+
84
+ if errors:
85
+ for error in errors:
86
+ print(error)
87
+ return 1
88
+
89
+ print("boulder state schema ok")
90
+ return 0
91
+
92
+
93
+ def _check_keys(label: str, item: dict, required: set[str]) -> list[str]:
94
+ errors = []
95
+ missing = sorted(required - set(item))
96
+ extra = sorted(set(item) - required)
97
+ if missing:
98
+ errors.append(f"{label} missing {missing}")
99
+ if extra:
100
+ errors.append(f"{label} unexpected {extra}")
101
+ return errors
102
+
103
+
104
+ if __name__ == "__main__":
105
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Cerebro OpenCode runtime files."""
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ REQUIRED_AGENTS = {
8
+ "cerebro",
9
+ "legion",
10
+ "cypher",
11
+ "professor-x",
12
+ "cyclops",
13
+ "wolverine",
14
+ "storm",
15
+ "forge",
16
+ "nightcrawler",
17
+ "sage",
18
+ "beast",
19
+ "emma-frost",
20
+ }
21
+ REQUIRED_COMMANDS = {
22
+ "cerebro-index",
23
+ "cerebro-plan",
24
+ "cerebro-start-work",
25
+ "cerebro-doctor",
26
+ "cerebro-reset",
27
+ "cerebro-upgrade",
28
+ "to-me-my-x-men",
29
+ }
30
+ MODEL_PATTERN = re.compile(r"openai/[A-Za-z0-9._/-]+")
31
+
32
+
33
+ def main() -> int:
34
+ errors = []
35
+ for path in [
36
+ "opencode.jsonc",
37
+ "AGENTS.md",
38
+ ".opencode/plugins/open-xmen.ts",
39
+ ".cerebro/cerebro-identity.md",
40
+ ".cerebro/opencode/model-routing.md",
41
+ ]:
42
+ if not Path(path).is_file():
43
+ errors.append(f"missing {path}")
44
+
45
+ allowed_models = _configured_models(Path(".cerebro/opencode/model-routing.md"))
46
+
47
+ agent_dir = Path(".opencode/agents")
48
+ seen_agents = {path.stem for path in agent_dir.glob("*.md")} if agent_dir.is_dir() else set()
49
+ for name in sorted(REQUIRED_AGENTS - seen_agents):
50
+ errors.append(f"missing .opencode/agents/{name}.md")
51
+ for path in sorted(agent_dir.glob("*.md")):
52
+ text = path.read_text(encoding="utf-8")
53
+ if not text.startswith("---\n"):
54
+ errors.append(f"{path}: missing frontmatter")
55
+ continue
56
+ model = _frontmatter_value(text, "model")
57
+ if model and model not in allowed_models:
58
+ errors.append(f"{path}: model {model!r} is outside configured model availability")
59
+
60
+ command_dir = Path(".opencode/commands")
61
+ seen_commands = {path.stem for path in command_dir.glob("*.md")} if command_dir.is_dir() else set()
62
+ for name in sorted(REQUIRED_COMMANDS - seen_commands):
63
+ errors.append(f"missing .opencode/commands/{name}.md")
64
+ for path in sorted(command_dir.glob("*.md")):
65
+ text = path.read_text(encoding="utf-8")
66
+ if "agent: cerebro" not in text:
67
+ errors.append(f"{path}: command must route to agent: cerebro")
68
+
69
+ if errors:
70
+ for error in errors:
71
+ print(error)
72
+ return 1
73
+ print("opencode runtime ok")
74
+ return 0
75
+
76
+
77
+ def _frontmatter_value(text: str, key: str) -> str | None:
78
+ end = text.find("\n---\n", 4)
79
+ if end == -1:
80
+ return None
81
+ for line in text[4:end].splitlines():
82
+ if line.startswith(key + ":"):
83
+ return line.split(":", 1)[1].strip()
84
+ return None
85
+
86
+
87
+ def _configured_models(path: Path) -> set[str]:
88
+ if not path.is_file():
89
+ return set()
90
+ return set(MODEL_PATTERN.findall(path.read_text(encoding="utf-8")))
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Cerebro team-run manifest template and runtime manifests."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ ALLOWED_COMMANDS = {
9
+ "/to-me-my-x-men",
10
+ "/cerebro-plan",
11
+ "/cerebro-start-work",
12
+ "/cerebro-index",
13
+ "/cerebro-doctor",
14
+ "/cerebro-reset",
15
+ "/cerebro-upgrade",
16
+ }
17
+ ALLOWED_STATUSES = {"planning", "running", "blocked", "completed", "cleaned_up"}
18
+ ALLOWED_RISKS = {"LOW", "MEDIUM", "HIGH"}
19
+ ALLOWED_AGENTS = {
20
+ "cerebro",
21
+ "legion",
22
+ "cypher",
23
+ "cyclops",
24
+ "wolverine",
25
+ "storm",
26
+ "professor-x",
27
+ "beast",
28
+ "emma-frost",
29
+ "nightcrawler",
30
+ "sage",
31
+ "forge",
32
+ }
33
+ ALLOWED_TEAMMATE_STATUSES = {"pending", "active", "idle", "done", "blocked"}
34
+ ALLOWED_APPROVAL_STATUSES = {"pending", "approved", "rejected"}
35
+ ALLOWED_VERIFICATION_STATUSES = {"NOT RUN", "PASS", "FAIL", "BLOCKED"}
36
+ ALLOWED_TASK_STATUSES = {"pending", "active", "blocked", "done", "verified", "failed"}
37
+
38
+
39
+ def main() -> int:
40
+ manifests = [
41
+ Path(".cerebro/templates/team-run.json"),
42
+ *sorted(
43
+ path
44
+ for path in Path(".cerebro/team-runs").glob("*.json")
45
+ if not path.name.endswith(".tasks.json")
46
+ ),
47
+ ]
48
+ task_ledgers = sorted(Path(".cerebro/team-runs").glob("*.tasks.json"))
49
+ mailbox_logs = sorted(Path(".cerebro/team-runs").glob("*.mailbox.jsonl"))
50
+ checkpoint_logs = sorted(Path(".cerebro/team-runs").glob("*.checkpoints.jsonl"))
51
+
52
+ errors = []
53
+ for path in manifests:
54
+ errors.extend(validate_manifest(path))
55
+ for path in task_ledgers:
56
+ errors.extend(validate_tasks(path))
57
+ for path in mailbox_logs:
58
+ errors.extend(_validate_jsonl(path, _validate_mailbox_record))
59
+ for path in checkpoint_logs:
60
+ errors.extend(_validate_jsonl(path, _validate_checkpoint_record))
61
+
62
+ if errors:
63
+ for error in errors:
64
+ print(error)
65
+ return 1
66
+
67
+ print(
68
+ "team run runtime files ok "
69
+ f"({len(manifests)} manifest(s), {len(task_ledgers)} task ledger(s), "
70
+ f"{len(mailbox_logs)} mailbox log(s), {len(checkpoint_logs)} checkpoint log(s))"
71
+ )
72
+ return 0
73
+
74
+
75
+ def validate_manifest(path: Path) -> list[str]:
76
+ data = json.loads(path.read_text())
77
+ errors = []
78
+
79
+ top_required = {
80
+ "version",
81
+ "run_id",
82
+ "command",
83
+ "status",
84
+ "lead",
85
+ "team_name",
86
+ "objective",
87
+ "risk_level",
88
+ "started_at",
89
+ "updated_at",
90
+ "teammates",
91
+ "ownership",
92
+ "mailbox_decisions",
93
+ "approvals",
94
+ "verification",
95
+ "cleanup",
96
+ }
97
+ errors.extend(_check_keys(str(path), data, top_required))
98
+
99
+ if data.get("version") != 1:
100
+ errors.append(f"{path}: version must be 1")
101
+ if data.get("command") not in ALLOWED_COMMANDS:
102
+ errors.append(f"{path}: invalid command {data.get('command')!r}")
103
+ if data.get("status") not in ALLOWED_STATUSES:
104
+ errors.append(f"{path}: invalid status {data.get('status')!r}")
105
+ if data.get("lead") != "cerebro":
106
+ errors.append(f"{path}: lead must be cerebro")
107
+ if data.get("risk_level") not in ALLOWED_RISKS:
108
+ errors.append(f"{path}: invalid risk_level {data.get('risk_level')!r}")
109
+ for key in ("run_id", "team_name", "objective", "started_at", "updated_at"):
110
+ errors.extend(_non_empty(f"{path}: {key}", data.get(key)))
111
+
112
+ for field in ("teammates", "ownership", "mailbox_decisions", "approvals", "verification"):
113
+ if not isinstance(data.get(field), list):
114
+ errors.append(f"{path}: {field} must be an array")
115
+
116
+ _validate_teammates(path, data.get("teammates", []), errors)
117
+ _validate_ownership(path, data.get("ownership", []), errors)
118
+ _validate_mailbox(path, data.get("mailbox_decisions", []), errors)
119
+ _validate_approvals(path, data.get("approvals", []), errors)
120
+ _validate_verification(path, data.get("verification", []), errors)
121
+ _validate_cleanup(path, data.get("cleanup"), errors)
122
+
123
+ return errors
124
+
125
+
126
+ def validate_tasks(path: Path) -> list[str]:
127
+ data = json.loads(path.read_text())
128
+ errors = []
129
+ if not isinstance(data, list):
130
+ return [f"{path}: task ledger must be an array"]
131
+
132
+ required = {
133
+ "id",
134
+ "subject",
135
+ "description",
136
+ "owner",
137
+ "status",
138
+ "depends_on",
139
+ "created_at",
140
+ "updated_at",
141
+ "notes",
142
+ "verification",
143
+ }
144
+ for index, item in enumerate(data):
145
+ label = f"{path}: tasks[{index}]"
146
+ errors.extend(_check_keys(label, item, required))
147
+ for key in ("id", "subject", "description", "owner", "created_at", "updated_at"):
148
+ errors.extend(_non_empty(f"{label}.{key}", item.get(key) if isinstance(item, dict) else None))
149
+ if isinstance(item, dict):
150
+ if item.get("status") not in ALLOWED_TASK_STATUSES:
151
+ errors.append(f"{label}.status invalid {item.get('status')!r}")
152
+ for key in ("depends_on", "notes", "verification"):
153
+ if not isinstance(item.get(key), list):
154
+ errors.append(f"{label}.{key} must be an array")
155
+ _validate_task_verification(path, index, item.get("verification", []), errors)
156
+ return errors
157
+
158
+
159
+ def _validate_teammates(path: Path, items: list, errors: list[str]) -> None:
160
+ required = {"name", "agent_type", "status", "responsibility", "last_signal"}
161
+ for index, item in enumerate(items):
162
+ label = f"{path}: teammates[{index}]"
163
+ errors.extend(_check_keys(label, item, required))
164
+ errors.extend(_non_empty(f"{label}.name", item.get("name")))
165
+ if item.get("agent_type") not in ALLOWED_AGENTS:
166
+ errors.append(f"{label}.agent_type invalid {item.get('agent_type')!r}")
167
+ if item.get("status") not in ALLOWED_TEAMMATE_STATUSES:
168
+ errors.append(f"{label}.status invalid {item.get('status')!r}")
169
+
170
+
171
+ def _validate_ownership(path: Path, items: list, errors: list[str]) -> None:
172
+ required = {"path", "owner", "reviewer", "notes"}
173
+ for index, item in enumerate(items):
174
+ label = f"{path}: ownership[{index}]"
175
+ errors.extend(_check_keys(label, item, required))
176
+ errors.extend(_non_empty(f"{label}.path", item.get("path")))
177
+ errors.extend(_non_empty(f"{label}.owner", item.get("owner")))
178
+
179
+
180
+ def _validate_mailbox(path: Path, items: list, errors: list[str]) -> None:
181
+ required = {"at", "from", "to", "decision", "notes"}
182
+ for index, item in enumerate(items):
183
+ label = f"{path}: mailbox_decisions[{index}]"
184
+ errors.extend(_check_keys(label, item, required))
185
+ for key in ("at", "from", "to", "decision"):
186
+ errors.extend(_non_empty(f"{label}.{key}", item.get(key)))
187
+
188
+
189
+ def _validate_approvals(path: Path, items: list, errors: list[str]) -> None:
190
+ required = {"gate", "status", "decided_at", "notes"}
191
+ for index, item in enumerate(items):
192
+ label = f"{path}: approvals[{index}]"
193
+ errors.extend(_check_keys(label, item, required))
194
+ errors.extend(_non_empty(f"{label}.gate", item.get("gate")))
195
+ if item.get("status") not in ALLOWED_APPROVAL_STATUSES:
196
+ errors.append(f"{label}.status invalid {item.get('status')!r}")
197
+
198
+
199
+ def _validate_verification(path: Path, items: list, errors: list[str]) -> None:
200
+ required = {"command", "status", "by", "notes"}
201
+ for index, item in enumerate(items):
202
+ label = f"{path}: verification[{index}]"
203
+ errors.extend(_check_keys(label, item, required))
204
+ errors.extend(_non_empty(f"{label}.command", item.get("command")))
205
+ errors.extend(_non_empty(f"{label}.by", item.get("by")))
206
+ if item.get("status") not in ALLOWED_VERIFICATION_STATUSES:
207
+ errors.append(f"{label}.status invalid {item.get('status')!r}")
208
+
209
+
210
+ def _validate_task_verification(path: Path, task_index: int, items: list, errors: list[str]) -> None:
211
+ if not isinstance(items, list):
212
+ return
213
+ required = {"at", "result"}
214
+ allowed = required | {"command", "notes"}
215
+ for index, item in enumerate(items):
216
+ label = f"{path}: tasks[{task_index}].verification[{index}]"
217
+ errors.extend(_check_required_keys(label, item, required, allowed))
218
+ if isinstance(item, dict):
219
+ errors.extend(_non_empty(f"{label}.at", item.get("at")))
220
+ if item.get("result") not in ALLOWED_VERIFICATION_STATUSES:
221
+ errors.append(f"{label}.result invalid {item.get('result')!r}")
222
+
223
+
224
+ def _validate_jsonl(path: Path, validator) -> list[str]:
225
+ errors = []
226
+ for line_number, line in enumerate(path.read_text().splitlines(), start=1):
227
+ if not line.strip():
228
+ continue
229
+ try:
230
+ record = json.loads(line)
231
+ except json.JSONDecodeError as exc:
232
+ errors.append(f"{path}:{line_number}: invalid JSON: {exc.msg}")
233
+ continue
234
+ errors.extend(validator(path, line_number, record))
235
+ return errors
236
+
237
+
238
+ def _validate_mailbox_record(path: Path, line_number: int, item: object) -> list[str]:
239
+ label = f"{path}:{line_number}"
240
+ if not isinstance(item, dict):
241
+ return [f"{label}: mailbox record must be an object"]
242
+
243
+ common_required = {"at", "from", "to", "type"}
244
+ if item.get("type") == "dispatch":
245
+ required = common_required | {"description"}
246
+ allowed = required
247
+ else:
248
+ required = common_required | {"run_id", "body"}
249
+ allowed = required | {"decision"}
250
+ errors = _check_required_keys(label, item, required, allowed)
251
+ for key in sorted(required):
252
+ errors.extend(_non_empty(f"{label}.{key}", item.get(key)))
253
+ return errors
254
+
255
+
256
+ def _validate_checkpoint_record(path: Path, line_number: int, item: object) -> list[str]:
257
+ label = f"{path}:{line_number}"
258
+ required = {"at", "run_id", "summary"}
259
+ allowed = required | {"next", "verification"}
260
+ errors = _check_required_keys(label, item, required, allowed)
261
+ if isinstance(item, dict):
262
+ for key in sorted(required):
263
+ errors.extend(_non_empty(f"{label}.{key}", item.get(key)))
264
+ return errors
265
+
266
+
267
+ def _validate_cleanup(path: Path, cleanup: object, errors: list[str]) -> None:
268
+ required = {"team_stopped", "pending_todos_clear", "notes"}
269
+ label = f"{path}: cleanup"
270
+ if not isinstance(cleanup, dict):
271
+ errors.append(f"{label} must be an object")
272
+ return
273
+ errors.extend(_check_keys(label, cleanup, required))
274
+ for key in ("team_stopped", "pending_todos_clear"):
275
+ if not isinstance(cleanup.get(key), bool):
276
+ errors.append(f"{label}.{key} must be a boolean")
277
+
278
+
279
+ def _check_keys(label: str, item: object, required: set[str]) -> list[str]:
280
+ if not isinstance(item, dict):
281
+ return [f"{label}: must be an object"]
282
+ errors = []
283
+ missing = sorted(required - set(item))
284
+ extra = sorted(set(item) - required)
285
+ if missing:
286
+ errors.append(f"{label}: missing {missing}")
287
+ if extra:
288
+ errors.append(f"{label}: unexpected {extra}")
289
+ return errors
290
+
291
+
292
+ def _check_required_keys(label: str, item: object, required: set[str], allowed: set[str]) -> list[str]:
293
+ if not isinstance(item, dict):
294
+ return [f"{label}: must be an object"]
295
+ errors = []
296
+ missing = sorted(required - set(item))
297
+ extra = sorted(set(item) - allowed)
298
+ if missing:
299
+ errors.append(f"{label}: missing {missing}")
300
+ if extra:
301
+ errors.append(f"{label}: unexpected {extra}")
302
+ return errors
303
+
304
+
305
+ def _non_empty(label: str, value: object) -> list[str]:
306
+ return [] if isinstance(value, str) and value else [f"{label}: must be a non-empty string"]
307
+
308
+
309
+ if __name__ == "__main__":
310
+ raise SystemExit(main())