codee-agent 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 (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,93 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from codee.lib.trigger_issue_skills import (
6
+ find_issue_triggered_skills,
7
+ issue_statuses,
8
+ match_issue_skill,
9
+ )
10
+
11
+
12
+ class IssueTriggeredSkillsTest(unittest.TestCase):
13
+ def _skill(self, root: Path, slug: str, frontmatter: str) -> None:
14
+ directory = root / slug
15
+ directory.mkdir()
16
+ (directory / "SKILL.md").write_text(f"---\n{frontmatter}---\nBody\n")
17
+
18
+ def test_reads_the_model_frontmatter_when_present(self) -> None:
19
+ with tempfile.TemporaryDirectory() as temporary_directory:
20
+ root = Path(temporary_directory)
21
+ self._skill(
22
+ root, "with-model",
23
+ "name: With model\ndisable-model-invocation: true\n"
24
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
25
+ "x-codee-issue-type: story\nmodel: claude-opus-5\n",
26
+ )
27
+ self._skill(
28
+ root, "without-model",
29
+ "name: Without model\ndisable-model-invocation: true\n"
30
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
31
+ "x-codee-issue-type: task\n",
32
+ )
33
+
34
+ models = {skill.slug: skill.model
35
+ for skill in find_issue_triggered_skills(root)}
36
+
37
+ self.assertEqual(models,
38
+ {"with-model": "claude-opus-5", "without-model": ""})
39
+
40
+ def test_loads_valid_issue_type_and_rejects_invalid_skills(self) -> None:
41
+ with tempfile.TemporaryDirectory() as temporary_directory:
42
+ root = Path(temporary_directory)
43
+ self._skill(
44
+ root,
45
+ "valid",
46
+ "name: Valid\ndisable-model-invocation: true\n"
47
+ "x-codee-trigger: issue\n"
48
+ "x-codee-issue-status: [Ready, Custom status]\n"
49
+ "x-codee-issue-type: story\n",
50
+ )
51
+ self._skill(
52
+ root,
53
+ "invalid",
54
+ "name: Invalid\nx-codee-trigger: issue\n"
55
+ "x-codee-issue-status: [Ready]\n",
56
+ )
57
+ self._skill(
58
+ root,
59
+ "invalid-type",
60
+ "name: Invalid type\ndisable-model-invocation: true\n"
61
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
62
+ "x-codee-issue-type: bug\n",
63
+ )
64
+
65
+ skills = find_issue_triggered_skills(root)
66
+
67
+ self.assertEqual([skill.slug for skill in skills], ["valid"])
68
+ self.assertEqual(issue_statuses(skills), [
69
+ "Ready", "Custom status"])
70
+ self.assertEqual(match_issue_skill(
71
+ skills, "custom STATUS", "Story").slug, "valid")
72
+
73
+ def test_matches_only_the_requested_issue_type(self) -> None:
74
+ with tempfile.TemporaryDirectory() as temporary_directory:
75
+ root = Path(temporary_directory)
76
+ common = (
77
+ "disable-model-invocation: true\nx-codee-trigger: issue\n"
78
+ "x-codee-issue-status: ['In Progress']\n"
79
+ )
80
+ self._skill(root, "story", f"{common}x-codee-issue-type: story\n")
81
+ self._skill(root, "task", f"{common}x-codee-issue-type: task\n")
82
+ skills = find_issue_triggered_skills(root)
83
+
84
+ self.assertEqual(match_issue_skill(
85
+ skills, "in progress", "Story").slug, "story")
86
+ self.assertEqual(match_issue_skill(
87
+ skills, "in progress", "Task").slug, "task")
88
+ self.assertIsNone(match_issue_skill(
89
+ skills, "in progress", "Bug"))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ unittest.main()
@@ -0,0 +1,224 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Callable, Protocol
5
+
6
+ from codee_main_context.context import CodeeMainContext
7
+
8
+ from codee.lib import runs_db
9
+ from codee_main_context.context import project_root, skills_dir as default_skills_dir
10
+
11
+ REPO_ROOT = project_root()
12
+ SKILLS_DIR = default_skills_dir(REPO_ROOT)
13
+
14
+ RunClaude = Callable[[str, str, str], str]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AwsSqsTriggeredSkill:
19
+ key: str
20
+ name: str
21
+ path: Path
22
+ queue: str
23
+ body: str
24
+ model: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AwsSqsMessage:
29
+ content: str
30
+ queue_url: str
31
+ receipt_handle: str
32
+
33
+
34
+ class AwsSqsMessageSource(Protocol):
35
+ def receive(self, skill: AwsSqsTriggeredSkill) -> AwsSqsMessage | None:
36
+ pass
37
+
38
+ def delete(self, message: AwsSqsMessage) -> None:
39
+ pass
40
+
41
+
42
+ def trigger_aws_sqs_skills(
43
+ run_claude: RunClaude,
44
+ *,
45
+ skills_dir: Path = SKILLS_DIR,
46
+ sqs_message_source: AwsSqsMessageSource | None = None,
47
+ main_context: CodeeMainContext
48
+ ) -> None:
49
+ """Scan skills with AWS SQS trigger frontmatter and run one message per skill."""
50
+ skills = find_aws_sqs_triggered_skills(skills_dir)
51
+ if not skills:
52
+ print("[aws_sqs_skills] No AWS SQS-triggered skills found.")
53
+ return
54
+
55
+ if sqs_message_source is None:
56
+ sqs_message_source = Boto3AwsSqsMessageSource()
57
+
58
+ for skill in skills:
59
+ try:
60
+ message = sqs_message_source.receive(skill)
61
+ except Exception as exc:
62
+ print(
63
+ f"[aws_sqs_skills] Failed to poll SQS trigger for {skill.name}: {exc}")
64
+ continue
65
+
66
+ if message is None:
67
+ continue
68
+
69
+ print(
70
+ f"[aws_sqs_skills] Running {skill.name} from SQS queue {skill.queue}")
71
+ session_id = str(uuid.uuid4())
72
+ prompt = render_aws_sqs_prompt(skill.body, message.content)
73
+ try:
74
+ response = run_claude(prompt, session_id, skill.model)
75
+ print(
76
+ f"[aws_sqs_skills] Claude response for {skill.name} ({len(response)} chars)")
77
+ sqs_message_source.delete(message)
78
+ runs_db.record_run(skill.name, "aws-sqs",
79
+ session_id, "succeeded", message=prompt,
80
+ main_context=main_context)
81
+ except Exception as exc:
82
+ print(f"[aws_sqs_skills] Failed to run {skill.name}: {exc}")
83
+ runs_db.record_run(skill.name, "aws-sqs", session_id, "failed",
84
+ error=str(exc)[:500], message=prompt,
85
+ main_context=main_context)
86
+
87
+
88
+ def find_aws_sqs_triggered_skills(skills_dir: Path = SKILLS_DIR) -> list[AwsSqsTriggeredSkill]:
89
+ skills: list[AwsSqsTriggeredSkill] = []
90
+ for path in sorted(skills_dir.glob("*/SKILL.md")):
91
+ try:
92
+ metadata, body = _parse_skill_file(path.read_text())
93
+ except OSError as exc:
94
+ print(f"[aws_sqs_skills] Failed to read {path}: {exc}")
95
+ continue
96
+
97
+ trigger = metadata.get("x-codee-trigger", "").strip().lower()
98
+ if not trigger:
99
+ continue
100
+
101
+ name = metadata.get(
102
+ "name", path.parent.name).strip() or path.parent.name
103
+ if metadata.get("disable-model-invocation", "").strip().lower() != "true":
104
+ print(
105
+ f"[aws_sqs_skills] ERROR: {path} declares x-codee-trigger but is missing "
106
+ "disable-model-invocation: true; skipping."
107
+ )
108
+ continue
109
+
110
+ if trigger != "aws-sqs":
111
+ continue
112
+
113
+ queue = metadata.get("x-codee-aws-sqs-queue", "").strip()
114
+ if not queue:
115
+ print(
116
+ f"[aws_sqs_skills] ERROR: {path} declares x-codee-trigger: aws-sqs but is missing "
117
+ "x-codee-aws-sqs-queue; skipping."
118
+ )
119
+ continue
120
+
121
+ skills.append(
122
+ AwsSqsTriggeredSkill(
123
+ key=_skill_key(path),
124
+ name=name,
125
+ path=path,
126
+ queue=queue,
127
+ body=body.strip(),
128
+ model=metadata.get("model", "").strip(),
129
+ )
130
+ )
131
+ return skills
132
+
133
+
134
+ class Boto3AwsSqsMessageSource:
135
+ def __init__(self, *, visibility_timeout: int = 7200) -> None:
136
+ import boto3
137
+
138
+ self._sqs = boto3.client("sqs")
139
+ self._visibility_timeout = visibility_timeout
140
+ self._queue_urls_by_skill: dict[str, str] = {}
141
+
142
+ def receive(self, skill: AwsSqsTriggeredSkill) -> AwsSqsMessage | None:
143
+ queue_url = self._resolve_queue_url(skill)
144
+ print("Receiving from SQS queue:", queue_url)
145
+ response = self._sqs.receive_message(
146
+ QueueUrl=queue_url,
147
+ MaxNumberOfMessages=1,
148
+ WaitTimeSeconds=0,
149
+ VisibilityTimeout=self._visibility_timeout,
150
+ AttributeNames=["All"],
151
+ MessageAttributeNames=["All"],
152
+ )
153
+ messages = response.get("Messages", [])
154
+ if not messages:
155
+ return None
156
+
157
+ raw_message = messages[0]
158
+ return AwsSqsMessage(
159
+ content=raw_message.get("Body", ""),
160
+ queue_url=queue_url,
161
+ receipt_handle=raw_message["ReceiptHandle"],
162
+ )
163
+
164
+ def delete(self, message: AwsSqsMessage) -> None:
165
+ self._sqs.delete_message(
166
+ QueueUrl=message.queue_url, ReceiptHandle=message.receipt_handle)
167
+
168
+ def _resolve_queue_url(self, skill: AwsSqsTriggeredSkill) -> str:
169
+ if skill.key in self._queue_urls_by_skill:
170
+ return self._queue_urls_by_skill[skill.key]
171
+
172
+ if skill.queue.startswith("http://") or skill.queue.startswith("https://"):
173
+ queue_url = skill.queue
174
+ else:
175
+ queue_url = self._sqs.get_queue_url(
176
+ QueueName=skill.queue)["QueueUrl"]
177
+
178
+ self._queue_urls_by_skill[skill.key] = queue_url
179
+ return queue_url
180
+
181
+
182
+ def render_aws_sqs_prompt(body: str, content: str) -> str:
183
+ if "{CONTENT}" in body:
184
+ return body.replace("{CONTENT}", content)
185
+ return f"{body.rstrip()}\n\n{content}"
186
+
187
+
188
+ def _parse_skill_file(contents: str) -> tuple[dict[str, str], str]:
189
+ lines = contents.splitlines()
190
+ if not lines or lines[0].strip() != "---":
191
+ return {}, contents
192
+
193
+ end_index = None
194
+ for index, line in enumerate(lines[1:], start=1):
195
+ if line.strip() == "---":
196
+ end_index = index
197
+ break
198
+
199
+ if end_index is None:
200
+ return {}, contents
201
+
202
+ metadata: dict[str, str] = {}
203
+ for line in lines[1:end_index]:
204
+ stripped = line.strip()
205
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
206
+ continue
207
+ key, _, value = stripped.partition(":")
208
+ metadata[key.strip().lower()] = _strip_quotes(value.strip())
209
+
210
+ body = "\n".join(lines[end_index + 1:]).lstrip("\n")
211
+ return metadata, body
212
+
213
+
214
+ def _skill_key(path: Path) -> str:
215
+ try:
216
+ return str(path.relative_to(REPO_ROOT))
217
+ except ValueError:
218
+ return str(path)
219
+
220
+
221
+ def _strip_quotes(value: str) -> str:
222
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
223
+ return value[1:-1]
224
+ return value
@@ -0,0 +1,364 @@
1
+ import json
2
+ import uuid
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from codee_main_context.context import CodeeMainContext
9
+
10
+ from codee.lib import runs_db
11
+ from codee_main_context.context import project_root, skills_dir as default_skills_dir
12
+
13
+ REPO_ROOT = project_root()
14
+ SKILLS_DIR = default_skills_dir(REPO_ROOT)
15
+
16
+ # How far back to look for a missed scheduled fire. A tick can be delayed for
17
+ # hours because a single run_once may block on long-running Claude jobs (each up
18
+ # to 2h). Without catch-up the exact cron minute is skipped and the job waits a
19
+ # whole period (e.g. a week for "0 0 * * 1"). 24h covers realistic backlogs
20
+ # while still refusing to run occurrences that are absurdly stale.
21
+ DEFAULT_CATCHUP = timedelta(hours=24)
22
+
23
+
24
+ # (prompt, session_id, model) -> agent reply. `model` is the skill's `model:`
25
+ # frontmatter, empty when it declares none.
26
+ RunClaude = Callable[[str, str, str], str]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CronField:
31
+ values: set[int]
32
+ is_wildcard: bool
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ScheduledSkill:
37
+ key: str
38
+ name: str
39
+ path: Path
40
+ cron: str
41
+ body: str
42
+ model: str
43
+
44
+
45
+ def _get_force_file_path(main_context: CodeeMainContext) -> Path:
46
+ return main_context.data_dir / "cron_skill_force.json"
47
+
48
+
49
+ def trigger_cron_skills(
50
+ run_claude: RunClaude,
51
+ *,
52
+ now: datetime | None = None,
53
+ skills_dir: Path = SKILLS_DIR,
54
+ state_file: Path = None,
55
+ force_file: Path = None,
56
+ catchup_window: timedelta = DEFAULT_CATCHUP,
57
+ main_context: CodeeMainContext
58
+ ) -> None:
59
+ """Scan skills with cron frontmatter and run due jobs.
60
+
61
+ A job is considered due if its most recent scheduled fire at or before
62
+ ``now`` (within ``catchup_window``) has not been recorded yet. This lets a
63
+ delayed tick still run a job whose exact cron minute was missed while the
64
+ process was busy, instead of skipping it until the next period.
65
+ """
66
+
67
+ if state_file is None:
68
+ state_file = main_context.data_dir / "cron_skill_runs.json"
69
+ if force_file is None:
70
+ force_file = _get_force_file_path(main_context)
71
+
72
+ tick = (now or datetime.now()).replace(second=0, microsecond=0)
73
+ state = _load_state(state_file)
74
+ forced = _load_force(force_file)
75
+ changed = False
76
+ force_changed = False
77
+
78
+ scheduled_skills = _find_scheduled_skills(skills_dir)
79
+ if not scheduled_skills:
80
+ print("[cron_skills] No scheduled skills found.")
81
+ return
82
+
83
+ for skill in scheduled_skills:
84
+ is_forced = skill.key in forced
85
+
86
+ if is_forced:
87
+ # Admin UI asked for a one-off run on this tick; ignore the schedule.
88
+ print(
89
+ f"[cron_skills] Force-running {skill.name} on this tick (manual schedule).")
90
+ else:
91
+ try:
92
+ due = _latest_due(skill.cron, tick, catchup_window)
93
+ except ValueError as exc:
94
+ print(f"[cron_skills] Invalid cron for {skill.name}: {exc}")
95
+ continue
96
+
97
+ if due is None:
98
+ continue
99
+
100
+ due_slot = due.isoformat(timespec="minutes")
101
+ if state.get(skill.key) == due_slot:
102
+ continue
103
+
104
+ if skill.key not in state and due != tick:
105
+ # First time we observe this skill and the only due fire is in the
106
+ # past (before the process/skill existed). Don't back-run it; seed a
107
+ # baseline so genuine future misses are still caught up.
108
+ state[skill.key] = due_slot
109
+ changed = True
110
+ continue
111
+
112
+ if due != tick:
113
+ print(
114
+ f"[cron_skills] {skill.name} ({skill.cron}) scheduled at {due_slot} "
115
+ f"was missed; catching up at {tick.isoformat(timespec='minutes')}."
116
+ )
117
+ print(
118
+ f"[cron_skills] Running {skill.name} ({skill.cron}) from {skill.path}")
119
+ session_id = str(uuid.uuid4())
120
+ try:
121
+ response = run_claude(skill.body, session_id, skill.model)
122
+ print(
123
+ f"[cron_skills] Claude response for {skill.name} ({len(response)} chars)")
124
+ runs_db.record_run(skill.name, "cron", session_id,
125
+ "succeeded", message=skill.body,
126
+ main_context=main_context)
127
+ except Exception as exc:
128
+ # Don't advance the state slot: leave the job "due" so a later tick
129
+ # (within the catch-up window) retries it instead of skipping the
130
+ # whole period. Over-limit runs raise here and get retried.
131
+ print(
132
+ f"[cron_skills] Failed to run {skill.name}, will retry: {exc}")
133
+ runs_db.record_run(skill.name, "cron", session_id, "failed",
134
+ error=str(exc)[:500], message=skill.body,
135
+ main_context=main_context)
136
+ continue
137
+ if is_forced:
138
+ # One-shot off-schedule run: clear the flag but leave the cron slot
139
+ # untracked, so the next tick still catches up a genuinely-due fire.
140
+ forced.discard(skill.key)
141
+ force_changed = True
142
+ else:
143
+ state[skill.key] = due_slot
144
+ changed = True
145
+
146
+ if changed:
147
+ _save_state(state_file, state)
148
+ if force_changed:
149
+ _save_force(force_file, forced)
150
+
151
+
152
+ def _latest_due(cron: str, tick: datetime, catchup_window: timedelta) -> datetime | None:
153
+ """Most recent minute matching ``cron`` in ``[tick - catchup_window, tick]``.
154
+
155
+ Returns ``None`` if no scheduled fire falls within the window. Validates the
156
+ expression on the first candidate so an invalid cron raises ``ValueError``.
157
+ """
158
+ max_steps = max(0, int(catchup_window.total_seconds() // 60))
159
+ candidate = tick
160
+ for _ in range(max_steps + 1):
161
+ if _cron_matches(cron, candidate):
162
+ return candidate
163
+ candidate -= timedelta(minutes=1)
164
+ return None
165
+
166
+
167
+ def _find_scheduled_skills(skills_dir: Path = SKILLS_DIR) -> list[ScheduledSkill]:
168
+ skills: list[ScheduledSkill] = []
169
+ for path in sorted(skills_dir.glob("*/SKILL.md")):
170
+ try:
171
+ metadata, body = _parse_skill_file(path.read_text())
172
+ except OSError as exc:
173
+ print(f"[cron_skills] Failed to read {path}: {exc}")
174
+ continue
175
+
176
+ # New convention: x-codee-trigger: cron + x-codee-cron. Old `cron` kept as fallback.
177
+ cron = (metadata.get("x-codee-cron")
178
+ or metadata.get("cron", "")).strip()
179
+ if not cron:
180
+ continue
181
+
182
+ name = metadata.get(
183
+ "name", path.parent.name).strip() or path.parent.name
184
+ if metadata.get("disable-model-invocation", "").strip().lower() != "true":
185
+ print(
186
+ f"[cron_skills] ERROR: {path} declares cron but is missing "
187
+ "disable-model-invocation: true; skipping."
188
+ )
189
+ continue
190
+
191
+ skills.append(
192
+ ScheduledSkill(
193
+ key=_skill_key(path),
194
+ name=name,
195
+ path=path,
196
+ cron=cron,
197
+ body=body.strip(),
198
+ model=metadata.get("model", "").strip(),
199
+ )
200
+ )
201
+ return skills
202
+
203
+
204
+ def _parse_skill_file(contents: str) -> tuple[dict[str, str], str]:
205
+ lines = contents.splitlines()
206
+ if not lines or lines[0].strip() != "---":
207
+ return {}, contents
208
+
209
+ end_index = None
210
+ for index, line in enumerate(lines[1:], start=1):
211
+ if line.strip() == "---":
212
+ end_index = index
213
+ break
214
+
215
+ if end_index is None:
216
+ return {}, contents
217
+
218
+ metadata: dict[str, str] = {}
219
+ for line in lines[1:end_index]:
220
+ stripped = line.strip()
221
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
222
+ continue
223
+ key, _, value = stripped.partition(":")
224
+ metadata[key.strip().lower()] = _strip_quotes(value.strip())
225
+
226
+ body = "\n".join(lines[end_index + 1:]).lstrip("\n")
227
+ return metadata, body
228
+
229
+
230
+ def _cron_matches(expression: str, tick: datetime) -> bool:
231
+ parts = expression.split()
232
+ if len(parts) != 5:
233
+ raise ValueError(
234
+ "expected 5 fields: minute hour day-of-month month day-of-week")
235
+
236
+ minute = _parse_cron_field(parts[0], 0, 59)
237
+ hour = _parse_cron_field(parts[1], 0, 23)
238
+ day_of_month = _parse_cron_field(parts[2], 1, 31)
239
+ month = _parse_cron_field(parts[3], 1, 12)
240
+ day_of_week = _parse_cron_field(
241
+ parts[4], 0, 7, normalize_seven_to_zero=True)
242
+ cron_day_of_week = (tick.weekday() + 1) % 7
243
+
244
+ if tick.minute not in minute.values or tick.hour not in hour.values or tick.month not in month.values:
245
+ return False
246
+
247
+ month_day_matches = tick.day in day_of_month.values
248
+ week_day_matches = cron_day_of_week in day_of_week.values
249
+
250
+ if not day_of_month.is_wildcard and not day_of_week.is_wildcard:
251
+ return month_day_matches or week_day_matches
252
+ return month_day_matches and week_day_matches
253
+
254
+
255
+ def _parse_cron_field(
256
+ field: str,
257
+ minimum: int,
258
+ maximum: int,
259
+ *,
260
+ normalize_seven_to_zero: bool = False,
261
+ ) -> CronField:
262
+ values: set[int] = set()
263
+ is_wildcard = True
264
+
265
+ for item in field.split(","):
266
+ item = item.strip()
267
+ if not item:
268
+ raise ValueError(f"empty cron field item in {field!r}")
269
+
270
+ base, slash, step_text = item.partition("/")
271
+ step = 1
272
+ if slash:
273
+ if not step_text.isdigit() or int(step_text) <= 0:
274
+ raise ValueError(f"invalid step {step_text!r} in {field!r}")
275
+ step = int(step_text)
276
+
277
+ if base == "*":
278
+ start = minimum
279
+ end = maximum
280
+ elif "-" in base:
281
+ start_text, _, end_text = base.partition("-")
282
+ start = _parse_cron_int(
283
+ start_text, minimum, maximum, normalize_seven_to_zero)
284
+ end = _parse_cron_int(
285
+ end_text, minimum, maximum, normalize_seven_to_zero)
286
+ is_wildcard = False
287
+ if start > end:
288
+ raise ValueError(f"invalid range {base!r} in {field!r}")
289
+ else:
290
+ value = _parse_cron_int(
291
+ base, minimum, maximum, normalize_seven_to_zero)
292
+ start = value
293
+ end = value
294
+ is_wildcard = False
295
+
296
+ values.update(range(start, end + 1, step))
297
+
298
+ if normalize_seven_to_zero and 7 in values:
299
+ values.remove(7)
300
+ values.add(0)
301
+
302
+ return CronField(values=values, is_wildcard=is_wildcard)
303
+
304
+
305
+ def _parse_cron_int(value: str, minimum: int, maximum: int, normalize_seven_to_zero: bool) -> int:
306
+ if not value.isdigit():
307
+ raise ValueError(f"invalid integer {value!r}")
308
+ parsed = int(value)
309
+ if normalize_seven_to_zero and parsed == 7:
310
+ return parsed
311
+ if parsed < minimum or parsed > maximum:
312
+ raise ValueError(f"value {parsed} outside {minimum}-{maximum}")
313
+ return parsed
314
+
315
+
316
+ def _load_state(state_file: Path) -> dict[str, str]:
317
+ try:
318
+ if state_file.exists():
319
+ data = json.loads(state_file.read_text())
320
+ if isinstance(data, dict):
321
+ return {str(key): str(value) for key, value in data.items()}
322
+ except (json.JSONDecodeError, OSError) as exc:
323
+ print(f"[cron_skills] Failed to load state from {state_file}: {exc}")
324
+ return {}
325
+
326
+
327
+ def _save_state(state_file: Path, state: dict[str, str]) -> None:
328
+ state_file.write_text(json.dumps(state, indent=2, sort_keys=True))
329
+
330
+
331
+ def _load_force(force_file: Path) -> set[str]:
332
+ try:
333
+ if force_file.exists():
334
+ data = json.loads(force_file.read_text())
335
+ if isinstance(data, list):
336
+ return {str(key) for key in data}
337
+ except (json.JSONDecodeError, OSError) as exc:
338
+ print(
339
+ f"[cron_skills] Failed to load force list from {force_file}: {exc}")
340
+ return set()
341
+
342
+
343
+ def _save_force(force_file: Path, keys: set[str]) -> None:
344
+ force_file.write_text(json.dumps(sorted(keys), indent=2))
345
+
346
+
347
+ def request_force_run(skill_path: Path, main_context: CodeeMainContext) -> None:
348
+ """Queue a cron skill to run on the next trigger tick, ignoring its schedule."""
349
+ keys = _load_force(_get_force_file_path(main_context))
350
+ keys.add(_skill_key(Path(skill_path)))
351
+ _save_force(_get_force_file_path(main_context), keys)
352
+
353
+
354
+ def _skill_key(path: Path) -> str:
355
+ try:
356
+ return str(path.relative_to(REPO_ROOT))
357
+ except ValueError:
358
+ return str(path)
359
+
360
+
361
+ def _strip_quotes(value: str) -> str:
362
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
363
+ return value[1:-1]
364
+ return value