runops 0.2.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.
- runops/__init__.py +5 -0
- runops/_data/README.md +476 -0
- runops/adapters/__init__.py +29 -0
- runops/adapters/_utils/__init__.py +36 -0
- runops/adapters/_utils/toml_utils.py +81 -0
- runops/adapters/base.py +335 -0
- runops/adapters/contrib/__init__.py +5 -0
- runops/adapters/contrib/beach.py +837 -0
- runops/adapters/contrib/emses.py +1010 -0
- runops/adapters/generic.py +439 -0
- runops/adapters/registry.py +244 -0
- runops/cli/__init__.py +3 -0
- runops/cli/analyze.py +222 -0
- runops/cli/clone.py +104 -0
- runops/cli/config.py +217 -0
- runops/cli/context.py +56 -0
- runops/cli/create.py +263 -0
- runops/cli/dashboard.py +179 -0
- runops/cli/extend.py +204 -0
- runops/cli/history.py +105 -0
- runops/cli/init.py +1432 -0
- runops/cli/jobs.py +145 -0
- runops/cli/knowledge.py +1017 -0
- runops/cli/list.py +102 -0
- runops/cli/log.py +163 -0
- runops/cli/main.py +96 -0
- runops/cli/manage.py +231 -0
- runops/cli/new.py +343 -0
- runops/cli/notes.py +257 -0
- runops/cli/run_lookup.py +148 -0
- runops/cli/setup.py +174 -0
- runops/cli/status.py +187 -0
- runops/cli/submit.py +297 -0
- runops/cli/update.py +113 -0
- runops/cli/update_harness.py +245 -0
- runops/cli/update_refs.py +370 -0
- runops/core/__init__.py +3 -0
- runops/core/actions.py +1186 -0
- runops/core/analysis.py +1090 -0
- runops/core/campaign.py +156 -0
- runops/core/case.py +307 -0
- runops/core/context.py +426 -0
- runops/core/discovery.py +192 -0
- runops/core/environment.py +266 -0
- runops/core/exceptions.py +93 -0
- runops/core/knowledge.py +595 -0
- runops/core/knowledge_source.py +1204 -0
- runops/core/manifest.py +219 -0
- runops/core/project.py +171 -0
- runops/core/provenance.py +147 -0
- runops/core/retry.py +193 -0
- runops/core/run.py +170 -0
- runops/core/run_creation.py +456 -0
- runops/core/site.py +337 -0
- runops/core/state.py +197 -0
- runops/core/survey.py +380 -0
- runops/core/validation.py +40 -0
- runops/harness/__init__.py +27 -0
- runops/harness/builder.py +327 -0
- runops/harness/claude.py +189 -0
- runops/jobgen/__init__.py +3 -0
- runops/jobgen/generator.py +295 -0
- runops/launchers/__init__.py +17 -0
- runops/launchers/base.py +313 -0
- runops/launchers/mpiexec.py +131 -0
- runops/launchers/mpirun.py +132 -0
- runops/launchers/srun.py +126 -0
- runops/sites/__init__.py +0 -0
- runops/sites/camphor.md +98 -0
- runops/sites/camphor.toml +27 -0
- runops/slurm/__init__.py +3 -0
- runops/slurm/query.py +384 -0
- runops/slurm/submit.py +203 -0
- runops/templates/__init__.py +29 -0
- runops/templates/adapters/beach/agent_guide.md +50 -0
- runops/templates/adapters/beach/beach.toml +19 -0
- runops/templates/adapters/beach/case.toml +16 -0
- runops/templates/adapters/beach/summarize.py +272 -0
- runops/templates/adapters/emses/agent_guide.md +39 -0
- runops/templates/adapters/emses/case.toml +18 -0
- runops/templates/adapters/emses/plasma.toml +118 -0
- runops/templates/adapters/emses/summarize.py +413 -0
- runops/templates/adapters/generic/case.toml.j2 +13 -0
- runops/templates/adapters/generic/summarize.py +21 -0
- runops/templates/agent.md +156 -0
- runops/templates/rules/cookbook.md +22 -0
- runops/templates/scaffold/campaign.toml.j2 +10 -0
- runops/templates/scaffold/cases_claude.md +22 -0
- runops/templates/scaffold/facts.toml +2 -0
- runops/templates/scaffold/gitignore.txt +30 -0
- runops/templates/scaffold/notes/README.md +69 -0
- runops/templates/scaffold/rules/plan-before-act.md +17 -0
- runops/templates/scaffold/rules/runops-workflow.md +84 -0
- runops/templates/scaffold/rules/upstream-feedback.md +85 -0
- runops/templates/scaffold/runs_claude.md +24 -0
- runops/templates/scaffold/vscode_settings.json +9 -0
- runops/templates/skills/analyze/SKILL.md +40 -0
- runops/templates/skills/check-status/SKILL.md +29 -0
- runops/templates/skills/cleanup/SKILL.md +43 -0
- runops/templates/skills/create-run/SKILL.md +135 -0
- runops/templates/skills/debug-failed/SKILL.md +38 -0
- runops/templates/skills/learn/SKILL.md +54 -0
- runops/templates/skills/new-case/SKILL.md +108 -0
- runops/templates/skills/note/SKILL.md +107 -0
- runops/templates/skills/run-all/SKILL.md +47 -0
- runops/templates/skills/runops-reference/SKILL.md +203 -0
- runops/templates/skills/setup-campaign/SKILL.md +111 -0
- runops/templates/skills/setup-env/SKILL.md +32 -0
- runops/templates/skills/survey-design/SKILL.md +73 -0
- runops/templates/survey.toml.j2 +22 -0
- runops-0.2.0.dist-info/METADATA +491 -0
- runops-0.2.0.dist-info/RECORD +115 -0
- runops-0.2.0.dist-info/WHEEL +4 -0
- runops-0.2.0.dist-info/entry_points.txt +2 -0
- runops-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Harness file generation shared by ``runops init`` and ``runops update-harness``.
|
|
2
|
+
|
|
3
|
+
The builder renders every harness file (``CLAUDE.md``, ``AGENTS.md``,
|
|
4
|
+
``.claude/skills/*``, ``.claude/rules/*``, ``.claude/settings.json``,
|
|
5
|
+
``cases/CLAUDE.md``, ``runs/CLAUDE.md``) into an in-memory mapping of
|
|
6
|
+
``relative_path -> rendered_content``.
|
|
7
|
+
|
|
8
|
+
``runops init`` iterates the mapping and writes each file (``_write_if_missing``),
|
|
9
|
+
then persists template hashes to ``.runops/harness.lock``. ``runops update-harness``
|
|
10
|
+
re-renders the same mapping, compares the current on-disk hash against the lock
|
|
11
|
+
to detect user edits, and then overwrites / emits ``.new`` files accordingly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
import tomllib
|
|
25
|
+
else:
|
|
26
|
+
import tomli as tomllib
|
|
27
|
+
|
|
28
|
+
HARNESS_LOCK_PATH = ".runops/harness.lock"
|
|
29
|
+
|
|
30
|
+
# File paths that `build_harness_bundle` may emit, as project-relative strings.
|
|
31
|
+
CLAUDE_MD = "CLAUDE.md"
|
|
32
|
+
AGENTS_MD = "AGENTS.md"
|
|
33
|
+
CLAUDE_SETTINGS = ".claude/settings.json"
|
|
34
|
+
CASES_CLAUDE_MD = "cases/CLAUDE.md"
|
|
35
|
+
RUNS_CLAUDE_MD = "runs/CLAUDE.md"
|
|
36
|
+
RULE_WORKFLOW = ".claude/rules/runops-workflow.md"
|
|
37
|
+
RULE_PLAN_BEFORE_ACT = ".claude/rules/plan-before-act.md"
|
|
38
|
+
RULE_COOKBOOK = ".claude/rules/cookbook.md"
|
|
39
|
+
RULE_UPSTREAM_FEEDBACK = ".claude/rules/upstream-feedback.md"
|
|
40
|
+
|
|
41
|
+
# Files that update-harness is allowed to touch. Any other file under the
|
|
42
|
+
# project root (campaign.toml, cases/**, runs/**, etc.) is user-owned and
|
|
43
|
+
# must never be rewritten by update-harness.
|
|
44
|
+
_ALL_HARNESS_PREFIXES: tuple[str, ...] = (
|
|
45
|
+
CLAUDE_MD,
|
|
46
|
+
AGENTS_MD,
|
|
47
|
+
CLAUDE_SETTINGS,
|
|
48
|
+
CASES_CLAUDE_MD,
|
|
49
|
+
RUNS_CLAUDE_MD,
|
|
50
|
+
".claude/skills/",
|
|
51
|
+
".claude/rules/",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_harness_path(rel_path: str) -> bool:
|
|
56
|
+
"""Return True if ``rel_path`` is owned by the harness builder."""
|
|
57
|
+
normalized = rel_path.replace("\\", "/")
|
|
58
|
+
return any(
|
|
59
|
+
normalized == prefix or normalized.startswith(prefix)
|
|
60
|
+
for prefix in _ALL_HARNESS_PREFIXES
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class HarnessBundle:
|
|
66
|
+
"""Rendered harness file contents keyed by project-relative path."""
|
|
67
|
+
|
|
68
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
upstream_feedback: bool = True
|
|
70
|
+
|
|
71
|
+
def hashes(self) -> dict[str, str]:
|
|
72
|
+
"""Return ``{relative_path: sha256}`` for every file in the bundle."""
|
|
73
|
+
return {rel: hash_text(content) for rel, content in self.files.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hash_text(text: str) -> str:
|
|
77
|
+
"""Return hex sha256 of ``text`` encoded as UTF-8."""
|
|
78
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def hash_file(path: Path) -> str | None:
|
|
82
|
+
"""Return hex sha256 of the file at ``path``, or ``None`` if unreadable."""
|
|
83
|
+
try:
|
|
84
|
+
return hash_text(path.read_text(encoding="utf-8"))
|
|
85
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Adapter lookups (private to harness builder; duplicated here to avoid a
|
|
91
|
+
# circular import with runops.cli.init which also depends on this module).
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collect_doc_repos(simulator_names: list[str]) -> list[tuple[str, str]]:
|
|
96
|
+
"""Return unique ``(url, dest)`` pairs from the given adapters."""
|
|
97
|
+
import runops.adapters # noqa: F401
|
|
98
|
+
from runops.adapters.registry import get_global_registry
|
|
99
|
+
|
|
100
|
+
registry = get_global_registry()
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
repos: list[tuple[str, str]] = []
|
|
103
|
+
for sim_name in simulator_names:
|
|
104
|
+
try:
|
|
105
|
+
adapter_cls = registry.get(sim_name)
|
|
106
|
+
except KeyError:
|
|
107
|
+
continue
|
|
108
|
+
for url, dest in adapter_cls.doc_repos():
|
|
109
|
+
if dest in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(dest)
|
|
112
|
+
repos.append((url, dest))
|
|
113
|
+
return repos
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _collect_pip_packages(simulator_names: list[str]) -> list[str]:
|
|
117
|
+
"""Return unique pip packages declared by the given adapters."""
|
|
118
|
+
import runops.adapters # noqa: F401
|
|
119
|
+
from runops.adapters.registry import get_global_registry
|
|
120
|
+
|
|
121
|
+
registry = get_global_registry()
|
|
122
|
+
seen: set[str] = set()
|
|
123
|
+
packages: list[str] = []
|
|
124
|
+
for sim_name in simulator_names:
|
|
125
|
+
try:
|
|
126
|
+
adapter_cls = registry.get(sim_name)
|
|
127
|
+
except KeyError:
|
|
128
|
+
continue
|
|
129
|
+
for pkg in adapter_cls.pip_packages():
|
|
130
|
+
if pkg in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(pkg)
|
|
133
|
+
packages.append(pkg)
|
|
134
|
+
return packages
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Individual file renderers
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_agent_md(
|
|
143
|
+
doc_name: str,
|
|
144
|
+
project_name: str,
|
|
145
|
+
simulator_names: list[str],
|
|
146
|
+
*,
|
|
147
|
+
knowledge_imports_path: str,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Render the shared ``agent.md`` Jinja template for CLAUDE/AGENTS md."""
|
|
150
|
+
from runops.templates import get_jinja_env
|
|
151
|
+
|
|
152
|
+
env = get_jinja_env()
|
|
153
|
+
template = env.get_template("agent.md")
|
|
154
|
+
return template.render(
|
|
155
|
+
doc_name=doc_name,
|
|
156
|
+
project_name=project_name,
|
|
157
|
+
doc_repos=_collect_doc_repos(simulator_names) if simulator_names else [],
|
|
158
|
+
knowledge_imports_path=knowledge_imports_path,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _render_skill_files(
|
|
163
|
+
project_name: str,
|
|
164
|
+
simulator_names: list[str],
|
|
165
|
+
) -> dict[str, str]:
|
|
166
|
+
"""Return ``{"<skill-name>/SKILL.md": content}`` for all bundled skills."""
|
|
167
|
+
pip_pkgs = _collect_pip_packages(simulator_names) if simulator_names else []
|
|
168
|
+
if pip_pkgs:
|
|
169
|
+
pip_install_line = f"uv pip install {' '.join(pip_pkgs)}"
|
|
170
|
+
else:
|
|
171
|
+
pip_install_line = "# uv pip install <必要なパッケージ>"
|
|
172
|
+
|
|
173
|
+
skills_dir = Path(__file__).resolve().parent.parent / "templates" / "skills"
|
|
174
|
+
results: dict[str, str] = {}
|
|
175
|
+
for skill_path in sorted(skills_dir.iterdir()):
|
|
176
|
+
if not skill_path.is_dir():
|
|
177
|
+
continue
|
|
178
|
+
skill_md = skill_path / "SKILL.md"
|
|
179
|
+
if not skill_md.exists():
|
|
180
|
+
continue
|
|
181
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
182
|
+
if "{{" in content:
|
|
183
|
+
from runops.templates import get_jinja_env
|
|
184
|
+
|
|
185
|
+
env = get_jinja_env()
|
|
186
|
+
template = env.from_string(content)
|
|
187
|
+
content = template.render(
|
|
188
|
+
project_name=project_name,
|
|
189
|
+
pip_install_line=pip_install_line,
|
|
190
|
+
)
|
|
191
|
+
results[f"{skill_path.name}/SKILL.md"] = content
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Main entry points
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_harness_bundle(
|
|
201
|
+
project_name: str,
|
|
202
|
+
simulator_names: list[str],
|
|
203
|
+
*,
|
|
204
|
+
upstream_feedback: bool = True,
|
|
205
|
+
knowledge_imports_path: str = "",
|
|
206
|
+
) -> HarnessBundle:
|
|
207
|
+
"""Render every harness file into an in-memory bundle.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
project_name: Project name used in CLAUDE.md / AGENTS.md headers.
|
|
211
|
+
simulator_names: Simulator adapter names (e.g. ``["emses", "beach"]``).
|
|
212
|
+
upstream_feedback: Include the ``.claude/rules/upstream-feedback.md``
|
|
213
|
+
rule. ``runops init --no-upstream-feedback`` sets this to False.
|
|
214
|
+
knowledge_imports_path: Relative path to the rendered imports file,
|
|
215
|
+
or empty string if knowledge imports are not configured.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Bundle keyed by project-relative path; consumers iterate it to write
|
|
219
|
+
files or to compute template hashes for ``.runops/harness.lock``.
|
|
220
|
+
"""
|
|
221
|
+
from runops.harness import build_claude_settings
|
|
222
|
+
from runops.templates import load_static
|
|
223
|
+
|
|
224
|
+
files: dict[str, str] = {}
|
|
225
|
+
|
|
226
|
+
files[CLAUDE_MD] = _render_agent_md(
|
|
227
|
+
CLAUDE_MD,
|
|
228
|
+
project_name,
|
|
229
|
+
simulator_names,
|
|
230
|
+
knowledge_imports_path=knowledge_imports_path,
|
|
231
|
+
)
|
|
232
|
+
files[AGENTS_MD] = _render_agent_md(
|
|
233
|
+
AGENTS_MD,
|
|
234
|
+
project_name,
|
|
235
|
+
simulator_names,
|
|
236
|
+
knowledge_imports_path=knowledge_imports_path,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
files[CLAUDE_SETTINGS] = build_claude_settings()
|
|
240
|
+
|
|
241
|
+
for rel_path, content in _render_skill_files(project_name, simulator_names).items():
|
|
242
|
+
files[f".claude/skills/{rel_path}"] = content
|
|
243
|
+
|
|
244
|
+
files[RULE_WORKFLOW] = load_static("scaffold/rules/runops-workflow.md")
|
|
245
|
+
files[RULE_PLAN_BEFORE_ACT] = load_static("scaffold/rules/plan-before-act.md")
|
|
246
|
+
if simulator_names and _collect_doc_repos(simulator_names):
|
|
247
|
+
files[RULE_COOKBOOK] = load_static("rules/cookbook.md")
|
|
248
|
+
if upstream_feedback:
|
|
249
|
+
files[RULE_UPSTREAM_FEEDBACK] = load_static(
|
|
250
|
+
"scaffold/rules/upstream-feedback.md"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
files[CASES_CLAUDE_MD] = load_static("scaffold/cases_claude.md")
|
|
254
|
+
files[RUNS_CLAUDE_MD] = load_static("scaffold/runs_claude.md")
|
|
255
|
+
|
|
256
|
+
return HarnessBundle(files=files, upstream_feedback=upstream_feedback)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# harness.lock persistence
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_harness_lock(project_dir: Path) -> dict[str, str]:
|
|
265
|
+
"""Load the ``harness.lock`` mapping ``relative_path -> template sha256``.
|
|
266
|
+
|
|
267
|
+
Returns an empty dict when the lock file is missing, malformed, or when
|
|
268
|
+
the project predates this feature.
|
|
269
|
+
"""
|
|
270
|
+
lock_path = project_dir / HARNESS_LOCK_PATH
|
|
271
|
+
if not lock_path.is_file():
|
|
272
|
+
return {}
|
|
273
|
+
try:
|
|
274
|
+
raw = json.loads(lock_path.read_text(encoding="utf-8"))
|
|
275
|
+
except (json.JSONDecodeError, OSError):
|
|
276
|
+
return {}
|
|
277
|
+
if not isinstance(raw, dict):
|
|
278
|
+
return {}
|
|
279
|
+
hashes = raw.get("hashes")
|
|
280
|
+
if not isinstance(hashes, dict):
|
|
281
|
+
return {}
|
|
282
|
+
return {
|
|
283
|
+
str(k): str(v)
|
|
284
|
+
for k, v in hashes.items()
|
|
285
|
+
if isinstance(k, str) and isinstance(v, str)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def save_harness_lock(project_dir: Path, hashes: dict[str, str]) -> None:
|
|
290
|
+
"""Write the harness lock with sorted entries for stable diffs."""
|
|
291
|
+
lock_path = project_dir / HARNESS_LOCK_PATH
|
|
292
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
payload: dict[str, Any] = {
|
|
294
|
+
"version": 1,
|
|
295
|
+
"hashes": dict(sorted(hashes.items())),
|
|
296
|
+
}
|
|
297
|
+
lock_path.write_text(
|
|
298
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
299
|
+
encoding="utf-8",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# runops.toml [harness] helpers
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def read_upstream_feedback_setting(project_dir: Path) -> bool:
|
|
309
|
+
"""Return ``[harness].upstream_feedback`` from runops.toml.
|
|
310
|
+
|
|
311
|
+
Defaults to True when the key, table, or file is absent so that projects
|
|
312
|
+
predating this feature get upstream-feedback guidance by default once they
|
|
313
|
+
run ``runops update-harness``.
|
|
314
|
+
"""
|
|
315
|
+
project_file = project_dir / "runops.toml"
|
|
316
|
+
if not project_file.is_file():
|
|
317
|
+
return True
|
|
318
|
+
try:
|
|
319
|
+
with open(project_file, "rb") as f:
|
|
320
|
+
data = tomllib.load(f)
|
|
321
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
322
|
+
return True
|
|
323
|
+
harness = data.get("harness")
|
|
324
|
+
if not isinstance(harness, dict):
|
|
325
|
+
return True
|
|
326
|
+
value = harness.get("upstream_feedback", True)
|
|
327
|
+
return bool(value)
|
runops/harness/claude.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Claude Code harness policy and settings generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
# Bash commands the agent can run without confirmation.
|
|
9
|
+
#
|
|
10
|
+
# We deliberately keep ASK_BASH small (see _ASK_BASH below): the only Bash
|
|
11
|
+
# commands that should prompt are ones that spend HPC resources or destroy
|
|
12
|
+
# files irreversibly. Everything else, including knowledge sync, ref
|
|
13
|
+
# updates, and local git commits, lives here.
|
|
14
|
+
_ALLOW_BASH: Final[tuple[str, ...]] = (
|
|
15
|
+
# Read-only inspection
|
|
16
|
+
"Bash(runops --help*)",
|
|
17
|
+
"Bash(runops --version*)",
|
|
18
|
+
"Bash(runops context*)",
|
|
19
|
+
"Bash(runops runs list*)",
|
|
20
|
+
"Bash(runops runs status*)",
|
|
21
|
+
"Bash(runops runs sync*)",
|
|
22
|
+
"Bash(runops runs jobs*)",
|
|
23
|
+
"Bash(runops runs dashboard*)",
|
|
24
|
+
"Bash(runops runs history*)",
|
|
25
|
+
"Bash(runops runs log*)",
|
|
26
|
+
"Bash(runops doctor*)",
|
|
27
|
+
"Bash(runops config show*)",
|
|
28
|
+
# Generation (cheap, reversible by deleting the new files)
|
|
29
|
+
"Bash(runops case new *)",
|
|
30
|
+
"Bash(runops runs create *)",
|
|
31
|
+
"Bash(runops runs sweep *)",
|
|
32
|
+
"Bash(runops runs clone *)",
|
|
33
|
+
"Bash(runops runs extend *)",
|
|
34
|
+
# Analysis (read + write into analysis/)
|
|
35
|
+
"Bash(runops analyze summarize*)",
|
|
36
|
+
"Bash(runops analyze collect*)",
|
|
37
|
+
"Bash(runops analyze plot*)",
|
|
38
|
+
# Knowledge management (mutates .runops/knowledge/ via runops, reversible)
|
|
39
|
+
"Bash(runops knowledge list*)",
|
|
40
|
+
"Bash(runops knowledge show*)",
|
|
41
|
+
"Bash(runops knowledge facts*)",
|
|
42
|
+
"Bash(runops knowledge save*)",
|
|
43
|
+
"Bash(runops knowledge add-fact*)",
|
|
44
|
+
"Bash(runops knowledge promote-fact*)",
|
|
45
|
+
"Bash(runops knowledge source list*)",
|
|
46
|
+
"Bash(runops knowledge source status*)",
|
|
47
|
+
"Bash(runops knowledge source attach*)",
|
|
48
|
+
"Bash(runops knowledge source detach*)",
|
|
49
|
+
"Bash(runops knowledge source sync*)",
|
|
50
|
+
"Bash(runops knowledge source render*)",
|
|
51
|
+
# Notes (lab notebook, append-only by design)
|
|
52
|
+
"Bash(runops notes append*)",
|
|
53
|
+
"Bash(runops notes list*)",
|
|
54
|
+
"Bash(runops notes show*)",
|
|
55
|
+
# Refs / config additions (mutates the corresponding TOML, which is
|
|
56
|
+
# itself ask-listed below — the resulting prompt happens once, not twice)
|
|
57
|
+
"Bash(runops update-harness*)",
|
|
58
|
+
"Bash(runops update-refs*)",
|
|
59
|
+
"Bash(runops config add-simulator*)",
|
|
60
|
+
"Bash(runops config add-launcher*)",
|
|
61
|
+
# Lifecycle move that does not delete data
|
|
62
|
+
"Bash(runops runs archive*)",
|
|
63
|
+
"Bash(runops runs cancel*)",
|
|
64
|
+
# Dev tooling
|
|
65
|
+
"Bash(uv run pytest*)",
|
|
66
|
+
"Bash(uv run ruff*)",
|
|
67
|
+
"Bash(uv run mypy*)",
|
|
68
|
+
"Bash(source .venv/bin/activate*)",
|
|
69
|
+
"Bash(cat runs/*/work/*.out*)",
|
|
70
|
+
"Bash(cat runs/*/work/*.err*)",
|
|
71
|
+
# Git: read-only and local commits. Pushes are not auto-allowed; the
|
|
72
|
+
# agent system prompt already says to only commit when explicitly asked.
|
|
73
|
+
"Bash(git status*)",
|
|
74
|
+
"Bash(git log*)",
|
|
75
|
+
"Bash(git diff*)",
|
|
76
|
+
"Bash(git commit*)",
|
|
77
|
+
)
|
|
78
|
+
# Bash commands that must always prompt the user. Keep this list as short
|
|
79
|
+
# as possible — every entry here trains the user to dismiss prompts.
|
|
80
|
+
_ASK_BASH: Final[tuple[str, ...]] = (
|
|
81
|
+
"Bash(runops runs submit*)", # spends HPC resources
|
|
82
|
+
"Bash(runops runs purge-work*)", # deletes work/ files irreversibly
|
|
83
|
+
"Bash(runops runs delete*)", # removes run directory irreversibly
|
|
84
|
+
)
|
|
85
|
+
_DENY_BASH: Final[tuple[str, ...]] = (
|
|
86
|
+
"Bash(rm -rf *)",
|
|
87
|
+
"Bash(git push --force*)",
|
|
88
|
+
"Bash(git reset --hard*)",
|
|
89
|
+
)
|
|
90
|
+
# Paths the agent may freely Edit/Write without confirmation.
|
|
91
|
+
# tools/runops/** is included so projects that dev-install the runops
|
|
92
|
+
# repo from tools/runops/ can iterate on it without per-edit prompts.
|
|
93
|
+
# .claude/{rules,skills,commands}/** are allowed because they are
|
|
94
|
+
# documentation-style files. Only the actual policy files
|
|
95
|
+
# (.claude/settings.json, .claude/hooks/**) require confirmation.
|
|
96
|
+
_ALLOW_EDIT_PATHS: Final[tuple[str, ...]] = (
|
|
97
|
+
"/campaign.toml",
|
|
98
|
+
"/cases/**",
|
|
99
|
+
"/surveys/**",
|
|
100
|
+
"/runs/**/survey.toml",
|
|
101
|
+
"/docs/**",
|
|
102
|
+
"/notes/**",
|
|
103
|
+
"/README.md",
|
|
104
|
+
"/tools/runops/**",
|
|
105
|
+
"/.claude/rules/**",
|
|
106
|
+
"/.claude/skills/**",
|
|
107
|
+
"/.claude/commands/**",
|
|
108
|
+
"/.vscode/**",
|
|
109
|
+
"/.idea/**",
|
|
110
|
+
)
|
|
111
|
+
# Edit/Write paths that always prompt. Limited to project-defining and
|
|
112
|
+
# agent-behaviour-defining files.
|
|
113
|
+
_ASK_EDIT_PATHS: Final[tuple[str, ...]] = (
|
|
114
|
+
"/runops.toml",
|
|
115
|
+
"/simulators.toml",
|
|
116
|
+
"/launchers.toml",
|
|
117
|
+
"/CLAUDE.md",
|
|
118
|
+
"/AGENTS.md",
|
|
119
|
+
"/**/CLAUDE.md",
|
|
120
|
+
"/.claude/settings.json",
|
|
121
|
+
"/.claude/settings.local.json",
|
|
122
|
+
"/.claude/hooks/**",
|
|
123
|
+
)
|
|
124
|
+
_DENY_EDIT_PATHS: Final[tuple[str, ...]] = (
|
|
125
|
+
"/SITE.md",
|
|
126
|
+
"/runs/**/manifest.toml",
|
|
127
|
+
"/runs/**/input/**",
|
|
128
|
+
"/runs/**/submit/**",
|
|
129
|
+
"/runs/**/work/**",
|
|
130
|
+
"/runs/**/status/**",
|
|
131
|
+
"/runs/**/analysis/**",
|
|
132
|
+
"/.runops/environment.toml",
|
|
133
|
+
"/.runops/knowledge/**",
|
|
134
|
+
"/.runops/insights/**",
|
|
135
|
+
"/.runops/facts.toml",
|
|
136
|
+
"/refs/**",
|
|
137
|
+
"/.venv/**",
|
|
138
|
+
"/.git/**",
|
|
139
|
+
)
|
|
140
|
+
_DENY_READ_PATHS: Final[tuple[str, ...]] = (
|
|
141
|
+
"/.env",
|
|
142
|
+
"/.env.*",
|
|
143
|
+
"/secrets/**",
|
|
144
|
+
"~/.ssh/**",
|
|
145
|
+
"~/.aws/credentials",
|
|
146
|
+
"~/.config/gcloud/**",
|
|
147
|
+
"~/.kube/config",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _build_permission_rules(
|
|
152
|
+
tools: tuple[str, ...],
|
|
153
|
+
patterns: tuple[str, ...],
|
|
154
|
+
) -> list[str]:
|
|
155
|
+
"""Expand tool/path combinations into Claude permission rule strings."""
|
|
156
|
+
rules: list[str] = []
|
|
157
|
+
for tool in tools:
|
|
158
|
+
rules.extend(f"{tool}({pattern})" for pattern in patterns)
|
|
159
|
+
return rules
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_claude_settings() -> str:
|
|
163
|
+
"""Build team-shared Claude Code settings for runops projects.
|
|
164
|
+
|
|
165
|
+
The returned settings declare allow / ask / deny rules only. Behavioural
|
|
166
|
+
expectations that earlier versions enforced via PreToolUse hooks (submit
|
|
167
|
+
approval, run-directory protection, Bash write guards) are now documented
|
|
168
|
+
in ``.claude/rules/runops-workflow.md`` so they remain visible to the
|
|
169
|
+
agent without forcing per-action shell hooks on the user.
|
|
170
|
+
"""
|
|
171
|
+
allow_rules = list(_ALLOW_BASH)
|
|
172
|
+
allow_rules.extend(_build_permission_rules(("Edit", "Write"), _ALLOW_EDIT_PATHS))
|
|
173
|
+
|
|
174
|
+
ask_rules = list(_ASK_BASH)
|
|
175
|
+
ask_rules.extend(_build_permission_rules(("Edit", "Write"), _ASK_EDIT_PATHS))
|
|
176
|
+
|
|
177
|
+
deny_rules = list(_DENY_BASH)
|
|
178
|
+
deny_rules.extend(_build_permission_rules(("Edit", "Write"), _DENY_EDIT_PATHS))
|
|
179
|
+
deny_rules.extend(_build_permission_rules(("Read",), _DENY_READ_PATHS))
|
|
180
|
+
|
|
181
|
+
settings = {
|
|
182
|
+
"permissions": {
|
|
183
|
+
"allow": allow_rules,
|
|
184
|
+
"ask": ask_rules,
|
|
185
|
+
"deny": deny_rules,
|
|
186
|
+
"disableBypassPermissionsMode": "disable",
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
return json.dumps(settings, ensure_ascii=False, indent=2) + "\n"
|