Add prompt and artifact diff inspection surfaces
- SHA
75139aee1829354e1efaee5cb07f9df2feb9050a- Parents
-
6f05b29 - Tree
359b983
75139ae
75139aee1829354e1efaee5cb07f9df2feb9050a6f05b29
359b983| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/cli/main.py
|
147 | 0 |
| M |
src/loader/runtime/inspection.py
|
307 | 0 |
| M |
tests/test_inspection.py
|
178 | 0 |
src/loader/cli/main.pymodified@@ -17,13 +17,17 @@ from ..runtime.inspection import ( | ||
| 17 | 17 | DoctorReport, |
| 18 | 18 | PermissionCheckResult, |
| 19 | 19 | PermissionSnapshot, |
| 20 | + PromptDiffSnapshot, | |
| 20 | 21 | PromptPreview, |
| 21 | 22 | StatusSnapshot, |
| 23 | + WorkflowArtifactDiffSnapshot, | |
| 22 | 24 | WorkflowTimelineSnapshot, |
| 23 | 25 | collect_doctor_report, |
| 24 | 26 | collect_permission_snapshot, |
| 27 | + collect_prompt_diff, | |
| 25 | 28 | collect_prompt_preview, |
| 26 | 29 | collect_status_snapshot, |
| 30 | + collect_workflow_artifact_diffs, | |
| 27 | 31 | collect_workflow_timeline, |
| 28 | 32 | dry_run_permission_check, |
| 29 | 33 | list_session_summaries, |
@@ -953,6 +957,15 @@ def prompt_show_cli( | ||
| 953 | 957 | ) |
| 954 | 958 | |
| 955 | 959 | |
| 960 | +@prompt_cli.command("diff") | |
| 961 | +@click.option("--full", is_flag=True, help="Show the full unified prompt diff") | |
| 962 | +@click.argument("session_id", required=False) | |
| 963 | +def prompt_diff_cli(full: bool, session_id: str | None) -> None: | |
| 964 | + """Compare the latest persisted prompt contracts.""" | |
| 965 | + | |
| 966 | + _prompt_diff_main(session_id=session_id, full=full) | |
| 967 | + | |
| 968 | + | |
| 956 | 969 | @workflow_cli.command("show") |
| 957 | 970 | @click.option( |
| 958 | 971 | "--mode", |
@@ -984,11 +997,15 @@ def prompt_show_cli( | ||
| 984 | 997 | show_default=True, |
| 985 | 998 | help="Show only the most recent matching entries", |
| 986 | 999 | ) |
| 1000 | +@click.option("--diff", "show_diff", is_flag=True, help="Show persisted artifact diffs") | |
| 1001 | +@click.option("--full-diff", is_flag=True, help="Show the full unified artifact diffs") | |
| 987 | 1002 | @click.argument("session_id", required=False) |
| 988 | 1003 | def workflow_show_cli( |
| 989 | 1004 | mode: str | None, |
| 990 | 1005 | kind: str | None, |
| 991 | 1006 | limit: int, |
| 1007 | + show_diff: bool, | |
| 1008 | + full_diff: bool, | |
| 992 | 1009 | session_id: str | None, |
| 993 | 1010 | ) -> None: |
| 994 | 1011 | """Show the persisted workflow timeline for the latest or named session.""" |
@@ -998,6 +1015,8 @@ def workflow_show_cli( | ||
| 998 | 1015 | mode=mode, |
| 999 | 1016 | kind=kind, |
| 1000 | 1017 | limit=limit, |
| 1018 | + show_diff=show_diff, | |
| 1019 | + full_diff=full_diff, | |
| 1001 | 1020 | ) |
| 1002 | 1021 | |
| 1003 | 1022 | |
@@ -1049,6 +1068,7 @@ def _loader_help_text() -> str: | ||
| 1049 | 1068 | " loader status Show persisted runtime status", |
| 1050 | 1069 | " loader explore <prompt> Run a fast read-only lookup query", |
| 1051 | 1070 | " loader prompt show Preview the current prompt contract", |
| 1071 | + " loader prompt diff Compare the latest persisted prompt contracts", | |
| 1052 | 1072 | " loader permissions show Display normalized permission rules", |
| 1053 | 1073 | " loader permissions check Dry-run one permission decision", |
| 1054 | 1074 | " loader workflow show Show the persisted workflow timeline", |
@@ -1079,6 +1099,7 @@ def _prompt_help_text() -> str: | ||
| 1079 | 1099 | "", |
| 1080 | 1100 | "Commands:", |
| 1081 | 1101 | " show [task] Render the current prompt contract without a model call", |
| 1102 | + " diff [id] Compare the latest persisted prompt contracts", | |
| 1082 | 1103 | ] |
| 1083 | 1104 | ) |
| 1084 | 1105 | |
@@ -1090,6 +1111,7 @@ def _workflow_help_text() -> str: | ||
| 1090 | 1111 | "", |
| 1091 | 1112 | "Commands:", |
| 1092 | 1113 | " show [id] Show the persisted workflow timeline with optional filters", |
| 1114 | + " Add --diff to compare the latest persisted artifacts", | |
| 1093 | 1115 | ] |
| 1094 | 1116 | ) |
| 1095 | 1117 | |
@@ -1518,6 +1540,8 @@ def _workflow_show_main( | ||
| 1518 | 1540 | mode: str | None, |
| 1519 | 1541 | kind: str | None, |
| 1520 | 1542 | limit: int | None, |
| 1543 | + show_diff: bool, | |
| 1544 | + full_diff: bool, | |
| 1521 | 1545 | ) -> None: |
| 1522 | 1546 | try: |
| 1523 | 1547 | snapshot: WorkflowTimelineSnapshot = collect_workflow_timeline( |
@@ -1570,6 +1594,13 @@ def _workflow_show_main( | ||
| 1570 | 1594 | snapshot.workflow_ledger, |
| 1571 | 1595 | title="[bold blue]Workflow Ledger[/bold blue]", |
| 1572 | 1596 | ) |
| 1597 | + if show_diff: | |
| 1598 | + console.print() | |
| 1599 | + artifact_diffs = collect_workflow_artifact_diffs(session_id=session_id) | |
| 1600 | + _print_workflow_artifact_diffs( | |
| 1601 | + artifact_diffs, | |
| 1602 | + show_full=full_diff, | |
| 1603 | + ) | |
| 1573 | 1604 | console.print() |
| 1574 | 1605 | _print_workflow_timeline_entries( |
| 1575 | 1606 | snapshot.entries, |
@@ -1600,6 +1631,24 @@ def _prompt_show_main( | ||
| 1600 | 1631 | _print_prompt_preview(preview) |
| 1601 | 1632 | |
| 1602 | 1633 | |
| 1634 | +def _prompt_diff_main( | |
| 1635 | + *, | |
| 1636 | + session_id: str | None, | |
| 1637 | + full: bool, | |
| 1638 | +) -> None: | |
| 1639 | + try: | |
| 1640 | + snapshot: PromptDiffSnapshot = collect_prompt_diff(session_id=session_id) | |
| 1641 | + except FileNotFoundError: | |
| 1642 | + console.print(f"[red]Session not found:[/red] {session_id}") | |
| 1643 | + raise SystemExit(1) from None | |
| 1644 | + | |
| 1645 | + if snapshot.session_id is None or snapshot.current is None: | |
| 1646 | + console.print("[yellow]No persisted prompt history found.[/yellow]") | |
| 1647 | + return | |
| 1648 | + | |
| 1649 | + _print_prompt_diff(snapshot, show_full=full) | |
| 1650 | + | |
| 1651 | + | |
| 1603 | 1652 | def _permissions_check_main( |
| 1604 | 1653 | *, |
| 1605 | 1654 | tool_name: str, |
@@ -1787,6 +1836,104 @@ def _print_prompt_preview(preview: PromptPreview) -> None: | ||
| 1787 | 1836 | ) |
| 1788 | 1837 | |
| 1789 | 1838 | |
| 1839 | +def _print_prompt_diff(snapshot: PromptDiffSnapshot, *, show_full: bool) -> None: | |
| 1840 | + table = Table(show_header=False, box=None) | |
| 1841 | + table.add_column("Field", style="bold cyan") | |
| 1842 | + table.add_column("Value", style="white") | |
| 1843 | + table.add_row("Workspace", str(snapshot.project_root)) | |
| 1844 | + table.add_row("Session", snapshot.session_id or "none") | |
| 1845 | + table.add_row("Task", snapshot.current_task or "none") | |
| 1846 | + table.add_row( | |
| 1847 | + "Current", | |
| 1848 | + _format_prompt_snapshot(snapshot.current) if snapshot.current else "none", | |
| 1849 | + ) | |
| 1850 | + table.add_row( | |
| 1851 | + "Previous", | |
| 1852 | + _format_prompt_snapshot(snapshot.previous) if snapshot.previous else "none", | |
| 1853 | + ) | |
| 1854 | + console.print( | |
| 1855 | + Panel.fit( | |
| 1856 | + table, | |
| 1857 | + title="[bold blue]Prompt Diff[/bold blue]", | |
| 1858 | + border_style="blue", | |
| 1859 | + ) | |
| 1860 | + ) | |
| 1861 | + | |
| 1862 | + if snapshot.highlights: | |
| 1863 | + console.print() | |
| 1864 | + _print_workflow_highlights( | |
| 1865 | + snapshot.highlights, | |
| 1866 | + title="[bold blue]Prompt Changes[/bold blue]", | |
| 1867 | + ) | |
| 1868 | + | |
| 1869 | + if show_full: | |
| 1870 | + body = snapshot.unified_diff or "[dim]No prompt body diff available.[/dim]" | |
| 1871 | + console.print() | |
| 1872 | + console.print( | |
| 1873 | + Panel( | |
| 1874 | + body, | |
| 1875 | + title="[bold blue]Prompt Unified Diff[/bold blue]", | |
| 1876 | + border_style="blue", | |
| 1877 | + ) | |
| 1878 | + ) | |
| 1879 | + | |
| 1880 | + | |
| 1881 | +def _format_prompt_snapshot(snapshot) -> str: | |
| 1882 | + parts = [snapshot.workflow_mode, snapshot.permission_mode, snapshot.prompt_format] | |
| 1883 | + if snapshot.prompt_sections: | |
| 1884 | + parts.append(f"sections={len(snapshot.prompt_sections)}") | |
| 1885 | + return " / ".join(parts) | |
| 1886 | + | |
| 1887 | + | |
| 1888 | +def _print_workflow_artifact_diffs( | |
| 1889 | + snapshot: WorkflowArtifactDiffSnapshot, | |
| 1890 | + *, | |
| 1891 | + show_full: bool, | |
| 1892 | +) -> None: | |
| 1893 | + if not snapshot.entries: | |
| 1894 | + console.print("[dim]No persisted artifact diffs are available for this session.[/dim]") | |
| 1895 | + return | |
| 1896 | + | |
| 1897 | + if snapshot.highlights: | |
| 1898 | + _print_workflow_highlights( | |
| 1899 | + snapshot.highlights, | |
| 1900 | + title="[bold blue]Artifact Changes[/bold blue]", | |
| 1901 | + ) | |
| 1902 | + | |
| 1903 | + table = Table(show_header=True, header_style="bold cyan") | |
| 1904 | + table.add_column("Kind", style="white") | |
| 1905 | + table.add_column("Current", style="white") | |
| 1906 | + table.add_column("Previous", style="white") | |
| 1907 | + table.add_column("Summary", style="dim") | |
| 1908 | + | |
| 1909 | + for entry in snapshot.entries: | |
| 1910 | + table.add_row( | |
| 1911 | + entry.kind, | |
| 1912 | + entry.current_path.name, | |
| 1913 | + entry.previous_path.name if entry.previous_path else "none", | |
| 1914 | + "; ".join(entry.highlights[:2]) or "none", | |
| 1915 | + ) | |
| 1916 | + | |
| 1917 | + console.print( | |
| 1918 | + Panel( | |
| 1919 | + table, | |
| 1920 | + title="[bold blue]Artifact Diff Summary[/bold blue]", | |
| 1921 | + border_style="blue", | |
| 1922 | + ) | |
| 1923 | + ) | |
| 1924 | + | |
| 1925 | + if show_full: | |
| 1926 | + for entry in snapshot.entries: | |
| 1927 | + console.print() | |
| 1928 | + console.print( | |
| 1929 | + Panel( | |
| 1930 | + entry.unified_diff or "[dim]No diff available.[/dim]", | |
| 1931 | + title=f"[bold blue]{entry.kind} Diff[/bold blue]", | |
| 1932 | + border_style="blue", | |
| 1933 | + ) | |
| 1934 | + ) | |
| 1935 | + | |
| 1936 | + | |
| 1790 | 1937 | def _print_workflow_timeline_entries( |
| 1791 | 1938 | entries: list, |
| 1792 | 1939 | *, |
src/loader/runtime/inspection.pymodified@@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | +import difflib | |
| 5 | 6 | import json |
| 6 | 7 | import os |
| 7 | 8 | from dataclasses import dataclass, field |
@@ -24,6 +25,7 @@ from .permissions import ( | ||
| 24 | 25 | permission_path_hint, |
| 25 | 26 | summarize_permission_input, |
| 26 | 27 | ) |
| 28 | +from .prompt_history import PromptSnapshot | |
| 27 | 29 | from .prompting import build_system_prompt_result |
| 28 | 30 | from .session import SessionSnapshot, SessionStore |
| 29 | 31 | from .workflow_ledger import WorkflowLedger, workflow_ledger_highlights |
@@ -255,6 +257,41 @@ class WorkflowTimelineSnapshot: | ||
| 255 | 257 | workflow_ledger: WorkflowLedger = field(default_factory=WorkflowLedger) |
| 256 | 258 | |
| 257 | 259 | |
| 260 | +@dataclass(slots=True) | |
| 261 | +class PromptDiffSnapshot: | |
| 262 | + """Operator-facing diff between persisted prompt contracts.""" | |
| 263 | + | |
| 264 | + project_root: Path | |
| 265 | + session_id: str | None | |
| 266 | + current_task: str | None | |
| 267 | + current: PromptSnapshot | None | |
| 268 | + previous: PromptSnapshot | None | |
| 269 | + highlights: list[str] = field(default_factory=list) | |
| 270 | + unified_diff: str = "" | |
| 271 | + | |
| 272 | + | |
| 273 | +@dataclass(slots=True) | |
| 274 | +class ArtifactDiffEntry: | |
| 275 | + """One persisted artifact diff entry.""" | |
| 276 | + | |
| 277 | + kind: str | |
| 278 | + current_path: Path | |
| 279 | + previous_path: Path | None | |
| 280 | + highlights: list[str] = field(default_factory=list) | |
| 281 | + unified_diff: str = "" | |
| 282 | + | |
| 283 | + | |
| 284 | +@dataclass(slots=True) | |
| 285 | +class WorkflowArtifactDiffSnapshot: | |
| 286 | + """Operator-facing diff across persisted workflow artifacts.""" | |
| 287 | + | |
| 288 | + project_root: Path | |
| 289 | + session_id: str | None | |
| 290 | + current_task: str | None | |
| 291 | + entries: list[ArtifactDiffEntry] = field(default_factory=list) | |
| 292 | + highlights: list[str] = field(default_factory=list) | |
| 293 | + | |
| 294 | + | |
| 258 | 295 | def capability_summary(profile: CapabilityProfile) -> str: |
| 259 | 296 | """Render a short human-readable capability summary.""" |
| 260 | 297 | |
@@ -597,6 +634,121 @@ def collect_prompt_preview( | ||
| 597 | 634 | ) |
| 598 | 635 | |
| 599 | 636 | |
| 637 | +def collect_prompt_diff( | |
| 638 | + session_id: str | None = None, | |
| 639 | + *, | |
| 640 | + project_root: Path | str | None = None, | |
| 641 | +) -> PromptDiffSnapshot: | |
| 642 | + """Load the latest persisted prompt-contract diff for one session.""" | |
| 643 | + | |
| 644 | + resolved_root = Path(project_root or Path.cwd()).expanduser().resolve() | |
| 645 | + store = SessionStore(resolved_root) | |
| 646 | + snapshot = store.load(session_id) if session_id else store.load_latest() | |
| 647 | + if snapshot is None: | |
| 648 | + return PromptDiffSnapshot( | |
| 649 | + project_root=resolved_root, | |
| 650 | + session_id=None, | |
| 651 | + current_task=None, | |
| 652 | + current=None, | |
| 653 | + previous=None, | |
| 654 | + highlights=[], | |
| 655 | + unified_diff="", | |
| 656 | + ) | |
| 657 | + | |
| 658 | + current = snapshot.prompt_history[-1] if snapshot.prompt_history else None | |
| 659 | + previous = _previous_prompt_snapshot(snapshot.prompt_history) | |
| 660 | + highlights = _prompt_diff_highlights(previous, current) | |
| 661 | + unified_diff = _unified_diff( | |
| 662 | + previous.content if previous is not None else "", | |
| 663 | + current.content if current is not None else "", | |
| 664 | + from_label=_prompt_snapshot_label(previous, fallback="previous prompt"), | |
| 665 | + to_label=_prompt_snapshot_label(current, fallback="current prompt"), | |
| 666 | + ) | |
| 667 | + return PromptDiffSnapshot( | |
| 668 | + project_root=resolved_root, | |
| 669 | + session_id=snapshot.session_id, | |
| 670 | + current_task=snapshot.current_task, | |
| 671 | + current=current, | |
| 672 | + previous=previous, | |
| 673 | + highlights=highlights, | |
| 674 | + unified_diff=unified_diff, | |
| 675 | + ) | |
| 676 | + | |
| 677 | + | |
| 678 | +def collect_workflow_artifact_diffs( | |
| 679 | + session_id: str | None = None, | |
| 680 | + *, | |
| 681 | + project_root: Path | str | None = None, | |
| 682 | +) -> WorkflowArtifactDiffSnapshot: | |
| 683 | + """Load persisted workflow-artifact diffs for the latest or named session.""" | |
| 684 | + | |
| 685 | + resolved_root = Path(project_root or Path.cwd()).expanduser().resolve() | |
| 686 | + store = SessionStore(resolved_root) | |
| 687 | + snapshot = store.load(session_id) if session_id else store.load_latest() | |
| 688 | + if snapshot is None: | |
| 689 | + return WorkflowArtifactDiffSnapshot( | |
| 690 | + project_root=resolved_root, | |
| 691 | + session_id=None, | |
| 692 | + current_task=None, | |
| 693 | + entries=[], | |
| 694 | + highlights=[], | |
| 695 | + ) | |
| 696 | + | |
| 697 | + dod = _load_dod(snapshot.active_dod_path, project_root=resolved_root) | |
| 698 | + if dod is None: | |
| 699 | + return WorkflowArtifactDiffSnapshot( | |
| 700 | + project_root=resolved_root, | |
| 701 | + session_id=snapshot.session_id, | |
| 702 | + current_task=snapshot.current_task, | |
| 703 | + entries=[], | |
| 704 | + highlights=[], | |
| 705 | + ) | |
| 706 | + | |
| 707 | + entries: list[ArtifactDiffEntry] = [] | |
| 708 | + for kind, path_str in ( | |
| 709 | + ("clarify_brief", dod.clarify_brief), | |
| 710 | + ("implementation_plan", dod.implementation_plan), | |
| 711 | + ("verification_plan", dod.verification_plan), | |
| 712 | + ): | |
| 713 | + if not path_str: | |
| 714 | + continue | |
| 715 | + current_path = Path(path_str) | |
| 716 | + if not current_path.exists(): | |
| 717 | + continue | |
| 718 | + previous_path = _previous_artifact_path(current_path) | |
| 719 | + previous_text = previous_path.read_text() if previous_path and previous_path.exists() else "" | |
| 720 | + current_text = current_path.read_text() | |
| 721 | + entries.append( | |
| 722 | + ArtifactDiffEntry( | |
| 723 | + kind=kind, | |
| 724 | + current_path=current_path, | |
| 725 | + previous_path=previous_path, | |
| 726 | + highlights=_artifact_diff_highlights( | |
| 727 | + kind=kind, | |
| 728 | + current_path=current_path, | |
| 729 | + previous_path=previous_path, | |
| 730 | + previous_text=previous_text, | |
| 731 | + current_text=current_text, | |
| 732 | + ), | |
| 733 | + unified_diff=_unified_diff( | |
| 734 | + previous_text, | |
| 735 | + current_text, | |
| 736 | + from_label=str(previous_path) if previous_path else f"previous {kind}", | |
| 737 | + to_label=str(current_path), | |
| 738 | + ), | |
| 739 | + ) | |
| 740 | + ) | |
| 741 | + | |
| 742 | + highlights = [entry.highlights[0] for entry in entries if entry.highlights] | |
| 743 | + return WorkflowArtifactDiffSnapshot( | |
| 744 | + project_root=resolved_root, | |
| 745 | + session_id=snapshot.session_id, | |
| 746 | + current_task=snapshot.current_task, | |
| 747 | + entries=entries, | |
| 748 | + highlights=highlights, | |
| 749 | + ) | |
| 750 | + | |
| 751 | + | |
| 600 | 752 | def collect_permission_snapshot( |
| 601 | 753 | project_root: Path | str | None = None, |
| 602 | 754 | *, |
@@ -751,6 +903,161 @@ def dry_run_permission_check( | ||
| 751 | 903 | ) |
| 752 | 904 | |
| 753 | 905 | |
| 906 | +def _previous_prompt_snapshot( | |
| 907 | + history: list[PromptSnapshot], | |
| 908 | +) -> PromptSnapshot | None: | |
| 909 | + if len(history) < 2: | |
| 910 | + return None | |
| 911 | + current = history[-1] | |
| 912 | + for snapshot in reversed(history[:-1]): | |
| 913 | + if not snapshot.matches_contract(current): | |
| 914 | + return snapshot | |
| 915 | + return history[-2] | |
| 916 | + | |
| 917 | + | |
| 918 | +def _prompt_snapshot_label( | |
| 919 | + snapshot: PromptSnapshot | None, | |
| 920 | + *, | |
| 921 | + fallback: str, | |
| 922 | +) -> str: | |
| 923 | + if snapshot is None: | |
| 924 | + return fallback | |
| 925 | + return ( | |
| 926 | + f"{snapshot.timestamp} " | |
| 927 | + f"{snapshot.workflow_mode}/{snapshot.permission_mode}/{snapshot.prompt_format}" | |
| 928 | + ) | |
| 929 | + | |
| 930 | + | |
| 931 | +def _prompt_diff_highlights( | |
| 932 | + previous: PromptSnapshot | None, | |
| 933 | + current: PromptSnapshot | None, | |
| 934 | +) -> list[str]: | |
| 935 | + if current is None: | |
| 936 | + return [] | |
| 937 | + if previous is None: | |
| 938 | + return ["No earlier prompt snapshot is available for comparison."] | |
| 939 | + | |
| 940 | + highlights: list[str] = [] | |
| 941 | + if previous.workflow_mode != current.workflow_mode: | |
| 942 | + highlights.append( | |
| 943 | + f"Workflow mode changed: {previous.workflow_mode} -> {current.workflow_mode}" | |
| 944 | + ) | |
| 945 | + if previous.permission_mode != current.permission_mode: | |
| 946 | + highlights.append( | |
| 947 | + "Permission mode changed: " | |
| 948 | + f"{previous.permission_mode} -> {current.permission_mode}" | |
| 949 | + ) | |
| 950 | + if previous.prompt_format != current.prompt_format: | |
| 951 | + highlights.append( | |
| 952 | + f"Prompt format changed: {previous.prompt_format} -> {current.prompt_format}" | |
| 953 | + ) | |
| 954 | + if previous.current_task != current.current_task: | |
| 955 | + highlights.append("Task framing changed across prompt snapshots.") | |
| 956 | + added_sections = [item for item in current.prompt_sections if item not in previous.prompt_sections] | |
| 957 | + removed_sections = [item for item in previous.prompt_sections if item not in current.prompt_sections] | |
| 958 | + if added_sections: | |
| 959 | + highlights.append("Added sections: " + ", ".join(added_sections)) | |
| 960 | + if removed_sections: | |
| 961 | + highlights.append("Removed sections: " + ", ".join(removed_sections)) | |
| 962 | + additions, removals = _line_change_counts(previous.content, current.content) | |
| 963 | + highlights.append(f"Prompt body lines changed: +{additions} / -{removals}") | |
| 964 | + return highlights | |
| 965 | + | |
| 966 | + | |
| 967 | +def _previous_artifact_path(current_path: Path) -> Path | None: | |
| 968 | + name = current_path.name | |
| 969 | + if name in {"implementation.md", "verification.md"}: | |
| 970 | + parent = current_path.parent | |
| 971 | + if "-" not in parent.name: | |
| 972 | + return None | |
| 973 | + slug = parent.name.split("-", maxsplit=1)[1] | |
| 974 | + candidates = sorted( | |
| 975 | + item | |
| 976 | + for item in parent.parent.glob(f"*-{slug}") | |
| 977 | + if item.is_dir() and (item / name).exists() | |
| 978 | + ) | |
| 979 | + previous_dir = _previous_sorted_item(candidates, parent) | |
| 980 | + if previous_dir is None: | |
| 981 | + return None | |
| 982 | + return previous_dir / name | |
| 983 | + | |
| 984 | + stem = current_path.stem | |
| 985 | + if "-" not in stem: | |
| 986 | + return None | |
| 987 | + slug = stem.split("-", maxsplit=1)[1] | |
| 988 | + candidates = sorted( | |
| 989 | + item | |
| 990 | + for item in current_path.parent.glob(f"*-{slug}{current_path.suffix}") | |
| 991 | + if item.is_file() | |
| 992 | + ) | |
| 993 | + return _previous_sorted_item(candidates, current_path) | |
| 994 | + | |
| 995 | + | |
| 996 | +def _previous_sorted_item(items: list[Any], current: Any) -> Any | None: | |
| 997 | + previous: Any | None = None | |
| 998 | + for item in items: | |
| 999 | + if item == current: | |
| 1000 | + return previous | |
| 1001 | + previous = item | |
| 1002 | + return previous | |
| 1003 | + | |
| 1004 | + | |
| 1005 | +def _artifact_diff_highlights( | |
| 1006 | + *, | |
| 1007 | + kind: str, | |
| 1008 | + current_path: Path, | |
| 1009 | + previous_path: Path | None, | |
| 1010 | + previous_text: str, | |
| 1011 | + current_text: str, | |
| 1012 | +) -> list[str]: | |
| 1013 | + label = kind.replace("_", " ") | |
| 1014 | + if previous_path is None: | |
| 1015 | + return [f"{label}: no previous artifact version is available."] | |
| 1016 | + additions, removals = _line_change_counts(previous_text, current_text) | |
| 1017 | + if not additions and not removals: | |
| 1018 | + return [f"{label}: no content changes between persisted versions."] | |
| 1019 | + return [ | |
| 1020 | + f"{label}: +{additions} / -{removals} lines vs {previous_path.name}", | |
| 1021 | + f"current={current_path.name}", | |
| 1022 | + ] | |
| 1023 | + | |
| 1024 | + | |
| 1025 | +def _line_change_counts(previous_text: str, current_text: str) -> tuple[int, int]: | |
| 1026 | + additions = 0 | |
| 1027 | + removals = 0 | |
| 1028 | + diff_lines = difflib.unified_diff( | |
| 1029 | + previous_text.splitlines(), | |
| 1030 | + current_text.splitlines(), | |
| 1031 | + lineterm="", | |
| 1032 | + ) | |
| 1033 | + for line in diff_lines: | |
| 1034 | + if line.startswith(("---", "+++", "@@")): | |
| 1035 | + continue | |
| 1036 | + if line.startswith("+"): | |
| 1037 | + additions += 1 | |
| 1038 | + elif line.startswith("-"): | |
| 1039 | + removals += 1 | |
| 1040 | + return additions, removals | |
| 1041 | + | |
| 1042 | + | |
| 1043 | +def _unified_diff( | |
| 1044 | + previous_text: str, | |
| 1045 | + current_text: str, | |
| 1046 | + *, | |
| 1047 | + from_label: str, | |
| 1048 | + to_label: str, | |
| 1049 | +) -> str: | |
| 1050 | + return "\n".join( | |
| 1051 | + difflib.unified_diff( | |
| 1052 | + previous_text.splitlines(), | |
| 1053 | + current_text.splitlines(), | |
| 1054 | + fromfile=from_label, | |
| 1055 | + tofile=to_label, | |
| 1056 | + lineterm="", | |
| 1057 | + ) | |
| 1058 | + ) | |
| 1059 | + | |
| 1060 | + | |
| 754 | 1061 | def _coerce_permission_mode(value: PermissionMode | str) -> PermissionMode: |
| 755 | 1062 | if isinstance(value, PermissionMode): |
| 756 | 1063 | return value |
tests/test_inspection.pymodified@@ -15,13 +15,16 @@ from loader.runtime.inspection import ( | ||
| 15 | 15 | CheckStatus, |
| 16 | 16 | collect_doctor_report, |
| 17 | 17 | collect_permission_snapshot, |
| 18 | + collect_prompt_diff, | |
| 18 | 19 | collect_prompt_preview, |
| 19 | 20 | collect_status_snapshot, |
| 21 | + collect_workflow_artifact_diffs, | |
| 20 | 22 | collect_workflow_timeline, |
| 21 | 23 | dry_run_permission_check, |
| 22 | 24 | list_session_summaries, |
| 23 | 25 | load_session_detail, |
| 24 | 26 | ) |
| 27 | +from loader.runtime.prompt_history import PromptSnapshot | |
| 25 | 28 | from loader.runtime.session import SessionSnapshot, SessionStore |
| 26 | 29 | from loader.runtime.workflow_ledger import WorkflowLedger, WorkflowLedgerItem |
| 27 | 30 | from loader.runtime.workflow_policy import WorkflowTimelineEntry |
@@ -136,6 +139,31 @@ def _persist_session_with_dod(temp_dir: Path) -> tuple[str, str]: | ||
| 136 | 139 | permission_rules_source=str(temp_dir / ".loader" / "permission-rules.json"), |
| 137 | 140 | prompt_format="native", |
| 138 | 141 | prompt_sections=["Runtime Config", "Workflow Context", "Mode Guidance"], |
| 142 | + prompt_history=[ | |
| 143 | + PromptSnapshot( | |
| 144 | + timestamp="2026-04-06T12:04:00Z", | |
| 145 | + workflow_mode="verify", | |
| 146 | + permission_mode="prompt", | |
| 147 | + current_task="Fix the failing tests", | |
| 148 | + prompt_format="native", | |
| 149 | + prompt_sections=["Runtime Config", "Workflow Context", "Mode Guidance"], | |
| 150 | + content="# Introduction\nverify parser fix\n", | |
| 151 | + ), | |
| 152 | + PromptSnapshot( | |
| 153 | + timestamp="2026-04-06T12:05:00Z", | |
| 154 | + workflow_mode="execute", | |
| 155 | + permission_mode="prompt", | |
| 156 | + current_task="Fix the failing tests", | |
| 157 | + prompt_format="native", | |
| 158 | + prompt_sections=[ | |
| 159 | + "Runtime Config", | |
| 160 | + "Workflow Context", | |
| 161 | + "Mode Guidance", | |
| 162 | + "Project Context", | |
| 163 | + ], | |
| 164 | + content="# Introduction\nexecute parser fix\n# Project Context\npython\n", | |
| 165 | + ), | |
| 166 | + ], | |
| 139 | 167 | workflow_reason_code="verification_failed_reentry", |
| 140 | 168 | workflow_reason_summary="verification failed; returning to execute for fixes", |
| 141 | 169 | workflow_decision_kind="reentry", |
@@ -153,6 +181,40 @@ def _persist_session_with_dod(temp_dir: Path) -> tuple[str, str]: | ||
| 153 | 181 | |
| 154 | 182 | |
| 155 | 183 | def _persist_session_with_rich_workflow(temp_dir: Path) -> str: |
| 184 | + slug = "tighten-loader-workflow-behavior" | |
| 185 | + brief_old = temp_dir / ".loader" / "briefs" / f"20260406T150000Z-{slug}.md" | |
| 186 | + brief_new = temp_dir / ".loader" / "briefs" / f"20260406T150200Z-{slug}.md" | |
| 187 | + brief_old.write_text( | |
| 188 | + "# Task Brief\n\n## Likely Touchpoints\n- planned.txt\n\n## Acceptance Criteria\n- planned.txt exists.\n" | |
| 189 | + ) | |
| 190 | + brief_new.write_text( | |
| 191 | + "# Task Brief\n\n## Likely Touchpoints\n- notes.txt\n\n## Acceptance Criteria\n- notes.txt exists.\n" | |
| 192 | + ) | |
| 193 | + plan_old_root = temp_dir / ".loader" / "plans" / f"20260406T150100Z-{slug}" | |
| 194 | + plan_new_root = temp_dir / ".loader" / "plans" / f"20260406T150300Z-{slug}" | |
| 195 | + plan_old_root.mkdir(parents=True, exist_ok=True) | |
| 196 | + plan_new_root.mkdir(parents=True, exist_ok=True) | |
| 197 | + (plan_old_root / "implementation.md").write_text( | |
| 198 | + "# Implementation Plan\n\n## File Changes\n- Create planned.txt.\n" | |
| 199 | + ) | |
| 200 | + (plan_old_root / "verification.md").write_text( | |
| 201 | + "# Verification Plan\n\n## Acceptance Criteria\n- planned.txt exists.\n" | |
| 202 | + ) | |
| 203 | + (plan_new_root / "implementation.md").write_text( | |
| 204 | + "# Implementation Plan\n\n## File Changes\n- Keep notes.txt as the runtime artifact.\n" | |
| 205 | + ) | |
| 206 | + (plan_new_root / "verification.md").write_text( | |
| 207 | + "# Verification Plan\n\n## Acceptance Criteria\n- notes.txt exists.\n" | |
| 208 | + ) | |
| 209 | + | |
| 210 | + dod = create_definition_of_done("Tighten Loader workflow behavior") | |
| 211 | + dod.status = "fixing" | |
| 212 | + dod.clarify_brief = str(brief_new) | |
| 213 | + dod.implementation_plan = str(plan_new_root / "implementation.md") | |
| 214 | + dod.verification_plan = str(plan_new_root / "verification.md") | |
| 215 | + dod.acceptance_criteria = ["notes.txt exists in the workspace root."] | |
| 216 | + dod_path = DefinitionOfDoneStore(temp_dir).save(dod) | |
| 217 | + | |
| 156 | 218 | snapshot = SessionSnapshot( |
| 157 | 219 | session_id="20260406T150000Z-feedface", |
| 158 | 220 | created_at="2026-04-06T15:00:00Z", |
@@ -161,10 +223,38 @@ def _persist_session_with_rich_workflow(temp_dir: Path) -> str: | ||
| 161 | 223 | Message(role=Role.USER, content="Tighten Loader workflow behavior"), |
| 162 | 224 | Message(role=Role.ASSISTANT, content="I refreshed the workflow contract."), |
| 163 | 225 | ], |
| 226 | + active_dod_path=str(dod_path), | |
| 164 | 227 | current_task="Tighten Loader workflow behavior", |
| 165 | 228 | workflow_mode="execute", |
| 166 | 229 | permission_mode="prompt", |
| 167 | 230 | permission_prompting_enabled=True, |
| 231 | + prompt_format="native", | |
| 232 | + prompt_sections=["Runtime Config", "Workflow Context", "Mode Guidance"], | |
| 233 | + prompt_history=[ | |
| 234 | + PromptSnapshot( | |
| 235 | + timestamp="2026-04-06T15:02:00Z", | |
| 236 | + workflow_mode="plan", | |
| 237 | + permission_mode="prompt", | |
| 238 | + current_task="Tighten Loader workflow behavior", | |
| 239 | + prompt_format="native", | |
| 240 | + prompt_sections=["Runtime Config", "Workflow Context", "Mode Guidance"], | |
| 241 | + content="# Introduction\nplan around planned.txt\n", | |
| 242 | + ), | |
| 243 | + PromptSnapshot( | |
| 244 | + timestamp="2026-04-06T15:04:00Z", | |
| 245 | + workflow_mode="execute", | |
| 246 | + permission_mode="prompt", | |
| 247 | + current_task="Tighten Loader workflow behavior", | |
| 248 | + prompt_format="native", | |
| 249 | + prompt_sections=[ | |
| 250 | + "Runtime Config", | |
| 251 | + "Workflow Context", | |
| 252 | + "Mode Guidance", | |
| 253 | + "Project Context", | |
| 254 | + ], | |
| 255 | + content="# Introduction\nexecute around notes.txt\n# Project Context\npython\n", | |
| 256 | + ), | |
| 257 | + ], | |
| 168 | 258 | workflow_reason_code="full_replan_completed", |
| 169 | 259 | workflow_reason_summary="clarify and plan artifacts refreshed; returning to execute", |
| 170 | 260 | workflow_decision_kind="handoff", |
@@ -200,6 +290,11 @@ def _persist_session_with_rich_workflow(temp_dir: Path) -> str: | ||
| 200 | 290 | ), |
| 201 | 291 | ], |
| 202 | 292 | signal_summary=["recent_reentry=1", "stale_plan=true"], |
| 293 | + artifact_paths=[ | |
| 294 | + str(brief_new), | |
| 295 | + str(plan_new_root / "implementation.md"), | |
| 296 | + str(plan_new_root / "verification.md"), | |
| 297 | + ], | |
| 203 | 298 | ), |
| 204 | 299 | WorkflowTimelineEntry( |
| 205 | 300 | timestamp="2026-04-06T15:03:00Z", |
@@ -459,6 +554,43 @@ def test_collect_workflow_timeline_supports_filters_and_highlights( | ||
| 459 | 554 | ) |
| 460 | 555 | |
| 461 | 556 | |
| 557 | +def test_collect_prompt_diff_uses_persisted_prompt_history(temp_dir: Path) -> None: | |
| 558 | + _write_python_workspace(temp_dir) | |
| 559 | + _ensure_loader_dirs(temp_dir) | |
| 560 | + session_id, _ = _persist_session_with_dod(temp_dir) | |
| 561 | + | |
| 562 | + diff = collect_prompt_diff(project_root=temp_dir) | |
| 563 | + | |
| 564 | + assert diff.session_id == session_id | |
| 565 | + assert diff.previous is not None | |
| 566 | + assert diff.current is not None | |
| 567 | + assert diff.current.workflow_mode == "execute" | |
| 568 | + assert diff.previous.workflow_mode == "verify" | |
| 569 | + assert any("Workflow mode changed:" in item for item in diff.highlights) | |
| 570 | + assert "---" in diff.unified_diff | |
| 571 | + assert "execute parser fix" in diff.unified_diff | |
| 572 | + | |
| 573 | + | |
| 574 | +def test_collect_workflow_artifact_diffs_reads_versioned_artifacts( | |
| 575 | + temp_dir: Path, | |
| 576 | +) -> None: | |
| 577 | + _write_python_workspace(temp_dir) | |
| 578 | + _ensure_loader_dirs(temp_dir) | |
| 579 | + session_id = _persist_session_with_rich_workflow(temp_dir) | |
| 580 | + | |
| 581 | + snapshot = collect_workflow_artifact_diffs(project_root=temp_dir) | |
| 582 | + | |
| 583 | + assert snapshot.session_id == session_id | |
| 584 | + assert len(snapshot.entries) == 3 | |
| 585 | + assert {entry.kind for entry in snapshot.entries} == { | |
| 586 | + "clarify_brief", | |
| 587 | + "implementation_plan", | |
| 588 | + "verification_plan", | |
| 589 | + } | |
| 590 | + assert any("notes.txt" in entry.unified_diff for entry in snapshot.entries) | |
| 591 | + assert snapshot.highlights | |
| 592 | + | |
| 593 | + | |
| 462 | 594 | def test_status_and_session_commands_render_persisted_state( |
| 463 | 595 | temp_dir: Path, |
| 464 | 596 | monkeypatch: pytest.MonkeyPatch, |
@@ -575,6 +707,31 @@ def test_workflow_show_command_supports_filters_and_highlights( | ||
| 575 | 707 | assert "gates=non_goals,decision_boundaries" in clarify_result.output |
| 576 | 708 | |
| 577 | 709 | |
| 710 | +def test_workflow_show_can_render_artifact_diffs( | |
| 711 | + temp_dir: Path, | |
| 712 | + monkeypatch: pytest.MonkeyPatch, | |
| 713 | +) -> None: | |
| 714 | + _write_python_workspace(temp_dir) | |
| 715 | + _ensure_loader_dirs(temp_dir) | |
| 716 | + _persist_session_with_rich_workflow(temp_dir) | |
| 717 | + runner = CliRunner() | |
| 718 | + | |
| 719 | + monkeypatch.chdir(temp_dir) | |
| 720 | + | |
| 721 | + result = runner.invoke( | |
| 722 | + cli_main_module.workflow_cli, | |
| 723 | + ["show", "--diff", "--full-diff"], | |
| 724 | + ) | |
| 725 | + | |
| 726 | + assert result.exit_code == 0 | |
| 727 | + assert "Artifact Changes" in result.output | |
| 728 | + assert "Artifact Diff Summary" in result.output | |
| 729 | + assert "clarify_brief" in result.output | |
| 730 | + assert "implementation_plan" in result.output | |
| 731 | + assert "verification_plan" in result.output | |
| 732 | + assert "notes.txt" in result.output | |
| 733 | + | |
| 734 | + | |
| 578 | 735 | def test_collect_prompt_preview_uses_persisted_runtime_state(temp_dir: Path) -> None: |
| 579 | 736 | _write_python_workspace(temp_dir) |
| 580 | 737 | _ensure_loader_dirs(temp_dir) |
@@ -634,6 +791,27 @@ def test_prompt_show_command_renders_preview_without_model_call( | ||
| 634 | 791 | assert "Execute Mode" in result.output |
| 635 | 792 | |
| 636 | 793 | |
| 794 | +def test_prompt_diff_command_renders_persisted_prompt_changes( | |
| 795 | + temp_dir: Path, | |
| 796 | + monkeypatch: pytest.MonkeyPatch, | |
| 797 | +) -> None: | |
| 798 | + _write_python_workspace(temp_dir) | |
| 799 | + _ensure_loader_dirs(temp_dir) | |
| 800 | + _persist_session_with_dod(temp_dir) | |
| 801 | + runner = CliRunner() | |
| 802 | + | |
| 803 | + monkeypatch.chdir(temp_dir) | |
| 804 | + | |
| 805 | + result = runner.invoke(cli_main_module.prompt_cli, ["diff", "--full"]) | |
| 806 | + | |
| 807 | + assert result.exit_code == 0 | |
| 808 | + assert "Prompt Diff" in result.output | |
| 809 | + assert "Prompt Changes" in result.output | |
| 810 | + assert "Workflow mode changed:" in result.output | |
| 811 | + assert "Prompt Unified Diff" in result.output | |
| 812 | + assert "execute parser fix" in result.output | |
| 813 | + | |
| 814 | + | |
| 637 | 815 | def test_permission_snapshot_and_dry_run_reflect_rules(temp_dir: Path) -> None: |
| 638 | 816 | _write_python_workspace(temp_dir) |
| 639 | 817 | _ensure_loader_dirs(temp_dir) |