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.
- researchforge/__init__.py +3 -0
- researchforge/__main__.py +4 -0
- researchforge/analytics/__init__.py +1 -0
- researchforge/analytics/cli.py +93 -0
- researchforge/analytics/service.py +167 -0
- researchforge/claude/__init__.py +1 -0
- researchforge/claude/cli.py +87 -0
- researchforge/claude/installer.py +184 -0
- researchforge/claude/skills/researchforge-baseline/SKILL.md +65 -0
- researchforge/claude/skills/researchforge-doctor/SKILL.md +33 -0
- researchforge/claude/skills/researchforge-hypotheses/SKILL.md +50 -0
- researchforge/claude/skills/researchforge-landscape/SKILL.md +54 -0
- researchforge/claude/skills/researchforge-paper/SKILL.md +42 -0
- researchforge/claude/skills/researchforge-papers/SKILL.md +47 -0
- researchforge/claude/skills/researchforge-plan/SKILL.md +76 -0
- researchforge/claude/skills/researchforge-results/SKILL.md +47 -0
- researchforge/claude/skills/researchforge-run/SKILL.md +58 -0
- researchforge/claude/skills/researchforge-ship/SKILL.md +75 -0
- researchforge/claude/skills/researchforge-start/SKILL.md +70 -0
- researchforge/claude/skills/researchforge-validate/SKILL.md +41 -0
- researchforge/cli.py +369 -0
- researchforge/config/__init__.py +0 -0
- researchforge/config/paths.py +85 -0
- researchforge/config/paths_cli.py +52 -0
- researchforge/config/registry.py +108 -0
- researchforge/config/settings.py +37 -0
- researchforge/contract/__init__.py +0 -0
- researchforge/contract/cli.py +198 -0
- researchforge/contract/render.py +131 -0
- researchforge/contract/service.py +125 -0
- researchforge/contract/validate.py +186 -0
- researchforge/contract/wizard.py +95 -0
- researchforge/domain/__init__.py +48 -0
- researchforge/domain/baseline.py +51 -0
- researchforge/domain/contract.py +166 -0
- researchforge/domain/deliverable.py +26 -0
- researchforge/domain/environment.py +31 -0
- researchforge/domain/evidence.py +27 -0
- researchforge/domain/experiment.py +255 -0
- researchforge/domain/hypothesis.py +65 -0
- researchforge/domain/landscape.py +49 -0
- researchforge/domain/paper.py +46 -0
- researchforge/domain/project.py +55 -0
- researchforge/domain/repo_scan.py +58 -0
- researchforge/execution/__init__.py +0 -0
- researchforge/execution/baseline.py +208 -0
- researchforge/execution/cli.py +122 -0
- researchforge/execution/constraints.py +96 -0
- researchforge/execution/docker_exec.py +114 -0
- researchforge/execution/environment.py +177 -0
- researchforge/execution/evaluation.py +348 -0
- researchforge/execution/experiments.py +825 -0
- researchforge/execution/metrics.py +121 -0
- researchforge/execution/path_guard.py +122 -0
- researchforge/execution/ranking.py +303 -0
- researchforge/execution/runner.py +103 -0
- researchforge/execution/validation.py +214 -0
- researchforge/execution/venv_exec.py +103 -0
- researchforge/execution/worktrees.py +197 -0
- researchforge/experiments/__init__.py +0 -0
- researchforge/experiments/cli.py +595 -0
- researchforge/experiments/context_export.py +241 -0
- researchforge/experiments/importers.py +427 -0
- researchforge/hypotheses/__init__.py +0 -0
- researchforge/hypotheses/cli.py +108 -0
- researchforge/project/__init__.py +0 -0
- researchforge/project/cli.py +104 -0
- researchforge/project/service.py +89 -0
- researchforge/py.typed +0 -0
- researchforge/reporting/__init__.py +0 -0
- researchforge/reporting/cli.py +148 -0
- researchforge/reporting/dashboard.py +605 -0
- researchforge/reporting/dashboard_cli.py +116 -0
- researchforge/reporting/engineering_report.py +330 -0
- researchforge/reporting/paper_cli.py +73 -0
- researchforge/reporting/research_package.py +475 -0
- researchforge/reporting/research_report.py +272 -0
- researchforge/reporting/svg_charts.py +519 -0
- researchforge/repository/__init__.py +0 -0
- researchforge/repository/cli.py +87 -0
- researchforge/repository/scanner.py +257 -0
- researchforge/research/__init__.py +0 -0
- researchforge/research/arxiv_client.py +200 -0
- researchforge/research/cli.py +261 -0
- researchforge/research/context_export.py +152 -0
- researchforge/research/dedup.py +39 -0
- researchforge/research/importers.py +236 -0
- researchforge/research/queries.py +57 -0
- researchforge/research/ranking.py +98 -0
- researchforge/research/search_service.py +113 -0
- researchforge/research/text.py +153 -0
- researchforge/server/__init__.py +1 -0
- researchforge/server/app.py +149 -0
- researchforge/server/cli.py +221 -0
- researchforge/server/data.py +134 -0
- researchforge/server/hub.py +172 -0
- researchforge/server/monitor.py +204 -0
- researchforge/server/pages.py +828 -0
- researchforge/shipping/__init__.py +0 -0
- researchforge/shipping/branch.py +412 -0
- researchforge/shipping/cli.py +264 -0
- researchforge/shipping/gh.py +178 -0
- researchforge/shipping/pr_body.py +201 -0
- researchforge/storage/__init__.py +0 -0
- researchforge/storage/baseline_repository.py +56 -0
- researchforge/storage/contract_repository.py +57 -0
- researchforge/storage/db.py +260 -0
- researchforge/storage/deliverable_repository.py +72 -0
- researchforge/storage/experiment_repository.py +274 -0
- researchforge/storage/hypothesis_repository.py +45 -0
- researchforge/storage/paper_repository.py +136 -0
- researchforge/storage/project_repository.py +72 -0
- researchforge/storage/scan_repository.py +31 -0
- researchforge/storage/synthesis_repository.py +47 -0
- researchforge/utils/__init__.py +0 -0
- researchforge/utils/artifact_io.py +41 -0
- researchforge/utils/output.py +47 -0
- researchforge/utils/system_checks.py +117 -0
- researchforge-0.1.0.dist-info/METADATA +333 -0
- researchforge-0.1.0.dist-info/RECORD +123 -0
- researchforge-0.1.0.dist-info/WHEEL +4 -0
- researchforge-0.1.0.dist-info/entry_points.txt +2 -0
- researchforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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.
|