spanforge 2.0.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 (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/_cli.py ADDED
@@ -0,0 +1,3081 @@
1
+ """Command-line interface for spanforge utilities.
2
+
3
+ This module provides the ``spanforge`` entry-point command. It is excluded
4
+ from coverage measurement because it is a thin integration shim over the
5
+ public library API — all business logic lives in tested library modules.
6
+
7
+ Entry-point (configured in pyproject.toml)::
8
+
9
+ spanforge = "spanforge._cli:main"
10
+
11
+ Sub-commands
12
+ ------------
13
+ ``spanforge check``
14
+ End-to-end health check: validates configuration, emits a test event,
15
+ and confirms the export pipeline is working. Exits 0 on success.
16
+
17
+ ``spanforge check-compat <events.json>``
18
+ Load a JSON file containing a list of serialised events and run the
19
+ v1.0 compatibility checklist. Exits 0 on success, 1 on violations,
20
+ 2 on usage/parse errors.
21
+
22
+ ``spanforge list-deprecated``
23
+ Print all event types registered in the global deprecation registry.
24
+
25
+ ``spanforge migration-roadmap [--json]``
26
+ Print the planned v1 → v2 migration roadmap from
27
+ :func:`~spanforge.migrate.v2_migration_roadmap`. Pass
28
+ ``--json`` to emit JSON for machine consumption.
29
+
30
+ ``spanforge check-consumers``
31
+ Assert that all globally registered consumers are compatible with the
32
+ installed schema version. Exits 0 on success, 1 on incompatibilities.
33
+
34
+ ``spanforge validate <events.jsonl>``
35
+ Validate every event in a JSONL file against the published schema.
36
+ Exits 0 if all events are valid, 1 if any fail validation.
37
+
38
+ ``spanforge audit-chain <events.jsonl>``
39
+ Verify the HMAC signing chain of events in a JSONL file. Reads the
40
+ signing key from the ``SPANFORGE_SIGNING_KEY`` environment variable.
41
+ Exits 0 if the chain is intact, 1 if tampering or gaps are found.
42
+
43
+ ``spanforge inspect <event_id> <events.jsonl>``
44
+ Find a single event by ``event_id`` in a JSONL file and pretty-print
45
+ its JSON envelope to stdout. Exits 0 on success, 1 if not found.
46
+
47
+ ``spanforge stats <events.jsonl>``
48
+ Print a summary table of events in a JSONL file: event counts by type,
49
+ total prompt/completion/total tokens, total cost, and timestamp range.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import json
56
+ import os
57
+ import sys
58
+ import threading
59
+ from pathlib import Path
60
+ from typing import NoReturn
61
+
62
+ _NO_EVENTS_MSG = "No events found in file."
63
+
64
+ def _cmd_check(_args: argparse.Namespace) -> int:
65
+ """Implement the ``check`` sub-command — end-to-end health check."""
66
+ import traceback # noqa: PLC0415
67
+
68
+ print("spanforge health check")
69
+ print("=" * 40)
70
+ ok = True
71
+
72
+ # Step 1: Config
73
+ try:
74
+ from spanforge.config import get_config # noqa: PLC0415
75
+ cfg = get_config()
76
+ print(f"[✓] Config loaded exporter={cfg.exporter!r} env={cfg.env!r} "
77
+ f"service={cfg.service_name!r}")
78
+ except Exception as exc:
79
+ print(f"[✗] Config failed: {exc}", file=sys.stderr)
80
+ return 1
81
+
82
+ # Step 2: Event creation
83
+ try:
84
+ from spanforge.event import Event # noqa: PLC0415
85
+ from spanforge.ulid import generate as gen_ulid # noqa: PLC0415
86
+ event = Event(
87
+ event_type="llm.trace.span.completed",
88
+ source=f"{cfg.service_name}@0.0.0",
89
+ payload={
90
+ "span_id": "0" * 16,
91
+ "trace_id": "0" * 32,
92
+ "span_name": "spanforge.health.check",
93
+ "operation": "chat",
94
+ "span_kind": "client",
95
+ "status": "ok",
96
+ "start_time_unix_nano": 0,
97
+ "end_time_unix_nano": 1_000_000,
98
+ "duration_ms": 1.0,
99
+ },
100
+ event_id=gen_ulid(),
101
+ )
102
+ print("[✓] Test event created")
103
+ except Exception as exc:
104
+ print(f"[✗] Event creation failed: {exc}", file=sys.stderr)
105
+ traceback.print_exc(file=sys.stderr)
106
+ return 1
107
+
108
+ # Step 3: Schema validation
109
+ try:
110
+ from spanforge.validate import validate_event # noqa: PLC0415
111
+ validate_event(event)
112
+ print("[✓] Schema validation passed")
113
+ except Exception as exc:
114
+ print(f"[✗] Schema validation failed: {exc}", file=sys.stderr)
115
+ ok = False
116
+
117
+ # Step 4: Export pipeline
118
+ try:
119
+ from spanforge._stream import _dispatch # noqa: PLC0415
120
+ _dispatch(event)
121
+ print("[✓] Export pipeline: event dispatched successfully")
122
+ except Exception as exc:
123
+ print(f"[✗] Export pipeline failed: {exc}", file=sys.stderr)
124
+ traceback.print_exc(file=sys.stderr)
125
+ ok = False
126
+
127
+ # Step 5: TraceStore recording (only if enabled)
128
+ if cfg.enable_trace_store:
129
+ try:
130
+ from spanforge._store import get_store # noqa: PLC0415
131
+ store = get_store()
132
+ events = store.get_trace("0" * 32)
133
+ if events is not None and len(events) >= 1:
134
+ print(f"[✓] TraceStore recorded {len(events)} event(s)")
135
+ else:
136
+ print("[✗] TraceStore: event not found after dispatch", file=sys.stderr)
137
+ ok = False
138
+ except Exception as exc:
139
+ print(f"[✗] TraceStore check failed: {exc}", file=sys.stderr)
140
+ ok = False
141
+ else:
142
+ print("[–] TraceStore: disabled (set SPANFORGE_ENABLE_TRACE_STORE=1 to enable)")
143
+
144
+ print("=" * 40)
145
+ if ok:
146
+ print("PASS — all checks passed.")
147
+ return 0
148
+ print("FAIL — one or more checks failed.", file=sys.stderr)
149
+ return 1
150
+
151
+
152
+ def _cmd_check_compat(args: argparse.Namespace) -> int:
153
+ """Implement the ``check-compat`` sub-command."""
154
+ from spanforge.compliance import test_compatibility # noqa: PLC0415
155
+ from spanforge.event import Event # noqa: PLC0415
156
+
157
+ path = Path(args.file)
158
+ if not path.exists():
159
+ print(f"error: file not found: {path}", file=sys.stderr)
160
+ return 2
161
+
162
+ try:
163
+ raw = json.loads(path.read_text(encoding="utf-8"))
164
+ except json.JSONDecodeError as exc:
165
+ print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr)
166
+ return 2
167
+
168
+ if not isinstance(raw, list):
169
+ print("error: JSON file must contain a top-level array of events", file=sys.stderr)
170
+ return 2
171
+
172
+ from spanforge.exceptions import DeserializationError, SchemaValidationError # noqa: PLC0415
173
+ try:
174
+ events = [Event.from_dict(item) for item in raw]
175
+ except (DeserializationError, SchemaValidationError, KeyError, TypeError) as exc:
176
+ print(f"error: could not deserialise events: {exc}", file=sys.stderr)
177
+ return 2
178
+
179
+ result = test_compatibility(events)
180
+
181
+ if result.passed:
182
+ print(
183
+ f"OK — {result.events_checked} event(s) passed all compatibility checks."
184
+ )
185
+ return 0
186
+
187
+ print(
188
+ f"FAIL — {len(result.violations)} violation(s) found in "
189
+ f"{result.events_checked} event(s):\n"
190
+ )
191
+ for v in result.violations:
192
+ event_ref = f"[{v.event_id}] " if v.event_id else ""
193
+ print(f" {event_ref}{v.check_id} ({v.rule}): {v.detail}")
194
+
195
+ return 1
196
+
197
+
198
+ def _cmd_list_deprecated(_args: argparse.Namespace) -> int:
199
+ """Implement the ``list-deprecated`` sub-command."""
200
+ try:
201
+ from spanforge.deprecations import list_deprecated # noqa: PLC0415
202
+
203
+ notices = list_deprecated()
204
+ if not notices:
205
+ print("No deprecated event types registered.")
206
+ return 0
207
+
208
+ print(f"{'Event Type':<50} {'Since':<8} {'Sunset':<8} Replacement")
209
+ print("-" * 90)
210
+ for n in notices:
211
+ repl = n.replacement or "(no replacement)"
212
+ print(f"{n.event_type:<50} {n.since:<8} {n.sunset:<8} {repl}")
213
+ return 0
214
+ except Exception as exc:
215
+ print(f"error: {exc}", file=sys.stderr)
216
+ return 1
217
+
218
+
219
+ def _cmd_migration_roadmap(args: argparse.Namespace) -> int:
220
+ """Implement the ``migration-roadmap`` sub-command."""
221
+ try:
222
+ from spanforge.migrate import v2_migration_roadmap # noqa: PLC0415
223
+ except ImportError as exc:
224
+ print(f"error: {exc}", file=sys.stderr)
225
+ return 1
226
+
227
+ roadmap = v2_migration_roadmap()
228
+ if not roadmap:
229
+ print("No migration records found.")
230
+ return 0
231
+
232
+ if getattr(args, "json", False):
233
+ output = [
234
+ {
235
+ "event_type": r.event_type,
236
+ "since": r.since,
237
+ "sunset": r.sunset,
238
+ "sunset_policy": r.sunset_policy.value,
239
+ "replacement": r.replacement,
240
+ "migration_notes": r.migration_notes,
241
+ "field_renames": r.field_renames,
242
+ }
243
+ for r in roadmap
244
+ ]
245
+ print(json.dumps(output, indent=2))
246
+ return 0
247
+
248
+ print(f"v1 → v2 Migration Roadmap ({len(roadmap)} changes)\n")
249
+ for r in roadmap:
250
+ arrow = f" → {r.replacement}" if r.replacement else " (removed)"
251
+ print(f" [{r.since}→{r.sunset}] {r.event_type}{arrow}")
252
+ if r.migration_notes:
253
+ import textwrap # noqa: PLC0415
254
+ wrapped = textwrap.fill(r.migration_notes, width=72, initial_indent=" ", subsequent_indent=" ") # noqa: E501
255
+ print(wrapped)
256
+ if r.field_renames:
257
+ for old, new in r.field_renames.items():
258
+ print(f" field rename: {old!r} → {new!r}")
259
+ print()
260
+ return 0
261
+
262
+
263
+ def _cmd_check_consumers(_args: argparse.Namespace) -> int:
264
+ """Implement the ``check-consumers`` sub-command."""
265
+ from spanforge.consumer import get_registry # noqa: PLC0415
266
+
267
+ registry = get_registry()
268
+ all_records = registry.all()
269
+ if not all_records:
270
+ print("No consumers registered.")
271
+ return 0
272
+
273
+ incompatible = registry.check_compatible()
274
+ if not incompatible:
275
+ print(f"OK — all {len(all_records)} consumer(s) are compatible.")
276
+ return 0
277
+
278
+ print(f"INCOMPATIBLE — {len(incompatible)} consumer(s) require a newer schema:\n")
279
+ for tool_name, version in incompatible:
280
+ print(f" {tool_name!r} requires schema v{version}")
281
+ return 1
282
+
283
+
284
+ def _read_jsonl_events(path: Path): # noqa: ANN202
285
+ """Read a JSONL file and return a list of (lineno, Event | Exception) pairs."""
286
+ from spanforge.event import Event # noqa: PLC0415
287
+ from spanforge.exceptions import DeserializationError, SchemaValidationError # noqa: PLC0415
288
+
289
+ results = []
290
+ for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
291
+ line = raw_line.strip()
292
+ if not line:
293
+ continue
294
+ try:
295
+ obj = json.loads(line)
296
+ event = Event.from_dict(obj)
297
+ results.append((lineno, event))
298
+ except (json.JSONDecodeError, DeserializationError, SchemaValidationError, KeyError, TypeError) as exc: # noqa: E501
299
+ results.append((lineno, exc))
300
+ return results
301
+
302
+
303
+ def _cmd_validate(args: argparse.Namespace) -> int:
304
+ """Implement the ``validate`` sub-command."""
305
+ from spanforge.exceptions import SchemaValidationError # noqa: PLC0415
306
+ from spanforge.validate import validate_event # noqa: PLC0415
307
+
308
+ path = Path(args.file)
309
+ if not path.exists():
310
+ print(f"error: file not found: {path}", file=sys.stderr)
311
+ return 2
312
+
313
+ rows = _read_jsonl_events(path)
314
+ if not rows:
315
+ print(_NO_EVENTS_MSG)
316
+ return 0
317
+
318
+ errors: list[tuple[int, str]] = []
319
+ for lineno, item in rows:
320
+ if isinstance(item, Exception):
321
+ errors.append((lineno, f"parse error: {item}"))
322
+ continue
323
+ try:
324
+ validate_event(item)
325
+ except SchemaValidationError as exc:
326
+ errors.append((lineno, str(exc)))
327
+
328
+ total = len(rows)
329
+ if not errors:
330
+ print(f"OK — {total} event(s) passed schema validation.")
331
+ return 0
332
+
333
+ print(f"FAIL — {len(errors)} of {total} event(s) failed validation:\n")
334
+ for lineno, msg in errors:
335
+ print(f" line {lineno}: {msg}")
336
+ return 1
337
+
338
+
339
+ def _cmd_audit_chain(args: argparse.Namespace) -> int: # noqa: PLR0911
340
+ """Implement the ``audit-chain`` sub-command."""
341
+ import os # noqa: PLC0415
342
+
343
+ from spanforge.signing import SigningError, verify_chain # noqa: PLC0415
344
+
345
+ path = Path(args.file)
346
+ if not path.exists():
347
+ print(f"error: file not found: {path}", file=sys.stderr)
348
+ return 2
349
+
350
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
351
+ if not org_secret:
352
+ print(
353
+ "error: SPANFORGE_SIGNING_KEY environment variable is not set.",
354
+ file=sys.stderr,
355
+ )
356
+ return 2
357
+
358
+ rows = _read_jsonl_events(path)
359
+ if not rows:
360
+ print(_NO_EVENTS_MSG)
361
+ return 0
362
+
363
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
364
+ if bad_lines:
365
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
366
+ for ln, exc in bad_lines[:5]:
367
+ print(f" line {ln}: {exc}", file=sys.stderr)
368
+ return 2
369
+
370
+ events = [ev for _, ev in rows]
371
+
372
+ try:
373
+ result = verify_chain(events, org_secret=org_secret)
374
+ except SigningError as exc:
375
+ print(f"error: {exc}", file=sys.stderr)
376
+ return 2
377
+
378
+ if result.valid:
379
+ print(f"OK — chain of {len(events)} event(s) is intact.")
380
+ return 0
381
+
382
+ print(f"FAIL — chain verification failed ({result.tampered_count} tampered event(s)):\n")
383
+ if result.first_tampered:
384
+ print(f" first tampered event_id: {result.first_tampered}")
385
+ if result.gaps:
386
+ print(f" linkage gaps ({len(result.gaps)}):")
387
+ for gap_id in result.gaps:
388
+ print(f" {gap_id}")
389
+ return 1
390
+
391
+
392
+ def _cmd_inspect(args: argparse.Namespace) -> int:
393
+ """Implement the ``inspect`` sub-command."""
394
+ path = Path(args.file)
395
+ if not path.exists():
396
+ print(f"error: file not found: {path}", file=sys.stderr)
397
+ return 2
398
+
399
+ rows = _read_jsonl_events(path)
400
+ target_id = args.event_id
401
+
402
+ for _lineno, item in rows:
403
+ if isinstance(item, Exception):
404
+ continue
405
+ if item.event_id == target_id:
406
+ print(json.dumps(item.to_dict(), indent=2))
407
+ return 0
408
+
409
+ print(f"error: event_id {target_id!r} not found in {path}", file=sys.stderr)
410
+ return 1
411
+
412
+
413
+ def _accumulate_stats(
414
+ rows: list[tuple[int, Any]],
415
+ ) -> tuple[dict[str, int], int, int, int, float, list[str], int]:
416
+ """Aggregate token/cost/type counters from parsed event rows."""
417
+ type_counts: dict[str, int] = {}
418
+ prompt_tokens = 0
419
+ completion_tokens = 0
420
+ total_tokens = 0
421
+ cost_usd = 0.0
422
+ timestamps: list[str] = []
423
+ parse_errors = 0
424
+ for _lineno, item in rows:
425
+ if isinstance(item, Exception):
426
+ parse_errors += 1
427
+ continue
428
+ event_type = str(item.event_type) if item.event_type else "(unknown)"
429
+ type_counts[event_type] = type_counts.get(event_type, 0) + 1
430
+ payload = item.payload or {}
431
+ prompt_tokens += int(payload.get("prompt_tokens") or 0)
432
+ completion_tokens += int(payload.get("completion_tokens") or 0)
433
+ total_tokens += int(payload.get("total_tokens") or 0)
434
+ cost_usd += float(payload.get("cost_usd") or 0.0)
435
+ if item.timestamp:
436
+ timestamps.append(item.timestamp)
437
+ return type_counts, prompt_tokens, completion_tokens, total_tokens, cost_usd, timestamps, parse_errors
438
+
439
+
440
+ def _cmd_stats(args: argparse.Namespace) -> int:
441
+ """Implement the ``stats`` sub-command."""
442
+ path = Path(args.file)
443
+ if not path.exists():
444
+ print(f"error: file not found: {path}", file=sys.stderr)
445
+ return 2
446
+
447
+ rows = _read_jsonl_events(path)
448
+ if not rows:
449
+ print(_NO_EVENTS_MSG)
450
+ return 0
451
+
452
+ type_counts, prompt_tokens, completion_tokens, total_tokens, cost_usd, timestamps, parse_errors = _accumulate_stats(rows) # noqa: E501
453
+
454
+ total_events = len(rows) - parse_errors
455
+ print(f"Events: {total_events}" + (f" ({parse_errors} parse error(s) skipped)" if parse_errors else "")) # noqa: E501
456
+ print()
457
+
458
+ if type_counts:
459
+ print(f"{'Event Type':<55} {'Count':>7}")
460
+ print("-" * 65)
461
+ for et, cnt in sorted(type_counts.items(), key=lambda x: -x[1]):
462
+ print(f" {et:<53} {cnt:>7}")
463
+ print()
464
+
465
+ print(f"Prompt tokens: {prompt_tokens:>12,}")
466
+ print(f"Completion tokens: {completion_tokens:>12,}")
467
+ print(f"Total tokens: {total_tokens:>12,}")
468
+ print(f"Cost (USD): {cost_usd:>12.6f}")
469
+ print()
470
+
471
+ if timestamps:
472
+ ts_sorted = sorted(timestamps)
473
+ print(f"Earliest: {ts_sorted[0]}")
474
+ print(f"Latest: {ts_sorted[-1]}")
475
+
476
+ return 0
477
+
478
+
479
+ def _cmd_compliance_generate(args: argparse.Namespace) -> int: # noqa: PLR0911
480
+ """Implement ``spanforge compliance generate``."""
481
+ from spanforge.core.compliance_mapping import ( # noqa: PLC0415
482
+ ComplianceFramework,
483
+ ComplianceMappingEngine,
484
+ )
485
+
486
+ # Resolve framework enum — accept both enum value ("EU AI Act") and YAML slug ("eu_ai_act")
487
+ _FRAMEWORK_SLUG_MAP = {
488
+ "eu_ai_act": "EU AI Act",
489
+ "iso_42001": "ISO/IEC 42001",
490
+ "nist_ai_rmf": "NIST AI RMF",
491
+ "gdpr": "GDPR",
492
+ "soc2": "SOC 2 Type II",
493
+ }
494
+ fw_map = {e.value: e for e in ComplianceFramework}
495
+ # also index by slug
496
+ for _slug, _val in _FRAMEWORK_SLUG_MAP.items():
497
+ if _val in fw_map:
498
+ fw_map[_slug] = fw_map[_val]
499
+ framework_key = args.framework.lower()
500
+ # try case-insensitive match against all keys
501
+ matched = None
502
+ for k, v in fw_map.items():
503
+ if k.lower() == framework_key:
504
+ matched = v
505
+ break
506
+ if matched is None:
507
+ valid = ", ".join(sorted(_FRAMEWORK_SLUG_MAP.keys()))
508
+ print(f"error: unknown framework {args.framework!r}. Valid slugs: {valid}", file=sys.stderr)
509
+ return 2
510
+
511
+ framework = matched
512
+
513
+ # Optionally load audit events from a JSONL file
514
+ audit_events: list[dict] = []
515
+ if getattr(args, "events_file", None):
516
+ events_path = Path(args.events_file)
517
+ if not events_path.exists():
518
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
519
+ return 2
520
+ for line in events_path.read_text(encoding="utf-8").splitlines():
521
+ line = line.strip()
522
+ if line:
523
+ try:
524
+ audit_events.append(json.loads(line))
525
+ except json.JSONDecodeError as exc:
526
+ print(f"warning: skipping invalid JSON line in events file: {exc}", file=sys.stderr)
527
+
528
+ engine = ComplianceMappingEngine()
529
+ try:
530
+ pkg = engine.generate_evidence_package(
531
+ model_id=args.model_id,
532
+ framework=framework,
533
+ from_date=args.from_date,
534
+ to_date=args.to_date,
535
+ audit_events=audit_events or None,
536
+ )
537
+ except Exception as exc:
538
+ print(f"error: evidence package generation failed: {exc}", file=sys.stderr)
539
+ return 1
540
+
541
+ # Write output files
542
+ out_dir = Path(args.output)
543
+ out_dir.mkdir(parents=True, exist_ok=True)
544
+
545
+ safe_id = args.model_id.replace("/", "_")[:40]
546
+ prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
547
+
548
+ attestation_path = out_dir / f"{prefix}_attestation.json"
549
+ attestation_path.write_text(pkg.attestation.to_json(), encoding="utf-8")
550
+ print(f"[✓] Attestation → {attestation_path}")
551
+
552
+ report_path = out_dir / f"{prefix}_report.txt"
553
+ report_path.write_text(pkg.report_text, encoding="utf-8")
554
+ print(f"[✓] Report → {report_path}")
555
+
556
+ if pkg.gap_report.has_gaps:
557
+ gap_data = {
558
+ "model_id": pkg.gap_report.model_id,
559
+ "framework": pkg.gap_report.framework,
560
+ "period_from": pkg.gap_report.period_from,
561
+ "period_to": pkg.gap_report.period_to,
562
+ "generated_at": pkg.gap_report.generated_at,
563
+ "gap_clause_ids": pkg.gap_report.gap_clause_ids,
564
+ "partial_clause_ids": pkg.gap_report.partial_clause_ids,
565
+ }
566
+ gap_path = out_dir / f"{prefix}_gap_report.json"
567
+ gap_path.write_text(json.dumps(gap_data, indent=2), encoding="utf-8")
568
+ print(f"[✓] Gap report → {gap_path}")
569
+ else:
570
+ print("[✓] No compliance gaps found")
571
+
572
+ if pkg.audit_exports:
573
+ exports_dir = out_dir / "exports"
574
+ exports_dir.mkdir(exist_ok=True)
575
+ for clause_id, events in pkg.audit_exports.items():
576
+ safe_clause = clause_id.replace("/", "_").replace(".", "_")
577
+ clause_path = exports_dir / f"{safe_clause}.jsonl"
578
+ clause_path.write_text(
579
+ "\n".join(json.dumps(e) for e in events), encoding="utf-8"
580
+ )
581
+ print(f"[✓] Clause exports → {exports_dir}/ ({len(pkg.audit_exports)} clause(s))")
582
+
583
+ print(f"\nOverall status: {pkg.attestation.overall_status.value}")
584
+ return 0
585
+
586
+
587
+ def _attestation_from_dict(data: dict) -> "object": # noqa: ANN001
588
+ """Reconstruct a ComplianceAttestation from its to_dict() output."""
589
+ from spanforge.core.compliance_mapping import ( # noqa: PLC0415
590
+ ClauseStatus,
591
+ ComplianceAttestation,
592
+ EvidenceRecord,
593
+ )
594
+
595
+ period = data.get("period", {})
596
+ clauses = [
597
+ EvidenceRecord(
598
+ clause_id=c["clause_id"],
599
+ status=ClauseStatus(c["status"]),
600
+ evidence_count=c.get("evidence_count", 0),
601
+ audit_ids=c.get("audit_ids", []),
602
+ summary=c.get("summary", ""),
603
+ )
604
+ for c in data.get("clauses", [])
605
+ ]
606
+ return ComplianceAttestation(
607
+ model_id=data["model_id"],
608
+ framework=data["framework"],
609
+ period_from=period.get("from", data.get("period_from", "")),
610
+ period_to=period.get("to", data.get("period_to", "")),
611
+ generated_at=data.get("generated_at", ""),
612
+ generated_by=data.get("generated_by", ""),
613
+ clauses=clauses,
614
+ overall_status=ClauseStatus(data["overall_status"]),
615
+ hmac_sig=data.get("hmac_sig", ""),
616
+ )
617
+
618
+
619
+ def _cmd_compliance_validate_attestation(args: argparse.Namespace) -> int:
620
+ """Implement ``spanforge compliance validate-attestation``."""
621
+ from spanforge.core.compliance_mapping import verify_attestation_signature # noqa: PLC0415
622
+
623
+ att_path = Path(args.attestation_file)
624
+ if not att_path.exists():
625
+ print(f"error: file not found: {att_path}", file=sys.stderr)
626
+ return 2
627
+
628
+ try:
629
+ data = json.loads(att_path.read_text(encoding="utf-8"))
630
+ except json.JSONDecodeError as exc:
631
+ print(f"error: invalid JSON in {att_path}: {exc}", file=sys.stderr)
632
+ return 2
633
+
634
+ try:
635
+ attestation = _attestation_from_dict(data)
636
+ except (KeyError, ValueError) as exc:
637
+ print(f"error: could not parse attestation: {exc}", file=sys.stderr)
638
+ return 2
639
+
640
+ valid = verify_attestation_signature(attestation)
641
+ if valid:
642
+ print(f"[✓] Attestation signature is valid model_id={data.get('model_id')!r}")
643
+ return 0
644
+ print(f"[✗] Attestation signature is INVALID model_id={data.get('model_id')!r}", file=sys.stderr)
645
+ return 1
646
+
647
+
648
+ def _cmd_compliance_report(args: argparse.Namespace) -> int: # noqa: PLR0911
649
+ """Implement ``spanforge compliance report`` — JSON/PDF report with attestation."""
650
+ from spanforge.core.compliance_mapping import ( # noqa: PLC0415
651
+ ComplianceFramework,
652
+ ComplianceMappingEngine,
653
+ )
654
+
655
+ _FRAMEWORK_SLUG_MAP = {
656
+ "eu_ai_act": "EU AI Act",
657
+ "iso_42001": "ISO/IEC 42001",
658
+ "nist_ai_rmf": "NIST AI RMF",
659
+ "gdpr": "GDPR",
660
+ "soc2": "SOC 2 Type II",
661
+ "hipaa": "HIPAA",
662
+ }
663
+ fw_map = {e.value: e for e in ComplianceFramework}
664
+ for _slug, _val in _FRAMEWORK_SLUG_MAP.items():
665
+ if _val in fw_map:
666
+ fw_map[_slug] = fw_map[_val]
667
+ framework_key = args.framework.lower()
668
+ matched = None
669
+ for k, v in fw_map.items():
670
+ if k.lower() == framework_key:
671
+ matched = v
672
+ break
673
+ if matched is None:
674
+ valid = ", ".join(sorted(_FRAMEWORK_SLUG_MAP.keys()))
675
+ print(f"error: unknown framework {args.framework!r}. Valid slugs: {valid}", file=sys.stderr)
676
+ return 2
677
+
678
+ audit_events: list[dict] = []
679
+ if getattr(args, "events_file", None):
680
+ events_path = Path(args.events_file)
681
+ if not events_path.exists():
682
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
683
+ return 2
684
+ for line in events_path.read_text(encoding="utf-8").splitlines():
685
+ line = line.strip()
686
+ if line:
687
+ try:
688
+ audit_events.append(json.loads(line))
689
+ except json.JSONDecodeError as exc:
690
+ print(f"warning: skipping invalid JSON line: {exc}", file=sys.stderr)
691
+
692
+ engine = ComplianceMappingEngine()
693
+ try:
694
+ pkg = engine.generate_evidence_package(
695
+ model_id=args.model_id,
696
+ framework=matched,
697
+ from_date=args.from_date,
698
+ to_date=args.to_date,
699
+ audit_events=audit_events or None,
700
+ )
701
+ except Exception as exc:
702
+ print(f"error: report generation failed: {exc}", file=sys.stderr)
703
+ return 1
704
+
705
+ out_dir = Path(args.output)
706
+ out_dir.mkdir(parents=True, exist_ok=True)
707
+ safe_id = args.model_id.replace("/", "_")[:40]
708
+ prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
709
+ fmt = getattr(args, "report_format", "json")
710
+
711
+ if fmt in ("json", "both"):
712
+ json_path = out_dir / f"{prefix}_report.json"
713
+ json_path.write_text(pkg.to_json(), encoding="utf-8")
714
+ print(f"[✓] JSON report → {json_path}")
715
+
716
+ if fmt in ("pdf", "both"):
717
+ pdf_path = out_dir / f"{prefix}_report.pdf"
718
+ try:
719
+ pkg.to_pdf(str(pdf_path))
720
+ print(f"[✓] PDF report → {pdf_path}")
721
+ except ImportError:
722
+ print("error: PDF generation requires reportlab. Install: pip install spanforge[compliance]",
723
+ file=sys.stderr)
724
+ return 1
725
+
726
+ overall = pkg.attestation.overall_status.value
727
+ print(f"\nOverall status: {overall.upper()}")
728
+ return 0 if overall == "pass" else 1
729
+
730
+
731
+ def _cmd_compliance_check(args: argparse.Namespace) -> int:
732
+ """Implement ``spanforge compliance check`` — CI-friendly exit-code gate."""
733
+ from spanforge.core.compliance_mapping import ComplianceMappingEngine # noqa: PLC0415
734
+
735
+ audit_events: list[dict] = []
736
+ if getattr(args, "events_file", None):
737
+ events_path = Path(args.events_file)
738
+ if not events_path.exists():
739
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
740
+ return 2
741
+ for line in events_path.read_text(encoding="utf-8").splitlines():
742
+ line = line.strip()
743
+ if line:
744
+ try:
745
+ audit_events.append(json.loads(line))
746
+ except json.JSONDecodeError as exc:
747
+ print(f"warning: skipping invalid JSON line in events file: {exc}", file=sys.stderr)
748
+
749
+ engine = ComplianceMappingEngine()
750
+ try:
751
+ pkg = engine.generate_evidence_package(
752
+ model_id=args.model_id,
753
+ framework=args.framework,
754
+ from_date=args.from_date,
755
+ to_date=args.to_date,
756
+ audit_events=audit_events or None,
757
+ )
758
+ except ValueError as exc:
759
+ print(f"error: {exc}", file=sys.stderr)
760
+ return 2
761
+ except Exception as exc: # noqa: BLE001
762
+ print(f"error: compliance check failed: {exc}", file=sys.stderr)
763
+ return 1
764
+
765
+ allow_partial = getattr(args, "allow_partial", False)
766
+ gap = pkg.gap_report
767
+ overall = pkg.attestation.overall_status.value
768
+
769
+ # Print concise per-clause summary
770
+ for rec in pkg.attestation.clauses:
771
+ icon = {"pass": "[✓]", "fail": "[✗]", "partial": "[~]"}.get(rec.status.value, "[?]")
772
+ print(f" {icon} {rec.clause_id:<20} {rec.status.value:<8} {rec.evidence_count} events")
773
+
774
+ print(f"\nOverall: {overall.upper()}")
775
+
776
+ if gap.gap_clause_ids:
777
+ print(f"Gaps : {', '.join(gap.gap_clause_ids)}")
778
+ if gap.partial_clause_ids:
779
+ print(f"Partial : {', '.join(gap.partial_clause_ids)}")
780
+
781
+ # Exit code logic
782
+ if gap.has_gaps:
783
+ if not allow_partial or gap.gap_clause_ids:
784
+ print("\n[FAIL] Compliance check failed — fix gaps before deploying.", file=sys.stderr)
785
+ return 1
786
+ print("\n[PASS] Compliance check passed.")
787
+ return 0
788
+
789
+
790
+ def _cmd_compliance_status(args: argparse.Namespace) -> int:
791
+ """Implement ``spanforge compliance status`` — single JSON summary."""
792
+ from spanforge.redact import scan_payload # noqa: PLC0415
793
+ from spanforge.signing import verify_chain # noqa: PLC0415
794
+
795
+ events_path = Path(args.events_file)
796
+ if not events_path.exists():
797
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
798
+ return 2
799
+
800
+ raw_events: list[dict] = []
801
+ for line in events_path.read_text(encoding="utf-8").splitlines():
802
+ line = line.strip()
803
+ if line:
804
+ try:
805
+ raw_events.append(json.loads(line))
806
+ except json.JSONDecodeError:
807
+ continue
808
+
809
+ if not raw_events:
810
+ print("error: no events found in file", file=sys.stderr)
811
+ return 1
812
+
813
+ # Chain integrity
814
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
815
+ chain_ok = False
816
+ chain_msg = "no signing key"
817
+ if signing_key:
818
+ try:
819
+ result = verify_chain(raw_events, signing_key)
820
+ chain_ok = result.valid
821
+ chain_msg = "valid" if result.valid else f"broken at event {result.first_broken_index}"
822
+ except Exception as exc: # noqa: BLE001
823
+ chain_msg = f"error: {exc}"
824
+
825
+ # PII scan
826
+ pii_clean = True
827
+ pii_hits = 0
828
+ for evt in raw_events:
829
+ payload = evt.get("payload", {})
830
+ if isinstance(payload, dict):
831
+ result = scan_payload(payload)
832
+ if not result.clean:
833
+ pii_clean = False
834
+ pii_hits += len(result.hits)
835
+
836
+ # Clause coverage
837
+ clause_summary: dict[str, Any] = {}
838
+ try:
839
+ from spanforge.core.compliance_mapping import ( # noqa: PLC0415
840
+ ComplianceMappingEngine,
841
+ )
842
+ engine = ComplianceMappingEngine()
843
+ pkg = engine.generate_evidence_package(
844
+ model_id="*",
845
+ framework=args.framework,
846
+ from_date="2000-01-01",
847
+ to_date="2099-12-31",
848
+ audit_events=raw_events,
849
+ )
850
+ for rec in pkg.attestation.clauses:
851
+ clause_summary[rec.clause_id] = {
852
+ "status": rec.status.value,
853
+ "evidence_count": rec.evidence_count,
854
+ }
855
+ except Exception: # noqa: BLE001
856
+ clause_summary = {"error": "could not evaluate clause coverage"}
857
+
858
+ # Last attestation timestamp
859
+ last_attestation: str | None = None
860
+ for evt in reversed(raw_events):
861
+ et = evt.get("event_type", "")
862
+ if "attestation" in et.lower() or "compliance" in et.lower():
863
+ last_attestation = evt.get("timestamp")
864
+ break
865
+
866
+ summary = {
867
+ "chain_integrity": {"valid": chain_ok, "message": chain_msg},
868
+ "pii_scan": {"clean": pii_clean, "hit_count": pii_hits},
869
+ "clause_coverage": clause_summary,
870
+ "last_attestation_timestamp": last_attestation,
871
+ "events_analysed": len(raw_events),
872
+ }
873
+
874
+ print(json.dumps(summary, indent=2, default=str))
875
+ return 0
876
+
877
+
878
+ def _load_cost_brief_store_json(store_path: Path) -> dict:
879
+ """Load or initialise a JSON-file-backed cost brief store."""
880
+ if store_path.exists():
881
+ try:
882
+ return json.loads(store_path.read_text(encoding="utf-8"))
883
+ except (json.JSONDecodeError, OSError):
884
+ pass
885
+ return {}
886
+
887
+
888
+ def _cmd_cost_brief_submit(args: argparse.Namespace) -> int:
889
+ """Implement ``spanforge cost brief submit``."""
890
+ brief_path = Path(args.file)
891
+ if not brief_path.exists():
892
+ print(f"error: file not found: {brief_path}", file=sys.stderr)
893
+ return 2
894
+
895
+ try:
896
+ brief_data = json.loads(brief_path.read_text(encoding="utf-8"))
897
+ except json.JSONDecodeError as exc:
898
+ print(f"error: invalid JSON in {brief_path}: {exc}", file=sys.stderr)
899
+ return 2
900
+
901
+ # Validate required fields
902
+ required = {"model_id", "submitted_by", "resource_config", "scenarios"}
903
+ missing = required - set(brief_data.keys())
904
+ if missing:
905
+ print(f"error: cost brief missing required fields: {', '.join(sorted(missing))}", file=sys.stderr)
906
+ return 2
907
+
908
+ store_path = Path(args.store)
909
+ store = _load_cost_brief_store_json(store_path)
910
+ from datetime import datetime, timezone # noqa: PLC0415
911
+ store[brief_data["model_id"]] = {
912
+ **brief_data,
913
+ "stored_at": datetime.now(timezone.utc).isoformat(),
914
+ }
915
+ store_path.parent.mkdir(parents=True, exist_ok=True)
916
+ store_path.write_text(json.dumps(store, indent=2), encoding="utf-8")
917
+
918
+ print(f"[✓] Cost brief submitted model_id={brief_data['model_id']!r} store={store_path}")
919
+ return 0
920
+
921
+
922
+ def _cmd_cost_run(args: argparse.Namespace) -> int:
923
+ """Implement ``spanforge cost run --run-id <id> --input <jsonl>``."""
924
+ run_id: str = args.run_id
925
+ events_path = Path(args.input)
926
+
927
+ if not events_path.exists():
928
+ print(f"error: file not found: {events_path}", file=sys.stderr)
929
+ return 2
930
+
931
+ # Collect cost and agent-run events matching the given run_id.
932
+ cost_events: list[dict[str, Any]] = []
933
+ agent_run_event: dict[str, Any] | None = None
934
+
935
+ for line in events_path.read_text(encoding="utf-8").splitlines():
936
+ line = line.strip()
937
+ if not line:
938
+ continue
939
+ try:
940
+ event = json.loads(line)
941
+ except json.JSONDecodeError:
942
+ continue
943
+
944
+ payload = event.get("payload", {})
945
+ ns = event.get("namespace", "")
946
+
947
+ # Match cost events by agent_run_id in payload
948
+ if ns.startswith("llm.cost.") and payload.get("agent_run_id") == run_id:
949
+ cost_events.append(event)
950
+ # Match agent run completion event
951
+ elif ns == "llm.trace.agent.completed" and payload.get("agent_run_id") == run_id:
952
+ agent_run_event = event
953
+
954
+ if not cost_events and agent_run_event is None:
955
+ print(f"error: no events found for run_id={run_id!r}", file=sys.stderr)
956
+ return 1
957
+
958
+ # Aggregate by model
959
+ by_model: dict[str, dict[str, float]] = {}
960
+ total_usd = 0.0
961
+ total_input_tokens = 0
962
+ total_output_tokens = 0
963
+
964
+ for ev in cost_events:
965
+ p = ev.get("payload", {})
966
+ cost_data = p.get("cost", {})
967
+ model_data = p.get("model", {})
968
+ model_name = model_data.get("name", "unknown") if isinstance(model_data, dict) else "unknown"
969
+
970
+ in_cost = float(cost_data.get("input_cost_usd", 0.0))
971
+ out_cost = float(cost_data.get("output_cost_usd", 0.0))
972
+ total_cost = float(cost_data.get("total_cost_usd", 0.0))
973
+
974
+ token_data = p.get("token_usage", {})
975
+ in_tokens = int(token_data.get("input_tokens", 0))
976
+ out_tokens = int(token_data.get("output_tokens", 0))
977
+
978
+ if model_name not in by_model:
979
+ by_model[model_name] = {
980
+ "input_cost": 0.0, "output_cost": 0.0, "total_cost": 0.0,
981
+ "input_tokens": 0, "output_tokens": 0, "calls": 0,
982
+ }
983
+ by_model[model_name]["input_cost"] += in_cost
984
+ by_model[model_name]["output_cost"] += out_cost
985
+ by_model[model_name]["total_cost"] += total_cost
986
+ by_model[model_name]["input_tokens"] += in_tokens
987
+ by_model[model_name]["output_tokens"] += out_tokens
988
+ by_model[model_name]["calls"] += 1
989
+
990
+ total_usd += total_cost
991
+ total_input_tokens += in_tokens
992
+ total_output_tokens += out_tokens
993
+
994
+ # If we have an agent_run_event, use its aggregated total as the authoritative figure
995
+ agent_name = "unknown"
996
+ run_status = "unknown"
997
+ run_duration_ms = 0.0
998
+ if agent_run_event:
999
+ arp = agent_run_event.get("payload", {})
1000
+ agent_name = arp.get("agent_name", "unknown")
1001
+ run_status = arp.get("status", "unknown")
1002
+ run_duration_ms = float(arp.get("duration_ms", 0.0))
1003
+ run_cost = arp.get("total_cost", {})
1004
+ if run_cost:
1005
+ total_usd = max(total_usd, float(run_cost.get("total_cost_usd", total_usd)))
1006
+
1007
+ # Print the report
1008
+ lines: list[str] = []
1009
+ lines.append("=" * 62)
1010
+ lines.append(f" SpanForge Per-Run Cost Report")
1011
+ lines.append("=" * 62)
1012
+ lines.append(f" Run ID : {run_id}")
1013
+ lines.append(f" Agent : {agent_name}")
1014
+ lines.append(f" Status : {run_status}")
1015
+ if run_duration_ms > 0:
1016
+ lines.append(f" Duration : {run_duration_ms:,.1f} ms")
1017
+ lines.append(f" Total cost : ${total_usd:.6f}")
1018
+ lines.append(f" Input tokens : {total_input_tokens:,}")
1019
+ lines.append(f" Output tokens : {total_output_tokens:,}")
1020
+ lines.append(f" LLM calls : {len(cost_events)}")
1021
+ lines.append("-" * 62)
1022
+
1023
+ if by_model:
1024
+ lines.append(" Cost by model:")
1025
+ lines.append(f" {'Model':<30s} {'Calls':>5s} {'Input $':>9s} {'Output $':>9s} {'Total $':>10s}")
1026
+ lines.append(f" {'-'*30} {'-'*5} {'-'*9} {'-'*9} {'-'*10}")
1027
+ for model_name, data in sorted(by_model.items(), key=lambda kv: kv[1]["total_cost"], reverse=True):
1028
+ lines.append(
1029
+ f" {model_name:<30s} {int(data['calls']):>5d} "
1030
+ f"${data['input_cost']:>8.6f} ${data['output_cost']:>8.6f} ${data['total_cost']:>9.6f}"
1031
+ )
1032
+
1033
+ lines.append("=" * 62)
1034
+ print("\n".join(lines))
1035
+ return 0
1036
+
1037
+
1038
+ def _cmd_dev(args: argparse.Namespace) -> int:
1039
+ """Implement ``spanforge dev <action>``."""
1040
+ from spanforge.core.dx import DevCLI # noqa: PLC0415
1041
+
1042
+ action = getattr(args, "dev_command", None)
1043
+ if action is None:
1044
+ print("error: specify a dev sub-command: start, stop, reset, logs, status", file=sys.stderr)
1045
+ return 2
1046
+
1047
+ cli = DevCLI()
1048
+ if action == "start":
1049
+ service = getattr(args, "service", "spanforge-dev")
1050
+ cli.start(service)
1051
+ print(f"[✓] Dev environment started service={service!r}")
1052
+ elif action == "stop":
1053
+ cli.stop()
1054
+ print("[✓] Dev environment stopped (no buffered spans)")
1055
+ elif action == "reset":
1056
+ cli.reset()
1057
+ print("[✓] Dev environment reset")
1058
+ elif action == "logs":
1059
+ entries = cli.logs()
1060
+ if not entries:
1061
+ print("(no log entries for this session)")
1062
+ else:
1063
+ for line in entries:
1064
+ print(line)
1065
+ elif action == "status":
1066
+ status = cli.status()
1067
+ print(json.dumps(status, indent=2))
1068
+ else:
1069
+ print(f"error: unknown dev sub-command: {action!r}", file=sys.stderr)
1070
+ return 2
1071
+ return 0
1072
+
1073
+
1074
+ def _cmd_module_create(args: argparse.Namespace) -> int:
1075
+ """Implement ``spanforge module create``."""
1076
+ from spanforge.core.dx import ModuleCLI # noqa: PLC0415
1077
+
1078
+ base_dir = Path(getattr(args, "output_dir", ".") or ".")
1079
+ cli = ModuleCLI()
1080
+ try:
1081
+ scaffolded = cli.scaffold(
1082
+ module_name=args.name,
1083
+ trust_level=getattr(args, "trust_level", "UNTRUSTED"),
1084
+ author=getattr(args, "author", "unknown"),
1085
+ base_dir=base_dir,
1086
+ )
1087
+ except Exception as exc:
1088
+ print(f"error: scaffolding failed: {exc}", file=sys.stderr)
1089
+ return 1
1090
+
1091
+ # Write generated files to disk
1092
+ root = scaffolded.root_dir
1093
+ root.mkdir(parents=True, exist_ok=True)
1094
+ for rel_path, content in scaffolded.files.items():
1095
+ file_path = root / rel_path
1096
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1097
+ file_path.write_text(content, encoding="utf-8")
1098
+ print(f"[✓] {file_path}")
1099
+
1100
+ print(f"\nModule {args.name!r} created at {root}")
1101
+ return 0
1102
+
1103
+
1104
+ def _cmd_serve(args: argparse.Namespace) -> int:
1105
+ """Implement ``spanforge serve`` — start a local trace viewer."""
1106
+ import signal # noqa: PLC0415
1107
+ from spanforge._server import TraceViewerServer # noqa: PLC0415
1108
+
1109
+ port: int = getattr(args, "port", 8888)
1110
+ host: str = getattr(args, "host", "127.0.0.1")
1111
+ jsonl_file: str | None = getattr(args, "file", None)
1112
+
1113
+ # Pre-load a JSONL file if provided.
1114
+ if jsonl_file:
1115
+ try:
1116
+ import json # noqa: PLC0415
1117
+ from spanforge._store import get_store # noqa: PLC0415
1118
+ from spanforge.event import Event # noqa: PLC0415
1119
+ store = get_store()
1120
+ loaded = 0
1121
+ with open(jsonl_file, encoding="utf-8") as fh:
1122
+ for line in fh:
1123
+ line = line.strip()
1124
+ if not line:
1125
+ continue
1126
+ raw = json.loads(line)
1127
+ try:
1128
+ evt = Event.from_dict(raw)
1129
+ store.record(evt)
1130
+ loaded += 1
1131
+ except Exception: # NOSONAR
1132
+ pass
1133
+ print(f"[spanforge] Loaded {loaded} events from {jsonl_file!r}")
1134
+ except FileNotFoundError:
1135
+ print(f"error: file not found: {jsonl_file!r}", file=sys.stderr)
1136
+ return 1
1137
+ except Exception as exc:
1138
+ print(f"error: could not load file: {exc}", file=sys.stderr)
1139
+ return 1
1140
+
1141
+ server = TraceViewerServer(port=port, host=host)
1142
+ server.start()
1143
+ print(f"[spanforge] Serving traces at http://{host}:{port}/traces")
1144
+ print("[spanforge] Press Ctrl+C to stop.")
1145
+
1146
+ # Block until SIGINT / SIGTERM.
1147
+ stop_event = threading.Event()
1148
+
1149
+ def _handle_signal(sig: int, frame: object) -> None: # noqa: ARG001
1150
+ stop_event.set()
1151
+
1152
+ signal.signal(signal.SIGINT, _handle_signal)
1153
+ try:
1154
+ signal.signal(signal.SIGTERM, _handle_signal)
1155
+ except (OSError, ValueError):
1156
+ pass # SIGTERM not available on Windows in some contexts
1157
+
1158
+ stop_event.wait()
1159
+ server.stop()
1160
+ return 0
1161
+
1162
+
1163
+ # ---------------------------------------------------------------------------
1164
+ # New Phase B sub-commands: init, quickstart, report, ui
1165
+ # ---------------------------------------------------------------------------
1166
+
1167
+ _SPANFORGE_TOML_TEMPLATE = """\
1168
+ # spanforge.toml — project-level spanforge configuration
1169
+ # Generated by: spanforge init
1170
+ # Reference: https://www.getspanforge.com/docs/configuration
1171
+
1172
+ [spanforge]
1173
+ service_name = "{service_name}"
1174
+ env = "development" # development | staging | production
1175
+ exporter = "console" # console | jsonl | otlp | webhook | datadog | grafana_loki
1176
+
1177
+ # Uncomment to write events to a local JSONL file:
1178
+ # endpoint = "events.jsonl"
1179
+
1180
+ # Uncomment to enable HMAC audit-chain signing:
1181
+ # signing_key = "" # base64-encoded 32-byte key; set via SPANFORGE_SIGNING_KEY env var
1182
+
1183
+ # PII redaction — enabled by default in production:
1184
+ [spanforge.redaction]
1185
+ enabled = true
1186
+
1187
+ # Sampling:
1188
+ [spanforge.sampling]
1189
+ rate = 1.0 # 1.0 = emit all events; 0.1 = 10 % sample
1190
+ always_sample_errors = true
1191
+ """
1192
+
1193
+ _EXAMPLE_PY_TEMPLATE = '''\
1194
+ """Example: tracing an LLM call with spanforge.
1195
+
1196
+ Run: python examples/trace_llm.py
1197
+ """
1198
+
1199
+ import spanforge
1200
+
1201
+ spanforge.configure(exporter="console", service_name="{service_name}")
1202
+
1203
+ with spanforge.span("call-llm") as span:
1204
+ span.set_model(model="gpt-4o", system="openai")
1205
+ # --- replace with your real LLM call ---
1206
+ result = {{"role": "assistant", "content": "Hello, world!"}}
1207
+ # ---------------------------------------
1208
+ span.set_token_usage(input=10, output=8, total=18)
1209
+ span.set_status("ok")
1210
+
1211
+ print("Event emitted. Check above output for the JSON envelope.")
1212
+ '''
1213
+
1214
+
1215
+ def _cmd_init(args: argparse.Namespace) -> int:
1216
+ """Implement the ``init`` sub-command — scaffold spanforge.toml in current dir."""
1217
+ import os # noqa: PLC0415
1218
+
1219
+ out_dir = Path(args.output_dir).resolve()
1220
+ out_dir.mkdir(parents=True, exist_ok=True)
1221
+
1222
+ toml_path = out_dir / "spanforge.toml"
1223
+ if toml_path.exists() and not args.force:
1224
+ print(f"[!] {toml_path} already exists. Use --force to overwrite.", file=sys.stderr)
1225
+ return 1
1226
+
1227
+ service_name = args.service_name or Path(os.getcwd()).name or "my-service"
1228
+ toml_path.write_text(
1229
+ _SPANFORGE_TOML_TEMPLATE.format(service_name=service_name), encoding="utf-8"
1230
+ )
1231
+ print(f"[OK] Created {toml_path}")
1232
+
1233
+ examples_dir = out_dir / "examples"
1234
+ examples_dir.mkdir(exist_ok=True)
1235
+ ex_path = examples_dir / "trace_llm.py"
1236
+ if not ex_path.exists():
1237
+ ex_path.write_text(
1238
+ _EXAMPLE_PY_TEMPLATE.format(service_name=service_name), encoding="utf-8"
1239
+ )
1240
+ print(f"[OK] Created {ex_path}")
1241
+
1242
+ print("\nNext steps:")
1243
+ print(f" 1. Edit {toml_path} to configure your exporter.")
1244
+ print(" 2. Run: python examples/trace_llm.py")
1245
+ print(" 3. Run: spanforge check")
1246
+ return 0
1247
+
1248
+
1249
+ def _cmd_quickstart(_args: argparse.Namespace) -> int:
1250
+ """Implement the ``quickstart`` sub-command — interactive setup wizard."""
1251
+ print("spanforge quickstart wizard")
1252
+ print("=" * 40)
1253
+ print("This wizard will configure spanforge for your project.\n")
1254
+
1255
+ try:
1256
+ service_name = input("Service name [my-service]: ").strip() or "my-service"
1257
+ env = (
1258
+ input("Environment (development/staging/production) [development]: ").strip()
1259
+ or "development"
1260
+ )
1261
+ exporter = (
1262
+ input("Exporter (console/jsonl/otlp/datadog) [console]: ").strip() or "console"
1263
+ )
1264
+ endpoint = ""
1265
+ if exporter == "jsonl":
1266
+ endpoint = input("JSONL output path [events.jsonl]: ").strip() or "events.jsonl"
1267
+ elif exporter in ("otlp", "datadog"):
1268
+ endpoint = input("Endpoint URL: ").strip()
1269
+ enable_signing = input("Enable HMAC signing? (y/N): ").strip().lower() in ("y", "yes")
1270
+ except (KeyboardInterrupt, EOFError):
1271
+ print("\nAborted.", file=sys.stderr)
1272
+ return 1
1273
+
1274
+ lines = [
1275
+ "# spanforge.toml — generated by spanforge quickstart",
1276
+ "[spanforge]",
1277
+ f'service_name = "{service_name}"',
1278
+ f'env = "{env}"',
1279
+ f'exporter = "{exporter}"',
1280
+ ]
1281
+ if endpoint:
1282
+ lines.append(f'endpoint = "{endpoint}"')
1283
+ if enable_signing:
1284
+ lines.append("# signing_key = \"\" # export SPANFORGE_SIGNING_KEY=<key>")
1285
+ Path("spanforge.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
1286
+ print("[OK] Wrote spanforge.toml")
1287
+
1288
+ print("\nRunning health check ...")
1289
+ import importlib # noqa: PLC0415
1290
+
1291
+ try:
1292
+ sf = importlib.import_module("spanforge")
1293
+ sf.configure(exporter=exporter, service_name=service_name, env=env)
1294
+ with sf.span("quickstart-test") as span:
1295
+ span.set_status("ok")
1296
+ print("[OK] Test event emitted successfully!")
1297
+ except Exception as exc: # noqa: BLE001
1298
+ print(f"[!] Health check failed: {exc}", file=sys.stderr)
1299
+
1300
+ print("\nSetup complete. Run 'spanforge check' any time to verify your pipeline.")
1301
+ return 0
1302
+
1303
+
1304
+ def _cmd_report(args: argparse.Namespace) -> int:
1305
+ """Implement the ``report`` sub-command — generate a static HTML trace report."""
1306
+ src = Path(args.file)
1307
+ if not src.exists():
1308
+ print(f"[x] File not found: {src}", file=sys.stderr)
1309
+ return 1
1310
+
1311
+ out_path = Path(args.output)
1312
+ events: list[dict] = []
1313
+ with src.open(encoding="utf-8") as fh:
1314
+ for lineno, line in enumerate(fh, 1):
1315
+ line = line.strip()
1316
+ if not line:
1317
+ continue
1318
+ try:
1319
+ events.append(json.loads(line))
1320
+ except json.JSONDecodeError as exc:
1321
+ print(f"[!] Line {lineno}: {exc}", file=sys.stderr)
1322
+
1323
+ if not events:
1324
+ print(_NO_EVENTS_MSG)
1325
+ return 0
1326
+
1327
+ rows: list[str] = []
1328
+ for ev in events:
1329
+ ts = ev.get("timestamp", "")[:19]
1330
+ ns = ev.get("namespace", "")
1331
+ eid = ev.get("event_id", "")[:8]
1332
+ svc = ev.get("service_name", "")
1333
+ payload_str = json.dumps(ev.get("payload", {}), separators=(",", ":"))[:120]
1334
+ rows.append(
1335
+ f"<tr><td>{ts}</td><td><code>{ns}</code></td>"
1336
+ f"<td><code>{eid}</code></td><td>{svc}</td>"
1337
+ f"<td><pre style='margin:0;font-size:11px'>{payload_str}</pre></td></tr>"
1338
+ )
1339
+
1340
+ html = (
1341
+ "<!DOCTYPE html>\n<html lang='en'>\n<head>\n"
1342
+ " <meta charset='utf-8'/>\n"
1343
+ f" <title>spanforge report \u2014 {src.name}</title>\n"
1344
+ " <style>\n"
1345
+ " body{font-family:system-ui,sans-serif;padding:1rem 2rem}\n"
1346
+ " h1{font-size:1.3rem;color:#333}\n"
1347
+ " table{border-collapse:collapse;width:100%;font-size:13px}\n"
1348
+ " th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}\n"
1349
+ " th{background:#f4f4f4}\n"
1350
+ " tr:nth-child(even){background:#fafafa}\n"
1351
+ " </style>\n</head>\n<body>\n"
1352
+ " <h1>spanforge \u2014 Trace Report</h1>\n"
1353
+ f" <p>Source: <code>{src}</code> &nbsp;|&nbsp; Events: <strong>{len(events)}</strong></p>\n"
1354
+ " <table>\n <thead>\n"
1355
+ " <tr><th>Timestamp</th><th>Namespace</th><th>Event ID</th>"
1356
+ "<th>Service</th><th>Payload</th></tr>\n"
1357
+ " </thead>\n <tbody>\n"
1358
+ + "".join(f" {r}\n" for r in rows)
1359
+ + " </tbody>\n </table>\n</body>\n</html>"
1360
+ )
1361
+
1362
+ out_path.write_text(html, encoding="utf-8")
1363
+ print(f"[OK] Report written to {out_path} ({len(events)} events)")
1364
+ return 0
1365
+
1366
+
1367
+ def _cmd_ui(args: argparse.Namespace) -> int:
1368
+ """Implement the ``ui`` sub-command — serve the interactive SPA trace viewer."""
1369
+ import signal # noqa: PLC0415
1370
+ import webbrowser # noqa: PLC0415
1371
+
1372
+ from spanforge._server import TraceViewerServer # noqa: PLC0415
1373
+
1374
+ port = args.port
1375
+
1376
+ if args.file:
1377
+ src = Path(args.file)
1378
+ if not src.exists():
1379
+ print(f"[x] File not found: {src}", file=sys.stderr)
1380
+ return 1
1381
+ from spanforge._store import get_store # noqa: PLC0415
1382
+ from spanforge.event import Event # noqa: PLC0415
1383
+
1384
+ store = get_store()
1385
+ loaded = 0
1386
+ with src.open(encoding="utf-8") as fh:
1387
+ for line in fh:
1388
+ line = line.strip()
1389
+ if not line:
1390
+ continue
1391
+ try:
1392
+ store.record(Event.from_dict(json.loads(line)))
1393
+ loaded += 1
1394
+ except Exception: # noqa: BLE001
1395
+ pass
1396
+ print(f"[spanforge] Loaded {loaded} events from {str(src)!r}")
1397
+
1398
+ server = TraceViewerServer(port=port, host="127.0.0.1")
1399
+ server.start()
1400
+ url = f"http://127.0.0.1:{port}/"
1401
+ print(f"[spanforge] Trace viewer running at {url}")
1402
+ print("[spanforge] Press Ctrl+C to stop.")
1403
+
1404
+ if not args.no_browser:
1405
+ webbrowser.open(url)
1406
+
1407
+ stop_evt = threading.Event()
1408
+
1409
+ def _handle_sig(*_: object) -> None:
1410
+ stop_evt.set()
1411
+
1412
+ signal.signal(signal.SIGINT, _handle_sig)
1413
+ if hasattr(signal, "SIGTERM"):
1414
+ signal.signal(signal.SIGTERM, _handle_sig)
1415
+
1416
+ stop_evt.wait()
1417
+ server.stop()
1418
+ print("\n[spanforge] Stopped.")
1419
+ return 0
1420
+
1421
+
1422
+ def _cmd_audit_erase(args: argparse.Namespace) -> int:
1423
+ """Implement ``spanforge audit erase`` — GDPR subject erasure on a JSONL file."""
1424
+ import os # noqa: PLC0415
1425
+
1426
+ from spanforge.signing import AuditStream, verify_chain # noqa: PLC0415
1427
+
1428
+ path = Path(args.file)
1429
+ if not path.exists():
1430
+ print(f"error: file not found: {path}", file=sys.stderr)
1431
+ return 2
1432
+
1433
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
1434
+ if not org_secret:
1435
+ print("error: SPANFORGE_SIGNING_KEY environment variable is not set.", file=sys.stderr)
1436
+ return 2
1437
+
1438
+ subject_id = args.subject_id
1439
+ if not subject_id or not subject_id.strip():
1440
+ print("error: --subject-id must be non-empty", file=sys.stderr)
1441
+ return 2
1442
+
1443
+ # Prevent accidental overwrite of the input file
1444
+ out_path = Path(args.output) if args.output else path.with_suffix(".erased.jsonl")
1445
+ if out_path.resolve() == path.resolve():
1446
+ print(
1447
+ "error: --output must differ from input file to prevent overwrite",
1448
+ file=sys.stderr,
1449
+ )
1450
+ return 2
1451
+
1452
+ rows = _read_jsonl_events(path)
1453
+ if not rows:
1454
+ print(_NO_EVENTS_MSG)
1455
+ return 0
1456
+
1457
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
1458
+ if bad_lines:
1459
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
1460
+ for ln, exc in bad_lines[:5]:
1461
+ print(f" line {ln}: {exc}", file=sys.stderr)
1462
+ return 2
1463
+
1464
+ events = [ev for _, ev in rows]
1465
+
1466
+ stream = AuditStream(org_secret=org_secret, source="spanforge-cli@1.0.0")
1467
+ # Load events into stream
1468
+ for evt in events:
1469
+ stream.append(evt)
1470
+
1471
+ tombstones = stream.erase_subject(
1472
+ subject_id,
1473
+ erased_by=getattr(args, "erased_by", "cli"),
1474
+ reason=getattr(args, "reason", "GDPR Art.17 right to erasure"),
1475
+ request_ref=getattr(args, "request_ref", ""),
1476
+ )
1477
+
1478
+ if not tombstones:
1479
+ print(f"No events found mentioning subject {subject_id!r}.")
1480
+ return 0
1481
+
1482
+ # Pre-verify: ensure chain is still valid before writing
1483
+ chain_result = verify_chain(list(stream.events), org_secret)
1484
+ if not chain_result.valid:
1485
+ print(
1486
+ "error: chain verification failed after erasure — aborting write",
1487
+ file=sys.stderr,
1488
+ )
1489
+ return 2
1490
+
1491
+ # Write the updated chain back to the output file
1492
+ with out_path.open("w", encoding="utf-8") as fh:
1493
+ for evt in stream.events:
1494
+ fh.write(evt.to_json())
1495
+ fh.write("\n")
1496
+
1497
+ print(f"[✓] Erased {len(tombstones)} event(s) mentioning {subject_id!r}")
1498
+ print(f"[✓] Updated chain written to {out_path}")
1499
+ return 0
1500
+
1501
+
1502
+ def _cmd_scan(args: argparse.Namespace) -> int:
1503
+ """Implement ``spanforge scan`` — deep PII scan on a JSONL file."""
1504
+ from spanforge.redact import scan_payload # noqa: PLC0415
1505
+
1506
+ path = Path(args.file)
1507
+ if not path.exists():
1508
+ print(f"error: file not found: {path}", file=sys.stderr)
1509
+ return 2
1510
+
1511
+ rows = _read_jsonl_events(path)
1512
+ if not rows:
1513
+ print(_NO_EVENTS_MSG)
1514
+ return 0
1515
+
1516
+ # GA-03-D: --types filter
1517
+ type_filter: set[str] | None = None
1518
+ raw_types = getattr(args, "types", None)
1519
+ if raw_types:
1520
+ type_filter = {t.strip().lower() for t in raw_types.split(",")}
1521
+
1522
+ all_hits: list[dict[str, str]] = []
1523
+ total_scanned = 0
1524
+
1525
+ for idx, row in enumerate(rows):
1526
+ if isinstance(row[1], Exception):
1527
+ continue
1528
+ event = row[1]
1529
+ payload = getattr(event, "payload", None)
1530
+ if not isinstance(payload, dict):
1531
+ continue
1532
+ result = scan_payload(payload)
1533
+ total_scanned += result.scanned
1534
+ for hit in result.hits:
1535
+ if type_filter and hit.pii_type.lower() not in type_filter:
1536
+ continue
1537
+ all_hits.append({
1538
+ "event_index": str(idx),
1539
+ "event_id": getattr(event, "event_id", "unknown"),
1540
+ "pii_type": hit.pii_type,
1541
+ "path": hit.path,
1542
+ "match_count": str(hit.match_count),
1543
+ "sensitivity": hit.sensitivity,
1544
+ })
1545
+
1546
+ fmt = getattr(args, "format", "text")
1547
+ if fmt == "json":
1548
+ import json as _json # noqa: PLC0415
1549
+ print(_json.dumps({
1550
+ "file": str(path),
1551
+ "events_scanned": len(rows),
1552
+ "strings_scanned": total_scanned,
1553
+ "pii_hits": len(all_hits),
1554
+ "hits": all_hits,
1555
+ }, indent=2))
1556
+ else:
1557
+ print(f"Scanned {len(rows)} events ({total_scanned} string values)")
1558
+ if not all_hits:
1559
+ print("[✓] No PII detected.")
1560
+ else:
1561
+ print(f"[!] Found {len(all_hits)} PII hit(s):\n")
1562
+ for h in all_hits:
1563
+ print(f" event #{h['event_index']} ({h['event_id']}) "
1564
+ f"{h['pii_type']:20s} path={h['path']} "
1565
+ f"matches={h['match_count']} sensitivity={h['sensitivity']}")
1566
+
1567
+ # GA-03-D: --fail-on-match returns 1 on any hit
1568
+ fail_on_match = getattr(args, "fail_on_match", False)
1569
+ if fail_on_match and all_hits:
1570
+ return 1
1571
+ return 1 if all_hits else 0
1572
+
1573
+
1574
+ def _cmd_migrate(args: argparse.Namespace) -> int:
1575
+ """Implement ``spanforge migrate`` — schema v1→v2 migration."""
1576
+ import os # noqa: PLC0415
1577
+
1578
+ from spanforge.migrate import migrate_file # noqa: PLC0415
1579
+
1580
+ path = Path(args.file)
1581
+ if not path.exists():
1582
+ print(f"error: file not found: {path}", file=sys.stderr)
1583
+ return 2
1584
+
1585
+ output = getattr(args, "output", None)
1586
+ target_version = getattr(args, "target_version", "2.0")
1587
+ dry_run = getattr(args, "dry_run", False)
1588
+
1589
+ # GA-05-C: --sign reads SPANFORGE_SIGNING_KEY for chain re-signing
1590
+ org_secret: str | None = None
1591
+ if getattr(args, "sign", False):
1592
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
1593
+ if not org_secret:
1594
+ print("error: --sign requires SPANFORGE_SIGNING_KEY", file=sys.stderr)
1595
+ return 2
1596
+
1597
+ stats = migrate_file(
1598
+ path,
1599
+ output=output,
1600
+ org_secret=org_secret,
1601
+ target_version=target_version,
1602
+ dry_run=dry_run,
1603
+ )
1604
+
1605
+ print(f"Total events: {stats.total}")
1606
+ print(f"Migrated (v1→v2): {stats.migrated}")
1607
+ print(f"Skipped (v2): {stats.skipped}")
1608
+ print(f"Errors: {stats.errors}")
1609
+ if stats.warnings:
1610
+ print(f"Warnings: {len(stats.warnings)}")
1611
+ for w in stats.warnings:
1612
+ print(f" - {w}")
1613
+ if stats.transformed_fields:
1614
+ print("Transformations:")
1615
+ for k, v in stats.transformed_fields.items():
1616
+ print(f" {k}: {v}")
1617
+ if dry_run:
1618
+ print("(dry run — no files written)")
1619
+ else:
1620
+ print(f"Output: {stats.output_path}")
1621
+ return 1 if stats.errors > 0 else 0
1622
+
1623
+
1624
+ def _cmd_migrate_langsmith(args: argparse.Namespace) -> int:
1625
+ """Implement ``spanforge migrate-langsmith`` — LangSmith export import."""
1626
+ import time as _time # noqa: PLC0415
1627
+
1628
+ from spanforge import Event, EventType, Tags # noqa: PLC0415
1629
+ from spanforge.ulid import generate as ulid_generate # noqa: PLC0415
1630
+
1631
+ path = Path(args.file)
1632
+ if not path.exists():
1633
+ print(f"error: file not found: {path}", file=sys.stderr)
1634
+ return 2
1635
+
1636
+ output = args.output or str(path).rsplit(".", 1)[0] + "_spanforge.jsonl"
1637
+ source = args.source
1638
+
1639
+ # Read LangSmith export (supports JSONL and JSON array)
1640
+ raw_runs: list[dict] = []
1641
+ content = path.read_text(encoding="utf-8")
1642
+ first_char = content.lstrip()[:1]
1643
+ if first_char == "[":
1644
+ # JSON array format
1645
+ try:
1646
+ raw_runs = json.loads(content)
1647
+ except json.JSONDecodeError as exc:
1648
+ print(f"error: invalid JSON: {exc}", file=sys.stderr)
1649
+ return 1
1650
+ else:
1651
+ # JSONL format
1652
+ for line in content.splitlines():
1653
+ line = line.strip()
1654
+ if line:
1655
+ try:
1656
+ raw_runs.append(json.loads(line))
1657
+ except json.JSONDecodeError:
1658
+ continue
1659
+
1660
+ if not raw_runs:
1661
+ print("error: no runs found in file", file=sys.stderr)
1662
+ return 1
1663
+
1664
+ events: list[dict] = []
1665
+ for run in raw_runs:
1666
+ # LangSmith run schema: id, name, run_type, inputs, outputs,
1667
+ # start_time, end_time, parent_run_id, trace_id, dotted_order,
1668
+ # total_tokens, prompt_tokens, completion_tokens, status, error
1669
+ run_type = run.get("run_type", "chain")
1670
+ run_name = run.get("name", "unknown")
1671
+ run_id = run.get("id", ulid_generate())
1672
+
1673
+ # Map LangSmith run_type → SpanForge EventType
1674
+ if run_type == "llm":
1675
+ event_type = EventType.TRACE_SPAN_COMPLETED.value
1676
+ elif run_type == "tool":
1677
+ event_type = EventType.TOOL_CALL_COMPLETED.value
1678
+ elif run_type == "retriever":
1679
+ event_type = EventType.TRACE_SPAN_COMPLETED.value
1680
+ else:
1681
+ event_type = EventType.TRACE_SPAN_COMPLETED.value
1682
+
1683
+ # Build payload
1684
+ payload: dict[str, Any] = {
1685
+ "span_name": run_name,
1686
+ "run_type": run_type,
1687
+ "status": run.get("status", "ok"),
1688
+ }
1689
+
1690
+ # Token usage
1691
+ total_tok = run.get("total_tokens") or 0
1692
+ prompt_tok = run.get("prompt_tokens") or 0
1693
+ completion_tok = run.get("completion_tokens") or 0
1694
+ if total_tok or prompt_tok or completion_tok:
1695
+ payload["token_usage"] = {
1696
+ "input_tokens": prompt_tok,
1697
+ "output_tokens": completion_tok,
1698
+ "total_tokens": total_tok or (prompt_tok + completion_tok),
1699
+ }
1700
+
1701
+ # Timing
1702
+ if run.get("start_time"):
1703
+ payload["start_time"] = run["start_time"]
1704
+ if run.get("end_time"):
1705
+ payload["end_time"] = run["end_time"]
1706
+
1707
+ # Inputs/outputs (sanitised — no raw content)
1708
+ if run.get("inputs"):
1709
+ payload["input_keys"] = list(run["inputs"].keys()) if isinstance(run["inputs"], dict) else ["input"]
1710
+ if run.get("outputs"):
1711
+ payload["output_keys"] = list(run["outputs"].keys()) if isinstance(run["outputs"], dict) else ["output"]
1712
+
1713
+ # Error info
1714
+ if run.get("error"):
1715
+ payload["error"] = str(run["error"])[:500]
1716
+
1717
+ # Build event
1718
+ trace_id = run.get("trace_id", run.get("session_id", ""))
1719
+ parent_id = run.get("parent_run_id", "")
1720
+
1721
+ event = {
1722
+ "event_id": ulid_generate(),
1723
+ "event_type": event_type,
1724
+ "source": source,
1725
+ "schema_version": "2.0",
1726
+ "timestamp": run.get("start_time") or _time.time(),
1727
+ "payload": payload,
1728
+ "tags": {
1729
+ "langsmith_run_id": str(run_id),
1730
+ "langsmith_trace_id": str(trace_id) if trace_id else "",
1731
+ "langsmith_parent_id": str(parent_id) if parent_id else "",
1732
+ },
1733
+ }
1734
+ events.append(event)
1735
+
1736
+ # Write output
1737
+ out_path = Path(output)
1738
+ with open(out_path, "w", encoding="utf-8") as fh:
1739
+ for evt in events:
1740
+ fh.write(json.dumps(evt, default=str) + "\n")
1741
+
1742
+ print(f"[✓] Imported {len(events)} runs from LangSmith export")
1743
+ print(f" Source: {path}")
1744
+ print(f" Output: {out_path}")
1745
+
1746
+ # Summary by run_type
1747
+ type_counts: dict[str, int] = {}
1748
+ for run in raw_runs:
1749
+ rt = run.get("run_type", "unknown")
1750
+ type_counts[rt] = type_counts.get(rt, 0) + 1
1751
+ for rt, count in sorted(type_counts.items()):
1752
+ print(f" {rt}: {count}")
1753
+
1754
+ return 0
1755
+
1756
+
1757
+ def _cmd_audit_check_health(args: argparse.Namespace) -> int:
1758
+ """Implement ``spanforge audit check-health``."""
1759
+ import os # noqa: PLC0415
1760
+
1761
+ from spanforge.redact import scan_payload # noqa: PLC0415
1762
+ from spanforge.signing import ( # noqa: PLC0415
1763
+ AuditStream,
1764
+ check_key_expiry,
1765
+ validate_key_strength,
1766
+ verify_chain,
1767
+ )
1768
+
1769
+ path = Path(args.file)
1770
+ if not path.exists():
1771
+ print(f"error: file not found: {path}", file=sys.stderr)
1772
+ return 2
1773
+
1774
+ output_fmt = getattr(args, "output", "text")
1775
+ checks: list[dict[str, object]] = []
1776
+ all_ok = True
1777
+
1778
+ # 1. File readable
1779
+ checks.append({"name": "file_readable", "status": "pass", "detail": str(path)})
1780
+
1781
+ # 2. Parse events
1782
+ rows = _read_jsonl_events(path)
1783
+ if not rows:
1784
+ checks.append({"name": "parse_events", "status": "skip", "detail": "File is empty"})
1785
+ if output_fmt == "json":
1786
+ import json as _json # noqa: PLC0415
1787
+ print(_json.dumps({"file": str(path), "checks": checks, "result": "pass"}, indent=2))
1788
+ else:
1789
+ print(f"Health check: {path}\n")
1790
+ print("[✓] File exists and is readable")
1791
+ print("[!] File is empty — no events to check")
1792
+ return 0
1793
+
1794
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
1795
+ events = [ev for _, ev in rows if not isinstance(ev, Exception)]
1796
+
1797
+ parse_status = "pass" if not bad_lines else "fail"
1798
+ if bad_lines:
1799
+ all_ok = False
1800
+ checks.append({
1801
+ "name": "parse_events",
1802
+ "status": parse_status,
1803
+ "detail": f"{len(events)} parsed, {len(bad_lines)} error(s)",
1804
+ })
1805
+
1806
+ # 3. Chain integrity
1807
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
1808
+ if org_secret and events:
1809
+ result = verify_chain(events, org_secret)
1810
+ if result.valid:
1811
+ checks.append({
1812
+ "name": "chain_integrity",
1813
+ "status": "pass",
1814
+ "detail": f"{len(events)} events verified",
1815
+ })
1816
+ else:
1817
+ all_ok = False
1818
+ checks.append({
1819
+ "name": "chain_integrity",
1820
+ "status": "fail",
1821
+ "detail": f"{result.tampered_count} tampered, {len(result.gaps)} gap(s)",
1822
+ })
1823
+ else:
1824
+ checks.append({
1825
+ "name": "chain_integrity",
1826
+ "status": "skip",
1827
+ "detail": "SPANFORGE_SIGNING_KEY not set",
1828
+ })
1829
+
1830
+ # 4. Key strength
1831
+ if org_secret:
1832
+ warnings = validate_key_strength(org_secret)
1833
+ if warnings:
1834
+ all_ok = False
1835
+ checks.append({
1836
+ "name": "key_strength",
1837
+ "status": "fail",
1838
+ "detail": "; ".join(warnings),
1839
+ })
1840
+ else:
1841
+ checks.append({"name": "key_strength", "status": "pass", "detail": "OK"})
1842
+ else:
1843
+ checks.append({
1844
+ "name": "key_strength",
1845
+ "status": "skip",
1846
+ "detail": "No key to check",
1847
+ })
1848
+
1849
+ # 5. Key expiry
1850
+ expires_at = os.environ.get("SPANFORGE_SIGNING_KEY_EXPIRES_AT", "")
1851
+ if expires_at:
1852
+ status, days = check_key_expiry(expires_at)
1853
+ if status == "expired":
1854
+ all_ok = False
1855
+ checks.append({
1856
+ "name": "key_expiry",
1857
+ "status": "fail",
1858
+ "detail": f"EXPIRED {days} day(s) ago",
1859
+ })
1860
+ elif status == "expiring_soon":
1861
+ all_ok = False
1862
+ checks.append({
1863
+ "name": "key_expiry",
1864
+ "status": "fail",
1865
+ "detail": f"expiring in {days} day(s)",
1866
+ })
1867
+ else:
1868
+ checks.append({
1869
+ "name": "key_expiry",
1870
+ "status": "pass",
1871
+ "detail": f"valid for {days} day(s)",
1872
+ })
1873
+ else:
1874
+ checks.append({
1875
+ "name": "key_expiry",
1876
+ "status": "skip",
1877
+ "detail": "SPANFORGE_SIGNING_KEY_EXPIRES_AT not set",
1878
+ })
1879
+
1880
+ # 6. GA-08-B: PII scan
1881
+ pii_hit_count = 0
1882
+ for _, item in rows:
1883
+ if isinstance(item, Exception):
1884
+ continue
1885
+ payload = getattr(item, "payload", None)
1886
+ if isinstance(payload, dict):
1887
+ result_pii = scan_payload(payload)
1888
+ pii_hit_count += len(result_pii.hits)
1889
+ if pii_hit_count:
1890
+ all_ok = False
1891
+ checks.append({
1892
+ "name": "pii_scan",
1893
+ "status": "fail",
1894
+ "detail": f"{pii_hit_count} PII hit(s) detected",
1895
+ })
1896
+ else:
1897
+ checks.append({"name": "pii_scan", "status": "pass", "detail": "No PII detected"})
1898
+
1899
+ # 7. GA-08-B: Egress config check
1900
+ from spanforge.config import get_config # noqa: PLC0415
1901
+ try:
1902
+ cfg = get_config()
1903
+ if cfg.exporter:
1904
+ checks.append({
1905
+ "name": "egress_config",
1906
+ "status": "pass",
1907
+ "detail": f"exporter={cfg.exporter!r}",
1908
+ })
1909
+ else:
1910
+ checks.append({
1911
+ "name": "egress_config",
1912
+ "status": "skip",
1913
+ "detail": "No exporter configured",
1914
+ })
1915
+ except Exception as exc:
1916
+ all_ok = False
1917
+ checks.append({
1918
+ "name": "egress_config",
1919
+ "status": "fail",
1920
+ "detail": str(exc),
1921
+ })
1922
+
1923
+ # Output
1924
+ if output_fmt == "json":
1925
+ import json as _json # noqa: PLC0415
1926
+ print(_json.dumps({
1927
+ "file": str(path),
1928
+ "events": len(events),
1929
+ "errors": len(bad_lines),
1930
+ "checks": checks,
1931
+ "result": "pass" if all_ok else "fail",
1932
+ }, indent=2))
1933
+ else:
1934
+ print(f"Health check: {path}\n")
1935
+ for c in checks:
1936
+ icon = {"pass": "✓", "fail": "!", "skip": "–"}.get(c["status"], "?") # type: ignore[arg-type]
1937
+ print(f"[{icon}] {c['name']}: {c['detail']}")
1938
+ print(f"\nTotal: {len(events)} events, {len(bad_lines)} errors")
1939
+ print(f"Result: {'PASS' if all_ok else 'FAIL'}")
1940
+
1941
+ # GA-08-B: return 1 on ANY check failure
1942
+ return 0 if all_ok else 1
1943
+
1944
+
1945
+ def _cmd_audit_verify(args: argparse.Namespace) -> int:
1946
+ """Implement ``spanforge audit verify``."""
1947
+ import glob as _glob # noqa: PLC0415
1948
+ import os # noqa: PLC0415
1949
+
1950
+ from spanforge.signing import verify_chain # noqa: PLC0415
1951
+
1952
+ org_secret = args.key or os.environ.get("SPANFORGE_SIGNING_KEY", "")
1953
+ if not org_secret:
1954
+ print(
1955
+ "error: no signing key — pass --key or set SPANFORGE_SIGNING_KEY",
1956
+ file=sys.stderr,
1957
+ )
1958
+ return 2
1959
+
1960
+ # Expand glob pattern
1961
+ matched = sorted(_glob.glob(args.input, recursive=True))
1962
+ if not matched:
1963
+ print(f"error: no files matched: {args.input}", file=sys.stderr)
1964
+ return 2
1965
+
1966
+ all_events = []
1967
+ parse_errors = 0
1968
+ for fpath in matched:
1969
+ rows = _read_jsonl_events(Path(fpath))
1970
+ for _ln, item in rows:
1971
+ if isinstance(item, Exception):
1972
+ parse_errors += 1
1973
+ else:
1974
+ all_events.append(item)
1975
+
1976
+ if not all_events:
1977
+ print("error: no events found in matched files", file=sys.stderr)
1978
+ return 2
1979
+
1980
+ result = verify_chain(all_events, org_secret)
1981
+
1982
+ # Report
1983
+ print(f"Files checked : {len(matched)}")
1984
+ print(f"Total events : {len(all_events)}")
1985
+ if parse_errors:
1986
+ print(f"Parse errors : {parse_errors}")
1987
+ if result.tombstone_count:
1988
+ print(f"Tombstones : {result.tombstone_count}")
1989
+ print(f"Tampered : {result.tampered_count}")
1990
+ print(f"Gaps : {len(result.gaps)}")
1991
+ if result.first_tampered:
1992
+ print(f"First tampered: {result.first_tampered}")
1993
+ if result.gaps:
1994
+ print(f"Gap event IDs : {', '.join(result.gaps[:10])}")
1995
+ if len(result.gaps) > 10:
1996
+ print(f" ... and {len(result.gaps) - 10} more")
1997
+
1998
+ if result.valid:
1999
+ print("\nResult: PASS")
2000
+ return 0
2001
+ else:
2002
+ print("\nResult: FAIL")
2003
+ return 1
2004
+
2005
+
2006
+ def _cmd_audit_rotate_key(args: argparse.Namespace) -> int:
2007
+ """Implement ``spanforge audit rotate-key``."""
2008
+ import os # noqa: PLC0415
2009
+
2010
+ from spanforge.signing import AuditStream, verify_chain # noqa: PLC0415
2011
+
2012
+ path = Path(args.file)
2013
+ if not path.exists():
2014
+ print(f"error: file not found: {path}", file=sys.stderr)
2015
+ return 2
2016
+
2017
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
2018
+ if not org_secret:
2019
+ print("error: SPANFORGE_SIGNING_KEY environment variable is not set.", file=sys.stderr)
2020
+ return 2
2021
+
2022
+ new_key_env = getattr(args, "new_key_env", "SPANFORGE_NEW_SIGNING_KEY")
2023
+ new_secret = os.environ.get(new_key_env, "")
2024
+ if not new_secret:
2025
+ print(f"error: {new_key_env} environment variable is not set.", file=sys.stderr)
2026
+ return 2
2027
+
2028
+ rows = _read_jsonl_events(path)
2029
+ if not rows:
2030
+ print(_NO_EVENTS_MSG)
2031
+ return 0
2032
+
2033
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
2034
+ if bad_lines:
2035
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
2036
+ for ln, exc in bad_lines[:5]:
2037
+ print(f" line {ln}: {exc}", file=sys.stderr)
2038
+ return 2
2039
+
2040
+ events = [ev for _, ev in rows]
2041
+
2042
+ stream = AuditStream(org_secret=org_secret, source="spanforge-cli@1.0.0")
2043
+ for evt in events:
2044
+ stream.append(evt)
2045
+
2046
+ reason = getattr(args, "reason", "scheduled rotation")
2047
+ stream.rotate_key(new_secret, metadata={"reason": reason, "rotated_by": "cli"})
2048
+
2049
+ # GA-01-C: default output must differ from input
2050
+ explicit_output = getattr(args, "output", None)
2051
+ if explicit_output:
2052
+ out_path = Path(explicit_output)
2053
+ else:
2054
+ out_path = path.with_suffix(".rotated.jsonl")
2055
+
2056
+ with out_path.open("w", encoding="utf-8") as fh:
2057
+ for evt in stream.events:
2058
+ fh.write(evt.to_json())
2059
+ fh.write("\n")
2060
+
2061
+ print(f"[✓] Key rotated — chain rewritten to {out_path}")
2062
+
2063
+ # GA-01-C: re-verify the rotated chain with the new key
2064
+ rotated_events = stream.events
2065
+ vr = verify_chain(rotated_events, new_secret)
2066
+ if vr.valid:
2067
+ print(f"[✓] Re-verification: chain valid ({len(rotated_events)} events)")
2068
+ else:
2069
+ print(f"[!] Re-verification: FAILED — {vr.tampered_count} tampered, {len(vr.gaps)} gap(s)")
2070
+ return 1
2071
+
2072
+ print(f"[✓] Update SPANFORGE_SIGNING_KEY to the value of {new_key_env}")
2073
+ return 0
2074
+
2075
+
2076
+ # ---------------------------------------------------------------------------
2077
+ # T.R.U.S.T. Framework CLI handlers
2078
+ # ---------------------------------------------------------------------------
2079
+
2080
+
2081
+ def _cmd_consent(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2082
+ """Handle ``spanforge consent`` subcommands."""
2083
+ from spanforge.consent import grant_consent, revoke_consent, check_consent # noqa: PLC0415
2084
+
2085
+ action = getattr(args, "consent_command", None)
2086
+ if action == "check":
2087
+ ok = check_consent(args.subject, args.scope)
2088
+ status = "GRANTED" if ok else "NOT GRANTED"
2089
+ print(f"consent({args.subject!r}, {args.scope!r}) = {status}")
2090
+ return 0 if ok else 1
2091
+ elif action == "grant":
2092
+ grant_consent(
2093
+ subject_id=args.subject,
2094
+ scope=args.scope,
2095
+ purpose=args.purpose,
2096
+ legal_basis=args.legal_basis,
2097
+ )
2098
+ print(f"[✓] Consent granted: subject={args.subject!r} scope={args.scope!r}")
2099
+ return 0
2100
+ elif action == "revoke":
2101
+ revoke_consent(subject_id=args.subject, scope=args.scope)
2102
+ print(f"[✓] Consent revoked: subject={args.subject!r} scope={args.scope!r}")
2103
+ return 0
2104
+ else:
2105
+ parser.print_help()
2106
+ return 2
2107
+
2108
+
2109
+ def _cmd_hitl(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2110
+ """Handle ``spanforge hitl`` subcommands."""
2111
+ import json as _json # noqa: PLC0415
2112
+ from spanforge.hitl import list_pending, review_item # noqa: PLC0415
2113
+
2114
+ action = getattr(args, "hitl_command", None)
2115
+ if action == "pending":
2116
+ items = list_pending()
2117
+ if not items:
2118
+ print("No pending HITL items.")
2119
+ else:
2120
+ for item in items:
2121
+ print(
2122
+ f" {item.decision_id} risk={item.risk_tier} "
2123
+ f"agent={item.agent_id} reason={item.reason}"
2124
+ )
2125
+ return 0
2126
+ elif action == "review":
2127
+ result = review_item(args.decision_id, args.reviewer, args.outcome)
2128
+ if result is None:
2129
+ print(f"[!] Decision {args.decision_id!r} not found in queue.")
2130
+ return 1
2131
+ print(
2132
+ f"[✓] Decision {args.decision_id!r} marked as {args.outcome} "
2133
+ f"by {args.reviewer}"
2134
+ )
2135
+ return 0
2136
+ else:
2137
+ parser.print_help()
2138
+ return 2
2139
+
2140
+
2141
+ def _cmd_model(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2142
+ """Handle ``spanforge model`` subcommands."""
2143
+ from spanforge.model_registry import ( # noqa: PLC0415
2144
+ ModelRegistry,
2145
+ deprecate_model,
2146
+ list_models,
2147
+ register_model,
2148
+ retire_model,
2149
+ )
2150
+
2151
+ action = getattr(args, "model_command", None)
2152
+ if action == "list":
2153
+ models = list_models()
2154
+ if not models:
2155
+ print("No models registered.")
2156
+ else:
2157
+ for m in models:
2158
+ print(
2159
+ f" {m.model_id} name={m.name!r} version={m.version} "
2160
+ f"status={m.status} risk={m.risk_tier} owner={m.owner}"
2161
+ )
2162
+ return 0
2163
+ elif action == "register":
2164
+ try:
2165
+ entry = register_model(
2166
+ model_id=args.model_id,
2167
+ name=args.name,
2168
+ version=args.version,
2169
+ risk_tier=args.risk_tier,
2170
+ owner=args.owner,
2171
+ purpose=args.purpose,
2172
+ )
2173
+ print(f"[✓] Model registered: {entry.model_id}")
2174
+ return 0
2175
+ except ValueError as exc:
2176
+ print(f"[!] {exc}")
2177
+ return 1
2178
+ elif action == "deprecate":
2179
+ try:
2180
+ entry = deprecate_model(args.model_id, reason=args.reason)
2181
+ print(f"[✓] Model deprecated: {entry.model_id}")
2182
+ return 0
2183
+ except (KeyError, ValueError) as exc:
2184
+ print(f"[!] {exc}")
2185
+ return 1
2186
+ elif action == "retire":
2187
+ try:
2188
+ entry = retire_model(args.model_id)
2189
+ print(f"[✓] Model retired: {entry.model_id}")
2190
+ return 0
2191
+ except (KeyError, ValueError) as exc:
2192
+ print(f"[!] {exc}")
2193
+ return 1
2194
+ else:
2195
+ parser.print_help()
2196
+ return 2
2197
+
2198
+
2199
+ def _cmd_explain(args: argparse.Namespace) -> int:
2200
+ """Handle ``spanforge explain`` subcommand."""
2201
+ from spanforge.explain import generate_explanation # noqa: PLC0415
2202
+
2203
+ record = generate_explanation(
2204
+ trace_id=args.trace_id,
2205
+ agent_id=args.agent_id,
2206
+ decision_id=args.decision_id,
2207
+ factors=[],
2208
+ summary=args.summary,
2209
+ )
2210
+ print(record.to_text())
2211
+ return 0
2212
+
2213
+
2214
+ def _cmd_eval(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2215
+ """Handle ``spanforge eval`` subcommands."""
2216
+ import json # noqa: PLC0415
2217
+
2218
+ action = getattr(args, "eval_command", None)
2219
+
2220
+ if action == "save":
2221
+ # Save examples to JSONL dataset file
2222
+ examples: list[dict[str, Any]] = []
2223
+ if getattr(args, "input", None):
2224
+ in_path = Path(args.input)
2225
+ if not in_path.exists():
2226
+ print(f"[!] File not found: {in_path}")
2227
+ return 1
2228
+ with open(in_path, encoding="utf-8") as fh:
2229
+ for line in fh:
2230
+ line = line.strip()
2231
+ if line:
2232
+ try:
2233
+ obj = json.loads(line)
2234
+ # Extract example-shaped data from events
2235
+ payload = obj.get("payload", obj)
2236
+ example: dict[str, Any] = {}
2237
+ if "output" in payload:
2238
+ example["output"] = payload["output"]
2239
+ elif "response" in payload:
2240
+ example["output"] = payload["response"]
2241
+ if "context" in payload:
2242
+ example["context"] = payload["context"]
2243
+ if "reference" in payload:
2244
+ example["reference"] = payload["reference"]
2245
+ if "input" in payload:
2246
+ example["input"] = payload["input"]
2247
+ elif "query" in payload:
2248
+ example["input"] = payload["query"]
2249
+ if "span_id" in obj:
2250
+ example["span_id"] = obj["span_id"]
2251
+ if "trace_id" in obj:
2252
+ example["trace_id"] = obj["trace_id"]
2253
+ if example:
2254
+ examples.append(example)
2255
+ except json.JSONDecodeError:
2256
+ continue
2257
+ else:
2258
+ print("[!] --input is required for 'eval save'")
2259
+ return 1
2260
+
2261
+ out_path = Path(args.output)
2262
+ with open(out_path, "w", encoding="utf-8") as fh:
2263
+ for ex in examples:
2264
+ fh.write(json.dumps(ex, default=str) + "\n")
2265
+ print(f"[✓] Saved {len(examples)} examples to {out_path}")
2266
+ return 0
2267
+
2268
+ elif action == "run":
2269
+ from spanforge.eval import ( # noqa: PLC0415
2270
+ EvalRunner,
2271
+ FaithfulnessScorer,
2272
+ PIILeakageScorer,
2273
+ RefusalDetectionScorer,
2274
+ )
2275
+
2276
+ dataset_path = Path(args.file)
2277
+ if not dataset_path.exists():
2278
+ print(f"[!] File not found: {dataset_path}")
2279
+ return 1
2280
+
2281
+ dataset: list[dict[str, Any]] = []
2282
+ with open(dataset_path, encoding="utf-8") as fh:
2283
+ for line in fh:
2284
+ line = line.strip()
2285
+ if line:
2286
+ try:
2287
+ dataset.append(json.loads(line))
2288
+ except json.JSONDecodeError:
2289
+ continue
2290
+
2291
+ if not dataset:
2292
+ print("[!] No examples found in dataset file")
2293
+ return 1
2294
+
2295
+ # Build scorers from --scorers flag (or default to all)
2296
+ scorer_map = {
2297
+ "faithfulness": FaithfulnessScorer,
2298
+ "refusal": RefusalDetectionScorer,
2299
+ "pii_leakage": PIILeakageScorer,
2300
+ }
2301
+ requested = args.scorers.split(",") if args.scorers else list(scorer_map.keys())
2302
+ scorers = []
2303
+ for name in requested:
2304
+ name = name.strip()
2305
+ if name not in scorer_map:
2306
+ print(f"[!] Unknown scorer: {name} (available: {', '.join(scorer_map)})")
2307
+ return 1
2308
+ scorers.append(scorer_map[name]())
2309
+
2310
+ runner = EvalRunner(scorers=scorers, emit=False)
2311
+ report = runner.run(dataset)
2312
+ summary = report.summary()
2313
+
2314
+ fmt = getattr(args, "format", "text")
2315
+ if fmt == "json":
2316
+ result = {
2317
+ "examples": len(dataset),
2318
+ "scores": len(report.scores),
2319
+ "summary": summary,
2320
+ }
2321
+ print(json.dumps(result, indent=2, default=str))
2322
+ else:
2323
+ print(f"Dataset: {dataset_path} ({len(dataset)} examples)")
2324
+ print(f"{'Metric':<30} {'Mean':>10}")
2325
+ print("-" * 43)
2326
+ for metric, mean in sorted(summary.items()):
2327
+ print(f"{metric:<30} {mean:>10.4f}")
2328
+ print("-" * 43)
2329
+ print(f"Total scores: {len(report.scores)}")
2330
+
2331
+ return 0
2332
+
2333
+ else:
2334
+ parser.print_help()
2335
+ return 2
2336
+
2337
+
2338
+ def main(argv: list[str] | None = None) -> NoReturn:
2339
+ """Entry point for the ``spanforge`` CLI tool."""
2340
+ from spanforge import CONFORMANCE_PROFILE, __version__ # noqa: PLC0415
2341
+ parser = argparse.ArgumentParser(
2342
+ prog="spanforge",
2343
+ description="spanforge command-line utilities",
2344
+ )
2345
+ parser.add_argument(
2346
+ "-V", "--version",
2347
+ action="version",
2348
+ version=f"spanforge {__version__} [{CONFORMANCE_PROFILE}]",
2349
+ )
2350
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
2351
+
2352
+ # check sub-command (health check)
2353
+ sub.add_parser(
2354
+ "check",
2355
+ help="End-to-end health check: validates config, emits a test event, confirms export pipeline",
2356
+ )
2357
+
2358
+ # check-compat sub-command
2359
+ compat_parser = sub.add_parser(
2360
+ "check-compat",
2361
+ help="Check a JSON file of events against the v1.0 compatibility checklist",
2362
+ )
2363
+ compat_parser.add_argument(
2364
+ "file",
2365
+ metavar="EVENTS_JSON",
2366
+ help="Path to a JSON file containing a list of serialised events",
2367
+ )
2368
+
2369
+ # list-deprecated sub-command
2370
+ sub.add_parser(
2371
+ "list-deprecated",
2372
+ help="Print all deprecated event types from the global deprecation registry",
2373
+ )
2374
+
2375
+ # migration-roadmap sub-command
2376
+ roadmap_parser = sub.add_parser(
2377
+ "migration-roadmap",
2378
+ help="Print the planned v1 → v2 migration roadmap",
2379
+ )
2380
+ roadmap_parser.add_argument(
2381
+ "--json",
2382
+ action="store_true",
2383
+ default=False,
2384
+ help="Emit JSON output for machine consumption",
2385
+ )
2386
+
2387
+ # check-consumers sub-command
2388
+ sub.add_parser(
2389
+ "check-consumers",
2390
+ help="Assert all registered consumers are compatible with the installed schema",
2391
+ )
2392
+
2393
+ # validate sub-command
2394
+ validate_parser = sub.add_parser(
2395
+ "validate",
2396
+ help="Validate every event in a JSONL file against the published schema",
2397
+ )
2398
+ validate_parser.add_argument(
2399
+ "file",
2400
+ metavar="EVENTS_JSONL",
2401
+ help="Path to a JSONL file (one event JSON per line)",
2402
+ )
2403
+
2404
+ # audit-chain sub-command
2405
+ audit_parser = sub.add_parser(
2406
+ "audit-chain",
2407
+ help="Verify HMAC signing chain integrity of events in a JSONL file",
2408
+ )
2409
+ audit_parser.add_argument(
2410
+ "file",
2411
+ metavar="EVENTS_JSONL",
2412
+ help="Path to a JSONL file of signed events (reads SPANFORGE_SIGNING_KEY env var)",
2413
+ )
2414
+
2415
+ # audit command group (erase, check-health)
2416
+ audit_group_parser = sub.add_parser(
2417
+ "audit",
2418
+ help="Audit chain management (erase, check-health)",
2419
+ )
2420
+ audit_sub = audit_group_parser.add_subparsers(dest="audit_command", metavar="<action>")
2421
+
2422
+ erase_parser = audit_sub.add_parser(
2423
+ "erase",
2424
+ help="GDPR subject erasure: replace events mentioning a subject with tombstones",
2425
+ )
2426
+ erase_parser.add_argument(
2427
+ "file", metavar="EVENTS_JSONL",
2428
+ help="Path to the JSONL audit file",
2429
+ )
2430
+ erase_parser.add_argument(
2431
+ "--subject-id", dest="subject_id", required=True,
2432
+ help="The data-subject identifier to erase",
2433
+ )
2434
+ erase_parser.add_argument(
2435
+ "--erased-by", dest="erased_by", default="cli",
2436
+ help="Identity of the operator performing erasure (default: cli)",
2437
+ )
2438
+ erase_parser.add_argument(
2439
+ "--reason", default="GDPR Art.17 right to erasure",
2440
+ help="Reason for erasure (default: 'GDPR Art.17 right to erasure')",
2441
+ )
2442
+ erase_parser.add_argument(
2443
+ "--request-ref", dest="request_ref", default="",
2444
+ help="External erasure request reference (e.g. ticket ID)",
2445
+ )
2446
+ erase_parser.add_argument(
2447
+ "--output", default=None, metavar="FILE",
2448
+ help="Output file (required — must differ from input to prevent accidental overwrite)",
2449
+ )
2450
+
2451
+ rotate_key_parser = audit_sub.add_parser(
2452
+ "rotate-key",
2453
+ help="Rotate the signing key in a JSONL audit file",
2454
+ )
2455
+ rotate_key_parser.add_argument(
2456
+ "file", metavar="EVENTS_JSONL",
2457
+ help="Path to the JSONL audit file",
2458
+ )
2459
+ rotate_key_parser.add_argument(
2460
+ "--new-key-env", dest="new_key_env", default="SPANFORGE_NEW_SIGNING_KEY",
2461
+ help="Environment variable holding the new signing key (default: SPANFORGE_NEW_SIGNING_KEY)",
2462
+ )
2463
+ rotate_key_parser.add_argument(
2464
+ "--output", default=None, metavar="FILE",
2465
+ help="Output file (default: overwrite input file)",
2466
+ )
2467
+ rotate_key_parser.add_argument(
2468
+ "--reason", default="scheduled rotation",
2469
+ help="Reason for key rotation (default: 'scheduled rotation')",
2470
+ )
2471
+
2472
+ check_health_parser = audit_sub.add_parser(
2473
+ "check-health",
2474
+ help="Run health checks on a JSONL audit file",
2475
+ )
2476
+ check_health_parser.add_argument(
2477
+ "file", metavar="EVENTS_JSONL",
2478
+ help="Path to the JSONL audit file",
2479
+ )
2480
+ check_health_parser.add_argument(
2481
+ "--output", choices=["text", "json"], default="text",
2482
+ help="Output format (default: text)",
2483
+ )
2484
+
2485
+ # SF-13-C: audit verify — chain integrity verification
2486
+ verify_parser = audit_sub.add_parser(
2487
+ "verify",
2488
+ help="Verify HMAC chain integrity of JSONL audit file(s)",
2489
+ )
2490
+ verify_parser.add_argument(
2491
+ "--input", required=True,
2492
+ help="Path to JSONL audit file (supports glob: 'audit-*.jsonl')",
2493
+ )
2494
+ verify_parser.add_argument(
2495
+ "--key", default=None,
2496
+ help="HMAC signing key (default: $SPANFORGE_SIGNING_KEY)",
2497
+ )
2498
+
2499
+ # scan sub-command — GA-03 deep PII scanning
2500
+ scan_parser = sub.add_parser(
2501
+ "scan",
2502
+ help="Scan a JSONL file for PII using regex detectors",
2503
+ )
2504
+ scan_parser.add_argument(
2505
+ "file",
2506
+ metavar="FILE",
2507
+ help="Path to the JSONL file to scan",
2508
+ )
2509
+ scan_parser.add_argument(
2510
+ "--format", choices=["text", "json"], default="text",
2511
+ help="Output format (default: text)",
2512
+ )
2513
+ scan_parser.add_argument(
2514
+ "--types", default=None,
2515
+ help="Comma-separated PII types to filter (e.g. 'ssn,credit_card')",
2516
+ )
2517
+ scan_parser.add_argument(
2518
+ "--fail-on-match", dest="fail_on_match", action="store_true", default=False,
2519
+ help="Exit with code 1 if any PII is detected (CI gate mode)",
2520
+ )
2521
+
2522
+ # migrate sub-command — GA-05 schema migration
2523
+ migrate_parser = sub.add_parser(
2524
+ "migrate",
2525
+ help="Migrate a JSONL file from schema v1 to v2",
2526
+ )
2527
+ migrate_parser.add_argument(
2528
+ "file",
2529
+ metavar="FILE",
2530
+ help="Path to the JSONL file to migrate",
2531
+ )
2532
+ migrate_parser.add_argument(
2533
+ "--output", default=None, metavar="FILE",
2534
+ help="Output file (default: <input>_v2.jsonl)",
2535
+ )
2536
+ migrate_parser.add_argument(
2537
+ "--target-version", dest="target_version", default="2.0",
2538
+ help="Target schema version (default: 2.0)",
2539
+ )
2540
+ migrate_parser.add_argument(
2541
+ "--sign", action="store_true", default=False,
2542
+ help="Re-sign the migrated chain (reads SPANFORGE_SIGNING_KEY)",
2543
+ )
2544
+ migrate_parser.add_argument(
2545
+ "--dry-run", dest="dry_run", action="store_true", default=False,
2546
+ help="Preview migration without writing output",
2547
+ )
2548
+
2549
+ # migrate-langsmith sub-command — import LangSmith exports
2550
+ ls_migrate_parser = sub.add_parser(
2551
+ "migrate-langsmith",
2552
+ help="Import a LangSmith export file and convert to SpanForge events",
2553
+ )
2554
+ ls_migrate_parser.add_argument(
2555
+ "file",
2556
+ metavar="FILE",
2557
+ help="Path to the LangSmith export file (JSONL or JSON)",
2558
+ )
2559
+ ls_migrate_parser.add_argument(
2560
+ "--output", default=None, metavar="FILE",
2561
+ help="Output JSONL file (default: <input>_spanforge.jsonl)",
2562
+ )
2563
+ ls_migrate_parser.add_argument(
2564
+ "--source", default="langsmith-import",
2565
+ help="Source identifier for generated events (default: langsmith-import)",
2566
+ )
2567
+
2568
+ # inspect sub-command
2569
+ inspect_parser = sub.add_parser(
2570
+ "inspect",
2571
+ help="Pretty-print a single event by event_id from a JSONL file",
2572
+ )
2573
+ inspect_parser.add_argument(
2574
+ "event_id",
2575
+ metavar="EVENT_ID",
2576
+ help="The event_id to look up",
2577
+ )
2578
+ inspect_parser.add_argument(
2579
+ "file",
2580
+ metavar="EVENTS_JSONL",
2581
+ help="Path to a JSONL file to search",
2582
+ )
2583
+
2584
+ # stats sub-command
2585
+ stats_parser = sub.add_parser(
2586
+ "stats",
2587
+ help="Print a summary of events in a JSONL file (counts, tokens, cost, timestamps)",
2588
+ )
2589
+ stats_parser.add_argument(
2590
+ "file",
2591
+ metavar="EVENTS_JSONL",
2592
+ help="Path to a JSONL file",
2593
+ )
2594
+
2595
+ # compliance command group
2596
+ compliance_parser = sub.add_parser(
2597
+ "compliance",
2598
+ help="Compliance evidence generation and attestation validation",
2599
+ )
2600
+ comp_sub = compliance_parser.add_subparsers(dest="compliance_command", metavar="<action>")
2601
+
2602
+ gen_parser = comp_sub.add_parser(
2603
+ "generate",
2604
+ help="Generate a compliance evidence package for a model/framework/period",
2605
+ )
2606
+ gen_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
2607
+ gen_parser.add_argument(
2608
+ "--framework",
2609
+ required=True,
2610
+ help="Compliance framework (eu_ai_act, gdpr, iso_42001, nist_ai_rmf, soc2)",
2611
+ )
2612
+ gen_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE",
2613
+ help="Period start date (YYYY-MM-DD)")
2614
+ gen_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE",
2615
+ help="Period end date (YYYY-MM-DD)")
2616
+ gen_parser.add_argument("--output", default=".", metavar="DIR",
2617
+ help="Output directory for evidence files (default: .)")
2618
+ gen_parser.add_argument("--events-file", dest="events_file", metavar="JSONL",
2619
+ help="Optional JSONL file of audit events to include")
2620
+
2621
+ val_att_parser = comp_sub.add_parser(
2622
+ "validate-attestation",
2623
+ help="Verify the HMAC signature of a compliance attestation JSON file",
2624
+ )
2625
+ val_att_parser.add_argument(
2626
+ "attestation_file",
2627
+ metavar="ATTESTATION_JSON",
2628
+ help="Path to a compliance attestation JSON file",
2629
+ )
2630
+
2631
+ report_comp_parser = comp_sub.add_parser(
2632
+ "report",
2633
+ help="Generate a compliance report (JSON, PDF, or both) with HMAC attestation",
2634
+ )
2635
+ report_comp_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
2636
+ report_comp_parser.add_argument(
2637
+ "--framework", required=True,
2638
+ help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
2639
+ )
2640
+ report_comp_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE",
2641
+ help="Period start date (YYYY-MM-DD)")
2642
+ report_comp_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE",
2643
+ help="Period end date (YYYY-MM-DD)")
2644
+ report_comp_parser.add_argument(
2645
+ "--format", dest="report_format", default="json",
2646
+ choices=["json", "pdf", "both"],
2647
+ help="Output format: json, pdf, or both (default: json)",
2648
+ )
2649
+ report_comp_parser.add_argument("--output", default=".", metavar="DIR",
2650
+ help="Output directory (default: .)")
2651
+ report_comp_parser.add_argument("--events-file", dest="events_file", metavar="JSONL",
2652
+ help="Optional JSONL file of audit events to include")
2653
+ report_comp_parser.add_argument(
2654
+ "--sign", action="store_true", default=False,
2655
+ help="Embed HMAC attestation signature in the output",
2656
+ )
2657
+
2658
+ check_parser = comp_sub.add_parser(
2659
+ "check",
2660
+ help="CI-friendly compliance gate: exits 0 if all clauses pass, 1 if gaps exist",
2661
+ )
2662
+ check_parser.add_argument("--model-id", dest="model_id", default="*",
2663
+ help="Model ID to check (default: * = all models)")
2664
+ check_parser.add_argument(
2665
+ "--framework",
2666
+ required=True,
2667
+ help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
2668
+ )
2669
+ check_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE",
2670
+ help="Period start date (YYYY-MM-DD)")
2671
+ check_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE",
2672
+ help="Period end date (YYYY-MM-DD)")
2673
+ check_parser.add_argument("--events-file", dest="events_file", metavar="JSONL",
2674
+ help="Optional JSONL file of audit events")
2675
+ check_parser.add_argument(
2676
+ "--allow-partial", dest="allow_partial", action="store_true",
2677
+ help="Exit 0 on partial coverage (only fail on zero-evidence clauses)",
2678
+ )
2679
+
2680
+ status_comp_parser = comp_sub.add_parser(
2681
+ "status",
2682
+ help="Output a single JSON summary of compliance posture",
2683
+ )
2684
+ status_comp_parser.add_argument(
2685
+ "--events-file", dest="events_file", required=True, metavar="JSONL",
2686
+ help="JSONL file of audit events to analyse",
2687
+ )
2688
+ status_comp_parser.add_argument(
2689
+ "--framework", default="eu_ai_act",
2690
+ help="Compliance framework (default: eu_ai_act)",
2691
+ )
2692
+
2693
+ # cost command group
2694
+ cost_parser = sub.add_parser(
2695
+ "cost",
2696
+ help="Cost brief management",
2697
+ )
2698
+ cost_sub = cost_parser.add_subparsers(dest="cost_command", metavar="<action>")
2699
+
2700
+ brief_parser = cost_sub.add_parser("brief", help="Cost brief operations")
2701
+ brief_sub = brief_parser.add_subparsers(dest="brief_command", metavar="<action>")
2702
+
2703
+ submit_parser = brief_sub.add_parser(
2704
+ "submit",
2705
+ help="Submit a cost brief JSON file to the local brief store",
2706
+ )
2707
+ submit_parser.add_argument(
2708
+ "--file", required=True, metavar="BRIEF_JSON",
2709
+ help="Path to a cost brief JSON file",
2710
+ )
2711
+ submit_parser.add_argument(
2712
+ "--store", default=".spanforge-cost-briefs.json", metavar="STORE_JSON",
2713
+ help="Path to the local cost brief store JSON file (default: .spanforge-cost-briefs.json)",
2714
+ )
2715
+
2716
+ run_cost_parser = cost_sub.add_parser(
2717
+ "run",
2718
+ help="Show per-run cost breakdown for an agent run",
2719
+ )
2720
+ run_cost_parser.add_argument(
2721
+ "--run-id", required=True, metavar="RUN_ID",
2722
+ help="Agent run ID to look up",
2723
+ )
2724
+ run_cost_parser.add_argument(
2725
+ "--input", required=True, metavar="JSONL",
2726
+ help="Path to a JSONL events file to search",
2727
+ )
2728
+
2729
+ # dev command group
2730
+ dev_parser = sub.add_parser(
2731
+ "dev",
2732
+ help="Local development environment lifecycle",
2733
+ )
2734
+ dev_sub = dev_parser.add_subparsers(dest="dev_command", metavar="<action>")
2735
+
2736
+ dev_start_p = dev_sub.add_parser("start", help="Start the local dev environment")
2737
+ dev_start_p.add_argument(
2738
+ "service", nargs="?", default="spanforge-dev",
2739
+ help="Service name (default: spanforge-dev)",
2740
+ )
2741
+ dev_sub.add_parser("stop", help="Flush buffer and stop the local dev environment")
2742
+ dev_sub.add_parser("reset", help="Reset all in-memory dev state")
2743
+ dev_sub.add_parser("logs", help="Print accumulated dev log entries")
2744
+ dev_sub.add_parser("status", help="Print the current dev environment status as JSON")
2745
+
2746
+ # module command group
2747
+ module_parser = sub.add_parser(
2748
+ "module",
2749
+ help="SpanForge plugin module scaffolding",
2750
+ )
2751
+ module_sub = module_parser.add_subparsers(dest="module_command", metavar="<action>")
2752
+
2753
+ create_parser = module_sub.add_parser(
2754
+ "create",
2755
+ help="Scaffold a new SpanForge plugin module directory",
2756
+ )
2757
+ create_parser.add_argument("name", metavar="MODULE_NAME", help="Python-package-safe module name")
2758
+ create_parser.add_argument(
2759
+ "--trust-level", dest="trust_level", default="UNTRUSTED",
2760
+ metavar="LEVEL",
2761
+ help="Trust level: UNTRUSTED, COMMUNITY, VERIFIED, OFFICIAL (default: UNTRUSTED)",
2762
+ )
2763
+ create_parser.add_argument("--author", default="unknown", help="Author identifier")
2764
+ create_parser.add_argument(
2765
+ "--output-dir", dest="output_dir", default=".",
2766
+ metavar="DIR", help="Parent directory for the scaffolded module (default: .)",
2767
+ )
2768
+
2769
+ # serve subcommand — local trace viewer
2770
+ serve_parser = sub.add_parser(
2771
+ "serve",
2772
+ help="Start a local HTTP trace viewer at /traces (default port 8888)",
2773
+ )
2774
+ serve_parser.add_argument(
2775
+ "--port", type=int, default=8888,
2776
+ help="HTTP port to bind (default: 8888)",
2777
+ )
2778
+ serve_parser.add_argument(
2779
+ "--host", default="127.0.0.1",
2780
+ help="Interface to bind (default: 127.0.0.1)",
2781
+ )
2782
+ serve_parser.add_argument(
2783
+ "--file", dest="file", default=None, metavar="FILE",
2784
+ help="Optional JSONL file to pre-load into the trace store before serving",
2785
+ )
2786
+
2787
+ # init sub-command
2788
+ init_parser = sub.add_parser(
2789
+ "init",
2790
+ help="Scaffold a spanforge.toml config file in the current directory",
2791
+ )
2792
+ init_parser.add_argument(
2793
+ "--service-name", dest="service_name", default=None,
2794
+ help="Service name to embed in spanforge.toml (default: current directory name)",
2795
+ )
2796
+ init_parser.add_argument(
2797
+ "--output-dir", dest="output_dir", default=".",
2798
+ metavar="DIR", help="Directory to write files into (default: .)",
2799
+ )
2800
+ init_parser.add_argument(
2801
+ "--force", action="store_true", default=False,
2802
+ help="Overwrite existing spanforge.toml without prompting",
2803
+ )
2804
+
2805
+ # quickstart sub-command
2806
+ sub.add_parser(
2807
+ "quickstart",
2808
+ help="Interactive setup wizard: configure exporter, service name, and signing",
2809
+ )
2810
+
2811
+ # report sub-command
2812
+ report_parser = sub.add_parser(
2813
+ "report",
2814
+ help="Generate a static HTML trace report from a JSONL events file",
2815
+ )
2816
+ report_parser.add_argument(
2817
+ "file",
2818
+ metavar="EVENTS_JSONL",
2819
+ help="Path to the JSONL events file",
2820
+ )
2821
+ report_parser.add_argument(
2822
+ "--output", default="spanforge-report.html",
2823
+ metavar="HTML_FILE",
2824
+ help="Output HTML file path (default: spanforge-report.html)",
2825
+ )
2826
+
2827
+ # ui sub-command
2828
+ ui_parser = sub.add_parser(
2829
+ "ui",
2830
+ help="Open a local HTML trace viewer in your browser",
2831
+ )
2832
+ ui_parser.add_argument(
2833
+ "--file", dest="file", default=None, metavar="EVENTS_JSONL",
2834
+ help="JSONL file to render as a trace report",
2835
+ )
2836
+ ui_parser.add_argument(
2837
+ "--port", type=int, default=8889,
2838
+ help="HTTP port to bind (default: 8889)",
2839
+ )
2840
+ ui_parser.add_argument(
2841
+ "--no-browser", dest="no_browser", action="store_true", default=False,
2842
+ help="Do not automatically open the browser",
2843
+ )
2844
+
2845
+ # ---------------------------------------------------------------------------
2846
+ # T.R.U.S.T. Framework CLI commands
2847
+ # ---------------------------------------------------------------------------
2848
+
2849
+ # consent command group
2850
+ consent_parser = sub.add_parser(
2851
+ "consent",
2852
+ help="Consent boundary management",
2853
+ )
2854
+ consent_sub = consent_parser.add_subparsers(dest="consent_command", metavar="<action>")
2855
+
2856
+ consent_check_parser = consent_sub.add_parser(
2857
+ "check",
2858
+ help="Check if consent is granted for a given subject and scope",
2859
+ )
2860
+ consent_check_parser.add_argument("--subject", required=True, help="Subject ID")
2861
+ consent_check_parser.add_argument("--scope", required=True, help="Consent scope")
2862
+
2863
+ consent_grant_parser = consent_sub.add_parser(
2864
+ "grant",
2865
+ help="Grant consent for a subject/scope",
2866
+ )
2867
+ consent_grant_parser.add_argument("--subject", required=True, help="Subject ID")
2868
+ consent_grant_parser.add_argument("--scope", required=True, help="Consent scope")
2869
+ consent_grant_parser.add_argument("--purpose", default="cli-grant", help="Purpose (default: cli-grant)")
2870
+ consent_grant_parser.add_argument("--legal-basis", dest="legal_basis", default="consent", help="Legal basis")
2871
+
2872
+ consent_revoke_parser = consent_sub.add_parser(
2873
+ "revoke",
2874
+ help="Revoke consent for a subject/scope",
2875
+ )
2876
+ consent_revoke_parser.add_argument("--subject", required=True, help="Subject ID")
2877
+ consent_revoke_parser.add_argument("--scope", required=True, help="Consent scope")
2878
+
2879
+ # hitl command group
2880
+ hitl_parser = sub.add_parser(
2881
+ "hitl",
2882
+ help="Human-in-the-loop review queue",
2883
+ )
2884
+ hitl_sub = hitl_parser.add_subparsers(dest="hitl_command", metavar="<action>")
2885
+
2886
+ hitl_sub.add_parser(
2887
+ "pending",
2888
+ help="List all pending (queued) HITL items",
2889
+ )
2890
+
2891
+ hitl_review_parser = hitl_sub.add_parser(
2892
+ "review",
2893
+ help="Record a review decision for a pending item",
2894
+ )
2895
+ hitl_review_parser.add_argument("--id", dest="decision_id", required=True, help="Decision ID")
2896
+ hitl_review_parser.add_argument("--reviewer", required=True, help="Reviewer name")
2897
+ hitl_review_parser.add_argument(
2898
+ "--outcome", required=True, choices=["approved", "rejected"],
2899
+ help="Review outcome",
2900
+ )
2901
+
2902
+ # model command group
2903
+ model_parser = sub.add_parser(
2904
+ "model",
2905
+ help="Model registry management",
2906
+ )
2907
+ model_sub = model_parser.add_subparsers(dest="model_command", metavar="<action>")
2908
+
2909
+ model_sub.add_parser("list", help="List all registered models")
2910
+
2911
+ model_reg_parser = model_sub.add_parser(
2912
+ "register",
2913
+ help="Register a new model",
2914
+ )
2915
+ model_reg_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
2916
+ model_reg_parser.add_argument("--name", required=True, help="Model name")
2917
+ model_reg_parser.add_argument("--version", required=True, help="Model version")
2918
+ model_reg_parser.add_argument(
2919
+ "--risk-tier", dest="risk_tier", required=True,
2920
+ choices=["low", "medium", "high", "critical"],
2921
+ help="Risk tier",
2922
+ )
2923
+ model_reg_parser.add_argument("--owner", required=True, help="Owner")
2924
+ model_reg_parser.add_argument("--purpose", required=True, help="Purpose")
2925
+
2926
+ model_dep_parser = model_sub.add_parser(
2927
+ "deprecate",
2928
+ help="Deprecate a model",
2929
+ )
2930
+ model_dep_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
2931
+ model_dep_parser.add_argument("--reason", default="", help="Deprecation reason")
2932
+
2933
+ model_ret_parser = model_sub.add_parser(
2934
+ "retire",
2935
+ help="Retire a model",
2936
+ )
2937
+ model_ret_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
2938
+
2939
+ # explain command
2940
+ explain_parser = sub.add_parser(
2941
+ "explain",
2942
+ help="Generate an explainability record",
2943
+ )
2944
+ explain_parser.add_argument("--trace-id", dest="trace_id", required=True, help="Trace ID")
2945
+ explain_parser.add_argument("--agent-id", dest="agent_id", required=True, help="Agent ID")
2946
+ explain_parser.add_argument("--decision-id", dest="decision_id", required=True, help="Decision ID")
2947
+ explain_parser.add_argument("--summary", required=True, help="Human-readable summary")
2948
+
2949
+ # eval command group
2950
+ eval_parser = sub.add_parser(
2951
+ "eval",
2952
+ help="Evaluation dataset management and scorer execution",
2953
+ )
2954
+ eval_sub = eval_parser.add_subparsers(dest="eval_command", metavar="<action>")
2955
+
2956
+ eval_save_parser = eval_sub.add_parser(
2957
+ "save",
2958
+ help="Extract evaluation examples from a JSONL events file",
2959
+ )
2960
+ eval_save_parser.add_argument(
2961
+ "--input", required=True, metavar="JSONL",
2962
+ help="Path to a JSONL events file to extract examples from",
2963
+ )
2964
+ eval_save_parser.add_argument(
2965
+ "--output", default="eval_dataset.jsonl", metavar="FILE",
2966
+ help="Output JSONL file for evaluation examples (default: eval_dataset.jsonl)",
2967
+ )
2968
+
2969
+ eval_run_parser = eval_sub.add_parser(
2970
+ "run",
2971
+ help="Run evaluation scorers over a JSONL dataset",
2972
+ )
2973
+ eval_run_parser.add_argument(
2974
+ "--file", required=True, metavar="JSONL",
2975
+ help="Path to a JSONL evaluation dataset file",
2976
+ )
2977
+ eval_run_parser.add_argument(
2978
+ "--scorers", default=None,
2979
+ help="Comma-separated scorer names (default: all). Available: faithfulness, refusal, pii_leakage",
2980
+ )
2981
+ eval_run_parser.add_argument(
2982
+ "--format", choices=["text", "json"], default="text",
2983
+ help="Output format (default: text)",
2984
+ )
2985
+
2986
+ args = parser.parse_args(argv)
2987
+
2988
+ if args.command == "check":
2989
+ sys.exit(_cmd_check(args))
2990
+ elif args.command == "check-compat":
2991
+ sys.exit(_cmd_check_compat(args))
2992
+ elif args.command == "list-deprecated":
2993
+ sys.exit(_cmd_list_deprecated(args))
2994
+ elif args.command == "migration-roadmap":
2995
+ sys.exit(_cmd_migration_roadmap(args))
2996
+ elif args.command == "check-consumers":
2997
+ sys.exit(_cmd_check_consumers(args))
2998
+ elif args.command == "validate":
2999
+ sys.exit(_cmd_validate(args))
3000
+ elif args.command == "audit-chain":
3001
+ sys.exit(_cmd_audit_chain(args))
3002
+ elif args.command == "audit":
3003
+ audit_action = getattr(args, "audit_command", None)
3004
+ if audit_action == "erase":
3005
+ sys.exit(_cmd_audit_erase(args))
3006
+ elif audit_action == "rotate-key":
3007
+ sys.exit(_cmd_audit_rotate_key(args))
3008
+ elif audit_action == "check-health":
3009
+ sys.exit(_cmd_audit_check_health(args))
3010
+ elif audit_action == "verify":
3011
+ sys.exit(_cmd_audit_verify(args))
3012
+ else:
3013
+ audit_group_parser.print_help()
3014
+ sys.exit(2)
3015
+ elif args.command == "inspect":
3016
+ sys.exit(_cmd_inspect(args))
3017
+ elif args.command == "scan":
3018
+ sys.exit(_cmd_scan(args))
3019
+ elif args.command == "migrate":
3020
+ sys.exit(_cmd_migrate(args))
3021
+ elif args.command == "migrate-langsmith":
3022
+ sys.exit(_cmd_migrate_langsmith(args))
3023
+ elif args.command == "stats":
3024
+ sys.exit(_cmd_stats(args))
3025
+ elif args.command == "compliance":
3026
+ action = getattr(args, "compliance_command", None)
3027
+ if action == "generate":
3028
+ sys.exit(_cmd_compliance_generate(args))
3029
+ elif action == "validate-attestation":
3030
+ sys.exit(_cmd_compliance_validate_attestation(args))
3031
+ elif action == "check":
3032
+ sys.exit(_cmd_compliance_check(args))
3033
+ elif action == "report":
3034
+ sys.exit(_cmd_compliance_report(args))
3035
+ elif action == "status":
3036
+ sys.exit(_cmd_compliance_status(args))
3037
+ else:
3038
+ compliance_parser.print_help()
3039
+ sys.exit(2)
3040
+ elif args.command == "cost":
3041
+ cost_action = getattr(args, "cost_command", None)
3042
+ brief_action = getattr(args, "brief_command", None)
3043
+ if cost_action == "brief" and brief_action == "submit":
3044
+ sys.exit(_cmd_cost_brief_submit(args))
3045
+ elif cost_action == "run":
3046
+ sys.exit(_cmd_cost_run(args))
3047
+ else:
3048
+ cost_parser.print_help()
3049
+ sys.exit(2)
3050
+ elif args.command == "dev":
3051
+ sys.exit(_cmd_dev(args))
3052
+ elif args.command == "module":
3053
+ action = getattr(args, "module_command", None)
3054
+ if action == "create":
3055
+ sys.exit(_cmd_module_create(args))
3056
+ else:
3057
+ module_parser.print_help()
3058
+ sys.exit(2)
3059
+ elif args.command == "serve":
3060
+ sys.exit(_cmd_serve(args))
3061
+ elif args.command == "init":
3062
+ sys.exit(_cmd_init(args))
3063
+ elif args.command == "quickstart":
3064
+ sys.exit(_cmd_quickstart(args))
3065
+ elif args.command == "report":
3066
+ sys.exit(_cmd_report(args))
3067
+ elif args.command == "ui":
3068
+ sys.exit(_cmd_ui(args))
3069
+ elif args.command == "consent":
3070
+ sys.exit(_cmd_consent(args, consent_parser))
3071
+ elif args.command == "hitl":
3072
+ sys.exit(_cmd_hitl(args, hitl_parser))
3073
+ elif args.command == "model":
3074
+ sys.exit(_cmd_model(args, model_parser))
3075
+ elif args.command == "explain":
3076
+ sys.exit(_cmd_explain(args))
3077
+ elif args.command == "eval":
3078
+ sys.exit(_cmd_eval(args, eval_parser))
3079
+ else:
3080
+ parser.print_help()
3081
+ sys.exit(2)