task-harbor 0.7.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.
opus_manager/tui.py ADDED
@@ -0,0 +1,388 @@
1
+ """Terminal dashboard with explicit project and run controls."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import asyncio
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from rich.text import Text
11
+ from textual.app import App, ComposeResult
12
+ from textual.screen import ModalScreen
13
+ from textual.containers import Horizontal, VerticalScroll
14
+ from textual.widgets import Button, DataTable, Footer, Select, Static, ProgressBar
15
+
16
+ from .config import Config
17
+ from .reporting import run_report
18
+ from .help import GUIDE
19
+ from .desktop_actions import launch_run
20
+ from .reporting import review_text
21
+
22
+
23
+ class ConfirmRun(ModalScreen):
24
+ BINDINGS = [("escape", "cancel", "Cancel")]
25
+ DEFAULT_CSS = """
26
+ ConfirmRun { align: center middle; background: #000000 70%; }
27
+ #confirm-panel { width: 80%; height: auto; max-height: 90%; border: round #66e3c4; padding: 1 2; background: #111e2c; }
28
+ #confirm-text { height: auto; margin-bottom: 1; }
29
+ """
30
+
31
+ def __init__(self, description: str):
32
+ super().__init__()
33
+ self.description = description
34
+
35
+ def compose(self) -> ComposeResult:
36
+ with VerticalScroll(id="confirm-panel"):
37
+ yield Static(self.description, id="confirm-text", markup=False)
38
+ yield Button("Start agent work", id="confirm-start", variant="success")
39
+ yield Button("Cancel", id="cancel-start")
40
+
41
+ def action_cancel(self) -> None:
42
+ self.dismiss(False)
43
+
44
+ def on_button_pressed(self, event: Button.Pressed) -> None:
45
+ self.dismiss(event.button.id == "confirm-start")
46
+
47
+
48
+ class HelpScreen(ModalScreen):
49
+ BINDINGS = [("escape", "dismiss", "Close"), ("f1", "dismiss", "Close")]
50
+ DEFAULT_CSS = """
51
+ HelpScreen { align: center middle; background: #000000 70%; }
52
+ #help-panel { width: 85%; height: 90%; border: round #66e3c4; padding: 1 2; background: #111e2c; }
53
+ #help-text { height: auto; }
54
+ """
55
+
56
+ def __init__(self, content: str = GUIDE):
57
+ super().__init__()
58
+ self.content = content
59
+
60
+ def compose(self) -> ComposeResult:
61
+ with VerticalScroll(id="help-panel"):
62
+ yield Button("Close (Esc)", id="close-help", variant="primary")
63
+ yield Static(self.content, id="help-text", markup=False)
64
+
65
+ def on_button_pressed(self) -> None:
66
+ self.dismiss()
67
+
68
+
69
+ def runs_directory(repo: Path) -> Path:
70
+ # Config.load migrates files; monitoring must remain read-only.
71
+ path = repo / ".manager" / "config.json"
72
+ raw = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
73
+ return repo / Config(manager_dir=raw.get("manager_dir", ".manager")).manager_dir / "runs"
74
+
75
+
76
+ def event_tail(path: Path) -> str:
77
+ """Bound reads even for runs with large event logs; ignore partial last writes."""
78
+ if not path.exists():
79
+ return "No events recorded yet."
80
+ with path.open("rb") as stream:
81
+ stream.seek(0, 2)
82
+ start = max(0, stream.tell() - 16384)
83
+ stream.seek(start)
84
+ lines = stream.read().decode("utf-8", errors="replace").splitlines()
85
+ if start:
86
+ lines = lines[1:]
87
+ events = []
88
+ for line in lines:
89
+ try:
90
+ item = json.loads(line)
91
+ events.append(f'{str(item.get("time", ""))[11:19]} {item.get("kind", "event")} {item.get("task_id", "")}')
92
+ except (ValueError, AttributeError):
93
+ continue
94
+ return "\n".join(events[-8:]) or "No events recorded yet."
95
+
96
+
97
+ class HarborApp(App):
98
+ TITLE = "Task Harbor"
99
+ BINDINGS = [("f1", "help", "Help"), ("e", "evidence", "Evidence"), ("p", "preflight", "Preflight"), ("q", "quit", "Quit"), ("r", "reload", "Refresh"), ("tab", "focus_next", "Next pane")]
100
+ CSS = """
101
+ Screen { background: #0b111b; color: #d9e4ef; }
102
+ #brand { height: 5; padding: 1 2; background: #111e2c; color: #66e3c4; text-style: bold; }
103
+ #workspace { height: 1; margin: 0 2; color: #8295aa; }
104
+ #actions { height: 3; margin: 1 2 0 2; }
105
+ #actions Button { min-width: 10; width: 1fr; margin-right: 1; }
106
+ #action-result { height: auto; max-height: 5; margin: 0 2; color: #f2c777; }
107
+ #welcome { height: auto; padding: 1 2; margin: 1 2; border: round #66e3c4; background: #111e2c; }
108
+ #completion { height: 1; margin: 0 2; }
109
+ #run-picker { background: #111e2c; }
110
+ #run-picker { margin: 1 2 0 2; }
111
+ #summary { height: 5; margin: 0 2; }
112
+ .metric { width: 1fr; border: round #294158; padding: 0 1; margin-right: 1; }
113
+ #body { height: 1fr; margin: 0 2; }
114
+ #tasks { width: 2fr; height: 1fr; border: round #294158; background: #101b29; }
115
+ #tasks, #details-pane, #events { border-title-color: #66e3c4; }
116
+ #details-pane { width: 1fr; min-width: 25; border: round #294158; margin-left: 1; padding: 0 1; }
117
+ #details { height: auto; }
118
+ #events { height: 11; margin: 0 2; padding: 0 1; border: round #294158; color: #8295aa; }
119
+ #notice { height: auto; max-height: 4; margin: 0 2; color: #f2c777; }
120
+ DataTable > .datatable--cursor { background: #214c52; color: #ffffff; }
121
+ DataTable > .datatable--header { background: #152638; color: #66e3c4; }
122
+ Footer { background: #111e2c; }
123
+ .compact #body { layout: vertical; }
124
+ .compact #brand { height: 3; padding: 0 2; }
125
+ .compact #summary { height: 4; }
126
+ .compact #tasks { width: 1fr; height: 1fr; }
127
+ .compact #details-pane { width: 1fr; height: 6; margin-left: 0; }
128
+ .compact #events { height: 6; }
129
+ """
130
+
131
+ def __init__(self, repo: Path, run_id: str | None = None):
132
+ super().__init__()
133
+ self.repo = repo
134
+ self.runs = runs_directory(repo)
135
+ self.selected_run = run_id
136
+ self.report: dict | None = None
137
+ self.run_names: list[str] = []
138
+ self.launched_process = None
139
+ self.launch_log: Path | None = None
140
+ self.action_busy = False
141
+ self.resume_target: str | None = None
142
+ self.awaiting_new_run: set[str] | None = None
143
+
144
+ def compose(self) -> ComposeResult:
145
+ yield Static("▰ ▰ ▰ TASK HARBOR\n Plan → Build → Verify → Review", id="brand")
146
+ yield Static(str(self.repo), id="workspace", markup=False)
147
+ with Horizontal(id="actions"):
148
+ yield Button("Set up", id="setup")
149
+ yield Button("Validate", id="validate")
150
+ yield Button("New run", id="start", variant="success")
151
+ yield Button("Resume", id="resume")
152
+ yield Button("Stop", id="stop", variant="warning")
153
+ yield Button("Review", id="review")
154
+ yield Static("", id="action-result", markup=False)
155
+ yield Static("No runs yet.\n\n1 task-harbor init\n2 Edit BACKLOG.md and .manager/config.json\n3 task-harbor validate\n4 task-harbor manage (in another terminal)\n\nPress F1 for setup help. This dashboard will show the run when it starts.", id="welcome", markup=False)
156
+ yield Select([], prompt="Choose a run", id="run-picker")
157
+ with Horizontal(id="summary"):
158
+ for name in ("progress", "provider", "calls", "cost"):
159
+ yield Static("—", id=name, classes="metric", markup=False)
160
+ yield ProgressBar(total=1, show_eta=False, id="completion")
161
+ with Horizontal(id="body"):
162
+ yield DataTable(id="tasks", cursor_type="row")
163
+ with VerticalScroll(id="details-pane"):
164
+ yield Static("", id="details", markup=False)
165
+ yield Static("", id="events", markup=False)
166
+ yield Static("", id="notice", markup=False)
167
+ yield Footer()
168
+
169
+ def on_mount(self) -> None:
170
+ table = self.query_one(DataTable)
171
+ table.add_columns("Task", "State", "Title")
172
+ table.border_title = "TASKS"
173
+ self.query_one("#details-pane").border_title = "TASK DETAILS"
174
+ self.query_one("#events").border_title = "RECENT EVENTS"
175
+ self.action_reload()
176
+ self.set_interval(2, self.action_reload)
177
+ table.focus()
178
+
179
+ def action_help(self) -> None:
180
+ self.push_screen(HelpScreen())
181
+
182
+ def action_evidence(self) -> None:
183
+ if self.report and self.report["tasks"]:
184
+ row = self.query_one(DataTable).cursor_row
185
+ task = self.report["tasks"][min(row, len(self.report["tasks"]) - 1)]
186
+ self.push_screen(HelpScreen(review_text(self.report, task["id"])))
187
+ else:
188
+ self.action_message("Select a saved run and task to inspect its evidence.")
189
+
190
+ def action_preflight(self) -> None:
191
+ from .preflight import suggestions
192
+ suggested = suggestions(self.repo)
193
+ self.push_screen(HelpScreen("PROJECT PREFLIGHT\n\nSuggested checks (not executed):\n"
194
+ + "\n".join(suggested or ["No known commands detected."])
195
+ + "\n\nReview verification_commands, setup_commands, required_env and required_executables in .manager/config.json.\n"
196
+ "Then run: task-harbor preflight --run-checks\n\n"
197
+ "Set require_verification to true to pause before model calls when baseline checks do not pass."))
198
+
199
+ def action_message(self, message: str) -> None:
200
+ self.screen_stack[0].query_one("#action-result", Static).update(message)
201
+
202
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
203
+ action = event.button.id
204
+ if action == "review":
205
+ event.stop()
206
+ self.action_evidence()
207
+ return
208
+ if action not in {"setup", "validate", "start", "resume", "stop"}:
209
+ return
210
+ event.stop()
211
+ if self.action_busy:
212
+ return
213
+ if action in {"start", "resume"}:
214
+ if self.launched_process and self.launched_process.poll() is None:
215
+ self.action_message("A run launched here is still running. Stop it or wait before launching another.")
216
+ return
217
+ try:
218
+ path = self.repo / ".manager" / "config.json"
219
+ if not path.exists():
220
+ raise ValueError("Use Set up first, then edit the backlog and configuration.")
221
+ raw = json.loads(path.read_text(encoding="utf-8"))
222
+ cfg = Config(**{k: v for k, v in raw.items() if k in Config.__dataclass_fields__})
223
+ self.resume_target = self.selected_run if action == "resume" else None
224
+ if action == "resume" and not self.resume_target:
225
+ raise ValueError("Select a saved run to resume.")
226
+ verb = f"Resume {self.resume_target}" if self.resume_target else "Start a new run"
227
+ self.push_screen(ConfirmRun(
228
+ f"{verb}\n\nProject: {self.repo}\nProvider: {cfg.provider}\nModel: {cfg.model}\n"
229
+ f"Agent call limit: {cfg.max_agent_calls or 'Unlimited'}\n\n"
230
+ "This starts model calls and changes code in isolated Git worktrees. "
231
+ "Review your backlog and configuration first.\n\n"
232
+ "Work continues if you close the dashboard. Stop waits for in-flight calls."
233
+ ), self.confirm_launch)
234
+ except (OSError, ValueError, TypeError) as exc:
235
+ self.action_message(str(exc))
236
+ return
237
+ command = "init" if action == "setup" else action
238
+ args = [sys.executable, "-m", "task_harbor", "--repo", str(self.repo), command]
239
+ if action == "stop":
240
+ if not self.selected_run:
241
+ self.action_message("Select a run to stop.")
242
+ return
243
+ args += ["--run-id", self.selected_run]
244
+ self.action_busy = True
245
+ self.action_message(f"Running {command}…")
246
+ try:
247
+ process = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
248
+ output, _ = await process.communicate()
249
+ self.action_message(output.decode("utf-8", errors="replace")[-1600:])
250
+ self.runs = runs_directory(self.repo)
251
+ except (OSError, ValueError) as exc:
252
+ self.action_message(str(exc))
253
+ finally:
254
+ self.action_busy = False
255
+ self.action_reload()
256
+
257
+ def confirm_launch(self, confirmed: bool) -> None:
258
+ if not confirmed:
259
+ return
260
+ try:
261
+ self.runs = runs_directory(self.repo)
262
+ self.launched_process, self.launch_log = launch_run(self.repo, self.runs, self.resume_target)
263
+ if not self.resume_target:
264
+ self.awaiting_new_run = set(self.run_names)
265
+ self.action_message(f"Agent work launched. Closing this window won't stop it.\nLaunch output: {self.launch_log}")
266
+ except (OSError, ValueError) as exc:
267
+ self.action_message(f"Could not launch: {exc}")
268
+
269
+ def on_resize(self) -> None:
270
+ self.screen.set_class(self.size.width < 95, "compact")
271
+
272
+ def on_select_changed(self, event: Select.Changed) -> None:
273
+ if event.value != Select.BLANK:
274
+ self.selected_run = str(event.value)
275
+ self.action_reload()
276
+
277
+ def action_reload(self) -> None:
278
+ if isinstance(self.screen, ModalScreen):
279
+ return
280
+ try:
281
+ if self.launched_process and self.launched_process.poll() is not None:
282
+ code = self.launched_process.returncode
283
+ detail = ""
284
+ if code and self.launch_log and self.launch_log.exists():
285
+ with self.launch_log.open("rb") as stream:
286
+ stream.seek(0, 2)
287
+ stream.seek(max(0, stream.tell() - 1000))
288
+ detail = "\n" + stream.read().decode("utf-8", errors="replace")
289
+ self.action_message(f"Launched process finished (exit {code}). Output: {self.launch_log}{detail}")
290
+ self.launched_process = None
291
+ names = sorted((p.parent.name for p in self.runs.glob("*/state.json")), reverse=True)
292
+ if self.awaiting_new_run is not None:
293
+ created = [name for name in names if name not in self.awaiting_new_run]
294
+ if created:
295
+ self.selected_run = created[0]
296
+ self.awaiting_new_run = None
297
+ self.query_one("#run-picker").display = bool(names)
298
+ for button in ("resume", "stop"):
299
+ self.query_one(f"#{button}", Button).disabled = not bool(names)
300
+ if names != self.run_names:
301
+ if self.selected_run in self.run_names and self.selected_run not in names:
302
+ self.selected_run = names[0] if names else None
303
+ self.run_names = names
304
+ picker = self.query_one(Select)
305
+ picker.set_options([(name, name) for name in names])
306
+ if self.selected_run is None and names:
307
+ self.selected_run = names[0]
308
+ if self.selected_run in names:
309
+ picker.value = self.selected_run
310
+ if not self.selected_run:
311
+ config_exists = (self.repo / ".manager" / "config.json").exists()
312
+ self.query_one("#welcome", Static).update(
313
+ "No runs yet. A run is one execution of your backlog.\n\n"
314
+ + ("1 Click Set up to create configuration and a sample backlog.\n" if not config_exists else "1 Project configured. Review your backlog and configuration.\n")
315
+ + "2 Edit BACKLOG.md and .manager/config.json in your editor.\n"
316
+ "3 Click Validate to check task requirements.\n4 Click New run to review settings and start.\n\n"
317
+ f"Looking for runs in: {self.runs}\nPress F1 for help."
318
+ )
319
+ self.query_one("#welcome").display = True
320
+ for selector in ("#body", "#events", "#summary", "#completion"):
321
+ self.query_one(selector).display = False
322
+ self.clear_run("No runs yet.\nStart with: task-harbor --repo <path> init\nEdit BACKLOG.md, then run manage in another terminal.")
323
+ return
324
+ self.query_one("#welcome").display = False
325
+ for selector in ("#body", "#events", "#summary", "#completion"):
326
+ self.query_one(selector).display = True
327
+ self.query_one("#events").display = self.size.height >= 36
328
+ run_dir = self.runs / self.selected_run
329
+ report = run_report(run_dir)
330
+ events = event_tail(run_dir / "events.jsonl")
331
+ updated = datetime.fromtimestamp((run_dir / "state.json").stat().st_mtime).strftime("%H:%M:%S")
332
+ self.query_one("#events", Static).update(events)
333
+ state = "Paused" if report["paused"] else "Complete" if report["completed"] == report["total"] else "Saved state"
334
+ self.query_one("#notice", Static).update(f'{state} · saved {updated} · refreshes every 2s · {report["stop_reason"] or "Viewing saved progress. F1 for help."}')
335
+ if report == self.report:
336
+ return
337
+ table = self.query_one(DataTable)
338
+ row = table.cursor_row
339
+ selected = self.report["tasks"][row]["id"] if self.report and row < len(self.report["tasks"]) else None
340
+ self.report = report
341
+ self.query_one(ProgressBar).update(total=max(1, report["total"]), progress=report["completed"])
342
+ usage = report["usage"]
343
+ for name, value in {
344
+ "progress": f'TASKS\n{report["completed"]} / {report["total"]} complete',
345
+ "provider": f'PROVIDER\n{report["provider"].upper()}',
346
+ "calls": f'AGENT CALLS\n{usage["agent_calls"]}',
347
+ "cost": f'REPORTED COST\n${usage["reported_cost_usd"]:.2f}' + (" + unknown" if usage["calls_without_cost"] else ""),
348
+ }.items():
349
+ self.query_one(f"#{name}", Static).update(value)
350
+ table.clear()
351
+ for task in report["tasks"]:
352
+ status = "BLOCKED" if task["blocked_reason"] else task["status"]
353
+ color = "#66e3c4" if status == "COMPLETE" else "#f2c777" if status in {"BLOCKED", "QA_FAIL"} else "#8cbaff"
354
+ table.add_row(Text(task["id"]), Text(status.replace("_", " "), style=color), Text(task["title"]), key=task["id"])
355
+ row = next((i for i, t in enumerate(report["tasks"]) if t["id"] == selected), 0)
356
+ table.move_cursor(row=row)
357
+ self.show_task(row)
358
+ except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
359
+ self.clear_run(f"Cannot read run data: {exc}\nRetrying in 2s. Press r to retry now.")
360
+
361
+ def clear_run(self, message: str) -> None:
362
+ self.report = None
363
+ self.query_one(ProgressBar).update(total=1, progress=0)
364
+ self.query_one(DataTable).clear()
365
+ for name in ("progress", "provider", "calls", "cost"):
366
+ self.query_one(f"#{name}", Static).update("—")
367
+ self.query_one("#details", Static).update(message)
368
+ self.query_one("#events", Static).update("")
369
+ self.query_one("#notice", Static).update("Viewing is free of model calls. New run / Resume starts agent work after confirmation.")
370
+
371
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
372
+ self.show_task(event.cursor_row)
373
+
374
+ def show_task(self, row: int) -> None:
375
+ if not self.report or row >= len(self.report["tasks"]):
376
+ return
377
+ task = self.report["tasks"][row]
378
+ tokens = task["usage"]["tokens"]
379
+ self.query_one("#details", Static).update(
380
+ f'{task["id"]}\n{task["title"]}\n\n'
381
+ f'Status: {task["status"]}\nDependencies: {", ".join(task["dependencies"]) or "None"}\n'
382
+ f'Generation: {task["generation"]}\n\n'
383
+ f'Next: {task["next_action"] or "—"}\n'
384
+ f'Blocked: {task["blocked_reason"] or "No"}\n\n'
385
+ f'Checkpoint: {task["checkpoint"] or "—"}\n\n'
386
+ f'Verification: {task.get("evidence", {}).get("verification_level", "not recorded")}\nPress E for evidence and diff.\n\n'
387
+ + "\n".join(f'{key}: {value:,}' for key, value in tokens.items())
388
+ )
opus_manager/usage.py ADDED
@@ -0,0 +1,77 @@
1
+ """Durable call reservations. Unknown interrupted costs remain reserved on resume."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from .config import Config
12
+ from .state import atomic_write_json
13
+
14
+
15
+ class RunStopped(RuntimeError):
16
+ pass
17
+
18
+
19
+ class UsageLedger:
20
+ def __init__(self, path: Path, config: Config):
21
+ self.path, self.config = path, config
22
+ self.lock = threading.Lock()
23
+
24
+ def read(self) -> dict:
25
+ return json.loads(self.path.read_text(encoding="utf-8")) if self.path.exists() else {"calls": []}
26
+
27
+ def reserve(self, role: str, task: str | None) -> tuple[str, float | None]:
28
+ with self.lock:
29
+ control = self.path.parent / "stop-request.json"
30
+ if control.exists():
31
+ raise RunStopped("Operator requested a stop; resume explicitly to continue")
32
+ data = self.read()
33
+ calls = data["calls"]
34
+ cfg = self.config
35
+ if cfg.max_agent_calls is not None and len(calls) >= cfg.max_agent_calls:
36
+ raise RunStopped("Agent call budget exhausted")
37
+ if task and cfg.max_task_agent_calls is not None and sum(c.get("task") == task for c in calls) >= cfg.max_task_agent_calls:
38
+ raise RunStopped(f"Task {task} agent call budget exhausted")
39
+ cap = cfg.max_call_cost_usd
40
+ if cfg.max_run_cost_usd is not None:
41
+ spent = sum(c.get("cost_usd") if c.get("cost_usd") is not None else c.get("reserved_usd", 0) or 0 for c in calls)
42
+ # Missing usage from an older/unbudgeted invocation cannot be assumed free.
43
+ if any(c.get("cost_usd") is None and not c.get("reserved_usd") for c in calls):
44
+ raise RunStopped("Unknown prior cost prevents enforcing the run budget")
45
+ remaining = cfg.max_run_cost_usd - spent
46
+ if remaining < 0.01:
47
+ raise RunStopped("Estimated run cost budget exhausted (including outstanding reservations)")
48
+ cap = min(cap or remaining / cfg.max_active_planners, remaining)
49
+ call_id = uuid.uuid4().hex
50
+ calls.append({"id": call_id, "role": role, "task": task, "status": "pending",
51
+ "provider": cfg.provider, "model": cfg.model, "started_at": time.time(), "reserved_usd": cap, "cost_usd": None})
52
+ atomic_write_json(self.path, data)
53
+ return call_id, cap
54
+
55
+ def finish(self, call_id: str, raw: dict | None, error: str | None = None) -> None:
56
+ with self.lock:
57
+ data = self.read()
58
+ call = next(c for c in data["calls"] if c["id"] == call_id)
59
+ raw = raw or {}
60
+ cost = raw.get("total_cost_usd")
61
+ if type(cost) in {int, float} and math.isfinite(cost) and cost >= 0:
62
+ call["cost_usd"] = cost
63
+ call.update(status="failed" if error else "completed", finished_at=time.time(),
64
+ usage=raw.get("usage", {}), session_id=raw.get("session_id"), error=error)
65
+ atomic_write_json(self.path, data)
66
+
67
+
68
+ def summarize(data: dict) -> dict:
69
+ calls = data.get("calls", [])
70
+ known = [c["cost_usd"] for c in calls if c.get("cost_usd") is not None]
71
+ tokens = {}
72
+ for call in calls:
73
+ for key, value in call.get("usage", {}).items():
74
+ if key.endswith("tokens") and type(value) is int:
75
+ tokens[key] = tokens.get(key, 0) + value
76
+ return {"agent_calls": len(calls), "reported_cost_usd": sum(known),
77
+ "calls_without_cost": len(calls) - len(known), "tokens": tokens}
@@ -0,0 +1,39 @@
1
+ """Run operator-configured checks without a model; keep bounded output evidence."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ def run_checks(commands: list[str], cwd: Path, *, timeout: int, limit: int,
13
+ env: dict[str, str] | None = None) -> list[dict]:
14
+ results = []
15
+ for command in commands:
16
+ started = time.monotonic()
17
+ with tempfile.TemporaryFile() as output:
18
+ process = subprocess.Popen(command, shell=True, cwd=cwd, env=env,
19
+ stdout=output, stderr=subprocess.STDOUT,
20
+ start_new_session=os.name != "nt")
21
+ timed_out = False
22
+ try:
23
+ process.wait(timeout=timeout)
24
+ except subprocess.TimeoutExpired:
25
+ timed_out = True
26
+ if os.name == "nt":
27
+ subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True)
28
+ else:
29
+ os.killpg(process.pid, signal.SIGKILL)
30
+ process.wait()
31
+ size = output.tell()
32
+ output.seek(max(0, size - limit))
33
+ text = output.read().decode("utf-8", errors="replace")
34
+ results.append({"command": command, "exit_code": process.returncode,
35
+ "timed_out": timed_out, "duration_seconds": round(time.monotonic() - started, 3),
36
+ "output": text, "output_truncated": size > limit})
37
+ if timed_out or process.returncode != 0:
38
+ break
39
+ return results
@@ -0,0 +1 @@
1
+ """Public module entry point for Task Harbor."""
@@ -0,0 +1,3 @@
1
+ from opus_manager.cli import main
2
+
3
+ raise SystemExit(main())