Show workflow ledger in inspection surfaces
- SHA
8893a2babdc532089e833fd6a810aaa630d621db- Parents
-
383a8c1 - Tree
13fbe06
8893a2b
8893a2babdc532089e833fd6a810aaa630d621db383a8c1
13fbe06| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/cli/main.py
|
41 | 0 |
| M |
src/loader/runtime/inspection.py
|
6 | 1 |
| M |
tests/test_inspection.py
|
62 | 0 |
src/loader/cli/main.pymodified@@ -1564,6 +1564,12 @@ def _workflow_show_main( | ||
| 1564 | 1564 | snapshot.highlights, |
| 1565 | 1565 | title="[bold blue]Workflow Answers[/bold blue]", |
| 1566 | 1566 | ) |
| 1567 | + if snapshot.workflow_ledger.has_items(): | |
| 1568 | + console.print() | |
| 1569 | + _print_workflow_ledger( | |
| 1570 | + snapshot.workflow_ledger, | |
| 1571 | + title="[bold blue]Workflow Ledger[/bold blue]", | |
| 1572 | + ) | |
| 1567 | 1573 | console.print() |
| 1568 | 1574 | _print_workflow_timeline_entries( |
| 1569 | 1575 | snapshot.entries, |
@@ -1845,6 +1851,41 @@ def _format_workflow_timeline_context(entry) -> str: | ||
| 1845 | 1851 | return ", ".join(parts) or "-" |
| 1846 | 1852 | |
| 1847 | 1853 | |
| 1854 | +def _print_workflow_ledger( | |
| 1855 | + ledger, | |
| 1856 | + *, | |
| 1857 | + title: str, | |
| 1858 | +) -> None: | |
| 1859 | + table = Table(show_header=False, box=None) | |
| 1860 | + table.add_column("Section", style="bold cyan") | |
| 1861 | + table.add_column("Details", style="white") | |
| 1862 | + | |
| 1863 | + for label, items in ( | |
| 1864 | + ("Assumptions", ledger.assumptions), | |
| 1865 | + ("Acceptance Anchors", ledger.acceptance_anchors), | |
| 1866 | + ("Decision Boundaries", ledger.decision_boundaries), | |
| 1867 | + ): | |
| 1868 | + if not items: | |
| 1869 | + continue | |
| 1870 | + table.add_row( | |
| 1871 | + label, | |
| 1872 | + "\n".join(_format_workflow_ledger_item(item) for item in items[:4]), | |
| 1873 | + ) | |
| 1874 | + | |
| 1875 | + console.print(Panel.fit(table, title=title, border_style="blue")) | |
| 1876 | + | |
| 1877 | + | |
| 1878 | +def _format_workflow_ledger_item(item) -> str: | |
| 1879 | + details = [item.status] | |
| 1880 | + if item.updated_phase and item.updated_phase != item.introduced_phase: | |
| 1881 | + details.append(f"updated={item.updated_phase}") | |
| 1882 | + elif item.introduced_phase: | |
| 1883 | + details.append(f"phase={item.introduced_phase}") | |
| 1884 | + if item.evidence: | |
| 1885 | + details.append(f"evidence={item.evidence[0]}") | |
| 1886 | + return f"- {item.text} ({', '.join(details)})" | |
| 1887 | + | |
| 1888 | + | |
| 1848 | 1889 | def _print_workflow_highlights( |
| 1849 | 1890 | highlights: list[str], |
| 1850 | 1891 | *, |
src/loader/runtime/inspection.pymodified@@ -26,6 +26,7 @@ from .permissions import ( | ||
| 26 | 26 | ) |
| 27 | 27 | from .prompting import build_system_prompt_result |
| 28 | 28 | from .session import SessionSnapshot, SessionStore |
| 29 | +from .workflow_ledger import WorkflowLedger, workflow_ledger_highlights | |
| 29 | 30 | from .workflow_policy import WorkflowTimelineEntry |
| 30 | 31 | |
| 31 | 32 | |
@@ -251,6 +252,7 @@ class WorkflowTimelineSnapshot: | ||
| 251 | 252 | entry_limit: int | None = None |
| 252 | 253 | highlights: list[str] = field(default_factory=list) |
| 253 | 254 | entries: list[WorkflowTimelineEntry] = field(default_factory=list) |
| 255 | + workflow_ledger: WorkflowLedger = field(default_factory=WorkflowLedger) | |
| 254 | 256 | |
| 255 | 257 | |
| 256 | 258 | def capability_summary(profile: CapabilityProfile) -> str: |
@@ -656,6 +658,7 @@ def collect_workflow_timeline( | ||
| 656 | 658 | entry_limit=limit, |
| 657 | 659 | highlights=[], |
| 658 | 660 | entries=[], |
| 661 | + workflow_ledger=WorkflowLedger(), | |
| 659 | 662 | ) |
| 660 | 663 | |
| 661 | 664 | filtered_entries = list(snapshot.workflow_timeline) |
@@ -664,6 +667,7 @@ def collect_workflow_timeline( | ||
| 664 | 667 | if kind: |
| 665 | 668 | filtered_entries = [entry for entry in filtered_entries if entry.kind == kind] |
| 666 | 669 | highlights = _workflow_timeline_highlights(filtered_entries) |
| 670 | + highlights.extend(workflow_ledger_highlights(snapshot.workflow_ledger)) | |
| 667 | 671 | if limit is not None: |
| 668 | 672 | filtered_entries = filtered_entries[-limit:] |
| 669 | 673 | |
@@ -677,8 +681,9 @@ def collect_workflow_timeline( | ||
| 677 | 681 | selected_mode=mode, |
| 678 | 682 | selected_kind=kind, |
| 679 | 683 | entry_limit=limit, |
| 680 | - highlights=highlights, | |
| 684 | + highlights=list(dict.fromkeys(highlights)), | |
| 681 | 685 | entries=filtered_entries, |
| 686 | + workflow_ledger=snapshot.workflow_ledger.copy(), | |
| 682 | 687 | ) |
| 683 | 688 | |
| 684 | 689 | |
tests/test_inspection.pymodified@@ -23,6 +23,7 @@ from loader.runtime.inspection import ( | ||
| 23 | 23 | load_session_detail, |
| 24 | 24 | ) |
| 25 | 25 | from loader.runtime.session import SessionSnapshot, SessionStore |
| 26 | +from loader.runtime.workflow_ledger import WorkflowLedger, WorkflowLedgerItem | |
| 26 | 27 | from loader.runtime.workflow_policy import WorkflowTimelineEntry |
| 27 | 28 | |
| 28 | 29 | |
@@ -210,6 +211,40 @@ def _persist_session_with_rich_workflow(temp_dir: Path) -> str: | ||
| 210 | 211 | signal_summary=["verify_pressure=low"], |
| 211 | 212 | ), |
| 212 | 213 | ], |
| 214 | + workflow_ledger=WorkflowLedger( | |
| 215 | + assumptions=[ | |
| 216 | + WorkflowLedgerItem( | |
| 217 | + text="notes.txt stays out of scope unless clarified otherwise.", | |
| 218 | + status="contradicted", | |
| 219 | + introduced_phase="clarify", | |
| 220 | + updated_phase="recovery", | |
| 221 | + evidence=["Clarify scope assumed `notes.txt` stayed out of scope."], | |
| 222 | + ) | |
| 223 | + ], | |
| 224 | + acceptance_anchors=[ | |
| 225 | + WorkflowLedgerItem( | |
| 226 | + text="notes.txt exists in the workspace root.", | |
| 227 | + status="changed", | |
| 228 | + introduced_phase="clarify", | |
| 229 | + updated_phase="recovery", | |
| 230 | + evidence=[ | |
| 231 | + ( | |
| 232 | + "Failed verification exposed missing brief coverage for " | |
| 233 | + "`notes.txt exists`." | |
| 234 | + ) | |
| 235 | + ], | |
| 236 | + ) | |
| 237 | + ], | |
| 238 | + decision_boundaries=[ | |
| 239 | + WorkflowLedgerItem( | |
| 240 | + text="Escalate before broad UX changes.", | |
| 241 | + status="reopened", | |
| 242 | + introduced_phase="clarify", | |
| 243 | + updated_phase="recovery", | |
| 244 | + evidence=["The active task framing outgrew the persisted clarify brief."], | |
| 245 | + ) | |
| 246 | + ], | |
| 247 | + ), | |
| 213 | 248 | ) |
| 214 | 249 | SessionStore(temp_dir).save(snapshot) |
| 215 | 250 | return snapshot.session_id |
@@ -417,6 +452,11 @@ def test_collect_workflow_timeline_supports_filters_and_highlights( | ||
| 417 | 452 | "decision_boundaries", |
| 418 | 453 | ] |
| 419 | 454 | assert any(item.startswith("Asked again:") for item in snapshot.highlights) |
| 455 | + assert snapshot.workflow_ledger.assumptions[0].status == "contradicted" | |
| 456 | + assert any( | |
| 457 | + item.startswith("Contradicted assumptions:") | |
| 458 | + for item in snapshot.highlights | |
| 459 | + ) | |
| 420 | 460 | |
| 421 | 461 | |
| 422 | 462 | def test_status_and_session_commands_render_persisted_state( |
@@ -477,6 +517,28 @@ def test_status_and_session_commands_render_persisted_state( | ||
| 477 | 517 | assert "next=verify" in workflow_result.output |
| 478 | 518 | |
| 479 | 519 | |
| 520 | +def test_workflow_show_renders_workflow_ledger( | |
| 521 | + temp_dir: Path, | |
| 522 | + monkeypatch: pytest.MonkeyPatch, | |
| 523 | +) -> None: | |
| 524 | + _write_python_workspace(temp_dir) | |
| 525 | + _ensure_loader_dirs(temp_dir) | |
| 526 | + _persist_session_with_rich_workflow(temp_dir) | |
| 527 | + runner = CliRunner() | |
| 528 | + | |
| 529 | + monkeypatch.chdir(temp_dir) | |
| 530 | + | |
| 531 | + result = runner.invoke(cli_main_module.workflow_cli, ["show"]) | |
| 532 | + | |
| 533 | + assert result.exit_code == 0 | |
| 534 | + assert "Workflow Ledger" in result.output | |
| 535 | + assert "Assumptions" in result.output | |
| 536 | + assert "contradicted" in result.output | |
| 537 | + assert "notes.txt stays out of scope" in result.output | |
| 538 | + assert "Acceptance Anchors" in result.output | |
| 539 | + assert "Decision Boundaries" in result.output | |
| 540 | + | |
| 541 | + | |
| 480 | 542 | def test_workflow_show_command_supports_filters_and_highlights( |
| 481 | 543 | temp_dir: Path, |
| 482 | 544 | monkeypatch: pytest.MonkeyPatch, |