simjecture 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 (75) hide show
  1. conjecture_solver/__init__.py +10 -0
  2. conjecture_solver/__main__.py +4 -0
  3. conjecture_solver/action_handlers.py +459 -0
  4. conjecture_solver/adapters/__init__.py +7 -0
  5. conjecture_solver/adapters/base.py +96 -0
  6. conjecture_solver/adapters/fake.py +155 -0
  7. conjecture_solver/adapters/pic.py +196 -0
  8. conjecture_solver/adapters/warpx.py +1375 -0
  9. conjecture_solver/autonomous_research.py +1071 -0
  10. conjecture_solver/benchmarks/__init__.py +6 -0
  11. conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
  12. conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
  13. conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
  14. conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
  15. conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
  16. conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
  17. conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
  18. conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
  19. conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
  20. conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
  21. conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
  22. conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
  23. conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
  24. conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
  25. conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
  26. conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
  27. conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
  28. conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
  29. conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
  30. conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
  31. conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
  32. conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
  33. conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
  34. conjecture_solver/campaign.py +406 -0
  35. conjecture_solver/cli.py +963 -0
  36. conjecture_solver/confirmation.py +287 -0
  37. conjecture_solver/control.py +234 -0
  38. conjecture_solver/deployment.py +874 -0
  39. conjecture_solver/discovery.py +68 -0
  40. conjecture_solver/domains/__init__.py +40 -0
  41. conjecture_solver/domains/base.py +157 -0
  42. conjecture_solver/domains/kinetic_sufficiency.py +521 -0
  43. conjecture_solver/ledger.py +238 -0
  44. conjecture_solver/lifecycle.py +131 -0
  45. conjecture_solver/literature.py +494 -0
  46. conjecture_solver/llm.py +228 -0
  47. conjecture_solver/models.py +279 -0
  48. conjecture_solver/mvp_agent.py +3212 -0
  49. conjecture_solver/mvp_claims.py +1279 -0
  50. conjecture_solver/mvp_control.py +322 -0
  51. conjecture_solver/mvp_guidance.py +172 -0
  52. conjecture_solver/mvp_launch.py +1296 -0
  53. conjecture_solver/mvp_monitor.py +1487 -0
  54. conjecture_solver/mvp_skills.py +538 -0
  55. conjecture_solver/orchestration.py +578 -0
  56. conjecture_solver/outbox.py +526 -0
  57. conjecture_solver/parameters.py +394 -0
  58. conjecture_solver/proposals.py +427 -0
  59. conjecture_solver/research_tools.py +278 -0
  60. conjecture_solver/schema_export.py +246 -0
  61. conjecture_solver/search.py +1047 -0
  62. conjecture_solver/semantics.py +146 -0
  63. conjecture_solver/tui/__init__.py +7 -0
  64. conjecture_solver/tui/app.py +202 -0
  65. conjecture_solver/tui/claim_views.py +213 -0
  66. conjecture_solver/tui/screens.py +1014 -0
  67. conjecture_solver/warpx_analysis.py +184 -0
  68. conjecture_solver/warpx_campaign.py +148 -0
  69. conjecture_solver/warpx_confirmation.py +617 -0
  70. simjecture-0.1.0.dist-info/METADATA +400 -0
  71. simjecture-0.1.0.dist-info/RECORD +75 -0
  72. simjecture-0.1.0.dist-info/WHEEL +4 -0
  73. simjecture-0.1.0.dist-info/entry_points.txt +4 -0
  74. simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
@@ -0,0 +1,1487 @@
1
+ """Read-only projection of durable natural-language MVP run artifacts.
2
+
3
+ The monitor never writes to a run directory. Claim ledgers and terminal reports
4
+ remain authoritative; model prose is only used to name the current typed action.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from collections import Counter
14
+ from collections.abc import Callable, Iterable
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import Any, TextIO
20
+
21
+ from pydantic import Field
22
+
23
+ from .models import StrictModel
24
+ from .mvp_agent import (
25
+ MVPAgentAction,
26
+ MVPAuthorAndRunCapabilityAction,
27
+ MVPCloseClaimAction,
28
+ MVPFinishAction,
29
+ MVPLinkClaimEvidenceAction,
30
+ MVPListFilesAction,
31
+ MVPListSkillsAction,
32
+ MVPMaterializeSkillResourceAction,
33
+ MVPReadFileAction,
34
+ MVPReadSkillAction,
35
+ MVPRegisterClaimAction,
36
+ MVPRegisterEvidenceContractAction,
37
+ MVPRunCapabilityAction,
38
+ MVPRunPythonAction,
39
+ MVPSearchLiteratureAction,
40
+ MVPWriteFileAction,
41
+ parse_mvp_action,
42
+ )
43
+ from .mvp_control import (
44
+ ControlCommand,
45
+ elapsed_from_clock,
46
+ format_token_counts,
47
+ parse_usage_payload,
48
+ read_clock,
49
+ read_control,
50
+ read_pause_state,
51
+ )
52
+
53
+ TERMINAL_REPORT_STATUSES = frozenset(
54
+ {"completed", "cancelled", "provider_failed", "budget_exhausted"}
55
+ )
56
+ OPERATOR_INPUT_DIR = "operator_input"
57
+ DEFAULT_RECENT_EVENT_LIMIT = 40
58
+ _MAX_RETAINED_EVENTS = 80
59
+
60
+
61
+ class RunPhase(StrEnum):
62
+ """Operator-facing phase derived from durable artifacts, not process liveness."""
63
+
64
+ INITIALIZED = "initialized"
65
+ INCOMPLETE = "incomplete"
66
+ PAUSED = "paused"
67
+ COMPLETED = "completed"
68
+ CANCELLED = "cancelled"
69
+ PROVIDER_FAILED = "provider_failed"
70
+ BUDGET_EXHAUSTED = "budget_exhausted"
71
+
72
+
73
+ class TranscriptCursor(StrictModel):
74
+ """Byte-oriented resume point for an append-only transcript."""
75
+
76
+ inode: int | None = None
77
+ offset: int = Field(default=0, ge=0)
78
+ size: int = Field(default=0, ge=0)
79
+ mtime_ns: int | None = None
80
+
81
+
82
+ class RunIdentity(StrictModel):
83
+ run_directory: str
84
+ campaign_id: str | None = None
85
+ hypothesis: str | None = None
86
+ campaign_instruction: str | None = None
87
+ manifest_schema_version: str | None = None
88
+ config: dict[str, Any] = Field(default_factory=dict)
89
+ skill_hashes: dict[str, str] = Field(default_factory=dict)
90
+ capability_hashes: dict[str, str] = Field(default_factory=dict)
91
+
92
+
93
+ class ClaimSummary(StrictModel):
94
+ id: str
95
+ status: str
96
+ kind: str | None = None
97
+ relation: str | None = None
98
+ parent_id: str | None = None
99
+ statement: str = ""
100
+ evidence_count: int = Field(default=0, ge=0)
101
+ contract_count: int = Field(default=0, ge=0)
102
+ closed_reason: str | None = None
103
+ active: bool = False
104
+
105
+
106
+ class CurrentAction(StrictModel):
107
+ iteration: int = Field(ge=1)
108
+ action_name: str | None = None
109
+ description: str
110
+ pending: bool
111
+ capability: str | None = None
112
+ stage: str | None = None
113
+ active_claim_id: str | None = None
114
+ path: str | None = None
115
+ model: str | None = None
116
+ route: str | None = None
117
+ argv: tuple[str, ...] = ()
118
+
119
+
120
+ class HeartbeatObservation(StrictModel):
121
+ iteration: int = Field(ge=1)
122
+ elapsed_wall_seconds: float | None = None
123
+ stdout_bytes: int | None = None
124
+ stderr_bytes: int | None = None
125
+ workspace_bytes: int | None = None
126
+ observed_at: datetime | None = None
127
+ age_seconds: float | None = None
128
+
129
+
130
+ class HumanizedEvent(StrictModel):
131
+ sequence: int = Field(default=0, ge=0)
132
+ kind: str
133
+ iteration: int | None = None
134
+ summary: str
135
+ action_name: str | None = None
136
+
137
+
138
+ class ModelTokenUsage(StrictModel):
139
+ model: str
140
+ prompt_tokens: int = Field(default=0, ge=0)
141
+ completion_tokens: int = Field(default=0, ge=0)
142
+ total_tokens: int = Field(default=0, ge=0)
143
+ turns: int = Field(default=0, ge=0)
144
+
145
+
146
+ class TokenUsageSummary(StrictModel):
147
+ prompt_tokens: int = Field(default=0, ge=0)
148
+ completion_tokens: int = Field(default=0, ge=0)
149
+ total_tokens: int = Field(default=0, ge=0)
150
+ cached_tokens: int = Field(default=0, ge=0)
151
+ reasoning_tokens: int = Field(default=0, ge=0)
152
+ turns: int = Field(default=0, ge=0)
153
+ turns_missing_usage: int = Field(default=0, ge=0)
154
+ by_model: tuple[ModelTokenUsage, ...] = ()
155
+ label: str = "tokens: 0 in / 0 out / 0 total (0 turns)"
156
+
157
+
158
+ class TerminalReportSummary(StrictModel):
159
+ status: str
160
+ final_answer: str
161
+ iterations: int = Field(ge=0)
162
+ elapsed_wall_seconds: float = Field(ge=0)
163
+ started_at: datetime | None = None
164
+ finished_at: datetime | None = None
165
+ open_claim_ids: tuple[str, ...] = ()
166
+ closed_claim_ids: tuple[str, ...] = ()
167
+ finish_claim_notes: tuple[str, ...] = ()
168
+ workspace_artifact_count: int = Field(default=0, ge=0)
169
+
170
+
171
+ class ArtifactPaths(StrictModel):
172
+ run_directory: str
173
+ manifest: str | None = None
174
+ transcript: str | None = None
175
+ report: str | None = None
176
+ claim_ledger: str | None = None
177
+ claim_summary: str | None = None
178
+ artifact_provenance: str | None = None
179
+ workspace: str | None = None
180
+ controller_log: str | None = None
181
+ operator_input: str | None = None
182
+
183
+
184
+ class ContainedArtifact(StrictModel):
185
+ relative_path: str
186
+ bytes: int = Field(ge=0)
187
+ kind: str
188
+
189
+
190
+ class RecentRun(StrictModel):
191
+ run_directory: str
192
+ campaign_id: str | None = None
193
+ phase: RunPhase
194
+ hypothesis: str | None = None
195
+ modified_at: datetime | None = None
196
+
197
+
198
+ class MVPRunSnapshot(StrictModel):
199
+ phase: RunPhase
200
+ phase_label: str
201
+ identity: RunIdentity
202
+ configured_wall_seconds: float | None = None
203
+ elapsed_wall_seconds: float | None = None
204
+ elapsed_is_estimate: bool = False
205
+ iterations: int = Field(default=0, ge=0)
206
+ action_counts: dict[str, int] = Field(default_factory=dict)
207
+ claims: tuple[ClaimSummary, ...] = ()
208
+ open_claim_ids: tuple[str, ...] = ()
209
+ closed_claim_ids: tuple[str, ...] = ()
210
+ current_action: CurrentAction | None = None
211
+ latest_heartbeat: HeartbeatObservation | None = None
212
+ recent_events: tuple[HumanizedEvent, ...] = ()
213
+ report: TerminalReportSummary | None = None
214
+ artifacts: ArtifactPaths
215
+ workspace_bytes: int = Field(default=0, ge=0)
216
+ last_model: str | None = None
217
+ last_capability: str | None = None
218
+ token_usage: TokenUsageSummary = Field(default_factory=TokenUsageSummary)
219
+ pending_control: str | None = None
220
+ warnings: tuple[str, ...] = ()
221
+ transcript_cursor: TranscriptCursor = Field(default_factory=TranscriptCursor)
222
+
223
+
224
+ @dataclass
225
+ class _TranscriptState:
226
+ assistant: dict[int, dict[str, Any]] = field(default_factory=dict)
227
+ tools: dict[int, dict[str, Any]] = field(default_factory=dict)
228
+ last_heartbeat: dict[str, Any] | None = None
229
+ last_heartbeat_observed_at: datetime | None = None
230
+ events: list[HumanizedEvent] = field(default_factory=list)
231
+ action_counts: Counter[str] = field(default_factory=Counter)
232
+ last_model: str | None = None
233
+ last_route: str | None = None
234
+ last_capability: str | None = None
235
+ parse_warnings: list[str] = field(default_factory=list)
236
+ prompt_tokens: int = 0
237
+ completion_tokens: int = 0
238
+ total_tokens: int = 0
239
+ cached_tokens: int = 0
240
+ reasoning_tokens: int = 0
241
+ usage_turns: int = 0
242
+ missing_usage_turns: int = 0
243
+ model_usage: dict[str, dict[str, int]] = field(default_factory=dict)
244
+ next_event_sequence: int = 0
245
+
246
+ def reset(self) -> None:
247
+ self.assistant.clear()
248
+ self.tools.clear()
249
+ self.last_heartbeat = None
250
+ self.last_heartbeat_observed_at = None
251
+ self.events.clear()
252
+ self.action_counts.clear()
253
+ self.last_model = None
254
+ self.last_route = None
255
+ self.last_capability = None
256
+ self.parse_warnings.clear()
257
+ self.prompt_tokens = 0
258
+ self.completion_tokens = 0
259
+ self.total_tokens = 0
260
+ self.cached_tokens = 0
261
+ self.reasoning_tokens = 0
262
+ self.usage_turns = 0
263
+ self.missing_usage_turns = 0
264
+ self.model_usage.clear()
265
+ self.next_event_sequence = 0
266
+
267
+
268
+ def utc_now() -> datetime:
269
+ return datetime.now(UTC)
270
+
271
+
272
+ def format_duration(seconds: float | None) -> str:
273
+ if seconds is None:
274
+ return "--:--:--"
275
+ total = max(0, int(seconds))
276
+ hours, remainder = divmod(total, 3600)
277
+ minutes, secs = divmod(remainder, 60)
278
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
279
+
280
+
281
+ def format_bytes(size: int | None) -> str:
282
+ if size is None:
283
+ return "unknown"
284
+ value = float(max(0, size))
285
+ for unit in ("B", "KB", "MB", "GB", "TB"):
286
+ if value < 1024.0 or unit == "TB":
287
+ if unit == "B":
288
+ return f"{int(value)} B"
289
+ return f"{value:.1f} {unit}"
290
+ value /= 1024.0
291
+ return f"{size} B"
292
+
293
+
294
+ def format_age(seconds: float | None) -> str:
295
+ if seconds is None:
296
+ return "unknown"
297
+ if seconds < 1:
298
+ return "just now"
299
+ if seconds < 60:
300
+ return f"{int(seconds)} s ago"
301
+ if seconds < 3600:
302
+ return f"{int(seconds // 60)} min ago"
303
+ return f"{int(seconds // 3600)} h ago"
304
+
305
+
306
+ def claim_status_marker(status: str) -> str:
307
+ markers = {
308
+ "open": "●",
309
+ "supported": "✓",
310
+ "weakened": "~",
311
+ "falsified": "×",
312
+ "superseded": "▸",
313
+ "unresolved": "?",
314
+ "instrument_limited": "!",
315
+ }
316
+ return markers.get(status, "·")
317
+
318
+
319
+ def phase_label(phase: RunPhase) -> str:
320
+ if phase is RunPhase.INCOMPLETE:
321
+ return "incomplete (no terminal report)"
322
+ if phase is RunPhase.INITIALIZED:
323
+ return "initialized"
324
+ if phase is RunPhase.PAUSED:
325
+ return "paused (action boundary)"
326
+ return phase.value
327
+
328
+
329
+ def humanize_action(action: MVPAgentAction) -> str:
330
+ """Describe a typed action without treating research notes as scientific status."""
331
+
332
+ if isinstance(action, MVPSearchLiteratureAction):
333
+ return f"Searching literature for {action.query!r}"
334
+ if isinstance(action, MVPWriteFileAction):
335
+ return f"Writing {action.path}"
336
+ if isinstance(action, MVPReadFileAction):
337
+ return f"Reading {action.path}"
338
+ if isinstance(action, MVPListFilesAction):
339
+ return f"Listing {action.path}"
340
+ if isinstance(action, MVPRunPythonAction):
341
+ command = _short_argv(action.argv)
342
+ if action.active_claim_id:
343
+ return f"Running Python {command} for {action.active_claim_id}"
344
+ return f"Running Python {command}"
345
+ if isinstance(action, MVPListSkillsAction):
346
+ return "Listing installed skills and capabilities"
347
+ if isinstance(action, MVPReadSkillAction):
348
+ if action.path:
349
+ return f"Reading skill {action.skill} ({action.path})"
350
+ return f"Reading skill {action.skill}"
351
+ if isinstance(action, MVPMaterializeSkillResourceAction):
352
+ return (
353
+ f"Materializing {action.skill}:{action.source_path} → {action.destination_path}"
354
+ )
355
+ if isinstance(action, MVPRunCapabilityAction):
356
+ claim = f", claim={action.active_claim_id}" if action.active_claim_id else ""
357
+ return (
358
+ f"Running capability {action.capability} "
359
+ f"(stage={action.stage.value}{claim})"
360
+ )
361
+ if isinstance(action, MVPAuthorAndRunCapabilityAction):
362
+ claim = f", claim={action.active_claim_id}" if action.active_claim_id else ""
363
+ return (
364
+ f"Authoring and running {action.path} on {action.capability} "
365
+ f"(stage={action.stage.value}{claim})"
366
+ )
367
+ if isinstance(action, MVPRegisterClaimAction):
368
+ return f"Registering {action.claim_id} ({action.kind.value})"
369
+ if isinstance(action, MVPRegisterEvidenceContractAction):
370
+ return f"Registering evidence contract for {action.claim_id}"
371
+ if isinstance(action, MVPLinkClaimEvidenceAction):
372
+ return f"Linking evidence {action.path} to {action.claim_id}"
373
+ if isinstance(action, MVPCloseClaimAction):
374
+ return f"Closing {action.claim_id} as {action.status.value}"
375
+ if isinstance(action, MVPFinishAction):
376
+ return "Finishing the campaign"
377
+ if action.action.value == "list_claims":
378
+ return "Listing claims"
379
+ return f"Performing {action.action.value}"
380
+
381
+
382
+ def action_details(action: MVPAgentAction) -> dict[str, Any]:
383
+ details: dict[str, Any] = {"action_name": action.action.value}
384
+ if isinstance(
385
+ action,
386
+ (MVPRunCapabilityAction, MVPAuthorAndRunCapabilityAction),
387
+ ):
388
+ details["capability"] = action.capability
389
+ details["stage"] = action.stage.value
390
+ details["active_claim_id"] = action.active_claim_id
391
+ details["argv"] = action.argv
392
+ if isinstance(action, MVPAuthorAndRunCapabilityAction):
393
+ details["path"] = action.path
394
+ elif isinstance(action, MVPRunPythonAction):
395
+ details["argv"] = action.argv
396
+ details["active_claim_id"] = action.active_claim_id
397
+ elif isinstance(
398
+ action,
399
+ (MVPWriteFileAction, MVPReadFileAction, MVPListFilesAction, MVPReadSkillAction),
400
+ ):
401
+ details["path"] = action.path
402
+ elif isinstance(action, MVPMaterializeSkillResourceAction):
403
+ details["path"] = action.destination_path
404
+ elif isinstance(
405
+ action,
406
+ (
407
+ MVPRegisterClaimAction,
408
+ MVPRegisterEvidenceContractAction,
409
+ MVPLinkClaimEvidenceAction,
410
+ MVPCloseClaimAction,
411
+ ),
412
+ ):
413
+ details["active_claim_id"] = action.claim_id
414
+ if isinstance(action, MVPLinkClaimEvidenceAction):
415
+ details["path"] = action.path
416
+ return details
417
+
418
+
419
+ def read_new_transcript_records(
420
+ path: Path,
421
+ cursor: TranscriptCursor,
422
+ ) -> tuple[list[dict[str, Any]], TranscriptCursor, tuple[str, ...]]:
423
+ """Read newly completed JSONL records without consuming a partial last line."""
424
+
425
+ if not path.exists() or not path.is_file():
426
+ return [], TranscriptCursor(), ()
427
+ stat = path.stat()
428
+ inode = getattr(stat, "st_ino", None)
429
+ reset = False
430
+ if cursor.inode is not None and inode != cursor.inode:
431
+ reset = True
432
+ if stat.st_size < cursor.offset:
433
+ reset = True
434
+ start = 0 if reset else cursor.offset
435
+ with path.open("rb") as stream:
436
+ stream.seek(start)
437
+ chunk = stream.read()
438
+ newline_at = chunk.rfind(b"\n")
439
+ if newline_at < 0:
440
+ return (
441
+ [],
442
+ TranscriptCursor(
443
+ inode=inode,
444
+ offset=start,
445
+ size=stat.st_size,
446
+ mtime_ns=stat.st_mtime_ns,
447
+ ),
448
+ (),
449
+ )
450
+ complete = chunk[: newline_at + 1]
451
+ records: list[dict[str, Any]] = []
452
+ warnings: list[str] = []
453
+ for raw_line in complete.split(b"\n"):
454
+ if not raw_line.strip():
455
+ continue
456
+ try:
457
+ payload = json.loads(raw_line.decode("utf-8"))
458
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
459
+ warnings.append(f"skipped malformed transcript line: {error}")
460
+ continue
461
+ if isinstance(payload, dict):
462
+ records.append(payload)
463
+ else:
464
+ warnings.append("skipped non-object transcript line")
465
+ return (
466
+ records,
467
+ TranscriptCursor(
468
+ inode=inode,
469
+ offset=start + len(complete),
470
+ size=stat.st_size,
471
+ mtime_ns=stat.st_mtime_ns,
472
+ ),
473
+ tuple(warnings),
474
+ )
475
+
476
+
477
+ def load_json_object(path: Path) -> dict[str, Any] | None:
478
+ if not path.exists() or not path.is_file():
479
+ return None
480
+ try:
481
+ payload = json.loads(path.read_text())
482
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
483
+ return None
484
+ return payload if isinstance(payload, dict) else None
485
+
486
+
487
+ def directory_bytes(root: Path) -> int:
488
+ if not root.is_dir():
489
+ return 0
490
+ resolved = root.resolve()
491
+ total = 0
492
+ for dirpath, dirnames, filenames in os.walk(resolved, followlinks=False):
493
+ current = Path(dirpath)
494
+ try:
495
+ if not current.resolve().is_relative_to(resolved):
496
+ dirnames.clear()
497
+ continue
498
+ except OSError:
499
+ dirnames.clear()
500
+ continue
501
+ for name in filenames:
502
+ path = current / name
503
+ if path.is_symlink():
504
+ continue
505
+ try:
506
+ total += path.stat().st_size
507
+ except OSError:
508
+ continue
509
+ return total
510
+
511
+
512
+ def contained_path(root: Path, relative: str | Path) -> Path | None:
513
+ resolved_root = root.resolve()
514
+ candidate = (resolved_root / relative).resolve()
515
+ if not candidate.is_relative_to(resolved_root):
516
+ return None
517
+ return candidate
518
+
519
+
520
+ def list_contained_artifacts(
521
+ run_directory: Path,
522
+ *,
523
+ max_entries: int = 200,
524
+ ) -> tuple[ContainedArtifact, ...]:
525
+ root = Path(run_directory).resolve()
526
+ if not root.is_dir():
527
+ return ()
528
+ entries: list[ContainedArtifact] = []
529
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
530
+ current = Path(dirpath)
531
+ try:
532
+ if not current.resolve().is_relative_to(root):
533
+ dirnames.clear()
534
+ continue
535
+ except OSError:
536
+ dirnames.clear()
537
+ continue
538
+ dirnames.sort()
539
+ for name in sorted(filenames):
540
+ path = current / name
541
+ if path.is_symlink():
542
+ kind = "symlink"
543
+ size = 0
544
+ elif path.is_file():
545
+ kind = "file"
546
+ try:
547
+ size = path.stat().st_size
548
+ except OSError:
549
+ size = 0
550
+ else:
551
+ continue
552
+ relative = path.relative_to(root).as_posix()
553
+ entries.append(
554
+ ContainedArtifact(relative_path=relative, bytes=size, kind=kind)
555
+ )
556
+ if len(entries) >= max_entries:
557
+ return tuple(entries)
558
+ return tuple(entries)
559
+
560
+
561
+ def discover_recent_runs(
562
+ roots: Iterable[str | Path] | None = None,
563
+ *,
564
+ limit: int = 20,
565
+ ) -> tuple[RecentRun, ...]:
566
+ search_roots = [Path(item).expanduser() for item in roots] if roots else _default_run_roots()
567
+ found: dict[str, RecentRun] = {}
568
+ for search_root in search_roots:
569
+ if not search_root.is_dir():
570
+ continue
571
+ for manifest in _candidate_manifests(search_root):
572
+ run_dir = manifest.parent
573
+ key = str(run_dir)
574
+ if key in found:
575
+ continue
576
+ try:
577
+ snapshot = load_run_snapshot(run_dir)
578
+ except OSError:
579
+ continue
580
+ found[key] = RecentRun(
581
+ run_directory=snapshot.identity.run_directory,
582
+ campaign_id=snapshot.identity.campaign_id,
583
+ phase=snapshot.phase,
584
+ hypothesis=snapshot.identity.hypothesis,
585
+ modified_at=_latest_mtime(run_dir),
586
+ )
587
+ ordered = sorted(
588
+ found.values(),
589
+ key=lambda item: item.modified_at or datetime.fromtimestamp(0, UTC),
590
+ reverse=True,
591
+ )
592
+ return tuple(ordered[:limit])
593
+
594
+
595
+ def _default_run_roots() -> list[Path]:
596
+ cwd = Path.cwd()
597
+ return [cwd, cwd / "artifacts"]
598
+
599
+
600
+ def _candidate_manifests(root: Path) -> list[Path]:
601
+ if (root / "mvp_manifest.json").is_file():
602
+ return [root / "mvp_manifest.json"]
603
+ matches: list[Path] = []
604
+ try:
605
+ children = list(root.iterdir())
606
+ except OSError:
607
+ return []
608
+ for child in children:
609
+ if not child.is_dir() or child.name.startswith("."):
610
+ continue
611
+ manifest = child / "mvp_manifest.json"
612
+ if manifest.is_file():
613
+ matches.append(manifest)
614
+ continue
615
+ try:
616
+ grandchildren = list(child.iterdir())
617
+ except OSError:
618
+ continue
619
+ for grandchild in grandchildren:
620
+ nested = grandchild / "mvp_manifest.json"
621
+ if grandchild.is_dir() and nested.is_file():
622
+ matches.append(nested)
623
+ return matches
624
+
625
+
626
+ def _latest_mtime(run_directory: Path) -> datetime | None:
627
+ newest: int | None = None
628
+ for name in ("mvp_report.json", "transcript.jsonl", "mvp_manifest.json"):
629
+ path = run_directory / name
630
+ if not path.exists():
631
+ continue
632
+ try:
633
+ stamp = path.stat().st_mtime_ns
634
+ except OSError:
635
+ continue
636
+ newest = stamp if newest is None else max(newest, stamp)
637
+ if newest is None:
638
+ return None
639
+ return datetime.fromtimestamp(newest / 1_000_000_000, UTC)
640
+
641
+
642
+ def _short_argv(argv: tuple[str, ...] | list[str], *, limit: int = 72) -> str:
643
+ text = " ".join(str(item) for item in argv)
644
+ if len(text) > limit:
645
+ return text[: limit - 3] + "..."
646
+ return text or "(no arguments)"
647
+
648
+
649
+ def _existing_path(path: Path) -> str | None:
650
+ return str(path) if path.exists() else None
651
+
652
+
653
+ def _as_int(value: Any) -> int | None:
654
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
655
+ return None
656
+ return int(value)
657
+
658
+
659
+ def _as_float(value: Any) -> float | None:
660
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
661
+ return None
662
+ return float(value)
663
+
664
+
665
+ def _parse_datetime(value: Any) -> datetime | None:
666
+ if isinstance(value, datetime):
667
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
668
+ if not isinstance(value, str) or not value:
669
+ return None
670
+ try:
671
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
672
+ except ValueError:
673
+ return None
674
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
675
+
676
+
677
+ def _iteration_of(record: dict[str, Any]) -> int | None:
678
+ value = record.get("iteration")
679
+ return value if isinstance(value, int) and value >= 1 else None
680
+
681
+
682
+ def _action_from_assistant(record: dict[str, Any]) -> MVPAgentAction | None:
683
+ content = record.get("content")
684
+ if not isinstance(content, str) or not content.strip():
685
+ return None
686
+ try:
687
+ return parse_mvp_action(content)
688
+ except ValueError:
689
+ return None
690
+
691
+
692
+ def _tool_payload(record: dict[str, Any]) -> dict[str, Any] | None:
693
+ content = record.get("content")
694
+ if not isinstance(content, str):
695
+ return None
696
+ try:
697
+ payload = json.loads(content)
698
+ except json.JSONDecodeError:
699
+ return None
700
+ if not isinstance(payload, dict):
701
+ return None
702
+ tool = payload.get("tool_result")
703
+ return tool if isinstance(tool, dict) else None
704
+
705
+
706
+ class MVPRunMonitor:
707
+ """Incrementally project a run directory into an operator-facing snapshot."""
708
+
709
+ def __init__(self, run_directory: str | Path) -> None:
710
+ self.root = Path(run_directory).expanduser().resolve()
711
+ self._cursor = TranscriptCursor()
712
+ self._state = _TranscriptState()
713
+ self._sidecar_cache: dict[str, tuple[int | None, dict[str, Any] | None]] = {}
714
+
715
+ def snapshot(self, *, now: datetime | None = None) -> MVPRunSnapshot:
716
+ observed_at = now or utc_now()
717
+ warnings: list[str] = []
718
+ if not self.root.exists():
719
+ raise FileNotFoundError(f"run directory does not exist: {self.root}")
720
+ if not self.root.is_dir():
721
+ raise NotADirectoryError(f"run path is not a directory: {self.root}")
722
+ warnings.extend(self._ingest_transcript(observed_at=observed_at))
723
+ manifest = self._read_sidecar("mvp_manifest.json")
724
+ report = self._read_sidecar("mvp_report.json")
725
+ ledger = self._read_sidecar("hypothesis_ledger.json")
726
+ launch = self._read_sidecar(f"{OPERATOR_INPUT_DIR}/launch.json")
727
+ if (self.root / "mvp_manifest.json").exists() and manifest is None:
728
+ warnings.append("mvp_manifest.json exists but could not be parsed")
729
+ if (self.root / "mvp_report.json").exists() and report is None:
730
+ warnings.append("mvp_report.json exists but could not be parsed")
731
+ if (self.root / "hypothesis_ledger.json").exists() and ledger is None:
732
+ warnings.append("hypothesis_ledger.json exists but could not be parsed")
733
+ identity = self._identity(manifest, report, ledger, launch)
734
+ phase = self._phase(report)
735
+ current = None if report is not None else self._current_action()
736
+ claims = self._claims(ledger, report, current)
737
+ heartbeat = self._heartbeat(observed_at)
738
+ terminal = self._terminal_report(report)
739
+ configured = None
740
+ if isinstance(identity.config.get("max_wall_seconds"), (int, float)):
741
+ configured = float(identity.config["max_wall_seconds"])
742
+ elapsed, elapsed_is_estimate = self._elapsed(
743
+ report=report,
744
+ configured=configured,
745
+ heartbeat=heartbeat,
746
+ observed_at=observed_at,
747
+ )
748
+ workspace_size = 0
749
+ if heartbeat is not None and heartbeat.workspace_bytes is not None:
750
+ workspace_size = heartbeat.workspace_bytes
751
+ else:
752
+ workspace_size = directory_bytes(self.root / "workspace")
753
+ open_ids = tuple(claim.id for claim in claims if claim.status == "open")
754
+ closed_ids = tuple(claim.id for claim in claims if claim.status != "open")
755
+ if terminal is not None:
756
+ if terminal.open_claim_ids:
757
+ open_ids = terminal.open_claim_ids
758
+ if terminal.closed_claim_ids:
759
+ closed_ids = terminal.closed_claim_ids
760
+ warnings.extend(self._state.parse_warnings[-8:])
761
+ control = read_control(self.root)
762
+ pending = (
763
+ control.command.value
764
+ if control.command is not ControlCommand.NONE
765
+ else None
766
+ )
767
+ return MVPRunSnapshot(
768
+ phase=phase,
769
+ phase_label=phase_label(phase),
770
+ identity=identity,
771
+ configured_wall_seconds=configured,
772
+ elapsed_wall_seconds=elapsed,
773
+ elapsed_is_estimate=elapsed_is_estimate,
774
+ iterations=len(self._state.assistant),
775
+ action_counts=dict(self._state.action_counts),
776
+ claims=claims,
777
+ open_claim_ids=open_ids,
778
+ closed_claim_ids=closed_ids,
779
+ current_action=current,
780
+ latest_heartbeat=heartbeat,
781
+ recent_events=tuple(self._state.events[-DEFAULT_RECENT_EVENT_LIMIT:]),
782
+ report=terminal,
783
+ artifacts=self._artifact_paths(),
784
+ workspace_bytes=workspace_size,
785
+ last_model=self._state.last_model,
786
+ last_capability=self._state.last_capability,
787
+ token_usage=self._token_usage(),
788
+ pending_control=pending,
789
+ warnings=tuple(dict.fromkeys(warnings)),
790
+ transcript_cursor=self._cursor,
791
+ )
792
+
793
+ def _ingest_transcript(self, *, observed_at: datetime) -> list[str]:
794
+ transcript = self.root / "transcript.jsonl"
795
+ previous = self._cursor
796
+ records, cursor, warnings = read_new_transcript_records(transcript, previous)
797
+ if previous.inode is not None and (
798
+ cursor.inode != previous.inode or cursor.offset < previous.offset
799
+ ):
800
+ self._state.reset()
801
+ self._cursor = cursor
802
+ recorded_fallback = observed_at
803
+ try:
804
+ recorded_fallback = datetime.fromtimestamp(transcript.stat().st_mtime, UTC)
805
+ except OSError:
806
+ recorded_fallback = observed_at
807
+ for record in records:
808
+ self._ingest_record(record, observed_at=recorded_fallback)
809
+ return list(warnings)
810
+
811
+ def _ingest_record(self, record: dict[str, Any], *, observed_at: datetime) -> None:
812
+ kind = str(record.get("kind") or "unknown")
813
+ iteration = _iteration_of(record)
814
+ if kind == "assistant":
815
+ if iteration is None:
816
+ return
817
+ self._state.assistant[iteration] = record
818
+ if isinstance(record.get("model"), str):
819
+ self._state.last_model = record["model"]
820
+ if isinstance(record.get("route"), str):
821
+ self._state.last_route = record["route"]
822
+ action = _action_from_assistant(record)
823
+ action_name = action.action.value if action is not None else None
824
+ self._accumulate_usage(record)
825
+ if action is not None:
826
+ self._state.action_counts[action.action.value] += 1
827
+ details = action_details(action)
828
+ if details.get("capability"):
829
+ self._state.last_capability = str(details["capability"])
830
+ summary = humanize_action(action)
831
+ else:
832
+ summary = "Model turn recorded; no typed action parsed"
833
+ self._state.parse_warnings.append(
834
+ f"assistant iteration {iteration} has no typed action"
835
+ )
836
+ self._push_event(
837
+ HumanizedEvent(
838
+ kind="assistant",
839
+ iteration=iteration,
840
+ summary=summary,
841
+ action_name=action_name,
842
+ )
843
+ )
844
+ return
845
+ if kind == "tool":
846
+ if iteration is not None:
847
+ self._state.tools[iteration] = record
848
+ payload = _tool_payload(record)
849
+ action = None
850
+ if iteration in self._state.assistant:
851
+ action = _action_from_assistant(self._state.assistant[iteration])
852
+ if payload is None:
853
+ summary = f"Tool result recorded for iteration {iteration or '?'}"
854
+ elif payload.get("ok") is True:
855
+ summary = (
856
+ f"Completed {humanize_action(action)}"
857
+ if action is not None
858
+ else f"Completed action at iteration {iteration}"
859
+ )
860
+ else:
861
+ error = str(payload.get("error") or "unknown error")
862
+ summary = f"Action failed: {error}"
863
+ self._push_event(
864
+ HumanizedEvent(
865
+ kind="tool",
866
+ iteration=iteration,
867
+ summary=summary,
868
+ action_name=action.action.value if action is not None else None,
869
+ )
870
+ )
871
+ return
872
+ if kind == "tool_heartbeat":
873
+ self._state.last_heartbeat = record
874
+ recorded_at = _parse_datetime(record.get("recorded_at"))
875
+ self._state.last_heartbeat_observed_at = recorded_at or observed_at
876
+ elapsed = _as_float(record.get("elapsed_wall_seconds"))
877
+ workspace = _as_int(record.get("workspace_bytes"))
878
+ parts = ["Command heartbeat"]
879
+ if elapsed is not None:
880
+ parts.append(f"elapsed {format_duration(elapsed)}")
881
+ if workspace is not None:
882
+ parts.append(f"workspace {format_bytes(workspace)}")
883
+ self._push_event(
884
+ HumanizedEvent(
885
+ kind="tool_heartbeat",
886
+ iteration=iteration,
887
+ summary=", ".join(parts),
888
+ action_name=None,
889
+ )
890
+ )
891
+ return
892
+ if kind == "control":
893
+ event = str(record.get("event") or "control event")
894
+ action_name = record.get("action") if isinstance(record.get("action"), str) else None
895
+ if event == "campaign_cancelled":
896
+ summary = "Campaign cancelled"
897
+ elif event == "campaign_paused":
898
+ summary = "Paused at the next action boundary"
899
+ elif event == "interrupted_action_recovered":
900
+ summary = (
901
+ f"Recovered interrupted {action_name}"
902
+ if action_name
903
+ else "Recovered interrupted action"
904
+ )
905
+ elif event == "model_completion_retry":
906
+ attempt = record.get("attempt")
907
+ summary = f"Retrying model completion (attempt {attempt})"
908
+ else:
909
+ summary = event.replace("_", " ")
910
+ self._push_event(
911
+ HumanizedEvent(
912
+ kind="control",
913
+ iteration=iteration,
914
+ summary=summary,
915
+ action_name=action_name,
916
+ )
917
+ )
918
+
919
+ def _push_event(self, event: HumanizedEvent) -> None:
920
+ self._state.next_event_sequence += 1
921
+ self._state.events.append(
922
+ event.model_copy(update={"sequence": self._state.next_event_sequence})
923
+ )
924
+ if len(self._state.events) > _MAX_RETAINED_EVENTS:
925
+ self._state.events = self._state.events[-_MAX_RETAINED_EVENTS:]
926
+
927
+ def _read_sidecar(self, relative: str) -> dict[str, Any] | None:
928
+ path = self.root / relative
929
+ if not path.is_file():
930
+ self._sidecar_cache.pop(relative, None)
931
+ return None
932
+ try:
933
+ mtime_ns = path.stat().st_mtime_ns
934
+ except OSError:
935
+ return None
936
+ cached = self._sidecar_cache.get(relative)
937
+ if cached is not None and cached[0] == mtime_ns:
938
+ return cached[1]
939
+ payload = load_json_object(path)
940
+ self._sidecar_cache[relative] = (mtime_ns, payload)
941
+ return payload
942
+
943
+ def _identity(
944
+ self,
945
+ manifest: dict[str, Any] | None,
946
+ report: dict[str, Any] | None,
947
+ ledger: dict[str, Any] | None,
948
+ launch: dict[str, Any] | None,
949
+ ) -> RunIdentity:
950
+ hypothesis = None
951
+ instruction = None
952
+ config: dict[str, Any] = {}
953
+ schema_version = None
954
+ skill_hashes: dict[str, str] = {}
955
+ capability_hashes: dict[str, str] = {}
956
+ campaign_id = None
957
+ if launch and isinstance(launch.get("campaign_id"), str):
958
+ campaign_id = launch["campaign_id"]
959
+ if campaign_id is None:
960
+ campaign_id = self.root.name
961
+ source = manifest or {}
962
+ if source:
963
+ if isinstance(source.get("hypothesis"), str):
964
+ hypothesis = source["hypothesis"]
965
+ if source.get("campaign_instruction") is None or isinstance(
966
+ source.get("campaign_instruction"), str
967
+ ):
968
+ instruction = source.get("campaign_instruction")
969
+ if isinstance(source.get("schema_version"), str):
970
+ schema_version = source["schema_version"]
971
+ if isinstance(source.get("config"), dict):
972
+ config = source["config"]
973
+ if isinstance(source.get("skill_hashes"), dict):
974
+ skill_hashes = {
975
+ str(key): str(value) for key, value in source["skill_hashes"].items()
976
+ }
977
+ if isinstance(source.get("capability_hashes"), dict):
978
+ capability_hashes = {
979
+ str(key): str(value)
980
+ for key, value in source["capability_hashes"].items()
981
+ }
982
+ if report:
983
+ if hypothesis is None and isinstance(report.get("hypothesis"), str):
984
+ hypothesis = report["hypothesis"]
985
+ if instruction is None and (
986
+ report.get("campaign_instruction") is None
987
+ or isinstance(report.get("campaign_instruction"), str)
988
+ ):
989
+ instruction = report.get("campaign_instruction")
990
+ if ledger and hypothesis is None and isinstance(ledger.get("root_hypothesis"), str):
991
+ hypothesis = ledger["root_hypothesis"]
992
+ operator_hypothesis = self.root / OPERATOR_INPUT_DIR / "hypothesis.txt"
993
+ if hypothesis is None and operator_hypothesis.is_file():
994
+ try:
995
+ text = operator_hypothesis.read_text().strip()
996
+ except OSError:
997
+ text = ""
998
+ if text:
999
+ hypothesis = text
1000
+ return RunIdentity(
1001
+ run_directory=str(self.root),
1002
+ campaign_id=campaign_id,
1003
+ hypothesis=hypothesis,
1004
+ campaign_instruction=instruction,
1005
+ manifest_schema_version=schema_version,
1006
+ config=config,
1007
+ skill_hashes=skill_hashes,
1008
+ capability_hashes=capability_hashes,
1009
+ )
1010
+
1011
+ def _phase(self, report: dict[str, Any] | None) -> RunPhase:
1012
+ if report is not None:
1013
+ status = report.get("status")
1014
+ if status == "completed":
1015
+ return RunPhase.COMPLETED
1016
+ if status == "cancelled":
1017
+ return RunPhase.CANCELLED
1018
+ if status == "provider_failed":
1019
+ return RunPhase.PROVIDER_FAILED
1020
+ if status == "budget_exhausted":
1021
+ return RunPhase.BUDGET_EXHAUSTED
1022
+ # An unrecognized report is still a terminal document, not "running".
1023
+ return RunPhase.INCOMPLETE
1024
+ pause = read_pause_state(self.root)
1025
+ clock = read_clock(self.root)
1026
+ if pause is not None or (clock is not None and clock.state == "paused"):
1027
+ return RunPhase.PAUSED
1028
+ if self._state.assistant or self._state.tools or self._state.events:
1029
+ return RunPhase.INCOMPLETE
1030
+ transcript = self.root / "transcript.jsonl"
1031
+ if transcript.is_file():
1032
+ try:
1033
+ if transcript.stat().st_size > 0:
1034
+ return RunPhase.INCOMPLETE
1035
+ except OSError:
1036
+ return RunPhase.INCOMPLETE
1037
+ if (self.root / "mvp_manifest.json").is_file():
1038
+ return RunPhase.INITIALIZED
1039
+ return RunPhase.INCOMPLETE
1040
+
1041
+ def _current_action(self) -> CurrentAction | None:
1042
+ pending_iterations = [
1043
+ iteration
1044
+ for iteration in self._state.assistant
1045
+ if iteration not in self._state.tools
1046
+ ]
1047
+ if not pending_iterations:
1048
+ return None
1049
+ iteration = max(pending_iterations)
1050
+ record = self._state.assistant[iteration]
1051
+ action = _action_from_assistant(record)
1052
+ details = action_details(action) if action is not None else {}
1053
+ description = (
1054
+ humanize_action(action)
1055
+ if action is not None
1056
+ else "Model turn recorded; waiting for a tool result"
1057
+ )
1058
+ argv = details.get("argv") or ()
1059
+ return CurrentAction(
1060
+ iteration=iteration,
1061
+ action_name=details.get("action_name")
1062
+ if action is not None
1063
+ else None,
1064
+ description=description,
1065
+ pending=True,
1066
+ capability=details.get("capability"),
1067
+ stage=details.get("stage"),
1068
+ active_claim_id=details.get("active_claim_id"),
1069
+ path=details.get("path"),
1070
+ model=record.get("model") if isinstance(record.get("model"), str) else None,
1071
+ route=record.get("route") if isinstance(record.get("route"), str) else None,
1072
+ argv=tuple(str(item) for item in argv),
1073
+ )
1074
+
1075
+ def _claims(
1076
+ self,
1077
+ ledger: dict[str, Any] | None,
1078
+ report: dict[str, Any] | None,
1079
+ current: CurrentAction | None,
1080
+ ) -> tuple[ClaimSummary, ...]:
1081
+ payload = None
1082
+ if ledger and isinstance(ledger.get("claims"), list):
1083
+ payload = ledger["claims"]
1084
+ elif report and isinstance(report.get("claim_ledger"), dict):
1085
+ claims = report["claim_ledger"].get("claims")
1086
+ if isinstance(claims, list):
1087
+ payload = claims
1088
+ if not payload:
1089
+ return ()
1090
+ active_id = current.active_claim_id if current is not None else None
1091
+ summaries: list[ClaimSummary] = []
1092
+ for item in payload:
1093
+ if not isinstance(item, dict):
1094
+ continue
1095
+ claim_id = item.get("id")
1096
+ if not isinstance(claim_id, str) or not claim_id:
1097
+ continue
1098
+ evidence = item.get("evidence") or []
1099
+ contracts = item.get("evidence_contracts") or []
1100
+ summaries.append(
1101
+ ClaimSummary(
1102
+ id=claim_id,
1103
+ status=str(item.get("status") or "open"),
1104
+ kind=str(item["kind"]) if isinstance(item.get("kind"), str) else None,
1105
+ relation=(
1106
+ str(item["relation"])
1107
+ if isinstance(item.get("relation"), str)
1108
+ else None
1109
+ ),
1110
+ parent_id=(
1111
+ str(item["parent_id"])
1112
+ if isinstance(item.get("parent_id"), str)
1113
+ else None
1114
+ ),
1115
+ statement=str(item.get("statement") or ""),
1116
+ evidence_count=len(evidence) if isinstance(evidence, list) else 0,
1117
+ contract_count=len(contracts) if isinstance(contracts, list) else 0,
1118
+ closed_reason=(
1119
+ str(item["closed_reason"])
1120
+ if isinstance(item.get("closed_reason"), str)
1121
+ else None
1122
+ ),
1123
+ active=claim_id == active_id,
1124
+ )
1125
+ )
1126
+ return tuple(summaries)
1127
+
1128
+ def _heartbeat(self, observed_at: datetime) -> HeartbeatObservation | None:
1129
+ record = self._state.last_heartbeat
1130
+ if not record:
1131
+ return None
1132
+ iteration = _iteration_of(record)
1133
+ if iteration is None:
1134
+ return None
1135
+ seen_at = self._state.last_heartbeat_observed_at
1136
+ age = None
1137
+ if seen_at is not None:
1138
+ age = max(0.0, (observed_at - seen_at).total_seconds())
1139
+ return HeartbeatObservation(
1140
+ iteration=iteration,
1141
+ elapsed_wall_seconds=_as_float(record.get("elapsed_wall_seconds")),
1142
+ stdout_bytes=_as_int(record.get("stdout_bytes")),
1143
+ stderr_bytes=_as_int(record.get("stderr_bytes")),
1144
+ workspace_bytes=_as_int(record.get("workspace_bytes")),
1145
+ observed_at=seen_at,
1146
+ age_seconds=age,
1147
+ )
1148
+
1149
+ def _terminal_report(
1150
+ self, report: dict[str, Any] | None
1151
+ ) -> TerminalReportSummary | None:
1152
+ if not report:
1153
+ return None
1154
+ status = report.get("status")
1155
+ if not isinstance(status, str):
1156
+ return None
1157
+ artifacts = report.get("workspace_artifacts")
1158
+ artifact_count = len(artifacts) if isinstance(artifacts, dict) else 0
1159
+ open_ids = tuple(
1160
+ str(item) for item in report.get("open_claim_ids") or [] if item
1161
+ )
1162
+ closed_ids = tuple(
1163
+ str(item) for item in report.get("closed_claim_ids") or [] if item
1164
+ )
1165
+ notes = tuple(
1166
+ str(item) for item in report.get("finish_claim_notes") or [] if item
1167
+ )
1168
+ return TerminalReportSummary(
1169
+ status=status,
1170
+ final_answer=str(report.get("final_answer") or ""),
1171
+ iterations=max(0, _as_int(report.get("iterations")) or 0),
1172
+ elapsed_wall_seconds=max(0.0, _as_float(report.get("elapsed_wall_seconds")) or 0.0),
1173
+ started_at=_parse_datetime(report.get("started_at")),
1174
+ finished_at=_parse_datetime(report.get("finished_at")),
1175
+ open_claim_ids=open_ids,
1176
+ closed_claim_ids=closed_ids,
1177
+ finish_claim_notes=notes,
1178
+ workspace_artifact_count=artifact_count,
1179
+ )
1180
+
1181
+ def _elapsed(
1182
+ self,
1183
+ *,
1184
+ report: dict[str, Any] | None,
1185
+ configured: float | None,
1186
+ heartbeat: HeartbeatObservation | None,
1187
+ observed_at: datetime,
1188
+ ) -> tuple[float | None, bool]:
1189
+ clock = read_clock(self.root)
1190
+ if clock is not None:
1191
+ return elapsed_from_clock(clock, now=observed_at), False
1192
+ if report is not None:
1193
+ value = _as_float(report.get("elapsed_wall_seconds"))
1194
+ if value is not None:
1195
+ return max(0.0, value), False
1196
+ started = None
1197
+ if report is not None:
1198
+ started = _parse_datetime(report.get("started_at"))
1199
+ if started is None:
1200
+ started = _latest_mtime(self.root)
1201
+ if started is None:
1202
+ return None, False
1203
+ return max(0.0, (observed_at - started).total_seconds()), True
1204
+ elapsed = max(0.0, (observed_at - started).total_seconds())
1205
+ if configured is not None:
1206
+ elapsed = min(elapsed, configured)
1207
+ if heartbeat is not None and heartbeat.elapsed_wall_seconds is not None:
1208
+ # Heartbeat is command-local; keep directory estimate for campaign clock.
1209
+ return elapsed, True
1210
+ return elapsed, True
1211
+
1212
+ def _accumulate_usage(self, record: dict[str, Any]) -> None:
1213
+ usage = parse_usage_payload(record.get("usage"))
1214
+ has_usage = any(
1215
+ usage[key] for key in ("prompt_tokens", "completion_tokens", "total_tokens")
1216
+ )
1217
+ if not has_usage:
1218
+ self._state.missing_usage_turns += 1
1219
+ return
1220
+ self._state.usage_turns += 1
1221
+ self._state.prompt_tokens += usage["prompt_tokens"]
1222
+ self._state.completion_tokens += usage["completion_tokens"]
1223
+ self._state.total_tokens += usage["total_tokens"]
1224
+ self._state.cached_tokens += usage["cached_tokens"]
1225
+ self._state.reasoning_tokens += usage["reasoning_tokens"]
1226
+ model = record.get("model") if isinstance(record.get("model"), str) else "unknown"
1227
+ bucket = self._state.model_usage.setdefault(
1228
+ model,
1229
+ {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "turns": 0},
1230
+ )
1231
+ bucket["prompt_tokens"] += usage["prompt_tokens"]
1232
+ bucket["completion_tokens"] += usage["completion_tokens"]
1233
+ bucket["total_tokens"] += usage["total_tokens"]
1234
+ bucket["turns"] += 1
1235
+
1236
+ def _token_usage(self) -> TokenUsageSummary:
1237
+ by_model = tuple(
1238
+ ModelTokenUsage(
1239
+ model=name,
1240
+ prompt_tokens=values["prompt_tokens"],
1241
+ completion_tokens=values["completion_tokens"],
1242
+ total_tokens=values["total_tokens"],
1243
+ turns=values["turns"],
1244
+ )
1245
+ for name, values in sorted(self._state.model_usage.items())
1246
+ )
1247
+ return TokenUsageSummary(
1248
+ prompt_tokens=self._state.prompt_tokens,
1249
+ completion_tokens=self._state.completion_tokens,
1250
+ total_tokens=self._state.total_tokens,
1251
+ cached_tokens=self._state.cached_tokens,
1252
+ reasoning_tokens=self._state.reasoning_tokens,
1253
+ turns=self._state.usage_turns,
1254
+ turns_missing_usage=self._state.missing_usage_turns,
1255
+ by_model=by_model,
1256
+ label=format_token_counts(
1257
+ prompt_tokens=self._state.prompt_tokens,
1258
+ completion_tokens=self._state.completion_tokens,
1259
+ total_tokens=self._state.total_tokens,
1260
+ turns=self._state.usage_turns,
1261
+ missing_turns=self._state.missing_usage_turns,
1262
+ cached_tokens=self._state.cached_tokens,
1263
+ ),
1264
+ )
1265
+
1266
+ def _artifact_paths(self) -> ArtifactPaths:
1267
+ return ArtifactPaths(
1268
+ run_directory=str(self.root),
1269
+ manifest=_existing_path(self.root / "mvp_manifest.json"),
1270
+ transcript=_existing_path(self.root / "transcript.jsonl"),
1271
+ report=_existing_path(self.root / "mvp_report.json"),
1272
+ claim_ledger=_existing_path(self.root / "hypothesis_ledger.json"),
1273
+ claim_summary=_existing_path(self.root / "claim_summary.md"),
1274
+ artifact_provenance=_existing_path(self.root / "artifact_provenance.json"),
1275
+ workspace=_existing_path(self.root / "workspace"),
1276
+ controller_log=_existing_path(self.root / "controller.log"),
1277
+ operator_input=_existing_path(self.root / OPERATOR_INPUT_DIR),
1278
+ )
1279
+
1280
+
1281
+ def load_run_snapshot(
1282
+ run_directory: str | Path,
1283
+ *,
1284
+ now: datetime | None = None,
1285
+ ) -> MVPRunSnapshot:
1286
+ return MVPRunMonitor(run_directory).snapshot(now=now)
1287
+
1288
+
1289
+ def format_human_status(snapshot: MVPRunSnapshot) -> str:
1290
+ identity = snapshot.identity
1291
+ campaign = identity.campaign_id or Path(identity.run_directory).name
1292
+ lines = [
1293
+ f"Campaign: {campaign} {snapshot.phase_label}",
1294
+ f"directory: {identity.run_directory}",
1295
+ ]
1296
+ elapsed = format_duration(snapshot.elapsed_wall_seconds)
1297
+ budget = format_duration(snapshot.configured_wall_seconds)
1298
+ estimate = " ~" if snapshot.elapsed_is_estimate else " "
1299
+ heartbeat = "heartbeat --"
1300
+ if snapshot.latest_heartbeat is not None:
1301
+ heartbeat = f"heartbeat {format_age(snapshot.latest_heartbeat.age_seconds)}"
1302
+ lines.append(f"elapsed{estimate}{elapsed} / {budget} {heartbeat}")
1303
+ model = snapshot.last_model
1304
+ if snapshot.current_action is not None and snapshot.current_action.model:
1305
+ model = snapshot.current_action.model
1306
+ capability = snapshot.last_capability
1307
+ if snapshot.current_action and snapshot.current_action.capability:
1308
+ capability = snapshot.current_action.capability
1309
+ lines.append(
1310
+ f"model: {model or '-'} capability: {capability or '-'}"
1311
+ )
1312
+ lines.append(f"iterations: {snapshot.iterations}")
1313
+ lines.append(snapshot.token_usage.label)
1314
+ if snapshot.pending_control:
1315
+ lines.append(f"pending control: {snapshot.pending_control} at next action boundary")
1316
+ lines.append("")
1317
+ lines.append("Root hypothesis")
1318
+ hypothesis = (identity.hypothesis or "(not yet recorded)").strip()
1319
+ for raw in hypothesis.splitlines() or ["(not yet recorded)"]:
1320
+ lines.append(f" {raw}")
1321
+ if identity.campaign_instruction:
1322
+ lines.append("")
1323
+ lines.append("Operational instruction")
1324
+ for raw in identity.campaign_instruction.strip().splitlines():
1325
+ lines.append(f" {raw}")
1326
+ lines.append("")
1327
+ lines.append("Claims")
1328
+ if not snapshot.claims:
1329
+ lines.append(" (no claims recorded)")
1330
+ for claim in snapshot.claims:
1331
+ marker = claim_status_marker(claim.status)
1332
+ active = " current" if claim.active else ""
1333
+ lines.append(
1334
+ f" {marker} {claim.id:<28} {claim.status:<18} "
1335
+ f"evidence {claim.evidence_count}{active}"
1336
+ )
1337
+ lines.append("")
1338
+ lines.append("Current action")
1339
+ if snapshot.current_action is None:
1340
+ if snapshot.phase in {
1341
+ RunPhase.COMPLETED,
1342
+ RunPhase.CANCELLED,
1343
+ RunPhase.PROVIDER_FAILED,
1344
+ RunPhase.BUDGET_EXHAUSTED,
1345
+ }:
1346
+ lines.append(" (campaign has a terminal report)")
1347
+ elif snapshot.phase is RunPhase.PAUSED:
1348
+ lines.append(" (paused at an action boundary; resume to continue)")
1349
+ else:
1350
+ lines.append(" (no pending action)")
1351
+ else:
1352
+ action = snapshot.current_action
1353
+ lines.append(f" {action.description}")
1354
+ details: list[str] = [f"iteration={action.iteration}"]
1355
+ if action.stage:
1356
+ details.append(f"stage={action.stage}")
1357
+ if snapshot.latest_heartbeat and snapshot.latest_heartbeat.elapsed_wall_seconds is not None:
1358
+ details.append(
1359
+ f"elapsed={int(snapshot.latest_heartbeat.elapsed_wall_seconds)} s"
1360
+ )
1361
+ details.append(f"workspace={format_bytes(snapshot.workspace_bytes)}")
1362
+ lines.append(" " + ", ".join(details))
1363
+ lines.append("")
1364
+ lines.append("Recent activity")
1365
+ events = snapshot.recent_events[-8:]
1366
+ if not events:
1367
+ lines.append(" (no transcript events yet)")
1368
+ for event in events:
1369
+ lines.append(f" {event.summary}")
1370
+ if snapshot.report is not None:
1371
+ lines.append("")
1372
+ lines.append(f"Terminal report: {snapshot.report.status}")
1373
+ answer = snapshot.report.final_answer.strip()
1374
+ if answer:
1375
+ lines.append("Final answer")
1376
+ preview = answer if len(answer) <= 1200 else answer[:1197] + "..."
1377
+ for raw in preview.splitlines():
1378
+ lines.append(f" {raw}")
1379
+ if snapshot.report.finish_claim_notes:
1380
+ lines.append("Finish notes")
1381
+ for note in snapshot.report.finish_claim_notes:
1382
+ lines.append(f" {note}")
1383
+ if snapshot.warnings:
1384
+ lines.append("")
1385
+ lines.append("Warnings")
1386
+ for warning in snapshot.warnings:
1387
+ lines.append(f" {warning}")
1388
+ return "\n".join(lines) + "\n"
1389
+
1390
+
1391
+ def watch_run(
1392
+ run_directory: str | Path,
1393
+ *,
1394
+ jsonl: bool = False,
1395
+ poll_seconds: float = 0.5,
1396
+ output: TextIO | None = None,
1397
+ sleep: Callable[[float], None] = time.sleep,
1398
+ should_stop: Callable[[], bool] | None = None,
1399
+ now: Callable[[], datetime] = utc_now,
1400
+ ) -> int:
1401
+ """Follow durable records until a terminal report appears.
1402
+
1403
+ Interrupting this viewer does not cancel the scientific campaign.
1404
+ """
1405
+
1406
+ stream = output if output is not None else sys.stdout
1407
+ monitor = MVPRunMonitor(run_directory)
1408
+ last_event_sequence = 0
1409
+ last_cursor = TranscriptCursor()
1410
+ last_signature: tuple[Any, ...] | None = None
1411
+ try:
1412
+ while True:
1413
+ snapshot = monitor.snapshot(now=now())
1414
+ cursor = snapshot.transcript_cursor
1415
+ if last_cursor.inode is not None and (
1416
+ cursor.inode != last_cursor.inode or cursor.offset < last_cursor.offset
1417
+ ):
1418
+ last_event_sequence = 0
1419
+ last_cursor = cursor
1420
+ events = snapshot.recent_events
1421
+ new_events = tuple(
1422
+ event for event in events if event.sequence > last_event_sequence
1423
+ )
1424
+ if events:
1425
+ last_event_sequence = max(
1426
+ last_event_sequence,
1427
+ max(event.sequence for event in events),
1428
+ )
1429
+ signature = (
1430
+ snapshot.phase,
1431
+ snapshot.iterations,
1432
+ snapshot.current_action.description if snapshot.current_action else None,
1433
+ snapshot.latest_heartbeat.age_seconds if snapshot.latest_heartbeat else None,
1434
+ len(snapshot.claims),
1435
+ snapshot.report.status if snapshot.report else None,
1436
+ )
1437
+ if jsonl:
1438
+ stream.write(snapshot.model_dump_json() + "\n")
1439
+ stream.flush()
1440
+ elif signature != last_signature or new_events:
1441
+ if last_signature is None:
1442
+ stream.write(format_human_status(snapshot))
1443
+ else:
1444
+ for event in new_events:
1445
+ stream.write(f" {event.summary}\n")
1446
+ if signature[0] != last_signature[0] or snapshot.report is not None:
1447
+ stream.write(format_human_status(snapshot))
1448
+ stream.flush()
1449
+ last_signature = signature
1450
+ if snapshot.phase in {
1451
+ RunPhase.COMPLETED,
1452
+ RunPhase.CANCELLED,
1453
+ RunPhase.PROVIDER_FAILED,
1454
+ RunPhase.BUDGET_EXHAUSTED,
1455
+ RunPhase.PAUSED,
1456
+ }:
1457
+ return 0
1458
+ if should_stop is not None and should_stop():
1459
+ return 0
1460
+ sleep(max(0.05, poll_seconds))
1461
+ except KeyboardInterrupt:
1462
+ if not jsonl:
1463
+ stream.write("watch stopped; the campaign was not cancelled\n")
1464
+ stream.flush()
1465
+ return 0
1466
+
1467
+
1468
+ def installed_capability_descriptors(
1469
+ project_root: str | Path | None = None,
1470
+ ) -> tuple[dict[str, Any], ...]:
1471
+ """Return generic descriptors of currently installed capabilities."""
1472
+
1473
+ from .mvp_skills import discover_builtin_mvp_resources
1474
+
1475
+ _skills, capabilities = discover_builtin_mvp_resources(project_root)
1476
+ descriptors: list[dict[str, Any]] = []
1477
+ for item in capabilities.descriptors():
1478
+ descriptors.append(
1479
+ {
1480
+ "name": item.get("name"),
1481
+ "version": item.get("version"),
1482
+ "description": item.get("description"),
1483
+ "skill": item.get("skill"),
1484
+ "runtime": item.get("runtime"),
1485
+ }
1486
+ )
1487
+ return tuple(descriptors)