@heretek-ai/epistemic-swarm 0.7.12 → 0.7.13

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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/prompts/agent_darkharvest.md +20 -1
  4. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  6. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  20. package/runner/auditor_engine.py +101 -10
  21. package/runner/darkharvest_claims.py +326 -0
  22. package/runner/research_swarm.py +30 -1
  23. package/runner/state_machine.py +35 -0
  24. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  47. package/runner/tests/test_darkharvest_audit.py +290 -0
  48. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  51. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  52. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  53. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  54. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  55. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "IUMBTEMS (Epistemic Swarm) — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi (pi.dev), OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -33,6 +33,17 @@ partial per capability), benchmark/API quotes, and per-feature harvest
33
33
  proposals. Every cell needs `[VERIFIED: <hash>]` from
34
34
  `.research/sources/<sha256>.md` or `file://<path>#L<start>-L<end>`.
35
35
 
36
+ Discover and cache every cited source before you cite it (zero-key, no API keys):
37
+
38
+ ```bash
39
+ python3 skills/epistemic_search/scripts/search.py "<query>"
40
+ python3 skills/research_cache/hasher.py cache --url "<URL>" --content "$(cat fetched.md)" --title "<TITLE>"
41
+ ```
42
+
43
+ Confirm a repository actually exists (`owner/repo`) before citing it. A run that
44
+ never searches caches zero sources and is flagged `WARNING_LOW_GROUNDING` — the
45
+ runner prints `retrieval: N queries, M results, K cached` as evidence either way.
46
+
36
47
  ### Beta — Red Team
37
48
  License gate (LICENSE file + package metadata → SPDX), transitive weight,
38
49
  CVE/takeover notes, staleness (`⚠️ STALE` when >12mo or solo-maintainer),
@@ -51,7 +62,9 @@ AND what we lack). Gates are warn-only: badge inline, never auto-skip.
51
62
  ## 4. OUTPUT SPECIFICATION
52
63
 
53
64
  Per scope write `alpha_dossier.json` / `beta_dossier.json` with
54
- `candidate_repositories[]` entries:
65
+ `candidate_repositories[]` entries. Every entry MUST carry `source_hash` **and**
66
+ `verbatim_quote` (an exact substring of the cached `.research/sources/<hash>.md`);
67
+ entries without both are rejected as unverified by the audit.
55
68
 
56
69
  ```json
57
70
  {
@@ -59,6 +72,7 @@ Per scope write `alpha_dossier.json` / `beta_dossier.json` with
59
72
  "name": "owner/repo",
60
73
  "url": "https://github.com/owner/repo",
61
74
  "source_hash": "<sha256>",
75
+ "verbatim_quote": "<exact substring of the cached source>",
62
76
  "license": "Apache-2.0",
63
77
  "license_risk": "SAFE | COPYLEFT_WARNING | PROHIBITIVE",
64
78
  "harvest_policy": "depend-or-vendor | clean-room-rebuild-only",
@@ -89,3 +103,8 @@ candidates.
89
103
  3. No unverified harvest verdicts. Metadata ranks; only VERIFIED harvests.
90
104
  4. No unbounded expansion past `--max-repos` or per-repo caps without user
91
105
  confirmation. Firecrawl missing → gap note, never abort.
106
+ 5. **Do NOT self-report audit results.** Never emit `epistemic_audit`,
107
+ `confidence`, `verified_*`, `unverified_*`, `mean_epistemic_score`, or any
108
+ `*.verified` / `*.total_sources` field. The runner computes verification from
109
+ cached witnesses; agent-declared counts are ignored (renamed to
110
+ `self_reported_*`) and only mislead a human reader.
@@ -5,6 +5,7 @@ Verifies quote authenticity against content-addressed source cache,
5
5
  calculates divergence metrics, prunes ungrounded claims, and generates synthesis reports.
6
6
  """
7
7
 
8
+ import os
8
9
  import sys
9
10
  import json
10
11
  from pathlib import Path
@@ -17,6 +18,22 @@ from skills.research_cache.hasher import SourceHasher
17
18
  from runner.state_machine import ResearchStateMachine, ScopeStatus
18
19
  from runner.refinement import compute_epistemic_score
19
20
 
21
+ from runner.darkharvest_claims import (
22
+ RepoValidator,
23
+ cross_check_repos,
24
+ normalize_dossier_claims,
25
+ sanitize_self_reported,
26
+ )
27
+
28
+
29
+ def _repo_validation_enabled() -> bool:
30
+ return os.environ.get("IUMBTEMS_REPO_VALIDATE", "1").strip().lower() not in (
31
+ "0",
32
+ "false",
33
+ "no",
34
+ "off",
35
+ )
36
+
20
37
 
21
38
  def _mark_rejected_claims(score_claims: list, *result_lists: list) -> None:
22
39
  """Mirror the auditor's UNVERIFIED_REJECTED verdict onto ClaimWitness records."""
@@ -114,10 +131,16 @@ def _apply_living_dossiers_degradation(
114
131
 
115
132
 
116
133
  class EpistemicAuditorEngine:
117
- def __init__(self, base_dir: Optional[Path] = None):
134
+ def __init__(
135
+ self,
136
+ base_dir: Optional[Path] = None,
137
+ repo_validator: Optional[Any] = None,
138
+ ):
118
139
  self.base_dir = base_dir or Path(".research")
119
140
  self.hasher = SourceHasher(base_dir=self.base_dir)
120
141
  self.state_machine = ResearchStateMachine(base_dir=self.base_dir)
142
+ # Ground-truth repo checks for darkharvest; injectable for tests/offline.
143
+ self.repo_validator = repo_validator
121
144
 
122
145
  def audit_scope(
123
146
  self,
@@ -149,16 +172,26 @@ class EpistemicAuditorEngine:
149
172
  with open(beta_file, "r", encoding="utf-8") as f:
150
173
  beta_dossier = json.load(f)
151
174
 
175
+ # Darkharvest dossiers emit `candidate_repositories[]`, not the standard
176
+ # claim keys, and may self-report audit-shaped fields (#5 layers 1 & 3).
177
+ for label, dossier in (("alpha", alpha_dossier), ("beta", beta_dossier)):
178
+ _, renamed = sanitize_self_reported(dossier)
179
+ if renamed:
180
+ print(
181
+ f" [Audit] ignored agent self-reported audit fields "
182
+ f"({label}): {', '.join(sorted(renamed))}"
183
+ )
184
+
152
185
  self.state_machine.update_scope_status(scope_id, ScopeStatus.AUDITING)
153
186
 
154
- # 1. Audit Alpha Claims
187
+ # 1. Audit Alpha Claims (normalized from either schema)
155
188
  alpha_results, alpha_verified, alpha_rejected = self._verify_claims(
156
- alpha_dossier.get("affirmative_claims", []), constitution=constitution
189
+ normalize_dossier_claims(alpha_dossier, "alpha"), constitution=constitution
157
190
  )
158
191
 
159
192
  # 2. Audit Beta Claims
160
193
  beta_results, beta_verified, beta_rejected = self._verify_claims(
161
- beta_dossier.get("falsification_claims", []), constitution=constitution
194
+ normalize_dossier_claims(beta_dossier, "beta"), constitution=constitution
162
195
  )
163
196
 
164
197
  counts = {
@@ -196,6 +229,28 @@ class EpistemicAuditorEngine:
196
229
  alpha_dossier, beta_dossier
197
230
  )
198
231
 
232
+ # 4b. Darkharvest: license cross-check + repo ground truth. A dialectic
233
+ # that recommends `depend-or-vendor` on an unstated license must not pass
234
+ # as CERTIFIED (observed: MIT vs NOASSERTION for the same repo, #5).
235
+ harvest_findings: List[Dict[str, Any]] = []
236
+ if mode == "darkharvest":
237
+ validator = self.repo_validator
238
+ if validator is None:
239
+ validator = RepoValidator(enabled=_repo_validation_enabled())
240
+ harvest_findings = cross_check_repos(
241
+ alpha_dossier, beta_dossier, validator=validator
242
+ )
243
+
244
+ blocking_findings = [
245
+ f for f in harvest_findings if f.get("severity") == "BLOCKING"
246
+ ]
247
+ if blocking_findings:
248
+ verdict = "WARNING_LICENSE_CONFLICT"
249
+ elif epistemic_score >= accept_threshold:
250
+ verdict = "CERTIFIED"
251
+ else:
252
+ verdict = "WARNING_LOW_GROUNDING"
253
+
199
254
  # 5. Build Audit Report
200
255
  audit_report = {
201
256
  "auditor": "Epistemic Auditor Engine v1.0",
@@ -209,13 +264,13 @@ class EpistemicAuditorEngine:
209
264
  "epistemic_score": epistemic_score,
210
265
  "divergence_score": divergence_score,
211
266
  "mode": mode,
212
- "verdict": "CERTIFIED"
213
- if epistemic_score >= accept_threshold
214
- else "WARNING_LOW_GROUNDING",
267
+ "blocking_findings": len(blocking_findings),
268
+ "verdict": verdict,
215
269
  },
216
270
  "alpha_claims_audit": alpha_results,
217
271
  "beta_claims_audit": beta_results,
218
272
  "divergence_matrix": divergence_matrix,
273
+ "harvest_findings": harvest_findings,
219
274
  "degradation": degradation,
220
275
  }
221
276
 
@@ -397,7 +452,9 @@ class EpistemicAuditorEngine:
397
452
  return lines
398
453
 
399
454
  @staticmethod
400
- def _render_rejected_section(claims: list) -> List[str]:
455
+ def _render_rejected_section(
456
+ claims: list, summary: Optional[Dict[str, Any]] = None
457
+ ) -> List[str]:
401
458
  lines = ["\n## 3. Rejected & Unverified Assertions"]
402
459
  rejected = [c for c in claims if c["audited_tag"] == "UNVERIFIED_REJECTED"]
403
460
  if rejected:
@@ -405,9 +462,26 @@ class EpistemicAuditorEngine:
405
462
  lines.append(
406
463
  f'- ⚠️ **PURGED**: "{r["statement"]}" — *Reason: {r["reason"]}*'
407
464
  )
465
+ return lines
466
+
467
+ # No rejected claims is only "all verified" when the audit actually
468
+ # verified something. Otherwise this is static text contradicting the
469
+ # verdict (observed in darkharvest: 0 verified + "100% verified", #5).
470
+ summary = summary or {}
471
+ verified = summary.get("verified_passed", 0)
472
+ if summary.get("verdict") == "WARNING_LICENSE_CONFLICT":
473
+ lines.append(
474
+ f"No claims rejected, but {summary.get('blocking_findings', 0)} "
475
+ "blocking license/ground-truth finding(s) require review before acting."
476
+ )
477
+ elif verified:
478
+ lines.append(
479
+ f"No claims rejected. {verified} claim(s) verified against the source cache."
480
+ )
408
481
  else:
409
482
  lines.append(
410
- "Zero claims rejected. 100% of cited assertions verified against source cache."
483
+ "No claims rejected — but **0 claims were verified** (empty source "
484
+ "cache). Treat every assertion above as unverified."
411
485
  )
412
486
  return lines
413
487
 
@@ -446,8 +520,25 @@ class EpistemicAuditorEngine:
446
520
  md.extend(self._render_tensions_section(audit.get("divergence_matrix", [])))
447
521
  md.extend(
448
522
  self._render_rejected_section(
449
- audit["alpha_claims_audit"] + audit["beta_claims_audit"]
523
+ audit["alpha_claims_audit"] + audit["beta_claims_audit"],
524
+ summary,
450
525
  )
451
526
  )
452
527
  md.extend(self._render_negative_knowledge_section(alpha, beta))
528
+ md.extend(self._render_harvest_findings(audit.get("harvest_findings", [])))
453
529
  return "\n".join(md)
530
+
531
+ @staticmethod
532
+ def _render_harvest_findings(findings: list) -> List[str]:
533
+ if not findings:
534
+ return []
535
+ lines = ["\n## 5. Harvest Safety Findings"]
536
+ for f in findings:
537
+ icon = "⛔" if f.get("severity") == "BLOCKING" else "⚠️"
538
+ detail = f.get("detail") or f.get("kind", "")
539
+ resolution = f.get("resolution")
540
+ suffix = f" → **{resolution}**" if resolution else ""
541
+ lines.append(
542
+ f"- {icon} `{f.get('repo', '?')}` — {f.get('kind', '')}: {detail}{suffix}"
543
+ )
544
+ return lines
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ """Darkharvest claim normalization, license cross-check, and repo validation.
3
+
4
+ Heretek-AI/IUMBTEMS#5: darkharvest dossiers emit `candidate_repositories[]` with
5
+ their own `source_hash` witnesses, but the auditor only reads
6
+ `affirmative_claims` / `falsification_claims` — so `total_claims_audited` was
7
+ always 0 and the dialectic was structurally inert. Separately, alpha and beta
8
+ can disagree about a repo's license (observed: `MIT` vs `NOASSERTION` for the
9
+ same repo, with alpha recommending `depend-or-vendor`) and nothing cross-checked
10
+ them, because the audit produced no claims to compare.
11
+
12
+ This module supplies the three missing pieces:
13
+ - `normalize_dossier_claims` — fold darkharvest's shape into the standard claim
14
+ schema so one contract flows to the auditor, PCRB, and ledger.
15
+ - `cross_check_repos` — alpha/beta license disagreement is a blocking finding,
16
+ resolved conservatively.
17
+ - `RepoValidator` — ground-truth (existence / license / archived) with fail-open
18
+ offline behavior.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import urllib.error
25
+ import urllib.request
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ # Top-level dossier keys that *look* like audit output but are agent-authored.
29
+ # Renamed to `self_reported_*` on load so no consumer mistakes them for verified
30
+ # counts (see #5 layer 3).
31
+ AUDIT_SHAPED_KEYS = frozenset(
32
+ {
33
+ "epistemic_audit",
34
+ "confidence",
35
+ "gaps",
36
+ "verified_claims",
37
+ "unverified_claims",
38
+ "verified_sources",
39
+ "total_sources",
40
+ "total_claims",
41
+ "total_candidates",
42
+ "claims_total",
43
+ "claims_verified",
44
+ "claims_unverified",
45
+ "claims_falsified",
46
+ "claims_unverifiable",
47
+ "rejected_claims",
48
+ "mean_epistemic_score",
49
+ "primary_sources",
50
+ "primary_benchmarks",
51
+ "technical_specs",
52
+ "peer_reviewed",
53
+ }
54
+ )
55
+
56
+ # Licenses that may be `depend`ed on or `vendor`ed. Everything else (incl.
57
+ # unknown / NOASSERTION) resolves to clean-room-rebuild-only.
58
+ PERMISSIVE_LICENSES = frozenset({"MIT", "APACHE-2.0", "BSD-3-CLAUSE", "ISC"})
59
+
60
+ UNKNOWN_LICENSES = frozenset({"", "UNKNOWN", "NOASSERTION", "NONE", "OTHER"})
61
+
62
+
63
+ def _norm_license(value: Any) -> str:
64
+ return str(value or "").strip().upper()
65
+
66
+
67
+ def _is_permissive(value: Any) -> bool:
68
+ return _norm_license(value) in PERMISSIVE_LICENSES
69
+
70
+
71
+ def sanitize_self_reported(dossier: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
72
+ """Rename agent-authored audit-shaped keys; return (dossier, renamed_keys).
73
+
74
+ Never deletes — the values are preserved under `self_reported_*` so a human
75
+ can still see what the agent claimed, but nothing counts them.
76
+ """
77
+ renamed: List[str] = []
78
+ if not isinstance(dossier, dict):
79
+ return dossier, renamed
80
+ for key in list(dossier.keys()):
81
+ if key in AUDIT_SHAPED_KEYS:
82
+ dossier[f"self_reported_{key}"] = dossier.pop(key)
83
+ renamed.append(key)
84
+ return dossier, renamed
85
+
86
+
87
+ def _repo_key(candidate: Dict[str, Any]) -> Optional[str]:
88
+ """Stable key for a candidate repo: `owner/repo`, lowercased."""
89
+ raw = str(candidate.get("name") or candidate.get("repo") or "").strip()
90
+ if not raw:
91
+ url = str(candidate.get("url") or "").strip()
92
+ match = re.search(r"github\.com/([^/]+/[^/#?]+)", url)
93
+ if match:
94
+ raw = match.group(1)
95
+ raw = raw.removesuffix(".git").strip("/")
96
+ return raw.lower() if raw else None
97
+
98
+
99
+ def _verdict_claims(role: str, candidate: Dict[str, Any]) -> List[Dict[str, Any]]:
100
+ """One standard claim per candidate verdict (verifiable when a quote exists)."""
101
+ name = candidate.get("name") or candidate.get("url") or "unknown-repo"
102
+ source_hash = candidate.get("source_hash")
103
+ source_url = candidate.get("url")
104
+ quote = candidate.get("verbatim_quote")
105
+ out: List[Dict[str, Any]] = []
106
+ for i, verdict in enumerate(candidate.get("verdicts") or [], start=1):
107
+ if not isinstance(verdict, dict):
108
+ continue
109
+ rid = candidate.get("repo_id") or name
110
+ out.append(
111
+ {
112
+ "claim_id": f"{rid}-{role}-V{i}",
113
+ "tag": "VERIFIED",
114
+ "statement": (
115
+ f"{name}: {verdict.get('feature', '?')} → "
116
+ f"{verdict.get('verdict', '?')} ({verdict.get('reason', '')})"
117
+ ),
118
+ "source_hash": source_hash,
119
+ "source_url": source_url,
120
+ # Prefer a verdict-specific quote; fall back to the candidate's.
121
+ "verbatim_quote": verdict.get("verbatim_quote") or quote,
122
+ }
123
+ )
124
+ return out
125
+
126
+
127
+ def normalize_dossier_claims(
128
+ dossier: Dict[str, Any], role: str
129
+ ) -> List[Dict[str, Any]]:
130
+ """Standard-schema claims for a dossier.
131
+
132
+ Uses the canonical keys when present; otherwise (darkharvest) derives them
133
+ from `candidate_repositories[]` so the auditor has something to verify.
134
+ """
135
+ if not isinstance(dossier, dict):
136
+ return []
137
+ standard = dossier.get("affirmative_claims") or dossier.get("falsification_claims")
138
+ if isinstance(standard, list) and standard:
139
+ return [c for c in standard if isinstance(c, dict)]
140
+
141
+ out: List[Dict[str, Any]] = []
142
+ for candidate in dossier.get("candidate_repositories") or []:
143
+ if isinstance(candidate, dict):
144
+ out.extend(_verdict_claims(role, candidate))
145
+ return out
146
+
147
+
148
+ def _resolved_policy(alpha: Dict[str, Any], beta: Dict[str, Any]) -> str:
149
+ """Conservative harvest policy when the two dossiers disagree."""
150
+ for side in (alpha, beta):
151
+ policy = str(side.get("harvest_policy") or "").lower()
152
+ risk = str(side.get("license_risk") or "").upper()
153
+ if policy == "clean-room-rebuild-only" or risk in (
154
+ "COPYLEFT_WARNING",
155
+ "PROHIBITIVE",
156
+ ):
157
+ return "clean-room-rebuild-only"
158
+ for side in (alpha, beta):
159
+ if not _is_permissive(side.get("license")):
160
+ return "clean-room-rebuild-only"
161
+ return "depend-or-vendor"
162
+
163
+
164
+ class RepoValidator:
165
+ """Ground-truth repo checks via the GitHub API.
166
+
167
+ Fail-open: any network/timeout error yields `exists=None` and blocks nothing,
168
+ so an offline or rate-limited run degrades rather than aborting.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ token: Optional[str] = None,
174
+ timeout: float = 5.0,
175
+ enabled: bool = True,
176
+ ):
177
+ self.token = token if token is not None else os.environ.get("GITHUB_TOKEN")
178
+ self.timeout = timeout
179
+ self.enabled = enabled
180
+ self._cache: Dict[str, Dict[str, Any]] = {}
181
+
182
+ def validate(self, owner_repo: str) -> Dict[str, Any]:
183
+ key = owner_repo.strip().lower()
184
+ if key in self._cache:
185
+ return self._cache[key]
186
+ result = {"exists": None, "license": None, "archived": None}
187
+ if self.enabled and key:
188
+ result = self._fetch(owner_repo)
189
+ self._cache[key] = result
190
+ return result
191
+
192
+ def _fetch(self, owner_repo: str) -> Dict[str, Any]:
193
+ request = urllib.request.Request(
194
+ f"https://api.github.com/repos/{owner_repo}",
195
+ headers={
196
+ "Accept": "application/vnd.github+json",
197
+ "User-Agent": "iumbtems-darkharvest-audit",
198
+ **({"Authorization": f"Bearer {self.token}"} if self.token else {}),
199
+ },
200
+ )
201
+ try:
202
+ with urllib.request.urlopen(request, timeout=self.timeout) as resp:
203
+ data = json.loads(resp.read().decode("utf-8", errors="replace"))
204
+ license_field = data.get("license") or {}
205
+ return {
206
+ "exists": True,
207
+ "license": license_field.get("spdx_id"),
208
+ "archived": bool(data.get("archived")),
209
+ }
210
+ except urllib.error.HTTPError as e:
211
+ if e.code == 404:
212
+ return {"exists": False, "license": None, "archived": None}
213
+ return {"exists": None, "license": None, "archived": None}
214
+ except Exception:
215
+ return {"exists": None, "license": None, "archived": None}
216
+
217
+
218
+ def cross_check_repos(
219
+ alpha_dossier: Dict[str, Any],
220
+ beta_dossier: Dict[str, Any],
221
+ validator: Optional[RepoValidator] = None,
222
+ ) -> List[Dict[str, Any]]:
223
+ """Blocking findings for license disagreement and ground-truth failures.
224
+
225
+ A dialectic that produces a `depend-or-vendor` verdict on an unstated license
226
+ must not reach a human as `divergence: 0`. Each finding carries the
227
+ conservative resolution.
228
+ """
229
+ alpha = {
230
+ key: c
231
+ for c in (alpha_dossier.get("candidate_repositories") or [])
232
+ if isinstance(c, dict) and (key := _repo_key(c))
233
+ }
234
+ beta = {
235
+ key: c
236
+ for c in (beta_dossier.get("candidate_repositories") or [])
237
+ if isinstance(c, dict) and (key := _repo_key(c))
238
+ }
239
+
240
+ findings: List[Dict[str, Any]] = []
241
+ for key in sorted(set(alpha) | set(beta)):
242
+ a = alpha.get(key)
243
+ b = beta.get(key)
244
+ if a and b:
245
+ alic, blic = (
246
+ _norm_license(a.get("license")),
247
+ _norm_license(b.get("license")),
248
+ )
249
+ if alic != blic:
250
+ findings.append(
251
+ {
252
+ "repo": key,
253
+ "kind": "LICENSE_DISAGREEMENT",
254
+ "severity": "BLOCKING",
255
+ "alpha": {
256
+ "license": a.get("license"),
257
+ "risk": a.get("license_risk"),
258
+ },
259
+ "beta": {
260
+ "license": b.get("license"),
261
+ "risk": b.get("license_risk"),
262
+ },
263
+ "resolution": _resolved_policy(a, b),
264
+ }
265
+ )
266
+ elif str(a.get("harvest_policy") or "") != str(
267
+ b.get("harvest_policy") or ""
268
+ ):
269
+ findings.append(
270
+ {
271
+ "repo": key,
272
+ "kind": "POLICY_DISAGREEMENT",
273
+ "severity": "BLOCKING",
274
+ "alpha": {"policy": a.get("harvest_policy")},
275
+ "beta": {"policy": b.get("harvest_policy")},
276
+ "resolution": _resolved_policy(a, b),
277
+ }
278
+ )
279
+
280
+ # Ground truth (fail-open when the API is unreachable).
281
+ if validator is not None:
282
+ for side_name, side in (("alpha", a), ("beta", b)):
283
+ if not side:
284
+ continue
285
+ truth = validator.validate(key)
286
+ if truth.get("exists") is False:
287
+ findings.append(
288
+ {
289
+ "repo": key,
290
+ "kind": "REPO_UNRESOLVABLE",
291
+ "severity": "BLOCKING",
292
+ "source": side_name,
293
+ "detail": "GitHub API returned 404",
294
+ "resolution": "reject-citation",
295
+ }
296
+ )
297
+ break
298
+ if truth.get("archived") is True:
299
+ findings.append(
300
+ {
301
+ "repo": key,
302
+ "kind": "REPO_ARCHIVED",
303
+ "severity": "WARNING",
304
+ "detail": "repository is archived",
305
+ }
306
+ )
307
+ break
308
+ truth_license = _norm_license(truth.get("license"))
309
+ if (
310
+ truth.get("exists")
311
+ and truth_license
312
+ and not _is_permissive(truth.get("license"))
313
+ and _is_permissive(side.get("license"))
314
+ ):
315
+ findings.append(
316
+ {
317
+ "repo": key,
318
+ "kind": "LICENSE_CONTRADICTS_GROUND_TRUTH",
319
+ "severity": "BLOCKING",
320
+ "source": side_name,
321
+ "detail": f"dossier says {side.get('license')}, GitHub says {truth.get('license')}",
322
+ "resolution": "clean-room-rebuild-only",
323
+ }
324
+ )
325
+ break
326
+ return findings
@@ -270,7 +270,17 @@ class SwarmRunner:
270
270
  self.state_machine = ResearchStateMachine(
271
271
  base_dir=self.base_dir, mode=self.mode
272
272
  )
273
- self.auditor = EpistemicAuditorEngine(base_dir=self.base_dir)
273
+ from runner.auditor_engine import _repo_validation_enabled
274
+ from runner.darkharvest_claims import RepoValidator
275
+
276
+ self.auditor = EpistemicAuditorEngine(
277
+ base_dir=self.base_dir,
278
+ # Ground-truth repo checks are for real runs; mock dossiers are
279
+ # synthetic, so the unit suite must stay offline.
280
+ repo_validator=RepoValidator(
281
+ enabled=(not self.mock_mode) and _repo_validation_enabled()
282
+ ),
283
+ )
274
284
  self.hasher = SourceHasher(base_dir=self.base_dir)
275
285
  self.prompts_dir = PROJECT_ROOT / "prompts"
276
286
 
@@ -1252,11 +1262,30 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
1252
1262
  )
1253
1263
  report_path = self._compile_master_synthesis(objective)
1254
1264
  self.state_machine.update_session_status(SessionStatus.COMPLETED)
1265
+ self._write_orphan_report()
1255
1266
 
1256
1267
  duration = (datetime.now(timezone.utc) - start_time).total_seconds()
1257
1268
  print(f" {self._retrieval_banner()}")
1258
1269
  print(f"\n🎉 Swarm run completed in {duration:.1f}s. Report: {report_path}")
1259
1270
 
1271
+ def _write_orphan_report(self) -> None:
1272
+ """Record scope dirs absent from every manifest (mark-only, never delete)."""
1273
+ try:
1274
+ orphans = self.state_machine.find_orphan_scopes()
1275
+ except Exception: # noqa: BLE001 - hygiene must not fail the run
1276
+ return
1277
+ if not orphans:
1278
+ return
1279
+ path = self.base_dir / "orphans.json"
1280
+ try:
1281
+ path.write_text(json.dumps(orphans, indent=2), encoding="utf-8")
1282
+ except OSError:
1283
+ return
1284
+ print(
1285
+ f" ⚠️ {len(orphans)} orphaned scope dir(s) not referenced by any "
1286
+ f"manifest -> {path.name} (not deleted)"
1287
+ )
1288
+
1260
1289
  def _retrieval_banner(self) -> str:
1261
1290
  """One-line retrieval summary for the current run (issue #3).
1262
1291
 
@@ -219,6 +219,41 @@ class ResearchStateMachine:
219
219
  def get_scope_dir(self, scope_id: str) -> Path:
220
220
  return self.scratchpads_dir / scope_id
221
221
 
222
+ def find_orphan_scopes(self) -> List[Dict[str, Any]]:
223
+ """Scope dirs not referenced by any manifest — marked, never deleted.
224
+
225
+ A dir may belong to a concurrently running session, so this reports
226
+ rather than prunes (issue #5 layer 6: 14 manifest-only leftovers).
227
+ """
228
+ referenced: set = set()
229
+ manifests = [self.manifest_file, *self.base_dir.glob("manifest.*.json")]
230
+ for manifest_path in manifests:
231
+ if not manifest_path.exists():
232
+ continue
233
+ try:
234
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
235
+ except (OSError, ValueError):
236
+ continue
237
+ for scope in data.get("scopes") or []:
238
+ if isinstance(scope, dict) and scope.get("scope_id"):
239
+ referenced.add(scope["scope_id"])
240
+
241
+ orphans: List[Dict[str, Any]] = []
242
+ if not self.scratchpads_dir.is_dir():
243
+ return orphans
244
+ for scope_dir in sorted(self.scratchpads_dir.iterdir()):
245
+ if not scope_dir.is_dir() or scope_dir.name in referenced:
246
+ continue
247
+ orphans.append(
248
+ {
249
+ "scope_id": scope_dir.name,
250
+ "path": str(scope_dir),
251
+ "alpha_dossier": (scope_dir / "alpha_dossier.json").exists(),
252
+ "beta_dossier": (scope_dir / "beta_dossier.json").exists(),
253
+ }
254
+ )
255
+ return orphans
256
+
222
257
  def _read_scope_unlocked(self, scope_id: str) -> Dict[str, Any]:
223
258
  scope_manifest_file = self.get_scope_dir(scope_id) / MANIFEST_FILENAME
224
259
  if not scope_manifest_file.exists():
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env python3
2
+ """Darkharvest audit regressions (issue #5).
3
+
4
+ Covers the three layers that made darkharvest ungrounded by construction:
5
+ claim-schema normalization (auditor could not see darkharvest claims), the
6
+ alpha/beta license cross-check (MIT vs NOASSERTION reached a human as
7
+ divergence 0), and self-reported verification fields that must never count.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
17
+ if str(PROJECT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+
20
+ from runner.auditor_engine import EpistemicAuditorEngine # noqa: E402
21
+ from runner.darkharvest_claims import ( # noqa: E402
22
+ RepoValidator,
23
+ cross_check_repos,
24
+ normalize_dossier_claims,
25
+ sanitize_self_reported,
26
+ )
27
+ from runner.research_swarm import SwarmRunner # noqa: E402
28
+ from skills.research_cache.hasher import SourceHasher # noqa: E402
29
+
30
+
31
+ class FakeValidator:
32
+ def __init__(self, result):
33
+ self.result = result
34
+ self.calls = []
35
+
36
+ def validate(self, owner_repo):
37
+ self.calls.append(owner_repo)
38
+ return self.result
39
+
40
+
41
+ def _candidate(license_, risk, policy, shash, quote):
42
+ return {
43
+ "repo_id": "DH-01",
44
+ "name": "getpaseo/paseo",
45
+ "url": "https://github.com/getpaseo/paseo",
46
+ "source_hash": shash,
47
+ "verbatim_quote": quote,
48
+ "license": license_,
49
+ "license_risk": risk,
50
+ "harvest_policy": policy,
51
+ "verdicts": [
52
+ {"feature": "worktree isolation", "verdict": "vendor", "reason": "r"}
53
+ ],
54
+ }
55
+
56
+
57
+ class TestNormalization(unittest.TestCase):
58
+ def test_darkharvest_shape_becomes_standard_claims(self):
59
+ dossier = {
60
+ "candidate_repositories": [
61
+ _candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
62
+ ]
63
+ }
64
+ claims = normalize_dossier_claims(dossier, "alpha")
65
+ self.assertEqual(len(claims), 1)
66
+ self.assertEqual(claims[0]["source_hash"], "abc")
67
+ self.assertEqual(claims[0]["verbatim_quote"], "q")
68
+
69
+ def test_standard_keys_win_when_present(self):
70
+ dossier = {
71
+ "affirmative_claims": [{"claim_id": "C1", "statement": "s"}],
72
+ "candidate_repositories": [
73
+ _candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
74
+ ],
75
+ }
76
+ claims = normalize_dossier_claims(dossier, "alpha")
77
+ self.assertEqual([c["claim_id"] for c in claims], ["C1"])
78
+
79
+
80
+ class TestSelfReported(unittest.TestCase):
81
+ def test_audit_shaped_keys_are_renamed_not_counted(self):
82
+ dossier = {
83
+ "epistemic_audit": {"verified_claims": 15},
84
+ "confidence": "HIGH",
85
+ "scope_id": "s1",
86
+ }
87
+ dossier, renamed = sanitize_self_reported(dossier)
88
+ self.assertIn("epistemic_audit", renamed)
89
+ self.assertIn("confidence", renamed)
90
+ self.assertNotIn("epistemic_audit", dossier)
91
+ self.assertEqual(
92
+ dossier["self_reported_epistemic_audit"]["verified_claims"], 15
93
+ )
94
+ # Nothing audit-shaped remains to be mistaken for verified counts.
95
+ self.assertFalse(set(dossier) & {"epistemic_audit", "confidence"})
96
+
97
+
98
+ class TestLicenseCrossCheck(unittest.TestCase):
99
+ def test_mit_vs_noassertion_is_blocking_and_conservative(self):
100
+ from runner.auditor_engine import _repo_validation_enabled # noqa: F401
101
+
102
+ alpha = {
103
+ "candidate_repositories": [
104
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
105
+ ]
106
+ }
107
+ beta = {
108
+ "candidate_repositories": [
109
+ _candidate(
110
+ "NOASSERTION", "PROHIBITIVE", "clean-room-rebuild-only", "a", "q"
111
+ )
112
+ ]
113
+ }
114
+ findings = cross_check_repos(alpha, beta)
115
+ kinds = {f["kind"] for f in findings}
116
+ self.assertIn("LICENSE_DISAGREEMENT", kinds)
117
+ self.assertTrue(all(f["severity"] == "BLOCKING" for f in findings))
118
+ self.assertEqual(findings[0]["resolution"], "clean-room-rebuild-only")
119
+
120
+ def test_agreement_yields_no_findings(self):
121
+ alpha = {
122
+ "candidate_repositories": [
123
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
124
+ ]
125
+ }
126
+ beta = {
127
+ "candidate_repositories": [
128
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
129
+ ]
130
+ }
131
+ self.assertEqual(cross_check_repos(alpha, beta), [])
132
+
133
+ def test_404_is_an_unresolvable_blocking_finding(self):
134
+ alpha = {
135
+ "candidate_repositories": [
136
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
137
+ ]
138
+ }
139
+ findings = cross_check_repos(
140
+ alpha, {}, validator=FakeValidator({"exists": False})
141
+ )
142
+ self.assertTrue(any(f["kind"] == "REPO_UNRESOLVABLE" for f in findings))
143
+
144
+ def test_offline_fails_open(self):
145
+ alpha = {
146
+ "candidate_repositories": [
147
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
148
+ ]
149
+ }
150
+ findings = cross_check_repos(
151
+ alpha,
152
+ {},
153
+ validator=FakeValidator(
154
+ {"exists": None, "license": None, "archived": None}
155
+ ),
156
+ )
157
+ self.assertEqual(findings, [])
158
+
159
+ def test_validator_fail_open_on_error(self):
160
+ """Network failure must yield exists=None, never raise (offline-safe)."""
161
+ import urllib.error
162
+ from unittest import mock
163
+
164
+ v = RepoValidator(enabled=True, timeout=0.001)
165
+ with mock.patch(
166
+ "urllib.request.urlopen", side_effect=urllib.error.URLError("offline")
167
+ ):
168
+ self.assertIsNone(v.validate("owner/repo")["exists"])
169
+
170
+ def test_validator_404_is_not_exists(self):
171
+ import urllib.error
172
+ from unittest import mock
173
+
174
+ v = RepoValidator(enabled=True, timeout=0.001)
175
+ err = urllib.error.HTTPError("u", 404, "not found", {}, None)
176
+ with mock.patch("urllib.request.urlopen", side_effect=err):
177
+ self.assertFalse(v.validate("owner/missing")["exists"])
178
+
179
+
180
+ class TestDarkharvestAuditIntegration(unittest.TestCase):
181
+ def _runner_and_scope(self):
182
+ tmp = tempfile.mkdtemp()
183
+ base = Path(tmp) / ".research"
184
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
185
+ sm = runner.state_machine
186
+ sm.init_session("arena")
187
+ sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
188
+ hasher = SourceHasher(base_dir=base)
189
+ shash = hasher.store_source(
190
+ "https://github.com/getpaseo/paseo",
191
+ "Paseo archives a clean worktree with no confirmation.",
192
+ "paseo",
193
+ )
194
+ quote = "Paseo archives a clean worktree with no confirmation."
195
+ scope_dir = sm.get_scope_dir("s1")
196
+ (scope_dir / "alpha_dossier.json").write_text(
197
+ json.dumps(
198
+ {
199
+ "scope_id": "s1",
200
+ "candidate_repositories": [
201
+ _candidate("MIT", "SAFE", "depend-or-vendor", shash, quote)
202
+ ],
203
+ "epistemic_audit": {"verified_claims": 15, "confidence": "HIGH"},
204
+ }
205
+ ),
206
+ encoding="utf-8",
207
+ )
208
+ (scope_dir / "beta_dossier.json").write_text(
209
+ json.dumps(
210
+ {
211
+ "scope_id": "s1",
212
+ "candidate_repositories": [
213
+ _candidate(
214
+ "NOASSERTION",
215
+ "PROHIBITIVE",
216
+ "clean-room-rebuild-only",
217
+ shash,
218
+ quote,
219
+ )
220
+ ],
221
+ }
222
+ ),
223
+ encoding="utf-8",
224
+ )
225
+ return runner, base
226
+
227
+ def test_auditor_sees_claims_and_blocks_license_conflict(self):
228
+ runner, base = self._runner_and_scope()
229
+ auditor = EpistemicAuditorEngine(
230
+ base_dir=base,
231
+ repo_validator=FakeValidator(
232
+ {"exists": True, "license": "NOASSERTION", "archived": False}
233
+ ),
234
+ )
235
+ report = auditor.audit_scope("s1", mode="darkharvest")
236
+ summary = report["summary"]
237
+
238
+ self.assertGreater(summary["total_claims_audited"], 0)
239
+ self.assertGreaterEqual(summary["verified_passed"], 1)
240
+ self.assertEqual(summary["verdict"], "WARNING_LICENSE_CONFLICT")
241
+ self.assertTrue(
242
+ any(f["kind"] == "LICENSE_DISAGREEMENT" for f in report["harvest_findings"])
243
+ )
244
+
245
+ def test_synthesis_section_no_longer_claims_one_hundred_percent(self):
246
+ from runner.auditor_engine import EpistemicAuditorEngine as AE
247
+
248
+ lines = AE._render_rejected_section(
249
+ [], {"verified_passed": 0, "verdict": "WARNING_LOW_GROUNDING"}
250
+ )
251
+ text = "\n".join(lines)
252
+ self.assertIn("0 claims were verified", text)
253
+ self.assertNotIn("100%", text)
254
+
255
+ def test_synthesis_section_reports_license_conflict(self):
256
+ from runner.auditor_engine import EpistemicAuditorEngine as AE
257
+
258
+ lines = AE._render_rejected_section(
259
+ [],
260
+ {
261
+ "verified_passed": 0,
262
+ "verdict": "WARNING_LICENSE_CONFLICT",
263
+ "blocking_findings": 2,
264
+ },
265
+ )
266
+ self.assertIn("blocking license/ground-truth finding", "\n".join(lines))
267
+
268
+
269
+ class TestOrphanScopes(unittest.TestCase):
270
+ def test_orphan_detected_and_not_deleted(self):
271
+ tmp = tempfile.mkdtemp()
272
+ base = Path(tmp) / ".research"
273
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
274
+ sm = runner.state_machine
275
+ sm.init_session("x")
276
+ sm.set_scopes([{"scope_id": "kept", "title": "t", "objective": "o"}])
277
+ stray = sm.get_scope_dir("abandoned")
278
+ stray.mkdir(parents=True, exist_ok=True)
279
+ (stray / "manifest.json").write_text("{}", encoding="utf-8")
280
+
281
+ orphans = sm.find_orphan_scopes()
282
+ ids = {o["scope_id"] for o in orphans}
283
+ self.assertIn("abandoned", ids)
284
+ self.assertNotIn("kept", ids)
285
+ # Marked, never deleted.
286
+ self.assertTrue(stray.exists())
287
+
288
+
289
+ if __name__ == "__main__":
290
+ unittest.main()