@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -14,10 +14,14 @@ from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
  from typing import Dict, Any, Optional, Tuple
16
16
 
17
+ DEFAULT_RESEARCH_DIR = ".research"
18
+ BASE_DIR_HELP = "Base .research directory"
19
+
17
20
  class SourceHasher:
18
21
  def __init__(self, base_dir: Optional[Path] = None):
19
- self.base_dir = base_dir or Path(".research")
20
- self.sources_dir = self.base_dir / "sources"
22
+ target = base_dir or Path(DEFAULT_RESEARCH_DIR)
23
+ self.base_dir = Path(os.path.realpath(str(target)))
24
+ self.sources_dir = Path(os.path.realpath(str(self.base_dir / "sources")))
21
25
  self.sources_dir.mkdir(parents=True, exist_ok=True)
22
26
 
23
27
  @staticmethod
@@ -70,15 +74,28 @@ class SourceHasher:
70
74
  return None
71
75
 
72
76
  def _resolve_hash_file(self, hash_prefix: str, extension: str) -> Optional[Path]:
73
- """Resolves exact or prefix hash to disk path."""
74
- exact_path = self.sources_dir / f"{hash_prefix}{extension}"
77
+ """Resolves exact or prefix hash to disk path.
78
+
79
+ ``hash_prefix`` is untrusted (it arrives from an LLM tool call), so the
80
+ result is required to stay inside ``sources_dir``. Containment is checked
81
+ inline rather than via ``runner.path_safety`` because this module is
82
+ mirrored into the standalone ``research-cache`` plugin, which has no
83
+ ``runner`` package on its path.
84
+ """
85
+ if not re.match(r"^[a-fA-F0-9]+$", hash_prefix):
86
+ return None
87
+ exact_path = Path(os.path.realpath(str(self.sources_dir / f"{hash_prefix}{extension}")))
88
+ if exact_path.parent != self.sources_dir:
89
+ return None
75
90
  if exact_path.exists():
76
91
  return exact_path
77
92
 
78
93
  # If prefix match
79
94
  matches = list(self.sources_dir.glob(f"{hash_prefix}*{extension}"))
80
95
  if len(matches) == 1:
81
- return matches[0]
96
+ candidate = Path(os.path.realpath(str(matches[0])))
97
+ if candidate.parent == self.sources_dir:
98
+ return candidate
82
99
  return None
83
100
 
84
101
  @staticmethod
@@ -143,20 +160,21 @@ def main():
143
160
  cache_parser.add_argument("--title", default="Untitled", help="Document Title")
144
161
  cache_parser.add_argument("--tier", default="WEB_DOCUMENT", help="Source tier")
145
162
  cache_parser.add_argument("--content", help="Raw text content (or read from stdin)")
146
- cache_parser.add_argument("--dir", default=".research", help="Base .research directory")
163
+ cache_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
147
164
 
148
165
  # Verify command
149
166
  verify_parser = subparsers.add_parser("verify", help="Verify a verbatim quote")
150
167
  verify_parser.add_argument("--hash", required=True, help="Document SHA-256 hash")
151
168
  verify_parser.add_argument("--quote", required=True, help="Verbatim quote to check")
152
- verify_parser.add_argument("--dir", default=".research", help="Base .research directory")
169
+ verify_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
153
170
 
154
171
  # List command
155
172
  list_parser = subparsers.add_parser("list", help="List cached sources")
156
- list_parser.add_argument("--dir", default=".research", help="Base .research directory")
173
+ list_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
157
174
 
158
175
  args = parser.parse_args()
159
- hasher = SourceHasher(base_dir=Path(args.dir if hasattr(args, "dir") else ".research"))
176
+ dir_val = getattr(args, "dir", DEFAULT_RESEARCH_DIR) or DEFAULT_RESEARCH_DIR
177
+ hasher = SourceHasher(base_dir=Path(os.path.realpath(str(dir_val))))
160
178
 
161
179
  if args.command == "cache":
162
180
  content = args.content
@@ -5,6 +5,7 @@ Implements Matt Pocock-style design tree traversal to isolate the active decisio
5
5
  """
6
6
 
7
7
  import sys
8
+ import os
8
9
  import json
9
10
  import argparse
10
11
  from pathlib import Path
@@ -84,7 +85,8 @@ class DesignTree:
84
85
  return len(self.compute_frontier()) == 0 and all(n.is_settled() for n in self.nodes.values())
85
86
 
86
87
  def export_frontier_json(self, output_path: Path):
87
- output_path.parent.mkdir(parents=True, exist_ok=True)
88
+ safe_path = Path(os.path.realpath(str(output_path)))
89
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
88
90
  data = {
89
91
  "objective": self.objective,
90
92
  "is_complete": self.is_complete(),
@@ -93,12 +95,13 @@ class DesignTree:
93
95
  k: v.settled_answer for k, v in self.nodes.items() if v.is_settled()
94
96
  }
95
97
  }
96
- with open(output_path, "w", encoding="utf-8") as f:
98
+ with open(safe_path, "w", encoding="utf-8") as f:
97
99
  json.dump(data, f, indent=2)
98
100
 
99
101
  @classmethod
100
102
  def load_from_json(cls, file_path: Path) -> 'DesignTree':
101
- with open(file_path, "r", encoding="utf-8") as f:
103
+ safe_path = Path(os.path.realpath(str(file_path)))
104
+ with open(safe_path, "r", encoding="utf-8") as f:
102
105
  data = json.load(f)
103
106
  tree = cls(objective=data["objective"])
104
107
  for k, v in data.get("nodes", {}).items():
@@ -106,15 +109,31 @@ class DesignTree:
106
109
  return tree
107
110
 
108
111
 
112
+ def _display_frontier(tree: DesignTree, frontier: list) -> None:
113
+ print(f"\n🎯 Objective: {tree.objective}")
114
+ print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
115
+ if not frontier:
116
+ if tree.nodes and tree.is_complete():
117
+ print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
118
+ else:
119
+ print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
120
+ else:
121
+ print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
122
+ for idx, node in enumerate(frontier, 1):
123
+ print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
124
+ print(f" {node.question}")
125
+ print(f" ➡️ Recommended: {node.recommended}")
126
+
127
+
109
128
  def main():
110
129
  parser = argparse.ArgumentParser(description="Epistemic Swarm Socratic Decision Tree")
111
130
  parser.add_argument("--objective", type=str, help="Research objective")
112
131
  parser.add_argument("--file", type=str, default=".research/frontier.json", help="Path to frontier.json")
113
132
  parser.add_argument("--show-frontier", action="store_true", help="Print the current decision frontier")
114
133
  parser.add_argument("--settle", nargs=2, metavar=("NODE_ID", "ANSWER"), help="Settle a decision node")
115
-
134
+
116
135
  args = parser.parse_args()
117
- frontier_path = Path(args.file)
136
+ frontier_path = Path(os.path.realpath(str(args.file)))
118
137
 
119
138
  if frontier_path.exists():
120
139
  tree = DesignTree.load_from_json(frontier_path)
@@ -130,19 +149,8 @@ def main():
130
149
 
131
150
  frontier = tree.compute_frontier()
132
151
  if args.show_frontier or not args.settle:
133
- print(f"\n🎯 Objective: {tree.objective}")
134
- print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
135
- if not frontier:
136
- if tree.nodes and tree.is_complete():
137
- print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
138
- else:
139
- print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
140
- else:
141
- print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
142
- for idx, node in enumerate(frontier, 1):
143
- print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
144
- print(f" {node.question}")
145
- print(f" ➡️ Recommended: {node.recommended}")
152
+ _display_frontier(tree, frontier)
153
+
146
154
 
147
155
  if __name__ == "__main__":
148
156
  main()
@@ -17,6 +17,68 @@ from skills.research_cache.hasher import SourceHasher
17
17
  from runner.state_machine import ResearchStateMachine, ScopeStatus
18
18
  from runner.refinement import compute_epistemic_score
19
19
 
20
+ def _compute_scope_epistemic_score(
21
+ constitution: Any,
22
+ alpha_dossier: Dict[str, Any],
23
+ beta_dossier: Dict[str, Any],
24
+ alpha_results: list,
25
+ beta_results: list,
26
+ counts: Dict[str, int],
27
+ ) -> float:
28
+ from runner.refinement import LEGACY_CONSTITUTION
29
+
30
+ if constitution is not LEGACY_CONSTITUTION:
31
+ from runner.claim_witness import STATUS_REJECTED, claims_from_dossier
32
+ from runner.refinement import compute_epistemic_score_from_claims
33
+
34
+ results_by_id = {r["claim_id"]: r for r in (alpha_results + beta_results)}
35
+ score_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
36
+ for c in score_claims:
37
+ r = results_by_id.get(c.claim_id)
38
+ if r is not None and r.get("audited_tag") == "UNVERIFIED_REJECTED":
39
+ c.tag = "UNVERIFIED_REJECTED"
40
+ c.status = STATUS_REJECTED
41
+ epistemic_score, _breakdown = compute_epistemic_score_from_claims(
42
+ score_claims, constitution=constitution
43
+ )
44
+ return epistemic_score
45
+
46
+ epistemic_score, _breakdown = compute_epistemic_score(counts)
47
+ return epistemic_score
48
+
49
+
50
+ def _apply_living_dossiers_degradation(
51
+ base_dir: Path,
52
+ scope_id: str,
53
+ alpha_dossier: Dict[str, Any],
54
+ beta_dossier: Dict[str, Any],
55
+ ) -> Dict[str, Any]:
56
+ degradation = {"events": [], "degraded_scopes": []}
57
+ try:
58
+ from runner.living_dossiers import (
59
+ apply_degradation,
60
+ append_ledger,
61
+ load_retractions,
62
+ queue_requeue,
63
+ )
64
+ from runner.claim_witness import claims_from_dossier
65
+
66
+ retractions = load_retractions(base_dir)
67
+ if retractions:
68
+ all_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
69
+ _degraded, events = apply_degradation(all_claims, retractions, scope_id=scope_id)
70
+ if events:
71
+ append_ledger(base_dir, events)
72
+ queue_requeue(base_dir, scope_id, reason="claim degradation (retraction event)")
73
+ degradation = {
74
+ "events": [e.to_dict() for e in events],
75
+ "degraded_scopes": [scope_id] if events else [],
76
+ }
77
+ except Exception as exc: # noqa: BLE001 - degradation must not break audit
78
+ print(f"[auditor] living-dossiers pass skipped: {exc}", file=sys.stderr)
79
+ return degradation
80
+
81
+
20
82
  class EpistemicAuditorEngine:
21
83
  def __init__(self, base_dir: Optional[Path] = None):
22
84
  self.base_dir = base_dir or Path(".research")
@@ -59,82 +121,24 @@ class EpistemicAuditorEngine:
59
121
  beta_dossier.get("falsification_claims", []), constitution=constitution
60
122
  )
61
123
 
62
- total_verified = alpha_verified + beta_verified
63
- total_rejected = alpha_rejected + beta_rejected
64
-
65
- # Negative knowledge counts
66
- neg_knowledge_alpha = len(alpha_dossier.get("negative_knowledge", []))
67
- neg_knowledge_beta = len(beta_dossier.get("negative_knowledge", []))
68
- total_neg_knowledge = neg_knowledge_alpha + neg_knowledge_beta
69
-
70
- total_inferred = len(alpha_dossier.get("inferred_implications", []))
71
- total_hypotheses = len(beta_dossier.get("hypotheses", []))
124
+ counts = {
125
+ "verified": alpha_verified + beta_verified,
126
+ "rejected": alpha_rejected + beta_rejected,
127
+ "inferred": len(alpha_dossier.get("inferred_implications", [])),
128
+ "hypotheses": len(beta_dossier.get("hypotheses", [])),
129
+ "neg_knowledge": len(alpha_dossier.get("negative_knowledge", [])) + len(beta_dossier.get("negative_knowledge", [])),
130
+ }
72
131
 
73
132
  # 3. Calculate Epistemic Score via the pure refinement function.
74
- # NOTE (docs/impl mismatch, deliberate): docs/SYSTEM_ARCHITECTURE.md
75
- # §1.2 publishes tier-weighted V(c_i); the runtime has always scored
76
- # flat 1.0 per verified claim. LEGACY_CONSTITUTION preserves that;
77
- # tier weighting is opt-in via Domain Packs (Stream G).
78
- if constitution is not LEGACY_CONSTITUTION:
79
- from runner.claim_witness import STATUS_REJECTED, claims_from_dossier
80
- from runner.refinement import compute_epistemic_score_from_claims
81
-
82
- results_by_id = {
83
- r["claim_id"]: r for r in (alpha_results + beta_results)
84
- }
85
- score_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
86
- for c in score_claims:
87
- r = results_by_id.get(c.claim_id)
88
- if r is not None and r.get("audited_tag") == "UNVERIFIED_REJECTED":
89
- # Reflect the auditor's verdict, not the dossier's claim.
90
- c.tag = "UNVERIFIED_REJECTED"
91
- c.status = STATUS_REJECTED
92
- epistemic_score, _breakdown = compute_epistemic_score_from_claims(
93
- score_claims, constitution=constitution
94
- )
95
- else:
96
- epistemic_score, _breakdown = compute_epistemic_score({
97
- "verified": total_verified,
98
- "rejected": total_rejected,
99
- "inferred": total_inferred,
100
- "hypotheses": total_hypotheses,
101
- "neg_knowledge": total_neg_knowledge,
102
- })
133
+ epistemic_score = _compute_scope_epistemic_score(
134
+ constitution, alpha_dossier, beta_dossier, alpha_results, beta_results, counts
135
+ )
103
136
  accept_threshold = constitution.accept_threshold
104
137
 
105
- # 3b. Living Dossiers (Stream C): a VERIFIED claim is a time-bounded
106
- # loan against its source. Join against retraction events —
107
- # RETRACTED => STALE even when the quote still matches locally.
108
- degradation = {"events": [], "degraded_scopes": []}
109
- try:
110
- from runner.living_dossiers import (
111
- apply_degradation,
112
- append_ledger,
113
- load_retractions,
114
- queue_requeue,
115
- )
116
- from runner.claim_witness import claims_from_dossier
117
-
118
- retractions = load_retractions(self.base_dir)
119
- if retractions:
120
- all_claims = (
121
- claims_from_dossier(alpha_dossier)
122
- + claims_from_dossier(beta_dossier)
123
- )
124
- _degraded, events = apply_degradation(
125
- all_claims, retractions, scope_id=scope_id
126
- )
127
- if events:
128
- append_ledger(self.base_dir, events)
129
- queue_requeue(
130
- self.base_dir, scope_id, reason="claim degradation (retraction event)"
131
- )
132
- degradation = {
133
- "events": [e.to_dict() for e in events],
134
- "degraded_scopes": [scope_id] if events else [],
135
- }
136
- except Exception as exc: # noqa: BLE001 - degradation must not break audit
137
- print(f"[auditor] living-dossiers pass skipped: {exc}", file=sys.stderr)
138
+ # 3b. Living Dossiers (Stream C): check degradation against retraction events
139
+ degradation = _apply_living_dossiers_degradation(
140
+ self.base_dir, scope_id, alpha_dossier, beta_dossier
141
+ )
138
142
 
139
143
  # 4. Calculate Divergence Score
140
144
  divergence_score, divergence_matrix = self._compute_divergence(alpha_dossier, beta_dossier)
@@ -146,9 +150,9 @@ class EpistemicAuditorEngine:
146
150
  "audited_at": datetime.now(timezone.utc).isoformat(),
147
151
  "summary": {
148
152
  "total_claims_audited": len(alpha_results) + len(beta_results),
149
- "verified_passed": total_verified,
150
- "unverified_rejected": total_rejected,
151
- "negative_knowledge_count": total_neg_knowledge,
153
+ "verified_passed": counts["verified"],
154
+ "unverified_rejected": counts["rejected"],
155
+ "negative_knowledge_count": counts["neg_knowledge"],
152
156
  "epistemic_score": epistemic_score,
153
157
  "divergence_score": divergence_score,
154
158
  "verdict": "CERTIFIED" if epistemic_score >= accept_threshold else "WARNING_LOW_GROUNDING"
@@ -294,44 +298,60 @@ class EpistemicAuditorEngine:
294
298
  divergence = round(min(1.0, (contradictions * 2) / total_claims), 2)
295
299
  return divergence, matrix
296
300
 
297
- def _generate_synthesis_markdown(self, scope_id: str, alpha: Dict[str, Any],
298
- beta: Dict[str, Any], audit: Dict[str, Any]) -> str:
299
- summary = audit["summary"]
300
- md = []
301
- md.append(f"# Epistemic Synthesis: Scope {scope_id}\n")
302
- md.append(f"**Audit Verdict**: `{summary['verdict']}` | **Epistemic Score**: `{summary['epistemic_score']}/1.0` | **Divergence Index**: `{summary['divergence_score']}`\n")
303
-
304
- md.append("## 1. Verified Empirical Grounding")
305
- for claim in audit["alpha_claims_audit"]:
301
+ @staticmethod
302
+ def _render_verified_section(alpha_audit: list, beta_audit: list) -> List[str]:
303
+ lines = ["## 1. Verified Empirical Grounding"]
304
+ for claim in alpha_audit:
306
305
  if claim["audited_tag"] == "VERIFIED":
307
- md.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` {claim['statement']}")
308
- for claim in audit["beta_claims_audit"]:
306
+ lines.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` {claim['statement']}")
307
+ for claim in beta_audit:
309
308
  if claim["audited_tag"] == "VERIFIED":
310
- md.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` (Counter-Evidence) {claim['statement']}")
311
-
312
- md.append("\n## 2. Dialectic Tensions & Falsification Audit")
313
- if audit["divergence_matrix"]:
314
- for item in audit["divergence_matrix"]:
315
- md.append(f"### Tension: {item['tension_type']}")
316
- md.append(f"- **Thesis Assertion**: {item['proponent_claim']}")
317
- md.append(f"- **Adversarial Critique**: {item['adversary_critique']}")
309
+ lines.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` (Counter-Evidence) {claim['statement']}")
310
+ return lines
311
+
312
+ @staticmethod
313
+ def _render_tensions_section(matrix: list) -> List[str]:
314
+ lines = ["\n## 2. Dialectic Tensions & Falsification Audit"]
315
+ if matrix:
316
+ for item in matrix:
317
+ lines.append(f"### Tension: {item['tension_type']}")
318
+ lines.append(f"- **Thesis Assertion**: {item['proponent_claim']}")
319
+ lines.append(f"- **Adversarial Critique**: {item['adversary_critique']}")
318
320
  else:
319
- md.append("No active contradictions identified between primary literature and red-team findings.")
321
+ lines.append("No active contradictions identified between primary literature and red-team findings.")
322
+ return lines
320
323
 
321
- md.append("\n## 3. Rejected & Unverified Assertions")
322
- rejected = [c for c in audit["alpha_claims_audit"] + audit["beta_claims_audit"] if c["audited_tag"] == "UNVERIFIED_REJECTED"]
324
+ @staticmethod
325
+ def _render_rejected_section(claims: list) -> List[str]:
326
+ lines = ["\n## 3. Rejected & Unverified Assertions"]
327
+ rejected = [c for c in claims if c["audited_tag"] == "UNVERIFIED_REJECTED"]
323
328
  if rejected:
324
329
  for r in rejected:
325
- md.append(f"- ⚠️ **PURGED**: \"{r['statement']}\" — *Reason: {r['reason']}*")
330
+ lines.append(f"- ⚠️ **PURGED**: \"{r['statement']}\" — *Reason: {r['reason']}*")
326
331
  else:
327
- md.append("Zero claims rejected. 100% of cited assertions verified against source cache.")
332
+ lines.append("Zero claims rejected. 100% of cited assertions verified against source cache.")
333
+ return lines
328
334
 
329
- md.append("\n## 4. Negative Knowledge Catalog")
335
+ @staticmethod
336
+ def _render_negative_knowledge_section(alpha: Dict[str, Any], beta: Dict[str, Any]) -> List[str]:
337
+ lines = ["\n## 4. Negative Knowledge Catalog"]
330
338
  all_neg = alpha.get("negative_knowledge", []) + beta.get("negative_knowledge", [])
331
339
  if all_neg:
332
340
  for n in all_neg:
333
- md.append(f"- `[NEGATIVE_KNOWLEDGE: {n['query']}]` {n['finding']}")
341
+ lines.append(f"- `[NEGATIVE_KNOWLEDGE: {n['query']}]` {n['finding']}")
334
342
  else:
335
- md.append("No negative knowledge declarations logged.")
343
+ lines.append("No negative knowledge declarations logged.")
344
+ return lines
336
345
 
346
+ def _generate_synthesis_markdown(self, scope_id: str, alpha: Dict[str, Any],
347
+ beta: Dict[str, Any], audit: Dict[str, Any]) -> str:
348
+ summary = audit["summary"]
349
+ md = [
350
+ f"# Epistemic Synthesis: Scope {scope_id}\n",
351
+ f"**Audit Verdict**: `{summary['verdict']}` | **Epistemic Score**: `{summary['epistemic_score']}/1.0` | **Divergence Index**: `{summary['divergence_score']}`\n",
352
+ ]
353
+ md.extend(self._render_verified_section(audit["alpha_claims_audit"], audit["beta_claims_audit"]))
354
+ md.extend(self._render_tensions_section(audit.get("divergence_matrix", [])))
355
+ md.extend(self._render_rejected_section(audit["alpha_claims_audit"] + audit["beta_claims_audit"]))
356
+ md.extend(self._render_negative_knowledge_section(alpha, beta))
337
357
  return "\n".join(md)
@@ -22,6 +22,7 @@ need an API call and are out of this phase's scope).
22
22
  import argparse
23
23
  import hashlib
24
24
  import json
25
+ import os
25
26
  import sqlite3
26
27
  import sys
27
28
  from pathlib import Path
@@ -50,8 +51,8 @@ def _has_fts5(conn: sqlite3.Connection) -> bool:
50
51
 
51
52
  class ClaimStore:
52
53
  def __init__(self, base_dir: Path, db_path: Optional[Path] = None):
53
- self.base_dir = Path(base_dir)
54
- self.db_path = db_path or (self.base_dir / "claims.sqlite")
54
+ self.base_dir = Path(os.path.realpath(str(base_dir)))
55
+ self.db_path = Path(os.path.realpath(str(db_path))) if db_path else (self.base_dir / "claims.sqlite")
55
56
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
56
57
  self.conn = sqlite3.connect(str(self.db_path))
57
58
  self.conn.row_factory = sqlite3.Row
@@ -344,7 +345,7 @@ def main(argv: Optional[List[str]] = None) -> int:
344
345
  search_p.add_argument("text")
345
346
  args = parser.parse_args(argv)
346
347
 
347
- base = Path(args.dir)
348
+ base = Path(os.path.realpath(str(args.dir)))
348
349
  if args.command == "reindex" or args.command is None:
349
350
  print(json.dumps(reindex(base), indent=2))
350
351
  return 0
@@ -354,7 +355,7 @@ def main(argv: Optional[List[str]] = None) -> int:
354
355
  store.close()
355
356
  print(json.dumps(rows, indent=2, default=str))
356
357
  return 0
357
- return 0
358
+ return 1
358
359
 
359
360
 
360
361
  if __name__ == "__main__":
@@ -21,6 +21,7 @@ Degradation rules (applied after quote verification):
21
21
 
22
22
  import json
23
23
  import os
24
+ import re
24
25
  import sys
25
26
  from dataclasses import dataclass, asdict
26
27
  from datetime import datetime, timezone
@@ -38,6 +39,7 @@ from runner.claim_witness import ( # noqa: E402
38
39
  STATUS_SUSPECT,
39
40
  ClaimWitness,
40
41
  )
42
+ from runner.path_safety import safe_join # noqa: E402
41
43
 
42
44
  EVENT_RETRACTED = "RETRACTED"
43
45
  EVENT_REVISED = "REVISED"
@@ -83,6 +85,8 @@ def write_retraction(
83
85
  """Record a RETRACTED/REVISED event for a cached source."""
84
86
  if event not in VALID_EVENTS:
85
87
  raise ValueError(f"event must be one of {VALID_EVENTS}, got {event!r}")
88
+ if not re.match(r"^[a-zA-Z0-9_\-\.]+$", source_hash):
89
+ raise ValueError(f"Invalid source hash: {source_hash!r}")
86
90
  retr_dir = Path(base_dir) / "retractions"
87
91
  retr_dir.mkdir(parents=True, exist_ok=True)
88
92
  payload = RetractionEvent(
@@ -92,7 +96,11 @@ def write_retraction(
92
96
  note=note,
93
97
  supersedes=supersedes,
94
98
  )
95
- path = retr_dir / f"{source_hash}.json"
99
+ # `source_hash` is untrusted tool input, so the filename is joined under
100
+ # `retractions/` through safe_join (no separators, no bare `..`, and a
101
+ # containment re-check after canonicalisation). The regex above is the
102
+ # input gate; safe_join is the one that holds if the gate is ever loosened.
103
+ path = safe_join(retr_dir, f"{source_hash}.json")
96
104
  # Last write wins per hash (a REVISED may follow a RETRACTED); the ledger
97
105
  # keeps the full history of claim transitions either way.
98
106
  with open(path, "w", encoding="utf-8") as f:
@@ -258,7 +266,14 @@ def check_staleness(base_dir: Path, scope_ids: Optional[List[str]] = None) -> Di
258
266
 
259
267
  if scope_ids is None:
260
268
  if not scratch.exists():
261
- return {"scopes": [], "retractions": len(retractions), "degraded": 0}
269
+ return {
270
+ "scopes": [],
271
+ "retractions": len(retractions),
272
+ "degraded": 0,
273
+ # Keep the early return shape identical to the normal one so
274
+ # callers can key off `degraded_scopes` unconditionally.
275
+ "degraded_scopes": [],
276
+ }
262
277
  scope_dirs = sorted(p for p in scratch.iterdir() if p.is_dir())
263
278
  else:
264
279
  scope_dirs = [scratch / s for s in scope_ids]
@@ -281,7 +296,7 @@ def check_staleness(base_dir: Path, scope_ids: Optional[List[str]] = None) -> Di
281
296
  claim_sets += 1
282
297
  dossier = load_dossier(dpath)
283
298
  claims = claims_from_dossier(dossier)
284
- degraded, events = apply_degradation(
299
+ _, events = apply_degradation(
285
300
  claims, retractions, scope_id=scope_id
286
301
  )
287
302
  all_events.extend(events)
@@ -336,19 +351,26 @@ def main(argv: Optional[List[str]] = None) -> int:
336
351
  rep.add_argument("--event", required=True, choices=list(VALID_EVENTS))
337
352
  rep.add_argument("--note", default="")
338
353
 
339
- sub.add_parser("check", help="Run one staleness/degradation pass")
354
+ sub.add_parser(
355
+ "check",
356
+ help=(
357
+ "Run one staleness/degradation pass. Exits 1 when any scope "
358
+ "degraded (the ledger reporting a problem IS the outcome), 0 "
359
+ "otherwise — callers under `set -e` should expect that."
360
+ ),
361
+ )
340
362
  args = parser.parse_args(argv)
341
363
 
342
- base = Path(args.dir)
364
+ base = Path(os.path.realpath(str(args.dir)))
343
365
  if args.command == "report":
344
366
  path = write_retraction(base, args.hash, args.event, note=args.note)
345
367
  print(json.dumps({"status": "recorded", "path": str(path)}))
346
368
  return 0
347
- if args.command == "check":
348
- print(json.dumps(check_staleness(base), indent=2))
349
- return 0
350
- print(json.dumps(check_staleness(base), indent=2))
351
- return 0
369
+ if args.command == "check" or args.command is None:
370
+ res = check_staleness(base)
371
+ print(json.dumps(res, indent=2))
372
+ return 1 if res.get("degraded_scopes") else 0
373
+ return 1
352
374
 
353
375
 
354
376
  if __name__ == "__main__":
@@ -140,7 +140,7 @@ class StdioJsonRpcServer:
140
140
  return self._error(req_id, -32601, f"Tool {tool_name} not found")
141
141
  try:
142
142
  text = spec.handler(args)
143
- except Exception as exc: # noqa: BLE001 - surface, do not kill the server
143
+ except Exception as exc: # noqa: BLE001 # surface, do not kill the server
144
144
  return self._error(req_id, -32603, f"{type(exc).__name__}: {exc}")
145
145
  return self._reply(
146
146
  req_id, {"content": [{"type": "text", "text": text}]}