diff --git a/docs/PURSUIT_STUDIO.md b/docs/PURSUIT_STUDIO.md new file mode 100644 index 0000000..7de1ad9 --- /dev/null +++ b/docs/PURSUIT_STUDIO.md @@ -0,0 +1,100 @@ +# Pursuit Studio + +Pursuit Studio is the editable, tree-first workspace for RightMemory's live Pursuit graph. It does not create a second Pursuit database: `PURSUITS.md` and reachable `PURSUIT_.md` files remain canonical. + +## Launch + +```bash +rightmemory pursuit studio +``` + +The command binds to loopback only, prints a one-process access-token URL, and opens it in the default browser. Use `--no-open`, `--port`, or a loopback `--host` when needed. + +The Studio provides: + +- a mind-map-style hierarchy with Focus, parked, F# backing, relation, and task indicators; +- structured create, edit, move, reorder, Focus, park, delete, relation, F# split, and inline operations; +- a Markdown diff before apply; +- whole-graph validation through the canonical `rightmemory.graph` index; +- optimistic revision checks so an old browser tab cannot overwrite newer edits; +- `MemoryWriteLock`, candidate validation, atomic file replacement with rollback, and persistent undo/redo history; +- optional Git commits that stage only the changed Pursuit paths. + +Visual layout is not semantic state. Moving a card changes nothing until an explicit structured move is staged and applied. + +Ordinary Pursuit hierarchy uses the schema's `##` and `###` layers. `####` remains reserved for a terminal typed reference, so create and move operations reject ordinary nodes that would enter that depth. + +## Command Surface + +The same mutation layer is available to local agents and scripts: + +```bash +rightmemory pursuit show --json +rightmemory pursuit create retrieval-speed --title "Faster retrieval" --parent rightmemory +rightmemory pursuit edit retrieval-speed --state "Lexical prefilter prototype exists." \ + --next "do: Benchmark recall and latency" +rightmemory pursuit move retrieval-speed --parent retrieval +rightmemory pursuit focus retrieval-speed +rightmemory pursuit split retrieval +rightmemory pursuit preview --operations-json '[{"op":"park","id":"retrieval-speed"}]' +rightmemory pursuit apply --operations-json '[{"op":"park","id":"retrieval-speed"}]' \ + --revision +rightmemory pursuit undo +rightmemory pursuit redo +``` + +Structured operations are the shared contract used by the Web Studio, CLI, and task reconciliation. A candidate is rendered and validated before the canonical files change. + +## Task Links + +Task execution is operational state, not Pursuit prose. Links live in root `pursuit_tasks.toml`; the writer adds the file to the root Git allowlist when necessary. + +```bash +rightmemory pursuit task link --pursuit retrieval-speed --current \ + --title "Benchmark current retrieval" --project /path/to/RightMemory + +rightmemory pursuit task plan --pursuit retrieval-speed \ + --action "Implement and benchmark the lexical prefilter" \ + --project /path/to/RightMemory + +rightmemory pursuit task run task-0123456789ab +``` + +A task records its provider, provider thread ID, linked Pursuits, project, host, prompt, status, and result. Re-linking the same provider thread is idempotent. Planning also avoids another live task with the same Pursuit and action. + +`task run` creates a Codex thread through the installed Codex SDK and executes its first turn on the machine running the command. `host` is recorded context; this version does not pretend to be an arbitrary cross-machine scheduler. A cross-device workflow can create a planned task and run it on the destination host. + +## Result Reconciliation + +Task completion never implies Pursuit completion. A Codex agent or user can propose the smallest justified structured update: + +```bash +rightmemory pursuit reconcile propose task-0123456789ab \ + --summary "The benchmark settled the prefilter direction." \ + --operations-json '[ + { + "op": "update", + "id": "retrieval-speed", + "state": "The lexical prefilter preserves target recall and reduces selection latency.", + "next": ["do: Integrate it into production retrieval"] + } + ]' +``` + +The proposal is previewed against a specific Pursuit revision before it is registered. It can then be applied or dismissed: + +```bash +rightmemory pursuit reconcile apply recon-0123456789ab +rightmemory pursuit reconcile dismiss recon-0123456789ab +``` + +A stale reconciliation fails rather than overwriting newer Pursuit work. + +## Data Boundaries + +- `PURSUITS.md` and reachable `PURSUIT_.md`: canonical live intent. +- `pursuit_tasks.toml`: task identities, links, status, results, and pending reconciliation. +- `.runtime/pursuit-studio/history.json`: local undo/redo state; ignored by Git. +- project repositories and task threads: detailed execution state and artifacts. + +Deleting a Pursuit with linked tasks is refused until those links are removed. Removing completed, abandoned, or superseded Pursuit still follows the normal Pursuit lifecycle: preserve independently durable consequences in Memory, repair references and Focus, then remove the live intent. diff --git a/rightmemory/entrypoint.py b/rightmemory/entrypoint.py index 0691a79..85f76df 100644 --- a/rightmemory/entrypoint.py +++ b/rightmemory/entrypoint.py @@ -21,6 +21,19 @@ def main(argv: list[str] | None = None) -> int: except (ValueError, FileNotFoundError, OSError, RuntimeError, UnicodeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 + if remaining[:1] == ["pursuit"]: + try: + active = resolve_memory_root( + profile_name=profile_name, + cwd=Path.cwd(), + default_root=default_memory_root(), + ) + from .pursuit_cli import pursuit_main + + return pursuit_main(active.memory_root, remaining[1:]) + except (ValueError, ProfileError, FileNotFoundError, OSError, RuntimeError, UnicodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 if remaining[:1] == ["mcp"]: try: active = resolve_memory_root( @@ -59,6 +72,12 @@ def main(argv: list[str] | None = None) -> int: for error in errors: print(f"- {error}", file=sys.stderr) return 1 + task_errors = _pursuit_task_validation_errors(root) + if task_errors: + print("pursuit task validation failed:", file=sys.stderr) + for error in task_errors: + print(f"- {error}", file=sys.stderr) + return 1 return 0 @@ -109,5 +128,20 @@ def _guidance_validation_errors(memory_root: Path) -> list[str]: return validate_guidance_inbox(path.read_text(encoding="utf-8")) +def _pursuit_task_validation_errors(memory_root: Path) -> list[str]: + path = memory_root / "pursuit_tasks.toml" + if not path.exists() and not path.is_symlink(): + return [] + if path.is_symlink() or not path.is_file(): + return ["pursuit_tasks.toml must be a regular file"] + try: + from .pursuit_tasks import load_registry + + load_registry(memory_root) + except (ValueError, OSError, UnicodeError) as exc: + return [str(exc)] + return [] + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/rightmemory/pursuit_cli.py b/rightmemory/pursuit_cli.py new file mode 100644 index 0000000..9780fb7 --- /dev/null +++ b/rightmemory/pursuit_cli.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from .pursuit_tasks import ( + apply_reconciliation, + dismiss_reconciliation, + link_current_codex_task, + link_task, + list_reconciliations, + list_tasks, + plan_task, + propose_reconciliation, + registry_revision, + run_task, + unlink_task, + update_task, +) +from .pursuit_workspace import ( + PursuitEditor, + apply_operations, + preview_operations, + redo, + undo, +) + + +def pursuit_main(memory_root: Path, argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="rightmemory pursuit", + description="Edit live Pursuit as a Markdown-backed map and coordinate linked agent tasks.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + studio = subparsers.add_parser("studio", help="launch the editable local Pursuit Studio") + studio.add_argument("--host", default="127.0.0.1") + studio.add_argument("--port", type=int, default=8767) + studio.add_argument("--no-open", action="store_true") + + show = subparsers.add_parser("show", help="show the Pursuit workspace") + show.add_argument("id", nargs="?") + show.add_argument("--json", action="store_true") + + preview = subparsers.add_parser("preview", help="validate and preview structured operations") + _add_operations_arguments(preview) + + apply_parser = subparsers.add_parser("apply", help="apply structured operations") + _add_operations_arguments(apply_parser) + apply_parser.add_argument("--commit", action="store_true") + + create = subparsers.add_parser("create", help="create a Pursuit") + create.add_argument("id") + create.add_argument("--title", required=True) + create.add_argument("--parent") + create.add_argument("--objective", default="") + create.add_argument("--state", default="") + create.add_argument("--next", action="append", default=[]) + create.add_argument("--done-when", default="") + create.add_argument("--status", choices=("active", "parked"), default="active") + create.add_argument("--edge", action="append", default=[]) + create.add_argument("--index", type=int) + create.add_argument("--commit", action="store_true") + + edit = subparsers.add_parser("edit", help="edit canonical Pursuit fields") + edit.add_argument("id") + edit.add_argument("--title") + edit.add_argument("--objective") + edit.add_argument("--state") + edit.add_argument("--next", action="append") + edit.add_argument("--done-when") + edit.add_argument("--status", choices=("active", "parked")) + edit.add_argument("--edge", action="append") + edit.add_argument("--commit", action="store_true") + + move = subparsers.add_parser("move", help="move a Pursuit subtree") + move.add_argument("id") + move.add_argument("--parent") + move.add_argument("--index", type=int) + move.add_argument("--commit", action="store_true") + + reorder = subparsers.add_parser("reorder", help="change a Pursuit's sibling order") + reorder.add_argument("id") + reorder.add_argument("index", type=int) + reorder.add_argument("--commit", action="store_true") + + delete = subparsers.add_parser("delete", help="remove a Pursuit from the live tree") + delete.add_argument("id") + delete.add_argument("--cascade", action="store_true") + delete.add_argument("--commit", action="store_true") + + focus = subparsers.add_parser("focus", help="replace the ordered Focus list") + focus.add_argument("ids", nargs="*") + focus.add_argument("--commit", action="store_true") + + for command, help_text in ( + ("park", "park a Pursuit"), + ("unpark", "return a Pursuit to active state"), + ("split", "move a Pursuit's children into PURSUIT_.md"), + ("inline", "move an F# Pursuit's children back inline"), + ): + child = subparsers.add_parser(command, help=help_text) + child.add_argument("id") + child.add_argument("--commit", action="store_true") + + undo_parser = subparsers.add_parser("undo", help="undo the latest Studio edit") + undo_parser.add_argument("--commit", action="store_true") + redo_parser = subparsers.add_parser("redo", help="redo the latest undone Studio edit") + redo_parser.add_argument("--commit", action="store_true") + + task = subparsers.add_parser("task", help="manage tasks linked to Pursuits") + task_subparsers = task.add_subparsers(dest="task_command", required=True) + task_list = task_subparsers.add_parser("list") + task_list.add_argument("--pursuit") + task_list.add_argument("--json", action="store_true") + + task_link = task_subparsers.add_parser("link", help="link an existing provider thread") + task_link.add_argument("--pursuit", action="append", required=True) + task_link.add_argument("--provider", default="codex") + thread_group = task_link.add_mutually_exclusive_group(required=True) + thread_group.add_argument("--thread") + thread_group.add_argument("--current", action="store_true") + task_link.add_argument("--title", required=True) + task_link.add_argument("--project") + task_link.add_argument("--host") + task_link.add_argument("--status", choices=("planned", "active", "completed", "failed", "cancelled"), default="active") + + task_plan = task_subparsers.add_parser("plan", help="create a planned task from Pursuit context") + task_plan.add_argument("--pursuit", required=True) + task_plan.add_argument("--action") + task_plan.add_argument("--title") + task_plan.add_argument("--project") + task_plan.add_argument("--host") + + task_run = task_subparsers.add_parser("run", help="start and execute a planned Codex task") + task_run.add_argument("task_id") + task_run.add_argument("--project") + task_run.add_argument("--model") + task_run.add_argument("--reasoning-effort", choices=("minimal", "low", "medium", "high", "xhigh")) + task_run.add_argument("--sandbox", choices=("read-only", "workspace-write"), default="workspace-write") + + task_update = task_subparsers.add_parser("update", help="record task status or result") + task_update.add_argument("task_id") + task_update.add_argument("--status", choices=("planned", "active", "completed", "failed", "cancelled")) + task_update.add_argument("--result") + task_update.add_argument("--result-file", type=Path) + task_update.add_argument("--error") + task_update.add_argument("--title") + + task_unlink = task_subparsers.add_parser("unlink") + task_unlink.add_argument("task_id") + task_unlink.add_argument("--pursuit") + + reconcile = subparsers.add_parser("reconcile", help="review task results back into Pursuit") + reconcile_subparsers = reconcile.add_subparsers(dest="reconcile_command", required=True) + reconcile_list = reconcile_subparsers.add_parser("list") + reconcile_list.add_argument("--status", choices=("pending", "applied", "dismissed")) + reconcile_list.add_argument("--json", action="store_true") + + reconcile_propose = reconcile_subparsers.add_parser("propose") + reconcile_propose.add_argument("task_id") + reconcile_propose.add_argument("--summary", required=True) + _add_operations_arguments(reconcile_propose) + + reconcile_apply = reconcile_subparsers.add_parser("apply") + reconcile_apply.add_argument("reconciliation_id") + reconcile_apply.add_argument("--commit", action="store_true") + + reconcile_dismiss = reconcile_subparsers.add_parser("dismiss") + reconcile_dismiss.add_argument("reconciliation_id") + + args = parser.parse_args([] if argv is None else argv) + root = Path(memory_root).expanduser().resolve() + + if args.command == "studio": + from .pursuit_web import serve_pursuit_studio + + return serve_pursuit_studio(root, host=args.host, port=args.port, open_browser=not args.no_open) + if args.command == "show": + return _show(root, args.id, as_json=args.json) + if args.command == "preview": + operations = _load_operations(args) + result = preview_operations(root, operations, expected_revision=args.revision) + print(result.diff or "No changes.") + return 0 + if args.command == "apply": + result = apply_operations( + root, + _load_operations(args), + expected_revision=args.revision, + commit=args.commit, + ) + _print_json(result.to_json()) + return 0 + if args.command == "create": + operation = { + "op": "create", + "id": args.id, + "title": args.title, + "parent_id": args.parent, + "objective": args.objective, + "state": args.state, + "next": args.next, + "done_when": args.done_when, + "status": args.status, + "edges": args.edge, + "index": args.index, + } + return _apply_single(root, operation, args.commit) + if args.command == "edit": + operation: dict[str, Any] = {"op": "update", "id": args.id} + for argument, key in ( + (args.title, "title"), + (args.objective, "objective"), + (args.state, "state"), + (args.next, "next"), + (args.done_when, "done_when"), + (args.status, "status"), + (args.edge, "edges"), + ): + if argument is not None: + operation[key] = argument + return _apply_single(root, operation, args.commit) + if args.command == "move": + return _apply_single(root, {"op": "move", "id": args.id, "parent_id": args.parent, "index": args.index}, args.commit) + if args.command == "reorder": + return _apply_single(root, {"op": "reorder", "id": args.id, "index": args.index}, args.commit) + if args.command == "delete": + linked = list_tasks(root, args.id) + if linked: + ids = ", ".join(task.task_id for task in linked) + raise ValueError(f"unlink Pursuit {args.id} from tasks before deletion: {ids}") + return _apply_single(root, {"op": "delete", "id": args.id, "cascade": args.cascade}, args.commit) + if args.command == "focus": + return _apply_single(root, {"op": "set_focus", "ids": args.ids}, args.commit) + if args.command in {"park", "unpark", "split", "inline"}: + operation_name = {"split": "split_file", "inline": "inline_file"}.get(args.command, args.command) + return _apply_single(root, {"op": operation_name, "id": args.id}, args.commit) + if args.command == "undo": + _print_json(undo(root, commit=args.commit).to_json()) + return 0 + if args.command == "redo": + _print_json(redo(root, commit=args.commit).to_json()) + return 0 + if args.command == "task": + return _task_command(root, args) + if args.command == "reconcile": + return _reconcile_command(root, args) + parser.error(f"unknown command: {args.command}") + return 2 + + +def _show(root: Path, item_id: str | None, *, as_json: bool) -> int: + snapshot = PursuitEditor(root).snapshot() + if item_id is None: + if as_json: + _print_json(snapshot) + return 0 + by_id = {node["id"]: node for node in snapshot["nodes"]} + for root_id in snapshot["roots"]: + _print_tree(root_id, by_id, prefix="") + return 0 + node = next((node for node in snapshot["nodes"] if node["id"] == item_id), None) + if node is None: + raise ValueError(f"unknown Pursuit id: {item_id}") + if as_json: + _print_json(node) + else: + print(f"{node['title']} (`{node['id']}`)") + print(node["objective"] or "(no objective)") + if node["state"]: + print(f"State: {node['state']}") + for movement in node["next"]: + print(f"Next {movement['kind']}: {movement['text']}") + if node["done_when"]: + print(f"Done when: {node['done_when']}") + return 0 + + +def _print_tree(item_id: str, by_id: dict[str, dict[str, Any]], prefix: str) -> None: + node = by_id[item_id] + markers = [] + if node["focused"]: + markers.append("focus") + if node["parked"]: + markers.append("parked") + suffix = f" [{', '.join(markers)}]" if markers else "" + print(f"{prefix}- {node['title']} (`{item_id}`){suffix}") + for child_id in node["children"]: + if child_id in by_id: + _print_tree(child_id, by_id, prefix + " ") + + +def _apply_single(root: Path, operation: dict[str, Any], commit: bool) -> int: + result = apply_operations(root, [operation], commit=commit) + _print_json(result.to_json()) + return 0 + + +def _task_command(root: Path, args: argparse.Namespace) -> int: + if args.task_command == "list": + tasks = list_tasks(root, args.pursuit) + if args.json: + _print_json({"revision": registry_revision(root), "tasks": [task.to_json() for task in tasks]}) + else: + for task in tasks: + thread = f" thread={task.thread_id}" if task.thread_id else "" + print(f"{task.task_id}\t{task.status}\t{task.title}{thread}\tpursuits={','.join(task.pursuit_ids)}") + return 0 + if args.task_command == "link": + if args.current: + record = link_current_codex_task( + root, + pursuit_ids=args.pursuit, + title=args.title, + project=args.project, + status=args.status, + ) + else: + record = link_task( + root, + pursuit_ids=args.pursuit, + provider=args.provider, + thread_id=args.thread, + title=args.title, + project=args.project, + host=args.host, + status=args.status, + ) + _print_json(record.to_json()) + return 0 + if args.task_command == "plan": + record = plan_task( + root, + pursuit_id=args.pursuit, + action=args.action, + title=args.title, + project=args.project, + host=args.host, + ) + _print_json(record.to_json()) + return 0 + if args.task_command == "run": + record = run_task( + root, + args.task_id, + project=args.project, + model=args.model, + reasoning_effort=args.reasoning_effort, + sandbox=args.sandbox, + ) + _print_json(record.to_json()) + return 0 + if args.task_command == "update": + result = args.result + if args.result_file is not None: + result = args.result_file.read_text(encoding="utf-8") + record = update_task( + root, + args.task_id, + status=args.status, + result=result, + error=args.error, + title=args.title, + ) + _print_json(record.to_json()) + return 0 + if args.task_command == "unlink": + unlink_task(root, args.task_id, args.pursuit) + print("unlinked") + return 0 + raise ValueError(f"unknown task command: {args.task_command}") + + +def _reconcile_command(root: Path, args: argparse.Namespace) -> int: + if args.reconcile_command == "list": + records = list_reconciliations(root, status=args.status) + if args.json: + _print_json({"reconciliations": [record.to_json() for record in records]}) + else: + for record in records: + print(f"{record.reconciliation_id}\t{record.status}\t{record.task_id}\t{record.summary}") + return 0 + if args.reconcile_command == "propose": + record = propose_reconciliation( + root, + task_id=args.task_id, + summary=args.summary, + operations=_load_operations(args), + expected_revision=args.revision, + ) + _print_json(record.to_json()) + return 0 + if args.reconcile_command == "apply": + _print_json(apply_reconciliation(root, args.reconciliation_id, commit=args.commit)) + return 0 + if args.reconcile_command == "dismiss": + _print_json(dismiss_reconciliation(root, args.reconciliation_id).to_json()) + return 0 + raise ValueError(f"unknown reconcile command: {args.reconcile_command}") + + +def _add_operations_arguments(parser: argparse.ArgumentParser) -> None: + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--operations-json") + source.add_argument("--operations-file", type=Path) + parser.add_argument("--revision") + + +def _load_operations(args: argparse.Namespace) -> list[dict[str, Any]]: + if args.operations_file is not None: + raw = args.operations_file.read_text(encoding="utf-8") + else: + raw = args.operations_json + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid operations JSON: {exc}") from exc + if isinstance(value, dict): + value = [value] + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise ValueError("operations JSON must be an object or list of objects") + return value + + +def _print_json(value: Any) -> None: + print(json.dumps(value, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + print("Use `rightmemory pursuit ...` through the package entry point.", file=sys.stderr) + raise SystemExit(2) diff --git a/rightmemory/pursuit_static/index.html b/rightmemory/pursuit_static/index.html new file mode 100644 index 0000000..063a41f --- /dev/null +++ b/rightmemory/pursuit_static/index.html @@ -0,0 +1,150 @@ + + + + + + RightMemory Pursuit Studio + + + +
+
+ RightMemory + Pursuit Studio +
+
+ + + + + + + +
+
+ +
+ +
+
+
+
+

Pursuit Map

+

+
+ +
+
+
+ + +
+ + +
+

New Pursuit

+ + + + + +
+ + +
+
+
+ + +
+

Markdown Diff

+ +
+

+    
+ + + + diff --git a/rightmemory/pursuit_static/pursuit.css b/rightmemory/pursuit_static/pursuit.css new file mode 100644 index 0000000..8a8b692 --- /dev/null +++ b/rightmemory/pursuit_static/pursuit.css @@ -0,0 +1,166 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --surface: #ffffff; + --surface-soft: #f8fafb; + --text: #20242a; + --muted: #66707c; + --line: #d7dee8; + --line-strong: #aeb9c7; + --accent: #28685f; + --accent-soft: #e5f1ed; + --action: #275dc8; + --danger: #a73737; + --focus: #8b5d16; +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +button, input, textarea, select { font: inherit; } +button { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface); + color: var(--text); + cursor: pointer; + padding: 6px 10px; +} +button:hover { border-color: var(--accent); } +button:disabled { cursor: not-allowed; opacity: .5; } +button.primary { border-color: var(--action); background: var(--action); color: white; } +button.danger { border-color: #d9a6a6; color: var(--danger); } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + min-height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--line); + background: rgba(255,255,255,.96); + padding: 12px 18px; +} +.topbar > div:first-child { display: flex; gap: 8px; align-items: baseline; } +.topbar span { color: var(--muted); } +.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.toolbar input[type="search"] { width: 220px; } +.commit-choice { display: inline-flex; gap: 5px; align-items: center; color: var(--muted); font-size: 13px; } + +.message { min-height: 26px; padding: 5px 18px; color: #8a4b12; } +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 430px); + gap: 14px; + padding: 0 14px 18px; +} +.map-panel, .details-panel { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} +.map-panel { min-height: calc(100vh - 108px); overflow: auto; } +.details-panel { align-self: start; padding: 14px; max-height: calc(100vh - 108px); overflow: auto; } +.map-heading, .details-heading, .dialog-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.map-heading { position: sticky; left: 0; z-index: 2; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--surface); } +h1, h2, h3, p { margin: 0; } +h1 { font-size: 22px; } +h2 { font-size: 17px; } +h3 { font-size: 14px; margin-bottom: 9px; } +p { color: var(--muted); } +code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } + +.mind-map { min-width: 780px; padding: 28px 34px 70px; } +.map-root { display: grid; gap: 24px; } +.map-branch { display: flex; align-items: center; gap: 36px; position: relative; } +.map-branch > .map-node-wrap { position: relative; flex: 0 0 250px; } +.map-children { display: grid; gap: 18px; position: relative; padding-left: 24px; } +.map-children::before { + content: ""; + position: absolute; + left: 0; + top: 16px; + bottom: 16px; + border-left: 1px solid var(--line-strong); +} +.map-children > .map-branch::before { + content: ""; + position: absolute; + left: -24px; + width: 24px; + border-top: 1px solid var(--line-strong); +} +.map-node { + width: 100%; + display: grid; + gap: 7px; + text-align: left; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--surface); + padding: 11px 12px; + box-shadow: 0 3px 10px rgba(31, 42, 52, .06); +} +.map-node.selected { border: 2px solid var(--action); padding: 10px 11px; } +.map-node.focused { box-shadow: 0 0 0 3px #f3e5c7; } +.map-node.parked { border-style: dashed; opacity: .78; } +.map-node strong { font-size: 14px; } +.map-node small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.node-meta { display: flex; flex-wrap: wrap; gap: 5px; } +.badge { border-radius: 999px; background: var(--surface-soft); color: var(--muted); padding: 2px 7px; font-size: 11px; } +.badge.focus { background: #f4e8cd; color: var(--focus); } +.badge.task { background: #e7edf9; color: #31568e; } +.badge.file { background: var(--accent-soft); color: var(--accent); } + +.empty-selection { padding: 28px 8px; display: grid; gap: 8px; } +.details-heading { margin-bottom: 14px; } +.details-heading code { display: block; color: var(--muted); margin-top: 4px; } +.stack-form { display: grid; gap: 10px; } +.stack-form.compact { gap: 8px; } +label { display: grid; gap: 5px; color: #3d4651; font-size: 13px; font-weight: 650; } +input, textarea, select { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface); + color: var(--text); + padding: 7px 9px; +} +textarea { resize: vertical; } +.hint { color: var(--muted); font-weight: 400; font-size: 11px; } +.button-row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; } +.action-section { border-top: 1px solid var(--line); margin-top: 16px; padding-top: 14px; } +.card-list { display: grid; gap: 7px; } +.mini-card { display: grid; gap: 4px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); padding: 9px; } +.mini-card small { color: var(--muted); overflow-wrap: anywhere; } +.mini-card p { color: var(--text); white-space: pre-wrap; font-size: 12px; } +details { border: 1px solid var(--line); border-radius: 8px; margin-top: 9px; padding: 8px 9px; } +summary { cursor: pointer; font-size: 13px; font-weight: 700; color: var(--accent); } +details[open] summary { margin-bottom: 10px; } + + dialog { width: min(620px, calc(100vw - 30px)); border: 1px solid var(--line); border-radius: 10px; padding: 18px; } +dialog::backdrop { background: rgba(15, 22, 28, .42); } +.diff-dialog { width: min(1000px, calc(100vw - 30px)); } +.diff-dialog pre { max-height: 70vh; overflow: auto; border: 1px solid var(--line); background: #f7f8fa; padding: 12px; white-space: pre; } +.dialog-heading { margin-bottom: 12px; } + +@media (max-width: 1000px) { + .layout { grid-template-columns: 1fr; } + .details-panel { max-height: none; } + .topbar { align-items: flex-start; flex-direction: column; } +} diff --git a/rightmemory/pursuit_static/pursuit.js b/rightmemory/pursuit_static/pursuit.js new file mode 100644 index 0000000..82b2c56 --- /dev/null +++ b/rightmemory/pursuit_static/pursuit.js @@ -0,0 +1,546 @@ +const state = { + token: null, + workspace: null, + tasks: [], + reconciliations: [], + taskRevision: null, + selectedId: null, + operations: [], + baseRevision: null, + preview: null, +}; + +function bootstrapToken() { + const url = new URL(window.location.href); + const queryToken = url.searchParams.get("token"); + if (queryToken) { + localStorage.setItem("rightmemory-pursuit-token", queryToken); + url.searchParams.delete("token"); + history.replaceState({}, "", url.pathname + url.search + url.hash); + } + state.token = queryToken || localStorage.getItem("rightmemory-pursuit-token"); + if (!state.token) { + throw new Error("Missing Pursuit Studio token. Launch through `rightmemory pursuit studio`."); + } +} + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + "content-type": "application/json", + authorization: `Bearer ${state.token}`, + ...(options.headers || {}), + }, + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + const detail = payload.detail || payload.message || `Request failed: ${response.status}`; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return payload.data; +} + +function setMessage(text, isError = false) { + const target = document.querySelector("#message"); + target.textContent = text || ""; + target.style.color = isError ? "#a73737" : "#8a4b12"; +} + +async function loadWorkspace({ preserveSelection = true } = {}) { + const selected = preserveSelection ? state.selectedId : null; + const data = await api("/api/workspace"); + state.workspace = data.workspace; + state.tasks = data.tasks || []; + state.reconciliations = data.reconciliations || []; + state.taskRevision = data.task_revision; + state.baseRevision = data.workspace.revision; + state.operations = []; + state.preview = null; + state.selectedId = selected && findNode(selected) ? selected : null; + renderAll(); +} + +function renderAll() { + document.querySelector("#root-label").textContent = `${state.workspace.nodes.length} Pursuits · revision ${state.workspace.revision.slice(0, 10)}`; + renderMap(); + renderEditor(); + renderReconciliations(); + syncToolbar(); +} + +function syncToolbar() { + const dirty = state.operations.length > 0; + document.querySelector("#apply").disabled = !dirty; + document.querySelector("#preview").disabled = !dirty; + document.querySelector("#discard").disabled = !dirty; + document.querySelector("#apply").textContent = dirty ? `Apply Changes (${state.operations.length})` : "Apply Changes"; +} + +function findNode(id) { + return (state.workspace?.nodes || []).find((node) => node.id === id) || null; +} + +function nodeMap() { + return new Map((state.workspace?.nodes || []).map((node) => [node.id, node])); +} + +function renderMap() { + const map = document.querySelector("#map"); + const byId = nodeMap(); + const query = document.querySelector("#search").value.trim().toLowerCase(); + const visible = new Set(); + if (query) { + for (const node of byId.values()) { + const haystack = `${node.id} ${node.title} ${node.objective} ${node.state}`.toLowerCase(); + if (haystack.includes(query)) { + let current = node; + while (current) { + visible.add(current.id); + current = current.parent_id ? byId.get(current.parent_id) : null; + } + } + } + } + const roots = (state.workspace.roots || []).filter((id) => byId.has(id)); + if (!roots.length) { + map.innerHTML = '

No live Pursuits yet.

'; + return; + } + map.innerHTML = `
${roots.map((id) => renderBranch(id, byId, visible, query)).join("")}
`; + map.querySelectorAll(".map-node[data-id]").forEach((button) => { + button.addEventListener("click", () => { + state.selectedId = button.dataset.id; + renderMap(); + renderEditor(); + }); + }); +} + +function renderBranch(id, byId, visible, query) { + const node = byId.get(id); + if (!node || (query && !visible.has(id))) { + return ""; + } + const children = (node.children || []).filter((child) => byId.has(child)); + const classes = ["map-node"]; + if (state.selectedId === id) classes.push("selected"); + if (node.focused) classes.push("focused"); + if (node.parked) classes.push("parked"); + const badges = []; + if (node.focused) badges.push('focus'); + if (node.parked) badges.push('parked'); + if (node.backing) badges.push('F#'); + if ((node.tasks || []).length) badges.push(`${node.tasks.length} task${node.tasks.length === 1 ? "" : "s"}`); + return ` +
+
+ +
+ ${children.length ? `
${children.map((child) => renderBranch(child, byId, visible, query)).join("")}
` : ""} +
+ `; +} + +function renderEditor() { + const empty = document.querySelector("#empty-selection"); + const section = document.querySelector("#editor-section"); + const node = state.selectedId ? findNode(state.selectedId) : null; + empty.hidden = Boolean(node); + section.hidden = !node; + if (!node) return; + + document.querySelector("#editor-heading").textContent = node.title; + document.querySelector("#editor-id").textContent = node.id; + document.querySelector("#editor-badges").innerHTML = [ + node.focused ? 'focus' : "", + node.parked ? 'parked' : "", + node.backing ? 'F# backing' : "", + ].join(""); + const form = document.querySelector("#editor-form"); + form.elements.title.value = node.title || ""; + form.elements.objective.value = node.objective || ""; + form.elements.state.value = node.state || ""; + form.elements.next.value = (node.next || []).map((item) => `${item.kind}: ${item.text}`).join("\n"); + form.elements.done_when.value = node.done_when || ""; + form.elements.status.value = node.status || "active"; + form.elements.edges.value = (node.edges || []).map((edge) => `${edge.type}:${edge.target}`).join("\n"); + + document.querySelector("#toggle-focus").textContent = node.focused ? "Remove from Focus" : "Add to Focus"; + document.querySelector("#toggle-backing").textContent = node.backing ? "Inline F# Children" : "Split Children to F#"; + + const parent = document.querySelector("#move-parent"); + const options = ['']; + for (const candidate of state.workspace.nodes) { + if (candidate.id !== node.id) { + options.push(``); + } + } + parent.innerHTML = options.join(""); + renderTasks(node); +} + +function renderTasks(node) { + const target = document.querySelector("#task-list"); + const tasks = state.tasks.filter((task) => (task.pursuit_ids || []).includes(node.id)); + if (!tasks.length) { + target.innerHTML = "

No linked tasks.

"; + return; + } + target.innerHTML = tasks.map((task) => ` +
+ ${escapeHtml(task.title)} + ${escapeHtml(task.task_id)} · ${escapeHtml(task.status)}${task.thread_id ? ` · thread ${escapeHtml(task.thread_id)}` : ""} + ${task.action ? `

${escapeHtml(task.action)}

` : ""} + ${task.result ? `
Result

${escapeHtml(task.result)}

` : ""} +
+ ${task.status === "planned" ? `` : ""} + +
+
+ `).join(""); + target.querySelectorAll(".run-task").forEach((button) => button.addEventListener("click", () => runTask(button.dataset.taskId))); + target.querySelectorAll(".unlink-task").forEach((button) => button.addEventListener("click", () => unlinkTask(button.dataset.taskId, node.id))); +} + +function renderReconciliations() { + const target = document.querySelector("#reconciliation-list"); + const records = state.reconciliations.filter((record) => record.status === "pending"); + if (!records.length) { + target.innerHTML = "

No pending task reconciliation.

"; + return; + } + target.innerHTML = records.map((record) => ` +
+ ${escapeHtml(record.summary)} + ${escapeHtml(record.reconciliation_id)} · task ${escapeHtml(record.task_id)} +

${escapeHtml(JSON.stringify(record.operations, null, 2))}

+
+ + +
+
+ `).join(""); + target.querySelectorAll(".apply-reconciliation").forEach((button) => button.addEventListener("click", () => applyReconciliation(button.dataset.id))); + target.querySelectorAll(".dismiss-reconciliation").forEach((button) => button.addEventListener("click", () => dismissReconciliation(button.dataset.id))); +} + +async function stageOperation(operation) { + const previousOperations = [...state.operations]; + state.operations.push(operation); + try { + const preview = await api("/api/preview", { + method: "POST", + body: JSON.stringify({ operations: state.operations, revision: state.baseRevision }), + }); + state.preview = preview; + state.workspace = preview.snapshot; + if (state.selectedId && !findNode(state.selectedId)) state.selectedId = null; + setMessage(`${state.operations.length} staged operation${state.operations.length === 1 ? "" : "s"}.`); + renderAll(); + } catch (error) { + state.operations = previousOperations; + setMessage(error.message, true); + } +} + +async function applyDraft() { + if (!state.operations.length) return; + try { + const data = await api("/api/apply", { + method: "POST", + body: JSON.stringify({ + operations: state.operations, + revision: state.baseRevision, + commit: document.querySelector("#commit").checked, + }), + }); + setMessage(data.commit ? `Applied and committed ${data.commit.slice(0, 12)}.` : "Applied Pursuit changes."); + await loadWorkspace(); + } catch (error) { + setMessage(error.message, true); + } +} + +async function showDiff() { + if (!state.operations.length) return; + try { + if (!state.preview) { + state.preview = await api("/api/preview", { + method: "POST", + body: JSON.stringify({ operations: state.operations, revision: state.baseRevision }), + }); + } + document.querySelector("#diff-output").textContent = state.preview.diff || "No Markdown changes."; + document.querySelector("#diff-dialog").showModal(); + } catch (error) { + setMessage(error.message, true); + } +} + +async function discardDraft() { + state.selectedId = state.selectedId; + await loadWorkspace(); + setMessage("Discarded staged changes."); +} + +function requireCleanDraft() { + if (state.operations.length) { + setMessage("Apply or discard staged Pursuit edits before changing task links.", true); + return false; + } + return true; +} + +async function runTask(taskId, project = null) { + if (!requireCleanDraft()) return; + setMessage("Running Codex task. This request remains open until the task turn completes."); + try { + await api(`/api/tasks/${encodeURIComponent(taskId)}/run`, { + method: "POST", + body: JSON.stringify(project ? { project } : {}), + }); + await loadWorkspace(); + setMessage("Codex task completed and its result was recorded."); + } catch (error) { + setMessage(error.message, true); + await loadWorkspace(); + } +} + +async function unlinkTask(taskId, pursuitId) { + if (!requireCleanDraft()) return; + try { + await api(`/api/tasks/${encodeURIComponent(taskId)}/unlink`, { + method: "POST", + body: JSON.stringify({ pursuit_id: pursuitId }), + }); + await loadWorkspace(); + setMessage("Task link removed."); + } catch (error) { + setMessage(error.message, true); + } +} + +async function applyReconciliation(id) { + if (!requireCleanDraft()) return; + try { + await api(`/api/reconciliations/${encodeURIComponent(id)}/apply`, { + method: "POST", + body: JSON.stringify({ commit: document.querySelector("#commit").checked }), + }); + await loadWorkspace(); + setMessage("Task result reconciled into Pursuit."); + } catch (error) { + setMessage(error.message, true); + } +} + +async function dismissReconciliation(id) { + if (!requireCleanDraft()) return; + try { + await api(`/api/reconciliations/${encodeURIComponent(id)}/dismiss`, { method: "POST", body: "{}" }); + await loadWorkspace(); + setMessage("Reconciliation dismissed."); + } catch (error) { + setMessage(error.message, true); + } +} + +function openNodeDialog(parentId, title) { + const form = document.querySelector("#node-form"); + form.reset(); + form.elements.parent_id.value = parentId || ""; + document.querySelector("#node-dialog-title").textContent = title; + document.querySelector("#node-dialog").showModal(); +} + +function parseLines(value) { + return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (character) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[character])); +} + +document.querySelector("#search").addEventListener("input", renderMap); +document.querySelector("#apply").addEventListener("click", applyDraft); +document.querySelector("#preview").addEventListener("click", showDiff); +document.querySelector("#discard").addEventListener("click", discardDraft); +document.querySelector("#close-diff").addEventListener("click", () => document.querySelector("#diff-dialog").close()); +document.querySelector("#add-root").addEventListener("click", () => openNodeDialog(null, "New Top-Level Pursuit")); +document.querySelector("#cancel-node").addEventListener("click", () => document.querySelector("#node-dialog").close()); + +document.querySelector("#editor-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const node = findNode(state.selectedId); + if (!node) return; + const form = new FormData(event.currentTarget); + await stageOperation({ + op: "update", + id: node.id, + title: form.get("title"), + objective: form.get("objective"), + state: form.get("state"), + next: parseLines(String(form.get("next") || "")), + done_when: form.get("done_when"), + status: form.get("status"), + edges: parseLines(String(form.get("edges") || "")), + }); +}); + +document.querySelector("#toggle-focus").addEventListener("click", async () => { + const node = findNode(state.selectedId); + if (!node) return; + const ids = [...state.workspace.focus_ids]; + const index = ids.indexOf(node.id); + if (index >= 0) ids.splice(index, 1); else ids.push(node.id); + await stageOperation({ op: "set_focus", ids }); +}); + +document.querySelector("#toggle-backing").addEventListener("click", async () => { + const node = findNode(state.selectedId); + if (!node) return; + await stageOperation({ op: node.backing ? "inline_file" : "split_file", id: node.id }); +}); + +document.querySelector("#move-node").addEventListener("click", async () => { + const node = findNode(state.selectedId); + if (!node) return; + const rawIndex = document.querySelector("#move-index").value; + const operation = { + op: "move", + id: node.id, + parent_id: document.querySelector("#move-parent").value || null, + }; + if (rawIndex !== "") operation.index = Number(rawIndex); + await stageOperation(operation); +}); + +document.querySelector("#add-child").addEventListener("click", () => { + const node = findNode(state.selectedId); + if (node) openNodeDialog(node.id, `New Child of ${node.title}`); +}); +document.querySelector("#add-sibling").addEventListener("click", () => { + const node = findNode(state.selectedId); + if (node) openNodeDialog(node.parent_id, `New Sibling of ${node.title}`); +}); + +document.querySelector("#delete-node").addEventListener("click", async () => { + const node = findNode(state.selectedId); + if (!node) return; + if ((node.tasks || []).length) { + setMessage("Unlink this Pursuit from its tasks before deleting it.", true); + return; + } + if (!window.confirm(`Remove ${node.title} and all of its children from live Pursuit? Git history remains available.`)) return; + await stageOperation({ op: "delete", id: node.id, cascade: true }); +}); + +document.querySelector("#node-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const next = String(form.get("next") || "").trim(); + document.querySelector("#node-dialog").close(); + await stageOperation({ + op: "create", + id: form.get("id"), + title: form.get("title"), + parent_id: form.get("parent_id") || null, + objective: form.get("objective"), + next: next ? [next] : [], + }); + state.selectedId = String(form.get("id")); + renderAll(); +}); + +document.querySelector("#link-task-form").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!requireCleanDraft()) return; + const node = findNode(state.selectedId); + if (!node) return; + const form = new FormData(event.currentTarget); + try { + await api("/api/tasks/link", { + method: "POST", + body: JSON.stringify({ + pursuit_ids: [node.id], + provider: "codex", + thread_id: form.get("thread_id"), + title: form.get("title"), + project: form.get("project"), + task_revision: state.taskRevision, + }), + }); + event.currentTarget.reset(); + await loadWorkspace(); + setMessage("Codex thread linked."); + } catch (error) { + setMessage(error.message, true); + } +}); + +document.querySelector("#plan-task-form").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!requireCleanDraft()) return; + const node = findNode(state.selectedId); + if (!node) return; + const form = new FormData(event.currentTarget); + const mode = event.submitter?.value || "plan"; + try { + const task = await api("/api/tasks/plan", { + method: "POST", + body: JSON.stringify({ + pursuit_id: node.id, + action: form.get("action"), + title: form.get("title"), + project: form.get("project"), + }), + }); + if (mode === "run") { + await runTask(task.task_id, String(form.get("project") || "") || null); + } else { + await loadWorkspace(); + setMessage("Planned task created and linked."); + } + } catch (error) { + setMessage(error.message, true); + } +}); + +document.querySelector("#undo").addEventListener("click", async () => { + if (!requireCleanDraft()) return; + try { + await api("/api/undo", { method: "POST", body: JSON.stringify({ commit: document.querySelector("#commit").checked }) }); + await loadWorkspace(); + setMessage("Undid the latest Pursuit Studio edit."); + } catch (error) { + setMessage(error.message, true); + } +}); + +document.querySelector("#redo").addEventListener("click", async () => { + if (!requireCleanDraft()) return; + try { + await api("/api/redo", { method: "POST", body: JSON.stringify({ commit: document.querySelector("#commit").checked }) }); + await loadWorkspace(); + setMessage("Redid the latest Pursuit Studio edit."); + } catch (error) { + setMessage(error.message, true); + } +}); + +(async () => { + try { + bootstrapToken(); + await loadWorkspace({ preserveSelection: false }); + } catch (error) { + setMessage(error.message, true); + } +})(); diff --git a/rightmemory/pursuit_tasks.py b/rightmemory/pursuit_tasks.py new file mode 100644 index 0000000..ddc086b --- /dev/null +++ b/rightmemory/pursuit_tasks.py @@ -0,0 +1,911 @@ +from __future__ import annotations + +import hashlib +import json +import os +import socket +import tomllib +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Iterable +from uuid import uuid4 + +from .codex_sdk import CodexSdkRunner +from .graph import build_graph_manifest, validate_item_id +from .session import MemoryWriteLock + + +REGISTRY_PATH = Path("pursuit_tasks.toml") +REGISTRY_VERSION = 1 +TASK_STATUSES = {"planned", "active", "completed", "failed", "cancelled"} +RECONCILIATION_STATUSES = {"pending", "applied", "dismissed"} + + +class PursuitTaskError(ValueError): + pass + + +@dataclass(slots=True) +class TaskRecord: + task_id: str + provider: str + pursuit_ids: list[str] + title: str + status: str = "planned" + thread_id: str | None = None + project: str | None = None + host: str | None = None + action: str | None = None + prompt: str | None = None + result: str | None = None + error: str | None = None + created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + + def validate(self) -> None: + if not self.task_id.strip(): + raise PursuitTaskError("task id must not be empty") + if not self.provider.strip(): + raise PursuitTaskError("task provider must not be empty") + if not self.title.strip(): + raise PursuitTaskError("task title must not be empty") + if self.status not in TASK_STATUSES: + raise PursuitTaskError(f"invalid task status: {self.status}") + if not self.pursuit_ids: + raise PursuitTaskError("task must link at least one Pursuit") + if len(self.pursuit_ids) != len(set(self.pursuit_ids)): + raise PursuitTaskError("task Pursuit links must be unique") + for pursuit_id in self.pursuit_ids: + validate_item_id(pursuit_id) + if self.thread_id is not None and not self.thread_id.strip(): + raise PursuitTaskError("thread id must not be empty") + + def to_json(self) -> dict[str, Any]: + return { + "task_id": self.task_id, + "provider": self.provider, + "pursuit_ids": list(self.pursuit_ids), + "title": self.title, + "status": self.status, + "thread_id": self.thread_id, + "project": self.project, + "host": self.host, + "action": self.action, + "prompt": self.prompt, + "result": self.result, + "error": self.error, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + +@dataclass(slots=True) +class ReconciliationRecord: + reconciliation_id: str + task_id: str + summary: str + operations: list[dict[str, Any]] + base_revision: str + status: str = "pending" + created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + + def validate(self) -> None: + if not self.reconciliation_id.strip(): + raise PursuitTaskError("reconciliation id must not be empty") + if not self.task_id.strip(): + raise PursuitTaskError("reconciliation task id must not be empty") + if not self.summary.strip(): + raise PursuitTaskError("reconciliation summary must not be empty") + if not isinstance(self.operations, list) or not all(isinstance(item, dict) for item in self.operations): + raise PursuitTaskError("reconciliation operations must be a list of objects") + if self.status not in RECONCILIATION_STATUSES: + raise PursuitTaskError(f"invalid reconciliation status: {self.status}") + if len(self.base_revision) != 64: + raise PursuitTaskError("reconciliation base revision is invalid") + + def to_json(self) -> dict[str, Any]: + return { + "reconciliation_id": self.reconciliation_id, + "task_id": self.task_id, + "summary": self.summary, + "operations": [dict(item) for item in self.operations], + "base_revision": self.base_revision, + "status": self.status, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + +@dataclass(slots=True) +class TaskRegistry: + tasks: list[TaskRecord] = field(default_factory=list) + reconciliations: list[ReconciliationRecord] = field(default_factory=list) + + def validate(self, memory_root: Path) -> None: + manifest = build_graph_manifest(memory_root) + if manifest.errors: + raise PursuitTaskError("RightMemory graph must be valid before task links can change") + pursuit_ids = { + item.id + for item in manifest.items.values() + if item.family == "pursuit" and item.item_kind == "heading" + } + task_ids: set[str] = set() + thread_keys: set[tuple[str, str]] = set() + for task in self.tasks: + task.validate() + if task.task_id in task_ids: + raise PursuitTaskError(f"duplicate task id: {task.task_id}") + task_ids.add(task.task_id) + for pursuit_id in task.pursuit_ids: + if pursuit_id not in pursuit_ids: + raise PursuitTaskError(f"task links unknown Pursuit: {pursuit_id}") + if task.thread_id: + key = (task.provider.casefold(), task.thread_id) + if key in thread_keys: + raise PursuitTaskError(f"duplicate provider thread link: {task.provider}:{task.thread_id}") + thread_keys.add(key) + reconciliation_ids: set[str] = set() + for reconciliation in self.reconciliations: + reconciliation.validate() + if reconciliation.reconciliation_id in reconciliation_ids: + raise PursuitTaskError(f"duplicate reconciliation id: {reconciliation.reconciliation_id}") + reconciliation_ids.add(reconciliation.reconciliation_id) + if reconciliation.task_id not in task_ids: + raise PursuitTaskError(f"reconciliation links unknown task: {reconciliation.task_id}") + + def task(self, task_id: str) -> TaskRecord: + for task in self.tasks: + if task.task_id == task_id: + return task + raise PursuitTaskError(f"unknown Pursuit task: {task_id}") + + def reconciliation(self, reconciliation_id: str) -> ReconciliationRecord: + for reconciliation in self.reconciliations: + if reconciliation.reconciliation_id == reconciliation_id: + return reconciliation + raise PursuitTaskError(f"unknown reconciliation: {reconciliation_id}") + + +def registry_revision(memory_root: Path) -> str: + path = Path(memory_root).expanduser().resolve() / REGISTRY_PATH + data = path.read_bytes() if path.is_file() else b"" + return hashlib.sha256(data).hexdigest() + + +def load_registry(memory_root: Path) -> TaskRegistry: + root = Path(memory_root).expanduser().resolve() + path = root / REGISTRY_PATH + if path.is_symlink(): + raise PursuitTaskError(f"{REGISTRY_PATH} must be a regular file") + if not path.is_file(): + if path.exists(): + raise PursuitTaskError(f"{REGISTRY_PATH} must be a regular file") + return TaskRegistry() + try: + payload = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc: + raise PursuitTaskError(f"invalid {REGISTRY_PATH}: {exc}") from exc + if payload.get("version") != REGISTRY_VERSION: + raise PursuitTaskError(f"unsupported {REGISTRY_PATH} version") + raw_tasks = payload.get("tasks", []) + raw_reconciliations = payload.get("reconciliations", []) + if not isinstance(raw_tasks, list) or not isinstance(raw_reconciliations, list): + raise PursuitTaskError(f"invalid {REGISTRY_PATH} table layout") + tasks = [_task_from_toml(item) for item in raw_tasks] + reconciliations = [_reconciliation_from_toml(item) for item in raw_reconciliations] + registry = TaskRegistry(tasks, reconciliations) + registry.validate(root) + return registry + + +def list_tasks(memory_root: Path, pursuit_id: str | None = None) -> list[TaskRecord]: + tasks = load_registry(memory_root).tasks + if pursuit_id is not None: + tasks = [task for task in tasks if pursuit_id in task.pursuit_ids] + return sorted(tasks, key=lambda task: (task.updated_at, task.task_id), reverse=True) + + +def list_reconciliations(memory_root: Path, *, status: str | None = None) -> list[ReconciliationRecord]: + records = load_registry(memory_root).reconciliations + if status is not None: + records = [record for record in records if record.status == status] + return sorted(records, key=lambda record: (record.updated_at, record.reconciliation_id), reverse=True) + + +def link_task( + memory_root: Path, + *, + pursuit_ids: Iterable[str], + provider: str, + thread_id: str, + title: str, + project: str | None = None, + host: str | None = None, + status: str = "active", + expected_revision: str | None = None, +) -> TaskRecord: + root = Path(memory_root).expanduser().resolve() + clean_pursuits = _clean_pursuit_ids(pursuit_ids) + clean_provider = _required_text(provider, "provider") + clean_thread = _required_text(thread_id, "thread id") + clean_title = _required_text(title, "title") + with MemoryWriteLock(root): + _check_registry_revision(root, expected_revision) + registry = load_registry(root) + existing = next( + ( + task + for task in registry.tasks + if task.provider.casefold() == clean_provider.casefold() and task.thread_id == clean_thread + ), + None, + ) + now = datetime.now(UTC).isoformat() + if existing is not None: + existing.pursuit_ids = list(dict.fromkeys([*existing.pursuit_ids, *clean_pursuits])) + existing.title = clean_title + existing.project = _clean_optional(project) or existing.project + existing.host = _clean_optional(host) or existing.host + existing.status = status + existing.updated_at = now + record = existing + else: + record = TaskRecord( + task_id=_new_id("task"), + provider=clean_provider, + pursuit_ids=clean_pursuits, + title=clean_title, + status=status, + thread_id=clean_thread, + project=_clean_optional(project), + host=_clean_optional(host) or socket.gethostname(), + created_at=now, + updated_at=now, + ) + registry.tasks.append(record) + _save_registry(root, registry) + return record + + +def link_current_codex_task( + memory_root: Path, + *, + pursuit_ids: Iterable[str], + title: str, + project: str | None = None, + status: str = "active", +) -> TaskRecord: + thread_id = os.environ.get("CODEX_THREAD_ID", "").strip() + if not thread_id: + raise PursuitTaskError("CODEX_THREAD_ID is not available in this agent environment") + return link_task( + memory_root, + pursuit_ids=pursuit_ids, + provider="codex", + thread_id=thread_id, + title=title, + project=project, + status=status, + ) + + +def plan_task( + memory_root: Path, + *, + pursuit_id: str, + action: str | None = None, + title: str | None = None, + project: str | None = None, + host: str | None = None, + provider: str = "codex", +) -> TaskRecord: + root = Path(memory_root).expanduser().resolve() + pursuit_id = validate_item_id(pursuit_id.strip()) + prompt, resolved_action, resolved_title = build_task_prompt( + root, + pursuit_id=pursuit_id, + action=action, + title=title, + project=project, + ) + now = datetime.now(UTC).isoformat() + record = TaskRecord( + task_id=_new_id("task"), + provider=_required_text(provider, "provider"), + pursuit_ids=[pursuit_id], + title=resolved_title, + status="planned", + project=_clean_optional(project), + host=_clean_optional(host) or socket.gethostname(), + action=resolved_action, + prompt=prompt, + created_at=now, + updated_at=now, + ) + with MemoryWriteLock(root): + registry = load_registry(root) + duplicate = next( + ( + task + for task in registry.tasks + if task.status in {"planned", "active"} + and pursuit_id in task.pursuit_ids + and (task.action or "").casefold() == resolved_action.casefold() + ), + None, + ) + if duplicate is not None: + return duplicate + registry.tasks.append(record) + _save_registry(root, registry) + return record + + +def run_task( + memory_root: Path, + task_id: str, + *, + project: str | None = None, + model: str | None = None, + reasoning_effort: str | None = None, + sandbox: str = "workspace-write", + runner: CodexSdkRunner | None = None, +) -> TaskRecord: + root = Path(memory_root).expanduser().resolve() + task = _claim_task_for_run(root, task_id, project=_clean_optional(project)) + cwd_value = _clean_optional(project) or task.project + if not cwd_value: + _mutate_task(root, task_id, status="failed", error="Codex task requires a project path") + raise PursuitTaskError("Codex task requires a project path") + cwd = Path(cwd_value).expanduser().resolve() + if not cwd.is_dir(): + message = f"Codex task project does not exist: {cwd}" + _mutate_task(root, task_id, status="failed", error=message) + raise PursuitTaskError(message) + prompt = task.prompt or build_task_prompt( + root, + pursuit_id=task.pursuit_ids[0], + action=task.action, + title=task.title, + project=str(cwd), + )[0] + owns_runner = runner is None + selected_runner = runner or CodexSdkRunner() + + def thread_started(thread_id: str) -> None: + _mutate_task(root, task_id, thread_id=thread_id, provider="codex", status="active") + + try: + result = selected_runner.run_turn( + prompt=prompt, + provider_session_id=None, + cwd=cwd, + model=model, + reasoning_effort=reasoning_effort, + sandbox=sandbox, + on_thread_started=thread_started, + ) + except Exception as exc: + _mutate_task(root, task_id, status="failed", error=f"{type(exc).__name__}: {exc}") + raise + finally: + if owns_runner: + selected_runner.close() + return _mutate_task( + root, + task_id, + thread_id=result.provider_session_id, + provider="codex", + status="completed", + result=result.text, + error=None, + prompt=prompt, + project=str(cwd), + ) + + +def update_task( + memory_root: Path, + task_id: str, + *, + status: str | None = None, + result: str | None = None, + error: str | None = None, + title: str | None = None, +) -> TaskRecord: + fields: dict[str, Any] = {} + if status is not None: + if status not in TASK_STATUSES: + raise PursuitTaskError(f"invalid task status: {status}") + fields["status"] = status + if result is not None: + fields["result"] = result.strip() + if error is not None: + fields["error"] = error.strip() + if title is not None: + fields["title"] = _required_text(title, "title") + return _mutate_task(Path(memory_root).expanduser().resolve(), task_id, **fields) + + +def unlink_task(memory_root: Path, task_id: str, pursuit_id: str | None = None) -> None: + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + registry = load_registry(root) + task = registry.task(task_id) + if pursuit_id is None: + registry.tasks = [item for item in registry.tasks if item.task_id != task_id] + registry.reconciliations = [ + item for item in registry.reconciliations if item.task_id != task_id + ] + else: + task.pursuit_ids = [item for item in task.pursuit_ids if item != pursuit_id] + if not task.pursuit_ids: + registry.tasks = [item for item in registry.tasks if item.task_id != task_id] + registry.reconciliations = [ + item for item in registry.reconciliations if item.task_id != task_id + ] + else: + task.updated_at = datetime.now(UTC).isoformat() + _save_registry(root, registry) + + +def detach_pursuit(memory_root: Path, pursuit_id: str) -> None: + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + registry = load_registry(root) + removed_task_ids: set[str] = set() + retained: list[TaskRecord] = [] + for task in registry.tasks: + task.pursuit_ids = [item for item in task.pursuit_ids if item != pursuit_id] + if task.pursuit_ids: + retained.append(task) + else: + removed_task_ids.add(task.task_id) + registry.tasks = retained + registry.reconciliations = [ + item for item in registry.reconciliations if item.task_id not in removed_task_ids + ] + _save_registry(root, registry) + + +def propose_reconciliation( + memory_root: Path, + *, + task_id: str, + summary: str, + operations: Iterable[dict[str, Any]], + expected_revision: str | None = None, +) -> ReconciliationRecord: + from .pursuit_workspace import PursuitEditor, preview_operations + + root = Path(memory_root).expanduser().resolve() + operation_list = [dict(item) for item in operations] + base_revision = expected_revision or PursuitEditor(root).revision() + preview_operations(root, operation_list, expected_revision=base_revision) + now = datetime.now(UTC).isoformat() + record = ReconciliationRecord( + reconciliation_id=_new_id("recon"), + task_id=task_id, + summary=_required_text(summary, "summary"), + operations=operation_list, + base_revision=base_revision, + created_at=now, + updated_at=now, + ) + with MemoryWriteLock(root): + registry = load_registry(root) + registry.task(task_id) + existing = next( + ( + item + for item in registry.reconciliations + if item.task_id == task_id and item.status == "pending" + ), + None, + ) + if existing is not None: + existing.summary = record.summary + existing.operations = record.operations + existing.base_revision = record.base_revision + existing.updated_at = now + record = existing + else: + registry.reconciliations.append(record) + _save_registry(root, registry) + return record + + +def apply_reconciliation( + memory_root: Path, + reconciliation_id: str, + *, + commit: bool = False, +) -> dict[str, Any]: + from .pursuit_workspace import ( + PursuitEditor, + PursuitWorkspaceError, + _commit_files, + _record_history, + _require_clean_git_paths, + _write_file_transaction, + preview_operations, + ) + + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + registry = load_registry(root) + reconciliation = registry.reconciliation(reconciliation_id) + if reconciliation.status != "pending": + raise PursuitTaskError("only pending reconciliation can be applied") + for operation in reconciliation.operations: + if operation.get("op") == "delete": + raise PursuitTaskError( + "reconciliation cannot delete a Pursuit while task history is linked; " + "resolve task links and remove the Pursuit explicitly" + ) + + preview = preview_operations( + root, + reconciliation.operations, + expected_revision=reconciliation.base_revision, + ) + before = PursuitEditor(root)._current_pursuit_files() + if commit: + _require_clean_git_paths(root, (*preview.changed_files, *preview.removed_files)) + registry_path = root / REGISTRY_PATH + registry_before = registry_path.read_bytes() if registry_path.is_file() else None + gitignore_path = root / ".gitignore" + gitignore_before = gitignore_path.read_bytes() if gitignore_path.is_file() else None + + try: + _write_file_transaction(root, preview.files) + actual = PursuitEditor(root) + if actual.revision() != preview.candidate_revision: + raise PursuitWorkspaceError( + "written Pursuit files did not match the validated reconciliation" + ) + reconciliation.status = "applied" + reconciliation.updated_at = datetime.now(UTC).isoformat() + _save_registry(root, registry) + _record_history( + root, + before, + preview.files, + preview.revision, + preview.candidate_revision, + ) + except Exception: + _write_file_transaction(root, before) + _restore_optional_file(registry_path, registry_before) + _restore_optional_file(gitignore_path, gitignore_before) + raise + + commit_paths: list[str] = [*preview.changed_files, *preview.removed_files, REGISTRY_PATH.as_posix()] + if gitignore_before != (gitignore_path.read_bytes() if gitignore_path.is_file() else None): + commit_paths.append(".gitignore") + commit_sha = ( + _commit_files(root, commit_paths, f"pursuit: reconcile task {reconciliation.task_id}") + if commit + else None + ) + return { + "reconciliation": reconciliation.to_json(), + "apply": { + "revision": actual.revision(), + "changed_files": list(preview.changed_files), + "removed_files": list(preview.removed_files), + "commit": commit_sha, + "snapshot": actual.snapshot(), + }, + } + + +def dismiss_reconciliation(memory_root: Path, reconciliation_id: str) -> ReconciliationRecord: + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + registry = load_registry(root) + record = registry.reconciliation(reconciliation_id) + record.status = "dismissed" + record.updated_at = datetime.now(UTC).isoformat() + _save_registry(root, registry) + return record + + +def build_task_prompt( + memory_root: Path, + *, + pursuit_id: str, + action: str | None = None, + title: str | None = None, + project: str | None = None, +) -> tuple[str, str, str]: + from .pursuit_workspace import PursuitEditor + + root = Path(memory_root).expanduser().resolve() + editor = PursuitEditor(root) + node = editor.get_node(pursuit_id) + resolved_action = _clean_optional(action) + if resolved_action is None and node.body.next: + resolved_action = node.body.next[0].text + if resolved_action is None: + raise PursuitTaskError("task creation requires an action or a Pursuit Next item") + resolved_title = _clean_optional(title) or f"{node.title}: {resolved_action[:80]}" + ancestor_ids = editor.ancestor_ids(pursuit_id) + ancestor_lines: list[str] = [] + for ancestor_id in ancestor_ids: + ancestor = editor.get_node(ancestor_id) + ancestor_lines.append(f"- {ancestor.title} (`{ancestor_id}`): {ancestor.body.objective}") + next_lines = "\n".join(f"- `{item.kind}` {item.text}" for item in node.body.next) or "- none recorded" + memory_chunks: list[str] = [] + for context_id in [*ancestor_ids, pursuit_id]: + context = _direct_memory_context(editor.manifest, context_id) + if context and context not in memory_chunks: + memory_chunks.append(context) + memory_context = "\n\n".join(memory_chunks) + project_line = _clean_optional(project) or "Use the project path supplied by the task runner." + prompt = f"""You are working on one concrete execution task linked to a RightMemory Pursuit. + +Pursuit +- id: {pursuit_id} +- title: {node.title} +- objective: {node.body.objective or '(not stated)'} +- current state: {node.body.state or '(not stated)'} +- done when: {node.body.done_when or '(not stated)'} +- project: {project_line} + +Ancestor context +{chr(10).join(ancestor_lines) if ancestor_lines else '- none'} + +Current movement +{next_lines} + +Exact task +{resolved_action} + +Relevant durable Memory +{memory_context or '- none directly linked'} + +Work in the supplied project and verify the current repository state before changing it. Keep the implementation scoped to the exact task rather than treating the Pursuit as permission to do every possible follow-up. At the end, report what changed, verification performed, unresolved issues, and the smallest justified update to the Pursuit's State or Next. Do not mark the Pursuit complete merely because this task finishes. +""" + return prompt.strip() + "\n", resolved_action, resolved_title + + +def _direct_memory_context(manifest: Any, pursuit_id: str, limit: int = 8000) -> str: + item = manifest.items.get(pursuit_id) + if item is None: + return "" + chunks: list[str] = [] + for edge_type, target in item.edges: + target_item = manifest.items.get(target) + if target_item is None or target_item.family != "memory" or target_item.block_key is None: + continue + text = _flatten_block(manifest, target_item.block_key).strip() + if text: + chunks.append(f"[{edge_type}:{target}]\n{text}") + value = "\n\n".join(chunks) + return value if len(value) <= limit else value[:limit] + "\n...[truncated]" + + +def _flatten_block(manifest: Any, key: Any) -> str: + block = manifest.blocks[key] + lines = [block.line] if block.kind != "root" else [] + for part in block.logical_parts: + if isinstance(part, tuple): + lines.append(_flatten_block(manifest, part)) + else: + lines.append(part) + return "\n".join(lines) + + + +def _claim_task_for_run(root: Path, task_id: str, *, project: str | None) -> TaskRecord: + with MemoryWriteLock(root): + registry = load_registry(root) + task = registry.task(task_id) + if task.status != "planned": + raise PursuitTaskError("only a planned task can be started") + if task.provider.casefold() != "codex": + raise PursuitTaskError("only Codex tasks can be started by this runner") + task.status = "active" + if project is not None: + task.project = project + task.updated_at = datetime.now(UTC).isoformat() + _save_registry(root, registry) + return task + + +def _mutate_task(root: Path, task_id: str, **changes: Any) -> TaskRecord: + with MemoryWriteLock(root): + registry = load_registry(root) + task = registry.task(task_id) + for key, value in changes.items(): + if not hasattr(task, key): + raise PursuitTaskError(f"unknown task field: {key}") + if value is not None or key in {"error", "result"}: + setattr(task, key, value) + task.updated_at = datetime.now(UTC).isoformat() + _save_registry(root, registry) + return task + + +def _save_registry(root: Path, registry: TaskRegistry) -> None: + registry.validate(root) + _ensure_registry_allowed(root) + path = root / REGISTRY_PATH + if path.is_symlink() or (path.exists() and not path.is_file()): + raise PursuitTaskError(f"{REGISTRY_PATH} must be a regular file") + _atomic_write_bytes(path, _registry_to_toml(registry).encode("utf-8")) + + +def _ensure_registry_allowed(root: Path) -> None: + gitignore = root / ".gitignore" + if gitignore.is_symlink() or (gitignore.exists() and not gitignore.is_file()): + raise PursuitTaskError(".gitignore must be a regular file") + if not gitignore.is_file(): + return + text = gitignore.read_text(encoding="utf-8") + line = f"!{REGISTRY_PATH.as_posix()}" + if any(item.strip() == line for item in text.splitlines()): + return + suffix = "" if text.endswith("\n") or not text else "\n" + updated = text + suffix + line + "\n" + _atomic_write_bytes(gitignore, updated.encode("utf-8")) + + + +def _atomic_write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp" + try: + with temporary.open("wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def _restore_optional_file(path: Path, data: bytes | None) -> None: + if data is None: + path.unlink(missing_ok=True) + else: + _atomic_write_bytes(path, data) + + +def _registry_to_toml(registry: TaskRegistry) -> str: + lines = [f"version = {REGISTRY_VERSION}", ""] + for task in registry.tasks: + lines.append("[[tasks]]") + _append_toml(lines, "task_id", task.task_id) + _append_toml(lines, "provider", task.provider) + _append_toml_array(lines, "pursuit_ids", task.pursuit_ids) + _append_toml(lines, "title", task.title) + _append_toml(lines, "status", task.status) + for key in ("thread_id", "project", "host", "action", "prompt", "result", "error"): + value = getattr(task, key) + if value is not None: + _append_toml(lines, key, value) + _append_toml(lines, "created_at", task.created_at) + _append_toml(lines, "updated_at", task.updated_at) + lines.append("") + for reconciliation in registry.reconciliations: + lines.append("[[reconciliations]]") + _append_toml(lines, "reconciliation_id", reconciliation.reconciliation_id) + _append_toml(lines, "task_id", reconciliation.task_id) + _append_toml(lines, "summary", reconciliation.summary) + _append_toml(lines, "operations_json", json.dumps(reconciliation.operations, ensure_ascii=False, separators=(",", ":"))) + _append_toml(lines, "base_revision", reconciliation.base_revision) + _append_toml(lines, "status", reconciliation.status) + _append_toml(lines, "created_at", reconciliation.created_at) + _append_toml(lines, "updated_at", reconciliation.updated_at) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _append_toml(lines: list[str], key: str, value: str) -> None: + lines.append(f"{key} = {json.dumps(value, ensure_ascii=False)}") + + +def _append_toml_array(lines: list[str], key: str, values: list[str]) -> None: + lines.append(f"{key} = [" + ", ".join(json.dumps(item, ensure_ascii=False) for item in values) + "]") + + +def _task_from_toml(value: object) -> TaskRecord: + if not isinstance(value, dict): + raise PursuitTaskError("task registry entry must be a table") + pursuit_ids = value.get("pursuit_ids") + if not isinstance(pursuit_ids, list) or not all(isinstance(item, str) for item in pursuit_ids): + raise PursuitTaskError("task pursuit_ids must be a list of strings") + return TaskRecord( + task_id=_table_string(value, "task_id"), + provider=_table_string(value, "provider"), + pursuit_ids=list(pursuit_ids), + title=_table_string(value, "title"), + status=_table_string(value, "status"), + thread_id=_table_optional_string(value, "thread_id"), + project=_table_optional_string(value, "project"), + host=_table_optional_string(value, "host"), + action=_table_optional_string(value, "action"), + prompt=_table_optional_string(value, "prompt"), + result=_table_optional_string(value, "result"), + error=_table_optional_string(value, "error"), + created_at=_table_string(value, "created_at"), + updated_at=_table_string(value, "updated_at"), + ) + + +def _reconciliation_from_toml(value: object) -> ReconciliationRecord: + if not isinstance(value, dict): + raise PursuitTaskError("reconciliation registry entry must be a table") + raw_operations = _table_string(value, "operations_json") + try: + operations = json.loads(raw_operations) + except json.JSONDecodeError as exc: + raise PursuitTaskError("reconciliation operations_json is invalid") from exc + if not isinstance(operations, list) or not all(isinstance(item, dict) for item in operations): + raise PursuitTaskError("reconciliation operations must be a list of objects") + return ReconciliationRecord( + reconciliation_id=_table_string(value, "reconciliation_id"), + task_id=_table_string(value, "task_id"), + summary=_table_string(value, "summary"), + operations=operations, + base_revision=_table_string(value, "base_revision"), + status=_table_string(value, "status"), + created_at=_table_string(value, "created_at"), + updated_at=_table_string(value, "updated_at"), + ) + + +def _table_string(value: dict[str, Any], key: str) -> str: + item = value.get(key) + if not isinstance(item, str) or not item.strip(): + raise PursuitTaskError(f"registry field {key} must be a non-empty string") + return item + + +def _table_optional_string(value: dict[str, Any], key: str) -> str | None: + item = value.get(key) + if item is None: + return None + if not isinstance(item, str): + raise PursuitTaskError(f"registry field {key} must be a string") + return item + + +def _check_registry_revision(root: Path, expected: str | None) -> None: + if expected is not None and registry_revision(root) != expected: + raise PursuitTaskError("Pursuit task registry changed since it was loaded") + + +def _clean_pursuit_ids(values: Iterable[str]) -> list[str]: + result: list[str] = [] + for value in values: + item = validate_item_id(_required_text(value, "Pursuit id")) + if item not in result: + result.append(item) + if not result: + raise PursuitTaskError("at least one Pursuit id is required") + return result + + +def _required_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise PursuitTaskError(f"{label} must be a non-empty string") + return value.strip() + + +def _clean_optional(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise PursuitTaskError("optional text value must be a string") + return value.strip() or None + + +def _new_id(prefix: str) -> str: + return f"{prefix}-{uuid4().hex[:12]}" diff --git a/rightmemory/pursuit_web.py b/rightmemory/pursuit_web.py new file mode 100644 index 0000000..8b37ea9 --- /dev/null +++ b/rightmemory/pursuit_web.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import secrets +import webbrowser +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlencode + +import uvicorn +from fastapi import Body, Depends, FastAPI, Header, HTTPException, Response, status + +from .codex_sdk import CodexSdkRunner +from .pursuit_tasks import ( + PursuitTaskError, + apply_reconciliation, + dismiss_reconciliation, + link_task, + list_reconciliations, + list_tasks, + plan_task, + propose_reconciliation, + registry_revision, + run_task, + unlink_task, + update_task, +) +from .pursuit_workspace import ( + PursuitEditor, + PursuitRevisionConflict, + PursuitWorkspaceError, + apply_operations, + preview_operations, + redo, + undo, +) + + +RunnerFactory = Callable[[], CodexSdkRunner] + + +def create_pursuit_app( + memory_root: Path, + *, + access_token: str | None = None, + runner_factory: RunnerFactory | None = None, +) -> FastAPI: + root = Path(memory_root).expanduser().resolve() + token = access_token or secrets.token_urlsafe(32) + static_root = Path(__file__).parent / "pursuit_static" + app = FastAPI(title="RightMemory Pursuit Studio") + app.state.access_token = token + app.state.memory_root = root + + def require_token(authorization: str | None = Header(default=None)) -> None: + scheme, separator, value = (authorization or "").partition(" ") + if not separator or scheme.casefold() != "bearer" or not secrets.compare_digest(value.strip(), token): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid Pursuit Studio token") + + @app.get("/") + def index() -> Response: + return _static_file(static_root / "index.html", "text/html; charset=utf-8") + + @app.get("/static/{asset_name}") + def static_asset(asset_name: str) -> Response: + allowed = {"pursuit.js": "text/javascript; charset=utf-8", "pursuit.css": "text/css; charset=utf-8"} + if asset_name not in allowed: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="static asset not found") + return _static_file(static_root / asset_name, allowed[asset_name]) + + @app.get("/api/workspace", dependencies=[Depends(require_token)]) + def workspace() -> dict[str, Any]: + editor = PursuitEditor(root) + return _ok( + { + "workspace": editor.snapshot(), + "tasks": [task.to_json() for task in list_tasks(root)], + "task_revision": registry_revision(root), + "reconciliations": [record.to_json() for record in list_reconciliations(root)], + } + ) + + @app.post("/api/preview", dependencies=[Depends(require_token)]) + def preview(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return _handle( + lambda: preview_operations( + root, + _operations(payload), + expected_revision=_optional_string(payload, "revision"), + ).to_json() + ) + + @app.post("/api/apply", dependencies=[Depends(require_token)]) + def apply(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + operations = _operations(payload) + _reject_linked_deletions(root, operations) + return _handle( + lambda: apply_operations( + root, + operations, + expected_revision=_optional_string(payload, "revision"), + commit=bool(payload.get("commit", False)), + ).to_json() + ) + + @app.post("/api/undo", dependencies=[Depends(require_token)]) + def undo_api(payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: + return _handle(lambda: undo(root, commit=bool((payload or {}).get("commit", False))).to_json()) + + @app.post("/api/redo", dependencies=[Depends(require_token)]) + def redo_api(payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: + return _handle(lambda: redo(root, commit=bool((payload or {}).get("commit", False))).to_json()) + + @app.post("/api/tasks/link", dependencies=[Depends(require_token)]) + def task_link(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return _handle( + lambda: link_task( + root, + pursuit_ids=_string_list(payload, "pursuit_ids"), + provider=_required_string(payload, "provider"), + thread_id=_required_string(payload, "thread_id"), + title=_required_string(payload, "title"), + project=_optional_string(payload, "project"), + host=_optional_string(payload, "host"), + status=_optional_string(payload, "status") or "active", + expected_revision=_optional_string(payload, "task_revision"), + ).to_json() + ) + + @app.post("/api/tasks/plan", dependencies=[Depends(require_token)]) + def task_plan(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return _handle( + lambda: plan_task( + root, + pursuit_id=_required_string(payload, "pursuit_id"), + action=_optional_string(payload, "action"), + title=_optional_string(payload, "title"), + project=_optional_string(payload, "project"), + host=_optional_string(payload, "host"), + ).to_json() + ) + + @app.post("/api/tasks/{task_id}/run", dependencies=[Depends(require_token)]) + def task_run(task_id: str, payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: + values = payload or {} + + def execute() -> dict[str, Any]: + if runner_factory is None: + record = run_task( + root, + task_id, + project=_optional_string(values, "project"), + model=_optional_string(values, "model"), + reasoning_effort=_optional_string(values, "reasoning_effort"), + sandbox=_optional_string(values, "sandbox") or "workspace-write", + ) + else: + runner = runner_factory() + try: + record = run_task( + root, + task_id, + project=_optional_string(values, "project"), + model=_optional_string(values, "model"), + reasoning_effort=_optional_string(values, "reasoning_effort"), + sandbox=_optional_string(values, "sandbox") or "workspace-write", + runner=runner, + ) + finally: + close = getattr(runner, "close", None) + if callable(close): + close() + return record.to_json() + + return _handle(execute) + + @app.post("/api/tasks/{task_id}/update", dependencies=[Depends(require_token)]) + def task_update(task_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return _handle( + lambda: update_task( + root, + task_id, + status=_optional_string(payload, "status"), + result=_optional_string(payload, "result"), + error=_optional_string(payload, "error"), + title=_optional_string(payload, "title"), + ).to_json() + ) + + @app.post("/api/tasks/{task_id}/unlink", dependencies=[Depends(require_token)]) + def task_unlink(task_id: str, payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: + def execute() -> dict[str, Any]: + unlink_task(root, task_id, _optional_string(payload or {}, "pursuit_id")) + return {"task_id": task_id} + + return _handle(execute) + + @app.post("/api/reconciliations/propose", dependencies=[Depends(require_token)]) + def reconciliation_propose(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return _handle( + lambda: propose_reconciliation( + root, + task_id=_required_string(payload, "task_id"), + summary=_required_string(payload, "summary"), + operations=_operations(payload), + expected_revision=_optional_string(payload, "revision"), + ).to_json() + ) + + @app.post("/api/reconciliations/{reconciliation_id}/apply", dependencies=[Depends(require_token)]) + def reconciliation_apply( + reconciliation_id: str, + payload: dict[str, Any] | None = Body(default=None), + ) -> dict[str, Any]: + return _handle( + lambda: apply_reconciliation( + root, + reconciliation_id, + commit=bool((payload or {}).get("commit", False)), + ) + ) + + @app.post("/api/reconciliations/{reconciliation_id}/dismiss", dependencies=[Depends(require_token)]) + def reconciliation_dismiss(reconciliation_id: str) -> dict[str, Any]: + return _handle(lambda: dismiss_reconciliation(root, reconciliation_id).to_json()) + + return app + + +def serve_pursuit_studio( + memory_root: Path, + *, + host: str = "127.0.0.1", + port: int = 8767, + open_browser: bool = True, +) -> int: + if host not in {"127.0.0.1", "localhost", "::1"}: + raise PursuitWorkspaceError("Pursuit Studio currently binds only to a local loopback host") + if port < 1 or port > 65535: + raise PursuitWorkspaceError("Pursuit Studio port must be between 1 and 65535") + token = secrets.token_urlsafe(32) + app = create_pursuit_app(memory_root, access_token=token) + display_host = "127.0.0.1" if host == "localhost" else ("[::1]" if host == "::1" else host) + url = f"http://{display_host}:{port}/?{urlencode({'token': token})}" + print(f"Pursuit Studio: {url}") + print("The token grants local edit and Codex-task access for this server process.") + if open_browser: + webbrowser.open(url) + uvicorn.run(app, host=host, port=port, log_level="warning") + return 0 + + +def _static_file(path: Path, media_type: str) -> Response: + if not path.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="static asset not found") + return Response(content=path.read_bytes(), media_type=media_type) + + +def _ok(data: Any) -> dict[str, Any]: + return {"ok": True, "data": data} + + +def _handle(callback: Callable[[], Any]) -> dict[str, Any]: + try: + return _ok(callback()) + except PursuitRevisionConflict as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except (PursuitWorkspaceError, PursuitTaskError, ValueError, FileNotFoundError, OSError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +def _operations(payload: dict[str, Any]) -> list[dict[str, Any]]: + value = payload.get("operations") + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise PursuitWorkspaceError("operations must be a list of objects") + return [dict(item) for item in value] + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise PursuitWorkspaceError(f"missing required field: {key}") + return value.strip() + + +def _optional_string(payload: dict[str, Any], key: str) -> str | None: + value = payload.get(key) + if value is None: + return None + if not isinstance(value, str): + raise PursuitWorkspaceError(f"{key} must be a string") + return value.strip() or None + + +def _string_list(payload: dict[str, Any], key: str) -> list[str]: + value = payload.get(key) + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise PursuitWorkspaceError(f"{key} must be a non-empty list of strings") + return [item.strip() for item in value] + + +def _reject_linked_deletions(root: Path, operations: list[dict[str, Any]]) -> None: + for operation in operations: + if operation.get("op") != "delete": + continue + pursuit_id = operation.get("id") + if not isinstance(pursuit_id, str): + continue + tasks = list_tasks(root, pursuit_id) + if tasks: + linked = ", ".join(task.task_id for task in tasks) + raise PursuitTaskError( + f"unlink Pursuit {pursuit_id} from tasks before deletion: {linked}" + ) diff --git a/rightmemory/pursuit_workspace.py b/rightmemory/pursuit_workspace.py new file mode 100644 index 0000000..f5f300a --- /dev/null +++ b/rightmemory/pursuit_workspace.py @@ -0,0 +1,1325 @@ +from __future__ import annotations + +import difflib +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Iterable +from uuid import uuid4 + +from .graph import KNOWN_EDGE_TYPES, GraphManifest, build_graph_manifest, validate_item_id +from .session import MemoryWriteLock + + +_HEADING_TITLE_RE = re.compile(r"^(#{1,4})\s+(.*?)\s+\{(?:F#|#)[A-Za-z0-9_.-]+\}(?:\s*(?:→|->)\s*\[(.*?)\])?\s*$") +_PLAIN_HEADING_RE = re.compile(r"^(#{1,4})\s+(.*?)\s*$") +_FIELD_RE = re.compile(r"^\s*\*\*(State|Next|Done when|Status):\*\*\s*(.*)$", re.IGNORECASE) +_NEXT_RE = re.compile(r"^\s*-\s+`(do|ask|wait)`\s+(.+?)\s*$", re.IGNORECASE) +_HISTORY_LIMIT = 20 +_HISTORY_PATH = Path(".runtime") / "pursuit-studio" / "history.json" + + +class PursuitWorkspaceError(ValueError): + pass + + +class PursuitRevisionConflict(PursuitWorkspaceError): + pass + + +@dataclass(slots=True) +class PursuitNext: + kind: str + text: str + + def __post_init__(self) -> None: + self.kind = self.kind.strip().lower() + self.text = self.text.strip() + if "\n" in self.text or "\r" in self.text or "\x00" in self.text: + raise PursuitWorkspaceError("Next text must be one line") + if self.kind not in {"do", "ask", "wait"}: + raise PursuitWorkspaceError("Next kind must be do, ask, or wait") + if not self.text: + raise PursuitWorkspaceError("Next text must not be empty") + + +@dataclass(slots=True) +class PursuitBody: + objective: str = "" + state: str = "" + next: list[PursuitNext] = field(default_factory=list) + done_when: str = "" + status: str = "active" + extra: str = "" + + +@dataclass(slots=True) +class _Node: + key: str + item_id: str | None + title: str + anchor_kind: str | None + edges: list[tuple[str, str]] + depth: int + document: str + parent_key: str | None + children: list[str] + raw_body: str + body: PursuitBody + traversal_rank: int + source_line: int + dirty_body: bool = False + removed: bool = False + is_focus: bool = False + + @property + def editable(self) -> bool: + return self.item_id is not None and not self.is_focus + + @property + def parked(self) -> bool: + return self.body.status.casefold() == "parked" + + +@dataclass(frozen=True, slots=True) +class PursuitPreview: + revision: str + candidate_revision: str + diff: str + changed_files: tuple[str, ...] + removed_files: tuple[str, ...] + files: dict[str, str | None] + snapshot: dict[str, Any] + + def to_json(self) -> dict[str, Any]: + return { + "revision": self.revision, + "candidate_revision": self.candidate_revision, + "diff": self.diff, + "changed_files": list(self.changed_files), + "removed_files": list(self.removed_files), + "files": dict(self.files), + "snapshot": self.snapshot, + } + + +@dataclass(frozen=True, slots=True) +class PursuitApplyResult: + revision: str + changed_files: tuple[str, ...] + removed_files: tuple[str, ...] + commit: str | None + snapshot: dict[str, Any] + + def to_json(self) -> dict[str, Any]: + return { + "revision": self.revision, + "changed_files": list(self.changed_files), + "removed_files": list(self.removed_files), + "commit": self.commit, + "snapshot": self.snapshot, + } + + +class PursuitEditor: + """Mutable view over canonical Pursuit Markdown backed by graph.py's index.""" + + def __init__(self, memory_root: Path): + self.memory_root = Path(memory_root).expanduser().resolve() + self.manifest = build_graph_manifest(self.memory_root) + if self.manifest.errors: + joined = "\n- ".join(self.manifest.errors) + raise PursuitWorkspaceError(f"RightMemory graph is invalid:\n- {joined}") + self.nodes: dict[str, _Node] = {} + self.block_to_key: dict[tuple[Path, int], str] = {} + self.document_preambles: dict[str, str] = {} + self.document_newlines: dict[str, str] = {} + self.document_order: list[str] = [] + self.root_order: list[str] = [] + self.focus_ids: list[str] = [item_id for item_id, _path, _line in self.manifest.focus_ids] + self._known_item_ids = set(self.manifest.items) + self._top_container_key: str | None = None + self._load() + + @classmethod + def snapshot_from_root(cls, memory_root: Path) -> dict[str, Any]: + return cls(memory_root).snapshot() + + def _load(self) -> None: + pursuit_documents = [ + document + for document in self.manifest.documents.values() + if document.family == "pursuit" + ] + pursuit_documents.sort(key=lambda document: document.source_order) + for document in pursuit_documents: + relative = document.relative_path + self.document_order.append(relative) + self.document_newlines[relative] = "\r\n" if "\r\n" in document.text else "\n" + first_heading = min( + ( + block.line_number + for block in self.manifest.blocks.values() + if block.source_path == document.path and block.kind == "heading" + ), + default=len(document.lines) + 1, + ) + self.document_preambles[relative] = "\n".join(document.lines[: max(0, first_heading - 1)]) + + heading_blocks = [ + block + for block in self.manifest.blocks.values() + if block.kind == "heading" and block.family == "pursuit" + ] + heading_blocks.sort(key=lambda block: (block.traversal_rank if block.traversal_rank >= 0 else 10**9, block.line_number)) + + for block in heading_blocks: + document = self.manifest.documents[block.source_path] + key = block.item_id or f"@{document.relative_path}:{block.line_number}" + if key in self.nodes: + raise PursuitWorkspaceError(f"duplicate Pursuit editor key: {key}") + title = _heading_title(block.line) + raw_body = _body_text(document.lines, block.body_span) + is_focus = block.depth == 2 and title.casefold() == "focus" and block.item_id is None + node = _Node( + key=key, + item_id=block.item_id, + title=title, + anchor_kind=block.anchor_kind, + edges=list(self.manifest.items[block.item_id].edges) if block.item_id else [], + depth=block.depth, + document=document.relative_path, + parent_key=None, + children=[], + raw_body=raw_body, + body=_parse_body(raw_body), + traversal_rank=block.traversal_rank, + source_line=block.line_number, + is_focus=is_focus, + ) + self.nodes[key] = node + self.block_to_key[block.key] = key + if ( + document.relative_path == "PURSUITS.md" + and block.depth == 1 + and title.casefold() == "pursuits" + and block.item_id is None + ): + self._top_container_key = key + + for block_key, key in self.block_to_key.items(): + block = self.manifest.blocks[block_key] + parent = block.logical_parent + while parent is not None and parent not in self.block_to_key: + parent = self.manifest.blocks[parent].logical_parent + parent_key = self.block_to_key.get(parent) if parent is not None else None + self.nodes[key].parent_key = parent_key + + for node in self.nodes.values(): + if node.parent_key is None: + self.root_order.append(node.key) + else: + self.nodes[node.parent_key].children.append(node.key) + + for node in self.nodes.values(): + node.children.sort(key=lambda key: self.nodes[key].traversal_rank) + self.root_order.sort(key=lambda key: self.nodes[key].traversal_rank) + + if self._top_container_key is None: + self._top_container_key = self._create_plain_top_container() + self._ensure_focus_node() + + def _create_plain_top_container(self) -> str: + key = "@PURSUITS.md:virtual-root" + node = _Node( + key=key, + item_id=None, + title="Pursuits", + anchor_kind=None, + edges=[], + depth=1, + document="PURSUITS.md", + parent_key=None, + children=[], + raw_body="", + body=PursuitBody(), + traversal_rank=-1, + source_line=0, + ) + self.nodes[key] = node + self.root_order.insert(0, key) + return key + + + def _ensure_focus_node(self) -> None: + if any(node.is_focus and not node.removed for node in self.nodes.values()): + return + assert self._top_container_key is not None + parent = self.nodes[self._top_container_key] + key = "@PURSUITS.md:virtual-focus" + node = _Node( + key=key, + item_id=None, + title="Focus", + anchor_kind=None, + edges=[], + depth=parent.depth + 1, + document=parent.document, + parent_key=parent.key, + children=[], + raw_body="", + body=PursuitBody(), + traversal_rank=-1, + source_line=0, + is_focus=True, + ) + self.nodes[key] = node + parent.children.insert(0, key) + + def snapshot(self) -> dict[str, Any]: + editable_nodes = [node for node in self.nodes.values() if node.editable and not node.removed] + editable_nodes.sort(key=lambda node: (node.traversal_rank if node.traversal_rank >= 0 else 10**9, node.item_id or "")) + tasks_by_pursuit: dict[str, list[dict[str, Any]]] = {} + try: + from .pursuit_tasks import list_tasks + + for task in list_tasks(self.memory_root): + for pursuit_id in task.pursuit_ids: + tasks_by_pursuit.setdefault(pursuit_id, []).append(task.to_json()) + except (FileNotFoundError, OSError, UnicodeError): + tasks_by_pursuit = {} + + return { + "revision": self.revision(), + "focus_ids": list(self.focus_ids), + "nodes": [self._node_json(node, tasks_by_pursuit.get(node.item_id or "", [])) for node in editable_nodes], + "roots": [ + node.item_id + for node in editable_nodes + if self._nearest_editable_parent(node.key) is None + ], + "documents": sorted(self._current_pursuit_files()), + "edge_types": sorted(KNOWN_EDGE_TYPES), + } + + def _node_json(self, node: _Node, tasks: list[dict[str, Any]]) -> dict[str, Any]: + assert node.item_id is not None + parent_id = self._nearest_editable_parent(node.key) + children = [self.nodes[key].item_id for key in self._editable_children(node.key)] + return { + "id": node.item_id, + "title": node.title, + "parent_id": parent_id, + "children": children, + "objective": node.body.objective, + "state": node.body.state, + "next": [asdict(item) for item in node.body.next], + "done_when": node.body.done_when, + "status": node.body.status, + "parked": node.parked, + "focused": node.item_id in self.focus_ids, + "edges": [{"type": edge_type, "target": target} for edge_type, target in node.edges], + "backing": node.anchor_kind == "F#", + "document": node.document, + "depth": node.depth, + "tasks": tasks, + } + + def _editable_children(self, key: str) -> list[str]: + result: list[str] = [] + for child_key in self.nodes[key].children: + child = self.nodes[child_key] + if child.removed: + continue + if child.editable: + result.append(child_key) + else: + result.extend(self._editable_children(child_key)) + return result + + def _nearest_editable_parent(self, key: str) -> str | None: + parent_key = self.nodes[key].parent_key + while parent_key is not None: + parent = self.nodes[parent_key] + if parent.editable and not parent.removed: + return parent.item_id + parent_key = parent.parent_key + return None + + def revision(self) -> str: + return _files_revision(self._current_pursuit_files()) + + def _current_pursuit_files(self) -> dict[str, str | None]: + paths: set[Path] = {self.memory_root / "PURSUITS.md"} + for document in self.manifest.documents.values(): + if document.family == "pursuit": + paths.add(document.path) + return { + _relative(self.memory_root, path): _read_text_exact(path) if path.is_file() else None + for path in sorted(paths) + } + + def get_node(self, item_id: str) -> _Node: + node = self.nodes.get(item_id) + if node is None or not node.editable or node.removed: + raise PursuitWorkspaceError(f"unknown Pursuit id: {item_id}") + return node + + def ancestor_ids(self, item_id: str) -> list[str]: + node = self.get_node(item_id) + result: list[str] = [] + parent_key = node.parent_key + while parent_key is not None: + parent = self.nodes[parent_key] + if parent.editable and parent.item_id and not parent.removed: + result.append(parent.item_id) + parent_key = parent.parent_key + result.reverse() + return result + + def apply_operations(self, operations: Iterable[dict[str, Any]]) -> None: + for raw_operation in operations: + if not isinstance(raw_operation, dict): + raise PursuitWorkspaceError("each operation must be an object") + operation = str(raw_operation.get("op", "")).strip() + if operation == "create": + self._op_create(raw_operation) + elif operation == "update": + self._op_update(raw_operation) + elif operation == "move": + self._op_move(raw_operation) + elif operation == "reorder": + self._op_reorder(raw_operation) + elif operation == "delete": + self._op_delete(raw_operation) + elif operation == "set_focus": + self._op_set_focus(raw_operation) + elif operation == "park": + self._op_park(raw_operation, parked=True) + elif operation == "unpark": + self._op_park(raw_operation, parked=False) + elif operation == "split_file": + self._op_split_file(raw_operation) + elif operation == "inline_file": + self._op_inline_file(raw_operation) + else: + raise PursuitWorkspaceError(f"unknown Pursuit operation: {operation or ''}") + self._validate_model() + + def _op_create(self, operation: dict[str, Any]) -> None: + item_id = validate_item_id(_required_str(operation, "id")) + if item_id in self._known_item_ids or item_id in self.nodes: + raise PursuitWorkspaceError(f"Pursuit id already exists: {item_id}") + title = _title_text(_required_str(operation, "title")) + parent_key = self._resolve_parent_key(operation.get("parent_id")) + parent = self.nodes[parent_key] + depth = parent.depth + 1 + if depth > 3: + raise PursuitWorkspaceError( + "ordinary Pursuit headings cannot be deeper than ###" + ) + document = _child_document(parent) + body = _body_from_operation(operation, base=PursuitBody()) + edges = _edges_from_value(operation.get("edges", [])) + node = _Node( + key=item_id, + item_id=item_id, + title=title, + anchor_kind="#", + edges=edges, + depth=depth, + document=document, + parent_key=parent_key, + children=[], + raw_body="", + body=body, + traversal_rank=10**9 + len(self.nodes), + source_line=0, + dirty_body=True, + ) + self.nodes[item_id] = node + self._known_item_ids.add(item_id) + index = _optional_index(operation.get("index"), len(parent.children)) + parent.children.insert(index, item_id) + + def _op_update(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + if "title" in operation: + node.title = _title_text(_non_empty_str(operation["title"], "title")) + if "edges" in operation: + node.edges = _edges_from_value(operation["edges"]) + new_body = _body_from_operation(operation, base=node.body) + if new_body != node.body: + node.body = new_body + node.dirty_body = True + if node.parked and node.item_id in self.focus_ids: + self.focus_ids = [item_id for item_id in self.focus_ids if item_id != node.item_id] + + def _op_move(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + new_parent_key = self._resolve_parent_key(operation.get("parent_id")) + if new_parent_key == node.key or self._is_descendant(new_parent_key, node.key): + raise PursuitWorkspaceError("cannot move a Pursuit inside its own subtree") + new_parent = self.nodes[new_parent_key] + new_depth = new_parent.depth + 1 + delta = new_depth - node.depth + for target_key in self._subtree_keys(node.key): + target = self.nodes[target_key] + candidate_depth = target.depth + delta + if candidate_depth > 4: + raise PursuitWorkspaceError("move would make a Pursuit heading deeper than ####") + if candidate_depth == 4 and ( + target.anchor_kind != "F#" + or any(not self.nodes[child].removed for child in target.children) + ): + raise PursuitWorkspaceError( + "#### is reserved for a terminal F# Pursuit reference" + ) + self._remove_from_parent(node) + node.parent_key = new_parent_key + index = _optional_index(operation.get("index"), len(new_parent.children)) + new_parent.children.insert(index, node.key) + self._shift_depth(node.key, delta) + self._assign_subtree_document(node.key, _child_document(new_parent)) + + def _op_reorder(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + parent = self.nodes[node.parent_key] if node.parent_key else None + siblings = parent.children if parent is not None else self.root_order + if node.key not in siblings: + raise PursuitWorkspaceError("Pursuit is not present in its parent order") + siblings.remove(node.key) + siblings.insert(_optional_index(operation.get("index"), len(siblings)), node.key) + + def _op_delete(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + descendants = [key for key in self._subtree_keys(node.key) if key != node.key and self.nodes[key].editable] + if descendants and not bool(operation.get("cascade", False)): + raise PursuitWorkspaceError("deleting a Pursuit with children requires cascade=true") + self._remove_from_parent(node) + removed_ids: set[str] = set() + for key in self._subtree_keys(node.key): + target = self.nodes[key] + target.removed = True + if target.item_id: + removed_ids.add(target.item_id) + self.focus_ids = [item_id for item_id in self.focus_ids if item_id not in removed_ids] + + def _op_set_focus(self, operation: dict[str, Any]) -> None: + value = operation.get("ids") + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise PursuitWorkspaceError("set_focus ids must be a list of Pursuit ids") + clean: list[str] = [] + for item_id in value: + node = self.get_node(item_id.strip()) + if node.parked: + raise PursuitWorkspaceError(f"parked Pursuit cannot be focused: {item_id}") + if node.item_id not in clean: + clean.append(node.item_id or "") + self.focus_ids = clean + + def _op_park(self, operation: dict[str, Any], *, parked: bool) -> None: + node = self.get_node(_required_str(operation, "id")) + node.body = PursuitBody( + objective=node.body.objective, + state=node.body.state, + next=list(node.body.next), + done_when=node.body.done_when, + status="parked" if parked else "active", + extra=node.body.extra, + ) + node.dirty_body = True + if parked and node.item_id in self.focus_ids: + self.focus_ids = [item_id for item_id in self.focus_ids if item_id != node.item_id] + + def _op_split_file(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + if node.anchor_kind == "F#": + return + assert node.item_id is not None + backing = f"PURSUIT_{node.item_id}.md" + if (self.memory_root / backing).exists() and backing not in self.document_preambles: + raise PursuitWorkspaceError(f"backing file already exists: {backing}") + node.anchor_kind = "F#" + self.document_preambles.setdefault(backing, "") + self.document_newlines.setdefault(backing, self.document_newlines.get(node.document, "\n")) + if backing not in self.document_order: + self.document_order.append(backing) + for child_key in node.children: + self._assign_subtree_document(child_key, backing) + + def _op_inline_file(self, operation: dict[str, Any]) -> None: + node = self.get_node(_required_str(operation, "id")) + if node.anchor_kind != "F#": + return + if node.depth == 4: + raise PursuitWorkspaceError( + "a terminal #### F# Pursuit cannot be inlined as an ordinary heading" + ) + assert node.item_id is not None + backing = f"PURSUIT_{node.item_id}.md" + if self.document_preambles.get(backing, "").strip(): + raise PursuitWorkspaceError( + f"cannot inline {backing} because it has document-level prose outside child Pursuits" + ) + node.anchor_kind = "#" + for child_key in node.children: + self._assign_subtree_document(child_key, node.document) + + def _resolve_parent_key(self, parent_id: object) -> str: + if parent_id is None or (isinstance(parent_id, str) and not parent_id.strip()): + assert self._top_container_key is not None + return self._top_container_key + if not isinstance(parent_id, str): + raise PursuitWorkspaceError("parent_id must be a Pursuit id or null") + return self.get_node(parent_id.strip()).key + + def _remove_from_parent(self, node: _Node) -> None: + if node.parent_key is None: + if node.key in self.root_order: + self.root_order.remove(node.key) + else: + children = self.nodes[node.parent_key].children + if node.key in children: + children.remove(node.key) + + def _is_descendant(self, candidate_key: str, ancestor_key: str) -> bool: + current: str | None = candidate_key + while current is not None: + if current == ancestor_key: + return True + current = self.nodes[current].parent_key + return False + + def _subtree_keys(self, key: str) -> list[str]: + result: list[str] = [] + + def visit(current: str) -> None: + result.append(current) + for child in self.nodes[current].children: + visit(child) + + visit(key) + return result + + def _shift_depth(self, key: str, delta: int) -> None: + for target_key in self._subtree_keys(key): + self.nodes[target_key].depth += delta + + def _assign_subtree_document(self, key: str, document: str) -> None: + node = self.nodes[key] + node.document = document + if node.anchor_kind == "F#" and node.item_id: + child_document = f"PURSUIT_{node.item_id}.md" + self.document_preambles.setdefault(child_document, "") + self.document_newlines.setdefault(child_document, self.document_newlines.get(document, "\n")) + if child_document not in self.document_order: + self.document_order.append(child_document) + else: + child_document = document + for child_key in node.children: + self._assign_subtree_document(child_key, child_document) + + def _validate_model(self) -> None: + active_ids = {node.item_id for node in self.nodes.values() if node.editable and not node.removed} + if None in active_ids: + active_ids.remove(None) + if len(active_ids) != len([node for node in self.nodes.values() if node.editable and not node.removed]): + raise PursuitWorkspaceError("Pursuit ids must remain unique") + if len(self.focus_ids) != len(set(self.focus_ids)): + raise PursuitWorkspaceError("Focus ids must remain unique") + for item_id in self.focus_ids: + node = self.get_node(item_id) + if node.parked: + raise PursuitWorkspaceError(f"parked Pursuit cannot be focused: {item_id}") + for node in self.nodes.values(): + if node.removed: + continue + if node.depth < 1 or node.depth > 4: + raise PursuitWorkspaceError(f"invalid heading depth for {node.key}: {node.depth}") + if node.depth == 4 and ( + node.anchor_kind != "F#" + or any(not self.nodes[child].removed for child in node.children) + ): + raise PursuitWorkspaceError( + f"#### is reserved for a terminal F# Pursuit reference: {node.key}" + ) + if node.editable: + if not node.title.strip(): + raise PursuitWorkspaceError(f"Pursuit title must not be empty: {node.item_id}") + seen_edges: set[tuple[str, str]] = set() + for edge_type, target in node.edges: + if edge_type not in KNOWN_EDGE_TYPES: + raise PursuitWorkspaceError(f"unknown edge type: {edge_type}") + if target == node.item_id: + raise PursuitWorkspaceError(f"self edge is not allowed: {edge_type}:{target}") + if (edge_type, target) in seen_edges: + raise PursuitWorkspaceError(f"duplicate edge: {edge_type}:{target}") + seen_edges.add((edge_type, target)) + if target not in self._known_item_ids: + raise PursuitWorkspaceError(f"edge target does not exist: {target}") + if node.anchor_kind == "F#" and node.item_id: + expected = f"PURSUIT_{node.item_id}.md" + for child_key in node.children: + if self.nodes[child_key].document != expected: + raise PursuitWorkspaceError( + f"children of F# Pursuit {node.item_id} must live in {expected}" + ) + + def render_files(self) -> dict[str, str | None]: + self._validate_model() + files: dict[str, str | None] = {} + active_documents = {node.document for node in self.nodes.values() if not node.removed} + active_documents.add("PURSUITS.md") + for document in sorted(active_documents, key=self._document_sort_key): + roots = self._document_roots(document) + preamble = self.document_preambles.get(document, "") + chunks: list[str] = [] + if preamble.strip(): + chunks.append(preamble.strip("\n")) + for key in roots: + chunks.append(self._render_node(key, document).strip("\n")) + text = "\n\n".join(chunk for chunk in chunks if chunk).rstrip() + "\n" + newline = self.document_newlines.get(document, self.document_newlines.get("PURSUITS.md", "\n")) + files[document] = text if newline == "\n" else text.replace("\n", newline) + + current_files = self._current_pursuit_files() + for document in current_files: + if document != "PURSUITS.md" and document not in active_documents: + files[document] = None + return files + + def _document_sort_key(self, document: str) -> tuple[int, str]: + try: + return (self.document_order.index(document), document) + except ValueError: + return (10**9, document) + + def _document_roots(self, document: str) -> list[str]: + ordered: list[str] = [] + seen: set[str] = set() + + def visit(key: str) -> None: + node = self.nodes[key] + if node.removed: + return + parent = self.nodes[node.parent_key] if node.parent_key else None + if node.document == document and (parent is None or parent.document != document): + if key not in seen: + ordered.append(key) + seen.add(key) + for child in node.children: + visit(child) + + for key in self.root_order: + visit(key) + return ordered + + def _render_node(self, key: str, document: str) -> str: + node = self.nodes[key] + if node.removed or node.document != document: + return "" + lines = [self._heading_line(node)] + body = self._body_for_render(node) + if body.strip(): + lines.extend(["", body.strip("\n")]) + for child_key in node.children: + child = self.nodes[child_key] + if child.removed or child.document != document: + continue + rendered = self._render_node(child_key, document) + if rendered: + lines.extend(["", rendered.strip("\n")]) + return "\n".join(lines) + + def _heading_line(self, node: _Node) -> str: + prefix = "#" * node.depth + if node.item_id is None: + return f"{prefix} {node.title}" + marker = "F#" if node.anchor_kind == "F#" else "#" + line = f"{prefix} {node.title} {{{marker}{node.item_id}}}" + if node.edges: + line += " → [" + ", ".join(f"{edge_type}:{target}" for edge_type, target in node.edges) + "]" + return line + + def _body_for_render(self, node: _Node) -> str: + if node.is_focus: + if not self.focus_ids: + return "No Pursuit is focused yet." + return "\n".join(f"- `{item_id}`" for item_id in self.focus_ids) + if not node.dirty_body: + return node.raw_body + return _render_body(node.body) + + +def preview_operations( + memory_root: Path, + operations: Iterable[dict[str, Any]], + *, + expected_revision: str | None = None, +) -> PursuitPreview: + editor = PursuitEditor(memory_root) + revision = editor.revision() + if expected_revision is not None and expected_revision != revision: + raise PursuitRevisionConflict("Pursuit files changed since the workspace was loaded") + before = editor._current_pursuit_files() + editor.apply_operations(operations) + after = editor.render_files() + _validate_candidate(editor.memory_root, editor.manifest, after) + changed = tuple(sorted(path for path, text in after.items() if before.get(path) != text and text is not None)) + removed = tuple(sorted(path for path, text in after.items() if text is None and before.get(path) is not None)) + diff = _files_diff(before, after) + candidate_revision = _files_revision(after) + return PursuitPreview( + revision=revision, + candidate_revision=candidate_revision, + diff=diff, + changed_files=changed, + removed_files=removed, + files=after, + snapshot=editor.snapshot() | {"revision": candidate_revision}, + ) + + +def apply_operations( + memory_root: Path, + operations: Iterable[dict[str, Any]], + *, + expected_revision: str | None = None, + commit: bool = False, + commit_message: str = "pursuit: edit via Pursuit Studio", +) -> PursuitApplyResult: + root = Path(memory_root).expanduser().resolve() + operation_list = [dict(operation) for operation in operations] + with MemoryWriteLock(root): + preview = preview_operations(root, operation_list, expected_revision=expected_revision) + before = PursuitEditor(root)._current_pursuit_files() + if commit: + _require_clean_git_paths(root, (*preview.changed_files, *preview.removed_files)) + _write_file_transaction(root, preview.files) + actual = PursuitEditor(root) + if actual.revision() != preview.candidate_revision: + _write_file_transaction(root, before) + raise PursuitWorkspaceError("written Pursuit files did not match the validated candidate") + _record_history(root, before, preview.files, preview.revision, preview.candidate_revision) + commit_sha = _commit_files(root, (*preview.changed_files, *preview.removed_files), commit_message) if commit else None + return PursuitApplyResult( + revision=actual.revision(), + changed_files=preview.changed_files, + removed_files=preview.removed_files, + commit=commit_sha, + snapshot=actual.snapshot(), + ) + + +def undo(memory_root: Path, *, commit: bool = False) -> PursuitApplyResult: + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + history = _load_history(root) + cursor = int(history.get("cursor", 0)) + entries = history.get("entries", []) + if cursor <= 0 or not isinstance(entries, list): + raise PursuitWorkspaceError("nothing to undo") + entry = entries[cursor - 1] + current = PursuitEditor(root) + if current.revision() != entry["after_revision"]: + raise PursuitRevisionConflict("Pursuit files changed after the recorded edit; undo refused") + before = _decode_history_files(entry["before"]) + _validate_candidate(root, current.manifest, before) + current_files = current._current_pursuit_files() + changed, removed = _changed_paths(current_files, before) + if commit: + _require_clean_git_paths(root, (*changed, *removed)) + _write_file_transaction(root, before) + history["cursor"] = cursor - 1 + _save_history(root, history) + restored = PursuitEditor(root) + commit_sha = _commit_files(root, (*changed, *removed), "pursuit: undo Pursuit Studio edit") if commit else None + return PursuitApplyResult(restored.revision(), changed, removed, commit_sha, restored.snapshot()) + + +def redo(memory_root: Path, *, commit: bool = False) -> PursuitApplyResult: + root = Path(memory_root).expanduser().resolve() + with MemoryWriteLock(root): + history = _load_history(root) + cursor = int(history.get("cursor", 0)) + entries = history.get("entries", []) + if not isinstance(entries, list) or cursor >= len(entries): + raise PursuitWorkspaceError("nothing to redo") + entry = entries[cursor] + current = PursuitEditor(root) + if current.revision() != entry["before_revision"]: + raise PursuitRevisionConflict("Pursuit files changed after undo; redo refused") + after = _decode_history_files(entry["after"]) + _validate_candidate(root, current.manifest, after) + current_files = current._current_pursuit_files() + changed, removed = _changed_paths(current_files, after) + if commit: + _require_clean_git_paths(root, (*changed, *removed)) + _write_file_transaction(root, after) + history["cursor"] = cursor + 1 + _save_history(root, history) + restored = PursuitEditor(root) + commit_sha = _commit_files(root, (*changed, *removed), "pursuit: redo Pursuit Studio edit") if commit else None + return PursuitApplyResult(restored.revision(), changed, removed, commit_sha, restored.snapshot()) + + +def _heading_title(line: str) -> str: + match = _HEADING_TITLE_RE.match(line) + if match is not None: + return match.group(2).strip() + plain = _PLAIN_HEADING_RE.match(line) + if plain is not None: + return plain.group(2).strip() + raise PursuitWorkspaceError(f"could not parse Pursuit heading: {line}") + + +def _body_text(lines: tuple[str, ...], span: Any | None) -> str: + if span is None: + return "" + return "\n".join(lines[span.start_line - 1 : span.end_line]) + + +def _parse_body(raw: str) -> PursuitBody: + lines = raw.splitlines() + fields: list[tuple[int, str, str]] = [] + for index, line in enumerate(lines): + match = _FIELD_RE.match(line) + if match is not None: + fields.append((index, match.group(1).casefold(), match.group(2))) + first_field = fields[0][0] if fields else len(lines) + objective = "\n".join(lines[:first_field]).strip() + state = "" + next_items: list[PursuitNext] = [] + done_when = "" + status = "active" + extra_chunks: list[str] = [] + for position, (index, name, inline) in enumerate(fields): + end = fields[position + 1][0] if position + 1 < len(fields) else len(lines) + section_lines = ([inline] if inline else []) + lines[index + 1 : end] + if name == "next": + leftovers: list[str] = [] + for line in section_lines: + if not line.strip(): + continue + match = _NEXT_RE.match(line) + if match is not None: + next_items.append(PursuitNext(match.group(1), match.group(2))) + elif next_items and (line.startswith(" ") or line.startswith("\t")): + next_items[-1].text = f"{next_items[-1].text} {line.strip()}" + else: + leftovers.append(line) + if leftovers: + extra_chunks.append("\n".join(leftovers).strip()) + else: + value = "\n".join(section_lines).strip() + if name == "state": + state = value + elif name == "done when": + done_when = value + elif name == "status": + status = value.casefold() or "active" + if status not in {"active", "parked"}: + status = status.strip() or "active" + return PursuitBody(objective, state, next_items, done_when, status, "\n\n".join(chunk for chunk in extra_chunks if chunk)) + + +def _render_body(body: PursuitBody) -> str: + chunks: list[str] = [] + if body.objective.strip(): + chunks.append(body.objective.strip()) + if body.state.strip(): + chunks.append(f"**State:** {body.state.strip()}") + if body.next: + chunks.append("**Next:**\n" + "\n".join(f"- `{item.kind}` {item.text}" for item in body.next)) + if body.done_when.strip(): + chunks.append(f"**Done when:** {body.done_when.strip()}") + if body.status.casefold() == "parked": + chunks.append("**Status:** parked") + if body.extra.strip(): + chunks.append(body.extra.strip()) + return "\n\n".join(chunks) + + +def _body_from_operation(operation: dict[str, Any], *, base: PursuitBody) -> PursuitBody: + objective = _optional_text(operation, "objective", base.objective) + state = _optional_text(operation, "state", base.state) + done_when = _optional_text(operation, "done_when", base.done_when) + status = _optional_text(operation, "status", base.status).casefold() or "active" + if status not in {"active", "parked"}: + raise PursuitWorkspaceError("Pursuit status must be active or parked") + extra = _optional_text(operation, "extra", base.extra) + if "next" in operation: + next_value = operation["next"] + if not isinstance(next_value, list): + raise PursuitWorkspaceError("next must be a list") + next_items: list[PursuitNext] = [] + for item in next_value: + if isinstance(item, dict): + next_items.append(PursuitNext(_required_str(item, "kind"), _required_str(item, "text"))) + elif isinstance(item, str): + kind, separator, text = item.partition(":") + if not separator: + raise PursuitWorkspaceError("string Next entries must use kind:text") + next_items.append(PursuitNext(kind, text)) + else: + raise PursuitWorkspaceError("Next entries must be objects or kind:text strings") + else: + next_items = list(base.next) + for label, text in (("objective", objective), ("state", state), ("done_when", done_when), ("extra", extra)): + _validate_structured_body_text(text, label) + return PursuitBody(objective, state, next_items, done_when, status, extra) + + +def _edges_from_value(value: object) -> list[tuple[str, str]]: + if value is None: + return [] + if not isinstance(value, list): + raise PursuitWorkspaceError("edges must be a list") + result: list[tuple[str, str]] = [] + for item in value: + if isinstance(item, dict): + edge_type = _required_str(item, "type") + target = validate_item_id(_required_str(item, "target")) + elif isinstance(item, str): + edge_type, separator, target = item.partition(":") + if not separator: + raise PursuitWorkspaceError("string edges must use type:target") + edge_type = edge_type.strip() + target = validate_item_id(target.strip()) + else: + raise PursuitWorkspaceError("edge entries must be objects or type:target strings") + result.append((edge_type, target)) + return result + + + +def _title_text(value: str) -> str: + value = value.strip() + if any(character in value for character in "\r\n\x00{}"): + raise PursuitWorkspaceError("Pursuit title must be one line and must not contain braces") + return value + + +def _validate_structured_body_text(value: str, label: str) -> None: + if "\x00" in value: + raise PursuitWorkspaceError(f"{label} must not contain NUL") + for line in value.splitlines(): + if re.match(r"^ {0,3}#{1,4}(?:\s|$)", line): + raise PursuitWorkspaceError(f"{label} must not introduce Markdown headings") + if re.match(r"^ {0,3}(?:`{3,}|~{3,})", line): + raise PursuitWorkspaceError(f"{label} must not introduce fenced blocks") + if _FIELD_RE.match(line): + raise PursuitWorkspaceError(f"{label} must not introduce Pursuit field markers") + if re.match(r"^\s*-\s+`[^`]+`.*(?:→|->)\s*\[", line): + raise PursuitWorkspaceError(f"{label} must not introduce graph nodes") + + +def _required_str(payload: dict[str, Any], key: str) -> str: + if key not in payload: + raise PursuitWorkspaceError(f"missing required field: {key}") + return _non_empty_str(payload[key], key) + + +def _non_empty_str(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise PursuitWorkspaceError(f"{label} must be a non-empty string") + return value.strip() + + +def _optional_text(payload: dict[str, Any], key: str, default: str) -> str: + if key not in payload: + return default + value = payload[key] + if value is None: + return "" + if not isinstance(value, str): + raise PursuitWorkspaceError(f"{key} must be a string") + return value.strip() + + +def _optional_index(value: object, length: int) -> int: + if value is None: + return length + if isinstance(value, bool) or not isinstance(value, int): + raise PursuitWorkspaceError("index must be an integer") + return max(0, min(value, length)) + + +def _child_document(parent: _Node) -> str: + if parent.anchor_kind == "F#" and parent.item_id: + return f"PURSUIT_{parent.item_id}.md" + return parent.document + + +def _validate_candidate(root: Path, manifest: GraphManifest, pursuit_files: dict[str, str | None]) -> None: + runtime = root / ".runtime" / "pursuit-studio" + runtime.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="candidate-", dir=runtime) as tempdir: + candidate = Path(tempdir) + for path in manifest.files: + if not path.is_file() or path.is_symlink(): + continue + relative = _relative(root, path) + target = candidate / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) + for relative, text in pursuit_files.items(): + target = candidate / relative + if text is None: + target.unlink(missing_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8", newline="") + candidate_manifest = build_graph_manifest(candidate) + if candidate_manifest.errors: + joined = "\n- ".join(candidate_manifest.errors) + raise PursuitWorkspaceError(f"candidate Pursuit graph is invalid:\n- {joined}") + _validate_candidate_task_links(root, candidate_manifest) + + + +def _validate_candidate_task_links(root: Path, candidate_manifest: GraphManifest) -> None: + registry_path = root / "pursuit_tasks.toml" + if not registry_path.exists(): + return + from .pursuit_tasks import load_registry + + registry = load_registry(root) + candidate_ids = { + item.id + for item in candidate_manifest.items.values() + if item.family == "pursuit" and item.item_kind == "heading" + } + for task in registry.tasks: + missing = [item_id for item_id in task.pursuit_ids if item_id not in candidate_ids] + if missing: + joined = ", ".join(missing) + raise PursuitWorkspaceError( + f"unlink Pursuit {joined} from task {task.task_id} before removing it" + ) + + +def _read_text_exact(path: Path) -> str: + with path.open("r", encoding="utf-8", newline="") as handle: + return handle.read() + + +def _files_revision(files: dict[str, str | None]) -> str: + digest = hashlib.sha256() + for path in sorted(path for path, value in files.items() if value is not None): + digest.update(path.encode("utf-8")) + digest.update(b"\0") + value = files[path] + assert value is not None + digest.update(value.encode("utf-8")) + digest.update(b"\0") + return digest.hexdigest() + + +def _files_diff(before: dict[str, str | None], after: dict[str, str | None]) -> str: + chunks: list[str] = [] + for path in sorted(set(before) | set(after)): + old = before.get(path) + new = after.get(path) + if old == new: + continue + old_lines = [] if old is None else old.splitlines(keepends=True) + new_lines = [] if new is None else new.splitlines(keepends=True) + chunks.extend( + difflib.unified_diff( + old_lines, + new_lines, + fromfile=f"a/{path}" if old is not None else "/dev/null", + tofile=f"b/{path}" if new is not None else "/dev/null", + ) + ) + return "".join(chunks) + + +def _write_file_transaction(root: Path, files: dict[str, str | None]) -> None: + originals: dict[str, bytes | None] = {} + for relative in files: + path = _safe_root_path(root, relative) + originals[relative] = path.read_bytes() if path.is_file() else None + try: + for relative, text in files.items(): + path = _safe_root_path(root, relative) + if text is None: + path.unlink(missing_ok=True) + else: + _atomic_write_text(path, text) + except Exception: + for relative, data in originals.items(): + path = _safe_root_path(root, relative) + if data is None: + path.unlink(missing_ok=True) + else: + _atomic_write_bytes(path, data) + raise + + +def _safe_root_path(root: Path, relative: str) -> Path: + path = (root / relative).resolve(strict=False) + try: + path.relative_to(root) + except ValueError as exc: + raise PursuitWorkspaceError(f"path escapes RightMemory root: {relative}") from exc + if path.exists() and path.is_symlink(): + raise PursuitWorkspaceError(f"refusing to write symlink: {relative}") + return path + + +def _atomic_write_text(path: Path, text: str) -> None: + _atomic_write_bytes(path, text.encode("utf-8")) + + +def _atomic_write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp" + try: + with temporary.open("wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def _record_history( + root: Path, + before: dict[str, str | None], + after: dict[str, str | None], + before_revision: str, + after_revision: str, +) -> None: + history = _load_history(root) + entries = history.get("entries", []) + cursor = int(history.get("cursor", 0)) + if not isinstance(entries, list): + entries = [] + entries = entries[:cursor] + entries.append( + { + "created_at": datetime.now(UTC).isoformat(), + "before_revision": before_revision, + "after_revision": after_revision, + "before": before, + "after": after, + } + ) + if len(entries) > _HISTORY_LIMIT: + entries = entries[-_HISTORY_LIMIT:] + history = {"version": 1, "cursor": len(entries), "entries": entries} + _save_history(root, history) + + +def _load_history(root: Path) -> dict[str, Any]: + path = root / _HISTORY_PATH + if not path.is_file(): + return {"version": 1, "cursor": 0, "entries": []} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise PursuitWorkspaceError(f"invalid Pursuit Studio history: {exc}") from exc + if not isinstance(value, dict) or value.get("version") != 1: + raise PursuitWorkspaceError("invalid Pursuit Studio history version") + return value + + +def _save_history(root: Path, history: dict[str, Any]) -> None: + path = root / _HISTORY_PATH + _atomic_write_text(path, json.dumps(history, ensure_ascii=False, indent=2) + "\n") + + +def _decode_history_files(value: object) -> dict[str, str | None]: + if not isinstance(value, dict): + raise PursuitWorkspaceError("invalid Pursuit Studio history file map") + result: dict[str, str | None] = {} + for path, text in value.items(): + if not isinstance(path, str) or (text is not None and not isinstance(text, str)): + raise PursuitWorkspaceError("invalid Pursuit Studio history file entry") + result[path] = text + return result + + +def _changed_paths(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[tuple[str, ...], tuple[str, ...]]: + changed = tuple(sorted(path for path, value in after.items() if value is not None and before.get(path) != value)) + removed = tuple(sorted(path for path, value in after.items() if value is None and before.get(path) is not None)) + return changed, removed + + + +def _require_clean_git_paths(root: Path, paths: Iterable[str]) -> None: + unique = sorted(set(paths)) + if not unique: + return + status = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain=v1", "--", *unique], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if status.returncode != 0: + raise PursuitWorkspaceError(status.stderr.strip() or "could not inspect Memory Git state") + if status.stdout.strip(): + raise PursuitWorkspaceError( + "cannot commit Pursuit edit because affected files already have uncommitted changes" + ) + + +def _commit_files(root: Path, paths: Iterable[str], message: str) -> str | None: + unique = sorted(set(paths)) + if not unique: + return None + status = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain=v1", "--", *unique], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if status.returncode != 0: + raise PursuitWorkspaceError(status.stderr.strip() or "could not inspect Memory Git state") + if not status.stdout.strip(): + return None + add = subprocess.run( + ["git", "-C", str(root), "add", "--", *unique], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if add.returncode != 0: + raise PursuitWorkspaceError(add.stderr.strip() or "could not stage Pursuit files") + commit = subprocess.run( + ["git", "-C", str(root), "commit", "-m", message, "--", *unique], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if commit.returncode != 0: + raise PursuitWorkspaceError(commit.stderr.strip() or commit.stdout.strip() or "could not commit Pursuit files") + rev = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return rev.stdout.strip() + + +def _relative(root: Path, path: Path) -> str: + return path.resolve().relative_to(root).as_posix() diff --git a/skills/rightmemory-auto-orchestrator-cli/SKILL.md b/skills/rightmemory-auto-orchestrator-cli/SKILL.md index 17f899c..4be99f1 100644 --- a/skills/rightmemory-auto-orchestrator-cli/SKILL.md +++ b/skills/rightmemory-auto-orchestrator-cli/SKILL.md @@ -37,6 +37,26 @@ Choose one stable session id for the conversation and reuse it for every RightMe Continue the user's task without waiting for Update. +## Pursuit Tasks + +When current work clearly advances an existing Pursuit, link the current Codex thread rather than copying task history into Pursuit prose: + +`rightmemory pursuit task link --pursuit --current --title "" --project ` + +Before creating another task, inspect active links with `rightmemory pursuit task list --pursuit `. Create a planned task when the work should move to a separate execution context: + +`rightmemory pursuit task plan --pursuit --action "" --project ` + +Run it only when the current user instruction authorizes that execution: + +`rightmemory pursuit task run ` + +A completed task does not complete its Pursuit. Record the result, then propose the smallest justified structured update to State, Next, hierarchy, Focus, or status: + +`rightmemory pursuit reconcile propose --summary "" --operations-json ''` + +Apply an unambiguous reconciliation when direct Pursuit editing is authorized; otherwise leave it pending for review. Do not create duplicate planned or active tasks for the same Pursuit and action. Task prompts should preserve the objective, necessary ancestors, current State and Next, directly related Memory, project location, and the exact action without inventing detailed implementation instructions. + ## Capture Agent Guidance Capture plausible evidence about how an agent should handle similar future work. Bias toward capture rather than filtering: uncertainty about whether the pattern will recur is not a reason to skip it, and similar captures from distinct occurrences are useful. diff --git a/tests/test_pursuit_web.py b/tests/test_pursuit_web.py new file mode 100644 index 0000000..c76f2ac --- /dev/null +++ b/tests/test_pursuit_web.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from rightmemory.pursuit_web import create_pursuit_app +from tests.asgi_client import ASGITestClient as TestClient + + +class FakeRunner: + def run_turn(self, **kwargs): + callback = kwargs.get("on_thread_started") + if callback: + callback("web-thread") + return SimpleNamespace(provider_session_id="web-thread", text="Web task done.") + + def close(self): + pass + + +class PursuitWebTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) + (self.root / "MEMORY.md").write_text("# Memory\n", encoding="utf-8") + (self.root / "PURSUITS.md").write_text( + "# Pursuits\n\n## Focus\n\nNo Pursuit is focused yet.\n\n" + "## Product {#product}\n\nBuild the product.\n\n" + "**Next:**\n- `do` Implement the map.\n", + encoding="utf-8", + ) + self.client = TestClient( + create_pursuit_app(self.root, access_token="test-token", runner_factory=FakeRunner) + ) + self.headers = {"authorization": "Bearer test-token"} + + def test_static_shell_and_auth(self): + index = self.client.get("/") + script = self.client.get("/static/pursuit.js") + unauthorized = self.client.get("/api/workspace") + authorized = self.client.get("/api/workspace", headers=self.headers) + + self.assertEqual(index.status_code, 200) + self.assertIn("Pursuit Map", index.text) + self.assertEqual(script.status_code, 200) + self.assertEqual(unauthorized.status_code, 401) + self.assertEqual(authorized.status_code, 200) + self.assertEqual(authorized.json()["data"]["workspace"]["roots"], ["product"]) + + def test_preview_apply_task_and_run_endpoints(self): + workspace = self.client.get("/api/workspace", headers=self.headers).json()["data"]["workspace"] + operation = {"op": "update", "id": "product", "state": "Map implementation started."} + preview = self.client.post( + "/api/preview", + headers=self.headers, + json={"revision": workspace["revision"], "operations": [operation]}, + ) + self.assertEqual(preview.status_code, 200) + self.assertIn("Map implementation started", preview.json()["data"]["diff"]) + + applied = self.client.post( + "/api/apply", + headers=self.headers, + json={"revision": workspace["revision"], "operations": [operation]}, + ) + self.assertEqual(applied.status_code, 200) + + planned = self.client.post( + "/api/tasks/plan", + headers=self.headers, + json={"pursuit_id": "product", "project": str(self.root)}, + ) + self.assertEqual(planned.status_code, 200) + task_id = planned.json()["data"]["task_id"] + + ran = self.client.post( + f"/api/tasks/{task_id}/run", + headers=self.headers, + json={"project": str(self.root)}, + ) + self.assertEqual(ran.status_code, 200) + self.assertEqual(ran.json()["data"]["thread_id"], "web-thread") + self.assertEqual(ran.json()["data"]["status"], "completed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pursuit_workspace.py b/tests/test_pursuit_workspace.py new file mode 100644 index 0000000..f309e6d --- /dev/null +++ b/tests/test_pursuit_workspace.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import tempfile +import unittest +from unittest.mock import patch +from pathlib import Path +from types import SimpleNamespace + +from rightmemory.pursuit_tasks import ( + apply_reconciliation, + link_task, + list_tasks, + plan_task, + propose_reconciliation, + run_task, +) +from rightmemory.pursuit_workspace import ( + PursuitEditor, + PursuitRevisionConflict, + PursuitWorkspaceError, + apply_operations, + preview_operations, + redo, + undo, +) + + +class FakeCodexRunner: + def __init__(self): + self.calls = [] + self.closed = False + + def run_turn(self, **kwargs): + self.calls.append(kwargs) + callback = kwargs.get("on_thread_started") + if callback: + callback("thread-created") + return SimpleNamespace(provider_session_id="thread-created", text="Implemented and verified the task.") + + def close(self): + self.closed = True + + +class _PursuitFixture: + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) + (self.root / "MEMORY.md").write_text( + "# Memory\n\n## Retrieval Context {#retrieval-context}\n\nUse deterministic graph context.\n", + encoding="utf-8", + ) + (self.root / "PURSUITS.md").write_text( + "# Pursuits\n\n" + "## Focus\n\n" + "- `rightmemory`\n\n" + "## RightMemory Product {#rightmemory} → [rel:retrieval-context]\n\n" + "Make RightMemory a coherent long-term agent memory system.\n\n" + "**State:** The core graph is stable.\n\n" + "### Faster Retrieval {#retrieval-speed}\n\n" + "Reduce retrieval latency without losing relevant context.\n\n" + "**Next:**\n" + "- `do` Benchmark the current selector.\n", + encoding="utf-8", + ) + + +class PursuitWorkspaceTests(_PursuitFixture, unittest.TestCase): + def test_snapshot_exposes_tree_and_fields(self): + snapshot = PursuitEditor(self.root).snapshot() + by_id = {node["id"]: node for node in snapshot["nodes"]} + + self.assertEqual(snapshot["roots"], ["rightmemory"]) + self.assertEqual(snapshot["focus_ids"], ["rightmemory"]) + self.assertEqual(by_id["retrieval-speed"]["parent_id"], "rightmemory") + self.assertEqual(by_id["retrieval-speed"]["next"][0]["kind"], "do") + self.assertEqual(by_id["rightmemory"]["edges"], [{"type": "rel", "target": "retrieval-context"}]) + + def test_preview_apply_move_and_unicode_round_trip(self): + revision = PursuitEditor(self.root).revision() + operations = [ + { + "op": "create", + "id": "pursuit-map", + "title": "可编辑 Pursuit Map", + "parent_id": None, + "objective": "用树状思维导图管理长期意图。", + "next": ["do: 完成结构化编辑器"], + }, + { + "op": "update", + "id": "retrieval-speed", + "state": "基准已经准备好。", + "next": [{"kind": "do", "text": "比较延迟与召回率"}], + }, + {"op": "set_focus", "ids": ["pursuit-map", "retrieval-speed"]}, + {"op": "move", "id": "retrieval-speed", "parent_id": "pursuit-map", "index": 0}, + ] + + preview = preview_operations(self.root, operations, expected_revision=revision) + self.assertIn("可编辑 Pursuit Map", preview.diff) + self.assertEqual(preview.snapshot["focus_ids"], ["pursuit-map", "retrieval-speed"]) + + result = apply_operations(self.root, operations, expected_revision=revision) + by_id = {node["id"]: node for node in result.snapshot["nodes"]} + self.assertEqual(by_id["retrieval-speed"]["parent_id"], "pursuit-map") + self.assertIn("基准已经准备好", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + + + def test_ordinary_pursuits_cannot_enter_terminal_depth(self): + with self.assertRaisesRegex(PursuitWorkspaceError, "deeper than ###"): + preview_operations( + self.root, + [{ + "op": "create", + "id": "too-deep", + "title": "Too Deep", + "parent_id": "retrieval-speed", + "objective": "This would be an ordinary #### heading.", + }], + ) + + with self.assertRaisesRegex(PursuitWorkspaceError, "terminal F#"): + preview_operations( + self.root, + [ + { + "op": "create", + "id": "child", + "title": "Child", + "parent_id": "rightmemory", + "objective": "A valid ### child.", + }, + {"op": "move", "id": "retrieval-speed", "parent_id": "child"}, + ], + ) + + def test_split_and_inline_f_backing(self): + apply_operations(self.root, [{"op": "split_file", "id": "rightmemory"}]) + backing = self.root / "PURSUIT_rightmemory.md" + self.assertTrue(backing.is_file()) + self.assertIn("{F#rightmemory}", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + self.assertIn("retrieval-speed", backing.read_text(encoding="utf-8")) + + apply_operations(self.root, [{"op": "inline_file", "id": "rightmemory"}]) + self.assertFalse(backing.exists()) + text = (self.root / "PURSUITS.md").read_text(encoding="utf-8") + self.assertIn("{#rightmemory}", text) + self.assertIn("retrieval-speed", text) + + def test_structural_text_injection_is_rejected(self): + with self.assertRaisesRegex(ValueError, "must not introduce Markdown headings"): + preview_operations( + self.root, + [ + { + "op": "update", + "id": "retrieval-speed", + "state": "Valid state.\n\n## Injected {#injected}", + } + ], + ) + + def test_crlf_style_is_preserved(self): + text = (self.root / "PURSUITS.md").read_text(encoding="utf-8") + with (self.root / "PURSUITS.md").open("w", encoding="utf-8", newline="") as handle: + handle.write(text.replace("\n", "\r\n")) + apply_operations(self.root, [{"op": "park", "id": "retrieval-speed"}]) + data = (self.root / "PURSUITS.md").read_bytes() + self.assertIn(b"\r\n", data) + self.assertNotIn(b"\n", data.replace(b"\r\n", b"")) + + def test_stale_revision_is_rejected(self): + revision = PursuitEditor(self.root).revision() + apply_operations(self.root, [{"op": "park", "id": "retrieval-speed"}]) + with self.assertRaises(PursuitRevisionConflict): + apply_operations( + self.root, + [{"op": "unpark", "id": "retrieval-speed"}], + expected_revision=revision, + ) + + def test_undo_and_redo_restore_exact_state(self): + original = (self.root / "PURSUITS.md").read_text(encoding="utf-8") + apply_operations(self.root, [{"op": "park", "id": "retrieval-speed"}]) + parked = (self.root / "PURSUITS.md").read_text(encoding="utf-8") + self.assertIn("**Status:** parked", parked) + + undo(self.root) + self.assertEqual((self.root / "PURSUITS.md").read_text(encoding="utf-8"), original) + redo(self.root) + self.assertEqual((self.root / "PURSUITS.md").read_text(encoding="utf-8"), parked) + + +class PursuitTaskTests(_PursuitFixture, unittest.TestCase): + def test_link_is_idempotent_and_plan_avoids_duplicate_live_task(self): + first = link_task( + self.root, + pursuit_ids=["retrieval-speed"], + provider="codex", + thread_id="thread-1", + title="Benchmark retrieval", + project=str(self.root), + ) + second = link_task( + self.root, + pursuit_ids=["rightmemory"], + provider="codex", + thread_id="thread-1", + title="Benchmark retrieval", + project=str(self.root), + ) + self.assertEqual(first.task_id, second.task_id) + self.assertEqual(set(second.pursuit_ids), {"retrieval-speed", "rightmemory"}) + + planned = plan_task( + self.root, + pursuit_id="retrieval-speed", + action="Benchmark selector latency and recall", + project=str(self.root), + ) + duplicate = plan_task( + self.root, + pursuit_id="retrieval-speed", + action="Benchmark selector latency and recall", + project=str(self.root), + ) + self.assertEqual(planned.task_id, duplicate.task_id) + self.assertIn("Relevant durable Memory", planned.prompt) + self.assertIn("retrieval-context", planned.prompt) + + def test_linked_pursuit_cannot_be_deleted(self): + link_task( + self.root, + pursuit_ids=["retrieval-speed"], + provider="codex", + thread_id="thread-delete", + title="Linked task", + ) + with self.assertRaisesRegex(ValueError, "unlink Pursuit"): + preview_operations( + self.root, + [{"op": "delete", "id": "retrieval-speed"}], + ) + + def test_run_records_real_thread_and_result(self): + task = plan_task( + self.root, + pursuit_id="retrieval-speed", + action="Implement the benchmark", + project=str(self.root), + ) + runner = FakeCodexRunner() + completed = run_task(self.root, task.task_id, runner=runner) + + self.assertEqual(completed.status, "completed") + self.assertEqual(completed.thread_id, "thread-created") + self.assertIn("Implemented and verified", completed.result) + self.assertEqual(runner.calls[0]["cwd"], self.root.resolve()) + with self.assertRaisesRegex(ValueError, "only a planned task"): + run_task(self.root, task.task_id, runner=runner) + + def test_reconciliation_rolls_back_if_registry_write_fails(self): + task = link_task( + self.root, + pursuit_ids=["retrieval-speed"], + provider="codex", + thread_id="thread-rollback", + title="Benchmark retrieval", + status="completed", + ) + before = (self.root / "PURSUITS.md").read_bytes() + reconciliation = propose_reconciliation( + self.root, + task_id=task.task_id, + summary="Update state.", + operations=[{"op": "park", "id": "retrieval-speed"}], + ) + original_save = __import__("rightmemory.pursuit_tasks", fromlist=["_save_registry"])._save_registry + calls = 0 + + def fail_after_pursuit(root, registry): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("simulated registry failure") + return original_save(root, registry) + + with patch("rightmemory.pursuit_tasks._save_registry", side_effect=fail_after_pursuit): + with self.assertRaisesRegex(OSError, "simulated registry failure"): + apply_reconciliation(self.root, reconciliation.reconciliation_id) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), before) + + def test_reconciliation_is_revision_bound_and_applies(self): + task = link_task( + self.root, + pursuit_ids=["retrieval-speed"], + provider="codex", + thread_id="thread-2", + title="Benchmark retrieval", + status="completed", + ) + revision = PursuitEditor(self.root).revision() + reconciliation = propose_reconciliation( + self.root, + task_id=task.task_id, + summary="The benchmark settled the next production step.", + expected_revision=revision, + operations=[ + { + "op": "update", + "id": "retrieval-speed", + "state": "The benchmark passed the target recall gate.", + "next": ["do: Integrate the selector"], + } + ], + ) + + outcome = apply_reconciliation(self.root, reconciliation.reconciliation_id) + self.assertEqual(outcome["reconciliation"]["status"], "applied") + node = next(node for node in PursuitEditor(self.root).snapshot()["nodes"] if node["id"] == "retrieval-speed") + self.assertIn("passed", node["state"]) + self.assertEqual(list_tasks(self.root, "retrieval-speed")[0].task_id, task.task_id) + + +if __name__ == "__main__": + unittest.main()