← Files LegalQuants LitigationARCHIVED FILE

skills/timenarratives/scripts/start_run.py

7.55 KB · Oct 5, 2026 · 18:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Start one /timenarratives run: build the request, site the run, compile.

The host supplies only what the lawyer said: their name, the matter, the
selected paths, and optionally inline notes, a client name, aliases, or a date
window. Everything else the request schema needs is defaulted here so nothing
has to be asked. The run directory is always a new sibling of the source root.
"""

from __future__ import annotations

import argparse
import json
import re
import secrets
from datetime import UTC, datetime
from pathlib import Path

from build_packet import PacketBuildError, compile_packet_to_directory
from folder_selection import expand_folders
from packet_core import REQUEST_VERSION, validate_request
from safe_output import OutputSafetyError, write_new_bytes
from source_paths import SourcePathError, validate_source_root

ALL_SOURCE_TYPES = ["text", "email", "docx", "user_note", "conversation"]


def _slug(value: str) -> str:
    slug = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
    return slug[:40] or "run"


def _now_rfc3339() -> str:
    return datetime.now(UTC).astimezone().isoformat(timespec="seconds")


def _relative(path: str, source_root: Path | None) -> str:
    """Portable POSIX form; an absolute path under the root is relativised."""
    if source_root is not None:
        candidate = Path(path)
        if candidate.is_absolute():
            try:
                # Preserve link components for the source-path checks; resolving
                # here would turn an absolute alias into an apparently ordinary path.
                path = str(candidate.relative_to(source_root.absolute()))
            except (OSError, ValueError):
                pass
    return path.replace("\\", "/").removeprefix("./")


def build_request(
    *,
    lawyer: str,
    matter: str,
    paths: list[str],
    folders: list[str] | None = None,
    conversations: list[tuple[str, str]] | None = None,
    notes: list[str] | None = None,
    client: str | None = None,
    aliases: list[str] | None = None,
    since: str | None = None,
    until: str | None = None,
    as_of: str | None = None,
    source_root: Path | None = None,
) -> dict:
    """Return a schema-valid request with every optional field defaulted."""
    selected_paths = [_relative(path, source_root) for path in paths]
    if folders:
        if source_root is None:
            raise PacketBuildError("selected folders require a source root")
        selected_paths = expand_folders(
            source_root,
            [_relative(folder, source_root) for folder in folders],
            selected_paths,
        )
    selections: list[dict] = [{"kind": "file", "path": path} for path in selected_paths]
    selections.extend({"kind": "user_note", "text": note} for note in notes or [])
    selections.extend(
        {
            "kind": "conversation",
            "conversationId": identity,
            "path": _relative(path, source_root),
        }
        for identity, path in conversations or []
    )
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    request = {
        "schemaVersion": REQUEST_VERSION,
        "runId": f"tn-{_slug(matter)}-{stamp}",
        "actor": {
            "id": f"lawyer-{_slug(lawyer)}",
            "name": lawyer,
            "aliases": aliases or [],
        },
        "matter": {"id": matter, "client": client, "aliases": []},
        "selections": selections,
        "filters": {
            "sourceTypes": ALL_SOURCE_TYPES,
            "since": since,
            "until": until,
            "asOf": as_of if as_of is not None else _now_rfc3339(),
        },
    }
    return validate_request(request)


def _summary(packet: dict, run_dir: Path) -> dict:
    containers = {row["containerId"]: row for row in packet["containers"]}
    roots = packet["roots"]
    unreadable = [
        {"source": containers[r["containerId"]]["displayName"], "reason": r["reason"]}
        for r in roots
        if r["disposition"] == "unreadable"
    ]
    return {
        "status": packet["status"],
        "runDir": str(run_dir),
        "packet": str(run_dir / "artifacts" / "packet.private.json"),
        "request": str(run_dir / "request.json"),
        "selected": len(roots),
        "readable": sum(1 for r in roots if r["disposition"] != "unreadable"),
        "duplicates": sum(1 for r in roots if r["disposition"] == "exactDuplicate"),
        "needsConfirmation": sum(
            1 for r in roots if r["disposition"] == "requiresConfirmation"
        ),
        "unreadable": unreadable,
        "limitations": packet["limitations"],
    }


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--lawyer", required=True, help="name identifying the work")
    parser.add_argument("--matter", required=True, help="matter as the user gave it")
    parser.add_argument("--source-root", required=True)
    parser.add_argument("paths", nargs="*", help="selected files under --source-root")
    parser.add_argument(
        "--folder",
        action="append",
        default=[],
        help=(
            "selected folder including subfolders; '.' expressly selects source root"
        ),
    )
    parser.add_argument("--note", action="append", default=[], help="inline note")
    parser.add_argument(
        "--conversation",
        nargs=2,
        action="append",
        default=[],
        metavar=("ID", "PATH"),
        help="selected conversation snapshot",
    )
    parser.add_argument("--client")
    parser.add_argument("--alias", action="append", default=[])
    parser.add_argument("--since")
    parser.add_argument("--until")
    parser.add_argument(
        "--as-of", help="explicit RFC3339 observation clock for reproducible replay"
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    args = _parser().parse_args(argv)
    if not args.paths and not args.folder and not args.note and not args.conversation:
        print(json.dumps({"status": "no_sources_selected"}))
        return 1
    try:
        source_root = validate_source_root(Path(args.source_root))
        request = build_request(
            lawyer=args.lawyer,
            matter=args.matter,
            paths=args.paths,
            folders=args.folder,
            notes=args.note,
            conversations=args.conversation,
            client=args.client,
            aliases=args.alias,
            since=args.since,
            until=args.until,
            as_of=args.as_of,
            source_root=source_root,
        )
    except (SourcePathError, PacketBuildError) as exc:
        print(json.dumps({"status": "invalid_request", "error": str(exc)}))
        return 1
    token = secrets.token_hex(4)
    run_dir = source_root.parent / f".timenarratives-run-{_slug(args.matter)}-{token}"
    try:
        packet = compile_packet_to_directory(request, source_root, run_dir)
        payload = json.dumps(request, ensure_ascii=False, indent=2) + "\n"
        write_new_bytes(
            run_dir / "request.json",
            payload.encode("utf-8"),
            forbidden_roots=(source_root,),
        )
    except OutputSafetyError as exc:
        print(json.dumps({"status": "operational_failure", "error": exc.args[0]}))
        return 2
    except OSError:
        print(
            json.dumps({"status": "operational_failure", "error": "run_write_failed"})
        )
        return 2
    except PacketBuildError as exc:
        print(json.dumps({"status": "invalid_request", "error": str(exc)}))
        return 1
    print(json.dumps(_summary(packet, run_dir), ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: edae2f48d53349310e71cad2393dcc16fde4f59ee1010858c0ae17c63d0803bc