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.
Files changed (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. 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