rockycode 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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Safe chat tools for reviewing and merging a goal branch.
|
|
2
|
+
|
|
3
|
+
A goal run leaves its work COMMITTED on a `goal/<slug>` branch in a separate git
|
|
4
|
+
worktree — never in the user's current files. Back in chat the model may be asked
|
|
5
|
+
to "review it" or "merge it". Rather than trust it to hand-roll git (where a bad
|
|
6
|
+
merge could leave the repo half-conflicted), these tools bake in the guards:
|
|
7
|
+
|
|
8
|
+
list_goal_branches — discover what's there (read-only)
|
|
9
|
+
review_goal_branch — the branch's diff vs where it forked (read-only)
|
|
10
|
+
merge_goal_branch — merge into the current branch, but ONLY if the tree is
|
|
11
|
+
clean, and ABORT (restoring the repo exactly) on conflict
|
|
12
|
+
|
|
13
|
+
Every tool refuses any ref that isn't a `goal/...` branch, so the model can't be
|
|
14
|
+
talked into merging an arbitrary branch. merge is risk="risky" → it still goes
|
|
15
|
+
through the normal approval gate in ask/careful mode.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rockycode.engine.tools import Tool, _fn_schema
|
|
24
|
+
|
|
25
|
+
_GOAL_BRANCH_RX = re.compile(r"^goal/[\w./-]+$")
|
|
26
|
+
_MAX_DIFF_CHARS = 40_000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _git(repo: str, *args: str) -> tuple[str, int]:
|
|
30
|
+
"""Run `git -C repo <args>` off the event loop → (combined output, rc)."""
|
|
31
|
+
proc = await asyncio.create_subprocess_exec(
|
|
32
|
+
"git", "-C", repo, *args,
|
|
33
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
|
34
|
+
)
|
|
35
|
+
out, _ = await proc.communicate()
|
|
36
|
+
return out.decode("utf-8", "replace"), proc.returncode
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_goal_branch(name: str) -> bool:
|
|
40
|
+
return bool(_GOAL_BRANCH_RX.match(name or ""))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _exists(repo: str, branch: str) -> bool:
|
|
44
|
+
_out, rc = await _git(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
45
|
+
return rc == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_goal_tools(*, workdir: Path, reviewer=None) -> dict[str, Tool]:
|
|
49
|
+
"""Return {list_goal_branches, review_goal_branch, merge_goal_branch} bound to
|
|
50
|
+
the host repo at *workdir*.
|
|
51
|
+
|
|
52
|
+
*reviewer* (optional): an async callable (branch) -> report. When provided,
|
|
53
|
+
review_goal_branch delegates to it — a read-only explore child reads the
|
|
54
|
+
branch and returns cited, harness-verified findings — so the chat context
|
|
55
|
+
receives a REVIEW instead of a 40k-char raw diff. When None (tests, callers
|
|
56
|
+
that don't carry an engine), the plain truncated diff dump remains."""
|
|
57
|
+
repo = str(Path(workdir).resolve())
|
|
58
|
+
|
|
59
|
+
async def list_goal_branches() -> str:
|
|
60
|
+
"""List the goal/* branches in this repo, newest first, with how many
|
|
61
|
+
commits each is ahead of the current branch."""
|
|
62
|
+
out, rc = await _git(
|
|
63
|
+
repo, "for-each-ref", "--sort=-committerdate",
|
|
64
|
+
"--format=%(refname:short)\t%(contents:subject)", "refs/heads/goal")
|
|
65
|
+
if rc != 0:
|
|
66
|
+
return f"[error] {out.strip() or 'could not list branches'}"
|
|
67
|
+
rows = [ln for ln in out.splitlines() if ln.strip()]
|
|
68
|
+
if not rows:
|
|
69
|
+
return "[ok] no goal branches — run /goal to create one."
|
|
70
|
+
lines = ["goal branches (newest first):"]
|
|
71
|
+
for row in rows:
|
|
72
|
+
branch, _, subject = row.partition("\t")
|
|
73
|
+
ahead, _ = await _git(repo, "rev-list", "--count", f"HEAD..{branch}")
|
|
74
|
+
lines.append(f" {branch} (+{ahead.strip() or '?'} commits) {subject.strip()}")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
def _diff_dump(branch: str, out: str) -> str:
|
|
78
|
+
if len(out) > _MAX_DIFF_CHARS:
|
|
79
|
+
head = out[:_MAX_DIFF_CHARS]
|
|
80
|
+
return (f"[ok] diff of {branch} (truncated to {_MAX_DIFF_CHARS:,} chars — "
|
|
81
|
+
f"full: git -C {repo} diff HEAD...{branch}):\n{head}\n…[truncated]")
|
|
82
|
+
return f"[ok] diff of {branch} vs the current branch:\n{out}"
|
|
83
|
+
|
|
84
|
+
async def review_goal_branch(branch: str, raw: bool = False) -> str:
|
|
85
|
+
"""Review a goal branch. With a reviewer wired: delegate to a read-only
|
|
86
|
+
explore child (full diff + surrounding code, cited findings) and return
|
|
87
|
+
only its report. raw=True — or no reviewer — returns the plain
|
|
88
|
+
(truncated) diff instead. Use before merge_goal_branch."""
|
|
89
|
+
if not _is_goal_branch(branch):
|
|
90
|
+
return f"[refused] '{branch}' is not a goal branch (must look like goal/…)."
|
|
91
|
+
if not await _exists(repo, branch):
|
|
92
|
+
return f"[not found] no branch '{branch}' — call list_goal_branches to see what exists."
|
|
93
|
+
out, rc = await _git(repo, "diff", f"HEAD...{branch}")
|
|
94
|
+
if rc != 0:
|
|
95
|
+
return f"[error] {out.strip() or 'diff failed'}"
|
|
96
|
+
if not out.strip():
|
|
97
|
+
return f"[ok] '{branch}' has no changes vs the current branch."
|
|
98
|
+
if reviewer is None or raw:
|
|
99
|
+
return _diff_dump(branch, out)
|
|
100
|
+
try:
|
|
101
|
+
report = await reviewer(branch)
|
|
102
|
+
except Exception as e: # noqa: BLE001 — a failed review degrades to the dump
|
|
103
|
+
note = f"[note] delegated review failed ({type(e).__name__}) — raw diff instead.\n"
|
|
104
|
+
return note + _diff_dump(branch, out)
|
|
105
|
+
return (f"[ok] delegated review of {branch} (the full diff stayed out of "
|
|
106
|
+
f"this context; raw=true for the diff itself):\n{report}")
|
|
107
|
+
|
|
108
|
+
async def merge_goal_branch(branch: str) -> str:
|
|
109
|
+
"""Merge a goal branch into the CURRENT branch. Guarded: refuses if the
|
|
110
|
+
working tree has uncommitted changes, and aborts (restoring the repo
|
|
111
|
+
exactly) if the merge conflicts — so it can never leave a half-merged mess.
|
|
112
|
+
Only operates on goal/… branches."""
|
|
113
|
+
if not _is_goal_branch(branch):
|
|
114
|
+
return f"[refused] '{branch}' is not a goal branch (must look like goal/…). Nothing merged."
|
|
115
|
+
if not await _exists(repo, branch):
|
|
116
|
+
return f"[not found] no branch '{branch}' — call list_goal_branches to see what exists."
|
|
117
|
+
|
|
118
|
+
status, _ = await _git(repo, "status", "--porcelain")
|
|
119
|
+
if status.strip():
|
|
120
|
+
return ("[refused] your working tree has uncommitted changes — commit or stash them "
|
|
121
|
+
"first, then merge again. Nothing was merged.")
|
|
122
|
+
target, _ = await _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
123
|
+
target = target.strip() or "the current branch"
|
|
124
|
+
|
|
125
|
+
out, rc = await _git(repo, "merge", "--no-ff", branch, "-m", f"Merge goal branch {branch}")
|
|
126
|
+
if rc == 0:
|
|
127
|
+
return (f"[merged] {branch} → {target}. Your files now have the changes. "
|
|
128
|
+
f"Tidy the leftover goal worktrees any time with: rockycode goal --clean "
|
|
129
|
+
f"(keeps the branches).")
|
|
130
|
+
await _git(repo, "merge", "--abort") # restore the repo byte-for-byte
|
|
131
|
+
return (f"[conflict] merging {branch} into {target} hit conflicts — I aborted it, so your "
|
|
132
|
+
f"repo is unchanged. Resolve manually if you want: git -C {repo} merge {branch}\n"
|
|
133
|
+
f"conflicting files:\n{out.strip()[:1500]}")
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"list_goal_branches": Tool(
|
|
137
|
+
name="list_goal_branches",
|
|
138
|
+
schema=_fn_schema("list_goal_branches",
|
|
139
|
+
"List the goal/* branches produced by past /goal runs, with how many "
|
|
140
|
+
"commits each is ahead. Read-only.", {}, []),
|
|
141
|
+
fn=list_goal_branches, risk="safe"),
|
|
142
|
+
"review_goal_branch": Tool(
|
|
143
|
+
name="review_goal_branch",
|
|
144
|
+
schema=_fn_schema("review_goal_branch",
|
|
145
|
+
"Review a goal branch before merging. Read-only. Returns an independent "
|
|
146
|
+
"reviewer's report with verified citations (the diff itself stays out of "
|
|
147
|
+
"this context); pass raw=true if you need the plain truncated diff.",
|
|
148
|
+
{"branch": {"type": "string", "description": "The goal branch, e.g. goal/20260706-2113."},
|
|
149
|
+
"raw": {"type": "boolean", "description": "Return the raw (truncated) diff instead of the delegated review."}},
|
|
150
|
+
["branch"]),
|
|
151
|
+
fn=review_goal_branch, risk="safe"),
|
|
152
|
+
"merge_goal_branch": Tool(
|
|
153
|
+
name="merge_goal_branch",
|
|
154
|
+
schema=_fn_schema("merge_goal_branch",
|
|
155
|
+
"Safely merge a goal branch into the current branch. Refuses a dirty tree "
|
|
156
|
+
"and aborts cleanly on conflict — never leaves a half-merged repo. Only "
|
|
157
|
+
"goal/… branches.",
|
|
158
|
+
{"branch": {"type": "string", "description": "The goal branch to merge, e.g. goal/20260706-2113."}},
|
|
159
|
+
["branch"]),
|
|
160
|
+
fn=merge_goal_branch, risk="risky"),
|
|
161
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Frontend-agnostic orchestration of ONE goal run.
|
|
2
|
+
|
|
3
|
+
The sequence — isolated workspace → plan → derive permits from the plan →
|
|
4
|
+
provision the sandbox → run the milestone loop → summarize — is identical whether
|
|
5
|
+
it's driven from a terminal or from the in-app GoalScreen. This module owns that
|
|
6
|
+
sequence behind a small seam (the methods GoalScreen calls), so the UI only has
|
|
7
|
+
to render and collect y/e/n + edit text; it never touches Docker, git, or the
|
|
8
|
+
models directly. That also makes the screen testable with a fake backend.
|
|
9
|
+
|
|
10
|
+
Planning happens BEFORE the sandbox exists (it's LLM-only), so network/permits are
|
|
11
|
+
decided from the REAL plan, not a guess at the user's wording — same rule as the
|
|
12
|
+
CLI. `LiveGoalBackend` is the real implementation; tests substitute their own.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time as _time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, List, Optional
|
|
20
|
+
|
|
21
|
+
from rockycode.engine.budget import GoalBudget
|
|
22
|
+
from rockycode.engine.safety import Verdict, network_intent, pre_scan
|
|
23
|
+
from rockycode.engine.worktree import GoalWorkspace
|
|
24
|
+
from rockycode.pricing import UsageLedger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Permits:
|
|
29
|
+
"""What a plan will need, derived from the planner's REQUIRES line + a scan
|
|
30
|
+
backstop over the milestones. `blocked` set → the plan must not run."""
|
|
31
|
+
use_network: bool
|
|
32
|
+
net_reason: str
|
|
33
|
+
asks: List[Verdict]
|
|
34
|
+
approved: set
|
|
35
|
+
blocked: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def needs_notice(self) -> bool:
|
|
39
|
+
return bool(self.use_network or self.asks or self.net_reason)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GoalSummary:
|
|
44
|
+
status: str # "done" | "budget" | "stalled" | "aborted" | "cancelled" | "error"
|
|
45
|
+
reason: str
|
|
46
|
+
milestones_done: int = 0
|
|
47
|
+
milestones_total: int = 0
|
|
48
|
+
branch: str = ""
|
|
49
|
+
origin: str = ""
|
|
50
|
+
workspace: str = ""
|
|
51
|
+
log: str = ""
|
|
52
|
+
currency: str = "usd"
|
|
53
|
+
spend: float = 0.0
|
|
54
|
+
base: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def review_cmd(self) -> str:
|
|
58
|
+
if not self.branch:
|
|
59
|
+
return ""
|
|
60
|
+
return f"git -C {self.origin} diff {self.base or 'HEAD'}..{self.branch}"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def tidy_cmd(self) -> str:
|
|
64
|
+
if not self.branch:
|
|
65
|
+
return ""
|
|
66
|
+
return f"git -C {self.origin} worktree remove {self.workspace}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LiveGoalBackend:
|
|
70
|
+
"""The real backend: git worktree isolation + Docker sandbox + EngineDriver.
|
|
71
|
+
|
|
72
|
+
Lifecycle the screen drives: setup() → plan() → [discuss()…] → run() → done.
|
|
73
|
+
cleanup() is idempotent and safe to call on any exit path."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
objective: str,
|
|
78
|
+
context: str = "",
|
|
79
|
+
*,
|
|
80
|
+
model: str,
|
|
81
|
+
reviewer_model: str,
|
|
82
|
+
budget: GoalBudget,
|
|
83
|
+
workdir: Path,
|
|
84
|
+
currency: str = "usd",
|
|
85
|
+
network: Optional[bool] = None,
|
|
86
|
+
review_every: int = 3,
|
|
87
|
+
plan_file: Optional[Path] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.objective = objective
|
|
90
|
+
self.context = context
|
|
91
|
+
# When set, plan() reads THIS already-approved plan file (from chat /plan)
|
|
92
|
+
# instead of calling the LLM planner — the handoff skips re-planning.
|
|
93
|
+
self.plan_file = Path(plan_file) if plan_file else None
|
|
94
|
+
self.model = model
|
|
95
|
+
self.reviewer_model = reviewer_model
|
|
96
|
+
self.budget = budget
|
|
97
|
+
self.workdir = Path(workdir).resolve()
|
|
98
|
+
self.currency = currency
|
|
99
|
+
self.network = network # None → decide from the plan; True/False → forced
|
|
100
|
+
self.review_every = review_every
|
|
101
|
+
|
|
102
|
+
self.ledger = UsageLedger()
|
|
103
|
+
self.ws: Optional[GoalWorkspace] = None
|
|
104
|
+
self.driver = None
|
|
105
|
+
self._client = None
|
|
106
|
+
self._log_path: Optional[Path] = None
|
|
107
|
+
self._sandbox = None
|
|
108
|
+
|
|
109
|
+
# ---- lifecycle ----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
async def setup(self) -> str:
|
|
112
|
+
"""Create the isolated workspace + the driver (no sandbox yet). Returns a
|
|
113
|
+
one-line description of where the work will happen."""
|
|
114
|
+
from openai import AsyncOpenAI
|
|
115
|
+
|
|
116
|
+
from rockycode.engine.goal import EngineDriver
|
|
117
|
+
from rockycode.onboarding import require_base_url, require_key
|
|
118
|
+
|
|
119
|
+
slug = _time.strftime("%Y%m%d-%H%M%S")
|
|
120
|
+
self.ws = GoalWorkspace.create(self.workdir, slug)
|
|
121
|
+
log_dir = Path.home() / ".rockycode" / "goal-logs"
|
|
122
|
+
try:
|
|
123
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
self._log_path = log_dir / f"{slug}.log"
|
|
125
|
+
except OSError:
|
|
126
|
+
self._log_path = None
|
|
127
|
+
self.log(f"# goal: {self.objective}\n# {slug} workspace={self.ws.path} branch={self.ws.branch}")
|
|
128
|
+
|
|
129
|
+
# Explicit key AND endpoint, never the SDK's ambient env fallbacks.
|
|
130
|
+
self._client = AsyncOpenAI(api_key=require_key(), base_url=require_base_url(),
|
|
131
|
+
max_retries=5, timeout=300.0)
|
|
132
|
+
from rockycode.engine.explore import make_goal_verifier
|
|
133
|
+
self.driver = EngineDriver(
|
|
134
|
+
client=self._client, model=self.model, reviewer_model=self.reviewer_model,
|
|
135
|
+
workspace=self.ws, ledger=self.ledger, currency=self.currency,
|
|
136
|
+
verifier=make_goal_verifier(client=self._client, model=self.model,
|
|
137
|
+
workdir=self.ws.path, ledger=self.ledger),
|
|
138
|
+
)
|
|
139
|
+
where = str(self.ws.path) + (f" · branch {self.ws.branch}" if self.ws.branch else " (copy)")
|
|
140
|
+
return where
|
|
141
|
+
|
|
142
|
+
async def plan(self) -> tuple[list[str], str]:
|
|
143
|
+
# Handoff from chat /plan: the plan is already drafted + approved. Read it
|
|
144
|
+
# (shape-agnostic) and turn each phase into a milestone — no LLM re-plan.
|
|
145
|
+
if self.plan_file is not None:
|
|
146
|
+
from rockycode.engine.planmode import parse_plan_file
|
|
147
|
+
try:
|
|
148
|
+
text = self.plan_file.read_text(errors="replace")
|
|
149
|
+
except OSError:
|
|
150
|
+
text = ""
|
|
151
|
+
phases = parse_plan_file(text)
|
|
152
|
+
milestones = [
|
|
153
|
+
(title + ("\n" + "\n".join(f"- {s}" for s in steps) if steps else ""))
|
|
154
|
+
for title, steps in phases
|
|
155
|
+
]
|
|
156
|
+
# Pass the FULL plan text as the requires-scan supplement so permits()
|
|
157
|
+
# catches an install/network mention wherever it sits (a REQUIRES line,
|
|
158
|
+
# or inside a step like "pip install requests"), not just in milestones.
|
|
159
|
+
return milestones[:12], text
|
|
160
|
+
plan_input = self.objective if not self.context else (
|
|
161
|
+
f"{self.objective}\n\n[Context from the chat that led here — use it to inform "
|
|
162
|
+
f"the plan; the objective above is the goal]:\n{self.context}")
|
|
163
|
+
plan, requires = await self.driver.plan(plan_input)
|
|
164
|
+
return plan, requires
|
|
165
|
+
|
|
166
|
+
def permits(self, plan: list[str], requires: str) -> Permits:
|
|
167
|
+
scan_text = "\n".join(plan) + (("\n" + requires) if requires else "")
|
|
168
|
+
flags = pre_scan(scan_text)
|
|
169
|
+
blocked = next((v.reason for v in flags if v.action == "block"), None)
|
|
170
|
+
asks = [v for v in flags if v.action == "ask"]
|
|
171
|
+
net_reason = network_intent(requires) or network_intent(scan_text)
|
|
172
|
+
use_network = self.network if self.network is not None else bool(net_reason)
|
|
173
|
+
return Permits(
|
|
174
|
+
use_network=bool(use_network), net_reason=net_reason or "",
|
|
175
|
+
asks=asks, approved={v.pattern for v in asks}, blocked=blocked,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def discuss(self, plan: list[str], requires: str, msg: str) -> tuple[str, list[str], str]:
|
|
179
|
+
"""Answer a question about the plan and return (reply, plan, requires) —
|
|
180
|
+
the plan may be revised. Errors become a short reply, never a crash."""
|
|
181
|
+
try:
|
|
182
|
+
return await self.driver.discuss(self.objective, plan, requires, msg)
|
|
183
|
+
except Exception as e: # noqa: BLE001
|
|
184
|
+
return (f"(couldn't reason about that — {type(e).__name__})", plan, requires)
|
|
185
|
+
|
|
186
|
+
async def run(
|
|
187
|
+
self, plan: list[str], permits: Permits, on_event: Callable[[str], None]
|
|
188
|
+
) -> GoalSummary:
|
|
189
|
+
"""Provision the sandbox with the permit decision, attach the engine, and
|
|
190
|
+
run the milestone loop. Always stops the sandbox; never raises for a normal
|
|
191
|
+
run failure (returns an 'error' summary instead)."""
|
|
192
|
+
from rockycode.engine.goal import GoalRunner, safe_bash_tool
|
|
193
|
+
from rockycode.engine.loop import Engine
|
|
194
|
+
from rockycode.engine.sandbox import ChatSandbox, build_sandbox_registry
|
|
195
|
+
|
|
196
|
+
self.log("plan:\n" + "\n".join(f" {i}. {m}" for i, m in enumerate(plan, 1)))
|
|
197
|
+
self.budget.start()
|
|
198
|
+
|
|
199
|
+
def emit(m: str) -> None:
|
|
200
|
+
on_event(m)
|
|
201
|
+
self.log(m)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
self._sandbox = await ChatSandbox.start(self.ws.path, network=permits.use_network)
|
|
205
|
+
except Exception as e: # noqa: BLE001
|
|
206
|
+
return self._summary("error", f"sandbox (Docker) failed to start — {e}")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
reg = build_sandbox_registry(self._sandbox)
|
|
210
|
+
reg["bash"] = safe_bash_tool(self._sandbox, permits.approved)
|
|
211
|
+
engine = Engine(model=self.model, client=self._client, workdir=self.ws.path,
|
|
212
|
+
registry=reg, trajectory_meta={"goal": self.objective, "runner": "goal"})
|
|
213
|
+
self.driver.attach(engine, network=permits.use_network)
|
|
214
|
+
runner = GoalRunner(self.objective, self.driver, self.budget, self.ws, self.ledger,
|
|
215
|
+
review_every=self.review_every, on_event=emit, preplanned=plan)
|
|
216
|
+
result = await runner.run()
|
|
217
|
+
summary = self._summary(result.status, result.reason,
|
|
218
|
+
result.milestones_done, result.milestones_total)
|
|
219
|
+
self.log(f"result: {result.status} — {result.reason} "
|
|
220
|
+
f"({result.milestones_done}/{result.milestones_total} milestones)")
|
|
221
|
+
return summary
|
|
222
|
+
except Exception as e: # noqa: BLE001
|
|
223
|
+
return self._summary("error", f"{type(e).__name__}: {e}")
|
|
224
|
+
finally:
|
|
225
|
+
try:
|
|
226
|
+
await self._sandbox.stop()
|
|
227
|
+
except Exception: # noqa: BLE001
|
|
228
|
+
pass
|
|
229
|
+
self._sandbox = None
|
|
230
|
+
|
|
231
|
+
async def cleanup(self, keep: bool) -> None:
|
|
232
|
+
"""Tidy the workspace. keep=True leaves the worktree + branch to review;
|
|
233
|
+
keep=False removes an unstarted run (cancel / plan error)."""
|
|
234
|
+
if self.ws is not None:
|
|
235
|
+
try:
|
|
236
|
+
self.ws.cleanup(keep=keep)
|
|
237
|
+
except Exception: # noqa: BLE001
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# ---- helpers ------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _summary(self, status: str, reason: str, done: int = 0, total: int = 0) -> GoalSummary:
|
|
243
|
+
ws = self.ws
|
|
244
|
+
return GoalSummary(
|
|
245
|
+
status=status, reason=reason, milestones_done=done, milestones_total=total,
|
|
246
|
+
branch=(ws.branch if ws else ""), origin=(str(ws.origin) if ws else ""),
|
|
247
|
+
workspace=(str(ws.path) if ws else ""), log=(str(self._log_path) if self._log_path else ""),
|
|
248
|
+
currency=self.currency, spend=self.ledger.cost(self.currency),
|
|
249
|
+
base=(getattr(ws, "base", "") or "" if ws else ""),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def log(self, msg: str) -> None:
|
|
253
|
+
if self._log_path is None:
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
with self._log_path.open("a", encoding="utf-8") as f:
|
|
257
|
+
f.write(msg + "\n")
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|