sick-agent 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sick/__init__.py +5 -0
- sick/agent.py +477 -0
- sick/audit.py +102 -0
- sick/cli.py +184 -0
- sick/config.py +59 -0
- sick/design_check.py +66 -0
- sick/learn.py +137 -0
- sick/lifecycle.py +338 -0
- sick/logging_conf.py +53 -0
- sick/lsp/__init__.py +1 -0
- sick/lsp/server.py +110 -0
- sick/memory.py +108 -0
- sick/preflight.py +137 -0
- sick/providers.py +244 -0
- sick/skills.py +106 -0
- sick/templates/cli/cookiecutter.json +4 -0
- sick/templates/cli/{{cookiecutter.project_slug}}/pyproject.toml +8 -0
- sick/templates/fastapi/cookiecutter.json +5 -0
- sick/templates/fastapi/{{cookiecutter.project_slug}}/app/main.py +5 -0
- sick/templates/fastapi/{{cookiecutter.project_slug}}/pyproject.toml +8 -0
- sick/templates/remotion/cookiecutter.json +4 -0
- sick/templates/remotion/{{cookiecutter.project_slug}}/src/Root.tsx +1 -0
- sick/templates.py +68 -0
- sick/tools/__init__.py +12 -0
- sick/tools/base.py +86 -0
- sick/tools/exec.py +89 -0
- sick/tools/files.py +89 -0
- sick/tools/git.py +101 -0
- sick/tools/pdf.py +67 -0
- sick/tools/registry.py +86 -0
- sick/tools/research.py +380 -0
- sick/tools/search.py +75 -0
- sick/tools/self.py +21 -0
- sick/tools/web.py +68 -0
- sick/tui/__init__.py +8 -0
- sick/tui/app.py +556 -0
- sick/tui/chat.py +39 -0
- sick/tui/commands.py +858 -0
- sick/tui/host.py +80 -0
- sick/tui/inputbar.py +147 -0
- sick_agent-0.2.0.dist-info/METADATA +308 -0
- sick_agent-0.2.0.dist-info/RECORD +45 -0
- sick_agent-0.2.0.dist-info/WHEEL +4 -0
- sick_agent-0.2.0.dist-info/entry_points.txt +3 -0
- sick_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
sick/__init__.py
ADDED
sick/agent.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from nooa import Skill, hidden, spec
|
|
6
|
+
from nooa.interactive import InteractiveAgent, RespondReason, RespondResult
|
|
7
|
+
|
|
8
|
+
from sick.audit import AuditLog
|
|
9
|
+
from sick.config import load_config
|
|
10
|
+
from sick.lifecycle import Lifecycle, Phase, Run
|
|
11
|
+
from sick.memory import Experience, ExperienceMemory
|
|
12
|
+
from sick.tools.base import Tool, ToolResult
|
|
13
|
+
from sick.tools.exec import Bash
|
|
14
|
+
from sick.tools.files import EditFile, ReadFile, WriteFile
|
|
15
|
+
from sick.tools.git import Checkpoint, ListCheckpoints, Restore
|
|
16
|
+
from sick.tools.pdf import ParsePdf
|
|
17
|
+
from sick.tools.registry import ToolRegistry
|
|
18
|
+
from sick.tools.research import CodeResearch
|
|
19
|
+
from sick.tools.search import Glob, Grep
|
|
20
|
+
from sick.tools.self import SelfRead, SelfWrite
|
|
21
|
+
from sick.tools.web import FetchUrl
|
|
22
|
+
|
|
23
|
+
PONYTAIL_PROMPT = """## ponytail (always active)
|
|
24
|
+
- Shortest correct solution. No over-engineering.
|
|
25
|
+
- Stdlib before dependencies. Native features before libraries.
|
|
26
|
+
- Deletion over addition. Boring over clever.
|
|
27
|
+
- Mark deliberate shortcuts with `ponytail:` comment.
|
|
28
|
+
- Evidence before assertions — run tests after every change.
|
|
29
|
+
- Always use `uv` for Python operations in this project."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PDF_PROMPT = """## pdf_handling
|
|
33
|
+
PDF files CANNOT be read with read_file() — it returns raw binary.
|
|
34
|
+
Use parse_pdf(path) instead — returns clean Markdown via Docling.
|
|
35
|
+
When a user attaches a PDF, its content is already parsed and in context."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ponytail: empty env var means unset (`SICK_REMOTION_DIR=` in .env must not
|
|
39
|
+
# resolve to Path("."), which silently disabled the remotion skills)
|
|
40
|
+
REMOTION_SKILL_DIR = Path(os.environ.get("SICK_REMOTION_DIR") or (Path.home() / ".agents" / "skills"))
|
|
41
|
+
REMOTION_REFERENCE_HINT = (
|
|
42
|
+
"\n\nReference guides (markup, render, captions, maps, studio, saas) live in "
|
|
43
|
+
"~/.agents/skills/remotion-best-practices/ — when a guide is relevant, read it "
|
|
44
|
+
"with `bash cat ~/.agents/skills/remotion-best-practices/remotion-markup/REFERENCE.md` "
|
|
45
|
+
"(adjust the subdirectory) and follow its guidance."
|
|
46
|
+
)
|
|
47
|
+
REMOTION_FALLBACK = """## remotion
|
|
48
|
+
You create explanatory videos with Remotion (https://remotion.dev).
|
|
49
|
+
The remotion skill files are missing. Install them with:
|
|
50
|
+
npx skills add remotion-dev/skills@remotion-create -g -y
|
|
51
|
+
npx skills add remotion-dev/skills@remotion-best-practices -g -y
|
|
52
|
+
Then scaffold with `npx create-video@latest --yes --blank --no-tailwind <name>`
|
|
53
|
+
and render with `npx remotion render`."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_remotion_skill(base: Path = REMOTION_SKILL_DIR) -> str:
|
|
57
|
+
parts = []
|
|
58
|
+
for name in ("remotion-best-practices", "remotion-create"):
|
|
59
|
+
try:
|
|
60
|
+
parts.append((base / name / "SKILL.md").read_text())
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
if not parts:
|
|
64
|
+
return REMOTION_FALLBACK
|
|
65
|
+
return "\n\n".join(parts) + REMOTION_REFERENCE_HINT
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_remotion_prompt() -> str:
|
|
69
|
+
try:
|
|
70
|
+
return _load_remotion_skill()
|
|
71
|
+
except Exception:
|
|
72
|
+
return REMOTION_FALLBACK
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _LazyRemotionSkill(Skill):
|
|
76
|
+
context_block = ("remotion", "")
|
|
77
|
+
|
|
78
|
+
def attach(self, agent):
|
|
79
|
+
prompt = _get_remotion_prompt()
|
|
80
|
+
agent.context["remotion"] = prompt
|
|
81
|
+
self.context_block = ("remotion", prompt)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
SDLC_PROMPT = """## sdlc_and_oo
|
|
85
|
+
SDLC is runtime-enforced by sick.lifecycle (see the live `sdlc_state` context
|
|
86
|
+
block): phases are observed from tool results, and delivery is blocked
|
|
87
|
+
without verification evidence after the last edit. Prefer OO structure where
|
|
88
|
+
it earns its keep (deep modules, small interfaces); flat code wins otherwise."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PonytailSkill(Skill):
|
|
92
|
+
context_block = ("ponytail", PONYTAIL_PROMPT)
|
|
93
|
+
|
|
94
|
+
def attach(self, agent):
|
|
95
|
+
agent.context["ponytail"] = PONYTAIL_PROMPT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PDFSkill(Skill):
|
|
99
|
+
context_block = ("pdf_handling", PDF_PROMPT)
|
|
100
|
+
|
|
101
|
+
def attach(self, agent):
|
|
102
|
+
agent.context["pdf_handling"] = PDF_PROMPT
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RemotionSkill(_LazyRemotionSkill):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SdlcSkill(Skill):
|
|
110
|
+
context_block = ("sdlc", SDLC_PROMPT)
|
|
111
|
+
|
|
112
|
+
def attach(self, agent):
|
|
113
|
+
agent.context["sdlc"] = SDLC_PROMPT
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SickAgent(InteractiveAgent):
|
|
117
|
+
"""You are Sick, a coding agent who modifies itself to get better over time.
|
|
118
|
+
|
|
119
|
+
Core philosophy (ponytail, always active):
|
|
120
|
+
- Shortest correct solution. No over-engineering.
|
|
121
|
+
- Stdlib before dependencies. Native features before libraries.
|
|
122
|
+
- Deletion over addition. Boring over clever.
|
|
123
|
+
- Mark deliberate shortcuts with `ponytail:` comment.
|
|
124
|
+
- Evidence before assertions — run tests after every change.
|
|
125
|
+
- Always use `uv` for Python operations in this project.
|
|
126
|
+
|
|
127
|
+
Available tools:
|
|
128
|
+
{doc(self)}
|
|
129
|
+
|
|
130
|
+
Past experiences relevant to this task:
|
|
131
|
+
{self._recall_for_prompt}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
_tools: dict[str, Tool]
|
|
135
|
+
memory: ExperienceMemory
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
attachments: list[str] | None = None,
|
|
140
|
+
skills: list[Skill] | None = None,
|
|
141
|
+
workspace: str | Path = ".",
|
|
142
|
+
**kwargs: Any,
|
|
143
|
+
):
|
|
144
|
+
super().__init__(**kwargs)
|
|
145
|
+
self.memory = ExperienceMemory()
|
|
146
|
+
self._skills: list[Skill] = skills or []
|
|
147
|
+
self.workspace = Path(workspace).resolve()
|
|
148
|
+
if not self.workspace.is_dir():
|
|
149
|
+
raise ValueError(f"workspace is not a directory: {workspace}")
|
|
150
|
+
|
|
151
|
+
cfg = load_config(self.workspace)
|
|
152
|
+
self.audit = AuditLog(self.workspace)
|
|
153
|
+
self.lifecycle = Lifecycle(self.workspace)
|
|
154
|
+
self.current_run: Run | None = None
|
|
155
|
+
self.registry = ToolRegistry(audit=self.audit, on_result=self._record_lifecycle)
|
|
156
|
+
self._tools = self.registry.tools
|
|
157
|
+
self._tool_counts = self.registry.counts()
|
|
158
|
+
self.turns = 0
|
|
159
|
+
|
|
160
|
+
from sick.tools.base import EXCLUDED_DIRS as _EXCL
|
|
161
|
+
|
|
162
|
+
merged_excluded = set(_EXCL) | set(cfg.get("excluded") or [])
|
|
163
|
+
# CodeResearch builds index on init; ensure it uses merged excluded
|
|
164
|
+
# by setting excluded before build (rebuild if needed)
|
|
165
|
+
code_research_tool = CodeResearch(str(self.workspace))
|
|
166
|
+
if getattr(code_research_tool.index, "excluded", None) != merged_excluded:
|
|
167
|
+
code_research_tool.index.excluded = merged_excluded
|
|
168
|
+
# rebuild if config adds exclusions (cheap: uses cache when possible)
|
|
169
|
+
code_research_tool.index.build()
|
|
170
|
+
code_research_tool.chunk_count = len(code_research_tool.index.chunks)
|
|
171
|
+
|
|
172
|
+
for t in [
|
|
173
|
+
ReadFile(self.workspace), WriteFile(self.workspace), EditFile(self.workspace),
|
|
174
|
+
Bash(self.workspace), Grep(self.workspace), Glob(self.workspace),
|
|
175
|
+
ParsePdf(self.workspace), code_research_tool, SelfRead(), SelfWrite(),
|
|
176
|
+
FetchUrl(), Checkpoint(self.workspace), Restore(self.workspace),
|
|
177
|
+
ListCheckpoints(self.workspace),
|
|
178
|
+
]:
|
|
179
|
+
t.excluded = merged_excluded
|
|
180
|
+
self.registry.register(t)
|
|
181
|
+
|
|
182
|
+
self._register_skill(PonytailSkill())
|
|
183
|
+
self._register_skill(PDFSkill())
|
|
184
|
+
self._register_skill(RemotionSkill())
|
|
185
|
+
self._register_skill(SdlcSkill())
|
|
186
|
+
for skill in self._skills:
|
|
187
|
+
self._register_skill(skill)
|
|
188
|
+
self._register_discovered_skills()
|
|
189
|
+
|
|
190
|
+
self._handle_attachments(attachments or [])
|
|
191
|
+
|
|
192
|
+
spec(self, "memory", hidden=False)
|
|
193
|
+
|
|
194
|
+
def _register_skill(self, skill: Skill) -> None:
|
|
195
|
+
attr_name = type(skill).__name__.lower().replace("skill", "")
|
|
196
|
+
setattr(self, attr_name, skill)
|
|
197
|
+
skill.attach(self)
|
|
198
|
+
spec(self, attr_name, hidden=False)
|
|
199
|
+
|
|
200
|
+
def _register_discovered_skills(self) -> None:
|
|
201
|
+
"""Index every installed SKILL.md (name + description) for on-demand use."""
|
|
202
|
+
try:
|
|
203
|
+
from sick.skills import discover_skills, skills_index_prompt
|
|
204
|
+
|
|
205
|
+
self.discovered_skills = discover_skills(self.workspace)
|
|
206
|
+
if self.discovered_skills:
|
|
207
|
+
self.context["skills"] = skills_index_prompt(self.discovered_skills)
|
|
208
|
+
except Exception:
|
|
209
|
+
self.discovered_skills = []
|
|
210
|
+
|
|
211
|
+
def _handle_attachments(self, attachments: list[str]) -> None:
|
|
212
|
+
MAX_ATTACH_BYTES = 100_000
|
|
213
|
+
for a in attachments:
|
|
214
|
+
p = Path(a)
|
|
215
|
+
if not p.exists() or p.is_dir():
|
|
216
|
+
continue
|
|
217
|
+
if p.suffix.lower() == ".pdf":
|
|
218
|
+
tool = self._tools.get("parse_pdf")
|
|
219
|
+
if tool:
|
|
220
|
+
md = self._call("parse_pdf", path=str(p))
|
|
221
|
+
self.context[f"attachment_{p.stem}"] = md
|
|
222
|
+
else:
|
|
223
|
+
try:
|
|
224
|
+
# Reuse workspace confinement: try read_file, fallback raw read with bound
|
|
225
|
+
result = self.registry.call("read_file", path=str(p))
|
|
226
|
+
content = str(result.output)
|
|
227
|
+
if not result.ok:
|
|
228
|
+
content = p.read_text(errors="replace")[:MAX_ATTACH_BYTES]
|
|
229
|
+
else:
|
|
230
|
+
content = content[:MAX_ATTACH_BYTES]
|
|
231
|
+
if len(content) >= MAX_ATTACH_BYTES:
|
|
232
|
+
content += f"\n[truncated after {MAX_ATTACH_BYTES} bytes]"
|
|
233
|
+
self.context[f"attachment_{p.stem}"] = content
|
|
234
|
+
except Exception as e:
|
|
235
|
+
self.context[f"attachment_{p.stem}"] = f"[unreadable: {e}]"
|
|
236
|
+
|
|
237
|
+
# ── lifecycle ──
|
|
238
|
+
|
|
239
|
+
def _record_lifecycle(self, result: ToolResult, args: dict) -> None:
|
|
240
|
+
if self.current_run is not None:
|
|
241
|
+
self.lifecycle.record(self.current_run, result, args)
|
|
242
|
+
|
|
243
|
+
def begin_run(self, task: str, *, planned: bool = False, approved: bool = False) -> Run:
|
|
244
|
+
"""Start an evidence-gated SDLC run and route phase skills.
|
|
245
|
+
|
|
246
|
+
`approved=True` is for CLI one-shot/modify/learn invocations: typing
|
|
247
|
+
the command is the human's approval to execute. Interactive TUI runs
|
|
248
|
+
leave it False, so RISKY runs there need /plan -> /approve.
|
|
249
|
+
"""
|
|
250
|
+
run = self.lifecycle.start(task)
|
|
251
|
+
if planned:
|
|
252
|
+
self.lifecycle.create_plan(run)
|
|
253
|
+
if approved:
|
|
254
|
+
self.lifecycle.approve(run)
|
|
255
|
+
self.current_run = run
|
|
256
|
+
for phase in (Phase.REQUIREMENTS, Phase.DESIGN, Phase.VERIFY):
|
|
257
|
+
routed = self.lifecycle.route_skill(run, phase)
|
|
258
|
+
if routed:
|
|
259
|
+
name, body = routed
|
|
260
|
+
self.context[f"skill_{phase.value}"] = body
|
|
261
|
+
self.context["sdlc_state"] = self.lifecycle.prompt(run)
|
|
262
|
+
return run
|
|
263
|
+
|
|
264
|
+
def finish_run(self, summary: str = "") -> Any:
|
|
265
|
+
"""Apply the delivery gate to the current run. Returns the verdict."""
|
|
266
|
+
if self.current_run is None:
|
|
267
|
+
return None
|
|
268
|
+
verdict = self.lifecycle.finish(self.current_run, summary)
|
|
269
|
+
self.context["sdlc_state"] = self.lifecycle.prompt(self.current_run)
|
|
270
|
+
self.context["sdlc_result"] = (
|
|
271
|
+
"DELIVERED" if verdict.allowed else "BLOCKED: " + "; ".join(verdict.reasons)
|
|
272
|
+
)
|
|
273
|
+
return verdict
|
|
274
|
+
|
|
275
|
+
# ── tool proxy methods (rendered output for the LLM; ToolResult inside) ──
|
|
276
|
+
|
|
277
|
+
def _call_result(self, tool: str, **kwargs: Any) -> ToolResult:
|
|
278
|
+
return self.registry.call(tool, **kwargs)
|
|
279
|
+
|
|
280
|
+
def _call(self, tool: str, **kwargs: Any) -> Any:
|
|
281
|
+
return self.registry.call(tool, **kwargs).output
|
|
282
|
+
|
|
283
|
+
def read_file(self, path: str, offset: int = 0, limit: int | None = None) -> str:
|
|
284
|
+
return str(self._call("read_file", path=path, offset=offset, limit=limit))
|
|
285
|
+
|
|
286
|
+
def write_file(self, path: str, content: str) -> str:
|
|
287
|
+
return str(self._call("write_file", path=path, content=content))
|
|
288
|
+
|
|
289
|
+
def edit_file(self, path: str, old: str, new: str) -> bool:
|
|
290
|
+
return bool(self._call_result("edit_file", path=path, old=old, new=new).ok)
|
|
291
|
+
|
|
292
|
+
def bash(self, command: str, timeout: int = 60) -> str:
|
|
293
|
+
return str(self._call("bash", command=command, timeout=timeout))
|
|
294
|
+
|
|
295
|
+
def grep(self, pattern: str, include: str | None = None, path: str = ".") -> list[str]:
|
|
296
|
+
out = self._call("grep", pattern=pattern, include=include, path=path)
|
|
297
|
+
return out if isinstance(out, list) else [str(out)]
|
|
298
|
+
|
|
299
|
+
def glob(self, pattern: str, path: str = ".") -> list[str]:
|
|
300
|
+
out = self._call("glob", pattern=pattern, path=path)
|
|
301
|
+
return out if isinstance(out, list) else [str(out)]
|
|
302
|
+
|
|
303
|
+
def parse_pdf(self, path: str) -> str:
|
|
304
|
+
return str(self._call("parse_pdf", path=path))
|
|
305
|
+
|
|
306
|
+
def code_research(self, query: str, k: int = 8) -> str:
|
|
307
|
+
return str(self._call("code_research", query=query, k=k))
|
|
308
|
+
|
|
309
|
+
def fetch_url(self, url: str) -> str:
|
|
310
|
+
return str(self._call("fetch_url", url=url))
|
|
311
|
+
|
|
312
|
+
def load_skill(self, name: str) -> str:
|
|
313
|
+
"""Load the full instructions of an installed skill by name (see the skills index)."""
|
|
314
|
+
from sick.skills import load_skill_body
|
|
315
|
+
|
|
316
|
+
body = load_skill_body(name, self.workspace)
|
|
317
|
+
if body is not None and self.current_run is not None:
|
|
318
|
+
self.lifecycle.record(self.current_run, ToolResult.success(body[:200], tool="load_skill"), {"name": name})
|
|
319
|
+
return body if body is not None else f"Error: unknown skill: {name}"
|
|
320
|
+
|
|
321
|
+
def checkpoint(self, message: str = "checkpoint") -> str:
|
|
322
|
+
return str(self._call("checkpoint", message=message))
|
|
323
|
+
|
|
324
|
+
def restore(self, n: int = 1) -> str:
|
|
325
|
+
return str(self._call("restore", n=n))
|
|
326
|
+
|
|
327
|
+
def checkpoints(self) -> str:
|
|
328
|
+
return str(self._call("checkpoints"))
|
|
329
|
+
|
|
330
|
+
# ── self-modification helpers ──
|
|
331
|
+
|
|
332
|
+
def self_read(self, path: str) -> str:
|
|
333
|
+
return str(self._call("self_read", path=path))
|
|
334
|
+
|
|
335
|
+
def self_write(self, path: str, content: str) -> str:
|
|
336
|
+
return str(self._call("self_write", path=path, content=content))
|
|
337
|
+
|
|
338
|
+
# ── memory ──
|
|
339
|
+
|
|
340
|
+
def _recall_for_prompt(self) -> str:
|
|
341
|
+
results = self.memory.recall("")
|
|
342
|
+
if not results:
|
|
343
|
+
return "No past experiences yet."
|
|
344
|
+
lines = []
|
|
345
|
+
for e in results:
|
|
346
|
+
lines.append(f"- {e.task}: {e.outcome} (pattern: {e.pattern}, tools: {', '.join(e.tools)})")
|
|
347
|
+
return "\n".join(lines)
|
|
348
|
+
|
|
349
|
+
def _record_experience(self, task: str, outcome: str, pattern: str) -> None:
|
|
350
|
+
tools = sorted(self._tool_counts.keys())
|
|
351
|
+
self.memory.record(Experience(task=task, outcome=outcome, pattern=pattern, tools=tools))
|
|
352
|
+
|
|
353
|
+
def learn_brief(self, focus: str = "") -> str:
|
|
354
|
+
"""Mine audit + memory for lessons, record one, return the brief.
|
|
355
|
+
|
|
356
|
+
Deterministic half of `/learn`; the generative half (actually editing
|
|
357
|
+
sick's source) runs from `sick.learn.build_learn_prompt`'s prompt.
|
|
358
|
+
"""
|
|
359
|
+
from sick.learn import analyze_audit, blocked_run_lessons, summarize
|
|
360
|
+
|
|
361
|
+
report = analyze_audit(self.audit.entries(100), focus=focus, turns=self.turns)
|
|
362
|
+
report.lessons = blocked_run_lessons(self.workspace) + report.lessons
|
|
363
|
+
brief = summarize(report, self.memory.recall(""))
|
|
364
|
+
lesson = report.lessons[0] if report.lessons else "session reviewed"
|
|
365
|
+
self.memory.record(
|
|
366
|
+
Experience(
|
|
367
|
+
task=f"learn: {focus or 'session review'}",
|
|
368
|
+
outcome="lesson recorded",
|
|
369
|
+
pattern=lesson,
|
|
370
|
+
tools=sorted(self._tool_counts.keys()),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
return brief
|
|
374
|
+
|
|
375
|
+
# ── interactive turn protocol ──
|
|
376
|
+
|
|
377
|
+
@hidden
|
|
378
|
+
async def handle(self, notification: dict[str, list]) -> RespondResult:
|
|
379
|
+
msgs = notification.get("user_messages", [])
|
|
380
|
+
if msgs:
|
|
381
|
+
task = msgs[-1]
|
|
382
|
+
# Reuse a planned run: awaiting approval = planning turn (do not
|
|
383
|
+
# finish it); approved = execution turn (gate it). Else start fresh.
|
|
384
|
+
run = self.current_run
|
|
385
|
+
planning_turn = bool(
|
|
386
|
+
run is not None and run.plan_created and not run.approved
|
|
387
|
+
and run.phase in (Phase.DESIGN, Phase.REQUIREMENTS)
|
|
388
|
+
)
|
|
389
|
+
if run is None or not (planning_turn or (run.approved and run.phase == Phase.IMPLEMENT)):
|
|
390
|
+
run = self.begin_run(str(task))
|
|
391
|
+
planning_turn = False
|
|
392
|
+
self.context["user_request"] = task
|
|
393
|
+
self.turns += 1
|
|
394
|
+
await self.respond()
|
|
395
|
+
if planning_turn:
|
|
396
|
+
run.phase = Phase.DESIGN # type: ignore[union-attr]
|
|
397
|
+
self.lifecycle.persist(run)
|
|
398
|
+
self._record_experience(str(task)[:80], "planned", f"sdlc:{run.phase.value}:{run.tier.value}") # type: ignore[union-attr]
|
|
399
|
+
return RespondResult(
|
|
400
|
+
kind=RespondReason.DONE,
|
|
401
|
+
explanation="plan produced; awaiting /approve or /reject",
|
|
402
|
+
)
|
|
403
|
+
verdict = self.finish_run("chat turn")
|
|
404
|
+
delivered = bool(verdict and verdict.allowed)
|
|
405
|
+
outcome = "success" if delivered else "blocked"
|
|
406
|
+
pattern = f"sdlc:{run.phase.value}:{run.tier.value}" if run else "chat turn"
|
|
407
|
+
self._record_experience(str(task)[:80], outcome, pattern)
|
|
408
|
+
return RespondResult(
|
|
409
|
+
kind=RespondReason.DONE,
|
|
410
|
+
explanation=(
|
|
411
|
+
"delivered; waiting for the next user message" if delivered
|
|
412
|
+
else f"blocked by SDLC gate: {'; '.join(verdict.reasons) if verdict else 'no run'}"
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
for cmd in notification.get("slash_commands", []):
|
|
416
|
+
value = getattr(cmd, "value", cmd)
|
|
417
|
+
text = getattr(cmd, "text", str(cmd))
|
|
418
|
+
self.context["user_request"] = f"Slash command result: {text}"
|
|
419
|
+
await self.respond()
|
|
420
|
+
return RespondResult(
|
|
421
|
+
kind=RespondReason.DONE,
|
|
422
|
+
explanation=str(value or text),
|
|
423
|
+
)
|
|
424
|
+
for sysmsg in notification.get("system_messages", []):
|
|
425
|
+
self.context["user_request"] = str(sysmsg)
|
|
426
|
+
await self.respond()
|
|
427
|
+
return RespondResult(
|
|
428
|
+
kind=RespondReason.DONE,
|
|
429
|
+
explanation="processed system message",
|
|
430
|
+
)
|
|
431
|
+
return RespondResult(
|
|
432
|
+
kind=RespondReason.DONE,
|
|
433
|
+
explanation="no pending input",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
async def respond(self) -> str:
|
|
437
|
+
"""Respond to the user's latest message (in the `user_request` context block).
|
|
438
|
+
|
|
439
|
+
This is a chat session. Do ALL the work before responding: explore,
|
|
440
|
+
implement, test, iterate. Then reply to the user in Markdown with a
|
|
441
|
+
concise summary of what you did. Call `message()` to send the reply.
|
|
442
|
+
|
|
443
|
+
ponytail: always apply the lazy philosophy.
|
|
444
|
+
"""
|
|
445
|
+
...
|
|
446
|
+
|
|
447
|
+
# ── one-shot generation methods (CLI mode) ──
|
|
448
|
+
|
|
449
|
+
async def run(self, task: str) -> str:
|
|
450
|
+
"""Complete a coding task.
|
|
451
|
+
|
|
452
|
+
1. Understand the task
|
|
453
|
+
2. Research existing code if needed (grep, read)
|
|
454
|
+
3. Make changes (write_file, edit_file)
|
|
455
|
+
4. Verify with tests/bash
|
|
456
|
+
5. Record experience
|
|
457
|
+
|
|
458
|
+
ponytail: always apply the lazy philosophy
|
|
459
|
+
"""
|
|
460
|
+
...
|
|
461
|
+
|
|
462
|
+
async def modify_self(self, instruction: str) -> str:
|
|
463
|
+
"""Modify sick's own source code to improve capabilities.
|
|
464
|
+
|
|
465
|
+
Steps:
|
|
466
|
+
1. checkpoint('pre-modify') first — self_write makes this a RISKY run
|
|
467
|
+
and the SDLC gate requires a checkpoint before delivery
|
|
468
|
+
2. self_read('agent.py') — understand current code
|
|
469
|
+
3. Make changes via self_write/edit
|
|
470
|
+
4. bash('uv run python -c "from sick.agent import SickAgent"') — verify import
|
|
471
|
+
5. bash('uv run python -m pytest tests/ -x') — verify tests pass
|
|
472
|
+
(verification must come after the last edit or delivery is blocked)
|
|
473
|
+
6. Record the improvement as an experience
|
|
474
|
+
|
|
475
|
+
You can modify: src/sick/agent.py, tools/*.py, skills/*.py, pyproject.toml, README.md
|
|
476
|
+
"""
|
|
477
|
+
...
|
sick/audit.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import portalocker # type: ignore
|
|
8
|
+
except Exception: # pragma: no cover
|
|
9
|
+
portalocker = None # type: ignore
|
|
10
|
+
|
|
11
|
+
MAX_AUDIT_BYTES = 5_000_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditLog:
|
|
15
|
+
"""Append-only JSONL record of every tool call for a workspace."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, workspace: str | Path) -> None:
|
|
18
|
+
self._path = Path(workspace).resolve() / ".sick" / "audit.jsonl"
|
|
19
|
+
|
|
20
|
+
def _rotate_if_needed(self) -> None:
|
|
21
|
+
try:
|
|
22
|
+
if self._path.exists() and self._path.stat().st_size > MAX_AUDIT_BYTES:
|
|
23
|
+
ts = int(time.time())
|
|
24
|
+
rot = self._path.with_suffix(f".{ts}.jsonl")
|
|
25
|
+
self._path.rename(rot)
|
|
26
|
+
olds = sorted(self._path.parent.glob(self._path.name + ".*.jsonl"))
|
|
27
|
+
for p in olds[:-3]:
|
|
28
|
+
try:
|
|
29
|
+
p.unlink()
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def record(
|
|
36
|
+
self,
|
|
37
|
+
tool: str,
|
|
38
|
+
ok: bool,
|
|
39
|
+
duration_ms: int,
|
|
40
|
+
**args: object,
|
|
41
|
+
) -> None:
|
|
42
|
+
import re
|
|
43
|
+
|
|
44
|
+
redacted = {}
|
|
45
|
+
for k, v in args.items():
|
|
46
|
+
s = str(v) if not isinstance(v, str) else v
|
|
47
|
+
s = re.sub(r"(?i)(api[_-]?key\s*[:=]\s*)([^\s\"']+)", r"\1[REDACTED]", s)
|
|
48
|
+
s = re.sub(r"(?i)(sk-[a-z0-9\-]{10,})", "[REDACTED]", s)
|
|
49
|
+
# ponytail: bound audit growth — full file contents don't belong in the log
|
|
50
|
+
if len(s) > 1000:
|
|
51
|
+
s = s[:1000] + f"…[truncated, {len(s)} chars total]"
|
|
52
|
+
redacted[k] = s
|
|
53
|
+
entry = {
|
|
54
|
+
"t": time.time(),
|
|
55
|
+
"tool": tool,
|
|
56
|
+
"ok": ok,
|
|
57
|
+
"duration_ms": duration_ms,
|
|
58
|
+
"args": redacted,
|
|
59
|
+
}
|
|
60
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self._rotate_if_needed()
|
|
62
|
+
line = json.dumps(entry, default=str) + "\n"
|
|
63
|
+
if portalocker:
|
|
64
|
+
try:
|
|
65
|
+
with portalocker.Lock(str(self._path), "a", timeout=5) as f: # type: ignore
|
|
66
|
+
f.write(line)
|
|
67
|
+
try:
|
|
68
|
+
f.flush()
|
|
69
|
+
os.fsync(f.fileno())
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
return
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
with open(self._path, "a") as f:
|
|
76
|
+
f.write(line)
|
|
77
|
+
|
|
78
|
+
def entries(self, n: int | None = None) -> list[dict]:
|
|
79
|
+
if not self._path.exists():
|
|
80
|
+
return []
|
|
81
|
+
rows = []
|
|
82
|
+
data = ""
|
|
83
|
+
if portalocker:
|
|
84
|
+
try:
|
|
85
|
+
with portalocker.Lock(str(self._path), "r", flags=portalocker.LOCK_SH) as f: # type: ignore
|
|
86
|
+
data = f.read()
|
|
87
|
+
except Exception:
|
|
88
|
+
data = ""
|
|
89
|
+
if not data:
|
|
90
|
+
try:
|
|
91
|
+
with open(self._path) as f:
|
|
92
|
+
data = f.read()
|
|
93
|
+
except OSError:
|
|
94
|
+
return []
|
|
95
|
+
for line in data.splitlines():
|
|
96
|
+
try:
|
|
97
|
+
rows.append(json.loads(line))
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
continue
|
|
100
|
+
if n is not None:
|
|
101
|
+
rows = rows[-n:]
|
|
102
|
+
return rows
|