koru 0.1.9__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.
- koru/__init__.py +67 -0
- koru/agents.py +222 -0
- koru/bootstrap.py +396 -0
- koru/cli.py +884 -0
- koru/context.py +734 -0
- koru/doctor.py +305 -0
- koru/dotenv_loader.py +104 -0
- koru/events.py +90 -0
- koru/init.py +317 -0
- koru/loop.py +131 -0
- koru/planfile_queue.py +705 -0
- koru/policy.py +240 -0
- koru/run_log.py +124 -0
- koru/runtime.py +103 -0
- koru/scan.py +474 -0
- koru/semcod_tools.py +129 -0
- koru/serve.py +486 -0
- koru/tasks.py +139 -0
- koru/watch.py +83 -0
- koru-0.1.9.dist-info/METADATA +556 -0
- koru-0.1.9.dist-info/RECORD +25 -0
- koru-0.1.9.dist-info/WHEEL +5 -0
- koru-0.1.9.dist-info/entry_points.txt +2 -0
- koru-0.1.9.dist-info/licenses/LICENSE +201 -0
- koru-0.1.9.dist-info/top_level.txt +1 -0
koru/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Koru loop automation package."""
|
|
2
|
+
|
|
3
|
+
from .bootstrap import (
|
|
4
|
+
ImportReport,
|
|
5
|
+
ValidationError,
|
|
6
|
+
import_flat_pipeline,
|
|
7
|
+
load_flat_pipeline,
|
|
8
|
+
materialize_to_planfile,
|
|
9
|
+
validate_flat_pipeline,
|
|
10
|
+
)
|
|
11
|
+
from .context import build_context, render_markdown_handoff
|
|
12
|
+
from .doctor import Check, DoctorReport, run_diagnostics
|
|
13
|
+
from .init import InitReport, init_project
|
|
14
|
+
from .loop import LoopReport, RunRecord, discover_repositories, run_closed_loop
|
|
15
|
+
from .planfile_queue import (
|
|
16
|
+
LlmRunResult,
|
|
17
|
+
QueueLoopResult,
|
|
18
|
+
QueueRunResult,
|
|
19
|
+
run_next_planfile_task,
|
|
20
|
+
run_planfile_queue_loop,
|
|
21
|
+
)
|
|
22
|
+
from .policy import Policy, load_policy, policy_path, policy_violations
|
|
23
|
+
from .run_log import RunLogWriter, open_run_log, open_run_log_eagerly
|
|
24
|
+
from .runtime import (
|
|
25
|
+
ensure_runs_dir,
|
|
26
|
+
new_run_id,
|
|
27
|
+
planfile_dir,
|
|
28
|
+
runs_dir,
|
|
29
|
+
runtime_dir,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Check",
|
|
34
|
+
"DoctorReport",
|
|
35
|
+
"ImportReport",
|
|
36
|
+
"InitReport",
|
|
37
|
+
"LlmRunResult",
|
|
38
|
+
"LoopReport",
|
|
39
|
+
"Policy",
|
|
40
|
+
"QueueLoopResult",
|
|
41
|
+
"QueueRunResult",
|
|
42
|
+
"RunLogWriter",
|
|
43
|
+
"RunRecord",
|
|
44
|
+
"ValidationError",
|
|
45
|
+
"build_context",
|
|
46
|
+
"discover_repositories",
|
|
47
|
+
"ensure_runs_dir",
|
|
48
|
+
"import_flat_pipeline",
|
|
49
|
+
"init_project",
|
|
50
|
+
"load_flat_pipeline",
|
|
51
|
+
"load_policy",
|
|
52
|
+
"materialize_to_planfile",
|
|
53
|
+
"new_run_id",
|
|
54
|
+
"open_run_log",
|
|
55
|
+
"open_run_log_eagerly",
|
|
56
|
+
"planfile_dir",
|
|
57
|
+
"policy_path",
|
|
58
|
+
"policy_violations",
|
|
59
|
+
"render_markdown_handoff",
|
|
60
|
+
"run_diagnostics",
|
|
61
|
+
"run_closed_loop",
|
|
62
|
+
"run_next_planfile_task",
|
|
63
|
+
"run_planfile_queue_loop",
|
|
64
|
+
"runs_dir",
|
|
65
|
+
"runtime_dir",
|
|
66
|
+
"validate_flat_pipeline",
|
|
67
|
+
]
|
koru/agents.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Detect and launch LLM/IDE agents available for a koru project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .runtime import runtime_dir
|
|
15
|
+
from .semcod_tools import detect_semcod_tools
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AgentOption:
|
|
20
|
+
id: str
|
|
21
|
+
label: str
|
|
22
|
+
available: bool
|
|
23
|
+
launchable: bool
|
|
24
|
+
command: str | None = None
|
|
25
|
+
reason: str = ""
|
|
26
|
+
project_hint: bool = False
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"id": self.id,
|
|
31
|
+
"label": self.label,
|
|
32
|
+
"available": self.available,
|
|
33
|
+
"launchable": self.launchable,
|
|
34
|
+
"command": self.command,
|
|
35
|
+
"reason": self.reason,
|
|
36
|
+
"project_hint": self.project_hint,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _which(command: str) -> str | None:
|
|
41
|
+
return shutil.which(command)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _marker(project: Path, *parts: str) -> bool:
|
|
45
|
+
return (project.joinpath(*parts)).exists()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_agent_options(project: Path) -> list[AgentOption]:
|
|
49
|
+
"""Return known LLM/IDE lanes ordered by koru preference."""
|
|
50
|
+
project = project.resolve()
|
|
51
|
+
windsurf_cmd = _which("windsurf")
|
|
52
|
+
cursor_cmd = _which("cursor")
|
|
53
|
+
claude_cmd = _which("claude")
|
|
54
|
+
aider_cmd = _which("aider")
|
|
55
|
+
codex_cmd = _which("codex")
|
|
56
|
+
openrouter_ready = bool(os.getenv("OPENROUTER_API_KEY"))
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
AgentOption(
|
|
60
|
+
id="claude-code",
|
|
61
|
+
label="Claude Code",
|
|
62
|
+
available=bool(claude_cmd),
|
|
63
|
+
launchable=bool(claude_cmd),
|
|
64
|
+
command=claude_cmd,
|
|
65
|
+
reason=(
|
|
66
|
+
"Claude Code CLI detected in PATH."
|
|
67
|
+
if claude_cmd
|
|
68
|
+
else "Install Claude Code CLI to launch it from koru."
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
AgentOption(
|
|
72
|
+
id="codex",
|
|
73
|
+
label="Codex CLI",
|
|
74
|
+
available=bool(codex_cmd),
|
|
75
|
+
launchable=bool(codex_cmd),
|
|
76
|
+
command=codex_cmd,
|
|
77
|
+
reason="Codex CLI detected in PATH." if codex_cmd else "Codex CLI is not in PATH.",
|
|
78
|
+
),
|
|
79
|
+
AgentOption(
|
|
80
|
+
id="cursor",
|
|
81
|
+
label="Cursor",
|
|
82
|
+
available=bool(cursor_cmd or _marker(project, ".cursor")),
|
|
83
|
+
launchable=bool(cursor_cmd),
|
|
84
|
+
command=cursor_cmd,
|
|
85
|
+
project_hint=_marker(project, ".cursor"),
|
|
86
|
+
reason=(
|
|
87
|
+
"Cursor CLI detected."
|
|
88
|
+
if cursor_cmd
|
|
89
|
+
else "Cursor project config detected; open the prompt in Cursor manually."
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
AgentOption(
|
|
93
|
+
id="windsurf",
|
|
94
|
+
label="Windsurf",
|
|
95
|
+
available=bool(windsurf_cmd or _marker(project, ".windsurf")),
|
|
96
|
+
launchable=bool(windsurf_cmd),
|
|
97
|
+
command=windsurf_cmd,
|
|
98
|
+
project_hint=_marker(project, ".windsurf"),
|
|
99
|
+
reason=(
|
|
100
|
+
"Windsurf CLI detected."
|
|
101
|
+
if windsurf_cmd
|
|
102
|
+
else "Windsurf project rules detected; paste the prompt into Windsurf."
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
AgentOption(
|
|
106
|
+
id="aider",
|
|
107
|
+
label="aider",
|
|
108
|
+
available=bool(aider_cmd),
|
|
109
|
+
launchable=bool(aider_cmd),
|
|
110
|
+
command=aider_cmd,
|
|
111
|
+
reason="aider detected in PATH." if aider_cmd else "aider is not in PATH.",
|
|
112
|
+
),
|
|
113
|
+
AgentOption(
|
|
114
|
+
id="openrouter",
|
|
115
|
+
label="OpenRouter automation lane",
|
|
116
|
+
available=openrouter_ready,
|
|
117
|
+
launchable=False,
|
|
118
|
+
command=None,
|
|
119
|
+
reason=(
|
|
120
|
+
"OPENROUTER_API_KEY is set; executor.kind=llm can run headless."
|
|
121
|
+
if openrouter_ready
|
|
122
|
+
else "OPENROUTER_API_KEY is not set."
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_project_environment(project: Path) -> dict[str, Any]:
|
|
129
|
+
"""Best-effort, read-only fingerprint of the current project."""
|
|
130
|
+
project = project.resolve()
|
|
131
|
+
markers = {
|
|
132
|
+
"git": _marker(project, ".git"),
|
|
133
|
+
"planfile": _marker(project, ".planfile", "config.yaml"),
|
|
134
|
+
"koru_policy": _marker(project, ".planfile", ".koru", "policy.yaml"),
|
|
135
|
+
"pyproject": _marker(project, "pyproject.toml"),
|
|
136
|
+
"package_json": _marker(project, "package.json"),
|
|
137
|
+
"taskfile": _marker(project, "Taskfile.yml") or _marker(project, "Taskfile.yaml"),
|
|
138
|
+
"makefile": _marker(project, "Makefile"),
|
|
139
|
+
"docker_compose": any(project.glob("docker-compose*.yml"))
|
|
140
|
+
or any(project.glob("docker-compose*.yaml")),
|
|
141
|
+
"windsurf_rules": _marker(project, ".windsurf", "rules.md"),
|
|
142
|
+
"cursor_rules": _marker(project, ".cursor"),
|
|
143
|
+
# On-change gate triad — surfaced in the brief's "On-change gates"
|
|
144
|
+
# section so the agent immediately sees which packages are wired
|
|
145
|
+
# to validate the project on every file save / pre-complete check.
|
|
146
|
+
"wup_yaml": _marker(project, "wup.yaml"),
|
|
147
|
+
"regix_yaml": _marker(project, "regix.yaml"),
|
|
148
|
+
"testql_scenarios": (
|
|
149
|
+
_marker(project, "testql-testing", "scenarios")
|
|
150
|
+
or _marker(project, "testql-scenarios")
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
"cwd": str(project),
|
|
155
|
+
"name": project.name,
|
|
156
|
+
"python": sys.version.split()[0],
|
|
157
|
+
"platform": platform.platform(),
|
|
158
|
+
"markers": markers,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_agent_environment(project: Path) -> dict[str, Any]:
|
|
163
|
+
"""Combined environment block embedded in the LLM handoff."""
|
|
164
|
+
agents = detect_agent_options(project)
|
|
165
|
+
recommended = next((agent for agent in agents if agent.available), None)
|
|
166
|
+
semcod_tools = detect_semcod_tools(project)
|
|
167
|
+
return {
|
|
168
|
+
"project": detect_project_environment(project),
|
|
169
|
+
"llm_agents": [agent.to_dict() for agent in agents],
|
|
170
|
+
"recommended_agent": recommended.to_dict() if recommended else None,
|
|
171
|
+
"semcod_tools": [tool.to_dict() for tool in semcod_tools],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def select_agent(
|
|
176
|
+
agents: list[AgentOption],
|
|
177
|
+
*,
|
|
178
|
+
agent_id: str | None = None,
|
|
179
|
+
interactive: bool = True,
|
|
180
|
+
) -> AgentOption | None:
|
|
181
|
+
candidates = [agent for agent in agents if agent.available]
|
|
182
|
+
if agent_id:
|
|
183
|
+
return next((agent for agent in agents if agent.id == agent_id), None)
|
|
184
|
+
if not candidates:
|
|
185
|
+
return None
|
|
186
|
+
launchable = [agent for agent in candidates if agent.launchable]
|
|
187
|
+
if len(launchable) <= 1 or not interactive:
|
|
188
|
+
return (launchable or candidates)[0]
|
|
189
|
+
|
|
190
|
+
print("Multiple launchable agents detected:")
|
|
191
|
+
for index, agent in enumerate(launchable, start=1):
|
|
192
|
+
print(f" {index}. {agent.label} ({agent.id})")
|
|
193
|
+
choice = input("Select agent number: ").strip()
|
|
194
|
+
try:
|
|
195
|
+
return launchable[int(choice) - 1]
|
|
196
|
+
except (ValueError, IndexError):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def save_agent_prompt(project: Path, prompt: str) -> Path:
|
|
201
|
+
prompts = runtime_dir(project) / "prompts"
|
|
202
|
+
prompts.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
path = prompts / "latest-agent-prompt.md"
|
|
204
|
+
path.write_text(prompt, encoding="utf-8")
|
|
205
|
+
return path
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def launch_agent(agent: AgentOption, project: Path, prompt: str) -> int:
|
|
209
|
+
"""Launch an agent CLI from the project root after saving the prompt."""
|
|
210
|
+
prompt_path = save_agent_prompt(project, prompt)
|
|
211
|
+
if not agent.launchable or not agent.command:
|
|
212
|
+
print(f"koru agent: {agent.label} is not launchable from PATH.")
|
|
213
|
+
print(f"Prompt saved: {prompt_path}")
|
|
214
|
+
return 2
|
|
215
|
+
print(f"koru agent: launching {agent.label}")
|
|
216
|
+
print(f"Prompt saved: {prompt_path}")
|
|
217
|
+
print("Open that prompt in the agent if its CLI starts an interactive session.")
|
|
218
|
+
try:
|
|
219
|
+
return subprocess.call([agent.command], cwd=project)
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
print(f"koru agent: failed to launch {agent.label}: {exc}")
|
|
222
|
+
return 1
|
koru/bootstrap.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Bootstrap a new project from a flat-format pipeline YAML.
|
|
2
|
+
|
|
3
|
+
This module bridges the two koru pipeline formats:
|
|
4
|
+
|
|
5
|
+
- **Authoring (flat)** — the format from
|
|
6
|
+
``docs/planfile-execution-gateway.md`` and ``examples/bootstrap.planfile.yaml``.
|
|
7
|
+
Tasks are a top-level list, easy to write and review.
|
|
8
|
+
|
|
9
|
+
- **Runtime (nested)** — the planfile-native layout:
|
|
10
|
+
``.planfile/sprints/<sprint>.yaml`` with tickets keyed by id under
|
|
11
|
+
``sprint.tickets``. Used by ``planfile`` CLI and ``koru --queue``.
|
|
12
|
+
|
|
13
|
+
The converter validates the flat schema and materialises it into the
|
|
14
|
+
runtime layout without touching planfile internals — koru stays a thin
|
|
15
|
+
orchestrator and planfile remains the source of truth at runtime.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
VALID_EXECUTOR_KINDS: frozenset[str] = frozenset({"shell", "human", "llm", "api", "mcp"})
|
|
27
|
+
VALID_EXECUTOR_MODES: frozenset[str] = frozenset({"automatic", "interactive"})
|
|
28
|
+
VALID_STATUSES: frozenset[str] = frozenset(
|
|
29
|
+
{"open", "in_progress", "review", "done", "blocked"}
|
|
30
|
+
)
|
|
31
|
+
VALID_PRIORITIES: frozenset[str] = frozenset({"critical", "high", "medium", "normal", "low"})
|
|
32
|
+
VALID_EXECUTION_STATES: frozenset[str] = frozenset(
|
|
33
|
+
{"pending", "ready", "running", "waiting_input", "done", "failed", "skipped"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ValidationError:
|
|
39
|
+
task_id: str
|
|
40
|
+
field: str
|
|
41
|
+
message: str
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return f"{self.task_id}: {self.field}: {self.message}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ImportReport:
|
|
49
|
+
project_dir: Path
|
|
50
|
+
sprint: str
|
|
51
|
+
tickets_imported: list[str] = field(default_factory=list)
|
|
52
|
+
config_created: bool = False
|
|
53
|
+
sprint_file_created: bool = False
|
|
54
|
+
sprint_file_overwritten: bool = False
|
|
55
|
+
|
|
56
|
+
def summary(self) -> str:
|
|
57
|
+
lines = [
|
|
58
|
+
f"project: {self.project_dir}",
|
|
59
|
+
f"sprint: {self.sprint}",
|
|
60
|
+
f"tickets: {len(self.tickets_imported)} imported",
|
|
61
|
+
]
|
|
62
|
+
if self.config_created:
|
|
63
|
+
lines.append("config: .planfile/config.yaml created")
|
|
64
|
+
if self.sprint_file_created:
|
|
65
|
+
lines.append(f"sprint: .planfile/sprints/{self.sprint}.yaml created")
|
|
66
|
+
elif self.sprint_file_overwritten:
|
|
67
|
+
lines.append(f"sprint: .planfile/sprints/{self.sprint}.yaml overwritten")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Loader + validator
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_flat_pipeline(path: str | Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
77
|
+
"""Read a flat-format pipeline YAML and return ``(header, tasks)``.
|
|
78
|
+
|
|
79
|
+
The file must have a ``tasks`` top-level list. The header is everything
|
|
80
|
+
else (project, version, generated, description, etc.) and is preserved
|
|
81
|
+
for round-tripping.
|
|
82
|
+
"""
|
|
83
|
+
file = Path(path)
|
|
84
|
+
if not file.exists():
|
|
85
|
+
raise FileNotFoundError(f"pipeline file not found: {file}")
|
|
86
|
+
data = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
raise ValueError(f"{file}: expected a YAML mapping at top level")
|
|
89
|
+
raw_tasks = data.get("tasks")
|
|
90
|
+
if not isinstance(raw_tasks, list):
|
|
91
|
+
raise ValueError(f"{file}: missing top-level 'tasks' list")
|
|
92
|
+
tasks = [t for t in raw_tasks if isinstance(t, dict)]
|
|
93
|
+
header = {k: v for k, v in data.items() if k != "tasks"}
|
|
94
|
+
return header, tasks
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_flat_pipeline(tasks: list[dict[str, Any]]) -> list[ValidationError]:
|
|
98
|
+
"""Validate a flat pipeline. Returns a list of errors (empty == valid)."""
|
|
99
|
+
errors: list[ValidationError] = []
|
|
100
|
+
seen_ids: set[str] = set()
|
|
101
|
+
|
|
102
|
+
for task in tasks:
|
|
103
|
+
tid = str(task.get("id") or "<missing-id>")
|
|
104
|
+
|
|
105
|
+
if "id" not in task:
|
|
106
|
+
errors.append(ValidationError(tid, "id", "missing"))
|
|
107
|
+
continue
|
|
108
|
+
if tid in seen_ids:
|
|
109
|
+
errors.append(ValidationError(tid, "id", "duplicate"))
|
|
110
|
+
continue
|
|
111
|
+
seen_ids.add(tid)
|
|
112
|
+
|
|
113
|
+
# name
|
|
114
|
+
if not (task.get("name") or task.get("title")):
|
|
115
|
+
errors.append(ValidationError(tid, "name", "missing (or 'title')"))
|
|
116
|
+
|
|
117
|
+
# status
|
|
118
|
+
status = task.get("status", "open")
|
|
119
|
+
if status not in VALID_STATUSES:
|
|
120
|
+
errors.append(
|
|
121
|
+
ValidationError(tid, "status", f"{status!r} not in {sorted(VALID_STATUSES)}")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# priority
|
|
125
|
+
priority = task.get("priority", "normal")
|
|
126
|
+
if isinstance(priority, str) and priority not in VALID_PRIORITIES:
|
|
127
|
+
errors.append(
|
|
128
|
+
ValidationError(
|
|
129
|
+
tid, "priority", f"{priority!r} not in {sorted(VALID_PRIORITIES)}"
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# executor
|
|
134
|
+
executor = task.get("executor")
|
|
135
|
+
if not isinstance(executor, dict):
|
|
136
|
+
errors.append(ValidationError(tid, "executor", "missing or not a mapping"))
|
|
137
|
+
else:
|
|
138
|
+
kind = executor.get("kind")
|
|
139
|
+
if kind not in VALID_EXECUTOR_KINDS:
|
|
140
|
+
errors.append(
|
|
141
|
+
ValidationError(
|
|
142
|
+
tid, "executor.kind", f"{kind!r} not in {sorted(VALID_EXECUTOR_KINDS)}"
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
mode = executor.get("mode", "automatic")
|
|
146
|
+
if mode not in VALID_EXECUTOR_MODES:
|
|
147
|
+
errors.append(
|
|
148
|
+
ValidationError(
|
|
149
|
+
tid, "executor.mode", f"{mode!r} not in {sorted(VALID_EXECUTOR_MODES)}"
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# execution.state
|
|
154
|
+
execution = task.get("execution") or {}
|
|
155
|
+
state = execution.get("state", "pending")
|
|
156
|
+
if state not in VALID_EXECUTION_STATES:
|
|
157
|
+
errors.append(
|
|
158
|
+
ValidationError(
|
|
159
|
+
tid, "execution.state", f"{state!r} not in {sorted(VALID_EXECUTION_STATES)}"
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# blocked_by must be list[str]
|
|
164
|
+
blocked_by = task.get("blocked_by", []) or []
|
|
165
|
+
if not isinstance(blocked_by, list):
|
|
166
|
+
errors.append(ValidationError(tid, "blocked_by", "must be a list"))
|
|
167
|
+
else:
|
|
168
|
+
for dep in blocked_by:
|
|
169
|
+
if not isinstance(dep, str):
|
|
170
|
+
errors.append(
|
|
171
|
+
ValidationError(tid, "blocked_by", f"non-string entry: {dep!r}")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Cross-task validation: all blocked_by references resolve, no cycles
|
|
175
|
+
ids = {str(t.get("id")) for t in tasks if t.get("id")}
|
|
176
|
+
for task in tasks:
|
|
177
|
+
tid = str(task.get("id") or "")
|
|
178
|
+
for dep in task.get("blocked_by") or []:
|
|
179
|
+
if isinstance(dep, str) and dep not in ids:
|
|
180
|
+
errors.append(
|
|
181
|
+
ValidationError(tid, "blocked_by", f"unknown task id {dep!r}")
|
|
182
|
+
)
|
|
183
|
+
cycle = _detect_cycle(tasks)
|
|
184
|
+
if cycle:
|
|
185
|
+
errors.append(
|
|
186
|
+
ValidationError(cycle[0], "blocked_by", f"cycle detected: {' → '.join(cycle)}")
|
|
187
|
+
)
|
|
188
|
+
return errors
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _detect_cycle(tasks: list[dict[str, Any]]) -> list[str]:
|
|
192
|
+
"""Return the first detected dependency cycle, or empty list."""
|
|
193
|
+
graph: dict[str, list[str]] = {}
|
|
194
|
+
for t in tasks:
|
|
195
|
+
tid = str(t.get("id") or "")
|
|
196
|
+
if not tid:
|
|
197
|
+
continue
|
|
198
|
+
graph[tid] = [
|
|
199
|
+
d for d in (t.get("blocked_by") or []) if isinstance(d, str)
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
203
|
+
color: dict[str, int] = dict.fromkeys(graph, WHITE)
|
|
204
|
+
parent: dict[str, str] = {}
|
|
205
|
+
|
|
206
|
+
def dfs(node: str) -> list[str]:
|
|
207
|
+
color[node] = GRAY
|
|
208
|
+
for nb in graph.get(node, []):
|
|
209
|
+
if color.get(nb, WHITE) == GRAY:
|
|
210
|
+
# Reconstruct cycle node → ... → nb → node
|
|
211
|
+
cycle = [node]
|
|
212
|
+
cursor = node
|
|
213
|
+
while cursor != nb and cursor in parent:
|
|
214
|
+
cursor = parent[cursor]
|
|
215
|
+
cycle.append(cursor)
|
|
216
|
+
cycle.append(node)
|
|
217
|
+
cycle.reverse()
|
|
218
|
+
return cycle
|
|
219
|
+
if color.get(nb, WHITE) == WHITE:
|
|
220
|
+
parent[nb] = node
|
|
221
|
+
found = dfs(nb)
|
|
222
|
+
if found:
|
|
223
|
+
return found
|
|
224
|
+
color[node] = BLACK
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
for node in graph:
|
|
228
|
+
if color[node] == WHITE:
|
|
229
|
+
cycle = dfs(node)
|
|
230
|
+
if cycle:
|
|
231
|
+
return cycle
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Materialiser (flat → nested .planfile/)
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def materialize_to_planfile(
|
|
241
|
+
flat_tasks: list[dict[str, Any]],
|
|
242
|
+
project_dir: str | Path,
|
|
243
|
+
*,
|
|
244
|
+
sprint: str = "current",
|
|
245
|
+
sprint_name: str = "Imported pipeline",
|
|
246
|
+
prefix: str = "PLF",
|
|
247
|
+
overwrite: bool = False,
|
|
248
|
+
) -> ImportReport:
|
|
249
|
+
"""Write ``flat_tasks`` into ``project_dir/.planfile/sprints/<sprint>.yaml``.
|
|
250
|
+
|
|
251
|
+
Creates ``.planfile/config.yaml`` if missing. Existing sprint file is
|
|
252
|
+
preserved unless ``overwrite=True``; otherwise raises ``FileExistsError``.
|
|
253
|
+
"""
|
|
254
|
+
project = Path(project_dir).resolve()
|
|
255
|
+
base_dir = project / ".planfile"
|
|
256
|
+
sprints_dir = base_dir / "sprints"
|
|
257
|
+
config_path = base_dir / "config.yaml"
|
|
258
|
+
sprint_path = sprints_dir / f"{sprint}.yaml"
|
|
259
|
+
|
|
260
|
+
sprints_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
|
|
262
|
+
report = ImportReport(project_dir=project, sprint=sprint)
|
|
263
|
+
|
|
264
|
+
if not config_path.exists():
|
|
265
|
+
config_data = {
|
|
266
|
+
"project": project.name,
|
|
267
|
+
"prefix": prefix,
|
|
268
|
+
"next_id": _next_id_after(flat_tasks, prefix),
|
|
269
|
+
}
|
|
270
|
+
config_path.write_text(yaml.safe_dump(config_data, sort_keys=False), encoding="utf-8")
|
|
271
|
+
report.config_created = True
|
|
272
|
+
|
|
273
|
+
if sprint_path.exists() and not overwrite:
|
|
274
|
+
raise FileExistsError(
|
|
275
|
+
f"{sprint_path} already exists. Use overwrite=True (or --force) to replace it."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
tickets: dict[str, dict[str, Any]] = {}
|
|
279
|
+
for task in flat_tasks:
|
|
280
|
+
tid = str(task["id"])
|
|
281
|
+
tickets[tid] = _normalise_task(task, default_sprint=sprint)
|
|
282
|
+
report.tickets_imported.append(tid)
|
|
283
|
+
|
|
284
|
+
sprint_data = {
|
|
285
|
+
"sprint": {
|
|
286
|
+
"id": sprint,
|
|
287
|
+
"name": sprint_name,
|
|
288
|
+
"status": "active",
|
|
289
|
+
"tickets": tickets,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if sprint_path.exists():
|
|
293
|
+
report.sprint_file_overwritten = True
|
|
294
|
+
else:
|
|
295
|
+
report.sprint_file_created = True
|
|
296
|
+
sprint_path.write_text(
|
|
297
|
+
yaml.safe_dump(sprint_data, sort_keys=False, default_flow_style=False),
|
|
298
|
+
encoding="utf-8",
|
|
299
|
+
)
|
|
300
|
+
return report
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _normalise_task(task: dict[str, Any], *, default_sprint: str) -> dict[str, Any]:
|
|
304
|
+
"""Return a planfile-Ticket-compatible mapping for a flat task."""
|
|
305
|
+
out = dict(task)
|
|
306
|
+
# Map title -> name if name absent
|
|
307
|
+
if "name" not in out and "title" in out:
|
|
308
|
+
out["name"] = out.pop("title")
|
|
309
|
+
out.setdefault("sprint", default_sprint)
|
|
310
|
+
out.setdefault("status", "open")
|
|
311
|
+
out.setdefault("priority", "normal")
|
|
312
|
+
|
|
313
|
+
# Default the executor mode for safety.
|
|
314
|
+
if isinstance(out.get("executor"), dict):
|
|
315
|
+
out["executor"].setdefault("mode", "automatic")
|
|
316
|
+
|
|
317
|
+
# Default execution.state to "ready" for tasks with no blocked_by, else "pending"
|
|
318
|
+
execution = out.get("execution")
|
|
319
|
+
if not isinstance(execution, dict):
|
|
320
|
+
execution = {}
|
|
321
|
+
execution.setdefault("queue", "default")
|
|
322
|
+
if "state" not in execution:
|
|
323
|
+
execution["state"] = "ready" if not out.get("blocked_by") else "pending"
|
|
324
|
+
execution.setdefault("attempt", 0)
|
|
325
|
+
execution.setdefault("max_attempts", 1)
|
|
326
|
+
out["execution"] = execution
|
|
327
|
+
|
|
328
|
+
# Drop fields planfile doesn't model so it stays clean.
|
|
329
|
+
for noisy in ("phase",):
|
|
330
|
+
out.pop(noisy, None)
|
|
331
|
+
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _next_id_after(tasks: list[dict[str, Any]], prefix: str) -> int:
|
|
336
|
+
"""Compute a safe next_id higher than any numeric suffix already in use."""
|
|
337
|
+
max_id = 0
|
|
338
|
+
pref = f"{prefix}-"
|
|
339
|
+
for task in tasks:
|
|
340
|
+
tid = str(task.get("id") or "")
|
|
341
|
+
if tid.startswith(pref):
|
|
342
|
+
try:
|
|
343
|
+
num = int(tid[len(pref):].split("-")[-1])
|
|
344
|
+
max_id = max(max_id, num)
|
|
345
|
+
except ValueError:
|
|
346
|
+
continue
|
|
347
|
+
return max_id + 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Top-level entry: validate → materialize
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def import_flat_pipeline(
|
|
356
|
+
flat_path: str | Path,
|
|
357
|
+
project_dir: str | Path,
|
|
358
|
+
*,
|
|
359
|
+
sprint: str = "current",
|
|
360
|
+
overwrite: bool = False,
|
|
361
|
+
prefix: str | None = None,
|
|
362
|
+
) -> ImportReport:
|
|
363
|
+
"""Validate and import a flat pipeline into ``project_dir/.planfile/``.
|
|
364
|
+
|
|
365
|
+
Raises ``ValueError`` with a multi-line message if validation fails.
|
|
366
|
+
"""
|
|
367
|
+
header, tasks = load_flat_pipeline(flat_path)
|
|
368
|
+
if not tasks:
|
|
369
|
+
raise ValueError(f"{flat_path}: no tasks found")
|
|
370
|
+
|
|
371
|
+
errors = validate_flat_pipeline(tasks)
|
|
372
|
+
if errors:
|
|
373
|
+
joined = "\n".join(f" - {e}" for e in errors)
|
|
374
|
+
raise ValueError(f"{flat_path}: validation failed\n{joined}")
|
|
375
|
+
|
|
376
|
+
sprint_name = str(header.get("description") or header.get("project") or "Imported pipeline")
|
|
377
|
+
if "\n" in sprint_name:
|
|
378
|
+
sprint_name = sprint_name.splitlines()[0].strip()
|
|
379
|
+
|
|
380
|
+
return materialize_to_planfile(
|
|
381
|
+
tasks,
|
|
382
|
+
project_dir,
|
|
383
|
+
sprint=sprint,
|
|
384
|
+
sprint_name=sprint_name,
|
|
385
|
+
prefix=(prefix or _infer_prefix(tasks) or "PLF"),
|
|
386
|
+
overwrite=overwrite,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _infer_prefix(tasks: list[dict[str, Any]]) -> str | None:
|
|
391
|
+
"""Infer planfile prefix from the first task id (e.g. KORU-B-001 → KORU)."""
|
|
392
|
+
for task in tasks:
|
|
393
|
+
tid = str(task.get("id") or "")
|
|
394
|
+
if "-" in tid:
|
|
395
|
+
return tid.split("-", 1)[0]
|
|
396
|
+
return None
|