krnl-code 1.0.4__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 (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Krnl Agent — a lightweight, provider-agnostic agentic coding backend.
2
+
3
+ Public surface:
4
+ from krnl_agent.config import load_config
5
+ from krnl_agent.llm import build_client
6
+ from krnl_agent.loop import AgentSession
7
+ """
8
+
9
+ __version__ = "1.4.0"
krnl_agent/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Enable `python -m krnl_agent ...` (used by the VS Code auto-start)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,95 @@
1
+ """Agent Registry for Phase 4: Agent Specialization.
2
+
3
+ Defines specialized agents for different tasks (frontend, backend, testing, docs)
4
+ with routing criteria based on keywords and file patterns.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class AgentDefinition:
14
+ """Definition of a specialized agent."""
15
+ name: str
16
+ keywords: list[str] = field(default_factory=list)
17
+ file_patterns: list[str] = field(default_factory=list)
18
+ description: str = ""
19
+
20
+
21
+ # Default agent registry
22
+ DEFAULT_AGENTS = [
23
+ AgentDefinition(
24
+ name="frontend",
25
+ keywords=["css", "html", "react", "vue", "frontend", "ui", "component"],
26
+ file_patterns=["*.css", "*.html", "*.jsx", "*.tsx", "*.vue"],
27
+ description="Specializes in frontend development, UI components, and styling.",
28
+ ),
29
+ AgentDefinition(
30
+ name="backend",
31
+ keywords=["api", "server", "backend", "endpoint", "service"],
32
+ file_patterns=["*.py", "*.js", "*.go", "*.rs", "*.java"],
33
+ description="Specializes in backend development, APIs, and server-side logic.",
34
+ ),
35
+ AgentDefinition(
36
+ name="testing",
37
+ keywords=["test", "spec", "pytest", "jest", "unit", "integration"],
38
+ file_patterns=["*test*.py", "test_*.py", "*.spec.js", "*.test.js"],
39
+ description="Specializes in writing and debugging tests.",
40
+ ),
41
+ AgentDefinition(
42
+ name="docs",
43
+ keywords=["doc", "readme", "markdown", "documentation"],
44
+ file_patterns=["*.md", "*.rst", "*.txt"],
45
+ description="Specializes in documentation and README files.",
46
+ ),
47
+ AgentDefinition(
48
+ name="database",
49
+ keywords=["sql", "schema", "migrate", "postgres", "db", "table", "query", "database"],
50
+ file_patterns=["*.sql", "*migrate*.py", "*schema*.py"],
51
+ description="Specializes in database schema design, migrations, and query performance.",
52
+ ),
53
+ AgentDefinition(
54
+ name="devops",
55
+ keywords=["docker", "kubernetes", "k8s", "ci", "cd", "workflow", "actions", "deployment", "terraform"],
56
+ file_patterns=["Dockerfile", "docker-compose.yml", "*.yaml", "*.yml", "*.tf"],
57
+ description="Specializes in CI/CD pipelines, containerization, deployment, and infrastructure.",
58
+ ),
59
+ AgentDefinition(
60
+ name="security",
61
+ keywords=["security", "auth", "login", "encrypt", "decrypt", "scan", "audit", "secrets"],
62
+ file_patterns=["*auth*.py", "*security*.py", "jwt*.py"],
63
+ description="Specializes in secure authentication, authorization, vulnerability scanning, and credentials handling.",
64
+ ),
65
+ ]
66
+
67
+
68
+ class AgentRegistry:
69
+ """Registry of specialized agents."""
70
+
71
+ def __init__(self, agents: Optional[list[AgentDefinition]] = None):
72
+ self.agents = agents or [AgentDefinition(**a.__dict__) for a in DEFAULT_AGENTS]
73
+
74
+ def get_agent(self, name: str) -> Optional[AgentDefinition]:
75
+ """Get an agent definition by name."""
76
+ for agent in self.agents:
77
+ if agent.name == name:
78
+ return agent
79
+ return None
80
+
81
+ def list_agents(self) -> list[AgentDefinition]:
82
+ """List all registered agents."""
83
+ return self.agents.copy()
84
+
85
+ def add_agent(self, agent: AgentDefinition) -> None:
86
+ """Add a new agent to the registry."""
87
+ self.agents.append(agent)
88
+
89
+ def remove_agent(self, name: str) -> bool:
90
+ """Remove an agent from the registry. Returns True if removed."""
91
+ for i, agent in enumerate(self.agents):
92
+ if agent.name == name:
93
+ self.agents.pop(i)
94
+ return True
95
+ return False
@@ -0,0 +1,69 @@
1
+ """Agent Selector for Phase 4: Agent Specialization.
2
+
3
+ Routes tasks to specialized agents based on keywords and file patterns.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import fnmatch
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .agent_registry import AgentDefinition, AgentRegistry
12
+
13
+
14
+ class AgentSelector:
15
+ """Selects the appropriate specialized agent for a task."""
16
+
17
+ def __init__(self, registry: Optional[AgentRegistry] = None):
18
+ self.registry = registry or AgentRegistry()
19
+
20
+ def select_agent(
21
+ self,
22
+ task: str,
23
+ file_paths: Optional[list[str]] = None,
24
+ ) -> Optional[AgentDefinition]:
25
+ """Select the best agent for a task based on keywords and file patterns.
26
+
27
+ Args:
28
+ task: The task description.
29
+ file_paths: List of file paths involved in the task.
30
+
31
+ Returns:
32
+ The selected agent definition, or None if no agent matches.
33
+ """
34
+ scores = {}
35
+
36
+ for agent in self.registry.list_agents():
37
+ score = 0
38
+
39
+ # Score based on task keywords
40
+ task_lower = task.lower()
41
+ for keyword in agent.keywords:
42
+ if keyword.lower() in task_lower:
43
+ score += 1
44
+
45
+ # Score based on file patterns (with specificity bonus)
46
+ if file_paths:
47
+ for file_path in file_paths:
48
+ file_name = Path(file_path).name
49
+ for pattern in agent.file_patterns:
50
+ if fnmatch.fnmatch(file_name, pattern):
51
+ # Give higher score to more specific patterns
52
+ # (patterns with more characters before/after wildcards)
53
+ specificity = len(pattern.replace("*", ""))
54
+ score += 1 + (specificity / 100)
55
+ break
56
+
57
+ if score > 0:
58
+ scores[agent.name] = (score, agent)
59
+
60
+ if not scores:
61
+ return None
62
+
63
+ # Return the agent with the highest score
64
+ _, best_agent = max(scores.values(), key=lambda x: x[0])
65
+ return best_agent
66
+
67
+ def select_agent_by_name(self, name: str) -> Optional[AgentDefinition]:
68
+ """Select an agent by name (explicit selection)."""
69
+ return self.registry.get_agent(name)
@@ -0,0 +1,155 @@
1
+ """Tamper-evident, append-only audit log of agent actions.
2
+
3
+ Every tool call (and its approval decision + outcome) is appended as one JSON line
4
+ to `.krnl/audit/audit.log`. Each record carries a SHA-256 hash chained over the
5
+ previous record's hash, so any insertion, deletion, or edit anywhere in the
6
+ history breaks the chain and is detectable with `verify()`.
7
+
8
+ This gives autonomous / dangerous-mode runs a reviewable, non-repudiable trail for
9
+ compliance and debugging - "what did the agent actually do, in what order".
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+
17
+ _GENESIS = "0" * 64
18
+
19
+
20
+ def _rel_dir(workspace: str) -> Path:
21
+ return Path(workspace) / ".krnl" / "audit"
22
+
23
+
24
+ def _log_path(workspace: str) -> Path:
25
+ return _rel_dir(workspace) / "audit.log"
26
+
27
+
28
+ def _hash_record(prev_hash: str, payload: dict) -> str:
29
+ body = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
30
+ return hashlib.sha256((prev_hash + body).encode("utf-8")).hexdigest()
31
+
32
+
33
+ def _last_hash(path: Path) -> str:
34
+ if not path.exists():
35
+ return _GENESIS
36
+ last = _GENESIS
37
+ try:
38
+ with open(path, encoding="utf-8") as fh:
39
+ for line in fh:
40
+ line = line.strip()
41
+ if line:
42
+ try:
43
+ last = json.loads(line).get("hash", last)
44
+ except Exception:
45
+ continue
46
+ except Exception:
47
+ return _GENESIS
48
+ return last
49
+
50
+
51
+ class AuditLog:
52
+ """Append-only, hash-chained log. Construct once per session; cheap to call."""
53
+
54
+ def __init__(self, workspace: str, enabled: bool = True, *, seq_start: int | None = None):
55
+ self.workspace = workspace
56
+ self.enabled = enabled
57
+ self.path = _log_path(workspace)
58
+ self._prev = _last_hash(self.path) if enabled else _GENESIS
59
+ self._seq = seq_start if seq_start is not None else self._count()
60
+
61
+ def _count(self) -> int:
62
+ if not self.path.exists():
63
+ return 0
64
+ try:
65
+ with open(self.path, encoding="utf-8") as fh:
66
+ return sum(1 for ln in fh if ln.strip())
67
+ except Exception:
68
+ return 0
69
+
70
+ def record(self, *, ts: str, tool: str, args: dict, decision: str,
71
+ ok: bool | None = None, summary: str = "", depth: int = 0) -> None:
72
+ """Append one action. `ts` is supplied by the caller (no hidden clock)."""
73
+ if not self.enabled:
74
+ return
75
+ payload = {
76
+ "seq": self._seq,
77
+ "ts": ts,
78
+ "tool": tool,
79
+ "args": _shrink(args),
80
+ "decision": decision,
81
+ "ok": ok,
82
+ "summary": summary[:300],
83
+ "depth": depth,
84
+ "prev": self._prev,
85
+ }
86
+ h = _hash_record(self._prev, payload)
87
+ payload["hash"] = h
88
+ try:
89
+ self.path.parent.mkdir(parents=True, exist_ok=True)
90
+ with open(self.path, "a", encoding="utf-8") as fh:
91
+ fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
92
+ self._prev = h
93
+ self._seq += 1
94
+ except Exception:
95
+ pass # auditing must never break the run
96
+
97
+
98
+ def _shrink(args: dict) -> dict:
99
+ """Keep the log small and secret-light: truncate long string values."""
100
+ out = {}
101
+ for k, v in (args or {}).items():
102
+ if isinstance(v, str) and len(v) > 200:
103
+ out[k] = v[:200] + f"…(+{len(v) - 200})"
104
+ elif isinstance(v, (list, dict)) and len(json.dumps(v, default=str)) > 200:
105
+ out[k] = f"<{type(v).__name__} len {len(v)}>"
106
+ else:
107
+ out[k] = v
108
+ return out
109
+
110
+
111
+ def verify(workspace: str) -> tuple[bool, str]:
112
+ """Re-walk the chain. Returns (intact, message)."""
113
+ path = _log_path(workspace)
114
+ if not path.exists():
115
+ return True, "No audit log yet."
116
+ prev = _GENESIS
117
+ n = 0
118
+ try:
119
+ with open(path, encoding="utf-8") as fh:
120
+ for lineno, line in enumerate(fh, 1):
121
+ line = line.strip()
122
+ if not line:
123
+ continue
124
+ rec = json.loads(line)
125
+ stored = rec.pop("hash", None)
126
+ if rec.get("prev") != prev:
127
+ return False, f"Chain break at line {lineno}: prev mismatch."
128
+ recomputed = _hash_record(prev, rec)
129
+ if recomputed != stored:
130
+ return False, f"Tampering at line {lineno}: hash mismatch."
131
+ prev = stored
132
+ n += 1
133
+ except Exception as e: # noqa: BLE001
134
+ return False, f"Audit log unreadable: {e}"
135
+ return True, f"Audit log intact — {n} record(s), chain verified."
136
+
137
+
138
+ def tail(workspace: str, n: int = 20) -> str:
139
+ path = _log_path(workspace)
140
+ if not path.exists():
141
+ return "No audit log yet."
142
+ try:
143
+ lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
144
+ except Exception as e: # noqa: BLE001
145
+ return f"Could not read audit log: {e}"
146
+ rows = []
147
+ for ln in lines[-n:]:
148
+ try:
149
+ r = json.loads(ln)
150
+ ok = "" if r.get("ok") is None else ("ok" if r["ok"] else "FAIL")
151
+ rows.append(f"{r.get('seq'):>4} {r.get('ts','')[:19]} {r.get('decision','?'):>5} "
152
+ f"{r.get('tool',''):<16} {ok:<4} {r.get('summary','')}")
153
+ except Exception:
154
+ continue
155
+ return "\n".join(rows) if rows else "No records."
@@ -0,0 +1,94 @@
1
+ """Background process manager for long-running commands (dev servers, watchers).
2
+
3
+ `bash_background` starts a process and returns immediately with an id; its output
4
+ is buffered by a reader thread and can be polled with `process_output`, listed
5
+ with `process_list`, and stopped with `process_kill`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass
15
+ class BgProc:
16
+ id: str
17
+ command: str
18
+ proc: subprocess.Popen
19
+ lines: list = field(default_factory=list)
20
+ lock: threading.Lock = field(default_factory=threading.Lock)
21
+
22
+
23
+ class BackgroundManager:
24
+ def __init__(self) -> None:
25
+ self._procs: dict[str, BgProc] = {}
26
+ self._counter = 0
27
+
28
+ def start(self, cwd: str, command: str) -> str:
29
+ self._counter += 1
30
+ pid = f"bg{self._counter}"
31
+ proc = subprocess.Popen(
32
+ command,
33
+ cwd=cwd,
34
+ shell=True,
35
+ stdout=subprocess.PIPE,
36
+ stderr=subprocess.STDOUT,
37
+ text=True,
38
+ bufsize=1,
39
+ )
40
+ bg = BgProc(pid, command, proc)
41
+ self._procs[pid] = bg
42
+
43
+ def reader():
44
+ try:
45
+ if proc.stdout:
46
+ for line in proc.stdout:
47
+ with bg.lock:
48
+ bg.lines.append(line)
49
+ if len(bg.lines) > 2000:
50
+ del bg.lines[:1000]
51
+ except Exception:
52
+ pass
53
+
54
+ threading.Thread(target=reader, daemon=True).start()
55
+ return pid
56
+
57
+ def output(self, pid: str, tail: int = 100) -> str:
58
+ bg = self._procs.get(pid)
59
+ if not bg:
60
+ return f"no such process: {pid}"
61
+ with bg.lock:
62
+ text = "".join(bg.lines[-tail:])
63
+ status = "running" if bg.proc.poll() is None else f"exited ({bg.proc.returncode})"
64
+ return f"[{pid}] {status}\n{text}" if text else f"[{pid}] {status} (no output yet)"
65
+
66
+ def kill(self, pid: str) -> str:
67
+ bg = self._procs.get(pid)
68
+ if not bg:
69
+ return f"no such process: {pid}"
70
+ try:
71
+ bg.proc.kill()
72
+ except Exception:
73
+ pass
74
+ return f"killed {pid}"
75
+
76
+ def list_(self) -> str:
77
+ if not self._procs:
78
+ return "(no background processes)"
79
+ rows = []
80
+ for pid, bg in self._procs.items():
81
+ status = "running" if bg.proc.poll() is None else f"exited({bg.proc.returncode})"
82
+ rows.append(f"{pid} {status} {bg.command}")
83
+ return "\n".join(rows)
84
+
85
+ def kill_all(self) -> None:
86
+ for bg in self._procs.values():
87
+ try:
88
+ bg.proc.kill()
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ # One manager per process is fine; ids are unique within it.
94
+ manager = BackgroundManager()
@@ -0,0 +1,67 @@
1
+ """Checkpoint / undo for file mutations.
2
+
3
+ Before any mutating file tool runs, the loop records the affected file's prior
4
+ state here. `undo_last_turn()` restores everything changed during the most recent
5
+ user turn — the "git checkpoint/revert" capability, without requiring git.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class FileSnapshot:
15
+ path: Path
16
+ existed: bool
17
+ content: str # empty when the file didn't exist
18
+
19
+
20
+ class Checkpointer:
21
+ def __init__(self) -> None:
22
+ # one list of snapshots per user turn (a stack of turns)
23
+ self._turns: list[list[FileSnapshot]] = []
24
+
25
+ def begin_turn(self) -> None:
26
+ self._turns.append([])
27
+
28
+ def record(self, full_path: Path) -> None:
29
+ """Snapshot a file's current state before it is mutated."""
30
+ if not self._turns:
31
+ self.begin_turn()
32
+ # avoid duplicate snapshots of the same path within a turn
33
+ for snap in self._turns[-1]:
34
+ if snap.path == full_path:
35
+ return
36
+ if full_path.exists():
37
+ try:
38
+ content = full_path.read_text(encoding="utf-8", errors="replace")
39
+ except Exception:
40
+ content = ""
41
+ self._turns[-1].append(FileSnapshot(full_path, True, content))
42
+ else:
43
+ self._turns[-1].append(FileSnapshot(full_path, False, ""))
44
+
45
+ @property
46
+ def can_undo(self) -> bool:
47
+ return any(turn for turn in self._turns)
48
+
49
+ def undo_last_turn(self) -> list[str]:
50
+ """Revert the most recent non-empty turn. Returns reverted paths."""
51
+ while self._turns:
52
+ turn = self._turns.pop()
53
+ if not turn:
54
+ continue
55
+ reverted: list[str] = []
56
+ for snap in reversed(turn):
57
+ try:
58
+ if snap.existed:
59
+ snap.path.parent.mkdir(parents=True, exist_ok=True)
60
+ snap.path.write_text(snap.content, encoding="utf-8")
61
+ elif snap.path.exists():
62
+ snap.path.unlink()
63
+ reverted.append(str(snap.path))
64
+ except Exception:
65
+ continue
66
+ return reverted
67
+ return []
krnl_agent/ci.py ADDED
@@ -0,0 +1,73 @@
1
+ """CI recipe scaffolding.
2
+
3
+ Generates a ready-to-use GitHub Actions workflow that runs the agent headlessly
4
+ (`krnl-agent run --json`) to review pull requests and/or run the project's tests
5
+ and a security pass. Written to `.github/workflows/krnl-agent.yml`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ WORKFLOW = """\
12
+ name: Krnl Agent
13
+
14
+ on:
15
+ pull_request:
16
+ workflow_dispatch:
17
+
18
+ jobs:
19
+ review:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.11"
29
+
30
+ - name: Install Krnl Agent
31
+ run: pip install krnl-coding-agent
32
+
33
+ - name: Security + dependency scan
34
+ env:
35
+ # Set the key for your provider in repo Settings → Secrets.
36
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
37
+ run: |
38
+ krnl-agent run --json --yes \\
39
+ "Run secret_scan and dependency_audit on this repo. Summarize any \\
40
+ findings with severity and a concrete fix. Fail loudly if criticals."
41
+
42
+ - name: Review the diff
43
+ if: github.event_name == 'pull_request'
44
+ env:
45
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
46
+ run: |
47
+ krnl-agent run --json --yes \\
48
+ "Review the changes on this branch vs the base for bugs, security \\
49
+ issues, and missing tests. Output a concise PR review."
50
+ """
51
+
52
+ README_SNIPPET = """\
53
+ ## CI: Krnl Agent
54
+
55
+ A GitHub Actions workflow at `.github/workflows/krnl-agent.yml` runs the agent on
56
+ every pull request to scan for secrets/vulnerable deps and to review the diff.
57
+
58
+ 1. Add your provider key as a repo secret (e.g. `OPENAI_API_KEY`).
59
+ 2. Adjust the provider/model with `--provider`/`--model` if needed.
60
+ """
61
+
62
+
63
+ def write_workflow(workspace: str, force: bool = False) -> tuple[bool, str]:
64
+ """Create the workflow file. Returns (written, path-or-message)."""
65
+ dest = Path(workspace) / ".github" / "workflows" / "krnl-agent.yml"
66
+ if dest.exists() and not force:
67
+ return False, f"{dest} already exists (use force to overwrite)."
68
+ try:
69
+ dest.parent.mkdir(parents=True, exist_ok=True)
70
+ dest.write_text(WORKFLOW, encoding="utf-8")
71
+ except Exception as e: # noqa: BLE001
72
+ return False, f"Could not write workflow: {e}"
73
+ return True, str(dest)