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.
Files changed (55) hide show
  1. claimset/__init__.py +12 -0
  2. claimset/__main__.py +7 -0
  3. claimset/agents.py +495 -0
  4. claimset/bundle.py +198 -0
  5. claimset/claim.py +79 -0
  6. claimset/cli.py +345 -0
  7. claimset/data/agents/AGENTS.md +16 -0
  8. claimset/data/agents/opencode-guard.js +13 -0
  9. claimset/data/examples/published-native.json +285 -0
  10. claimset/data/examples/submission-from-lab.json +507 -0
  11. claimset/data/examples/submission-native.json +271 -0
  12. claimset/data/skills/claimset-lab/SKILL.md +266 -0
  13. claimset/data/skills/claimset-lab/claimset +16 -0
  14. claimset/data/skills/claimset-review/SKILL.md +183 -0
  15. claimset/data/skills/claimset-revise/SKILL.md +87 -0
  16. claimset/data/skills/claimset-submit/SKILL.md +347 -0
  17. claimset/data/spec/claimset-revision-v0.6.md +561 -0
  18. claimset/data/spec/claimset-v0.40.md +1896 -0
  19. claimset/data/spec/extensions/README.md +23 -0
  20. claimset/data/spec/extensions/linguistics-v0.1.md +37 -0
  21. claimset/data/spec/extensions/qualitative-v0.1.md +117 -0
  22. claimset/data/spec/lab-record-v0.21.md +904 -0
  23. claimset/extensions/__init__.py +34 -0
  24. claimset/extensions/linguistics_v0_1.py +52 -0
  25. claimset/extensions/qualitative_count.py +168 -0
  26. claimset/extensions/qualitative_v0_1.py +96 -0
  27. claimset/lab/__init__.py +3 -0
  28. claimset/lab/anchor.py +508 -0
  29. claimset/lab/cli.py +1195 -0
  30. claimset/lab/core.py +2044 -0
  31. claimset/lab/decision.py +396 -0
  32. claimset/lab/export.py +1051 -0
  33. claimset/lab/guard.py +212 -0
  34. claimset/lab/layout.py +171 -0
  35. claimset/lab/protect.py +43 -0
  36. claimset/lab/qualitative.py +350 -0
  37. claimset/lab/record.py +578 -0
  38. claimset/lab/run.py +249 -0
  39. claimset/lab/sources.py +361 -0
  40. claimset/lab/summary.py +216 -0
  41. claimset/migrate.py +193 -0
  42. claimset/package.py +101 -0
  43. claimset/render.py +402 -0
  44. claimset/renumber.py +93 -0
  45. claimset/revision.py +1292 -0
  46. claimset/submit.py +168 -0
  47. claimset/validate.py +3330 -0
  48. claimset/versions/__init__.py +21 -0
  49. claimset/versions/v0_39.py +3288 -0
  50. claimset-0.40.0.dist-info/METADATA +198 -0
  51. claimset-0.40.0.dist-info/RECORD +55 -0
  52. claimset-0.40.0.dist-info/WHEEL +5 -0
  53. claimset-0.40.0.dist-info/entry_points.txt +2 -0
  54. claimset-0.40.0.dist-info/licenses/LICENSE +201 -0
  55. 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
@@ -0,0 +1,7 @@
1
+ """Allow `python -m claimset`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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)