claimset 0.40.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.
- claimset/__init__.py +12 -0
- claimset/__main__.py +7 -0
- claimset/agents.py +495 -0
- claimset/bundle.py +198 -0
- claimset/claim.py +79 -0
- claimset/cli.py +345 -0
- claimset/data/agents/AGENTS.md +16 -0
- claimset/data/agents/opencode-guard.js +13 -0
- claimset/data/examples/published-native.json +285 -0
- claimset/data/examples/submission-from-lab.json +507 -0
- claimset/data/examples/submission-native.json +271 -0
- claimset/data/skills/claimset-lab/SKILL.md +266 -0
- claimset/data/skills/claimset-lab/claimset +16 -0
- claimset/data/skills/claimset-review/SKILL.md +183 -0
- claimset/data/skills/claimset-revise/SKILL.md +87 -0
- claimset/data/skills/claimset-submit/SKILL.md +347 -0
- claimset/data/spec/claimset-revision-v0.6.md +561 -0
- claimset/data/spec/claimset-v0.40.md +1896 -0
- claimset/data/spec/extensions/README.md +23 -0
- claimset/data/spec/extensions/linguistics-v0.1.md +37 -0
- claimset/data/spec/extensions/qualitative-v0.1.md +117 -0
- claimset/data/spec/lab-record-v0.21.md +904 -0
- claimset/extensions/__init__.py +34 -0
- claimset/extensions/linguistics_v0_1.py +52 -0
- claimset/extensions/qualitative_count.py +168 -0
- claimset/extensions/qualitative_v0_1.py +96 -0
- claimset/lab/__init__.py +3 -0
- claimset/lab/anchor.py +508 -0
- claimset/lab/cli.py +1195 -0
- claimset/lab/core.py +2044 -0
- claimset/lab/decision.py +396 -0
- claimset/lab/export.py +1051 -0
- claimset/lab/guard.py +212 -0
- claimset/lab/layout.py +171 -0
- claimset/lab/protect.py +43 -0
- claimset/lab/qualitative.py +350 -0
- claimset/lab/record.py +578 -0
- claimset/lab/run.py +249 -0
- claimset/lab/sources.py +361 -0
- claimset/lab/summary.py +216 -0
- claimset/migrate.py +193 -0
- claimset/package.py +101 -0
- claimset/render.py +402 -0
- claimset/renumber.py +93 -0
- claimset/revision.py +1292 -0
- claimset/submit.py +168 -0
- claimset/validate.py +3330 -0
- claimset/versions/__init__.py +21 -0
- claimset/versions/v0_39.py +3288 -0
- claimset-0.40.0.dist-info/METADATA +198 -0
- claimset-0.40.0.dist-info/RECORD +55 -0
- claimset-0.40.0.dist-info/WHEEL +5 -0
- claimset-0.40.0.dist-info/entry_points.txt +2 -0
- claimset-0.40.0.dist-info/licenses/LICENSE +201 -0
- claimset-0.40.0.dist-info/top_level.txt +1 -0
claimset/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Claimset: an open format for research papers written by and for LLMs."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.40.0"
|
|
4
|
+
|
|
5
|
+
#: Version of the claimset specification this package implements.
|
|
6
|
+
SPEC_VERSION = "0.40"
|
|
7
|
+
|
|
8
|
+
#: Version of the lab record specification this package implements.
|
|
9
|
+
LAB_SPEC_VERSION = "0.21"
|
|
10
|
+
|
|
11
|
+
#: Version of the revision specification (reviews, responses, revised versions and scores) this package implements.
|
|
12
|
+
REVISION_SPEC_VERSION = "0.6"
|
claimset/__main__.py
ADDED
claimset/agents.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Agent harnesses: what each coding agent reads, how it identifies itself, how it runs a guard (lab record §4.5,
|
|
2
|
+
§4.6), and `claimset init`, which sets a project up for the agents its people use.
|
|
3
|
+
|
|
4
|
+
Everything that differs between harnesses is data in HARNESSES; the code is the same for all of them:
|
|
5
|
+
|
|
6
|
+
- **Skills** are installed once per folder: `.agents/skills/` is read by most agents (Codex, Gemini CLI, Qwen Code,
|
|
7
|
+
opencode, Kilo Code, GitHub Copilot, Cursor, Windsurf, Amp, Goose, Factory, Crush, Zed, MiniMax Code, Cline);
|
|
8
|
+
Claude Code, Junie, Kiro and Deep Code read only their own. `skill_dirs` picks the fewest folders that reach every
|
|
9
|
+
agent chosen.
|
|
10
|
+
- **Instructions** are one block (`data/agents/AGENTS.md`), written to `AGENTS.md`. An agent that reads another file
|
|
11
|
+
and can import (Claude Code: CLAUDE.md, Gemini CLI: GEMINI.md) gets a block that imports AGENTS.md, never a copy.
|
|
12
|
+
- **The guard** reads a proposed tool call in any harness's shape (`tool_actions`) as files it would write and
|
|
13
|
+
commands it would run; `claimset.lab.guard` decides. Only the configuration that registers it differs
|
|
14
|
+
(`hook_files`), and a refusal is exit code 2 with the reason on standard error everywhere but Cline.
|
|
15
|
+
- **The harness itself** is recorded as an agent's `system` (lab record §5.2) when it identifies itself in the
|
|
16
|
+
environment (`detect_system`).
|
|
17
|
+
|
|
18
|
+
What is known of each harness comes from its documentation or source as of November 2026; entries marked
|
|
19
|
+
`verified=False` rest on secondary sources and are written only when asked for by name.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
DATA = Path(__file__).resolve().parent / "data" / "agents"
|
|
31
|
+
BEGIN = "<!-- claimset:begin (written by `claimset init`; edit outside these lines) -->"
|
|
32
|
+
END = "<!-- claimset:end -->"
|
|
33
|
+
SHARED_SKILLS = ".agents/skills"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Harness:
|
|
38
|
+
key: str # the name `--agent` takes
|
|
39
|
+
name: str # the harness's name, as an agent's `system.name` records it
|
|
40
|
+
skills: tuple # project skill folders it reads, the shared one first when it reads it
|
|
41
|
+
instructions: str = "AGENTS.md" # the instructions file it reads by default
|
|
42
|
+
imports: str = None # how that file imports AGENTS.md, when it is not AGENTS.md
|
|
43
|
+
env: tuple = () # (variable, value or None for any value) that identify it in its shell
|
|
44
|
+
version_env: str = None # a variable holding its version, when it sets one
|
|
45
|
+
hooks: str = None # its hook dialect (HOOKS), None when it runs no command before a tool call
|
|
46
|
+
network: str = None # how to let its shell reach the git host and the platform, when sandboxed
|
|
47
|
+
notes: tuple = () # what the human does after `claimset init`
|
|
48
|
+
verified: bool = True # False: known from secondary sources only
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
HARNESSES = (
|
|
52
|
+
Harness("claude", "Claude Code", (".claude/skills",), "CLAUDE.md", "@AGENTS.md", (("CLAUDECODE", "1"),),
|
|
53
|
+
hooks="claude",
|
|
54
|
+
network="with Claude Code's sandbox on, add the git host and entrylib.org to "
|
|
55
|
+
"sandbox.network.allowedDomains in .claude/settings.json"),
|
|
56
|
+
Harness("codex", "Codex CLI", (SHARED_SKILLS, ".codex/skills"),
|
|
57
|
+
env=(("CODEX_THREAD_ID", None), ("CODEX_SANDBOX", None), ("CODEX_SANDBOX_NETWORK_DISABLED", None),
|
|
58
|
+
("CODEX_CI", None)),
|
|
59
|
+
hooks="codex",
|
|
60
|
+
network="Codex's workspace-write sandbox has no network by default: start it with "
|
|
61
|
+
"`codex -c sandbox_workspace_write.network_access=true`, or set network_access = true under "
|
|
62
|
+
"[sandbox_workspace_write] in ~/.codex/config.toml",
|
|
63
|
+
notes=("Codex runs project hooks only once you trust them: open /hooks in Codex and trust the claimset "
|
|
64
|
+
"hooks (again after `claimset init` changes them).",)),
|
|
65
|
+
Harness("gemini", "Gemini CLI", (SHARED_SKILLS, ".gemini/skills"), "GEMINI.md", "@./AGENTS.md",
|
|
66
|
+
(("GEMINI_CLI", "1"),), hooks="gemini",
|
|
67
|
+
network="with Gemini CLI's sandbox on, use a profile that allows the network (permissive-open, the "
|
|
68
|
+
"default on macOS), not a *-proxied one",
|
|
69
|
+
notes=("Gemini CLI asks before it runs a new or changed project hook: allow the claimset guard.",)),
|
|
70
|
+
Harness("qwen", "Qwen Code", (SHARED_SKILLS, ".qwen/skills"), env=(("QWEN_CODE", "1"),), hooks="qwen",
|
|
71
|
+
network="with Qwen Code's execution sandbox, set its network to open",
|
|
72
|
+
notes=("Qwen Code runs project hooks once you approve them or trust the folder.",)),
|
|
73
|
+
Harness("opencode", "opencode", (SHARED_SKILLS, ".opencode/skills", ".claude/skills"),
|
|
74
|
+
env=(("OPENCODE", "1"),), hooks="opencode"),
|
|
75
|
+
Harness("kilo", "Kilo Code", (SHARED_SKILLS, ".kilo/skills"), env=(("KILO", "1"),)),
|
|
76
|
+
Harness("copilot", "GitHub Copilot", (SHARED_SKILLS, ".github/skills", ".claude/skills"),
|
|
77
|
+
env=(("COPILOT_CLI", "1"),), hooks="copilot",
|
|
78
|
+
network="with Copilot's sandbox on (--sandbox), allow the git host and entrylib.org; the cloud agent's "
|
|
79
|
+
"firewall is set in the repository's Copilot settings",
|
|
80
|
+
notes=("Copilot runs repository hooks only in a trusted workspace.",)),
|
|
81
|
+
Harness("cursor", "Cursor", (SHARED_SKILLS, ".cursor/skills"), env=(("CURSOR_AGENT", "1"),), hooks="cursor",
|
|
82
|
+
network="Cursor's sandbox denies the network by default: allow the git host and entrylib.org in "
|
|
83
|
+
".cursor/sandbox.json", verified=False),
|
|
84
|
+
Harness("windsurf", "Windsurf", (SHARED_SKILLS, ".windsurf/skills"), verified=False),
|
|
85
|
+
Harness("cline", "Cline", (SHARED_SKILLS, ".claude/skills", ".cline/skills"), env=(("CLINE_ACTIVE", "true"),),
|
|
86
|
+
hooks="cline"),
|
|
87
|
+
Harness("amp", "Amp", (SHARED_SKILLS, ".claude/skills"), verified=False),
|
|
88
|
+
Harness("goose", "Goose", (SHARED_SKILLS, ".goose/skills"), env=(("GOOSE_TERMINAL", "1"), ("AGENT", "goose"))),
|
|
89
|
+
Harness("factory", "Factory Droid", (SHARED_SKILLS, ".factory/skills"), verified=False),
|
|
90
|
+
Harness("crush", "Crush", (SHARED_SKILLS, ".crush/skills"), env=(("CRUSH", "1"),)),
|
|
91
|
+
Harness("zed", "Zed", (SHARED_SKILLS,),
|
|
92
|
+
network="Zed's agent sandbox blocks the network unless allowed: add the git host and entrylib.org to "
|
|
93
|
+
"agent.sandbox_permissions.network_hosts"),
|
|
94
|
+
Harness("minimax", "MiniMax Code", (SHARED_SKILLS, ".minimax/skills")),
|
|
95
|
+
Harness("junie", "Junie", (".junie/skills",), verified=False),
|
|
96
|
+
Harness("kiro", "Kiro", (".kiro/skills",), verified=False),
|
|
97
|
+
Harness("deepcode", "Deep Code", (".deepcode/skills",), verified=False),
|
|
98
|
+
Harness("aider", "Aider", (),
|
|
99
|
+
notes=("Aider has no skills and reads no instructions file by default: start it with `aider --read "
|
|
100
|
+
"AGENTS.md`, or add `read: [AGENTS.md]` to .aider.conf.yml.",)),
|
|
101
|
+
Harness("generic", "an agent", (SHARED_SKILLS,),
|
|
102
|
+
notes=("For an agent not listed, point it at AGENTS.md and the skills in .agents/skills/.",)),
|
|
103
|
+
)
|
|
104
|
+
BY_KEY = {h.key: h for h in HARNESSES}
|
|
105
|
+
#: Order in which harnesses are recognised in the environment: forks before what they fork (Kilo sets OPENCODE=1),
|
|
106
|
+
#: the agent running the shell before the editor it may run in.
|
|
107
|
+
DETECT_ORDER = ("kilo", "opencode", "crush", "goose", "qwen", "gemini", "copilot", "codex", "claude", "cline",
|
|
108
|
+
"cursor")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def harness(key):
|
|
112
|
+
if key not in BY_KEY:
|
|
113
|
+
raise ValueError(f"unknown agent '{key}': one of {', '.join(BY_KEY)} (`claimset init --list`)")
|
|
114
|
+
return BY_KEY[key]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detect(environ=None):
|
|
118
|
+
"""The harness whose shell this is, from the variables it sets, or None."""
|
|
119
|
+
env = os.environ if environ is None else environ
|
|
120
|
+
for key in DETECT_ORDER:
|
|
121
|
+
for var, value in BY_KEY[key].env:
|
|
122
|
+
if var in env and (value is None or env[var] == value):
|
|
123
|
+
return BY_KEY[key]
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def detect_system(environ=None):
|
|
128
|
+
"""An agent's `system` (claimset §3.11, lab record §5.2) from the environment: {name, version} of the harness
|
|
129
|
+
that identifies itself, {name} from $AI_AGENT for one that only names itself there, else None."""
|
|
130
|
+
env = os.environ if environ is None else environ
|
|
131
|
+
h = detect(env)
|
|
132
|
+
if h is not None:
|
|
133
|
+
out = {"name": h.name}
|
|
134
|
+
if h.version_env and env.get(h.version_env):
|
|
135
|
+
out["version"] = env[h.version_env]
|
|
136
|
+
return out
|
|
137
|
+
name = (env.get("AI_AGENT") or "").strip()
|
|
138
|
+
return {"name": name} if name else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------- skills and instructions ----------
|
|
142
|
+
|
|
143
|
+
def skill_dirs(harnesses):
|
|
144
|
+
"""The fewest skill folders that reach every harness: a folder read by most of those not yet reached, first."""
|
|
145
|
+
left = [h for h in harnesses if h.skills]
|
|
146
|
+
chosen = []
|
|
147
|
+
while left:
|
|
148
|
+
counts = {}
|
|
149
|
+
for h in left:
|
|
150
|
+
for d in h.skills:
|
|
151
|
+
counts[d] = counts.get(d, 0) + 1
|
|
152
|
+
best = max(counts, key=lambda d: (counts[d], d == SHARED_SKILLS))
|
|
153
|
+
chosen.append(best)
|
|
154
|
+
left = [h for h in left if best not in h.skills]
|
|
155
|
+
return chosen
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def instructions_block(skills_dir):
|
|
159
|
+
"""The claimset block of AGENTS.md: one template for every agent."""
|
|
160
|
+
text = (DATA / "AGENTS.md").read_text(encoding="utf-8").replace("{skills}", skills_dir or SHARED_SKILLS)
|
|
161
|
+
return f"{BEGIN}\n{text.strip()}\n{END}\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def import_block(line):
|
|
165
|
+
return f"{BEGIN}\n{line}\n{END}\n"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def write_block(path, block):
|
|
169
|
+
"""Put the block in a file, replacing an earlier claimset block, or after what the file holds. Returns
|
|
170
|
+
"created", "updated" or "current"."""
|
|
171
|
+
path = Path(path)
|
|
172
|
+
old = path.read_text(encoding="utf-8") if path.exists() else None
|
|
173
|
+
if old is None:
|
|
174
|
+
new = block
|
|
175
|
+
elif BEGIN in old and END in old:
|
|
176
|
+
new = old[:old.index(BEGIN)] + block.rstrip("\n") + old[old.index(END) + len(END):]
|
|
177
|
+
if not new.endswith("\n"):
|
|
178
|
+
new += "\n"
|
|
179
|
+
else:
|
|
180
|
+
new = old.rstrip("\n") + ("\n\n" if old.strip() else "") + block
|
|
181
|
+
if new == old:
|
|
182
|
+
return "current"
|
|
183
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path.write_text(new, encoding="utf-8")
|
|
185
|
+
return "created" if old is None else "updated"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------- the guard: reading a proposed tool call ----------
|
|
189
|
+
|
|
190
|
+
#: Tools that run a shell command, by the names the harnesses give them
|
|
191
|
+
SHELL_TOOLS = {"Bash", "bash", "Shell", "shell", "run_shell_command", "Execute", "execute", "exec_command",
|
|
192
|
+
"local_shell", "powershell", "terminal", "run_terminal_cmd", "run_command"}
|
|
193
|
+
#: Tools that create, change or delete a file
|
|
194
|
+
WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "edit", "write", "multiedit", "write_file", "replace",
|
|
195
|
+
"notebook_edit", "create", "Create", "str_replace_editor", "str_replace_based_edit_tool", "Delete",
|
|
196
|
+
"delete", "delete_file", "edit_file", "search_replace"}
|
|
197
|
+
#: Tools that apply a patch, whose files are named inside it
|
|
198
|
+
PATCH_TOOLS = {"apply_patch", "ApplyPatch", "patch"}
|
|
199
|
+
PATH_KEYS = ("file_path", "filePath", "notebook_path", "path", "target_file", "filename")
|
|
200
|
+
COMMAND_KEYS = ("command", "cmd", "command_line", "commandLine")
|
|
201
|
+
PATCH_KEYS = ("patchText", "patch", "input", "command")
|
|
202
|
+
PATCH_FILE = re.compile(r"^\*\*\* (?:Add|Update|Delete) File: (.+?)\s*$|^\*\*\* Move to: (.+?)\s*$", re.M)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _text(value):
|
|
206
|
+
"""A command as text: a string as it is; an argv list as the script it runs (`bash -lc SCRIPT`), else joined."""
|
|
207
|
+
if isinstance(value, list):
|
|
208
|
+
argv = [str(v) for v in value]
|
|
209
|
+
if len(argv) >= 3 and Path(argv[0]).name in ("bash", "sh", "zsh", "dash") and argv[1] in ("-c", "-lc"):
|
|
210
|
+
return argv[2]
|
|
211
|
+
return " ".join(shlex.quote(v) for v in argv)
|
|
212
|
+
return value if isinstance(value, str) else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def patch_files(text):
|
|
216
|
+
"""The files an apply_patch text adds, changes, deletes or moves to."""
|
|
217
|
+
return [a or b for a, b in PATCH_FILE.findall(text or "")]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def tool_actions(agent, payload):
|
|
221
|
+
"""(cwd, actions) of a proposed tool call, in whatever shape the harness sends it: Claude Code, Codex and Qwen
|
|
222
|
+
Code (`tool_name`, `tool_input`), Gemini CLI (the same names, its own tools), Copilot (`toolName`, `toolArgs`,
|
|
223
|
+
possibly a JSON string), opencode through the plugin (`tool`, `args`), a bare shell command (`command`) and
|
|
224
|
+
Windsurf (`tool_info`). An action is ("write", path) or ("shell", command). A call that names no file and no
|
|
225
|
+
command gives no actions, and is allowed."""
|
|
226
|
+
if not isinstance(payload, dict):
|
|
227
|
+
return None, []
|
|
228
|
+
cwd = payload.get("cwd") or payload.get("directory")
|
|
229
|
+
if not cwd and isinstance(payload.get("workspace_roots"), list) and payload["workspace_roots"]:
|
|
230
|
+
cwd = payload["workspace_roots"][0]
|
|
231
|
+
tool = payload.get("tool_name") or payload.get("toolName") or payload.get("tool")
|
|
232
|
+
args = payload.get("tool_input", payload.get("toolArgs", payload.get("args")))
|
|
233
|
+
if isinstance(args, str):
|
|
234
|
+
try:
|
|
235
|
+
args = json.loads(args)
|
|
236
|
+
except ValueError:
|
|
237
|
+
args = {"command": args}
|
|
238
|
+
info = payload.get("tool_info")
|
|
239
|
+
if tool is None and isinstance(info, dict): # Windsurf
|
|
240
|
+
args = info
|
|
241
|
+
tool = "shell" if info.get("command_line") else "write" if info.get("file_path") else None
|
|
242
|
+
cwd = cwd or info.get("cwd")
|
|
243
|
+
if tool is None and isinstance(payload.get("command"), str): # a bare shell hook (Cursor)
|
|
244
|
+
tool, args = "shell", {"command": payload["command"]}
|
|
245
|
+
args = args if isinstance(args, dict) else {}
|
|
246
|
+
actions = []
|
|
247
|
+
if tool in PATCH_TOOLS:
|
|
248
|
+
for key in PATCH_KEYS:
|
|
249
|
+
text = _text(args.get(key))
|
|
250
|
+
if text:
|
|
251
|
+
actions += [("write", p) for p in patch_files(text)]
|
|
252
|
+
break
|
|
253
|
+
elif tool in SHELL_TOOLS:
|
|
254
|
+
command = next((_text(args.get(k)) for k in COMMAND_KEYS if _text(args.get(k))), None)
|
|
255
|
+
if command:
|
|
256
|
+
actions.append(("shell", command))
|
|
257
|
+
if command.lstrip().startswith("apply_patch"): # Codex may apply a patch through its shell
|
|
258
|
+
actions += [("write", p) for p in patch_files(command)]
|
|
259
|
+
elif tool in WRITE_TOOLS or tool == "write":
|
|
260
|
+
for key in PATH_KEYS:
|
|
261
|
+
if isinstance(args.get(key), str) and args[key]:
|
|
262
|
+
actions.append(("write", args[key]))
|
|
263
|
+
for edit in args.get("edits") or []: # several files in one call
|
|
264
|
+
if isinstance(edit, dict) and isinstance(edit.get("file_path"), str):
|
|
265
|
+
actions.append(("write", edit["file_path"]))
|
|
266
|
+
return cwd, actions
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def refuse(agent, reason, stdout=None, stderr=None):
|
|
270
|
+
"""Answer a refused tool call in the harness's protocol; returns the exit code."""
|
|
271
|
+
stdout, stderr = stdout or sys.stdout, stderr or sys.stderr
|
|
272
|
+
if agent == "cline":
|
|
273
|
+
stdout.write(json.dumps({"cancel": True, "errorMessage": reason}) + "\n")
|
|
274
|
+
return 0
|
|
275
|
+
stderr.write(reason + "\n")
|
|
276
|
+
return 2
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------- the guard: registering it with each harness ----------
|
|
280
|
+
|
|
281
|
+
GUARD_MATCHERS = {"claude": "Edit|Write|MultiEdit|NotebookEdit|Bash",
|
|
282
|
+
"codex": "Bash|apply_patch|Edit|Write",
|
|
283
|
+
"gemini": "run_shell_command|write_file|replace",
|
|
284
|
+
"qwen": "run_shell_command|write_file|edit|notebook_edit"}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _claude_style(agent, guard, studies, seconds=True):
|
|
288
|
+
"""Claude Code's hook format, which Codex and Qwen Code use too."""
|
|
289
|
+
timeout = {"timeout": 30} if seconds else {}
|
|
290
|
+
return {"hooks": {
|
|
291
|
+
"PreToolUse": [{"matcher": GUARD_MATCHERS[agent],
|
|
292
|
+
"hooks": [dict({"type": "command", "command": guard}, **timeout)]}],
|
|
293
|
+
"SessionStart": [{"hooks": [dict({"type": "command", "command": studies}, **timeout)]}],
|
|
294
|
+
}}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def hook_config(agent, guard="claimset lab guard"):
|
|
298
|
+
"""{path in the project: JSON to merge into it, or text of a file} that registers the guard (and `claimset
|
|
299
|
+
studies` at the start of a session) with one harness; {} for a harness without hooks."""
|
|
300
|
+
h = harness(agent)
|
|
301
|
+
studies = guard.replace("lab guard", "studies")
|
|
302
|
+
if h.hooks in ("claude", "codex", "qwen"):
|
|
303
|
+
path = {"claude": ".claude/settings.json", "codex": ".codex/hooks.json", "qwen": ".qwen/settings.json"}
|
|
304
|
+
return {path[h.hooks]: _claude_style(h.hooks, guard, studies, seconds=h.hooks != "claude")}
|
|
305
|
+
if h.hooks == "gemini":
|
|
306
|
+
return {".gemini/settings.json": {"hooks": {
|
|
307
|
+
"BeforeTool": [{"matcher": GUARD_MATCHERS["gemini"],
|
|
308
|
+
"hooks": [{"name": "claimset-guard", "type": "command", "command": guard,
|
|
309
|
+
"timeout": 30000}]}],
|
|
310
|
+
"SessionStart": [{"matcher": "startup",
|
|
311
|
+
"hooks": [{"name": "claimset-studies", "type": "command", "command": studies,
|
|
312
|
+
"timeout": 30000}]}],
|
|
313
|
+
}}}
|
|
314
|
+
if h.hooks == "copilot":
|
|
315
|
+
return {".github/hooks/claimset.json": {"version": 1, "hooks": {
|
|
316
|
+
"preToolUse": [{"type": "command", "bash": guard, "timeoutSec": 30}],
|
|
317
|
+
"sessionStart": [{"type": "command", "bash": studies, "timeoutSec": 30}],
|
|
318
|
+
}}}
|
|
319
|
+
if h.hooks == "cursor":
|
|
320
|
+
return {".cursor/hooks.json": {"version": 1, "hooks": {
|
|
321
|
+
"beforeShellExecution": [{"command": guard}],
|
|
322
|
+
"preToolUse": [{"command": guard, "matcher": "Shell|Write|Delete"}],
|
|
323
|
+
}}}
|
|
324
|
+
if h.hooks == "opencode":
|
|
325
|
+
js = (DATA / "opencode-guard.js").read_text(encoding="utf-8")
|
|
326
|
+
return {".opencode/plugins/claimset-guard.js": js.replace("{guard}", json.dumps(guard + " --agent opencode"))}
|
|
327
|
+
if h.hooks == "cline":
|
|
328
|
+
return {".clinerules/hooks/PreToolUse": f"#!/bin/sh\n# written by `claimset init`: the claimset guard "
|
|
329
|
+
f"(lab record §4.5)\nexec {guard} --agent cline\n"}
|
|
330
|
+
return {}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _merge(base, add):
|
|
334
|
+
"""Merge hook settings: lists of hook groups are extended with groups not already there."""
|
|
335
|
+
out = dict(base)
|
|
336
|
+
for k, v in add.items():
|
|
337
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
338
|
+
out[k] = _merge(out[k], v)
|
|
339
|
+
elif isinstance(v, list) and isinstance(out.get(k), list):
|
|
340
|
+
out[k] = out[k] + [x for x in v if x not in out[k]]
|
|
341
|
+
else:
|
|
342
|
+
out[k] = v
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _is_claimset_hook(group):
|
|
347
|
+
s = json.dumps(group)
|
|
348
|
+
return ("lab guard" in s or " studies" in s) and "claimset" in s
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _drop_claimset_hooks(settings):
|
|
352
|
+
"""The settings without hook entries an earlier `claimset init` wrote, so that a new guard command replaces
|
|
353
|
+
them instead of standing beside them."""
|
|
354
|
+
out = json.loads(json.dumps(settings))
|
|
355
|
+
hooks = out.get("hooks")
|
|
356
|
+
if isinstance(hooks, dict):
|
|
357
|
+
for event, groups in list(hooks.items()):
|
|
358
|
+
if isinstance(groups, list):
|
|
359
|
+
hooks[event] = [g for g in groups if not _is_claimset_hook(g)]
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def write_hooks(root, agent, guard):
|
|
364
|
+
"""Register the guard with one harness in the project at `root`. Returns [(path, what)]."""
|
|
365
|
+
done = []
|
|
366
|
+
for rel, content in hook_config(agent, guard).items():
|
|
367
|
+
path = Path(root) / rel
|
|
368
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
if isinstance(content, dict):
|
|
370
|
+
old = {}
|
|
371
|
+
if path.exists():
|
|
372
|
+
try:
|
|
373
|
+
old = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
374
|
+
except ValueError:
|
|
375
|
+
raise ValueError(f"{rel} is not valid JSON: fix it, then run `claimset init` again") from None
|
|
376
|
+
new = _merge(_drop_claimset_hooks(old), content)
|
|
377
|
+
text = json.dumps(new, indent=2, ensure_ascii=False) + "\n"
|
|
378
|
+
else:
|
|
379
|
+
text = content
|
|
380
|
+
before = path.read_text(encoding="utf-8") if path.exists() else None
|
|
381
|
+
if before != text:
|
|
382
|
+
path.write_text(text, encoding="utf-8")
|
|
383
|
+
if text.startswith("#!"):
|
|
384
|
+
path.chmod(0o755)
|
|
385
|
+
done.append((rel, "current" if before == text else "created" if before is None else "updated"))
|
|
386
|
+
return done
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------- the network a sandbox may withhold ----------
|
|
390
|
+
|
|
391
|
+
NETWORK_FAILURE = re.compile(r"could not resolve|resolve host|network is unreachable|connection refused|timed out|"
|
|
392
|
+
r"operation not permitted|temporary failure in name resolution|proxy|"
|
|
393
|
+
r"could not read from remote|unable to access|name or service not known", re.I)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def network_hint(message="", environ=None):
|
|
397
|
+
"""When a push or a call to the platform failed for want of network, how the harness running this shell lets
|
|
398
|
+
it through; "" when nothing points to a sandbox."""
|
|
399
|
+
env = os.environ if environ is None else environ
|
|
400
|
+
h = detect(env)
|
|
401
|
+
blocked = env.get("CODEX_SANDBOX_NETWORK_DISABLED") == "1"
|
|
402
|
+
if not (blocked or NETWORK_FAILURE.search(message or "")):
|
|
403
|
+
return ""
|
|
404
|
+
if h is not None and h.network:
|
|
405
|
+
return f" The agent's sandbox may be withholding the network: {h.network}."
|
|
406
|
+
return (" If an agent runs this command in a sandbox, the sandbox may be withholding the network: allow the git "
|
|
407
|
+
"host and entrylib.org in its settings (`claimset init --list` shows how for each agent).")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------- claimset init ----------
|
|
411
|
+
|
|
412
|
+
def init(root, agents, guard=False, skills=True, guard_command=None):
|
|
413
|
+
"""Set up the project at `root` for the harnesses `agents` (keys): skills in the fewest folders, the
|
|
414
|
+
instructions block in AGENTS.md and the files that import it, and, with `guard`, the guard's hooks. Returns
|
|
415
|
+
{"done": [(path, what)], "notes": [...]}."""
|
|
416
|
+
from . import bundle
|
|
417
|
+
hs = [harness(a) for a in agents]
|
|
418
|
+
root = Path(root)
|
|
419
|
+
done, notes = [], []
|
|
420
|
+
dirs = skill_dirs(hs)
|
|
421
|
+
if skills:
|
|
422
|
+
for d in dirs:
|
|
423
|
+
for name in bundle.SKILLS:
|
|
424
|
+
done.append((f"{d}/{name}", bundle.install(root / d, name)))
|
|
425
|
+
primary = SHARED_SKILLS if SHARED_SKILLS in dirs else (dirs[0] if dirs else SHARED_SKILLS)
|
|
426
|
+
done.append(("AGENTS.md", write_block(root / "AGENTS.md", instructions_block(primary))))
|
|
427
|
+
for h in hs:
|
|
428
|
+
if h.instructions != "AGENTS.md" and h.imports:
|
|
429
|
+
done.append((h.instructions, write_block(root / h.instructions, import_block(h.imports))))
|
|
430
|
+
if guard:
|
|
431
|
+
from .lab import guard as _guard
|
|
432
|
+
command = guard_command or _guard.guard_command()
|
|
433
|
+
for h in hs:
|
|
434
|
+
if h.hooks:
|
|
435
|
+
done += write_hooks(root, h.key, command)
|
|
436
|
+
else:
|
|
437
|
+
notes.append(f"{h.name} runs no command before a tool call, so it has no guard: the lab record is "
|
|
438
|
+
f"protected by anchoring and `claimset lab protect` (lab record §4.5).")
|
|
439
|
+
for h in hs:
|
|
440
|
+
notes += list(h.notes)
|
|
441
|
+
if not h.verified:
|
|
442
|
+
notes.append(f"What claimset knows of {h.name} rests on secondary sources: check that it reads "
|
|
443
|
+
f"{', '.join(h.skills) or 'its files'} and {h.instructions}.")
|
|
444
|
+
notes.append("If an agent cannot tell which model it is, set CLAIMSET_MODEL=\"provider|name|version|identifier\" "
|
|
445
|
+
"in the environment it runs in; `claimset lab actor` records it as reported by you.")
|
|
446
|
+
seen, out = set(), []
|
|
447
|
+
for x in done:
|
|
448
|
+
if x not in seen:
|
|
449
|
+
seen.add(x)
|
|
450
|
+
out.append(x)
|
|
451
|
+
return {"done": out, "notes": list(dict.fromkeys(notes)), "skills": dirs}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_init(args):
|
|
455
|
+
if args.list:
|
|
456
|
+
for h in HARNESSES:
|
|
457
|
+
print(f"{h.key:9} {h.name}{'' if h.verified else ' (unverified)'}: skills "
|
|
458
|
+
f"{', '.join(h.skills) or 'none'}; reads {h.instructions}; guard "
|
|
459
|
+
f"{'yes' if h.hooks else 'no'}" + (f"; network: {h.network}" if h.network else ""))
|
|
460
|
+
return 0
|
|
461
|
+
agents = args.agent or []
|
|
462
|
+
if "all" in agents:
|
|
463
|
+
agents = [h.key for h in HARNESSES if h.verified and h.key != "generic"]
|
|
464
|
+
if not agents:
|
|
465
|
+
found = detect()
|
|
466
|
+
agents = [found.key] if found else ["generic"]
|
|
467
|
+
print(f"agent: {found.name if found else 'none recognised; AGENTS.md and .agents/skills'} "
|
|
468
|
+
f"(--agent to choose; --list for all)")
|
|
469
|
+
try:
|
|
470
|
+
out = init(args.dir, agents, guard=args.guard, skills=not args.no_skills)
|
|
471
|
+
except (ValueError, OSError, FileExistsError) as exc:
|
|
472
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
473
|
+
return 1
|
|
474
|
+
for path, what in out["done"]:
|
|
475
|
+
print(f"{what:8} {path}")
|
|
476
|
+
for n in out["notes"]:
|
|
477
|
+
print(f"note: {n}")
|
|
478
|
+
if not args.guard:
|
|
479
|
+
print("note: the guard is not installed; `claimset init --guard` adds it for every agent above that "
|
|
480
|
+
"supports it. It restricts the agent, so the human decides.")
|
|
481
|
+
return 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def add_parsers(sub):
|
|
485
|
+
p = sub.add_parser("init", help="set a project up for coding agents: the skills, the instructions block in "
|
|
486
|
+
"AGENTS.md (imported by CLAUDE.md and GEMINI.md) and, with --guard, the lab "
|
|
487
|
+
"record's guard")
|
|
488
|
+
p.add_argument("--agent", action="append", metavar="NAME",
|
|
489
|
+
help="an agent to set up for; repeat for several, or `all` (default: the one running this "
|
|
490
|
+
"shell, else a generic setup); `--list` shows them")
|
|
491
|
+
p.add_argument("--guard", action="store_true", help="also install the guard hooks (lab record §4.5)")
|
|
492
|
+
p.add_argument("--no-skills", action="store_true", help="only the instructions (and hooks)")
|
|
493
|
+
p.add_argument("--dir", default=".", help="the project's top folder (default: here)")
|
|
494
|
+
p.add_argument("--list", action="store_true", help="the agents claimset knows, and what each reads")
|
|
495
|
+
p.set_defaults(func=cmd_init)
|