ctrlrelay 0.1.5__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.
@@ -0,0 +1,54 @@
1
+ """Core functionality for ctrlrelay orchestrator."""
2
+
3
+ from ctrlrelay.core.checkpoint import (
4
+ CheckpointError,
5
+ CheckpointState,
6
+ CheckpointStatus,
7
+ blocked,
8
+ done,
9
+ failed,
10
+ read_checkpoint,
11
+ )
12
+ from ctrlrelay.core.config import (
13
+ Config,
14
+ ConfigError,
15
+ RepoConfig,
16
+ load_config,
17
+ )
18
+ from ctrlrelay.core.dispatcher import (
19
+ AgentAdapter,
20
+ ClaudeDispatcher,
21
+ SessionResult,
22
+ make_agent_dispatcher,
23
+ )
24
+ from ctrlrelay.core.github import GitHubCLI
25
+ from ctrlrelay.core.poller import IssuePoller
26
+ from ctrlrelay.core.pr_verifier import PRVerifier, VerificationResult
27
+ from ctrlrelay.core.pr_watcher import PRWatcher
28
+ from ctrlrelay.core.state import StateDB
29
+ from ctrlrelay.core.worktree import WorktreeManager
30
+
31
+ __all__ = [
32
+ "CheckpointError",
33
+ "CheckpointState",
34
+ "CheckpointStatus",
35
+ "AgentAdapter",
36
+ "ClaudeDispatcher",
37
+ "make_agent_dispatcher",
38
+ "Config",
39
+ "ConfigError",
40
+ "GitHubCLI",
41
+ "IssuePoller",
42
+ "PRVerifier",
43
+ "PRWatcher",
44
+ "RepoConfig",
45
+ "SessionResult",
46
+ "StateDB",
47
+ "VerificationResult",
48
+ "WorktreeManager",
49
+ "blocked",
50
+ "done",
51
+ "failed",
52
+ "load_config",
53
+ "read_checkpoint",
54
+ ]
@@ -0,0 +1,257 @@
1
+ """Skill audit functionality for orchestrator readiness checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+
13
+ class AuditCheck(str, Enum):
14
+ """Types of orchestrator readiness checks."""
15
+
16
+ CHECKPOINT = "checkpoint"
17
+ HEADLESS = "headless"
18
+ CONTEXT_PATH = "context_path"
19
+ ATTRIBUTION = "attribution"
20
+
21
+
22
+ @dataclass
23
+ class AuditResult:
24
+ """Result of a single audit check."""
25
+
26
+ passed: bool
27
+ reason: str = ""
28
+ auto_fixable: bool = False
29
+
30
+
31
+ @dataclass
32
+ class SkillAudit:
33
+ """Audit results for a single skill."""
34
+
35
+ name: str
36
+ path: Path
37
+ results: dict[AuditCheck, AuditResult] = field(default_factory=dict)
38
+
39
+ @property
40
+ def passed(self) -> bool:
41
+ """True if all checks passed."""
42
+ return all(r.passed for r in self.results.values())
43
+
44
+ @property
45
+ def status(self) -> str:
46
+ """Human-readable status."""
47
+ return "READY" if self.passed else "NOT READY"
48
+
49
+
50
+ @dataclass
51
+ class SkillInfo:
52
+ """Basic skill information from SKILL.md."""
53
+
54
+ name: str
55
+ path: Path
56
+ content: str
57
+ frontmatter: dict
58
+
59
+
60
+ # Patterns for headless check
61
+ INTERACTIVE_PATTERNS = [
62
+ r"\binput\s*\(",
63
+ r"\bread\s+-p\b",
64
+ r"Confirm\s*\(",
65
+ r"typer\.confirm\s*\(",
66
+ ]
67
+
68
+ BROWSER_ONLY_TOOLS = [
69
+ "mcp__playwright__",
70
+ "mcp__chrome_devtools__",
71
+ ]
72
+
73
+ # Patterns for checkpoint check
74
+ CHECKPOINT_PATTERNS = [
75
+ r"from\s+ctrlrelay\s+import\s+checkpoint",
76
+ r"from\s+ctrlrelay\.core\.checkpoint\s+import",
77
+ r"checkpoint\.(done|blocked|failed)\s*\(",
78
+ r"CTRLRELAY_STATE_FILE",
79
+ ]
80
+
81
+ # Attribution patterns to avoid in output
82
+ ATTRIBUTION_PATTERNS = [
83
+ r"\bClaude\b",
84
+ r"\bAnthropic\b",
85
+ r"Generated by AI",
86
+ r"AI Assistant",
87
+ ]
88
+
89
+
90
+ def run_check(skill: SkillInfo, check: AuditCheck) -> AuditResult:
91
+ """Run a single audit check on a skill."""
92
+ content = skill.content
93
+ tools = skill.frontmatter.get("tools", "")
94
+
95
+ if check == AuditCheck.CHECKPOINT:
96
+ for pattern in CHECKPOINT_PATTERNS:
97
+ if re.search(pattern, content):
98
+ return AuditResult(passed=True)
99
+ return AuditResult(passed=False, reason="No checkpoint protocol usage found")
100
+
101
+ if check == AuditCheck.HEADLESS:
102
+ for pattern in INTERACTIVE_PATTERNS:
103
+ if re.search(pattern, content):
104
+ reason = f"Interactive prompt pattern found: {pattern}"
105
+ return AuditResult(passed=False, reason=reason)
106
+ for tool in BROWSER_ONLY_TOOLS:
107
+ if tool in content or tool in tools:
108
+ if "fallback" not in content.lower() and "cli" not in content.lower():
109
+ reason = f"Browser-only tool without fallback: {tool}"
110
+ return AuditResult(passed=False, reason=reason)
111
+ return AuditResult(passed=True)
112
+
113
+ if check == AuditCheck.CONTEXT_PATH:
114
+ if "REPO_CONTEXT_PATH" in content or "$REPO_CONTEXT_PATH" in content:
115
+ return AuditResult(passed=True)
116
+ if "context" in content.lower() and "/" in content:
117
+ return AuditResult(
118
+ passed=False, reason="May use hardcoded context path", auto_fixable=True
119
+ )
120
+ return AuditResult(passed=True)
121
+
122
+ if check == AuditCheck.ATTRIBUTION:
123
+ for pattern in ATTRIBUTION_PATTERNS:
124
+ match = re.search(pattern, content, re.IGNORECASE)
125
+ if match:
126
+ reason = f"Attribution pattern found: {match.group()}"
127
+ return AuditResult(passed=False, reason=reason, auto_fixable=True)
128
+ return AuditResult(passed=True)
129
+
130
+ return AuditResult(passed=True)
131
+
132
+
133
+ def discover_skills(skills_dir: Path) -> list[SkillInfo]:
134
+ """Discover all skills in a directory.
135
+
136
+ Args:
137
+ skills_dir: Path to skills directory.
138
+
139
+ Returns:
140
+ List of SkillInfo for each skill found.
141
+ """
142
+ skills = []
143
+
144
+ if not skills_dir.exists():
145
+ return skills
146
+
147
+ for skill_md in skills_dir.glob("*/SKILL.md"):
148
+ content = skill_md.read_text()
149
+
150
+ # Parse YAML frontmatter
151
+ frontmatter = {}
152
+ match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
153
+ if match:
154
+ try:
155
+ frontmatter = yaml.safe_load(match.group(1)) or {}
156
+ except yaml.YAMLError:
157
+ pass
158
+
159
+ name = frontmatter.get("name", skill_md.parent.name)
160
+ skills.append(
161
+ SkillInfo(
162
+ name=name,
163
+ path=skill_md.parent,
164
+ content=content,
165
+ frontmatter=frontmatter,
166
+ )
167
+ )
168
+
169
+ return sorted(skills, key=lambda s: s.name)
170
+
171
+
172
+ def audit_skill(skill: SkillInfo) -> SkillAudit:
173
+ """Run all audit checks on a skill.
174
+
175
+ Args:
176
+ skill: Skill information.
177
+
178
+ Returns:
179
+ SkillAudit with all check results.
180
+ """
181
+ results = {}
182
+ for check in AuditCheck:
183
+ results[check] = run_check(skill, check)
184
+
185
+ return SkillAudit(
186
+ name=skill.name,
187
+ path=skill.path,
188
+ results=results,
189
+ )
190
+
191
+
192
+ def audit_all(skills_dir: Path) -> list[SkillAudit]:
193
+ """Audit all skills in a directory.
194
+
195
+ Args:
196
+ skills_dir: Path to skills directory.
197
+
198
+ Returns:
199
+ List of SkillAudit results.
200
+ """
201
+ skills = discover_skills(skills_dir)
202
+ return [audit_skill(skill) for skill in skills]
203
+
204
+
205
+ def format_report(audits: list[SkillAudit]) -> str:
206
+ """Format audit results as markdown report.
207
+
208
+ Args:
209
+ audits: List of skill audit results.
210
+
211
+ Returns:
212
+ Markdown formatted report.
213
+ """
214
+ lines = [
215
+ "## Skill Audit Report",
216
+ "",
217
+ "| Skill | Checkpoint | Headless | Context | Attribution | Status |",
218
+ "|-------|------------|----------|---------|-------------|--------|",
219
+ ]
220
+
221
+ for audit in audits:
222
+ def icon(check: AuditCheck) -> str:
223
+ result = audit.results.get(check)
224
+ if result is None:
225
+ return "➖"
226
+ return "✅" if result.passed else "❌"
227
+
228
+ lines.append(
229
+ f"| {audit.name} "
230
+ f"| {icon(AuditCheck.CHECKPOINT)} "
231
+ f"| {icon(AuditCheck.HEADLESS)} "
232
+ f"| {icon(AuditCheck.CONTEXT_PATH)} "
233
+ f"| {icon(AuditCheck.ATTRIBUTION)} "
234
+ f"| {audit.status} |"
235
+ )
236
+
237
+ # Summary
238
+ ready = sum(1 for a in audits if a.passed)
239
+ total = len(audits)
240
+ lines.extend([
241
+ "",
242
+ f"**Summary:** {ready}/{total} skills ready for orchestrator",
243
+ ])
244
+
245
+ # Details for failed checks
246
+ failed_audits = [a for a in audits if not a.passed]
247
+ if failed_audits:
248
+ lines.extend(["", "### Issues", ""])
249
+ for audit in failed_audits:
250
+ lines.append(f"**{audit.name}:**")
251
+ for check, result in audit.results.items():
252
+ if not result.passed:
253
+ fixable = " (auto-fixable)" if result.auto_fixable else ""
254
+ lines.append(f"- {check.value}: {result.reason}{fixable}")
255
+ lines.append("")
256
+
257
+ return "\n".join(lines)
@@ -0,0 +1,155 @@
1
+ """Checkpoint protocol for skill-orchestrator communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+
15
+ class CheckpointStatus(str, Enum):
16
+ """Status values for checkpoint state."""
17
+
18
+ DONE = "DONE"
19
+ BLOCKED_NEEDS_INPUT = "BLOCKED_NEEDS_INPUT"
20
+ FAILED = "FAILED"
21
+
22
+
23
+ class CheckpointState(BaseModel):
24
+ """State written by skills to communicate with orchestrator."""
25
+
26
+ version: str = "1"
27
+ status: CheckpointStatus
28
+ session_id: str
29
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
30
+
31
+ summary: str | None = None
32
+
33
+ question: str | None = None
34
+ question_context: dict[str, Any] | None = None
35
+
36
+ error: str | None = None
37
+ recoverable: bool = True
38
+
39
+ outputs: dict[str, Any] = Field(default_factory=dict)
40
+
41
+ @model_validator(mode="after")
42
+ def validate_status_fields(self) -> "CheckpointState":
43
+ """Validate that status-dependent fields are present."""
44
+ if self.status == CheckpointStatus.BLOCKED_NEEDS_INPUT and not self.question:
45
+ raise ValueError("question is required when status is BLOCKED_NEEDS_INPUT")
46
+ if self.status == CheckpointStatus.FAILED and not self.error:
47
+ raise ValueError("error is required when status is FAILED")
48
+ return self
49
+
50
+
51
+ class CheckpointError(Exception):
52
+ """Raised when checkpoint operations fail."""
53
+
54
+
55
+ def _get_state_file() -> Path:
56
+ """Get state file path from environment."""
57
+ path = os.environ.get("CTRLRELAY_STATE_FILE")
58
+ if not path:
59
+ raise CheckpointError("CTRLRELAY_STATE_FILE environment variable not set")
60
+ return Path(path)
61
+
62
+
63
+ def _get_session_id() -> str:
64
+ """Get session ID from environment."""
65
+ session_id = os.environ.get("CTRLRELAY_SESSION_ID")
66
+ if not session_id:
67
+ raise CheckpointError("CTRLRELAY_SESSION_ID environment variable not set")
68
+ return session_id
69
+
70
+
71
+ def _write_checkpoint(state: CheckpointState) -> None:
72
+ """Write checkpoint state atomically."""
73
+ state_file = _get_state_file()
74
+ temp_file = state_file.with_suffix(".json.tmp")
75
+
76
+ state_file.parent.mkdir(parents=True, exist_ok=True)
77
+
78
+ # Write to temp file first
79
+ temp_file.write_text(state.model_dump_json(indent=2))
80
+
81
+ # Atomic rename
82
+ temp_file.rename(state_file)
83
+
84
+
85
+ def done(summary: str, outputs: dict[str, Any] | None = None) -> None:
86
+ """Report successful completion."""
87
+ # Check state file first so error message is consistent
88
+ _get_state_file()
89
+ state = CheckpointState(
90
+ status=CheckpointStatus.DONE,
91
+ session_id=_get_session_id(),
92
+ summary=summary,
93
+ outputs=outputs or {},
94
+ )
95
+ _write_checkpoint(state)
96
+
97
+
98
+ def blocked(question: str, context: dict[str, Any] | None = None) -> None:
99
+ """Report blocked on human input."""
100
+ # Check state file first so error message is consistent
101
+ _get_state_file()
102
+ state = CheckpointState(
103
+ status=CheckpointStatus.BLOCKED_NEEDS_INPUT,
104
+ session_id=_get_session_id(),
105
+ question=question,
106
+ question_context=context,
107
+ )
108
+ _write_checkpoint(state)
109
+
110
+
111
+ def failed(error: str, recoverable: bool = True) -> None:
112
+ """Report failure."""
113
+ # Check state file first so error message is consistent
114
+ _get_state_file()
115
+ state = CheckpointState(
116
+ status=CheckpointStatus.FAILED,
117
+ session_id=_get_session_id(),
118
+ error=error,
119
+ recoverable=recoverable,
120
+ )
121
+ _write_checkpoint(state)
122
+
123
+
124
+ def read_checkpoint(path: Path, delete_after: bool = False) -> CheckpointState:
125
+ """Read and parse a checkpoint file.
126
+
127
+ Used by the orchestrator to read skill results.
128
+
129
+ Args:
130
+ path: Path to the checkpoint file.
131
+ delete_after: If True, delete the file after reading.
132
+
133
+ Returns:
134
+ Parsed CheckpointState.
135
+
136
+ Raises:
137
+ CheckpointError: If file not found or invalid.
138
+ """
139
+ if not path.exists():
140
+ raise CheckpointError(f"Checkpoint file not found: {path}")
141
+
142
+ try:
143
+ data = json.loads(path.read_text())
144
+ except json.JSONDecodeError as e:
145
+ raise CheckpointError(f"Failed to parse checkpoint file: {e}") from e
146
+
147
+ try:
148
+ state = CheckpointState.model_validate(data)
149
+ except Exception as e:
150
+ raise CheckpointError(f"Invalid checkpoint data: {e}") from e
151
+
152
+ if delete_after:
153
+ path.unlink()
154
+
155
+ return state