opencommand 0.1.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.
Files changed (37) hide show
  1. opencommand/__init__.py +4 -0
  2. opencommand/cli.py +375 -0
  3. opencommand/core/agent.py +91 -0
  4. opencommand/core/config.py +65 -0
  5. opencommand/core/errors.py +23 -0
  6. opencommand/core/logging.py +65 -0
  7. opencommand/core/supervisor.py +81 -0
  8. opencommand/core/tools.py +136 -0
  9. opencommand/engine/__init__.py +66 -0
  10. opencommand/engine/runner.py +275 -0
  11. opencommand/modules/knowledge.py +85 -0
  12. opencommand/modules/memory/MEMORY.md +14 -0
  13. opencommand/modules/skills/bash.md +14 -0
  14. opencommand/modules/skills/powershell.md +15 -0
  15. opencommand/modules/skills/python.md +20 -0
  16. opencommand/modules/skills/system.md +38 -0
  17. opencommand/modules/system/automatic.py +151 -0
  18. opencommand/modules/system/cron.py +193 -0
  19. opencommand/modules/system/notify.py +85 -0
  20. opencommand/modules/system/platform.py +197 -0
  21. opencommand/modules/templates.py +227 -0
  22. opencommand/modules/tools/desktop.py +127 -0
  23. opencommand/modules/tools/files.py +82 -0
  24. opencommand/modules/tools/instructions.py +163 -0
  25. opencommand/modules/tools/memory.py +78 -0
  26. opencommand/modules/tools/playwright.py +61 -0
  27. opencommand/modules/tools/research.py +46 -0
  28. opencommand/modules/tools/system.py +163 -0
  29. opencommand/modules/tools/terminal.py +53 -0
  30. opencommand/providers/provider.py +157 -0
  31. opencommand/swarm/agents/commander.py +530 -0
  32. opencommand/swarm/agents/worker.py +26 -0
  33. opencommand/tui/dashboard.py +41 -0
  34. opencommand-0.1.0.dist-info/METADATA +76 -0
  35. opencommand-0.1.0.dist-info/RECORD +37 -0
  36. opencommand-0.1.0.dist-info/WHEEL +4 -0
  37. opencommand-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,157 @@
1
+ """Unified LLM provider layer for OpenCommand.
2
+
3
+ All four target backends (Ollama, LM Studio, vLLM, llama.cpp) expose the OpenAI
4
+ Chat Completions API, so a single client with per-backend presets covers them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, AsyncIterator, Iterator
11
+
12
+ try:
13
+ from openai import AsyncOpenAI, OpenAI
14
+ except Exception: # pragma: no cover - openai is a hard dep but keep import soft
15
+ AsyncOpenAI = None # type: ignore
16
+ OpenAI = None # type: ignore
17
+
18
+
19
+ @dataclass
20
+ class BackendPreset:
21
+ """Connection defaults for a local LLM server."""
22
+
23
+ name: str
24
+ base_url: str
25
+ default_model: str
26
+ api_key: str = "not-needed"
27
+ notes: str = ""
28
+
29
+
30
+ # Presets for the four supported OpenAI-compatible servers.
31
+ BACKENDS: dict[str, BackendPreset] = {
32
+ "ollama": BackendPreset("ollama", "http://localhost:11434/v1", "qwen2.5:7b",
33
+ notes="ollama serve; pull models with `ollama pull`."),
34
+ "lmstudio": BackendPreset("lmstudio", "http://localhost:1234/v1",
35
+ "lmstudio-community/qwen2.5-7b",
36
+ notes="LM Studio -> Developer tab -> Start Server."),
37
+ "vllm": BackendPreset("vllm", "http://localhost:8000/v1",
38
+ "Qwen/Qwen2.5-7B-Instruct", notes="vllm serve <model> --api-key token"),
39
+ "llamacpp": BackendPreset("llamacpp", "http://localhost:8080/v1", "local-model",
40
+ notes="server --api-key token (llama.cpp server)."),
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class ChatMessage:
46
+ role: str
47
+ content: Any # str or list[dict] for multimodal
48
+ name: str | None = None
49
+ tool_calls: list | None = None # OpenAI-style tool calls (assistant turns)
50
+ tool_call_id: str | None = None # links a tool result back to its call
51
+
52
+
53
+ @dataclass
54
+ class ProviderConfig:
55
+ backend: str = "ollama"
56
+ base_url: str | None = None
57
+ api_key: str | None = None
58
+ model: str | None = None
59
+ temperature: float = 0.2
60
+ max_tokens: int = 4096
61
+ extra: dict[str, Any] = field(default_factory=dict)
62
+
63
+ def resolve(self) -> BackendPreset:
64
+ preset = BACKENDS.get(self.backend)
65
+ if preset is None:
66
+ raise ValueError(
67
+ f"Unknown backend '{self.backend}'. "
68
+ f"Choose from: {', '.join(BACKENDS)}"
69
+ )
70
+ return preset
71
+
72
+ @property
73
+ def effective_base_url(self) -> str:
74
+ return self.base_url or self.resolve().base_url
75
+
76
+ @property
77
+ def effective_model(self) -> str:
78
+ return self.model or self.resolve().default_model
79
+
80
+ @property
81
+ def effective_api_key(self) -> str:
82
+ return self.api_key or self.resolve().api_key
83
+
84
+
85
+ class BaseProvider:
86
+ """Interface every provider implements."""
87
+
88
+ def complete(self, messages: list[ChatMessage], **kw) -> ChatMessage: # sync
89
+ raise NotImplementedError
90
+
91
+ async def acomplete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
92
+ raise NotImplementedError
93
+
94
+ def stream(self, messages: list[ChatMessage], **kw) -> Iterator[ChatMessage]:
95
+ raise NotImplementedError
96
+
97
+ async def astream(self, messages: list[ChatMessage], **kw) -> AsyncIterator[ChatMessage]:
98
+ raise NotImplementedError
99
+
100
+
101
+ class OpenAICompatibleProvider(BaseProvider):
102
+ """Talks to any OpenAI-compatible server (Ollama/LM Studio/vLLM/llama.cpp)."""
103
+
104
+ def __init__(self, cfg: ProviderConfig) -> None:
105
+ self.cfg = cfg
106
+ if OpenAI is None:
107
+ raise RuntimeError("openai package is required for the provider layer")
108
+ self._client = OpenAI(
109
+ base_url=cfg.effective_base_url,
110
+ api_key=cfg.effective_api_key,
111
+ )
112
+ self._aclient = AsyncOpenAI(
113
+ base_url=cfg.effective_base_url,
114
+ api_key=cfg.effective_api_key,
115
+ )
116
+
117
+ def _payload(self, messages: list[ChatMessage], **kw) -> dict[str, Any]:
118
+ payload = {
119
+ "model": self.cfg.effective_model,
120
+ "messages": [m.__dict__ for m in messages],
121
+ "temperature": kw.get("temperature", self.cfg.temperature),
122
+ "max_tokens": kw.get("max_tokens", self.cfg.max_tokens),
123
+ }
124
+ payload.update(self.cfg.extra)
125
+ payload.update({k: v for k, v in kw.items() if k not in payload})
126
+ return payload
127
+
128
+ def complete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
129
+ resp = self._client.chat.completions.create(**self._payload(messages, **kw))
130
+ msg = resp.choices[0].message
131
+ return ChatMessage(role=msg.role, content=msg.content or "")
132
+
133
+ async def acomplete(self, messages: list[ChatMessage], **kw) -> ChatMessage:
134
+ resp = await self._aclient.chat.completions.create(**self._payload(messages, **kw))
135
+ msg = resp.choices[0].message
136
+ return ChatMessage(role=msg.role, content=msg.content or "")
137
+
138
+ def stream(self, messages: list[ChatMessage], **kw):
139
+ stream = self._client.chat.completions.create(
140
+ **self._payload(messages, **kw), stream=True
141
+ )
142
+ for chunk in stream:
143
+ if chunk.choices and chunk.choices[0].delta.content:
144
+ yield ChatMessage(role="assistant", content=chunk.choices[0].delta.content)
145
+
146
+ async def astream(self, messages: list[ChatMessage], **kw):
147
+ stream = await self._aclient.chat.completions.create(
148
+ **self._payload(messages, **kw), stream=True
149
+ )
150
+ async for chunk in stream:
151
+ if chunk.choices and chunk.choices[0].delta.content:
152
+ yield ChatMessage(role="assistant", content=chunk.choices[0].delta.content)
153
+
154
+
155
+ def build_provider(cfg: ProviderConfig) -> BaseProvider:
156
+ """Factory: today every backend is OpenAI-compatible."""
157
+ return OpenAICompatibleProvider(cfg)
@@ -0,0 +1,530 @@
1
+ """Commander: the orchestrating model.
2
+
3
+ Takes a high-level GOAL, plans a multi-phase implementation, and dispatches
4
+ batches of research and worker agents via the Supervisor. Updates memory and
5
+ iterates until the goal and all subtasks are complete.
6
+
7
+ Phase 6 advanced pipeline (orchestrator-workers + evaluator-optimizer), hardened
8
+ for codebase-scale, fully-automated, long-horizon goals:
9
+
10
+ scaffold -> RESEARCH LOOP (refine until accurate) -> implement (workers) ->
11
+ TEST+DEBUG LOOP (until testable state passes) -> commander review gate
12
+ (evaluator-optimizer, incl. off-track check) -> live-verify (optional) ->
13
+ outer loop until the GOAL is 100% met.
14
+
15
+ Research is looped *before* any worker is dispatched so implementation is always
16
+ grounded in verified findings. Guardrails are injected into every prompt so the
17
+ Commander and workers never diverge from the assigned task or the workspace.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING
26
+
27
+ from ...core.agent import AgentLoop
28
+ from ...core.logging import get_logger
29
+ from ...core.supervisor import Supervisor
30
+ from ...core.tools import ToolRegistry
31
+ from ...engine import Engine
32
+ from ...engine.runner import LocalRunner
33
+ from ...providers.provider import ChatMessage
34
+ from ...modules.knowledge import KnowledgeBase
35
+ from ...modules.tools.memory import save_note
36
+
37
+ if TYPE_CHECKING:
38
+ from ...modules.system.automatic import LiveVerifyResult
39
+
40
+ log = get_logger("swarm.commander")
41
+
42
+ PLAN_PROMPT = (
43
+ "You are the Commander of an OpenCommand swarm. Given a GOAL, produce a "
44
+ "multi-phase plan as JSON: {\"phases\": [{\"name\": str, \"research\": [task], "
45
+ "\"workers\": [task]}]}. Keep each task concrete and independently executable. "
46
+ "Prefer research batches first (find best packages/tools/setups), then worker "
47
+ "batches that implement. Respond with JSON only."
48
+ )
49
+
50
+ REVIEW_PROMPT = (
51
+ "You are the Commander reviewing a completed phase against the original GOAL. "
52
+ "Decide whether the work actually satisfies the goal. Be strict: tests passing "
53
+ "is not enough if the wrong thing was built, requirements are missing, the "
54
+ "output is incomplete, or the work went OFF TRACK (modified unrelated files, "
55
+ "diverged from the goal, or re-planned the overall goal). Respond with JSON "
56
+ "only, using this shape:\n"
57
+ "{{\"passed\": true_or_false, \"off_track\": true_or_false, \"feedback\": "
58
+ "\"string\", \"missing\": [\"item\"]}}\n\n"
59
+ "GOAL: {goal}\nPHASE: {phase}\n\nWORK DONE:\n{report}"
60
+ )
61
+
62
+ RESEARCH_ASSESS_PROMPT = (
63
+ "You are the Commander assessing whether the research collected so far is "
64
+ "sufficient to implement the phase ACCURATELY. If there are gaps, unverified "
65
+ "claims, or unknowns that could make workers guess or go off track, list them "
66
+ "as concrete follow-up research tasks. Respond with JSON only, using this "
67
+ "shape:\n"
68
+ "{{\"sufficient\": true_or_false, \"gaps\": [\"follow-up research task\"], "
69
+ "\"note\": \"string\"}}\n\n"
70
+ "GOAL: {goal}\nPHASE: {phase}\n\nRESEARCH SO FAR:\n{research}"
71
+ )
72
+
73
+ # Injected into every research/worker prompt so the swarm stays on task and
74
+ # inside the workspace. The files tool also hard-rejects path escapes.
75
+ GUARDRAILS = (
76
+ "GUARDRAILS (strict, must follow):\n"
77
+ "- Stay strictly within the assigned task and the workspace. Do NOT modify "
78
+ "files unrelated to this task or phase.\n"
79
+ "- Do NOT re-plan the overall goal; implement only what is asked of you.\n"
80
+ "- Prefer running real commands/tests over claiming success. If a tool fails, "
81
+ "retry or report the error; never fabricate results.\n"
82
+ "- Stop when the task is complete; emit a final summary with no tool calls.\n"
83
+ "- All file writes must resolve under the workspace root (the files tool "
84
+ "rejects any path that escapes it)."
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class Phase:
90
+ name: str
91
+ research: list[str] = field(default_factory=list)
92
+ workers: list[str] = field(default_factory=list)
93
+
94
+
95
+ @dataclass
96
+ class Plan:
97
+ phases: list[Phase] = field(default_factory=list)
98
+
99
+ @classmethod
100
+ def from_json(cls, text: str) -> "Plan":
101
+ try:
102
+ data = json.loads(text)
103
+ except json.JSONDecodeError:
104
+ start = text.find("{")
105
+ end = text.rfind("}") + 1
106
+ data = json.loads(text[start:end]) if start != -1 else {"phases": []}
107
+ return cls(phases=[
108
+ Phase(name=p.get("name", "phase"), research=p.get("research", []),
109
+ workers=p.get("workers", []))
110
+ for p in data.get("phases", [])
111
+ ])
112
+
113
+
114
+ @dataclass
115
+ class ReviewResult:
116
+ passed: bool = False
117
+ off_track: bool = False
118
+ feedback: str = ""
119
+ missing: list[str] = field(default_factory=list)
120
+
121
+ @classmethod
122
+ def from_json(cls, text: str) -> "ReviewResult":
123
+ try:
124
+ data = json.loads(text)
125
+ except json.JSONDecodeError:
126
+ start = text.find("{")
127
+ end = text.rfind("}") + 1
128
+ data = json.loads(text[start:end]) if start != -1 else {}
129
+ return cls(
130
+ passed=bool(data.get("passed", False)),
131
+ off_track=bool(data.get("off_track", False)),
132
+ feedback=str(data.get("feedback", "")),
133
+ missing=list(data.get("missing", []) or []),
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class ResearchAssessment:
139
+ sufficient: bool = False
140
+ gaps: list[str] = field(default_factory=list)
141
+ note: str = ""
142
+
143
+ @classmethod
144
+ def from_json(cls, text: str) -> "ResearchAssessment":
145
+ try:
146
+ data = json.loads(text)
147
+ except json.JSONDecodeError:
148
+ start = text.find("{")
149
+ end = text.rfind("}") + 1
150
+ data = json.loads(text[start:end]) if start != -1 else {}
151
+ return cls(
152
+ sufficient=bool(data.get("sufficient", False)),
153
+ gaps=list(data.get("gaps", []) or []),
154
+ note=str(data.get("note", "")),
155
+ )
156
+
157
+
158
+ class Commander:
159
+ """Plans and drives the swarm for a GOAL.
160
+
161
+ The Commander uses a shared runner for planning/research; each worker gets a
162
+ dedicated runner instance from the engine pool so concurrent workers run
163
+ their inference in parallel (the GIL is released during llama.cpp generation).
164
+ A ``provider`` may be injected (tests / non-embedded backends) to override the
165
+ embedded commander runner.
166
+ """
167
+
168
+ def __init__(self, engine: Engine, supervisor: Supervisor,
169
+ tools: ToolRegistry, workspace: Path,
170
+ provider: "LocalRunner | None" = None) -> None:
171
+ self.engine = engine
172
+ self.provider = provider or engine.runner("commander")
173
+ self.supervisor = supervisor
174
+ self.tools = tools
175
+ self.workspace = workspace
176
+ # Knowledge base loads skills + tasks + docs + tool catalog into workers.
177
+ self.knowledge = KnowledgeBase(workspace, tools)
178
+
179
+ async def plan(self, goal: str) -> Plan:
180
+ msg = await self.provider.acomplete(
181
+ [ChatMessage(role="user", content=f"{PLAN_PROMPT}\n\nGOAL: {goal}")],
182
+ json_mode=True)
183
+ return Plan.from_json(msg.content or "{}")
184
+
185
+ # --- Standard pipeline (backward compatible) ---------------------------
186
+
187
+ async def execute(self, goal: str) -> str:
188
+ """Standard pipeline: plan -> research -> workers (no scaffold/review)."""
189
+ plan = await self.plan(goal)
190
+ save_note(self.workspace, "goal_plan",
191
+ json.dumps({"goal": goal, "phases": [p.__dict__ for p in plan.phases]},
192
+ indent=2), tags=["plan", "commander"])
193
+ report: list[str] = [f"GOAL: {goal}", f"Phases: {len(plan.phases)}"]
194
+ for idx, phase in enumerate(plan.phases, 1):
195
+ report.append(f"\n=== Phase {idx}: {phase.name} ===")
196
+ research_jobs = {f"research-{i}": self._research_job(i, t)
197
+ for i, t in enumerate(phase.research)}
198
+ if research_jobs:
199
+ for k, v in (await self.supervisor.run_all(research_jobs)).items():
200
+ report.append(f" [{k}] {v[:300]}")
201
+ worker_jobs = {f"worker-{i}": self._worker_job(i, t)
202
+ for i, t in enumerate(phase.workers)}
203
+ if worker_jobs:
204
+ for k, v in (await self.supervisor.run_all(worker_jobs)).items():
205
+ report.append(f" [{k}] {v[:300]}")
206
+ save_note(self.workspace, "goal_report", "\n".join(report), tags=["report"])
207
+ return "\n".join(report)
208
+
209
+ # --- Advanced pipeline (Phase 6, hardened) -----------------------------
210
+
211
+ async def execute_advanced(self, goal: str, max_research_loops: int = 3,
212
+ max_test_loops: int = 5,
213
+ verify_cmd: str | None = None,
214
+ live_verify: bool = False) -> str:
215
+ """Fully-featured autonomous loop for a GOAL.
216
+
217
+ Per phase:
218
+ 1. scaffold a stable working dir;
219
+ 2. RESEARCH LOOP - run the research batch, then the Commander assesses
220
+ sufficiency and dispatches follow-up research tasks until findings
221
+ are accurate (or the loop cap is hit). Findings flow into workers.
222
+ 3. implement workers (grounded in research + skills + guardrails);
223
+ 4. TEST+DEBUG LOOP - once a testable state is reached, run the verify
224
+ command and let workers fix failures until it passes (or cap hit);
225
+ 5. commander review gate (incl. off-track check);
226
+ 6. optional live-verify via the desktop tool for UI goals.
227
+
228
+ The outer loop in ``run_automatic`` re-runs the whole pipeline with the
229
+ failing report as fix context until the GOAL is 100% met.
230
+ """
231
+ plan = await self.plan(goal)
232
+ save_note(self.workspace, "goal_plan",
233
+ json.dumps({"goal": goal, "phases": [p.__dict__ for p in plan.phases]},
234
+ indent=2), tags=["plan", "commander"])
235
+ # Also persist a templated markdown plan for human readability.
236
+ from ...modules.templates import template_for
237
+ from ...modules.tools.instructions import _now
238
+
239
+ plan_md = template_for("plan").render(
240
+ goal=goal, created=_now(),
241
+ phases=str(len(plan.phases)),
242
+ description="\n".join(f"- {p.name}" for p in plan.phases),
243
+ )
244
+ save_note(self.workspace, "goal_plan_md", plan_md, tags=["plan", "commander"])
245
+ log.info("planned goal=%r phases=%d", goal, len(plan.phases))
246
+ # Load skills + tasks + docs + tool catalog into every worker instance.
247
+ knowledge = self.knowledge.worker_context()
248
+ report: list[str] = [f"GOAL: {goal}", f"Phases: {len(plan.phases)}"]
249
+ for idx, phase in enumerate(plan.phases, 1):
250
+ report.append(f"\n=== Phase {idx}: {phase.name} ===")
251
+ scaffold_root = self._scaffold(phase, idx)
252
+ # 2. RESEARCH LOOP: refine until accurate before any worker runs.
253
+ research_ctx = await self._research_loop(
254
+ goal, phase, idx, report, knowledge, max_loops=max_research_loops)
255
+ # 3. Implement workers, grounded in research + knowledge + guardrails.
256
+ context = self._build_context(goal, phase, research_ctx, scaffold_root, knowledge)
257
+ worker_jobs = {f"worker-{i}": self._worker_job(i, t, context)
258
+ for i, t in enumerate(phase.workers)}
259
+ if worker_jobs:
260
+ for k, v in (await self.supervisor.run_all(worker_jobs)).items():
261
+ report.append(f" [{k}] {v[:300]}")
262
+ # 4. TEST+DEBUG LOOP: reach a testable state, then fix until green.
263
+ if verify_cmd:
264
+ test_report = await self._test_debug_loop(
265
+ goal, phase, context, verify_cmd, max_loops=max_test_loops)
266
+ report.append(test_report)
267
+ # 5. Commander review gate (evaluator-optimizer + off-track check).
268
+ review = await self.review_goal(goal, "\n".join(report), phase=phase.name)
269
+ report.append(f" [review:{phase.name}] passed={review.passed} "
270
+ f"off_track={review.off_track} {review.feedback[:200]}")
271
+ if review.missing:
272
+ report.append(f" [review:{phase.name}] missing: {', '.join(review.missing)}")
273
+ # 6. Optional live-verify for UI/UX goals.
274
+ if live_verify:
275
+ lv = await self._live_verify(goal)
276
+ report.append(f" [live-verify:{phase.name}] "
277
+ f"{'PASS' if lv.ok else 'FAIL'}: {lv.summary}")
278
+ # 7. Self-improvement: distill what worked into skills/tasks/docs/memory
279
+ # so the NEXT run (and next phase) starts smarter.
280
+ distill = await self.distill_lessons(goal, "\n".join(report))
281
+ report.append(f"\n[self-improve] {distill}")
282
+ log.info("advanced pipeline complete goal=%r self_improve=%s", goal, distill)
283
+ full_report = "\n".join(report)
284
+ save_note(self.workspace, "goal_report", full_report, tags=["report"])
285
+ # Also persist a templated markdown report for human readability.
286
+ report_md = template_for("report").render(
287
+ goal=goal, created=_now(), status="complete",
288
+ description=full_report[:4000],
289
+ )
290
+ save_note(self.workspace, "goal_report_md", report_md, tags=["report"])
291
+ return full_report
292
+
293
+ async def _research_loop(self, goal: str, phase: Phase, idx: int,
294
+ report: list[str], knowledge: str,
295
+ max_loops: int = 3) -> str:
296
+ """Loop research batches until the Commander judges findings sufficient.
297
+
298
+ Returns the accumulated research context (flowed into workers). Each
299
+ iteration runs the current research tasks concurrently, appends findings
300
+ to memory, and asks the Commander whether more research is needed.
301
+ """
302
+ tasks = list(phase.research)
303
+ ctx_parts: list[str] = []
304
+ for loop in range(max_loops):
305
+ if not tasks:
306
+ break
307
+ jobs = {f"research-{loop}-{i}": self._research_job(i, t, knowledge)
308
+ for i, t in enumerate(tasks)}
309
+ results = await self.supervisor.run_all(jobs)
310
+ batch = "\n\n".join(f"[{k}]\n{v}" for k, v in results.items())
311
+ ctx_parts.append(batch)
312
+ save_note(self.workspace, f"research_{idx}_{loop}", batch,
313
+ tags=["research", phase.name])
314
+ for k, v in results.items():
315
+ report.append(f" [{k}] {v[:200]}")
316
+ # Commander assesses sufficiency of everything gathered so far.
317
+ assessment = await self.assess_research(goal, phase.name,
318
+ "\n\n".join(ctx_parts))
319
+ if assessment.sufficient:
320
+ report.append(f" [research:{phase.name}] sufficient after "
321
+ f"{loop + 1} loop(s): {assessment.note[:120]}")
322
+ break
323
+ # Otherwise queue follow-up research tasks and loop again.
324
+ tasks = assessment.gaps
325
+ report.append(f" [research:{phase.name}] gaps -> {len(tasks)} follow-up "
326
+ f"task(s): {assessment.note[:120]}")
327
+ return "\n\n".join(ctx_parts)
328
+
329
+ async def distill_lessons(self, goal: str, report: str) -> str:
330
+ """Self-improvement gate: turn the run into new/updated instructions.
331
+
332
+ The Commander is asked to produce concrete instruction artifacts (skills,
333
+ task playbooks, docs, memory notes) capturing what worked, what to avoid,
334
+ and any gotchas - then writes them via the instructions tool so future
335
+ runs load them into every worker. Returns a short summary.
336
+ """
337
+ prompt = (
338
+ "You are the Commander performing a self-improvement pass after a run. "
339
+ "Distill the run into reusable instruction artifacts so future runs "
340
+ "start smarter. For each artifact, output ONE line:\n"
341
+ " SKILL|<name>|<markdown body>\n"
342
+ " TASK|<name>|<markdown body>\n"
343
+ " DOC|<name>|<markdown body>\n"
344
+ " MEMORY|<topic>|<tags csv>|<markdown body>\n"
345
+ "Each body MUST follow its required template shape (front-matter + "
346
+ "sections): skills need Summary/Conventions/When to use/Examples/Gotchas; "
347
+ "tasks need Objective/Preconditions/Steps/Verification/Rollback; docs need "
348
+ "Overview/Details/References; memory is a tagged note. Only emit artifacts "
349
+ "that are genuinely reusable (not one-off task details). Prefer updating an "
350
+ "existing skill/task if relevant. Output at most 6 lines. If nothing is "
351
+ "reusable, output NONE.\n\n"
352
+ f"GOAL: {goal}\n\nRUN REPORT:\n{report[:5000]}"
353
+ )
354
+ msg = await self.provider.acomplete(
355
+ [ChatMessage(role="user", content=prompt)], json_mode=False)
356
+ text = msg.content or "NONE"
357
+ written = self._apply_instructions(text)
358
+ return f"wrote {written} artifact(s)" if written else "no reusable artifacts"
359
+
360
+ def _apply_instructions(self, text: str) -> int:
361
+ """Parse the Commander's distillation lines and persist them.
362
+
363
+ Each artifact is one ``KIND|name|body`` (or ``MEMORY|topic|tags|body``)
364
+ record. Bodies may span multiple lines until the next ``KIND|`` record,
365
+ so we accumulate lines and flush when the next record starts.
366
+ """
367
+ from ...modules.tools.instructions import InstructionsTool
368
+
369
+ tool = InstructionsTool(self.workspace)
370
+ count = 0
371
+ cur_kind: str | None = None
372
+ cur_rest: str = ""
373
+ records: list[tuple[str, str]] = []
374
+
375
+ def _flush() -> None:
376
+ nonlocal cur_kind, cur_rest
377
+ if cur_kind is not None:
378
+ records.append((cur_kind, cur_rest.strip()))
379
+ cur_kind, cur_rest = None, ""
380
+
381
+ for raw in text.splitlines():
382
+ line = raw.strip()
383
+ if not line or line == "NONE":
384
+ continue
385
+ if "|" in line and line.split("|", 1)[0].strip().lower() in (
386
+ "skill", "task", "doc", "memory"):
387
+ _flush()
388
+ cur_kind = line.split("|", 1)[0].strip().lower()
389
+ cur_rest = line.split("|", 1)[1]
390
+ elif cur_kind is not None:
391
+ cur_rest += "\n" + line
392
+ # lines before the first record are ignored
393
+ _flush()
394
+
395
+ for kind, rest in records:
396
+ try:
397
+ if kind == "memory":
398
+ name, tags, body = rest.split("|", 2)
399
+ res = tool.run("save", "memory", name=name.strip(),
400
+ body=body.strip(),
401
+ tags=[t.strip() for t in tags.split(",")])
402
+ elif kind in ("skill", "task", "doc"):
403
+ name, body = rest.split("|", 1)
404
+ res = tool.run("save", kind, name=name.strip(), body=body.strip())
405
+ else:
406
+ continue
407
+ if not res.startswith("ERROR"):
408
+ count += 1
409
+ except ValueError:
410
+ continue
411
+ return count
412
+
413
+ async def _test_debug_loop(self, goal: str, phase: Phase, context: str,
414
+ verify_cmd: str, max_loops: int = 5) -> str:
415
+ """Reach a testable state, then run tests and let workers fix failures.
416
+
417
+ Returns a short report line. Uses the same verify command as the outer
418
+ loop; workers are given the failing output as fix context so they iterate
419
+ until green (or the cap is hit).
420
+ """
421
+ from ...modules.system.automatic import _verify
422
+
423
+ last = ""
424
+ for loop in range(max_loops):
425
+ rc, out = _verify(self, verify_cmd)
426
+ if rc == 0:
427
+ return (f" [test:{phase.name}] {verify_cmd} -> PASS (exit 0) "
428
+ f"after {loop} fix(es)")
429
+ last = (f"Tests failed (exit {rc}). Fix the issues, then re-run "
430
+ f"`{verify_cmd}`.\nSTDOUT/STDERR:\n{out[:3000]}")
431
+ # Dispatch a focused fixer worker grounded in the failure.
432
+ fix_ctx = (f"{context}\n\n{last}\n\n"
433
+ "Fix ONLY what is needed to make the tests pass. Do not "
434
+ "modify unrelated files. Re-run the tests yourself to confirm.")
435
+ fix = {f"fixer-{loop}": self._worker_job(loop, "Fix failing tests", fix_ctx)}
436
+ res = await self.supervisor.run_all(fix)
437
+ for k, v in res.items():
438
+ last = f" [{k}] {v[:200]}"
439
+ return (f" [test:{phase.name}] {verify_cmd} -> STILL FAILING after "
440
+ f"{max_loops} fix loops. Last: {last[:200]}")
441
+
442
+ async def review_goal(self, goal: str, report: str,
443
+ phase: str | None = None) -> ReviewResult:
444
+ """Commander verification gate: did the work actually meet the GOAL?
445
+
446
+ Returns a structured ReviewResult (incl. off_track). This is the evaluator
447
+ in the evaluator-optimizer loop and is what lets the Commander reject work
448
+ that a worker (or even a passing test suite) claimed was done, or that
449
+ diverged from the assigned task.
450
+ """
451
+ prompt = REVIEW_PROMPT.format(goal=goal, phase=phase or "all",
452
+ report=report[:6000])
453
+ msg = await self.provider.acomplete(
454
+ [ChatMessage(role="user", content=prompt)], json_mode=True)
455
+ return ReviewResult.from_json(msg.content or "{}")
456
+
457
+ async def assess_research(self, goal: str, phase: str, research: str) -> "ResearchAssessment":
458
+ """Commander gate: are the research findings sufficient to implement accurately?"""
459
+ prompt = RESEARCH_ASSESS_PROMPT.format(goal=goal, phase=phase,
460
+ research=research[:6000])
461
+ msg = await self.provider.acomplete(
462
+ [ChatMessage(role="user", content=prompt)], json_mode=True)
463
+ return ResearchAssessment.from_json(msg.content or "{}")
464
+
465
+ async def _live_verify(self, goal: str) -> LiveVerifyResult:
466
+ """Interactive validation via the desktop tool (vision + action loop)."""
467
+ from ...modules.system.automatic import _live_verify as _lv
468
+ return await _lv(self, goal)
469
+
470
+ # --- Helpers -----------------------------------------------------------
471
+
472
+ def _scaffold(self, phase: Phase, idx: int) -> str:
473
+ """Lay the foundation for a phase so workers fill in structure, not fight it.
474
+
475
+ Creates a stable working directory under .opencommand/scaffold and writes
476
+ a manifest from the required scaffold template. Returns the absolute path.
477
+ """
478
+ from ...modules.templates import template_for
479
+ from ...modules.tools.instructions import _now
480
+
481
+ root = self.workspace / ".opencommand" / "scaffold" / f"{idx:02d}-{phase.name}"
482
+ root.mkdir(parents=True, exist_ok=True)
483
+ tpl = template_for("scaffold")
484
+ manifest = tpl.render(
485
+ phase=phase.name, index=str(idx), created=_now(),
486
+ description=(f"Phase {idx}. Workers write implementation here. "
487
+ f"Research tasks: {len(phase.research)}. "
488
+ f"Worker tasks: {len(phase.workers)}."),
489
+ )
490
+ (root / "scaffold.md").write_text(manifest, encoding="utf-8")
491
+ return str(root)
492
+
493
+ def _build_context(self, goal: str, phase: Phase, research: str,
494
+ scaffold_root: str, knowledge: str) -> str:
495
+ """Assemble the shared context block injected into every worker prompt.
496
+
497
+ ``knowledge`` already contains the skills + tasks + docs + tool catalog
498
+ loaded from the KnowledgeBase, so workers start with full instruction
499
+ context. Research findings and guardrails are layered on top.
500
+ """
501
+ parts = [
502
+ f"ORIGINAL GOAL: {goal}",
503
+ f"PHASE: {phase.name}",
504
+ f"WRITE CODE UNDER: {scaffold_root}",
505
+ ]
506
+ if research:
507
+ parts.append(f"RESEARCH CONTEXT (ground your work in this):\n{research}")
508
+ if knowledge:
509
+ parts.append(f"INSTRUCTIONS (skills/tasks/docs/tools):\n{knowledge}")
510
+ parts.append(GUARDRAILS)
511
+ return "\n\n".join(parts)
512
+
513
+ def _research_job(self, index: int, task: str, knowledge: str = ""):
514
+ # Research uses a dedicated worker runner so it can run concurrently with
515
+ # the Commander's planning and with other research/worker jobs.
516
+ runner = self.engine.worker_runner("worker", index)
517
+ async def _run() -> str:
518
+ prompt = (f"Research task: {task}. Summarize findings and best options "
519
+ f"concisely and cite sources.\n\n{GUARDRAILS}")
520
+ if knowledge:
521
+ prompt += f"\n\nINSTRUCTIONS (skills/tasks/docs/tools):\n{knowledge}"
522
+ return await AgentLoop(runner, self.tools).run(prompt)
523
+ return _run
524
+
525
+ def _worker_job(self, index: int, task: str, context: str = ""):
526
+ runner = self.engine.worker_runner("worker", index)
527
+ async def _run() -> str:
528
+ prompt = task if not context else f"{context}\n\nTASK: {task}"
529
+ return await AgentLoop(runner, self.tools).run(prompt)
530
+ return _run