piocloop 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.
- piocloop/__init__.py +1 -0
- piocloop/__main__.py +3 -0
- piocloop/cli.py +230 -0
- piocloop/loop.py +487 -0
- piocloop/pi_client.py +441 -0
- piocloop/pi_events.py +326 -0
- piocloop/plan_parser.py +140 -0
- piocloop/tui.py +289 -0
- piocloop-0.1.0.dist-info/METADATA +191 -0
- piocloop-0.1.0.dist-info/RECORD +13 -0
- piocloop-0.1.0.dist-info/WHEEL +4 -0
- piocloop-0.1.0.dist-info/entry_points.txt +2 -0
- piocloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piocloop/pi_events.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Map raw PI RPC events onto the small vocabulary the TUI cares about.
|
|
2
|
+
|
|
3
|
+
Kept free of Textual imports so it can be unit-tested as pure data-in/data-out.
|
|
4
|
+
`tui.py` turns these dataclasses into Textual messages.
|
|
5
|
+
|
|
6
|
+
Wire-format facts this module relies on (verified against pi 0.82.1, not assumed):
|
|
7
|
+
|
|
8
|
+
* `tool_execution_start` carries `args`; `tool_execution_end` does **not**
|
|
9
|
+
(`args` is null there), so paths must be correlated by `toolCallId`.
|
|
10
|
+
* Built-in tools are `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, and
|
|
11
|
+
file-taking tools use the key `path` with an absolute value.
|
|
12
|
+
* Token usage lives on the message in `message_end`; `turn_end` repeats the same
|
|
13
|
+
message, so usage is counted from `message_end` only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from .pi_client import (
|
|
24
|
+
EVENT_DIALOG_ANSWERED,
|
|
25
|
+
EVENT_PROCESS_EXITED,
|
|
26
|
+
EVENT_PROTOCOL_ERROR,
|
|
27
|
+
EVENT_STDERR,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Tools whose `path` argument means "this file was modified".
|
|
31
|
+
WRITE_TOOLS = frozenset({"edit", "write"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Event vocabulary
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LoopEvent:
|
|
41
|
+
"""Base class for everything the TUI consumes."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Settled(LoopEvent):
|
|
46
|
+
"""The iteration is fully finished.
|
|
47
|
+
|
|
48
|
+
Emitted for `agent_settled` only — never for `agent_end`, which may be
|
|
49
|
+
followed by an automatic retry, compaction, or a queued continuation.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Started(LoopEvent):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ToolStarted(LoopEvent):
|
|
60
|
+
tool: str
|
|
61
|
+
detail: str = ""
|
|
62
|
+
path: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ToolEnded(LoopEvent):
|
|
67
|
+
tool: str
|
|
68
|
+
path: Optional[str] = None
|
|
69
|
+
is_error: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PlanTouched(LoopEvent):
|
|
74
|
+
"""A write/edit tool wrote to the plan file — reload it."""
|
|
75
|
+
|
|
76
|
+
path: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class AssistantText(LoopEvent):
|
|
81
|
+
text: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Thinking(LoopEvent):
|
|
86
|
+
text: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Usage(LoopEvent):
|
|
91
|
+
input_tokens: int = 0
|
|
92
|
+
output_tokens: int = 0
|
|
93
|
+
cost: float = 0.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Notice(LoopEvent):
|
|
98
|
+
"""Something worth showing in the activity log.
|
|
99
|
+
|
|
100
|
+
`kind` is one of: retry, compaction, status, dialog, extension, stderr,
|
|
101
|
+
protocol, error.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
kind: str
|
|
105
|
+
text: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ProcessExited(LoopEvent):
|
|
110
|
+
returncode: Optional[int]
|
|
111
|
+
stderr_tail: tuple[str, ...] = ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Mapper
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EventMapper:
|
|
120
|
+
"""Stateful translator: raw event dict in, zero or more LoopEvents out.
|
|
121
|
+
|
|
122
|
+
State is needed for two things: correlating tool paths across
|
|
123
|
+
start/end, and coalescing streaming text deltas so a token-per-event stream
|
|
124
|
+
cannot flood the TUI.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
plan_file: Optional[Path] = None,
|
|
130
|
+
*,
|
|
131
|
+
text_interval: float = 0.5,
|
|
132
|
+
clock: Callable[[], float] = time.monotonic,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._plan_file = plan_file.resolve() if plan_file else None
|
|
135
|
+
self._text_interval = text_interval
|
|
136
|
+
self._clock = clock
|
|
137
|
+
self._tool_paths: dict[str, tuple[str, Optional[str]]] = {}
|
|
138
|
+
self._text_buf: list[str] = []
|
|
139
|
+
self._think_buf: list[str] = []
|
|
140
|
+
# Seed from the clock, not 0.0: otherwise the very first delta is
|
|
141
|
+
# always "overdue" and gets emitted on its own, defeating coalescing.
|
|
142
|
+
self._last_text_flush = clock()
|
|
143
|
+
|
|
144
|
+
# -- helpers -------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _is_plan(self, path: Optional[str]) -> bool:
|
|
147
|
+
if not path or self._plan_file is None:
|
|
148
|
+
return False
|
|
149
|
+
try:
|
|
150
|
+
return Path(path).resolve() == self._plan_file
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def _flush_text(self, force: bool = False) -> list[LoopEvent]:
|
|
155
|
+
if not self._text_buf:
|
|
156
|
+
return []
|
|
157
|
+
now = self._clock()
|
|
158
|
+
if not force and (now - self._last_text_flush) < self._text_interval:
|
|
159
|
+
return []
|
|
160
|
+
text = "".join(self._text_buf).strip()
|
|
161
|
+
self._text_buf.clear()
|
|
162
|
+
self._last_text_flush = now
|
|
163
|
+
return [AssistantText(text)] if text else []
|
|
164
|
+
|
|
165
|
+
def _flush_thinking(self) -> list[LoopEvent]:
|
|
166
|
+
if not self._think_buf:
|
|
167
|
+
return []
|
|
168
|
+
text = "".join(self._think_buf).strip()
|
|
169
|
+
self._think_buf.clear()
|
|
170
|
+
return [Thinking(text)] if text else []
|
|
171
|
+
|
|
172
|
+
# -- main entry point ----------------------------------------------
|
|
173
|
+
|
|
174
|
+
def map(self, raw: dict) -> list[LoopEvent]:
|
|
175
|
+
etype = raw.get("type", "")
|
|
176
|
+
handler = getattr(self, f"_on_{etype}", None)
|
|
177
|
+
if handler is not None:
|
|
178
|
+
return handler(raw)
|
|
179
|
+
return self._on_other(etype, raw)
|
|
180
|
+
|
|
181
|
+
# -- lifecycle ------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _on_agent_start(self, raw: dict) -> list[LoopEvent]:
|
|
184
|
+
return [Started()]
|
|
185
|
+
|
|
186
|
+
def _on_agent_settled(self, raw: dict) -> list[LoopEvent]:
|
|
187
|
+
# Flush anything buffered so the log is complete before the iteration ends.
|
|
188
|
+
return [*self._flush_text(force=True), Settled()]
|
|
189
|
+
|
|
190
|
+
def _on_agent_end(self, raw: dict) -> list[LoopEvent]:
|
|
191
|
+
if raw.get("willRetry"):
|
|
192
|
+
return [Notice("retry", "agent run failed — retrying automatically")]
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
# -- tools ----------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _on_tool_execution_start(self, raw: dict) -> list[LoopEvent]:
|
|
198
|
+
tool = raw.get("toolName", "?")
|
|
199
|
+
args = raw.get("args") or {}
|
|
200
|
+
path = args.get("path") if isinstance(args, dict) else None
|
|
201
|
+
call_id = raw.get("toolCallId")
|
|
202
|
+
if call_id:
|
|
203
|
+
self._tool_paths[call_id] = (tool, path)
|
|
204
|
+
return [*self._flush_text(force=True), ToolStarted(tool, _tool_detail(tool, args), path)]
|
|
205
|
+
|
|
206
|
+
def _on_tool_execution_end(self, raw: dict) -> list[LoopEvent]:
|
|
207
|
+
call_id = raw.get("toolCallId")
|
|
208
|
+
tool = raw.get("toolName") or "?"
|
|
209
|
+
path = None
|
|
210
|
+
if call_id and call_id in self._tool_paths:
|
|
211
|
+
remembered_tool, path = self._tool_paths.pop(call_id)
|
|
212
|
+
tool = raw.get("toolName") or remembered_tool
|
|
213
|
+
|
|
214
|
+
is_error = bool(raw.get("isError"))
|
|
215
|
+
events: list[LoopEvent] = [ToolEnded(tool, path, is_error)]
|
|
216
|
+
# Only a successful write/edit means the plan actually changed.
|
|
217
|
+
if not is_error and tool in WRITE_TOOLS and self._is_plan(path):
|
|
218
|
+
events.append(PlanTouched(str(path)))
|
|
219
|
+
return events
|
|
220
|
+
|
|
221
|
+
# -- streaming ------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _on_message_update(self, raw: dict) -> list[LoopEvent]:
|
|
224
|
+
delta = raw.get("assistantMessageEvent") or {}
|
|
225
|
+
dtype = delta.get("type")
|
|
226
|
+
|
|
227
|
+
if dtype == "text_delta":
|
|
228
|
+
self._text_buf.append(delta.get("delta", ""))
|
|
229
|
+
return self._flush_text()
|
|
230
|
+
if dtype == "text_end":
|
|
231
|
+
return self._flush_text(force=True)
|
|
232
|
+
if dtype == "thinking_delta":
|
|
233
|
+
self._think_buf.append(delta.get("delta", ""))
|
|
234
|
+
return []
|
|
235
|
+
if dtype == "thinking_end":
|
|
236
|
+
return self._flush_thinking()
|
|
237
|
+
if dtype == "error":
|
|
238
|
+
reason = delta.get("reason", "error")
|
|
239
|
+
if reason == "aborted":
|
|
240
|
+
return [Notice("error", "generation aborted")]
|
|
241
|
+
return [Notice("error", f"generation error: {reason}")]
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
def _on_message_end(self, raw: dict) -> list[LoopEvent]:
|
|
245
|
+
message = raw.get("message") or {}
|
|
246
|
+
usage = message.get("usage") or {}
|
|
247
|
+
if not usage:
|
|
248
|
+
return []
|
|
249
|
+
cost = usage.get("cost") or {}
|
|
250
|
+
return [
|
|
251
|
+
Usage(
|
|
252
|
+
input_tokens=int(usage.get("input") or 0),
|
|
253
|
+
output_tokens=int(usage.get("output") or 0),
|
|
254
|
+
cost=float(cost.get("total") or 0.0),
|
|
255
|
+
)
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
# -- notices --------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _on_auto_retry_start(self, raw: dict) -> list[LoopEvent]:
|
|
261
|
+
attempt = raw.get("attempt")
|
|
262
|
+
suffix = f" (attempt {attempt})" if attempt is not None else ""
|
|
263
|
+
return [Notice("retry", f"auto-retry after transient error{suffix}")]
|
|
264
|
+
|
|
265
|
+
def _on_auto_retry_end(self, raw: dict) -> list[LoopEvent]:
|
|
266
|
+
return [Notice("retry", "auto-retry finished")]
|
|
267
|
+
|
|
268
|
+
def _on_compaction_start(self, raw: dict) -> list[LoopEvent]:
|
|
269
|
+
return [Notice("compaction", "compacting context — this can take a while")]
|
|
270
|
+
|
|
271
|
+
def _on_compaction_end(self, raw: dict) -> list[LoopEvent]:
|
|
272
|
+
return [Notice("compaction", "compaction finished")]
|
|
273
|
+
|
|
274
|
+
def _on_extension_error(self, raw: dict) -> list[LoopEvent]:
|
|
275
|
+
return [Notice("extension", str(raw.get("error") or "extension error"))]
|
|
276
|
+
|
|
277
|
+
def _on_extension_ui_info(self, raw: dict) -> list[LoopEvent]:
|
|
278
|
+
text = raw.get("text") or ""
|
|
279
|
+
if not text:
|
|
280
|
+
return []
|
|
281
|
+
return [Notice("status", f"{raw.get('method', 'ui')}: {text}")]
|
|
282
|
+
|
|
283
|
+
def _on_queue_update(self, raw: dict) -> list[LoopEvent]:
|
|
284
|
+
pending = len(raw.get("steering") or []) + len(raw.get("followUp") or [])
|
|
285
|
+
if not pending:
|
|
286
|
+
return []
|
|
287
|
+
return [Notice("status", f"{pending} queued message(s)")]
|
|
288
|
+
|
|
289
|
+
# -- synthetic (from PiClient) --------------------------------------
|
|
290
|
+
|
|
291
|
+
def _on_other(self, etype: str, raw: dict) -> list[LoopEvent]:
|
|
292
|
+
if etype == EVENT_STDERR:
|
|
293
|
+
return [Notice("stderr", raw.get("text", ""))]
|
|
294
|
+
if etype == EVENT_PROTOCOL_ERROR:
|
|
295
|
+
return [Notice("protocol", raw.get("error", "protocol error"))]
|
|
296
|
+
if etype == EVENT_DIALOG_ANSWERED:
|
|
297
|
+
title = raw.get("title") or raw.get("method")
|
|
298
|
+
return [
|
|
299
|
+
Notice(
|
|
300
|
+
"dialog",
|
|
301
|
+
f"auto-answered {raw.get('method')} dialog "
|
|
302
|
+
f"({raw.get('policy')}): {title}",
|
|
303
|
+
)
|
|
304
|
+
]
|
|
305
|
+
if etype == EVENT_PROCESS_EXITED:
|
|
306
|
+
return [
|
|
307
|
+
ProcessExited(
|
|
308
|
+
raw.get("returncode"),
|
|
309
|
+
tuple(raw.get("stderr_tail") or ()),
|
|
310
|
+
)
|
|
311
|
+
]
|
|
312
|
+
if etype.startswith("summarization_retry"):
|
|
313
|
+
return [Notice("retry", etype.replace("_", " "))]
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _tool_detail(tool: str, args: Any) -> str:
|
|
318
|
+
"""A short one-line description of a tool call for the activity log."""
|
|
319
|
+
if not isinstance(args, dict):
|
|
320
|
+
return ""
|
|
321
|
+
if tool == "bash":
|
|
322
|
+
return str(args.get("command", ""))[:120]
|
|
323
|
+
for key in ("path", "pattern", "query"):
|
|
324
|
+
if key in args:
|
|
325
|
+
return str(args[key])[:120]
|
|
326
|
+
return ""
|
piocloop/plan_parser.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""PLAN.md parsing.
|
|
2
|
+
|
|
3
|
+
Agent-agnostic: this module knows nothing about PI or OpenCode. Ported verbatim
|
|
4
|
+
from pyocloop, which in turn ported it from OCLoop's src/lib/plan-parser.ts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PlanProgress:
|
|
16
|
+
total: int
|
|
17
|
+
completed: int
|
|
18
|
+
pending: int
|
|
19
|
+
manual: int
|
|
20
|
+
blocked: int
|
|
21
|
+
percent_complete: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TaskLine:
|
|
26
|
+
type: str # "completed" | "pending" | "manual" | "blocked" | "not-a-task"
|
|
27
|
+
description: str
|
|
28
|
+
blocked_reason: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_task_line(line: str) -> TaskLine:
|
|
32
|
+
trimmed = line.strip()
|
|
33
|
+
|
|
34
|
+
if not trimmed.startswith("- ["):
|
|
35
|
+
return TaskLine("not-a-task", "")
|
|
36
|
+
|
|
37
|
+
close = trimmed.find("]", 3)
|
|
38
|
+
if close == -1:
|
|
39
|
+
return TaskLine("not-a-task", "")
|
|
40
|
+
|
|
41
|
+
checkbox = trimmed[3:close].strip()
|
|
42
|
+
after = trimmed[close + 1:].strip()
|
|
43
|
+
|
|
44
|
+
if re.fullmatch(r"[xX]", checkbox):
|
|
45
|
+
return TaskLine("completed", after)
|
|
46
|
+
|
|
47
|
+
if re.fullmatch(r"MANUAL", checkbox, re.IGNORECASE):
|
|
48
|
+
return TaskLine("manual", after)
|
|
49
|
+
|
|
50
|
+
if checkbox == "" and after.upper().startswith("[MANUAL]"):
|
|
51
|
+
desc = re.sub(r"^\[MANUAL\]\s*", "", after, flags=re.IGNORECASE)
|
|
52
|
+
return TaskLine("manual", desc)
|
|
53
|
+
|
|
54
|
+
if re.match(r"^BLOCKED", checkbox, re.IGNORECASE):
|
|
55
|
+
reason = re.sub(r"^BLOCKED[:\s]*", "", checkbox, flags=re.IGNORECASE)
|
|
56
|
+
return TaskLine("blocked", after, reason)
|
|
57
|
+
|
|
58
|
+
if checkbox == "" and re.match(r"^\[BLOCKED", after, re.IGNORECASE):
|
|
59
|
+
m = re.match(r"^\[BLOCKED[:\s]*([^\]]*)\]\s*(.*)", after, re.IGNORECASE)
|
|
60
|
+
if m:
|
|
61
|
+
return TaskLine("blocked", m.group(2) or "", (m.group(1) or "").strip())
|
|
62
|
+
|
|
63
|
+
if checkbox == "":
|
|
64
|
+
return TaskLine("pending", after)
|
|
65
|
+
|
|
66
|
+
return TaskLine("not-a-task", "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_plan(content: str) -> PlanProgress:
|
|
70
|
+
total = completed = manual = blocked = 0
|
|
71
|
+
|
|
72
|
+
for line in content.splitlines():
|
|
73
|
+
task = parse_task_line(line)
|
|
74
|
+
if task.type == "not-a-task":
|
|
75
|
+
continue
|
|
76
|
+
total += 1
|
|
77
|
+
if task.type == "completed":
|
|
78
|
+
completed += 1
|
|
79
|
+
elif task.type == "manual":
|
|
80
|
+
manual += 1
|
|
81
|
+
elif task.type == "blocked":
|
|
82
|
+
blocked += 1
|
|
83
|
+
|
|
84
|
+
pending = total - completed - manual - blocked
|
|
85
|
+
denominator = total - manual
|
|
86
|
+
percent = round((completed / denominator) * 100) if denominator > 0 else 100
|
|
87
|
+
|
|
88
|
+
return PlanProgress(
|
|
89
|
+
total=total,
|
|
90
|
+
completed=completed,
|
|
91
|
+
pending=pending,
|
|
92
|
+
manual=manual,
|
|
93
|
+
blocked=blocked,
|
|
94
|
+
percent_complete=percent,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_plan_complete(content: str) -> str | None:
|
|
99
|
+
matches = list(re.finditer(
|
|
100
|
+
r"^<plan-complete>([\s\S]*?)<\/plan-complete>",
|
|
101
|
+
content,
|
|
102
|
+
re.MULTILINE,
|
|
103
|
+
))
|
|
104
|
+
if not matches:
|
|
105
|
+
return None
|
|
106
|
+
return matches[-1].group(1).strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_current_task(content: str) -> str | None:
|
|
110
|
+
for line in content.splitlines():
|
|
111
|
+
task = parse_task_line(line)
|
|
112
|
+
if task.type == "pending" and task.description:
|
|
113
|
+
return task.description
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_plan_complete(plan_path: Path) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
content = plan_path.read_text(encoding="utf-8")
|
|
120
|
+
return parse_plan_complete(content) is not None
|
|
121
|
+
except OSError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_plan_progress(plan_path: Path) -> PlanProgress:
|
|
126
|
+
content = plan_path.read_text(encoding="utf-8")
|
|
127
|
+
return parse_plan(content)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_current_task(plan_path: Path) -> str | None:
|
|
131
|
+
content = plan_path.read_text(encoding="utf-8")
|
|
132
|
+
return get_current_task(content)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_plan_complete_summary(plan_path: Path) -> str | None:
|
|
136
|
+
try:
|
|
137
|
+
content = plan_path.read_text(encoding="utf-8")
|
|
138
|
+
return parse_plan_complete(content)
|
|
139
|
+
except OSError:
|
|
140
|
+
return None
|