researchforge 0.1.0__py3-none-any.whl

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 (123) hide show
  1. researchforge/__init__.py +3 -0
  2. researchforge/__main__.py +4 -0
  3. researchforge/analytics/__init__.py +1 -0
  4. researchforge/analytics/cli.py +93 -0
  5. researchforge/analytics/service.py +167 -0
  6. researchforge/claude/__init__.py +1 -0
  7. researchforge/claude/cli.py +87 -0
  8. researchforge/claude/installer.py +184 -0
  9. researchforge/claude/skills/researchforge-baseline/SKILL.md +65 -0
  10. researchforge/claude/skills/researchforge-doctor/SKILL.md +33 -0
  11. researchforge/claude/skills/researchforge-hypotheses/SKILL.md +50 -0
  12. researchforge/claude/skills/researchforge-landscape/SKILL.md +54 -0
  13. researchforge/claude/skills/researchforge-paper/SKILL.md +42 -0
  14. researchforge/claude/skills/researchforge-papers/SKILL.md +47 -0
  15. researchforge/claude/skills/researchforge-plan/SKILL.md +76 -0
  16. researchforge/claude/skills/researchforge-results/SKILL.md +47 -0
  17. researchforge/claude/skills/researchforge-run/SKILL.md +58 -0
  18. researchforge/claude/skills/researchforge-ship/SKILL.md +75 -0
  19. researchforge/claude/skills/researchforge-start/SKILL.md +70 -0
  20. researchforge/claude/skills/researchforge-validate/SKILL.md +41 -0
  21. researchforge/cli.py +369 -0
  22. researchforge/config/__init__.py +0 -0
  23. researchforge/config/paths.py +85 -0
  24. researchforge/config/paths_cli.py +52 -0
  25. researchforge/config/registry.py +108 -0
  26. researchforge/config/settings.py +37 -0
  27. researchforge/contract/__init__.py +0 -0
  28. researchforge/contract/cli.py +198 -0
  29. researchforge/contract/render.py +131 -0
  30. researchforge/contract/service.py +125 -0
  31. researchforge/contract/validate.py +186 -0
  32. researchforge/contract/wizard.py +95 -0
  33. researchforge/domain/__init__.py +48 -0
  34. researchforge/domain/baseline.py +51 -0
  35. researchforge/domain/contract.py +166 -0
  36. researchforge/domain/deliverable.py +26 -0
  37. researchforge/domain/environment.py +31 -0
  38. researchforge/domain/evidence.py +27 -0
  39. researchforge/domain/experiment.py +255 -0
  40. researchforge/domain/hypothesis.py +65 -0
  41. researchforge/domain/landscape.py +49 -0
  42. researchforge/domain/paper.py +46 -0
  43. researchforge/domain/project.py +55 -0
  44. researchforge/domain/repo_scan.py +58 -0
  45. researchforge/execution/__init__.py +0 -0
  46. researchforge/execution/baseline.py +208 -0
  47. researchforge/execution/cli.py +122 -0
  48. researchforge/execution/constraints.py +96 -0
  49. researchforge/execution/docker_exec.py +114 -0
  50. researchforge/execution/environment.py +177 -0
  51. researchforge/execution/evaluation.py +348 -0
  52. researchforge/execution/experiments.py +825 -0
  53. researchforge/execution/metrics.py +121 -0
  54. researchforge/execution/path_guard.py +122 -0
  55. researchforge/execution/ranking.py +303 -0
  56. researchforge/execution/runner.py +103 -0
  57. researchforge/execution/validation.py +214 -0
  58. researchforge/execution/venv_exec.py +103 -0
  59. researchforge/execution/worktrees.py +197 -0
  60. researchforge/experiments/__init__.py +0 -0
  61. researchforge/experiments/cli.py +595 -0
  62. researchforge/experiments/context_export.py +241 -0
  63. researchforge/experiments/importers.py +427 -0
  64. researchforge/hypotheses/__init__.py +0 -0
  65. researchforge/hypotheses/cli.py +108 -0
  66. researchforge/project/__init__.py +0 -0
  67. researchforge/project/cli.py +104 -0
  68. researchforge/project/service.py +89 -0
  69. researchforge/py.typed +0 -0
  70. researchforge/reporting/__init__.py +0 -0
  71. researchforge/reporting/cli.py +148 -0
  72. researchforge/reporting/dashboard.py +605 -0
  73. researchforge/reporting/dashboard_cli.py +116 -0
  74. researchforge/reporting/engineering_report.py +330 -0
  75. researchforge/reporting/paper_cli.py +73 -0
  76. researchforge/reporting/research_package.py +475 -0
  77. researchforge/reporting/research_report.py +272 -0
  78. researchforge/reporting/svg_charts.py +519 -0
  79. researchforge/repository/__init__.py +0 -0
  80. researchforge/repository/cli.py +87 -0
  81. researchforge/repository/scanner.py +257 -0
  82. researchforge/research/__init__.py +0 -0
  83. researchforge/research/arxiv_client.py +200 -0
  84. researchforge/research/cli.py +261 -0
  85. researchforge/research/context_export.py +152 -0
  86. researchforge/research/dedup.py +39 -0
  87. researchforge/research/importers.py +236 -0
  88. researchforge/research/queries.py +57 -0
  89. researchforge/research/ranking.py +98 -0
  90. researchforge/research/search_service.py +113 -0
  91. researchforge/research/text.py +153 -0
  92. researchforge/server/__init__.py +1 -0
  93. researchforge/server/app.py +149 -0
  94. researchforge/server/cli.py +221 -0
  95. researchforge/server/data.py +134 -0
  96. researchforge/server/hub.py +172 -0
  97. researchforge/server/monitor.py +204 -0
  98. researchforge/server/pages.py +828 -0
  99. researchforge/shipping/__init__.py +0 -0
  100. researchforge/shipping/branch.py +412 -0
  101. researchforge/shipping/cli.py +264 -0
  102. researchforge/shipping/gh.py +178 -0
  103. researchforge/shipping/pr_body.py +201 -0
  104. researchforge/storage/__init__.py +0 -0
  105. researchforge/storage/baseline_repository.py +56 -0
  106. researchforge/storage/contract_repository.py +57 -0
  107. researchforge/storage/db.py +260 -0
  108. researchforge/storage/deliverable_repository.py +72 -0
  109. researchforge/storage/experiment_repository.py +274 -0
  110. researchforge/storage/hypothesis_repository.py +45 -0
  111. researchforge/storage/paper_repository.py +136 -0
  112. researchforge/storage/project_repository.py +72 -0
  113. researchforge/storage/scan_repository.py +31 -0
  114. researchforge/storage/synthesis_repository.py +47 -0
  115. researchforge/utils/__init__.py +0 -0
  116. researchforge/utils/artifact_io.py +41 -0
  117. researchforge/utils/output.py +47 -0
  118. researchforge/utils/system_checks.py +117 -0
  119. researchforge-0.1.0.dist-info/METADATA +333 -0
  120. researchforge-0.1.0.dist-info/RECORD +123 -0
  121. researchforge-0.1.0.dist-info/WHEEL +4 -0
  122. researchforge-0.1.0.dist-info/entry_points.txt +2 -0
  123. researchforge-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,3 @@
1
+ """ResearchForge: from papers to proof."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from researchforge.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1 @@
1
+ """Opt-in, local-only, privacy-preserving beta analytics (spec §20)."""
@@ -0,0 +1,93 @@
1
+ """`researchforge analytics` sub-app: opt-in local beta analytics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from researchforge.analytics.service import (
10
+ analytics_path,
11
+ compute_metrics,
12
+ is_enabled,
13
+ load_events,
14
+ set_enabled,
15
+ )
16
+ from researchforge.utils.output import JsonOption
17
+
18
+ analytics_app = typer.Typer(
19
+ name="analytics", no_args_is_help=True, help="Opt-in, local-only beta analytics."
20
+ )
21
+
22
+ COLLECTION_NOTICE = """\
23
+ Analytics are LOCAL-ONLY and nothing is ever transmitted anywhere.
24
+
25
+ When enabled, coarse events append to .researchforge/analytics.jsonl:
26
+ - an event name (e.g. baseline_completed), a timestamp, ok/failed,
27
+ and a short failure category.
28
+ Never collected: source code, paper text, secrets, datasets, metric
29
+ values, or logs. The file is yours — share it with a beta report only
30
+ if you choose to. Disable any time: researchforge analytics disable\
31
+ """
32
+
33
+
34
+ @analytics_app.command("enable")
35
+ def enable_command(json_output: JsonOption = False) -> None:
36
+ """Enable local analytics (prints exactly what is and is not collected)."""
37
+ set_enabled(True)
38
+ if json_output:
39
+ typer.echo(json.dumps({"analytics_enabled": True}))
40
+ else:
41
+ typer.echo(COLLECTION_NOTICE)
42
+ typer.echo("Enabled.")
43
+
44
+
45
+ @analytics_app.command("disable")
46
+ def disable_command(json_output: JsonOption = False) -> None:
47
+ """Disable local analytics (the existing log is kept; delete it if you wish)."""
48
+ set_enabled(False)
49
+ if json_output:
50
+ typer.echo(json.dumps({"analytics_enabled": False}))
51
+ else:
52
+ typer.echo(f"Disabled. Existing log (if any) remains at {analytics_path()}.")
53
+
54
+
55
+ @analytics_app.command("status")
56
+ def status_command(json_output: JsonOption = False) -> None:
57
+ """Show whether analytics are enabled and how many events are recorded."""
58
+ enabled = is_enabled()
59
+ count = len(load_events())
60
+ if json_output:
61
+ typer.echo(json.dumps({"analytics_enabled": enabled, "events_recorded": count}))
62
+ else:
63
+ typer.echo(f"Analytics: {'enabled' if enabled else 'disabled'} ({count} event(s) recorded)")
64
+
65
+
66
+ @analytics_app.command("show")
67
+ def show_command(json_output: JsonOption = False) -> None:
68
+ """Compute the beta metrics locally from the recorded events."""
69
+ metrics = compute_metrics()
70
+ if json_output:
71
+ typer.echo(metrics.model_dump_json(indent=2))
72
+ return
73
+ typer.echo(f"Events recorded: {metrics.events_recorded}")
74
+ for label, value in (
75
+ ("Time to first landscape", metrics.time_to_first_landscape_s),
76
+ ("Time to baseline", metrics.time_to_baseline_s),
77
+ ):
78
+ typer.echo(f"{label}: {f'{value:.0f}s' if value is not None else '—'}")
79
+ for label, rate in (
80
+ ("Baseline success rate", metrics.baseline_success_rate),
81
+ ("Experiment completion rate", metrics.experiment_completion_rate),
82
+ ("Valid-metrics rate", metrics.valid_metrics_rate),
83
+ ):
84
+ typer.echo(f"{label}: {f'{rate:.0%}' if rate is not None else '—'}")
85
+ typer.echo(
86
+ f"Validated findings: {metrics.validated_findings}; "
87
+ f"branches: {metrics.branches_created}; reports: {metrics.reports_generated}; "
88
+ f"packages: {metrics.packages_generated}"
89
+ )
90
+ if metrics.failure_categories:
91
+ typer.echo("Failure categories:")
92
+ for category, count in sorted(metrics.failure_categories.items()):
93
+ typer.echo(f" {category}: {count}")
@@ -0,0 +1,167 @@
1
+ """Local-only beta analytics (spec §20).
2
+
3
+ Disabled by default. When enabled, coarse events append to
4
+ `.researchforge/analytics.jsonl` — event name, timestamp, ok flag, and an
5
+ optional short category. Never code, paper text, secrets, metric values, or
6
+ logs; nothing is ever transmitted anywhere. The file exists so beta users
7
+ can *choose* to share it, and so `researchforge analytics show` can compute
8
+ the beta metrics locally. Recording must never break a command: failures to
9
+ write are swallowed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections import Counter
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from researchforge.config.paths import config_path, researchforge_dir
22
+ from researchforge.config.settings import load_settings
23
+
24
+ ANALYTICS_FILENAME = "analytics.jsonl"
25
+
26
+ # The spec §20 event vocabulary; record_event refuses anything else so the
27
+ # log can never accumulate ad-hoc payloads.
28
+ EVENTS = frozenset(
29
+ {
30
+ "initialized",
31
+ "doctor_passed",
32
+ "project_created",
33
+ "repo_scanned",
34
+ "papers_retrieved",
35
+ "landscape_imported",
36
+ "hypotheses_imported",
37
+ "contract_approved",
38
+ "baseline_completed",
39
+ "experiment_started",
40
+ "experiment_completed",
41
+ "validated_finding",
42
+ "branch_created",
43
+ "draft_pr_created",
44
+ "report_generated",
45
+ "package_generated",
46
+ }
47
+ )
48
+
49
+
50
+ def analytics_path(base: Path | None = None) -> Path:
51
+ return researchforge_dir(base) / ANALYTICS_FILENAME
52
+
53
+
54
+ def is_enabled(base: Path | None = None) -> bool:
55
+ try:
56
+ return load_settings(base).analytics_enabled
57
+ except (OSError, ValueError):
58
+ return False
59
+
60
+
61
+ def set_enabled(enabled: bool, base: Path | None = None) -> None:
62
+ """Flip the opt-in flag in `.researchforge/config.json`, preserving other keys."""
63
+ path = config_path(base)
64
+ raw: dict[str, object] = {}
65
+ if path.is_file():
66
+ raw = json.loads(path.read_text(encoding="utf-8"))
67
+ raw["analytics_enabled"] = enabled
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(json.dumps(raw, indent=2) + "\n", encoding="utf-8")
70
+
71
+
72
+ def record_event(
73
+ name: str, ok: bool = True, category: str | None = None, base: Path | None = None
74
+ ) -> None:
75
+ """Append one coarse event if analytics are enabled; never raise."""
76
+ assert name in EVENTS, f"unknown analytics event: {name}"
77
+ try:
78
+ if not is_enabled(base):
79
+ return
80
+ entry: dict[str, object] = {
81
+ "event": name,
82
+ "ts": datetime.now(UTC).isoformat(timespec="seconds"),
83
+ "ok": ok,
84
+ }
85
+ if category is not None:
86
+ entry["category"] = category
87
+ with analytics_path(base).open("a", encoding="utf-8") as handle:
88
+ handle.write(json.dumps(entry) + "\n")
89
+ except OSError:
90
+ return
91
+
92
+
93
+ def load_events(base: Path | None = None) -> list[dict[str, object]]:
94
+ path = analytics_path(base)
95
+ if not path.is_file():
96
+ return []
97
+ events = []
98
+ for line in path.read_text(encoding="utf-8").splitlines():
99
+ if line.strip():
100
+ events.append(json.loads(line))
101
+ return events
102
+
103
+
104
+ class BetaMetrics(BaseModel):
105
+ """Spec §20 key beta metrics, computed locally from the event log."""
106
+
107
+ events_recorded: int
108
+ time_to_first_landscape_s: float | None = None
109
+ time_to_baseline_s: float | None = None
110
+ baseline_success_rate: float | None = None
111
+ experiment_completion_rate: float | None = None
112
+ valid_metrics_rate: float | None = None
113
+ validated_findings: int = 0
114
+ branches_created: int = 0
115
+ reports_generated: int = 0
116
+ packages_generated: int = 0
117
+ failure_categories: dict[str, int] = {}
118
+
119
+
120
+ def _first_ts(events: list[dict[str, object]], name: str, ok_only: bool = False) -> datetime | None:
121
+ for event in events:
122
+ if event.get("event") == name and (not ok_only or event.get("ok")):
123
+ return datetime.fromisoformat(str(event["ts"]))
124
+ return None
125
+
126
+
127
+ def _rate(events: list[dict[str, object]], name: str) -> float | None:
128
+ matching = [e for e in events if e.get("event") == name]
129
+ if not matching:
130
+ return None
131
+ return sum(1 for e in matching if e.get("ok")) / len(matching)
132
+
133
+
134
+ def compute_metrics(base: Path | None = None) -> BetaMetrics:
135
+ events = load_events(base)
136
+ created = _first_ts(events, "project_created")
137
+ landscape = _first_ts(events, "landscape_imported", ok_only=True)
138
+ baseline = _first_ts(events, "baseline_completed", ok_only=True)
139
+
140
+ started = sum(1 for e in events if e.get("event") == "experiment_started")
141
+ completed = [e for e in events if e.get("event") == "experiment_completed"]
142
+
143
+ failure_categories = Counter(
144
+ str(e.get("category", "uncategorized"))
145
+ for e in events
146
+ if not e.get("ok") and e.get("event") in ("baseline_completed", "experiment_completed")
147
+ )
148
+
149
+ return BetaMetrics(
150
+ events_recorded=len(events),
151
+ time_to_first_landscape_s=(
152
+ (landscape - created).total_seconds() if created and landscape else None
153
+ ),
154
+ time_to_baseline_s=((baseline - created).total_seconds() if created and baseline else None),
155
+ baseline_success_rate=_rate(events, "baseline_completed"),
156
+ experiment_completion_rate=(
157
+ sum(1 for e in completed if e.get("ok")) / started if started else None
158
+ ),
159
+ valid_metrics_rate=_rate(events, "experiment_completed"),
160
+ validated_findings=sum(
161
+ 1 for e in events if e.get("event") == "validated_finding" and e.get("ok")
162
+ ),
163
+ branches_created=sum(1 for e in events if e.get("event") == "branch_created"),
164
+ reports_generated=sum(1 for e in events if e.get("event") == "report_generated"),
165
+ packages_generated=sum(1 for e in events if e.get("event") == "package_generated"),
166
+ failure_categories=dict(failure_categories),
167
+ )
@@ -0,0 +1 @@
1
+ """Claude Code integration: packaged project skills and their installer."""
@@ -0,0 +1,87 @@
1
+ """`researchforge claude` sub-app: manage the project-level Claude skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from researchforge.claude.installer import (
10
+ InstallReport,
11
+ SkillAction,
12
+ install_skills,
13
+ skills_status,
14
+ uninstall_skills,
15
+ )
16
+ from researchforge.utils.output import JsonOption
17
+
18
+ claude_app = typer.Typer(name="claude", no_args_is_help=True, help="Claude Code skills.")
19
+
20
+ ForceOption = typer.Option(
21
+ False,
22
+ "--force",
23
+ help="Overwrite/remove skills even if they were modified after installation.",
24
+ )
25
+
26
+ UserOption = typer.Option(
27
+ False,
28
+ "--user",
29
+ help="Target ~/.claude/skills/ (every project on this machine) instead of this repository.",
30
+ )
31
+
32
+ _ACTION_MARKERS = {
33
+ SkillAction.INSTALLED: "+",
34
+ SkillAction.UPDATED: "^",
35
+ SkillAction.UNCHANGED: "=",
36
+ SkillAction.SKIPPED_MODIFIED: "!",
37
+ SkillAction.REMOVED: "-",
38
+ SkillAction.LEFT_MODIFIED: "!",
39
+ SkillAction.MISSING: "?",
40
+ SkillAction.MODIFIED: "!",
41
+ }
42
+
43
+
44
+ def _echo_report(report: InstallReport, json_output: bool) -> None:
45
+ if json_output:
46
+ typer.echo(json.dumps(report.model_dump(), indent=2))
47
+ return
48
+ for result in report.results:
49
+ typer.echo(f"{_ACTION_MARKERS[result.action]} {result.skill}: {result.action.value}")
50
+
51
+
52
+ @claude_app.command("install")
53
+ def install_command(
54
+ force: bool = ForceOption, user: bool = UserOption, json_output: JsonOption = False
55
+ ) -> None:
56
+ """Install the ResearchForge skills into this repository's .claude/skills/."""
57
+ report = install_skills(force=force, user=user)
58
+ _echo_report(report, json_output)
59
+ if not json_output:
60
+ if report.conflicts:
61
+ typer.echo(
62
+ "Some skills were modified after installation and were left untouched; "
63
+ "re-run with --force to overwrite them."
64
+ )
65
+ typer.echo(f"Skills directory: {report.skills_dir}")
66
+
67
+
68
+ @claude_app.command("uninstall")
69
+ def uninstall_command(
70
+ force: bool = ForceOption, user: bool = UserOption, json_output: JsonOption = False
71
+ ) -> None:
72
+ """Remove the installed ResearchForge skills (user-modified files are kept)."""
73
+ report = uninstall_skills(force=force, user=user)
74
+ _echo_report(report, json_output)
75
+ if not json_output:
76
+ left = [r for r in report.results if r.action is SkillAction.LEFT_MODIFIED]
77
+ if left:
78
+ typer.echo(
79
+ "Modified skills were left in place; re-run with --force to remove them too."
80
+ )
81
+
82
+
83
+ @claude_app.command("status")
84
+ def status_command(user: bool = UserOption, json_output: JsonOption = False) -> None:
85
+ """Show whether each packaged skill is installed, modified, or missing."""
86
+ report = skills_status(user=user)
87
+ _echo_report(report, json_output)
@@ -0,0 +1,184 @@
1
+ """Install packaged Claude skills into a repository's `.claude/skills/`.
2
+
3
+ The skills are UX, not a security boundary — every gate they describe is
4
+ enforced by the Python engine regardless of what a skill (or Claude) says.
5
+ Installation is manifest-based so ResearchForge never overwrites or removes
6
+ Claude configuration it does not own: a sha256 per installed file is recorded
7
+ in `.researchforge/claude-skills-manifest.json`, and any file that no longer
8
+ matches its recorded hash is treated as user-owned.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from enum import StrEnum
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from researchforge.config.paths import researchforge_dir
21
+
22
+ SKILLS_PACKAGE = "researchforge.claude.skills"
23
+ MANIFEST_FILENAME = "claude-skills-manifest.json"
24
+ CLAUDE_SKILLS_DIR = Path(".claude") / "skills"
25
+
26
+
27
+ class SkillAction(StrEnum):
28
+ INSTALLED = "installed"
29
+ UPDATED = "updated"
30
+ UNCHANGED = "unchanged"
31
+ SKIPPED_MODIFIED = "skipped_modified"
32
+ REMOVED = "removed"
33
+ LEFT_MODIFIED = "left_modified"
34
+ MISSING = "missing"
35
+ MODIFIED = "modified"
36
+
37
+
38
+ class SkillReport(BaseModel):
39
+ skill: str
40
+ action: SkillAction
41
+ path: str
42
+
43
+
44
+ class InstallReport(BaseModel):
45
+ skills_dir: str
46
+ results: list[SkillReport]
47
+
48
+ @property
49
+ def conflicts(self) -> list[SkillReport]:
50
+ return [r for r in self.results if r.action is SkillAction.SKIPPED_MODIFIED]
51
+
52
+
53
+ class SkillsManifest(BaseModel):
54
+ """sha256 of each installed SKILL.md, keyed by skill name."""
55
+
56
+ version: int = 1
57
+ hashes: dict[str, str] = {}
58
+
59
+
60
+ def _sha256(data: bytes) -> str:
61
+ return hashlib.sha256(data).hexdigest()
62
+
63
+
64
+ def manifest_path(base: Path | None = None, user: bool = False) -> Path:
65
+ if user:
66
+ # User-level installs have no project `.researchforge/`; keep the
67
+ # manifest next to the user's Claude config instead.
68
+ return Path.home() / ".claude" / f"researchforge-{MANIFEST_FILENAME}"
69
+ return researchforge_dir(base) / MANIFEST_FILENAME
70
+
71
+
72
+ def _skills_root(base: Path | None, user: bool) -> Path:
73
+ if user:
74
+ return Path.home() / ".claude" / "skills"
75
+ return (base if base is not None else Path.cwd()) / CLAUDE_SKILLS_DIR
76
+
77
+
78
+ def load_manifest(base: Path | None = None, user: bool = False) -> SkillsManifest:
79
+ path = manifest_path(base, user)
80
+ if not path.is_file():
81
+ return SkillsManifest()
82
+ return SkillsManifest.model_validate_json(path.read_text(encoding="utf-8"))
83
+
84
+
85
+ def save_manifest(manifest: SkillsManifest, base: Path | None = None, user: bool = False) -> None:
86
+ path = manifest_path(base, user)
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ path.write_text(manifest.model_dump_json(indent=2) + "\n", encoding="utf-8")
89
+
90
+
91
+ def list_packaged_skills() -> dict[str, str]:
92
+ """Skill name -> SKILL.md content, from the wheel's packaged assets."""
93
+ skills: dict[str, str] = {}
94
+ root = resources.files(SKILLS_PACKAGE)
95
+ for entry in root.iterdir():
96
+ if entry.is_dir():
97
+ skill_file = entry / "SKILL.md"
98
+ if skill_file.is_file():
99
+ skills[entry.name] = skill_file.read_text(encoding="utf-8")
100
+ return dict(sorted(skills.items()))
101
+
102
+
103
+ def install_skills(
104
+ base: Path | None = None, force: bool = False, user: bool = False
105
+ ) -> InstallReport:
106
+ """Copy packaged skills into `.claude/skills/`, never clobbering user edits.
107
+
108
+ `user=True` installs into `~/.claude/skills/` (available in every project
109
+ and Claude account on this machine) instead of the repository.
110
+
111
+ Per skill: missing -> write; unchanged since our last install (manifest
112
+ hash matches) -> update in place; anything else -> skip unless `force`.
113
+ """
114
+ skills_root = _skills_root(base, user)
115
+ manifest = load_manifest(base, user)
116
+ results: list[SkillReport] = []
117
+
118
+ for name, content in list_packaged_skills().items():
119
+ target = skills_root / name / "SKILL.md"
120
+ packaged_hash = _sha256(content.encode("utf-8"))
121
+ if target.is_file():
122
+ current_hash = _sha256(target.read_bytes())
123
+ if current_hash == packaged_hash:
124
+ action = SkillAction.UNCHANGED
125
+ elif current_hash == manifest.hashes.get(name) or force:
126
+ action = SkillAction.UPDATED
127
+ else:
128
+ action = SkillAction.SKIPPED_MODIFIED
129
+ else:
130
+ action = SkillAction.INSTALLED
131
+
132
+ if action in (SkillAction.INSTALLED, SkillAction.UPDATED):
133
+ target.parent.mkdir(parents=True, exist_ok=True)
134
+ target.write_text(content, encoding="utf-8")
135
+ if action is not SkillAction.SKIPPED_MODIFIED:
136
+ manifest.hashes[name] = packaged_hash
137
+ results.append(SkillReport(skill=name, action=action, path=str(target)))
138
+
139
+ save_manifest(manifest, base, user)
140
+ return InstallReport(skills_dir=str(skills_root), results=results)
141
+
142
+
143
+ def uninstall_skills(
144
+ base: Path | None = None, force: bool = False, user: bool = False
145
+ ) -> InstallReport:
146
+ """Remove installed skills; user-modified files are left unless `force`."""
147
+ skills_root = _skills_root(base, user)
148
+ manifest = load_manifest(base, user)
149
+ results: list[SkillReport] = []
150
+
151
+ for name in list_packaged_skills():
152
+ target = skills_root / name / "SKILL.md"
153
+ recorded = manifest.hashes.get(name)
154
+ if not target.is_file():
155
+ action = SkillAction.MISSING
156
+ elif _sha256(target.read_bytes()) == recorded or force:
157
+ target.unlink()
158
+ if not any(target.parent.iterdir()):
159
+ target.parent.rmdir()
160
+ action = SkillAction.REMOVED
161
+ else:
162
+ action = SkillAction.LEFT_MODIFIED
163
+ if action is not SkillAction.LEFT_MODIFIED:
164
+ manifest.hashes.pop(name, None)
165
+ results.append(SkillReport(skill=name, action=action, path=str(target)))
166
+
167
+ save_manifest(manifest, base, user)
168
+ return InstallReport(skills_dir=str(skills_root), results=results)
169
+
170
+
171
+ def skills_status(base: Path | None = None, user: bool = False) -> InstallReport:
172
+ """Per-skill state: unchanged (as packaged), modified, or missing."""
173
+ skills_root = _skills_root(base, user)
174
+ results: list[SkillReport] = []
175
+ for name, content in list_packaged_skills().items():
176
+ target = skills_root / name / "SKILL.md"
177
+ if not target.is_file():
178
+ action = SkillAction.MISSING
179
+ elif _sha256(target.read_bytes()) == _sha256(content.encode("utf-8")):
180
+ action = SkillAction.UNCHANGED
181
+ else:
182
+ action = SkillAction.MODIFIED
183
+ results.append(SkillReport(skill=name, action=action, path=str(target)))
184
+ return InstallReport(skills_dir=str(skills_root), results=results)
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: researchforge-baseline
3
+ description: Set up the experiment contract and run the frozen baseline benchmark. Use when an improve-repository project needs its evaluation defined, the contract approved, or the baseline measured.
4
+ ---
5
+
6
+ # Contract and baseline
7
+
8
+ The contract (`researchforge.yaml` at the repository root) freezes the
9
+ evaluation: primary metric + direction, hard constraints, editable vs
10
+ protected paths, commands, resource limits, network mode, shipping flags.
11
+ Approval hashes it; later edits are detected as drift and refused until
12
+ re-approved. **The contract is the boundary — experiments can only change
13
+ `editable_paths` and are judged only by the contracted benchmark.**
14
+
15
+ ## 1. Draft the contract
16
+
17
+ ```bash
18
+ researchforge contract generate --json
19
+ ```
20
+
21
+ Then edit `researchforge.yaml` **with the user** — these are their choices,
22
+ not yours: the benchmark command (`execution.full_command`) must print or
23
+ write a machine-readable result (`execution.result_file`), the primary
24
+ metric must match a key in that result, and `permissions.editable_paths` /
25
+ `protected_paths` decide what experiments may touch. Benchmarks and
26
+ evaluation code belong in `protected_paths`.
27
+
28
+ ## 2. Validate and approve
29
+
30
+ ```bash
31
+ researchforge contract validate --json
32
+ ```
33
+
34
+ Fix any of the semantic errors it reports (they are field-level and
35
+ actionable). Then show the user a summary of what they are approving —
36
+ metric, constraints, commands, limits, paths — and ask them to run:
37
+
38
+ ```bash
39
+ researchforge contract approve
40
+ ```
41
+
42
+ The approval is a typed confirmation. Only run it with `--yes` yourself if
43
+ the user has explicitly said in this conversation to approve this contract.
44
+
45
+ ## 3. Run the baseline
46
+
47
+ ```bash
48
+ researchforge baseline run --json
49
+ researchforge baseline show --json
50
+ ```
51
+
52
+ The baseline runs in an isolated detached worktree; the user's checkout is
53
+ never touched. Report the recorded baseline metrics from the JSON. All
54
+ future experiments are compared against this frozen measurement.
55
+
56
+ Next: the researchforge-plan skill designs experiments against a hypothesis.
57
+
58
+ ## Rules
59
+
60
+ - The Python engine is the boundary: never work around a validation error, a
61
+ protected path, or an approval gate — fix the artifact or ask the user.
62
+ - Approvals belong to the user: never pass `--yes` or type a confirmation
63
+ unless the user explicitly approved that step in this conversation.
64
+ - Ground every summary in stored data: quote only numbers returned by
65
+ `--json` output or files under `.researchforge/` — never invent metrics.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: researchforge-doctor
3
+ description: Check that ResearchForge's dependencies (git, Python, optionally Docker) are available and explain any failures. Use when setup fails, before starting a project, or when the user asks whether their machine is ready.
4
+ ---
5
+
6
+ # Diagnose the ResearchForge environment
7
+
8
+ ```bash
9
+ researchforge doctor --json
10
+ ```
11
+
12
+ Each entry has `name`, `ok`, `required`, `detail`, and an optional `hint`.
13
+
14
+ - All required checks pass → say the environment is ready and continue.
15
+ - A required check fails → explain the failure in plain language, show the
16
+ `hint`, and stop: later commands will refuse to run. Do not try to work
17
+ around a failed required check.
18
+ - An optional check fails (e.g. Docker) → note the limitation: experiments
19
+ fall back to `.venv` mode, which is dependency isolation, not a security
20
+ sandbox — only trusted repositories should be run there.
21
+
22
+ If skills seem out of date or missing, `researchforge claude status --json`
23
+ reports the installed/modified/missing state of each skill and
24
+ `researchforge claude install` refreshes unmodified ones.
25
+
26
+ ## Rules
27
+
28
+ - The Python engine is the boundary: never work around a validation error, a
29
+ protected path, or an approval gate — fix the artifact or ask the user.
30
+ - Approvals belong to the user: never pass `--yes` or type a confirmation
31
+ unless the user explicitly approved that step in this conversation.
32
+ - Ground every summary in stored data: quote only numbers returned by
33
+ `--json` output or files under `.researchforge/` — never invent metrics.