@heretek-ai/epistemic-swarm 0.2.2 → 0.3.0

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 (138) hide show
  1. package/.agents/skills/brainstorming/SKILL.md +13 -0
  2. package/.agents/skills/code_audit/SKILL.md +13 -0
  3. package/.agents/skills/epistemic_search/SKILL.md +13 -0
  4. package/.agents/skills/grilling/SKILL.md +13 -0
  5. package/.agents/skills/oss_scout/SKILL.md +13 -0
  6. package/.agents/skills/research_cache/SKILL.md +13 -0
  7. package/.agents/skills/swarm_config/SKILL.md +13 -0
  8. package/.claude-plugin/plugin.json +15 -5
  9. package/.omp/README.md +39 -0
  10. package/.omp/SYSTEM.md +12 -0
  11. package/.omp/commands/audit.md +10 -0
  12. package/.omp/commands/brainstorming.md +12 -0
  13. package/.omp/commands/grill.md +9 -0
  14. package/.omp/commands/scout.md +10 -0
  15. package/.omp/commands/swarm-config.md +10 -0
  16. package/.omp/commands/swarm.md +9 -0
  17. package/.omp/hooks/post/epistemic-audit.ts +16 -0
  18. package/.omp/hooks/pre/epistemic-redirect.ts +21 -0
  19. package/.omp/prompts/brainstorming.md +8 -0
  20. package/.omp/prompts/swarm.md +8 -0
  21. package/MARKETPLACE.md +8 -0
  22. package/README.md +53 -8
  23. package/bin/cli.js +27 -3
  24. package/config/domain_packs/biopharma.json +23 -0
  25. package/config/domain_packs/legal.json +19 -0
  26. package/config/domain_packs/quant.json +19 -0
  27. package/config/mcp-research-servers.json +7 -0
  28. package/config/mcp_launcher.py +48 -137
  29. package/config/opencode-snippet.json +58 -3
  30. package/config/searxng_mcp.py +42 -83
  31. package/extensions/pi/index.js +196 -28
  32. package/install.sh +20 -4
  33. package/package.json +40 -5
  34. package/plugins/antigravity/README.md +28 -0
  35. package/plugins/antigravity/agents/alpha-thesis.md +6 -0
  36. package/plugins/antigravity/agents/beta-antithesis.md +7 -0
  37. package/plugins/antigravity/agents/brainstormer.md +7 -0
  38. package/plugins/antigravity/agents/epistemic-auditor.md +5 -0
  39. package/plugins/antigravity/hooks.json +23 -0
  40. package/plugins/antigravity/mcp_config.json +33 -0
  41. package/plugins/antigravity/plugin.json +21 -0
  42. package/plugins/antigravity/rules/epistemic-integrity.md +6 -0
  43. package/plugins/antigravity/skills/brainstorming/SKILL.md +13 -0
  44. package/plugins/antigravity/skills/code_audit/SKILL.md +13 -0
  45. package/plugins/antigravity/skills/epistemic_search/SKILL.md +13 -0
  46. package/plugins/antigravity/skills/grilling/SKILL.md +13 -0
  47. package/plugins/antigravity/skills/oss_scout/SKILL.md +13 -0
  48. package/plugins/antigravity/skills/research_cache/SKILL.md +13 -0
  49. package/plugins/antigravity/skills/swarm_config/SKILL.md +13 -0
  50. package/plugins/codex/AGENTS.md.snippet +10 -0
  51. package/plugins/codex/README.md +37 -0
  52. package/plugins/codex/config.toml.snippet +28 -0
  53. package/plugins/codex/openai.yaml +24 -0
  54. package/plugins/codex/skills/brainstorming/SKILL.md +13 -0
  55. package/plugins/codex/skills/code_audit/SKILL.md +13 -0
  56. package/plugins/codex/skills/epistemic_search/SKILL.md +13 -0
  57. package/plugins/codex/skills/grilling/SKILL.md +13 -0
  58. package/plugins/codex/skills/oss_scout/SKILL.md +13 -0
  59. package/plugins/codex/skills/research_cache/SKILL.md +13 -0
  60. package/plugins/codex/skills/swarm_config/SKILL.md +13 -0
  61. package/plugins/gemini/GEMINI.md +15 -0
  62. package/plugins/gemini/README.md +19 -0
  63. package/plugins/gemini/commands/audit.toml +6 -0
  64. package/plugins/gemini/commands/brainstorming.toml +10 -0
  65. package/plugins/gemini/commands/grill.toml +6 -0
  66. package/plugins/gemini/commands/scout.toml +7 -0
  67. package/plugins/gemini/commands/swarm-config.toml +7 -0
  68. package/plugins/gemini/commands/swarm.toml +8 -0
  69. package/plugins/gemini/gemini-extension.json +38 -0
  70. package/plugins/gemini/hooks/hooks.json +11 -0
  71. package/plugins/gemini/skills/brainstorming/SKILL.md +13 -0
  72. package/plugins/gemini/skills/code_audit/SKILL.md +13 -0
  73. package/plugins/gemini/skills/epistemic_search/SKILL.md +13 -0
  74. package/plugins/gemini/skills/grilling/SKILL.md +13 -0
  75. package/plugins/gemini/skills/oss_scout/SKILL.md +13 -0
  76. package/plugins/gemini/skills/research_cache/SKILL.md +13 -0
  77. package/plugins/gemini/skills/swarm_config/SKILL.md +13 -0
  78. package/plugins/opencode/index.js +335 -118
  79. package/prompts/agent_brainstormer.md +97 -0
  80. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  81. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  82. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  83. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  84. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  85. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  86. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  87. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  88. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  89. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  90. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  91. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  92. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  93. package/runner/auctioneer.py +169 -0
  94. package/runner/auditor_engine.py +127 -12
  95. package/runner/claim_store.py +361 -0
  96. package/runner/claim_witness.py +183 -0
  97. package/runner/living_dossiers.py +355 -0
  98. package/runner/mcp_protocol.py +188 -0
  99. package/runner/mcp_server.py +567 -0
  100. package/runner/pcrb.py +212 -0
  101. package/runner/pcrb_verify.py +237 -0
  102. package/runner/refinement.py +335 -0
  103. package/runner/research_swarm.py +378 -94
  104. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  105. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  106. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  107. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  108. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  109. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  110. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  111. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  112. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  113. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  114. package/runner/tests/fixtures/auction_objective.json +35 -0
  115. package/runner/tests/fixtures/borderline_claims.json +27 -0
  116. package/runner/tests/fixtures/divergence_objectives.json +31 -0
  117. package/runner/tests/test_auction_order.py +173 -0
  118. package/runner/tests/test_claim_store.py +162 -0
  119. package/runner/tests/test_claim_witness.py +186 -0
  120. package/runner/tests/test_domain_packs.py +217 -0
  121. package/runner/tests/test_fleet_seam.py +113 -0
  122. package/runner/tests/test_living_dossiers.py +204 -0
  123. package/runner/tests/test_mcp_server.py +212 -0
  124. package/runner/tests/test_pcrb.py +240 -0
  125. package/runner/tests/test_refinement.py +255 -0
  126. package/runner/tests/test_swarm.py +173 -15
  127. package/scripts/auction_experiment.py +180 -0
  128. package/scripts/build_adapters.py +183 -0
  129. package/scripts/divergence_experiment.py +184 -0
  130. package/skills/brainstorming/SKILL.md +106 -0
  131. package/skills/brainstorming/__init__.py +1 -0
  132. package/skills/brainstorming/scripts/brainstorm.py +200 -0
  133. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  134. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  135. package/skills/swarm_config/SKILL.md +1 -1
  136. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  137. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  138. package/skills/swarm_config/configure.py +45 -11
package/runner/pcrb.py ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Proof-Carrying Research Brief (PCRB) — export side (Stream D).
4
+
5
+ A PCRB is a self-contained signed bundle: the synthesis, every claim with its
6
+ quote witness, the full text of every cited source, an integrity manifest, and
7
+ an optional HMAC signature. A third party can re-verify everything WITHOUT
8
+ trusting the LLM that produced it and WITHOUT access to the original
9
+ .research/ workspace — verification runs against the bundled sources.
10
+
11
+ Bundle shape (brief.pcrb.json):
12
+ {
13
+ "pcrb_version": "1.0",
14
+ "objective": ..., "exported_at": ...,
15
+ "synthesis_markdown": ...,
16
+ "claims": [ {claim fields..., "witness": {quote, source_hash, confidence, verified_at}} ],
17
+ "sources": { "<hash>": {"url","title","tier","content"} },
18
+ "manifest": { "synthesis_markdown": sha, "claims": {id: sha}, "sources": {hash: sha} },
19
+ "signature": {"alg": "hmac-sha256" | "none", "key_id": ..., "sig": ...}
20
+ }
21
+
22
+ Member hashes use SourceHasher.compute_sha256 semantics so a bundled source's
23
+ member hash equals its cache hash.
24
+
25
+ Signing: stdlib hmac when a key is provided (env IUMBTEMS_PCRB_KEY or key
26
+ file). alg="none" provides integrity (member-vs-manifest) but NOT authenticity
27
+ — a coordinated rewrite of content + manifest is undetectable without a key.
28
+ Real PKI (ed25519) is a gated follow-on; HMAC is sufficient for the tamper
29
+ probe, not for the regulatory story.
30
+ """
31
+
32
+ import hashlib
33
+ import hmac
34
+ import json
35
+ import os
36
+ import sys
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Dict, List, Optional, Tuple
40
+
41
+ SCRIPT_DIR = Path(__file__).resolve().parent
42
+ PROJECT_ROOT = SCRIPT_DIR.parent
43
+ if str(PROJECT_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(PROJECT_ROOT))
45
+
46
+ from runner.claim_witness import claims_from_dossier, load_dossier # noqa: E402
47
+
48
+ PCRB_VERSION = "1.0"
49
+ ENV_KEY = "IUMBTEMS_PCRB_KEY"
50
+
51
+
52
+ def canonical_json(obj: Any) -> str:
53
+ """Deterministic serialization for hashing/signing."""
54
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
55
+
56
+
57
+ def sha256_text(text: str) -> str:
58
+ """SourceHasher.compute_sha256 semantics (strip + utf-8)."""
59
+ return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
60
+
61
+
62
+ def member_hash(obj: Any) -> str:
63
+ """Hash a structured member via canonical JSON."""
64
+ return sha256_text(canonical_json(obj))
65
+
66
+
67
+ def _resolve_key(key: Optional[str], key_path: Optional[Path]) -> Optional[bytes]:
68
+ if key:
69
+ return key.encode("utf-8")
70
+ env = os.environ.get(ENV_KEY)
71
+ if env:
72
+ return env.encode("utf-8")
73
+ if key_path and Path(key_path).exists():
74
+ return Path(key_path).read_bytes().strip()
75
+ return None
76
+
77
+
78
+ def sign_manifest(manifest: Dict[str, Any], key: Optional[bytes], key_id: str = "local") -> Dict[str, Any]:
79
+ if not key:
80
+ return {"alg": "none", "key_id": None, "sig": None}
81
+ sig = hmac.new(key, canonical_json(manifest).encode("utf-8"), hashlib.sha256).hexdigest()
82
+ return {"alg": "hmac-sha256", "key_id": key_id, "sig": sig}
83
+
84
+
85
+ def export_brief(
86
+ base_dir: Path,
87
+ out_path: Optional[Path] = None,
88
+ scope_ids: Optional[List[str]] = None,
89
+ objective: Optional[str] = None,
90
+ key: Optional[str] = None,
91
+ key_path: Optional[Path] = None,
92
+ key_id: str = "local",
93
+ ) -> Path:
94
+ """Assemble a PCRB from a .research workspace. Returns the bundle path."""
95
+ from skills.research_cache.hasher import SourceHasher
96
+
97
+ base_dir = Path(base_dir)
98
+ hasher = SourceHasher(base_dir)
99
+
100
+ # --- synthesis ---
101
+ synthesis = ""
102
+ final_md = base_dir / "final_synthesis.md"
103
+ if final_md.exists():
104
+ synthesis = final_md.read_text(encoding="utf-8")
105
+ else:
106
+ parts = sorted((base_dir / "scratchpads").glob("*/scope_synthesis.md"))
107
+ synthesis = "\n\n---\n\n".join(p.read_text(encoding="utf-8") for p in parts)
108
+
109
+ # --- claims + witnesses ---
110
+ scratch = base_dir / "scratchpads"
111
+ scope_dirs = (
112
+ [scratch / s for s in scope_ids]
113
+ if scope_ids
114
+ else (sorted(p for p in scratch.iterdir() if p.is_dir()) if scratch.exists() else [])
115
+ )
116
+ claim_records: List[Dict[str, Any]] = []
117
+ needed_hashes = set()
118
+
119
+ for scope_dir in scope_dirs:
120
+ for dossier_name in ("alpha_dossier.json", "beta_dossier.json"):
121
+ dpath = scope_dir / dossier_name
122
+ if not dpath.exists():
123
+ continue
124
+ dossier = load_dossier(dpath)
125
+ for c in claims_from_dossier(dossier):
126
+ rec = c.to_dict()
127
+ witness: Dict[str, Any] = {
128
+ "quote": c.verbatim_quote,
129
+ "source_hash": c.source_hash,
130
+ "confidence": None,
131
+ "verified_at": None,
132
+ }
133
+ if c.source_hash and c.verbatim_quote:
134
+ passed, conf, _msg = hasher.verify_quote(c.source_hash, c.verbatim_quote)
135
+ witness["confidence"] = float(conf)
136
+ witness["verified_at"] = datetime.now(timezone.utc).isoformat()
137
+ if passed and c.source_hash:
138
+ needed_hashes.add(c.source_hash)
139
+ elif c.source_hash:
140
+ needed_hashes.add(c.source_hash)
141
+ rec["witness"] = witness
142
+ claim_records.append(rec)
143
+
144
+ # --- bundled sources (self-contained: full text in the bundle) ---
145
+ sources: Dict[str, Dict[str, Any]] = {}
146
+ for src_hash in sorted(needed_hashes):
147
+ content = hasher.get_source_content(src_hash)
148
+ meta = hasher.get_source_metadata(src_hash) or {}
149
+ if content is None:
150
+ continue
151
+ sources[src_hash] = {
152
+ "url": meta.get("url"),
153
+ "title": meta.get("title"),
154
+ "tier": meta.get("tier"),
155
+ "content": content,
156
+ }
157
+
158
+ # --- manifest (integrity) ---
159
+ manifest: Dict[str, Any] = {
160
+ "synthesis_markdown": sha256_text(synthesis),
161
+ "claims": {rec["claim_id"]: member_hash(rec) for rec in claim_records},
162
+ "sources": {h: sha256_text(s["content"]) for h, s in sources.items()},
163
+ }
164
+
165
+ signature = sign_manifest(manifest, _resolve_key(key, key_path), key_id=key_id)
166
+
167
+ bundle = {
168
+ "pcrb_version": PCRB_VERSION,
169
+ "objective": objective
170
+ or (json.loads((base_dir / "manifest.json").read_text()).get("objective")
171
+ if (base_dir / "manifest.json").exists() else None),
172
+ "exported_at": datetime.now(timezone.utc).isoformat(),
173
+ "synthesis_markdown": synthesis,
174
+ "claims": claim_records,
175
+ "sources": sources,
176
+ "manifest": manifest,
177
+ "signature": signature,
178
+ }
179
+
180
+ out = Path(out_path) if out_path else (base_dir / "brief.pcrb.json")
181
+ out.parent.mkdir(parents=True, exist_ok=True)
182
+ with open(out, "w", encoding="utf-8") as f:
183
+ json.dump(bundle, f, indent=2)
184
+ return out
185
+
186
+
187
+ def main(argv: Optional[List[str]] = None) -> int:
188
+ import argparse
189
+
190
+ parser = argparse.ArgumentParser(description="Export a proof-carrying research brief")
191
+ parser.add_argument("--dir", default=".research")
192
+ parser.add_argument("--out", default=None)
193
+ parser.add_argument("--scope", action="append", dest="scope_ids")
194
+ parser.add_argument("--objective", default=None)
195
+ parser.add_argument("--key-file", default=None)
196
+ parser.add_argument("--key-id", default="local")
197
+ args = parser.parse_args(argv)
198
+
199
+ path = export_brief(
200
+ Path(args.dir),
201
+ out_path=Path(args.out) if args.out else None,
202
+ scope_ids=args.scope_ids,
203
+ objective=args.objective,
204
+ key_path=Path(args.key_file) if args.key_file else None,
205
+ key_id=args.key_id,
206
+ )
207
+ print(json.dumps({"status": "exported", "path": str(path)}))
208
+ return 0
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Proof-Carrying Research Brief (PCRB) — standalone verifier (Stream D).
4
+
5
+ Verifies a brief.pcrb.json WITHOUT trusting the LLM that produced it and
6
+ WITHOUT the original .research/ workspace: every quote is re-checked against
7
+ the sources BUNDLED INSIDE the brief.
8
+
9
+ Checks (all must pass for exit 0):
10
+ 1. source identity — sha256(source content) == its hash key in the bundle
11
+ 2. manifest integrity — every member hash recomputes to the manifest value
12
+ 3. signature — HMAC over the manifest (when alg != "none")
13
+ 4. quotes — every claim's verbatim_quote re-verified against the bundled
14
+ source text, using the SAME verification stack as the auditor
15
+ (SourceHasher.verify_quote via a throwaway cache directory)
16
+
17
+ Usage:
18
+ python3 runner/pcrb_verify.py brief.pcrb.json [--json] [--key-file ...]
19
+
20
+ Exit codes: 0 = all checks passed, 1 = tampering/failure detected,
21
+ 2 = unreadable bundle.
22
+ """
23
+
24
+ import argparse
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+
34
+ SCRIPT_DIR = Path(__file__).resolve().parent
35
+ PROJECT_ROOT = SCRIPT_DIR.parent
36
+ if str(PROJECT_ROOT) not in sys.path:
37
+ sys.path.insert(0, str(PROJECT_ROOT))
38
+
39
+ # Reuse the export-side canonicalization so hashes always agree.
40
+ from runner.pcrb import ( # noqa: E402
41
+ ENV_KEY,
42
+ canonical_json,
43
+ member_hash,
44
+ sha256_text,
45
+ )
46
+
47
+
48
+ def verify_brief(
49
+ bundle: Dict[str, Any],
50
+ key: Optional[bytes] = None,
51
+ ) -> Dict[str, Any]:
52
+ """Run all checks. Returns a structured report dict."""
53
+ import os
54
+ started = time.perf_counter()
55
+ failures: List[Dict[str, str]] = []
56
+
57
+ manifest = bundle.get("manifest") or {}
58
+ signature = bundle.get("signature") or {}
59
+ claims = bundle.get("claims") or []
60
+ sources = bundle.get("sources") or {}
61
+
62
+ # --- 1. source identity ---
63
+ for src_hash, meta in sources.items():
64
+ content = meta.get("content") or ""
65
+ actual = sha256_text(content)
66
+ if actual != src_hash:
67
+ failures.append({
68
+ "check": "source_identity",
69
+ "member": src_hash,
70
+ "message": f"bundled source content hashes to {actual}, not its key {src_hash}",
71
+ })
72
+
73
+ # --- 2. manifest integrity ---
74
+ synth_hash = sha256_text(bundle.get("synthesis_markdown") or "")
75
+ if manifest.get("synthesis_markdown") != synth_hash:
76
+ failures.append({
77
+ "check": "manifest_integrity",
78
+ "member": "synthesis_markdown",
79
+ "message": "synthesis_markdown does not match its manifest hash",
80
+ })
81
+
82
+ for rec in claims:
83
+ cid = rec.get("claim_id", "UNKNOWN")
84
+ expected = (manifest.get("claims") or {}).get(cid)
85
+ actual = member_hash(rec)
86
+ if expected is None:
87
+ failures.append({"check": "manifest_integrity", "member": cid, "message": "claim missing from manifest"})
88
+ elif expected != actual:
89
+ failures.append({
90
+ "check": "manifest_integrity",
91
+ "member": cid,
92
+ "message": f"claim hash mismatch (manifest {expected[:16]}… vs actual {actual[:16]}…)",
93
+ })
94
+
95
+ for src_hash, meta in sources.items():
96
+ expected = (manifest.get("sources") or {}).get(src_hash)
97
+ actual = sha256_text(meta.get("content") or "")
98
+ if expected is None:
99
+ failures.append({"check": "manifest_integrity", "member": src_hash, "message": "source missing from manifest"})
100
+ elif expected != actual:
101
+ failures.append({
102
+ "check": "manifest_integrity",
103
+ "member": src_hash,
104
+ "message": "source content does not match its manifest hash",
105
+ })
106
+
107
+ # --- 3. signature ---
108
+ alg = signature.get("alg") or "none"
109
+ sig_status = "unsigned"
110
+ if alg == "hmac-sha256":
111
+ if not key:
112
+ sig_status = "unverifiable"
113
+ failures.append({
114
+ "check": "signature",
115
+ "member": "manifest",
116
+ "message": "brief is HMAC-signed but no key was provided (env IUMBTEMS_PCRB_KEY or --key-file)",
117
+ })
118
+ else:
119
+ expected_sig = hmac.new(key, canonical_json(manifest).encode("utf-8"), hashlib.sha256).hexdigest()
120
+ provided = signature.get("sig") or ""
121
+ if not hmac.compare_digest(expected_sig, provided):
122
+ sig_status = "invalid"
123
+ failures.append({
124
+ "check": "signature",
125
+ "member": "manifest",
126
+ "message": "HMAC signature does not match manifest (manifest was rewritten or key differs)",
127
+ })
128
+ else:
129
+ sig_status = "valid"
130
+ # alg == "none": integrity-only mode — coordinated rewrites are NOT
131
+ # detectable without a key. Reported, not failed.
132
+
133
+ # --- 4. quotes against BUNDLED sources (single verification stack) ---
134
+ quotes_verified = 0
135
+ quotes_failed = 0
136
+ with tempfile.TemporaryDirectory(prefix="pcrb_verify_") as tmp:
137
+ from skills.research_cache.hasher import SourceHasher
138
+
139
+ hasher = SourceHasher(Path(tmp))
140
+ # Materialize bundled sources into the throwaway cache under their
141
+ # claimed hashes so verify_quote runs its exact/normalized/fuzzy ladder.
142
+ for src_hash, meta in sources.items():
143
+ content = meta.get("content") or ""
144
+ md_path = Path(tmp) / "sources" / f"{src_hash}.md"
145
+ md_path.parent.mkdir(parents=True, exist_ok=True)
146
+ md_path.write_text(content, encoding="utf-8")
147
+
148
+ for rec in claims:
149
+ cid = rec.get("claim_id", "UNKNOWN")
150
+ witness = rec.get("witness") or {}
151
+ src_hash = witness.get("source_hash") or rec.get("source_hash")
152
+ quote = witness.get("quote") or rec.get("verbatim_quote")
153
+ if not src_hash or not quote:
154
+ continue # structural rules live in refinement.check_invariants
155
+ if src_hash not in sources:
156
+ quotes_failed += 1
157
+ failures.append({
158
+ "check": "quote",
159
+ "member": cid,
160
+ "message": f"claim cites source {src_hash} which is not bundled",
161
+ })
162
+ continue
163
+ passed, conf, msg = hasher.verify_quote(src_hash, quote)
164
+ if passed:
165
+ quotes_verified += 1
166
+ else:
167
+ quotes_failed += 1
168
+ failures.append({
169
+ "check": "quote",
170
+ "member": cid,
171
+ "message": f"quote not found in bundled source (conf={conf}): {msg}",
172
+ })
173
+
174
+ elapsed = time.perf_counter() - started
175
+ return {
176
+ "ok": not failures,
177
+ "failures": failures,
178
+ "checks": {
179
+ "source_identity": "pass" if not any(f["check"] == "source_identity" for f in failures) else "fail",
180
+ "manifest_integrity": "pass" if not any(f["check"] == "manifest_integrity" for f in failures) else "fail",
181
+ "signature": sig_status,
182
+ "quotes": "pass" if quotes_failed == 0 else "fail",
183
+ },
184
+ "stats": {
185
+ "claims": len(claims),
186
+ "sources": len(sources),
187
+ "quotes_verified": quotes_verified,
188
+ "quotes_failed": quotes_failed,
189
+ "elapsed_s": round(elapsed, 4),
190
+ },
191
+ }
192
+
193
+
194
+ def main(argv: Optional[List[str]] = None) -> int:
195
+ parser = argparse.ArgumentParser(description="Verify a proof-carrying research brief")
196
+ parser.add_argument("brief", help="Path to brief.pcrb.json")
197
+ parser.add_argument("--json", action="store_true", help="Print JSON report")
198
+ parser.add_argument("--key-file", default=None)
199
+ args = parser.parse_args(argv)
200
+
201
+ path = Path(args.brief)
202
+ if not path.exists():
203
+ print(f"error: no such brief: {path}", file=sys.stderr)
204
+ return 2
205
+ try:
206
+ bundle = json.loads(path.read_text(encoding="utf-8"))
207
+ except (OSError, json.JSONDecodeError) as exc:
208
+ print(f"error: unreadable brief: {exc}", file=sys.stderr)
209
+ return 2
210
+
211
+ import os
212
+
213
+ key = None
214
+ if args.key_file and Path(args.key_file).exists():
215
+ key = Path(args.key_file).read_bytes().strip()
216
+ elif os.environ.get(ENV_KEY):
217
+ key = os.environ[ENV_KEY].encode("utf-8")
218
+
219
+ report = verify_brief(bundle, key=key)
220
+
221
+ if args.json:
222
+ print(json.dumps(report, indent=2))
223
+ else:
224
+ status = "PASS" if report["ok"] else "FAIL"
225
+ print(f"[{status}] {path}")
226
+ for name, st in report["checks"].items():
227
+ print(f" {name}: {st}")
228
+ for f in report["failures"]:
229
+ print(f" ✗ [{f['check']}] {f['member']}: {f['message']}")
230
+ s = report["stats"]
231
+ print(f" quotes verified: {s['quotes_verified']}/{s['claims']} in {s['elapsed_s']}s")
232
+
233
+ return 0 if report["ok"] else 1
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())