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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/__init__.py
ADDED
krnl_agent/__main__.py
ADDED
|
@@ -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)
|
krnl_agent/audit_log.py
ADDED
|
@@ -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."
|
krnl_agent/background.py
ADDED
|
@@ -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)
|