xrefkit 0.4.10__tar.gz → 0.4.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {xrefkit-0.4.10/xrefkit.egg-info → xrefkit-0.4.11}/PKG-INFO +1 -1
  2. {xrefkit-0.4.10 → xrefkit-0.4.11}/pyproject.toml +1 -1
  3. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_dashboard.py +30 -0
  4. xrefkit-0.4.11/tests/test_decision_trace.py +301 -0
  5. xrefkit-0.4.11/tests/test_wbs.py +43 -0
  6. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/__init__.py +1 -1
  7. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/cli.py +2 -1
  8. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/dashboard.py +82 -0
  9. xrefkit-0.4.11/xrefkit/decision_trace.py +801 -0
  10. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/catalog.py +12 -0
  11. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp_tools.py +1 -0
  12. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/operations_cli.py +9 -0
  13. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/skillrun.py +96 -0
  14. xrefkit-0.4.11/xrefkit/wbs.py +159 -0
  15. {xrefkit-0.4.10 → xrefkit-0.4.11/xrefkit.egg-info}/PKG-INFO +1 -1
  16. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit.egg-info/SOURCES.txt +4 -0
  17. {xrefkit-0.4.10 → xrefkit-0.4.11}/LICENSE +0 -0
  18. {xrefkit-0.4.10 → xrefkit-0.4.11}/README.md +0 -0
  19. {xrefkit-0.4.10 → xrefkit-0.4.11}/setup.cfg +0 -0
  20. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_base_sync_ownership.py +0 -0
  21. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_boundary_analysis.py +0 -0
  22. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_brownfield_file_editing_protocol.py +0 -0
  23. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_calibration_lint.py +0 -0
  24. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_check_skill_knowledge_xids.py +0 -0
  25. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_cli.py +0 -0
  26. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_collect_analyzer_sarif.py +0 -0
  27. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_convert_to_xrefkit_skill.py +0 -0
  28. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_cs_scope_probe.py +0 -0
  29. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_csharp_commonality.py +0 -0
  30. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_csharp_naming_profile.py +0 -0
  31. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_ctx.py +0 -0
  32. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_cutover_readiness.py +0 -0
  33. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_error_policy_audit.py +0 -0
  34. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_error_policy_locator.py +0 -0
  35. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_fm_multiroot.py +0 -0
  36. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_gate.py +0 -0
  37. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_goal_desired_state.py +0 -0
  38. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_host_precheck.py +0 -0
  39. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_human_evaluation.py +0 -0
  40. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_instruction_workflow.py +0 -0
  41. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_knowledge_relations_validator.py +0 -0
  42. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_mcp_setup.py +0 -0
  43. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_ownership.py +0 -0
  44. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_packmeta.py +0 -0
  45. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_project_quality_baseline.py +0 -0
  46. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_resource_provider.py +0 -0
  47. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_runtime_contracts.py +0 -0
  48. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_sarif_to_locator.py +0 -0
  49. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_skill_runtime_audit.py +0 -0
  50. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_skillmeta.py +0 -0
  51. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_skills_sync.py +0 -0
  52. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_structure_catalog.py +0 -0
  53. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xref.py +0 -0
  54. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xrefkit_instance.py +0 -0
  55. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xrefkit_tools.py +0 -0
  56. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xrefkit_v2_discovery.py +0 -0
  57. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xrefkit_v2_models.py +0 -0
  58. {xrefkit-0.4.10 → xrefkit-0.4.11}/tests/test_xrefkit_v2_pipeline.py +0 -0
  59. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/__main__.py +0 -0
  60. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/boundary_analysis.py +0 -0
  61. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/catalog_cli.py +0 -0
  62. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/contracts.py +0 -0
  63. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/ctx.py +0 -0
  64. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/discovery.py +0 -0
  65. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/gate.py +0 -0
  66. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/goalstate.py +0 -0
  67. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/hashing.py +0 -0
  68. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/host_precheck.py +0 -0
  69. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/import_skill.py +0 -0
  70. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/instance.py +0 -0
  71. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/loaders.py +0 -0
  72. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/__init__.py +0 -0
  73. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/audit.py +0 -0
  74. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/bootstrap.py +0 -0
  75. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/cli.py +0 -0
  76. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/client_cache.py +0 -0
  77. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/client_flow.py +0 -0
  78. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/context_registry.py +0 -0
  79. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/context_token.py +0 -0
  80. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/contracts.py +0 -0
  81. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/dist.py +0 -0
  82. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/ownership.py +0 -0
  83. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/repository.py +0 -0
  84. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/schemas.py +0 -0
  85. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/server.py +0 -0
  86. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/setup.py +0 -0
  87. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/mcp/startup_contract_pack.py +0 -0
  88. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/__init__.py +0 -0
  89. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/common.py +0 -0
  90. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/effective_bundle.py +0 -0
  91. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/human_evaluation.py +0 -0
  92. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/local_manifest.py +0 -0
  93. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/package_manifest.py +0 -0
  94. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/run_log.py +0 -0
  95. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/server_config.py +0 -0
  96. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/models/skill_definition.py +0 -0
  97. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/ownership.py +0 -0
  98. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/packmeta.py +0 -0
  99. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/registry.py +0 -0
  100. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resolver.py +0 -0
  101. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resource_provider.py +0 -0
  102. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/contracts.json +0 -0
  103. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/current.json +0 -0
  104. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/generations/7a682a5272907354/contracts.json +0 -0
  105. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/generations/7a682a5272907354/model_body.md +0 -0
  106. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +0 -0
  107. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +0 -0
  108. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/resources/base/model_body.md +0 -0
  109. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/runlog.py +0 -0
  110. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/skillmeta.py +0 -0
  111. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/skills_sync.py +0 -0
  112. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/structure_catalog.py +0 -0
  113. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/tools/__init__.py +0 -0
  114. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/tools/__main__.py +0 -0
  115. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/v2_cli.py +0 -0
  116. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/workspace.py +0 -0
  117. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit/xref.py +0 -0
  118. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit.egg-info/dependency_links.txt +0 -0
  119. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit.egg-info/entry_points.txt +0 -0
  120. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit.egg-info/requires.txt +0 -0
  121. {xrefkit-0.4.10 → xrefkit-0.4.11}/xrefkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xrefkit
3
- Version: 0.4.10
3
+ Version: 0.4.11
4
4
  Summary: Portable XID, Skill, Knowledge, workflow, and MCP runtime for XRefKit
5
5
  Author: synthaicode
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "xrefkit"
7
- version = "0.4.10"
7
+ version = "0.4.11"
8
8
  description = "Portable XID, Skill, Knowledge, workflow, and MCP runtime for XRefKit"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -628,3 +628,33 @@ class DashboardTests(unittest.TestCase):
628
628
  self.assertIn("<script>alert(1)</script>", html)
629
629
  self.assertIn("<script>alert(2)</script>", html)
630
630
  self.assertNotIn("<script>alert(1)", html)
631
+
632
+ def test_dashboard_includes_decision_trace_panel_and_records(self) -> None:
633
+ with tempfile.TemporaryDirectory() as tmp:
634
+ root = Path(tmp)
635
+ ledger = root / "work" / "decision-trace" / "events.jsonl"
636
+ ledger.parent.mkdir(parents=True, exist_ok=True)
637
+ ledger.write_text(
638
+ json.dumps({
639
+ "recorded_by": "ai_protocol",
640
+ "event_id": "DEC-001",
641
+ "event_type": "decision-change",
642
+ "status": "provisional",
643
+ "resolution": None,
644
+ "reason": "try Y",
645
+ "depends_on": [],
646
+ "branch": "hypothesis/decision-Y",
647
+ }) + "\n",
648
+ encoding="utf-8",
649
+ )
650
+ payload = build_payload(root, root / "work" / "sessions")
651
+ html = _html_page(payload)
652
+
653
+ self.assertEqual(payload["decision_trace"]["summary"]["events"], 1)
654
+ self.assertEqual(payload["decision_trace"]["summary"]["groups"], {"decision": 1})
655
+ self.assertIn('data-panel="decision-trace"', html)
656
+ self.assertIn('id="decision-trace"', html)
657
+ self.assertIn("DEC-001", html)
658
+ self.assertIn("hypothesis/decision-Y", html)
659
+ self.assertIn("Dependency graph (Mermaid)", html)
660
+ self.assertIn("Impact groups", html)
@@ -0,0 +1,301 @@
1
+ import json
2
+ import argparse
3
+ import subprocess
4
+ import uuid
5
+
6
+ from xrefkit.decision_trace import main
7
+ from xrefkit.skillrun import run_workflow_instruction
8
+
9
+
10
+ def test_event_impact_and_graph(tmp_path, capsys):
11
+ assert main([
12
+ "event", "--root", str(tmp_path), "--event-id", "DEC-1",
13
+ "--event-type", "decision-change", "--reason", "initial decision",
14
+ ]) == 0
15
+ assert main([
16
+ "event", "--root", str(tmp_path), "--event-id", "EVAL-1",
17
+ "--event-type", "evaluation", "--reason", "evaluate provisional result",
18
+ "--depends-on", "DEC-1",
19
+ ]) == 0
20
+ capsys.readouterr()
21
+
22
+ assert main(["impact", "--root", str(tmp_path), "--event-id", "DEC-1", "--json"]) == 0
23
+ impact = json.loads(capsys.readouterr().out)
24
+ assert impact["direct_impact"] == ["EVAL-1"]
25
+ assert impact["needs_human_review"] == ["EVAL-1"]
26
+ assert impact["summary"]["groups"] == {"evaluation": 1}
27
+ assert impact["groups"]["evaluation"][0]["review_required"] is True
28
+
29
+ assert main(["graph", "--root", str(tmp_path)]) == 0
30
+ graph = capsys.readouterr().out
31
+ assert "DEC-1<br/>provisional" in graph
32
+ assert "EVAL-1<br/>provisional" in graph
33
+ assert "-->" in graph
34
+
35
+
36
+ def test_duplicate_event_is_rejected(tmp_path):
37
+ args = [
38
+ "event", "--root", str(tmp_path), "--event-id", "DEC-1",
39
+ "--event-type", "decision-note", "--reason", "record",
40
+ ]
41
+ assert main(args) == 0
42
+ assert main(args) == 2
43
+ assert len((tmp_path / "work" / "decision-trace" / "events.jsonl").read_text(encoding="utf-8").splitlines()) == 1
44
+
45
+
46
+ def test_checkpoint_commits_manifest_and_creates_tag(tmp_path):
47
+ def git(*args):
48
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
49
+
50
+ git("init")
51
+ git("config", "user.email", "test@example.invalid")
52
+ git("config", "user.name", "Decision Trace Test")
53
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
54
+ git("add", "README.md")
55
+ git("commit", "-m", "initial")
56
+
57
+ assert main([
58
+ "checkpoint", "--root", str(tmp_path), "--checkpoint-id", "CP-1",
59
+ "--purpose", "before AI run", "--json",
60
+ ]) == 0
61
+ manifest = tmp_path / "work" / "decision-trace" / "checkpoints" / "CP-1.json"
62
+ assert manifest.exists()
63
+ assert json.loads(manifest.read_text(encoding="utf-8"))["git_commit"]
64
+ assert git("rev-parse", "checkpoint/CP-1^{}").stdout.strip() == git("rev-parse", "HEAD").stdout.strip()
65
+ assert "checkpoint: CP-1" in git("log", "-1", "--pretty=%s").stdout
66
+
67
+
68
+ def test_checkpoint_stops_on_dirty_worktree(tmp_path):
69
+ subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
70
+ (tmp_path / "README.md").write_text("uncommitted\n", encoding="utf-8")
71
+ assert main([
72
+ "checkpoint", "--root", str(tmp_path), "--checkpoint-id", "CP-1",
73
+ "--purpose", "before AI run",
74
+ ]) == 2
75
+
76
+
77
+ def test_branch_is_created_from_fixed_ref(tmp_path):
78
+ def git(*args):
79
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
80
+
81
+ git("init")
82
+ git("config", "user.email", "test@example.invalid")
83
+ git("config", "user.name", "Decision Trace Test")
84
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
85
+ git("add", "README.md")
86
+ git("commit", "-m", "initial")
87
+ base = git("rev-parse", "HEAD").stdout.strip()
88
+
89
+ assert main([
90
+ "branch", "--root", str(tmp_path), "--branch", "hypothesis/decision-Y",
91
+ "--from-ref", "HEAD", "--purpose", "provisional decision Y", "--json",
92
+ ]) == 0
93
+ assert git("rev-parse", "hypothesis/decision-Y").stdout.strip() == base
94
+
95
+
96
+ def test_branch_delete_preserves_disposal_adr(tmp_path):
97
+ def git(*args):
98
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
99
+
100
+ git("init")
101
+ git("config", "user.email", "test@example.invalid")
102
+ git("config", "user.name", "Decision Trace Test")
103
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
104
+ git("add", "README.md")
105
+ git("commit", "-m", "initial")
106
+ git("branch", "hypothesis/decision-Y")
107
+
108
+ assert main([
109
+ "branch-delete", "--root", str(tmp_path), "--branch", "hypothesis/decision-Y",
110
+ "--event-id", "ADR-002", "--reason", "performance evaluation failed", "--force", "--json",
111
+ ]) == 0
112
+ assert git("branch", "--list", "hypothesis/decision-Y").stdout.strip() == ""
113
+ events = [
114
+ json.loads(line)
115
+ for line in (tmp_path / "work" / "decision-trace" / "events.jsonl").read_text(encoding="utf-8").splitlines()
116
+ ]
117
+ assert [event["status"] for event in events] == ["disposal-requested", "deleted"]
118
+ assert events[1]["depends_on"] == ["ADR-002"]
119
+
120
+
121
+ def test_resolution_is_a_new_final_event(tmp_path):
122
+ assert main([
123
+ "event", "--root", str(tmp_path), "--event-id", "DEC-001",
124
+ "--event-type", "decision-change", "--reason", "try Y",
125
+ ]) == 0
126
+ assert main([
127
+ "resolve", "--root", str(tmp_path), "--event-id", "RES-001",
128
+ "--target-event-id", "DEC-001", "--resolution", "rejected",
129
+ "--reason", "evaluation failed", "--json",
130
+ ]) == 0
131
+ events = [
132
+ json.loads(line)
133
+ for line in (tmp_path / "work" / "decision-trace" / "events.jsonl").read_text(encoding="utf-8").splitlines()
134
+ ]
135
+ assert events[0]["status"] == "provisional"
136
+ assert events[0]["resolution"] is None
137
+ assert events[1]["event_type"] == "resolution"
138
+ assert events[1]["resolution"] == "rejected"
139
+ assert events[1]["target_event_id"] == "DEC-001"
140
+
141
+
142
+ def test_return_check_and_execute_checkpoint_before_branching(tmp_path):
143
+ def git(*args):
144
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
145
+
146
+ git("init")
147
+ git("config", "user.email", "test@example.invalid")
148
+ git("config", "user.name", "Decision Trace Test")
149
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
150
+ git("add", "README.md")
151
+ git("commit", "-m", "initial")
152
+ assert main([
153
+ "event", "--root", str(tmp_path), "--event-id", "DEC-001",
154
+ "--event-type", "decision-note", "--reason", "historical decision",
155
+ ]) == 0
156
+
157
+ assert main([
158
+ "return-check", "--root", str(tmp_path), "--target-event-id", "DEC-001",
159
+ "--to-ref", "HEAD", "--json",
160
+ ]) == 0
161
+ assert main([
162
+ "return-execute", "--root", str(tmp_path), "--event-id", "RET-001",
163
+ "--target-event-id", "DEC-001", "--to-ref", "HEAD",
164
+ "--branch", "hypothesis/resume-DEC-001", "--checkpoint-id", "CP-RETURN-001",
165
+ "--reason", "reconsider historical decision", "--confirmed", "--json",
166
+ ]) == 0
167
+ assert git("branch", "--list", "hypothesis/resume-DEC-001").stdout.strip()
168
+ events = [
169
+ json.loads(line)
170
+ for line in (tmp_path / "work" / "decision-trace" / "events.jsonl").read_text(encoding="utf-8").splitlines()
171
+ ]
172
+ assert events[-1]["event_type"] == "return"
173
+ assert events[-1]["status"] == "provisional"
174
+ assert events[-1]["human_decision"] == "confirmed"
175
+
176
+
177
+ def test_current_only_context_contains_only_dependency_lineage(tmp_path, capsys):
178
+ for event_id, event_type, reason, dependencies in [
179
+ ("DEC-001", "decision-note", "old decision", []),
180
+ ("DEC-002", "decision-change", "new decision", ["DEC-001"]),
181
+ ("EVAL-001", "evaluation", "evaluate new decision", ["DEC-002"]),
182
+ ("DEC-OLD", "decision-note", "unrelated old path", []),
183
+ ]:
184
+ command = [
185
+ "event", "--root", str(tmp_path), "--event-id", event_id,
186
+ "--event-type", event_type, "--reason", reason,
187
+ ]
188
+ for dependency in dependencies:
189
+ command.extend(["--depends-on", dependency])
190
+ assert main(command) == 0
191
+ capsys.readouterr()
192
+
193
+ assert main(["context", "--root", str(tmp_path), "--event-id", "EVAL-001", "--json"]) == 0
194
+ context = json.loads(capsys.readouterr().out)
195
+ assert [event["event_id"] for event in context["events"]] == ["DEC-001", "DEC-002", "EVAL-001"]
196
+ assert context["excluded_event_ids"] == ["DEC-OLD"]
197
+ assert context["policy"] == "current-only"
198
+
199
+
200
+ def test_source_check_verifies_commit_and_path(tmp_path):
201
+ source = tmp_path / "source"
202
+ ledger = tmp_path / "ledger"
203
+ source.mkdir()
204
+ ledger.mkdir()
205
+
206
+ def git(*args):
207
+ return subprocess.run(["git", *args], cwd=source, check=True, capture_output=True, text=True)
208
+
209
+ git("init")
210
+ git("config", "user.email", "test@example.invalid")
211
+ git("config", "user.name", "Decision Trace Test")
212
+ (source / "src.txt").write_text("source\n", encoding="utf-8")
213
+ git("add", "src.txt")
214
+ git("commit", "-m", "source")
215
+ commit = git("rev-parse", "HEAD").stdout.strip()
216
+ assert main([
217
+ "event", "--root", str(ledger), "--event-id", "SRC-001",
218
+ "--event-type", "source-link", "--reason", "link source",
219
+ "--source-repo", str(source), "--source-commit", commit, "--source-path", "src.txt",
220
+ ]) == 0
221
+ assert main([
222
+ "source-check", "--root", str(ledger), "--event-id", "SRC-001",
223
+ "--source-root", str(source), "--json",
224
+ ]) == 0
225
+
226
+
227
+ def test_validate_checks_ledger_references(tmp_path, capsys):
228
+ assert main([
229
+ "event", "--root", str(tmp_path), "--event-id", "DEC-001",
230
+ "--event-type", "decision-note", "--reason", "record",
231
+ ]) == 0
232
+ capsys.readouterr()
233
+ assert main(["validate", "--root", str(tmp_path), "--json"]) == 0
234
+ result = json.loads(capsys.readouterr().out)
235
+ assert result["valid"] is True
236
+ assert result["event_count"] == 1
237
+
238
+
239
+ def test_evaluation_point_records_criteria_as_provisional(tmp_path):
240
+ assert main([
241
+ "event", "--root", str(tmp_path), "--event-id", "DEC-001",
242
+ "--event-type", "decision-change", "--reason", "try change",
243
+ ]) == 0
244
+ assert main([
245
+ "evaluation-point", "--root", str(tmp_path), "--event-id", "EVP-001",
246
+ "--target-event-id", "DEC-001", "--criteria", "performance under threshold",
247
+ "--criteria", "source diff is traceable", "--evidence", "VER-001",
248
+ "--reason", "whole evaluation after provisional application",
249
+ ]) == 0
250
+ events = [
251
+ json.loads(line)
252
+ for line in (tmp_path / "work" / "decision-trace" / "events.jsonl").read_text(encoding="utf-8").splitlines()
253
+ ]
254
+ assert events[-1]["event_type"] == "evaluation-point"
255
+ assert events[-1]["status"] == "provisional"
256
+ assert events[-1]["criteria"] == ["performance under threshold", "source diff is traceable"]
257
+
258
+
259
+ def test_workflow_run_creates_automatic_checkpoint_in_git_worktree(tmp_path):
260
+ def git(*args):
261
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
262
+
263
+ git("init")
264
+ git("config", "user.email", "test@example.invalid")
265
+ git("config", "user.name", "Decision Trace Test")
266
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
267
+ git("add", "README.md")
268
+ git("commit", "-m", "initial")
269
+ run_id = str(uuid.uuid4())
270
+ result = run_workflow_instruction(argparse.Namespace(
271
+ root=str(tmp_path), task="continue protocol work", task_file=None,
272
+ completion_condition=["run log is created"], use_default_completion_conditions=False,
273
+ run_id=run_id, out=str(tmp_path / "run.md"), flow_id=None, root_run_id=None,
274
+ parent_run_id=None, work_item_id=None, node_id=None, purpose=None,
275
+ scope_in=[], scope_out=[], owner=None, authority=None, expected_evidence=[], stop_condition=[],
276
+ ))
277
+ assert result.ok is True
278
+ assert result.decision_trace_checkpoint["checkpoint"]["run_id"] == run_id
279
+ assert "AI Decision Trace Checkpoint" in (tmp_path / "run.md").read_text(encoding="utf-8")
280
+ assert git("rev-parse", f"checkpoint/CP-RUN-{run_id}^{{}}").stdout.strip()
281
+
282
+
283
+ def test_worktree_creates_isolated_hypothesis_checkout(tmp_path):
284
+ def git(*args):
285
+ return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True)
286
+
287
+ git("init")
288
+ git("config", "user.email", "test@example.invalid")
289
+ git("config", "user.name", "Decision Trace Test")
290
+ (tmp_path / "README.md").write_text("base\n", encoding="utf-8")
291
+ git("add", "README.md")
292
+ git("commit", "-m", "initial")
293
+ worktree_path = tmp_path.parent / f"{tmp_path.name}-hypothesis"
294
+ assert main([
295
+ "worktree", "--root", str(tmp_path), "--path", str(worktree_path),
296
+ "--branch", "hypothesis/parallel-Y", "--from-ref", "HEAD",
297
+ "--purpose", "parallel hypothesis evaluation", "--json",
298
+ ]) == 0
299
+ assert (worktree_path / "README.md").exists()
300
+ assert git("-C", str(worktree_path), "branch", "--show-current").stdout.strip() == "hypothesis/parallel-Y"
301
+ git("worktree", "remove", "--force", str(worktree_path))
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from xrefkit.wbs import WbsError, parse_wbs, render_rollup, write_csv
6
+
7
+
8
+ VALID = """| ID | Feature | Phase | Task | Assignee | Estimate(Person-Days) | Actual(Person-Days) | Status |
9
+ | --- | --- | --- | --- | --- | ---: | ---: | --- |
10
+ | T-001 | Intake | Analysis | A \\| B | Aki | 2 | 1 | done |
11
+ | T-002 | Intake | Implementation | Build | Aki | 3 | 0 | doing |
12
+ | T-003 | Reporting | Test | Check | Ren | 1 | 0 | todo |
13
+ """
14
+
15
+
16
+ def test_parse_accepts_escaped_pipe_and_rolls_up() -> None:
17
+ headers, rows = parse_wbs(VALID)
18
+ assert headers[1] == "Feature"
19
+ assert rows[0][3] == "A | B"
20
+ output = render_rollup(rows)
21
+ assert "| Intake | 5 | 1 | 2 |" in output
22
+ assert "| Total | 6 | 1 | 3 |" in output
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ "text, message",
27
+ [
28
+ (VALID.replace("| T-003 | Reporting | Test | Check | Ren | 1 | 0 | todo |", "| T-003 | Reporting | Test | Check | Ren | 1 | todo |"), "expected 8 cells"),
29
+ (VALID.replace("| T-003 | Reporting | Test | Check | Ren | 1 | 0 | todo |", "| T-003 | Reporting | Test | Check | Ren | -1 | 0 | todo |"), "must not be negative"),
30
+ (VALID.replace("| T-003 | Reporting | Test | Check | Ren | 1 | 0 | todo |", "| T-003 | Reporting | Test | Check | Ren | 1 | 0 | blocked |"), "Status must be"),
31
+ ],
32
+ )
33
+ def test_parse_rejects_invalid_rows(text: str, message: str) -> None:
34
+ with pytest.raises(WbsError, match=message):
35
+ parse_wbs(text)
36
+
37
+
38
+ def test_csv_is_excel_readable(tmp_path: Path) -> None:
39
+ headers, rows = parse_wbs(VALID)
40
+ target = tmp_path / "wbs.csv"
41
+ write_csv(headers, rows, target)
42
+ assert target.read_bytes().startswith(b"\xef\xbb\xbf")
43
+ assert "T-001" in target.read_text(encoding="utf-8-sig")
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.10"
5
+ __version__ = "0.4.11"
@@ -6,7 +6,7 @@ import sys
6
6
  from collections.abc import Sequence
7
7
 
8
8
 
9
- _OPERATIONS = {"xref", "ctx", "goal", "gate", "pack", "dashboard", "analysis", "skill", "workflow", "host"}
9
+ _OPERATIONS = {"xref", "ctx", "goal", "gate", "pack", "dashboard", "analysis", "skill", "workflow", "host", "trace"}
10
10
  _V2 = {"package", "show"}
11
11
 
12
12
 
@@ -28,6 +28,7 @@ def _print_help() -> None:
28
28
  " goal manage desired-state Goals and continuation state\n"
29
29
  " dashboard inspect runtime state\n"
30
30
  " analysis generate proposal-only observation reports\n"
31
+ " trace AI protocol adapter for decision changes and Git checkpoints\n"
31
32
  " mcp start the MCP server or initialize a client Prompt Flow\n"
32
33
  " skills synchronize released Skill and Knowledge bundles\n"
33
34
  " package inspect installed Skill packages\n"
@@ -760,6 +760,52 @@ def _missing_information_ranking(runs: list[DashboardRun]) -> list[dict[str, obj
760
760
  return rows
761
761
 
762
762
 
763
+ def _decision_trace_payload(root: Path) -> dict[str, object]:
764
+ try:
765
+ from xrefkit.decision_trace import _graph, _impact_group, _read_events, _validate
766
+
767
+ events = _read_events(root)
768
+ groups: dict[str, int] = {}
769
+ for event in events:
770
+ group = _impact_group(event)
771
+ groups[group] = groups.get(group, 0) + 1
772
+ return {
773
+ "events": events,
774
+ "graph": _graph(root),
775
+ "validation": _validate(root),
776
+ "summary": {"events": len(events), "groups": dict(sorted(groups.items()))},
777
+ }
778
+ except (OSError, ValueError) as exc:
779
+ return {
780
+ "events": [],
781
+ "graph": "flowchart TD\n empty[\"No decision-trace events\"]",
782
+ "validation": {"valid": False, "event_count": 0, "issues": [str(exc)]},
783
+ "summary": {"events": 0, "groups": {}},
784
+ }
785
+
786
+
787
+ def _decision_trace_rows(trace: object) -> str:
788
+ if not isinstance(trace, dict):
789
+ return "<tr><td colspan='6'>No decision-trace records found.</td></tr>"
790
+ events = trace.get("events", [])
791
+ if not isinstance(events, list) or not events:
792
+ return "<tr><td colspan='6'>No decision-trace records found.</td></tr>"
793
+ rows: list[str] = []
794
+ for event in events:
795
+ if not isinstance(event, dict):
796
+ continue
797
+ rows.append(
798
+ "<tr>"
799
+ f"<td><code>{html.escape(str(event.get('event_id') or '-'))}</code></td>"
800
+ f"<td>{html.escape(str(event.get('event_type') or '-'))}</td>"
801
+ f"<td>{html.escape(str(event.get('status') or '-'))}</td>"
802
+ f"<td>{html.escape(str(event.get('resolution') or '-'))}</td>"
803
+ f"<td>{html.escape(str(event.get('branch') or '-'))}</td>"
804
+ f"<td>{html.escape(str(event.get('reason') or '-'))}</td>"
805
+ "</tr>"
806
+ )
807
+ return "\n".join(rows) or "<tr><td colspan='6'>No decision-trace records found.</td></tr>"
808
+
763
809
  def _flow_rows(flows: object) -> str:
764
810
  if not isinstance(flows, list) or not flows:
765
811
  return "<tr><td colspan='9'>No Prompt Flows found.</td></tr>"
@@ -989,6 +1035,7 @@ def build_payload(
989
1035
  runs = collect_runs(root, sessions_dir, mcp_events_by_run, audit_errors)
990
1036
  flows = collect_flows(runs)
991
1037
  recoveries = collect_recoveries(runs)
1038
+ decision_trace = _decision_trace_payload(root)
992
1039
  summary = _summary(runs)
993
1040
  summary["flows"] = len(flows)
994
1041
  summary["recoveries"] = len(recoveries)
@@ -1003,6 +1050,7 @@ def build_payload(
1003
1050
  "runs": [run.to_dict() for run in runs],
1004
1051
  "flows": flows,
1005
1052
  "recoveries": recoveries,
1053
+ "decision_trace": decision_trace,
1006
1054
  }
1007
1055
  payload["boundary_analysis"] = analyze_dashboard_payload(
1008
1056
  payload,
@@ -1026,8 +1074,10 @@ def _html_page(payload: dict[str, object]) -> str:
1026
1074
  summary = payload["summary"]
1027
1075
  runs = payload["runs"]
1028
1076
  flows = payload.get("flows", [])
1077
+ decision_trace = payload.get("decision_trace", {})
1029
1078
  assert isinstance(summary, dict)
1030
1079
  assert isinstance(runs, list)
1080
+ assert isinstance(decision_trace, dict)
1031
1081
  boundary_analysis = payload.get("boundary_analysis")
1032
1082
  if not isinstance(boundary_analysis, dict):
1033
1083
  boundary_analysis = {}
@@ -1070,6 +1120,26 @@ def _html_page(payload: dict[str, object]) -> str:
1070
1120
  xid_rows = "\n".join(_xid_usage_card(run) for run in runs if _has_xid_records(run))
1071
1121
  unused_xid_rows = _unused_xid_ranking_table(payload.get("unused_xid_ranking", []))
1072
1122
  missing_information_rows = _missing_information_ranking_table(payload.get("missing_information_ranking", []))
1123
+ decision_trace_summary = decision_trace.get("summary", {})
1124
+ if not isinstance(decision_trace_summary, dict):
1125
+ decision_trace_summary = {}
1126
+ decision_trace_rows = _decision_trace_rows(decision_trace)
1127
+ decision_trace_groups = decision_trace_summary.get("groups", {})
1128
+ if not isinstance(decision_trace_groups, dict):
1129
+ decision_trace_groups = {}
1130
+ decision_trace_group_rows = "".join(
1131
+ f"<tr><td>{html.escape(str(group))}</td><td>{html.escape(str(count))}</td></tr>"
1132
+ for group, count in sorted(decision_trace_groups.items())
1133
+ ) or "<tr><td colspan='2'>No impact groups found.</td></tr>"
1134
+ decision_trace_validation = decision_trace.get("validation", {})
1135
+ if not isinstance(decision_trace_validation, dict):
1136
+ decision_trace_validation = {}
1137
+ decision_trace_issues = decision_trace_validation.get("issues", [])
1138
+ if not isinstance(decision_trace_issues, list):
1139
+ decision_trace_issues = []
1140
+ decision_trace_issue_html = "" if not decision_trace_issues else "<ul>" + "".join(
1141
+ f"<li>{html.escape(str(issue))}</li>" for issue in decision_trace_issues[:10]
1142
+ ) + "</ul>"
1073
1143
  missing_information_cards = "\n".join(
1074
1144
  _missing_information_card(run)
1075
1145
  for run in runs
@@ -1345,6 +1415,7 @@ def _html_page(payload: dict[str, object]) -> str:
1345
1415
  <button class="tab" data-panel="xids">XID Usage</button>
1346
1416
  <button class="tab" data-panel="analysis">Analysis</button>
1347
1417
  <button class="tab" data-panel="missing-information">Missing Information</button>
1418
+ <button class="tab" data-panel="decision-trace">Decision Trace</button>
1348
1419
  </nav>
1349
1420
  <section class="controls" aria-label="Skill run filters">
1350
1421
  <input id="run-search" class="search" type="search" placeholder="Search skill, path, run ID, session, repository, or status" aria-label="Search Skill runs">
@@ -1413,6 +1484,17 @@ def _html_page(payload: dict[str, object]) -> str:
1413
1484
  </div>
1414
1485
  {analysis_proposal_rows}
1415
1486
  </section>
1487
+ <section id="decision-trace" class="panel">
1488
+ <p class="category-note">AI decision-trace events, provisional resolutions, branches, and the current dependency graph. This panel observes records; it does not execute adoption or return operations.</p>
1489
+ <section class="metrics"><div class="metric"><span>Trace events</span><strong>{html.escape(str(decision_trace_summary.get('events', 0)))}</strong></div><div class="metric"><span>Validation</span><strong>{html.escape(str(decision_trace_validation.get('valid', False)))}</strong></div></section>
1490
+ <table class="table">
1491
+ <thead><tr><th>Event</th><th>Type</th><th>Status</th><th>Resolution</th><th>Branch</th><th>Reason</th></tr></thead>
1492
+ <tbody>{decision_trace_rows}</tbody>
1493
+ </table>
1494
+ <div class="box"><h3>Impact groups</h3><table class="table"><thead><tr><th>Group</th><th>Events</th></tr></thead><tbody>{decision_trace_group_rows}</tbody></table></div>
1495
+ <div class="box"><h3>Dependency graph (Mermaid)</h3><pre>{html.escape(str(decision_trace.get('graph') or ''))}</pre></div>
1496
+ {decision_trace_issue_html}
1497
+ </section>
1416
1498
  <section id="missing-information" class="panel">
1417
1499
  <p class="category-note">Information required to correlate Skill execution, MCP access, Knowledge application, and downstream feedback.</p>
1418
1500
  <div class="box"><h3>Missing Information Ranking</h3>{missing_information_rows}</div>