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.
Files changed (115) hide show
  1. runops/__init__.py +5 -0
  2. runops/_data/README.md +476 -0
  3. runops/adapters/__init__.py +29 -0
  4. runops/adapters/_utils/__init__.py +36 -0
  5. runops/adapters/_utils/toml_utils.py +81 -0
  6. runops/adapters/base.py +335 -0
  7. runops/adapters/contrib/__init__.py +5 -0
  8. runops/adapters/contrib/beach.py +837 -0
  9. runops/adapters/contrib/emses.py +1010 -0
  10. runops/adapters/generic.py +439 -0
  11. runops/adapters/registry.py +244 -0
  12. runops/cli/__init__.py +3 -0
  13. runops/cli/analyze.py +222 -0
  14. runops/cli/clone.py +104 -0
  15. runops/cli/config.py +217 -0
  16. runops/cli/context.py +56 -0
  17. runops/cli/create.py +263 -0
  18. runops/cli/dashboard.py +179 -0
  19. runops/cli/extend.py +204 -0
  20. runops/cli/history.py +105 -0
  21. runops/cli/init.py +1432 -0
  22. runops/cli/jobs.py +145 -0
  23. runops/cli/knowledge.py +1017 -0
  24. runops/cli/list.py +102 -0
  25. runops/cli/log.py +163 -0
  26. runops/cli/main.py +96 -0
  27. runops/cli/manage.py +231 -0
  28. runops/cli/new.py +343 -0
  29. runops/cli/notes.py +257 -0
  30. runops/cli/run_lookup.py +148 -0
  31. runops/cli/setup.py +174 -0
  32. runops/cli/status.py +187 -0
  33. runops/cli/submit.py +297 -0
  34. runops/cli/update.py +113 -0
  35. runops/cli/update_harness.py +245 -0
  36. runops/cli/update_refs.py +370 -0
  37. runops/core/__init__.py +3 -0
  38. runops/core/actions.py +1186 -0
  39. runops/core/analysis.py +1090 -0
  40. runops/core/campaign.py +156 -0
  41. runops/core/case.py +307 -0
  42. runops/core/context.py +426 -0
  43. runops/core/discovery.py +192 -0
  44. runops/core/environment.py +266 -0
  45. runops/core/exceptions.py +93 -0
  46. runops/core/knowledge.py +595 -0
  47. runops/core/knowledge_source.py +1204 -0
  48. runops/core/manifest.py +219 -0
  49. runops/core/project.py +171 -0
  50. runops/core/provenance.py +147 -0
  51. runops/core/retry.py +193 -0
  52. runops/core/run.py +170 -0
  53. runops/core/run_creation.py +456 -0
  54. runops/core/site.py +337 -0
  55. runops/core/state.py +197 -0
  56. runops/core/survey.py +380 -0
  57. runops/core/validation.py +40 -0
  58. runops/harness/__init__.py +27 -0
  59. runops/harness/builder.py +327 -0
  60. runops/harness/claude.py +189 -0
  61. runops/jobgen/__init__.py +3 -0
  62. runops/jobgen/generator.py +295 -0
  63. runops/launchers/__init__.py +17 -0
  64. runops/launchers/base.py +313 -0
  65. runops/launchers/mpiexec.py +131 -0
  66. runops/launchers/mpirun.py +132 -0
  67. runops/launchers/srun.py +126 -0
  68. runops/sites/__init__.py +0 -0
  69. runops/sites/camphor.md +98 -0
  70. runops/sites/camphor.toml +27 -0
  71. runops/slurm/__init__.py +3 -0
  72. runops/slurm/query.py +384 -0
  73. runops/slurm/submit.py +203 -0
  74. runops/templates/__init__.py +29 -0
  75. runops/templates/adapters/beach/agent_guide.md +50 -0
  76. runops/templates/adapters/beach/beach.toml +19 -0
  77. runops/templates/adapters/beach/case.toml +16 -0
  78. runops/templates/adapters/beach/summarize.py +272 -0
  79. runops/templates/adapters/emses/agent_guide.md +39 -0
  80. runops/templates/adapters/emses/case.toml +18 -0
  81. runops/templates/adapters/emses/plasma.toml +118 -0
  82. runops/templates/adapters/emses/summarize.py +413 -0
  83. runops/templates/adapters/generic/case.toml.j2 +13 -0
  84. runops/templates/adapters/generic/summarize.py +21 -0
  85. runops/templates/agent.md +156 -0
  86. runops/templates/rules/cookbook.md +22 -0
  87. runops/templates/scaffold/campaign.toml.j2 +10 -0
  88. runops/templates/scaffold/cases_claude.md +22 -0
  89. runops/templates/scaffold/facts.toml +2 -0
  90. runops/templates/scaffold/gitignore.txt +30 -0
  91. runops/templates/scaffold/notes/README.md +69 -0
  92. runops/templates/scaffold/rules/plan-before-act.md +17 -0
  93. runops/templates/scaffold/rules/runops-workflow.md +84 -0
  94. runops/templates/scaffold/rules/upstream-feedback.md +85 -0
  95. runops/templates/scaffold/runs_claude.md +24 -0
  96. runops/templates/scaffold/vscode_settings.json +9 -0
  97. runops/templates/skills/analyze/SKILL.md +40 -0
  98. runops/templates/skills/check-status/SKILL.md +29 -0
  99. runops/templates/skills/cleanup/SKILL.md +43 -0
  100. runops/templates/skills/create-run/SKILL.md +135 -0
  101. runops/templates/skills/debug-failed/SKILL.md +38 -0
  102. runops/templates/skills/learn/SKILL.md +54 -0
  103. runops/templates/skills/new-case/SKILL.md +108 -0
  104. runops/templates/skills/note/SKILL.md +107 -0
  105. runops/templates/skills/run-all/SKILL.md +47 -0
  106. runops/templates/skills/runops-reference/SKILL.md +203 -0
  107. runops/templates/skills/setup-campaign/SKILL.md +111 -0
  108. runops/templates/skills/setup-env/SKILL.md +32 -0
  109. runops/templates/skills/survey-design/SKILL.md +73 -0
  110. runops/templates/survey.toml.j2 +22 -0
  111. runops-0.2.0.dist-info/METADATA +491 -0
  112. runops-0.2.0.dist-info/RECORD +115 -0
  113. runops-0.2.0.dist-info/WHEEL +4 -0
  114. runops-0.2.0.dist-info/entry_points.txt +2 -0
  115. 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)
@@ -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"
@@ -0,0 +1,3 @@
1
+ """Job script generation for Slurm submission."""
2
+
3
+ from __future__ import annotations