robotframework-harness 1.0.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.
- rf_harness/__init__.py +3 -0
- rf_harness/_assets/.claude-plugin/plugin.json +10 -0
- rf_harness/_assets/bin/rfh-launch +60 -0
- rf_harness/_assets/bin/rfh-launch.cmd +19 -0
- rf_harness/_assets/bin/rfh_launch.py +719 -0
- rf_harness/_assets/hooks/hooks.json +84 -0
- rf_harness/_assets/skills/rf-harness/SKILL.md +78 -0
- rf_harness/_assets/skills/rf-harness/references/browser.md +157 -0
- rf_harness/_assets/skills/rf-harness/references/debug.md +39 -0
- rf_harness/_assets/skills/rf-harness/references/explore.md +52 -0
- rf_harness/_assets/skills/rf-harness/references/requests.md +37 -0
- rf_harness/_assets/skills/rf-harness/references/results.md +34 -0
- rf_harness/_assets/skills/rf-harness/references/selenium.md +40 -0
- rf_harness/_assets/skills/rf-harness/references/waits.md +28 -0
- rf_harness/_assets/variants/codex/hooks.json +72 -0
- rf_harness/_assets/variants/copilot/hooks.json +53 -0
- rf_harness/_assets/variants/opencode/plugins/rf-harness.js +130 -0
- rf_harness/config.py +255 -0
- rf_harness/installer/__init__.py +1 -0
- rf_harness/installer/_assets.py +73 -0
- rf_harness/installer/adapters/__init__.py +37 -0
- rf_harness/installer/adapters/_base.py +189 -0
- rf_harness/installer/adapters/claude_code.py +88 -0
- rf_harness/installer/adapters/codex.py +108 -0
- rf_harness/installer/adapters/copilot_cli.py +89 -0
- rf_harness/installer/adapters/opencode.py +62 -0
- rf_harness/installer/adapters/vscode.py +56 -0
- rf_harness/installer/cli.py +490 -0
- rf_harness/installer/codex_trust.py +190 -0
- rf_harness/installer/coexist.py +211 -0
- rf_harness/installer/demo_site/index.html +28 -0
- rf_harness/installer/doctor.py +343 -0
- rf_harness/installer/envfind.py +354 -0
- rf_harness/installer/executor.py +288 -0
- rf_harness/installer/homes.py +102 -0
- rf_harness/installer/manifest.py +204 -0
- rf_harness/installer/prompt.py +58 -0
- rf_harness/installer/transforms.py +483 -0
- rf_harness/runtime/__init__.py +1 -0
- rf_harness/runtime/analyzer.py +132 -0
- rf_harness/runtime/data/failure_rules.json +77 -0
- rf_harness/runtime/data/kwnames_cache.json +1 -0
- rf_harness/runtime/data/repl_format_rules.json +455 -0
- rf_harness/runtime/data/robocop-agent.toml +58 -0
- rf_harness/runtime/desktop/__init__.py +87 -0
- rf_harness/runtime/desktop/_env.py +134 -0
- rf_harness/runtime/desktop/decision.py +616 -0
- rf_harness/runtime/desktop/focus.py +1094 -0
- rf_harness/runtime/desktop/guidance.py +91 -0
- rf_harness/runtime/desktop/launch_env.py +562 -0
- rf_harness/runtime/desktop/safety.py +509 -0
- rf_harness/runtime/desktop/signals.py +321 -0
- rf_harness/runtime/docs.py +190 -0
- rf_harness/runtime/docsgen.py +174 -0
- rf_harness/runtime/engine.py +1000 -0
- rf_harness/runtime/execd.py +1131 -0
- rf_harness/runtime/guards.py +294 -0
- rf_harness/runtime/hints.py +1523 -0
- rf_harness/runtime/hook.py +1897 -0
- rf_harness/runtime/hookcore.py +832 -0
- rf_harness/runtime/kwdoc.py +285 -0
- rf_harness/runtime/kwdocs.py +286 -0
- rf_harness/runtime/leanfmt.py +451 -0
- rf_harness/runtime/lintd.py +423 -0
- rf_harness/runtime/livetmo.py +385 -0
- rf_harness/runtime/pagestate.py +489 -0
- rf_harness/runtime/portable.py +191 -0
- rf_harness/runtime/procs.py +106 -0
- rf_harness/runtime/profiles.py +144 -0
- rf_harness/runtime/proto.py +120 -0
- rf_harness/runtime/pwlog.py +89 -0
- rf_harness/runtime/replfmt.py +556 -0
- rf_harness/runtime/rfh_repl_listener.py +244 -0
- rf_harness/runtime/richfmt.py +301 -0
- rf_harness/runtime/robotcode_adapter.py +157 -0
- rf_harness/runtime/rthooks.py +623 -0
- rf_harness/runtime/smoke.py +113 -0
- robotframework_harness-1.0.0.dist-info/METADATA +85 -0
- robotframework_harness-1.0.0.dist-info/RECORD +82 -0
- robotframework_harness-1.0.0.dist-info/WHEEL +4 -0
- robotframework_harness-1.0.0.dist-info/entry_points.txt +3 -0
- robotframework_harness-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" sessionStart --agent auto",
|
|
8
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" sessionStart --agent auto",
|
|
9
|
+
"timeoutSec": 30
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"userPromptSubmitted": [
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" userPromptSubmitted --agent auto",
|
|
16
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" userPromptSubmitted --agent auto",
|
|
17
|
+
"timeoutSec": 10
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"preToolUse": [
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" preToolUse --agent auto",
|
|
24
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" preToolUse --agent auto",
|
|
25
|
+
"timeoutSec": 20
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"postToolUse": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" postToolUse --agent auto",
|
|
32
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" postToolUse --agent auto",
|
|
33
|
+
"timeoutSec": 180
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"postToolUseFailure": [
|
|
37
|
+
{
|
|
38
|
+
"type": "command",
|
|
39
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" postToolUseFailure --agent auto",
|
|
40
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" postToolUseFailure --agent auto",
|
|
41
|
+
"timeoutSec": 180
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"agentStop": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"bash": "python3 -S \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch\" agentStop --agent auto",
|
|
48
|
+
"powershell": "& \"${CLAUDE_PLUGIN_ROOT}/bin/rfh-launch.cmd\" agentStop --agent auto",
|
|
49
|
+
"timeoutSec": 90
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// rf-harness hooks for OpenCode (ported from the measured v3.2 prototype plugin, hookpkg32 install.py OPENCODE_PLUGIN).
|
|
2
|
+
// Every event goes to the same launcher as the other agents: python3 -S <..>/rf-harness/bin/rfh-launch <Event>
|
|
3
|
+
// --agent opencode, which finds the project's Python and runs rf_harness.runtime.hook there.
|
|
4
|
+
// plugin init -> SessionStart (its setup hint / context is added to the first prompt)
|
|
5
|
+
// chat.message -> UserPromptSubmit (first prompt only: project context appended to the prompt text)
|
|
6
|
+
// tool.execute.before -> PreToolUse (deny = throw; rewrite = mutate output.args; the Python side turns a repeated
|
|
7
|
+
// denial into allow + warning after 2)
|
|
8
|
+
// tool.execute.after -> PostToolUse (context PREPENDED to the tool output so a weak model reads it first)
|
|
9
|
+
// assistant message finished -> Stop gate (OpenCode has no blocking stop: a noReply prompt keeps `opencode run` going)
|
|
10
|
+
// dispose -> SessionEnd
|
|
11
|
+
// OpenCode has no hook timeout: every call is bounded here. The launcher path is resolved relative to this file
|
|
12
|
+
// (<root>/plugins/rf-harness.js -> <root>/rf-harness/bin/rfh-launch); RF_HARNESS_LAUNCHER overrides it and
|
|
13
|
+
// RF_HARNESS_LAUNCHER_PYTHON the interpreter that runs it (default: python3, on Windows `py -3`).
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const LAUNCH = process.env.RF_HARNESS_LAUNCHER || fileURLToPath(new URL("../rf-harness/bin/rfh-launch", import.meta.url));
|
|
18
|
+
const WIN = process.platform === "win32";
|
|
19
|
+
const PY = process.env.RF_HARNESS_LAUNCHER_PYTHON || (WIN ? "py" : "python3");
|
|
20
|
+
const PY_ARGS = process.env.RF_HARNESS_LAUNCHER_PYTHON || !WIN ? ["-S"] : ["-3", "-S"];
|
|
21
|
+
const LAUNCH_ARGS = [/*rf-harness:launch-args*/];
|
|
22
|
+
const EDIT = new Set(["write", "edit", "multiedit", "patch", "apply_patch"]);
|
|
23
|
+
const READ = new Set(["read", "view"]);
|
|
24
|
+
const RF = /\.(robot|resource)\b/;
|
|
25
|
+
const SHELL_PRE = /(^|[\s;&|(\/'"])(robot|robotcode|pip3?|uv|playwright|rfbrowser|npm|npx|PYTHONPATH=|cat|less)\b|PYTHONPATH|\.rf\/docs\/kw\//;
|
|
26
|
+
const ROBOT = /(^|[\s;&|(\/])(robot|robotcode)\s/;
|
|
27
|
+
|
|
28
|
+
// The model id goes into every payload (output profile lean|rich): `opencode run -m <provider/model>` from argv,
|
|
29
|
+
// OPENCODE_MODEL, then the model of the first chat message.
|
|
30
|
+
function argvModel() {
|
|
31
|
+
const a = (typeof process !== "undefined" && process.argv) || [];
|
|
32
|
+
for (let i = 0; i < a.length; i++) {
|
|
33
|
+
if (a[i] === "-m" || a[i] === "--model") return String(a[i + 1] || "");
|
|
34
|
+
if (String(a[i]).startsWith("--model=")) return String(a[i]).slice(8);
|
|
35
|
+
}
|
|
36
|
+
return (typeof process !== "undefined" && process.env && process.env.OPENCODE_MODEL) || "";
|
|
37
|
+
}
|
|
38
|
+
let MODEL = argvModel();
|
|
39
|
+
function modelOf(m) {
|
|
40
|
+
if (!m) return "";
|
|
41
|
+
if (typeof m === "string") return m;
|
|
42
|
+
const id = m.modelID || m.modelId || m.id || "";
|
|
43
|
+
const prov = m.providerID || m.providerId || "";
|
|
44
|
+
return id ? (prov && !String(id).startsWith(prov + "/") ? prov + "/" + id : String(id)) : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// async spawn (round-5 finding: spawnSync blocked OpenCode's event loop for the whole hook, ~5 s per edit)
|
|
48
|
+
function call(event, payload, ms) {
|
|
49
|
+
if (MODEL && payload && !payload.model) payload.model = MODEL;
|
|
50
|
+
if (payload) payload.ts = Date.now();
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let out = "", done = false;
|
|
53
|
+
const finish = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
|
|
54
|
+
let p;
|
|
55
|
+
try {
|
|
56
|
+
p = spawn(PY, [...PY_ARGS, LAUNCH, event, "--agent", "opencode", ...LAUNCH_ARGS],
|
|
57
|
+
{ cwd: payload.cwd, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
58
|
+
} catch (e) { return finish({}); }
|
|
59
|
+
const timer = setTimeout(() => { try { p.kill("SIGKILL"); } catch (e) {} finish({}); }, ms);
|
|
60
|
+
p.stdout.on("data", (d) => { out += d; });
|
|
61
|
+
p.on("error", () => finish({}));
|
|
62
|
+
p.on("close", () => { try { finish(out.trim() ? JSON.parse(out.trim().split("\n").pop()) : {}); } catch (e) { finish({}); } });
|
|
63
|
+
try { p.stdin.end(JSON.stringify(payload)); } catch (e) {}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const RfHarness = async ({ directory, client }) => {
|
|
68
|
+
const start = await call("SessionStart", { hook_event_name: "SessionStart", cwd: directory }, 30000);
|
|
69
|
+
let sessionNote = (start && start.text) || "";
|
|
70
|
+
const notes = new Map();
|
|
71
|
+
let lastStopAt = 0;
|
|
72
|
+
let prompted = false;
|
|
73
|
+
return {
|
|
74
|
+
"chat.message": async (input, output) => {
|
|
75
|
+
const m = modelOf((input && input.model) || (output && output.message && output.message.model));
|
|
76
|
+
if (m) MODEL = m;
|
|
77
|
+
if (prompted) return;
|
|
78
|
+
prompted = true;
|
|
79
|
+
const res = await call("UserPromptSubmit", { hook_event_name: "UserPromptSubmit", cwd: directory,
|
|
80
|
+
session_id: input && input.sessionID }, 10000);
|
|
81
|
+
const tp = output && output.parts && output.parts.find((p) => p.type === "text");
|
|
82
|
+
const text = [sessionNote, res.text].filter(Boolean).join("\n\n");
|
|
83
|
+
sessionNote = "";
|
|
84
|
+
if (text && tp) tp.text += "\n\n" + text;
|
|
85
|
+
},
|
|
86
|
+
"tool.execute.before": async (input, output) => {
|
|
87
|
+
const tool = String(input.tool || "").toLowerCase();
|
|
88
|
+
const args = output.args || {};
|
|
89
|
+
const s = JSON.stringify(args);
|
|
90
|
+
const want = (EDIT.has(tool) && RF.test(s)) || (tool === "bash" && SHELL_PRE.test(" " + (args.command || "")))
|
|
91
|
+
|| (READ.has(tool) && (/\.(xml|html?|json|log|txt|yaml|jpe?g|png)\b/.test(s) || /\.rf\/docs\/kw\//.test(s)));
|
|
92
|
+
if (!want) return;
|
|
93
|
+
const res = await call("PreToolUse", { hook_event_name: "PreToolUse", tool_name: input.tool, tool_input: args,
|
|
94
|
+
cwd: directory, session_id: input.sessionID, call_id: input.callID }, 20000);
|
|
95
|
+
if (res.action === "deny") throw new Error(res.text || "refused by the Robot Framework hook");
|
|
96
|
+
if (res.action === "rewrite" && res.args) Object.assign(output.args, res.args);
|
|
97
|
+
if (res.text) notes.set(input.callID, res.text);
|
|
98
|
+
},
|
|
99
|
+
"tool.execute.after": async (input, output) => {
|
|
100
|
+
const tool = String(input.tool || "").toLowerCase();
|
|
101
|
+
const args = input.args || {};
|
|
102
|
+
const s = JSON.stringify(args);
|
|
103
|
+
let text = notes.get(input.callID) || "";
|
|
104
|
+
notes.delete(input.callID);
|
|
105
|
+
if ((EDIT.has(tool) && RF.test(s)) || (tool === "bash" && ROBOT.test(" " + (args.command || "")))) {
|
|
106
|
+
const res = await call("PostToolUse", { hook_event_name: "PostToolUse", tool_name: input.tool, tool_input: args,
|
|
107
|
+
tool_response: output && output.output, cwd: directory, session_id: input.sessionID, call_id: input.callID },
|
|
108
|
+
180000);
|
|
109
|
+
text = [res.text, text].filter(Boolean).join("\n\n");
|
|
110
|
+
}
|
|
111
|
+
if (text && output && typeof output.output === "string")
|
|
112
|
+
output.output = "[Robot Framework hook]\n" + text + "\n[/hook]\n\n" + output.output;
|
|
113
|
+
},
|
|
114
|
+
event: async ({ event }) => {
|
|
115
|
+
const info = event.properties && event.properties.info;
|
|
116
|
+
if (event.type === "message.updated" && info && info.role === "assistant" && info.finish
|
|
117
|
+
&& info.finish !== "tool-calls" && Date.now() - lastStopAt > 5000) {
|
|
118
|
+
lastStopAt = Date.now();
|
|
119
|
+
const res = await call("Stop", { hook_event_name: "Stop", cwd: directory, session_id: info.sessionID }, 90000);
|
|
120
|
+
if (res.action === "block" && res.text) {
|
|
121
|
+
try {
|
|
122
|
+
await client.session.prompt({ path: { id: info.sessionID }, body: { noReply: true,
|
|
123
|
+
parts: [{ type: "text", text: res.text }] } });
|
|
124
|
+
} catch (e) {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
dispose: async () => { await call("SessionEnd", { hook_event_name: "SessionEnd", cwd: directory }, 5000); },
|
|
129
|
+
};
|
|
130
|
+
};
|
rf_harness/config.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Registry of every RFH_* environment variable: the single source for runtime defaults and the docs reference.
|
|
2
|
+
|
|
3
|
+
Each runtime module reads its settings through `get()` / `get_float()` / `get_int()` / `get_bool()` / `get_list()`, so a
|
|
4
|
+
variable that is not registered here raises at import time in tests, and `docs/reference/environment.md` is generated
|
|
5
|
+
from `VARIABLES`. Defaults are the measured v3.2 prototype values (hookpkg32); change them only through the release
|
|
6
|
+
gate.
|
|
7
|
+
|
|
8
|
+
Precedence: the process environment, then the `env` object of the project's `.rf/harness.json` (written by the
|
|
9
|
+
installer, e.g. `{"python": ..., "version": ..., "env": {"RFH_STATIC": "runtime-only"}}`), then the registered default.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
CATEGORIES = ("live", "feedback", "profiles", "guards", "docs", "lint", "logging", "desktop", "general")
|
|
20
|
+
AGENTS = ("claude", "codex", "copilot", "opencode", "vscode")
|
|
21
|
+
HARNESS_JSON = Path(".rf") / "harness.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Var:
|
|
26
|
+
name: str
|
|
27
|
+
default: str
|
|
28
|
+
doc: str
|
|
29
|
+
category: str = "general"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
VARIABLES: dict[str, Var] = {}
|
|
33
|
+
_PROJECT_ENV: dict[str, str] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(name: str, default: str, doc: str, category: str = "general") -> Var:
|
|
37
|
+
if not name.startswith("RFH_"):
|
|
38
|
+
raise ValueError(f"harness variables start with RFH_: {name}")
|
|
39
|
+
if category not in CATEGORIES:
|
|
40
|
+
raise ValueError(f"unknown category {category!r} for {name}; one of {', '.join(CATEGORIES)}")
|
|
41
|
+
var = Var(name, default, doc, category)
|
|
42
|
+
VARIABLES[name] = var
|
|
43
|
+
return var
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get(name: str) -> str:
|
|
47
|
+
var = VARIABLES.get(name)
|
|
48
|
+
if var is None:
|
|
49
|
+
raise KeyError(f"unregistered harness variable {name}; add it to rf_harness.config")
|
|
50
|
+
value = os.environ.get(name)
|
|
51
|
+
if value is not None:
|
|
52
|
+
return value
|
|
53
|
+
return project_env().get(name, var.default)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_harness_json(start: str | os.PathLike[str] | None = None) -> Path | None:
|
|
57
|
+
"""`.rf/harness.json` of the project: in RFH_ROOT / CLAUDE_PROJECT_DIR when set, else in `start` (default: the
|
|
58
|
+
cwd) or one of its six nearest parents."""
|
|
59
|
+
# os.environ directly, not get(): get() consults project_env(), which calls this function
|
|
60
|
+
env_root = os.environ.get("RFH_ROOT") or os.environ.get("CLAUDE_PROJECT_DIR")
|
|
61
|
+
if start is None and env_root and Path(env_root).is_dir():
|
|
62
|
+
candidates = [Path(env_root)]
|
|
63
|
+
else:
|
|
64
|
+
base = Path(start or os.getcwd()).absolute()
|
|
65
|
+
candidates = [base, *list(base.parents)[:6]]
|
|
66
|
+
for d in candidates:
|
|
67
|
+
f = d / HARNESS_JSON
|
|
68
|
+
if f.is_file():
|
|
69
|
+
return f
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_project_env(path: Path | None) -> dict[str, str]:
|
|
74
|
+
"""RFH_* entries of the `env` object in a harness.json (anything unreadable or malformed is ignored)."""
|
|
75
|
+
if path is None:
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
env = (json.loads(path.read_text(encoding="utf-8")) or {}).get("env") or {}
|
|
79
|
+
except (OSError, ValueError, AttributeError):
|
|
80
|
+
return {}
|
|
81
|
+
if not isinstance(env, dict):
|
|
82
|
+
return {}
|
|
83
|
+
return {str(k): str(v) for k, v in env.items()
|
|
84
|
+
if str(k).startswith("RFH_") and isinstance(v, (str, int, float, bool))}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def project_env() -> dict[str, str]:
|
|
88
|
+
"""Project defaults from `.rf/harness.json` (discovered once per process, see find_harness_json)."""
|
|
89
|
+
global _PROJECT_ENV
|
|
90
|
+
if _PROJECT_ENV is None:
|
|
91
|
+
_PROJECT_ENV = load_project_env(find_harness_json())
|
|
92
|
+
return _PROJECT_ENV
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_project_root(root: str | os.PathLike[str] | None) -> None:
|
|
96
|
+
"""Use `<root>/.rf/harness.json` from now on (None: rediscover on next access; tests)."""
|
|
97
|
+
global _PROJECT_ENV
|
|
98
|
+
_PROJECT_ENV = None if root is None else load_project_env(Path(root) / HARNESS_JSON)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_float(name: str) -> float:
|
|
102
|
+
try:
|
|
103
|
+
return float(get(name))
|
|
104
|
+
except ValueError:
|
|
105
|
+
return float(VARIABLES[name].default)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_int(name: str) -> int:
|
|
109
|
+
return int(get_float(name))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_bool(name: str) -> bool:
|
|
113
|
+
return get(name).strip().lower() not in ("0", "false", "no", "off", "")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_list(name: str) -> list[str]:
|
|
117
|
+
"""Comma-separated value; an empty value means the default (like the prototype's `os.environ.get(X) or default`)."""
|
|
118
|
+
return (get(name) or VARIABLES[name].default).split(",")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_registered(name: str) -> bool:
|
|
122
|
+
return name in VARIABLES
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------------------------------- general
|
|
126
|
+
register("RFH_ROOT", "", "Project root; default: CLAUDE_PROJECT_DIR, else the nearest parent of the hook's cwd that "
|
|
127
|
+
"already has .rf/hook, else the cwd.", "general")
|
|
128
|
+
register("RFH_PYTHON", "", "Interpreter for the helper daemons and doc workers; default: the hook's own interpreter.",
|
|
129
|
+
"general")
|
|
130
|
+
register("RFH_WATCH_PID", "", "PID of the agent process the daemons watch (they exit when it is gone); 0 disables the "
|
|
131
|
+
"watch; default: detected from the hook's ancestors.", "general")
|
|
132
|
+
register("RFH_TOKEN", "", "Internal: random per-daemon token set in the daemons' environment to find and clean up "
|
|
133
|
+
"their child processes. Do not set it.", "general")
|
|
134
|
+
register("RFH_FORCE_REPL", "0", "1 activates the REPL-only short-timeout listener also outside `robotcode repl|shell` "
|
|
135
|
+
"(tests only).", "general")
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------------------------------- live session
|
|
138
|
+
register("RFH_BUDGET", "90", "Maximum seconds a PostToolUse hook waits for live results before reporting 'still "
|
|
139
|
+
"running' (the result is delivered with the next hook event).", "live")
|
|
140
|
+
register("RFH_IDLE_EXIT", "1200", "Seconds without requests after which the helper daemons exit.", "live")
|
|
141
|
+
register("RFH_SHORT_TIMEOUTS", "1", "0 disables the short live element timeouts (live and REPL listener).", "live")
|
|
142
|
+
register("RFH_SHORT_TIMEOUT", "2.5", "Live element-keyword timeout in seconds (Browser).", "live")
|
|
143
|
+
register("RFH_SHORT_TIMEOUT_SELENIUM", "", "Live element timeout for SeleniumLibrary; default: RFH_SHORT_TIMEOUT.",
|
|
144
|
+
"live")
|
|
145
|
+
register("RFH_STABLE_S", "2", "Seconds without DOM change after which a page counts as stable (a missing element on a "
|
|
146
|
+
"stable page is MISSING at once).", "live")
|
|
147
|
+
register("RFH_ADAPT_FACTOR", "1.5", "A late element raises the site's learned short timeout to this factor times its "
|
|
148
|
+
"observed latency.", "live")
|
|
149
|
+
register("RFH_FRESH_DOC_S", "6", "A document younger than this many seconds is not yet considered stable.", "live")
|
|
150
|
+
register("RFH_MAX_CAPTURES", "2", "Maximum page captures per edit (failure + end of run).", "live")
|
|
151
|
+
register("RFH_STATE_HISTORY", "15", "Number of step-* capture directories kept in .rf/state.", "live")
|
|
152
|
+
register("RFH_CAPTURE", "end", "Page capture mode: end (after the last step and on failures), step (after every "
|
|
153
|
+
"page keyword) or off.", "live")
|
|
154
|
+
register("RFH_CAPTURE_SETTLE", "1.5", "Maximum seconds the end-of-run capture waits for the page to settle.", "live")
|
|
155
|
+
register("RFH_CAPTURE_GATE", "1", "0 captures after every page keyword (v2 behavior) instead of only on navigation, "
|
|
156
|
+
"failures and real DOM changes.", "live")
|
|
157
|
+
register("RFH_GUARD_SETTLE", "5.0", "Seconds the pre-execution guard lets a page settle between back-to-back file "
|
|
158
|
+
"lines.", "live")
|
|
159
|
+
register("RFH_PAGE_TEXT", "4000", "Maximum characters of visible page text in .rf/state/latest/page.txt.", "live")
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------------------------------- feedback
|
|
162
|
+
register("RFH_MAX_CONTEXT", "2048", "Maximum characters of any hook context returned to the agent.", "feedback")
|
|
163
|
+
register("RFH_MAX_CONTEXT_OPENCODE", "1200", "Maximum characters of hook context for OpenCode.", "feedback")
|
|
164
|
+
register("RFH_LEAN", "1", "0 switches to the v2 output format (ablation only).", "feedback")
|
|
165
|
+
register("RFH_REPRODUCE", "1", "0 disables the live reproduction of the first failure of a plain robot run.",
|
|
166
|
+
"feedback")
|
|
167
|
+
register("RFH_LOOP_AT", "3", "Number of identical failures after which the one-time loop hint is shown.", "feedback")
|
|
168
|
+
register("RFH_READ_STREAK_AT", "8", "Consecutive reads without an edit or command after which a note is added "
|
|
169
|
+
"(again at twice the value); 0 disables it.", "feedback")
|
|
170
|
+
register("RFH_COMPACT_AGENTS", "opencode", "Comma-separated agents that get the compact v2 output and image-read "
|
|
171
|
+
"guard.", "feedback")
|
|
172
|
+
register("RFH_RFMCP_HINTS", "0", "1 adds hints from the vendored rf-mcp hint engine (runtime/hints.py) to live failures. "
|
|
173
|
+
"Off by default: the measured v3.2 prototype ran without it.", "feedback")
|
|
174
|
+
register("RFH_GUIDANCE_FIRST", "opencode", "Comma-separated agents whose feedback starts with the guidance "
|
|
175
|
+
"(hint, NEXT:).", "feedback")
|
|
176
|
+
for _agent in AGENTS:
|
|
177
|
+
register(f"RFH_MAX_CTX_{_agent.upper()}", "", f"Lean-profile budget for {_agent} as 'ok,fail' characters; default: "
|
|
178
|
+
"the per-agent caps in leanfmt.", "feedback")
|
|
179
|
+
register("RFH_MAX_CTX_RICH", "", "Rich-profile budget as 'ok,fail' characters for every agent; default 1200,1200.",
|
|
180
|
+
"feedback")
|
|
181
|
+
for _agent in AGENTS:
|
|
182
|
+
register(f"RFH_MAX_CTX_RICH_{_agent.upper()}", "", f"Rich-profile budget for {_agent} as 'ok,fail' characters "
|
|
183
|
+
"(RFH_MAX_CTX_RICH wins).", "feedback")
|
|
184
|
+
del _agent
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------------------------------- profiles
|
|
187
|
+
register("RFH_PROFILE", "", "Force the output profile: lean or rich; default: chosen from the model in use.",
|
|
188
|
+
"profiles")
|
|
189
|
+
register("RFH_MODEL", "", "Model id used for the profile choice; default: from the hook payload, the agent's "
|
|
190
|
+
"environment, transcript or config.", "profiles")
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------------------------------- guards
|
|
193
|
+
register("RFH_RELAXED_DENIALS", "1", "0 restores the v2 denial policy (refuse every static finding; ablation only).",
|
|
194
|
+
"guards")
|
|
195
|
+
register("RFH_DENY_CODES", "TokenError,ParsingError,InvalidHeader,ModelError", "Comma-separated static-analysis codes "
|
|
196
|
+
"that refuse an edit; every other finding is a one-line warning.", "guards")
|
|
197
|
+
register("RFH_REWRITE_AGENTS", "claude,opencode,codex,copilot", "Comma-separated agents whose tool input may be "
|
|
198
|
+
"rewritten (deprecated-syntax fixes, REPL format fixes).", "guards")
|
|
199
|
+
register("RFH_MAX_DENIALS", "3", "Refusals of the same kind before the edit or command is let through with a note.",
|
|
200
|
+
"guards")
|
|
201
|
+
register("RFH_MAX_DENIALS_OPENCODE", "2", "RFH_MAX_DENIALS for OpenCode.", "guards")
|
|
202
|
+
register("RFH_MAX_STOP_BLOCKS", "2", "How often the end of a turn is blocked for a missing green plain robot run.",
|
|
203
|
+
"guards")
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------------------------------- docs
|
|
206
|
+
register("RFH_DOC_WAIT", "1.2", "Seconds a hook waits for a keyword documentation file before naming its path "
|
|
207
|
+
"anyway.", "docs")
|
|
208
|
+
register("RFH_KWDOC_FILES", "0", "1 writes .rf/docs/kw/<Owner>.<Keyword>.md on every failure (v2) instead of on "
|
|
209
|
+
"demand.", "docs")
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------------------------------- lint
|
|
212
|
+
register("RFH_STATIC", "on", "on: the harness checks edits statically (refusals for certainly-wrong structure, one-line "
|
|
213
|
+
"warnings, deprecated-syntax fixes). runtime-only: only certainly-wrong structural edits are refused, no static "
|
|
214
|
+
"warnings or fixes, and live runs execute lines without the static pre-check (used when rf-agentskills "
|
|
215
|
+
"validates edits).", "lint")
|
|
216
|
+
register("RFH_GATE_TIMEOUT", "2.5", "Seconds the PreToolUse static gate waits for the lint daemon; on timeout the "
|
|
217
|
+
"edit proceeds.", "lint")
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------------------------------- desktop
|
|
220
|
+
# Read by rf_harness.runtime.desktop through its own _env.env_get (rf-mcp's exact truthiness rules); registered here
|
|
221
|
+
# for the docs reference. Name/default/doc copied from desktop/_env.py ENV_VARIABLES (a test keeps them in sync).
|
|
222
|
+
register("RFH_DESKTOP", "0", "Set to 1 to allow live desktop (PlatynUI) steps. Off by default until the vendored rf-mcp "
|
|
223
|
+
"desktop guards pass their parity tests; even when on, every safety, isolation and focus guard still applies.",
|
|
224
|
+
"desktop")
|
|
225
|
+
register("RFH_PLATYNUI_ISOLATED_DISPLAY", "", "Isolation marker: the X DISPLAY(s) (comma/space separated) the harness "
|
|
226
|
+
"may drive, for example ':99'. Only counts when RFH_PLATYNUI_ISOLATED_XPID names the live X server for that "
|
|
227
|
+
"display.", "desktop")
|
|
228
|
+
register("RFH_PLATYNUI_ISOLATED_XPID", "", "PID of the Xvfb/Xephyr/Xorg server that owns the isolated display. "
|
|
229
|
+
"Corroborates the isolation marker; without it the display is classified 'unknown' and desktop input is "
|
|
230
|
+
"refused.", "desktop")
|
|
231
|
+
register("RFH_PLATYNUI_ALLOW_ACTIVE_DESKTOP", "0", "Escape hatch: 1 allows desktop input on an 'active' or 'unknown' "
|
|
232
|
+
"display (audited). Input may still target the wrong window on a shared desktop; prefer an isolated display.",
|
|
233
|
+
"desktop")
|
|
234
|
+
register("RFH_PLATYNUI_SAFETY_GUARD", "enforce", "'warn' logs instead of refusing desktop input on a non-isolated "
|
|
235
|
+
"display (transition mode).", "desktop")
|
|
236
|
+
register("RFH_PLATYNUI_REQUIRE_ISOLATED", "0", "Windows only: 1 refuses desktop input on the active Windows desktop "
|
|
237
|
+
"(there is no nested-display model).", "desktop")
|
|
238
|
+
register("RFH_PLATYNUI_ALLOW_UNSCOPED", "0", "1 allows //-rooted desktop Query/Evaluate locators (whole-desktop walks) "
|
|
239
|
+
"with a one-time warning.", "desktop")
|
|
240
|
+
register("RFH_PLATYNUI_ALLOW_PATH_DESCRIPTOR", "0", "1 allows an image path in the Take Screenshot descriptor slot "
|
|
241
|
+
"with a one-time warning.", "desktop")
|
|
242
|
+
register("RFH_PLATYNUI_ALLOW_CONTROL_WINDOW", "0", "1 allows control:Window locators on Linux (AT-SPI windows are "
|
|
243
|
+
"control:Frame) with a one-time warning.", "desktop")
|
|
244
|
+
register("RFH_PLATYNUI_NO_FOCUS", "0", "1 disables focus-before-act for desktop pointer/keyboard steps (the step is "
|
|
245
|
+
"marked focus-bypassed).", "desktop")
|
|
246
|
+
register("RFH_PLATYNUI_HIGHLIGHT", "1", "0 disables highlighting the target element before desktop input.", "desktop")
|
|
247
|
+
register("RFH_PLATYNUI_KEEP_WAYLAND", "0", "1 keeps the native Wayland backend instead of forcing "
|
|
248
|
+
"XDG_SESSION_TYPE=x11 before PlatynUI starts.", "desktop")
|
|
249
|
+
register("RFH_PLATYNUI_STEERING_CONFIDENCE", "enforce", "'warn' downgrades a 'contradicted' steering-confidence verdict "
|
|
250
|
+
"from a step failure to a warning.", "desktop")
|
|
251
|
+
register("RFH_SCREENSHOT_DIR", "", "Extra directory desktop screenshots may be written to (the system temp dir is "
|
|
252
|
+
"always allowed).", "desktop")
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------------------------------- logging
|
|
255
|
+
register("RFH_HOOK_LOG", "", "Path of the JSONL hook event log; default: .rf/hook/hooks.jsonl.", "logging")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rf-harness installer: sets up the Robot Framework agent harness for coding agents (stdlib only)."""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Locate the plugin tree (skill, hooks, launcher, variants) that the installer copies.
|
|
2
|
+
|
|
3
|
+
Search order:
|
|
4
|
+
1. ``RF_HARNESS_ASSETS_DIR`` (tests, local experiments);
|
|
5
|
+
2. a source checkout: ``<repo>/plugins/rf-harness`` next to ``src/rf_harness`` (the single source of truth);
|
|
6
|
+
3. the wheel: ``rf_harness/_assets`` (built from ``plugins/rf-harness`` by hatch_build.py).
|
|
7
|
+
|
|
8
|
+
Adapted from robotframework-agentskills' ``installer/src/rf_agentskills/_assets.py`` (same author, Apache-2.0).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import atexit
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from contextlib import ExitStack
|
|
18
|
+
from importlib.resources import as_file, files
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ASSETS_ENV = "RF_HARNESS_ASSETS_DIR"
|
|
22
|
+
_IGNORED = {"__pycache__", ".DS_Store"}
|
|
23
|
+
_stack = ExitStack()
|
|
24
|
+
atexit.register(_stack.close)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AssetsMissing(RuntimeError):
|
|
28
|
+
"""The plugin tree could not be found."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _valid(root: Path) -> bool:
|
|
32
|
+
return (root / ".claude-plugin" / "plugin.json").is_file()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def source_checkout_root() -> Path | None:
|
|
36
|
+
"""``plugins/rf-harness`` of the repository this package was imported from, if any."""
|
|
37
|
+
candidate = Path(__file__).resolve().parents[3] / "plugins" / "rf-harness"
|
|
38
|
+
return candidate if _valid(candidate) else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def plugin_root() -> Path:
|
|
42
|
+
env = os.environ.get(ASSETS_ENV)
|
|
43
|
+
if env:
|
|
44
|
+
root = Path(env).expanduser().resolve()
|
|
45
|
+
if not _valid(root):
|
|
46
|
+
raise AssetsMissing(f"{ASSETS_ENV}={env} has no .claude-plugin/plugin.json")
|
|
47
|
+
return root
|
|
48
|
+
checkout = source_checkout_root()
|
|
49
|
+
if checkout is not None:
|
|
50
|
+
return checkout
|
|
51
|
+
traversable = files("rf_harness").joinpath("_assets")
|
|
52
|
+
root = Path(_stack.enter_context(as_file(traversable))) # type: ignore[arg-type]
|
|
53
|
+
if not _valid(root):
|
|
54
|
+
raise AssetsMissing("the bundled plugin assets (rf_harness/_assets) are missing; reinstall robotframework-harness")
|
|
55
|
+
return root
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bundle_version(root: Path | None = None) -> str:
|
|
59
|
+
root = root or plugin_root()
|
|
60
|
+
data = json.loads((root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))
|
|
61
|
+
return str(data.get("version", "unknown"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_files(base: Path) -> Iterator[Path]:
|
|
65
|
+
"""Every shipped file under ``base`` (sorted, caches skipped)."""
|
|
66
|
+
if base.is_file():
|
|
67
|
+
yield base
|
|
68
|
+
return
|
|
69
|
+
if not base.is_dir():
|
|
70
|
+
return
|
|
71
|
+
for path in sorted(base.rglob("*")):
|
|
72
|
+
if path.is_file() and not (_IGNORED & set(path.parts)) and path.suffix != ".pyc":
|
|
73
|
+
yield path
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Per-agent adapters for the v1 agents: Claude Code, Codex, GitHub Copilot CLI, VS Code (Copilot) and OpenCode.
|
|
2
|
+
|
|
3
|
+
Cursor, Goose and Gemini CLI are out of v1 until they are verified live (design, Resolved Decisions).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ._base import Adapter, ConfigOp, FileTarget, Options, Plan
|
|
9
|
+
from .claude_code import ClaudeCodeAdapter
|
|
10
|
+
from .codex import CodexAdapter
|
|
11
|
+
from .copilot_cli import CopilotCliAdapter
|
|
12
|
+
from .opencode import OpenCodeAdapter
|
|
13
|
+
from .vscode import VSCodeAdapter
|
|
14
|
+
|
|
15
|
+
ALL_ADAPTERS: tuple[type[Adapter], ...] = (
|
|
16
|
+
ClaudeCodeAdapter,
|
|
17
|
+
CodexAdapter,
|
|
18
|
+
CopilotCliAdapter,
|
|
19
|
+
VSCodeAdapter,
|
|
20
|
+
OpenCodeAdapter,
|
|
21
|
+
)
|
|
22
|
+
ALIASES = {"claude": "claude-code", "copilot": "copilot-cli", "code": "vscode", "vs-code": "vscode"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def all_names() -> tuple[str, ...]:
|
|
26
|
+
return tuple(cls.name for cls in ALL_ADAPTERS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def by_name(name: str) -> Adapter | None:
|
|
30
|
+
name = ALIASES.get(name, name)
|
|
31
|
+
for cls in ALL_ADAPTERS:
|
|
32
|
+
if cls.name == name:
|
|
33
|
+
return cls()
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["ALL_ADAPTERS", "Adapter", "ConfigOp", "FileTarget", "Options", "Plan", "all_names", "by_name"]
|