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 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