davinci-resolve-mcp 2.206.0 → 2.207.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.207.0 — execution audit report exports
6
+
7
+ Contributed in PR #185.
8
+
9
+ ### Added
10
+
11
+ - **Execution traces can now be exported as reviewable audit reports.**
12
+ `resolve_control(action="export_execution_report")` writes the latest trace,
13
+ or a named `execution_id`, as Markdown or JSON. The report carries the
14
+ request, status, start/end timestamps, duration, tool summary, semantic
15
+ deltas, verification rollup, warnings, notes, and optional per-step table.
16
+ - **Reports default beside the trace log.** When no path is passed, reports are
17
+ written under `logs/execution-reports/<execution_id>.md` or `.json`.
18
+ `RESOLVE_MCP_TRACE_REPORT_DIR` can move that default destination without
19
+ changing where append-only trace events are logged.
20
+ - **Exports are observer-safe.** Creating a report is exempt from execution-step
21
+ recording, just like querying traces, so inspecting or exporting a trace
22
+ cannot mutate the trace being reviewed.
23
+ - **Existing files are protected by default.** A caller must pass
24
+ `overwrite=true` to replace a report at the chosen path.
25
+
26
+ ### Notes
27
+
28
+ - The export is built from the existing trace summary fields, not raw tool
29
+ arguments or raw tool results. It is meant for review and audit, not a replay
30
+ script.
31
+ - Added focused unit and server integration coverage for Markdown export, JSON
32
+ export, step omission, invalid formats, overwrite protection, observer
33
+ isolation, and `resolve_control` dispatch.
34
+
35
+ ### Fixed on the way in
36
+
37
+ - **An unverified run no longer reports itself as passed.** The verification
38
+ rollup collapsed "nothing reported any evidence" into `passed: True`, and the
39
+ report printed it verbatim — so a workflow where nothing was checked produced
40
+ an audit document reading `Status: unverified` on one line and `Passed: yes`
41
+ on the next. Those sit inches apart and only one of them gets scanned. The
42
+ rollup now carries `None` for the unknown case and the Passed row renders
43
+ "not established — no checks recorded"; a real pass still says yes and a real
44
+ failure still says no. This is the same distinction v2.206.0 documented for
45
+ `verification.status`, and the export is exactly where it stops being a
46
+ nuance and starts being a claim on paper.
47
+ - **The Simplified Chinese README was carrying a false version line.** Its
48
+ badge and "本翻译对应 vX.Y.Z 版 README" line were bumped to 2.207.0 without the
49
+ section itself, which is the specific failure the release process calls out —
50
+ a lagging translation whose version line asserts otherwise. Translated.
51
+ - `path` is documented as honoured-as-given, creating directories to reach the
52
+ destination: deliberate, since a conform's paperwork belongs beside the
53
+ conform rather than in `logs/`, but worth stating next to a source-media
54
+ safety policy. The `execution_id` route is sanitised to a bare filename and
55
+ cannot escape the report directory — verified.
56
+
5
57
  ## What's New in v2.206.0 — agent execution traces
6
58
 
7
59
  Adapted from the design contributed in PR #183.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.206.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.207.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
@@ -274,6 +274,16 @@ semantic deltas (`items_deleted`, `items_added`), and readback verifications.
274
274
  Agents and editors can inspect workflows via `resolve_control`:
275
275
  `get_execution_trace(execution_id?)`, `list_recent_executions()`, or open a
276
276
  scoped execution with `begin_execution(request="...")` / `end_execution()`.
277
+ `export_execution_report(execution_id?, format="markdown"|"json")` writes a
278
+ reviewable audit artifact with the same summary, defaulting to
279
+ `logs/execution-reports/<execution_id>.md`. `path` writes it anywhere you want
280
+ it instead — alongside a conform in a dated TransferFiles folder, say — and
281
+ creates the directories to get there, so check the path before you send it.
282
+ An existing file is never replaced without `overwrite: true`.
283
+
284
+ A report for a run where nothing was verified says **"not established — no
285
+ checks recorded"**, not "passed". Absence of evidence is a question still open,
286
+ and an audit document is the last place to let a reader read it as an all-clear.
277
287
 
278
288
  Traces live in a 100-entry in-memory ring and are appended to
279
289
  `logs/execution-traces.jsonl` beside `server.log` — `RESOLVE_MCP_TRACE_FILE`
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.206.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.207.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.206.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.207.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -172,6 +172,12 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
172
172
 
173
173
  轨迹保存在一个容量 100 条的内存环形缓冲里,并追加写入 `logs/execution-traces.jsonl`(就在 `server.log` 旁边,可用 `RESOLVE_MCP_TRACE_FILE` 改位置),文件到 8 MB 会轮转并保留一份上一代。`list_recent_executions` 会返回该路径以及是否可写,这样"日志是空的"和"根本没在写"就能区分开。记录的内容是工具名、动作、耗时、状态、语义增量和校验结果——不记录参数,也不记录文件路径。唯一的自由文本是你传给 `begin_execution` 的 `request`,在客户项目上请把它当成提交信息来写。
174
174
 
175
+ ### 导出执行审计报告
176
+
177
+ `export_execution_report(execution_id?, format="markdown"|"json")` 会把一条轨迹写成可供审阅的审计文件,默认落在 `logs/execution-reports/<execution_id>.md`。传 `path` 可以写到任何你想要的位置——比如跟着某次套底放进当天的 TransferFiles 文件夹——并且会自动创建沿途的目录,所以发出去之前请先确认路径。已存在的文件不会被覆盖,除非显式传 `overwrite: true`。
178
+
179
+ 如果这次运行根本没有做过校验,报告里写的是**"not established — no checks recorded"(未确立——没有记录任何检查)**,而不是"通过"。没有证据是一个仍然悬而未决的问题;审计文件恰恰是最不该让读者把它读成"一切正常"的地方。
180
+
175
181
  ## 可选增强
176
182
 
177
183
  核心安装刻意保持精简:Python、ffmpeg 和 Resolve 脚本 API。有些功能需要更多依赖,且**每一项都会诚实拒绝并给出自己的安装命令,而不是退化成瞎猜**——编造的节拍或虚构的电平会产出自信但错误的结果,比没有这个功能更糟。
package/docs/SKILL.md CHANGED
@@ -271,6 +271,11 @@ Example trace shape returned by `resolve_control(action="get_execution_trace")`:
271
271
  Fetches the trace for a specific ID, or the most recent execution if omitted.
272
272
  - **`list_recent_executions(limit?)`**: Returns the recent execution traces
273
273
  (newest first, default limit 20).
274
+ - **`export_execution_report(execution_id?, format?, path?, overwrite?, include_steps?)`**:
275
+ Writes a Markdown or JSON audit report for a trace. The default destination is
276
+ `logs/execution-reports/<execution_id>.md`; pass `format: "json"` for
277
+ structured output, `include_steps: false` for a shorter summary, or
278
+ `overwrite: true` to replace an existing report.
274
279
  - **`clear_executions(dry_run?)`**: Clears the in-memory execution trace buffer.
275
280
 
276
281
  Explicit correlation is also supported per-call: pass `params={"execution_id": ...}`
@@ -290,6 +295,20 @@ The only free text is the `request` string passed to `begin_execution`.
290
295
 
291
296
  The file rotates at 8 MB, keeping one previous generation as
292
297
  `execution-traces.jsonl.1`. Both are gitignored along with the rest of `logs/`.
298
+ Audit reports are separate point-in-time exports; `RESOLVE_MCP_TRACE_REPORT_DIR`
299
+ moves their default directory without moving the append-only trace log.
300
+
301
+ `path` is honoured as given — the report can be written anywhere, and the
302
+ directories are created to reach it. That is deliberate (a conform's paperwork
303
+ belongs beside the conform, not in `logs/`), so treat it the way you would any
304
+ other export destination and do not invent a path near source media. The
305
+ `execution_id` route cannot escape the report directory: it is sanitised to a
306
+ filename.
307
+
308
+ A report whose run recorded no verification checks renders **"not established
309
+ — no checks recorded"** in the Passed row, never "yes" — the same rule as
310
+ `verification.status: "unverified"` in the envelope. Do not report such a run
311
+ to the user as verified.
293
312
 
294
313
  ---
295
314
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.206.0"
40
+ VERSION = "2.207.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.206.0",
3
+ "version": "2.207.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.206.0"
90
+ VERSION = "2.207.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.206.0"
14
+ VERSION = "2.207.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -74,6 +74,7 @@ from src.utils.execution_trace import (
74
74
  begin_execution,
75
75
  end_execution,
76
76
  clear_executions,
77
+ export_execution_report,
77
78
  )
78
79
  from src.utils.render_ids import (
79
80
  render_codec_id_from_codecs as _render_codec_id_from_codecs,
@@ -1499,6 +1500,7 @@ def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
1499
1500
  _TRACE_OBSERVER_ACTIONS = {
1500
1501
  "get_execution_trace", "get_execution", "list_recent_executions",
1501
1502
  "clear_executions", "begin_execution", "end_execution",
1503
+ "export_execution_report",
1502
1504
  }
1503
1505
 
1504
1506
 
@@ -16120,6 +16122,8 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16120
16122
  — Open a multi-step execution trace so subsequent tool calls thread under this execution ID.
16121
16123
  end_execution(execution_id?, verification?, status?, notes?) -> {success, trace}
16122
16124
  — Conclude an execution trace and compute final rollups.
16125
+ export_execution_report(execution_id?, format?, path?, overwrite?, include_steps?) -> {success, path, bytes}
16126
+ — Write a Markdown or JSON audit report for an execution trace (no connection needed).
16123
16127
  clear_executions(dry_run?) -> {success, cleared}
16124
16128
  — Clear the in-memory execution trace buffer.
16125
16129
  """
@@ -16249,6 +16253,33 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16249
16253
  if not trace:
16250
16254
  return _err("No active or matching execution to end", code="NOT_FOUND", category="state")
16251
16255
  return {"success": True, "trace": trace}
16256
+ if action == "export_execution_report":
16257
+ exec_id = p.get("execution_id") or p.get("id")
16258
+ report_format = p.get("format") or p.get("report_format") or "markdown"
16259
+ include_steps = _setup_bool(p.get("include_steps", p.get("includeSteps")), True)
16260
+ overwrite = _setup_bool(p.get("overwrite"), False)
16261
+ try:
16262
+ res = _execution_trace.export_execution_report(
16263
+ execution_id=exec_id,
16264
+ report_format=report_format,
16265
+ output_path=p.get("path") or p.get("output_path") or p.get("outputPath"),
16266
+ overwrite=overwrite,
16267
+ include_steps=include_steps,
16268
+ )
16269
+ except FileExistsError as exc:
16270
+ return _err(str(exc), code="REPORT_EXISTS", category="invalid_input")
16271
+ except ValueError as exc:
16272
+ return _err(str(exc), code="INVALID_REPORT_FORMAT", category="invalid_input")
16273
+ except OSError as exc:
16274
+ return _err(
16275
+ "Could not write execution report",
16276
+ code="REPORT_WRITE_FAILED",
16277
+ category="io",
16278
+ reason=str(exc),
16279
+ )
16280
+ if not res:
16281
+ return _err(f"No execution trace found for id: {exec_id or 'latest'}", code="NOT_FOUND", category="state")
16282
+ return res
16252
16283
  if action == "clear_executions":
16253
16284
  dry_run = _setup_bool(p.get("dry_run", p.get("dryRun")), False)
16254
16285
  if dry_run:
@@ -16470,7 +16501,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16470
16501
  if err:
16471
16502
  return _err(err)
16472
16503
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
16473
- return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","clear_executions","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16504
+ return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16474
16505
 
16475
16506
 
16476
16507
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -26,11 +26,12 @@ import collections
26
26
  import json
27
27
  import logging
28
28
  import os
29
+ import re
29
30
  import threading
30
31
  import time
31
32
  import uuid
32
33
  from pathlib import Path
33
- from typing import Any, Deque, Dict, List, Optional
34
+ from typing import Any, Deque, Dict, List, Optional, Union
34
35
 
35
36
  logger = logging.getLogger("resolve-mcp.execution-trace")
36
37
 
@@ -135,8 +136,12 @@ def _merge_verification(
135
136
  status = "passed"
136
137
  passed = True
137
138
  else:
139
+ # None, not True. Nothing reported any evidence either way, and
140
+ # collapsing that into a boolean makes the rollup assert a pass it
141
+ # never observed — which then reaches a human as "Passed: yes" in an
142
+ # exported audit report.
138
143
  status = "unverified"
139
- passed = True
144
+ passed = None
140
145
 
141
146
  return {
142
147
  "status": status,
@@ -174,7 +179,10 @@ def begin_execution(
174
179
  "changes": None,
175
180
  "verification": {
176
181
  "status": "unverified",
177
- "passed": True,
182
+ # None, not True. "Nothing was checked" is not "everything passed",
183
+ # and the difference reaches a human in an exported audit report —
184
+ # see _execution_report_markdown.
185
+ "passed": None,
178
186
  "contradiction": False,
179
187
  "checks": [],
180
188
  },
@@ -313,7 +321,7 @@ def record_step(
313
321
  "changes": None,
314
322
  "verification": {
315
323
  "status": "unverified",
316
- "passed": True,
324
+ "passed": None, # see above: unknown, not passed
317
325
  "contradiction": False,
318
326
  "checks": [],
319
327
  },
@@ -338,7 +346,7 @@ def record_step(
338
346
  "changes": None,
339
347
  "verification": {
340
348
  "status": "unverified",
341
- "passed": True,
349
+ "passed": None, # see above: unknown, not passed
342
350
  "contradiction": False,
343
351
  "checks": [],
344
352
  },
@@ -466,6 +474,247 @@ def clear_executions() -> Dict[str, Any]:
466
474
  return {"success": True, "cleared": count}
467
475
 
468
476
 
477
+ # ── Audit Reports ───────────────────────────────────────────────────────────
478
+
479
+ def _report_format(report_format: str) -> str:
480
+ fmt = str(report_format or "markdown").strip().lower()
481
+ if fmt in {"md", "markdown"}:
482
+ return "markdown"
483
+ if fmt == "json":
484
+ return "json"
485
+ raise ValueError("format must be markdown or json")
486
+
487
+
488
+ def _report_extension(report_format: str) -> str:
489
+ return ".md" if _report_format(report_format) == "markdown" else ".json"
490
+
491
+
492
+ def _report_filename(execution_id: str, report_format: str) -> str:
493
+ safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(execution_id or "execution")).strip("._")
494
+ if not safe_id:
495
+ safe_id = "execution"
496
+ return f"{safe_id}{_report_extension(report_format)}"
497
+
498
+
499
+ def execution_report_dir() -> str:
500
+ """Directory used for generated execution audit reports."""
501
+ override = os.environ.get("RESOLVE_MCP_TRACE_REPORT_DIR")
502
+ if override:
503
+ return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
504
+ return str(_REPO_ROOT / "logs" / "execution-reports")
505
+
506
+
507
+ def _default_report_path(trace: Dict[str, Any], report_format: str) -> str:
508
+ return str(Path(execution_report_dir()) / _report_filename(trace["execution_id"], report_format))
509
+
510
+
511
+ def _format_ms(value: Any) -> str:
512
+ try:
513
+ return f"{max(0, int(value))} ms"
514
+ except (TypeError, ValueError):
515
+ return "0 ms"
516
+
517
+
518
+ def _scalar(value: Any) -> str:
519
+ if value is None:
520
+ return ""
521
+ if isinstance(value, bool):
522
+ return "yes" if value else "no"
523
+ if isinstance(value, (int, float)):
524
+ return str(value)
525
+ if isinstance(value, (dict, list)):
526
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
527
+ return str(value)
528
+
529
+
530
+ def _markdown_row(*cells: Any) -> str:
531
+ escaped = []
532
+ for cell in cells:
533
+ text = _scalar(cell).replace("\n", " ").replace("|", "\\|")
534
+ escaped.append(text)
535
+ return "| " + " | ".join(escaped) + " |"
536
+
537
+
538
+ def _execution_report_json(trace: Dict[str, Any], *, include_steps: bool = True) -> Dict[str, Any]:
539
+ """Return a stable, compact report object derived from safe trace summaries."""
540
+ out = {
541
+ "execution_id": trace.get("execution_id"),
542
+ "request": trace.get("request"),
543
+ "status": trace.get("status"),
544
+ "started_at": trace.get("started_at"),
545
+ "ended_at": trace.get("ended_at"),
546
+ "duration_ms": trace.get("duration_ms", 0),
547
+ "initiator": trace.get("initiator"),
548
+ "is_active": trace.get("is_active", False),
549
+ "tools": trace.get("tools", []),
550
+ "changes": trace.get("changes"),
551
+ "verification": trace.get("verification", {}),
552
+ "warnings": trace.get("warnings", []),
553
+ }
554
+ if trace.get("notes"):
555
+ out["notes"] = trace.get("notes")
556
+ if include_steps:
557
+ out["steps"] = trace.get("steps", [])
558
+ return out
559
+
560
+
561
+ def _execution_report_markdown(trace: Dict[str, Any], *, include_steps: bool = True) -> str:
562
+ report = _execution_report_json(trace, include_steps=include_steps)
563
+ lines = [
564
+ "# Execution Audit Report",
565
+ "",
566
+ _markdown_row("Field", "Value"),
567
+ _markdown_row("---", "---"),
568
+ _markdown_row("Execution ID", report.get("execution_id")),
569
+ _markdown_row("Request", report.get("request")),
570
+ _markdown_row("Status", report.get("status")),
571
+ _markdown_row("Started", report.get("started_at")),
572
+ _markdown_row("Ended", report.get("ended_at")),
573
+ _markdown_row("Duration", _format_ms(report.get("duration_ms"))),
574
+ _markdown_row("Initiator", report.get("initiator")),
575
+ "",
576
+ "## Tool Summary",
577
+ "",
578
+ ]
579
+
580
+ tools = report.get("tools") or []
581
+ if tools:
582
+ lines.extend([
583
+ _markdown_row("Tool", "Calls", "Duration"),
584
+ _markdown_row("---", "---:", "---:"),
585
+ ])
586
+ for tool in tools:
587
+ lines.append(_markdown_row(tool.get("tool"), tool.get("count", 0), _format_ms(tool.get("duration_ms"))))
588
+ else:
589
+ lines.append("No tool calls recorded.")
590
+
591
+ lines.extend(["", "## Changes", ""])
592
+ changes = report.get("changes")
593
+ if isinstance(changes, dict) and changes:
594
+ lines.extend([
595
+ _markdown_row("Change", "Value"),
596
+ _markdown_row("---", "---"),
597
+ ])
598
+ for key in sorted(changes):
599
+ lines.append(_markdown_row(key, changes[key]))
600
+ else:
601
+ lines.append("No semantic changes recorded.")
602
+
603
+ verification = report.get("verification") or {}
604
+ lines.extend([
605
+ "",
606
+ "## Verification",
607
+ "",
608
+ _markdown_row("Field", "Value"),
609
+ _markdown_row("---", "---"),
610
+ _markdown_row("Status", verification.get("status")),
611
+ _markdown_row("Passed", _verification_passed_label(verification)),
612
+ _markdown_row("Contradiction", verification.get("contradiction")),
613
+ _markdown_row("Checks", len(verification.get("checks") or [])),
614
+ ])
615
+
616
+ warnings = report.get("warnings") or []
617
+ lines.extend(["", "## Warnings", ""])
618
+ if warnings:
619
+ lines.extend(f"- {_scalar(w)}" for w in warnings)
620
+ else:
621
+ lines.append("No warnings recorded.")
622
+
623
+ if report.get("notes"):
624
+ lines.extend(["", "## Notes", "", _scalar(report["notes"])])
625
+
626
+ if include_steps:
627
+ lines.extend(["", "## Steps", ""])
628
+ steps = report.get("steps") or []
629
+ if steps:
630
+ lines.extend([
631
+ _markdown_row("#", "Timestamp", "Operation", "Status", "Duration"),
632
+ _markdown_row("---:", "---", "---", "---", "---:"),
633
+ ])
634
+ for step in steps:
635
+ lines.append(_markdown_row(
636
+ step.get("seq"),
637
+ step.get("timestamp"),
638
+ step.get("operation"),
639
+ step.get("status"),
640
+ _format_ms(step.get("duration_ms")),
641
+ ))
642
+ else:
643
+ lines.append("No steps recorded.")
644
+
645
+ lines.append("")
646
+ return "\n".join(lines)
647
+
648
+
649
+ def _verification_passed_label(verification: Dict[str, Any]) -> str:
650
+ """How the Passed row reads when nothing was actually verified.
651
+
652
+ A report that prints `Status: unverified` beside `Passed: yes` is read by a
653
+ human as "it passed" — the two lines are inches apart and only one of them
654
+ is scanned. Unverified means no evidence was reported, which is a question
655
+ still open, not a clean bill of health; an audit document is the last place
656
+ that distinction should be left to the reader.
657
+ """
658
+ passed = verification.get("passed")
659
+ if passed is None:
660
+ return "not established — no checks recorded"
661
+ return "yes" if passed else "no"
662
+
663
+
664
+ def render_execution_report(
665
+ trace: Dict[str, Any],
666
+ *,
667
+ report_format: str = "markdown",
668
+ include_steps: bool = True,
669
+ ) -> Union[str, Dict[str, Any]]:
670
+ """Render an execution trace as a Markdown or JSON audit report."""
671
+ fmt = _report_format(report_format)
672
+ if fmt == "json":
673
+ return _execution_report_json(trace, include_steps=include_steps)
674
+ return _execution_report_markdown(trace, include_steps=include_steps)
675
+
676
+
677
+ def export_execution_report(
678
+ execution_id: Optional[str] = None,
679
+ *,
680
+ report_format: str = "markdown",
681
+ output_path: Optional[str] = None,
682
+ overwrite: bool = False,
683
+ include_steps: bool = True,
684
+ ) -> Optional[Dict[str, Any]]:
685
+ """Write an execution audit report to disk.
686
+
687
+ The export is built from trace summaries, not raw tool arguments/results.
688
+ """
689
+ trace = get_execution_trace(execution_id)
690
+ if not trace:
691
+ return None
692
+
693
+ fmt = _report_format(report_format)
694
+ path = output_path or _default_report_path(trace, fmt)
695
+ real_path = os.path.realpath(os.path.abspath(os.path.expanduser(path)))
696
+ if os.path.exists(real_path) and not overwrite:
697
+ raise FileExistsError(f"Report already exists: {real_path}")
698
+
699
+ rendered = render_execution_report(trace, report_format=fmt, include_steps=include_steps)
700
+ os.makedirs(os.path.dirname(real_path), exist_ok=True)
701
+ if fmt == "json":
702
+ payload = json.dumps(rendered, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
703
+ else:
704
+ payload = str(rendered)
705
+ with open(real_path, "w", encoding="utf-8") as fh:
706
+ fh.write(payload)
707
+
708
+ return {
709
+ "success": True,
710
+ "execution_id": trace["execution_id"],
711
+ "format": fmt,
712
+ "path": real_path,
713
+ "bytes": len(payload.encode("utf-8")),
714
+ "included_steps": bool(include_steps),
715
+ }
716
+
717
+
469
718
  # ── Persistence (Best-Effort) ────────────────────────────────────────────────
470
719
 
471
720
  def trace_log_path() -> str: