stratarc 0.1.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.
Potentially problematic release.
This version of stratarc might be problematic. Click here for more details.
- stratarc/__init__.py +3 -0
- stratarc/__main__.py +6 -0
- stratarc/adapters/__init__.py +0 -0
- stratarc/adapters/_common.py +613 -0
- stratarc/adapters/_components.py +627 -0
- stratarc/adapters/_foreign.py +155 -0
- stratarc/adapters/_inventory.py +437 -0
- stratarc/adapters/_supports.py +67 -0
- stratarc/adapters/_text.py +172 -0
- stratarc/adapters/claude.py +478 -0
- stratarc/adapters/codex.py +913 -0
- stratarc/adapters/cursor.py +308 -0
- stratarc/adapters/gemini.py +564 -0
- stratarc/adapters/opencode.py +580 -0
- stratarc/api.py +626 -0
- stratarc/budgets.py +282 -0
- stratarc/changelog.py +742 -0
- stratarc/cli.py +639 -0
- stratarc/components.py +1354 -0
- stratarc/config.py +107 -0
- stratarc/config_cmd.py +349 -0
- stratarc/control_plane.py +304 -0
- stratarc/data/bin/caffeinate-shim.sh +7 -0
- stratarc/data/ci/attribution-check-comment.yml +28 -0
- stratarc/data/ci/attribution-check.py +298 -0
- stratarc/data/ci/attribution-check.yml +37 -0
- stratarc/data/git-hooks/check-staged-prose.sh +91 -0
- stratarc/data/git-hooks/check-staged-secrets.sh +25 -0
- stratarc/data/git-hooks/commit-msg +14 -0
- stratarc/data/git-hooks/post-commit +48 -0
- stratarc/data/git-hooks/pre-commit +15 -0
- stratarc/data/git-hooks/strip-commit-attribution.sh +66 -0
- stratarc/data/hooks/agent-graph-pre-edit.sh +122 -0
- stratarc/data/hooks/agent-graph-session-start.sh +70 -0
- stratarc/data/hooks/claude-agent-graph-hooks.json +25 -0
- stratarc/data/hooks/claude-worktree-hooks.json +36 -0
- stratarc/data/hooks/env-dump-guard.sh +109 -0
- stratarc/data/hooks/hooks.json +158 -0
- stratarc/data/hooks/lib/attribution-detect.py +2706 -0
- stratarc/data/hooks/lib/env-dump-detect.py +959 -0
- stratarc/data/hooks/lib/fleet-cap.py +304 -0
- stratarc/data/hooks/lib/guard-log.sh +183 -0
- stratarc/data/hooks/lib/guard-utils.sh +289 -0
- stratarc/data/hooks/lib/log.sh +21 -0
- stratarc/data/hooks/lib/program-summary.py +353 -0
- stratarc/data/hooks/lib/prompt-capture.json +22 -0
- stratarc/data/hooks/lib/prompt-capture.py +1267 -0
- stratarc/data/hooks/lib/prose-detect.py +598 -0
- stratarc/data/hooks/lib/public-targets.py +180 -0
- stratarc/data/hooks/lib/runtime-usage.py +556 -0
- stratarc/data/hooks/lib/secret-scan.sh +13 -0
- stratarc/data/hooks/lib/session-card.py +1025 -0
- stratarc/data/hooks/lib/session-footprint.py +236 -0
- stratarc/data/hooks/lib/session-statusline.py +349 -0
- stratarc/data/hooks/lib/subagent-cap.py +262 -0
- stratarc/data/hooks/lib/tool-budget.py +775 -0
- stratarc/data/hooks/lib/worktree.sh +38 -0
- stratarc/data/hooks/no-attribution-guard.sh +162 -0
- stratarc/data/hooks/opencode-runtime-hooks.ts +130 -0
- stratarc/data/hooks/prompt-capture.sh +77 -0
- stratarc/data/hooks/prose-guard.sh +115 -0
- stratarc/data/hooks/reconcile-control-plane.sh +121 -0
- stratarc/data/hooks/require-pr-on-stop.sh +421 -0
- stratarc/data/hooks/retired.json +15 -0
- stratarc/data/hooks/session-start.sh +171 -0
- stratarc/data/hooks/subagent-cap-guard.sh +124 -0
- stratarc/data/hooks/tool-budget-guard.sh +86 -0
- stratarc/data/hooks/worktree-create.sh +96 -0
- stratarc/data/hooks/worktree-remove.sh +45 -0
- stratarc/data/hooks/worktree-validate.sh +41 -0
- stratarc/data/schema/README.md +7 -0
- stratarc/data/schema/adapter-manifest.schema.json +38 -0
- stratarc/data/schema/api-v1.schema.json +306 -0
- stratarc/data/schema/components.schema.json +482 -0
- stratarc/data/schema/hooks.schema.json +54 -0
- stratarc/data/schema/permissions.schema.json +67 -0
- stratarc/data/schema/provider.schema.json +37 -0
- stratarc/data/templates/source-root/AGENTS.md +23 -0
- stratarc/data/templates/source-root/agents/README.md +3 -0
- stratarc/data/templates/source-root/commands/README.md +3 -0
- stratarc/data/templates/source-root/components.json +39 -0
- stratarc/data/templates/source-root/control-plane.md +3 -0
- stratarc/data/templates/source-root/hooks/hooks.json +3 -0
- stratarc/data/templates/source-root/permissions.json +31 -0
- stratarc/data/templates/source-root/projects-root/README.md +3 -0
- stratarc/data/templates/source-root/rules/README.md +3 -0
- stratarc/data/templates/source-root/rules/tiers.json +5 -0
- stratarc/data/templates/source-root/skills/README.md +3 -0
- stratarc/data/templates/source-root/stratarc.toml +34 -0
- stratarc/deploy_guard.py +323 -0
- stratarc/doctor.py +251 -0
- stratarc/gen_rules_digest.py +175 -0
- stratarc/home_layout.py +447 -0
- stratarc/layers.py +739 -0
- stratarc/log_cmd.py +194 -0
- stratarc/messages.py +551 -0
- stratarc/paths.py +130 -0
- stratarc/permissions.py +360 -0
- stratarc/project_permissions.py +517 -0
- stratarc/project_plugins_ingest.py +556 -0
- stratarc/projects.py +2294 -0
- stratarc/providers.py +336 -0
- stratarc/public_targets.py +55 -0
- stratarc/reconcile.py +789 -0
- stratarc/registry.py +575 -0
- stratarc/resources.py +16 -0
- stratarc/resources_cmd.py +1460 -0
- stratarc/rules_digest.py +365 -0
- stratarc/source_graph.py +1126 -0
- stratarc/staging.py +437 -0
- stratarc/strip_provenance.py +88 -0
- stratarc/sync.py +1521 -0
- stratarc/ui/__init__.py +39 -0
- stratarc/ui/app.py +203 -0
- stratarc/ui/model.py +298 -0
- stratarc/validate.py +758 -0
- stratarc/verify.py +389 -0
- stratarc-0.1.0.dist-info/METADATA +69 -0
- stratarc-0.1.0.dist-info/RECORD +122 -0
- stratarc-0.1.0.dist-info/WHEEL +4 -0
- stratarc-0.1.0.dist-info/entry_points.txt +2 -0
- stratarc-0.1.0.dist-info/licenses/LICENSE +21 -0
stratarc/__init__.py
ADDED
stratarc/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""Shared helpers for the sync adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import dataclasses
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
from contextlib import ExitStack
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, FrozenSet, List, Optional, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from stratarc.paths import home
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# The runtime registry. Each adapter module declares its runtime in
|
|
21
|
+
# one RUNTIME constant, and runtime_registry() lists them. The sync, component,
|
|
22
|
+
# session launcher and audit tooling read this instead of keeping their own lists, so
|
|
23
|
+
# bringing a runtime under management is one adapter file, one
|
|
24
|
+
# permissions.json runtimeDirectories entry and one reconciled row.
|
|
25
|
+
#
|
|
26
|
+
# The constant is read with ast.literal_eval, never by importing the module:
|
|
27
|
+
# listing runtimes then costs no adapter import, cannot cycle through this
|
|
28
|
+
# module (every adapter imports it), and works on a fixture directory whose
|
|
29
|
+
# modules are not importable.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_RUNTIME_KEYS = {"name", "target", "hook_registry", "hook_events"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RegistryError(ValueError):
|
|
36
|
+
"""An adapter file whose RUNTIME constant is missing or malformed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(frozen=True)
|
|
40
|
+
class Runtime:
|
|
41
|
+
"""One managed runtime, as its adapter module declares it.
|
|
42
|
+
|
|
43
|
+
relative is the target directory relative to the home directory
|
|
44
|
+
(".claude", ".config/opencode"). hook_registry is the file under the
|
|
45
|
+
target that holds hook registrations the reverse pass reads, or None for
|
|
46
|
+
a runtime with no JSON hook registry (OpenCode). hook_events are the
|
|
47
|
+
canonical hooks/hooks.json events the adapter handles, a deliberate drop
|
|
48
|
+
included; sync.py refuses a runtime missing any event the stage declares.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
module: str
|
|
53
|
+
relative: str
|
|
54
|
+
hook_registry: Optional[str]
|
|
55
|
+
hook_events: FrozenSet[str]
|
|
56
|
+
|
|
57
|
+
def target(self, home_dir: Optional[Path] = None) -> Path:
|
|
58
|
+
return (home_dir if home_dir is not None else home()) / self.relative
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _adapter_files(directory: Path) -> List[Path]:
|
|
62
|
+
"""Adapter modules: every *.py but tests, this module and __init__."""
|
|
63
|
+
return sorted(
|
|
64
|
+
path
|
|
65
|
+
for path in directory.glob("*.py")
|
|
66
|
+
if not path.stem.startswith("_") and not path.name.endswith(".test.py")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_runtime_constant(path: Path) -> Runtime:
|
|
71
|
+
try:
|
|
72
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
73
|
+
except (OSError, SyntaxError) as error:
|
|
74
|
+
raise RegistryError(f"{path.name}: cannot be read: {error}") from None
|
|
75
|
+
value = None
|
|
76
|
+
for node in tree.body:
|
|
77
|
+
if isinstance(node, ast.Assign) and any(
|
|
78
|
+
isinstance(t, ast.Name) and t.id == "RUNTIME" for t in node.targets
|
|
79
|
+
):
|
|
80
|
+
try:
|
|
81
|
+
value = ast.literal_eval(node.value)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise RegistryError(f"{path.name}: RUNTIME must be a literal dict") from None
|
|
84
|
+
if value is None:
|
|
85
|
+
raise RegistryError(f"{path.name}: declares no RUNTIME constant")
|
|
86
|
+
if not isinstance(value, dict):
|
|
87
|
+
raise RegistryError(f"{path.name}: RUNTIME must be a dict")
|
|
88
|
+
unknown = sorted(set(value) - _RUNTIME_KEYS)
|
|
89
|
+
if unknown:
|
|
90
|
+
raise RegistryError(f"{path.name}: RUNTIME has unknown key {', '.join(unknown)}")
|
|
91
|
+
name = value.get("name")
|
|
92
|
+
if name != path.stem:
|
|
93
|
+
raise RegistryError(f"{path.name}: RUNTIME name {name!r} must equal the file name {path.stem!r}")
|
|
94
|
+
relative = value.get("target")
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(relative, str)
|
|
97
|
+
or not relative
|
|
98
|
+
or relative.startswith(("/", "~"))
|
|
99
|
+
or ".." in Path(relative).parts
|
|
100
|
+
):
|
|
101
|
+
raise RegistryError(f"{path.name}: RUNTIME target must be a directory relative to the home directory")
|
|
102
|
+
registry = value.get("hook_registry")
|
|
103
|
+
if registry is not None and (not isinstance(registry, str) or not registry):
|
|
104
|
+
raise RegistryError(f"{path.name}: RUNTIME hook_registry must be a file name or None")
|
|
105
|
+
events = value.get("hook_events")
|
|
106
|
+
if not isinstance(events, list) or not all(isinstance(e, str) and e for e in events):
|
|
107
|
+
raise RegistryError(f"{path.name}: RUNTIME hook_events must be a list of event names")
|
|
108
|
+
return Runtime(
|
|
109
|
+
name=name,
|
|
110
|
+
module=f"stratarc.adapters.{name}",
|
|
111
|
+
relative=relative,
|
|
112
|
+
hook_registry=registry,
|
|
113
|
+
hook_events=frozenset(events),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def runtime_registry(adapters_dir: Optional[Path] = None) -> Dict[str, Runtime]:
|
|
118
|
+
"""Every runtime an adapter declares, by name, sorted by name.
|
|
119
|
+
|
|
120
|
+
adapters_dir defaults to this package. A file without a well-formed
|
|
121
|
+
RUNTIME constant raises RegistryError naming it, so a new adapter cannot
|
|
122
|
+
be silently left out of every list.
|
|
123
|
+
"""
|
|
124
|
+
with ExitStack() as stack:
|
|
125
|
+
if adapters_dir is not None:
|
|
126
|
+
directory = adapters_dir
|
|
127
|
+
else:
|
|
128
|
+
directory = stack.enter_context(resources.as_file(resources.files("stratarc.adapters")))
|
|
129
|
+
runtimes = [_read_runtime_constant(path) for path in _adapter_files(directory)]
|
|
130
|
+
return {runtime.name: runtime for runtime in sorted(runtimes, key=lambda r: r.name)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _walk_configuration(base: Path):
|
|
134
|
+
"""Walk deployable files, leaving interpreter caches owned by the runtime."""
|
|
135
|
+
for root, dirs, files in os.walk(base):
|
|
136
|
+
dirs[:] = [name for name in dirs if name != "__pycache__"]
|
|
137
|
+
yield (
|
|
138
|
+
root,
|
|
139
|
+
dirs,
|
|
140
|
+
[name for name in files if not name.endswith((".pyc", ".pyo"))],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def copy_preserving_mode(src: Path, dst: Path, dry_run: bool = False) -> str:
|
|
145
|
+
"""Copy a file, preserving the executable bit. Returns action description."""
|
|
146
|
+
verb = "would copy" if dry_run else "copy"
|
|
147
|
+
action = f"{verb} {src} -> {dst}"
|
|
148
|
+
if not dry_run:
|
|
149
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
shutil.copy2(src, dst)
|
|
151
|
+
# Preserve exec bit explicitly
|
|
152
|
+
src_mode = src.stat().st_mode
|
|
153
|
+
if src_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
|
|
154
|
+
dst.chmod(dst.stat().st_mode | (src_mode & 0o111))
|
|
155
|
+
return action
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mirror_dir(
|
|
159
|
+
src_dir: Path,
|
|
160
|
+
dst_dir: Path,
|
|
161
|
+
dry_run: bool = False,
|
|
162
|
+
*,
|
|
163
|
+
delete_extra: bool = True,
|
|
164
|
+
exclude_patterns: Optional[List[str]] = None,
|
|
165
|
+
file_glob: Optional[str] = None,
|
|
166
|
+
) -> List[str]:
|
|
167
|
+
"""Mirror src_dir into dst_dir. Returns list of action descriptions.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
delete_extra: if True, remove files in dst that are not in src.
|
|
171
|
+
exclude_patterns: glob patterns to skip in source (e.g. '*.test.sh').
|
|
172
|
+
file_glob: if set, only copy files matching this glob (applied per-dir).
|
|
173
|
+
"""
|
|
174
|
+
actions: List[str] = []
|
|
175
|
+
if not src_dir.is_dir():
|
|
176
|
+
return actions
|
|
177
|
+
|
|
178
|
+
# Collect source relative paths
|
|
179
|
+
src_files: set[str] = set()
|
|
180
|
+
for root_path, _dirs, files in _walk_configuration(src_dir):
|
|
181
|
+
root = Path(root_path)
|
|
182
|
+
for fname in files:
|
|
183
|
+
fpath = root / fname
|
|
184
|
+
rel = fpath.relative_to(src_dir)
|
|
185
|
+
|
|
186
|
+
# Apply exclusions
|
|
187
|
+
skip = False
|
|
188
|
+
if exclude_patterns:
|
|
189
|
+
for pat in exclude_patterns:
|
|
190
|
+
if fpath.match(pat):
|
|
191
|
+
skip = True
|
|
192
|
+
break
|
|
193
|
+
if skip:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if file_glob and not fpath.match(file_glob):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
src_files.add(str(rel))
|
|
200
|
+
dst_path = dst_dir / rel
|
|
201
|
+
|
|
202
|
+
# Only copy if changed or missing
|
|
203
|
+
needs_copy = True
|
|
204
|
+
if dst_path.exists():
|
|
205
|
+
if dst_path.stat().st_size == fpath.stat().st_size:
|
|
206
|
+
if dst_path.read_bytes() == fpath.read_bytes():
|
|
207
|
+
needs_copy = False
|
|
208
|
+
|
|
209
|
+
if needs_copy:
|
|
210
|
+
actions.append(copy_preserving_mode(fpath, dst_path, dry_run))
|
|
211
|
+
|
|
212
|
+
# Delete files in dst not in src
|
|
213
|
+
if delete_extra and dst_dir.is_dir():
|
|
214
|
+
for root_path, _dirs, files in _walk_configuration(dst_dir):
|
|
215
|
+
root = Path(root_path)
|
|
216
|
+
for fname in files:
|
|
217
|
+
fpath = root / fname
|
|
218
|
+
rel = str(fpath.relative_to(dst_dir))
|
|
219
|
+
if rel not in src_files:
|
|
220
|
+
verb = "would delete" if dry_run else "delete"
|
|
221
|
+
actions.append(f"{verb} {fpath}")
|
|
222
|
+
if not dry_run:
|
|
223
|
+
fpath.unlink()
|
|
224
|
+
|
|
225
|
+
# Clean empty dirs in dst after deletion
|
|
226
|
+
if not dry_run:
|
|
227
|
+
_remove_empty_dirs(dst_dir)
|
|
228
|
+
|
|
229
|
+
return actions
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def skill_excludes(src_dir: Path) -> set:
|
|
233
|
+
"""Skills listed in skills/sync-exclude.json are host-adapted and not synced."""
|
|
234
|
+
cfg = src_dir / "sync-exclude.json"
|
|
235
|
+
if not cfg.is_file():
|
|
236
|
+
return set()
|
|
237
|
+
try:
|
|
238
|
+
import json
|
|
239
|
+
|
|
240
|
+
return set(json.loads(cfg.read_text()).get("exclude", {}).keys())
|
|
241
|
+
except Exception:
|
|
242
|
+
return set()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def mirror_skills(src_dir: Path, dst_dir: Path, dry_run: bool = False) -> List[str]:
|
|
246
|
+
"""Mirror skill directories: add and update, never delete, here.
|
|
247
|
+
|
|
248
|
+
A skill directory absent from source is not touched by this loop. Deletion of
|
|
249
|
+
a target-only skill directory belongs to `sync.py --prune`, driven by
|
|
250
|
+
each adapter's `owned_outputs`, not to this copy loop.
|
|
251
|
+
"""
|
|
252
|
+
actions: List[str] = []
|
|
253
|
+
if not src_dir.is_dir():
|
|
254
|
+
return actions
|
|
255
|
+
|
|
256
|
+
excluded = skill_excludes(src_dir)
|
|
257
|
+
for skill_dir in sorted(src_dir.iterdir()):
|
|
258
|
+
if not skill_dir.is_dir():
|
|
259
|
+
continue
|
|
260
|
+
if skill_dir.name in excluded:
|
|
261
|
+
continue
|
|
262
|
+
dst_skill = dst_dir / skill_dir.name
|
|
263
|
+
# Mirror contents within each skill dir, but do not delete extra files
|
|
264
|
+
# within a skill dir either -- only add/update
|
|
265
|
+
for root_path, _dirs, files in os.walk(skill_dir):
|
|
266
|
+
root = Path(root_path)
|
|
267
|
+
for fname in files:
|
|
268
|
+
fpath = root / fname
|
|
269
|
+
rel = fpath.relative_to(skill_dir)
|
|
270
|
+
dst_path = dst_skill / rel
|
|
271
|
+
needs_copy = True
|
|
272
|
+
if dst_path.exists():
|
|
273
|
+
if dst_path.stat().st_size == fpath.stat().st_size:
|
|
274
|
+
if dst_path.read_bytes() == fpath.read_bytes():
|
|
275
|
+
needs_copy = False
|
|
276
|
+
if needs_copy:
|
|
277
|
+
actions.append(copy_preserving_mode(fpath, dst_path, dry_run))
|
|
278
|
+
return actions
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _remove_empty_dirs(base: Path) -> None:
|
|
282
|
+
"""Remove empty subdirectories bottom-up, but not base itself."""
|
|
283
|
+
for root_path, dirs, files in reversed(list(_walk_configuration(base))):
|
|
284
|
+
root = Path(root_path)
|
|
285
|
+
if root == base:
|
|
286
|
+
continue
|
|
287
|
+
if not any(root.iterdir()):
|
|
288
|
+
root.rmdir()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_frontmatter(text: str) -> Tuple[Dict[str, object], str]:
|
|
292
|
+
"""Parse leading --- YAML frontmatter into (dict, body) using only stdlib.
|
|
293
|
+
|
|
294
|
+
Handles:
|
|
295
|
+
- key: value (strings, unquoted or quoted)
|
|
296
|
+
- key: [a, b, c] (inline lists)
|
|
297
|
+
- multi-line values via continuation lines (indented under key)
|
|
298
|
+
- folded (>, >-) and literal (|, |-) block scalars: folded lines are
|
|
299
|
+
joined with spaces, literal lines keep their breaks. A live check found
|
|
300
|
+
the indicator itself kept as the value, so six agents reached Codex with
|
|
301
|
+
description = ">-".
|
|
302
|
+
- boolean-ish and numeric values kept as strings
|
|
303
|
+
"""
|
|
304
|
+
if not text.startswith("---"):
|
|
305
|
+
return {}, text
|
|
306
|
+
|
|
307
|
+
# Find closing ---
|
|
308
|
+
end_match = re.search(r"\n---\s*\n", text[3:])
|
|
309
|
+
if not end_match:
|
|
310
|
+
return {}, text
|
|
311
|
+
|
|
312
|
+
fm_block = text[3 : 3 + end_match.start()]
|
|
313
|
+
body = text[3 + end_match.end() :]
|
|
314
|
+
|
|
315
|
+
meta: Dict[str, object] = {}
|
|
316
|
+
current_key: Optional[str] = None
|
|
317
|
+
current_lines: List[str] = []
|
|
318
|
+
current_style = ""
|
|
319
|
+
|
|
320
|
+
def _flush() -> None:
|
|
321
|
+
nonlocal current_key, current_lines, current_style
|
|
322
|
+
if current_key is not None:
|
|
323
|
+
if current_style == ">":
|
|
324
|
+
val = " ".join(line.strip() for line in current_lines if line.strip())
|
|
325
|
+
elif current_style == "|":
|
|
326
|
+
val = "\n".join(line.strip() for line in current_lines).strip()
|
|
327
|
+
else:
|
|
328
|
+
val = "\n".join(current_lines).strip()
|
|
329
|
+
meta[current_key] = val
|
|
330
|
+
current_key = None
|
|
331
|
+
current_lines = []
|
|
332
|
+
current_style = ""
|
|
333
|
+
|
|
334
|
+
for line in fm_block.split("\n"):
|
|
335
|
+
# New key: value
|
|
336
|
+
m = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)", line)
|
|
337
|
+
if m:
|
|
338
|
+
_flush()
|
|
339
|
+
key = m.group(1)
|
|
340
|
+
val = m.group(2).strip()
|
|
341
|
+
|
|
342
|
+
# Inline list: [a, b, c]
|
|
343
|
+
list_match = re.match(r"^\[(.+)\]$", val)
|
|
344
|
+
if list_match:
|
|
345
|
+
items = [
|
|
346
|
+
item.strip().strip("'\"") for item in list_match.group(1).split(",")
|
|
347
|
+
]
|
|
348
|
+
meta[key] = items
|
|
349
|
+
elif re.fullmatch(r"[>|][+-]?", val):
|
|
350
|
+
current_key = key
|
|
351
|
+
current_lines = []
|
|
352
|
+
current_style = val[0]
|
|
353
|
+
elif val:
|
|
354
|
+
meta[key] = val
|
|
355
|
+
else:
|
|
356
|
+
# Value on next lines
|
|
357
|
+
current_key = key
|
|
358
|
+
current_lines = []
|
|
359
|
+
elif current_key is not None and (
|
|
360
|
+
line.startswith(" ") or line.startswith("\t") or line == ""
|
|
361
|
+
):
|
|
362
|
+
current_lines.append(line)
|
|
363
|
+
else:
|
|
364
|
+
_flush()
|
|
365
|
+
|
|
366
|
+
_flush()
|
|
367
|
+
return meta, body
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# Model names a source agent carries for Claude Code. Every source agent's
|
|
371
|
+
# `model:` is one of these as of 2026-09-23; none names a model on another
|
|
372
|
+
# runtime, so an adapter drops them and the agent inherits the session model,
|
|
373
|
+
# unless runtime_settings.<runtime>.aliases in components.json maps the name
|
|
374
|
+
# (adapters/_components.py alias_for).
|
|
375
|
+
CLAUDE_MODEL_ALIASES = frozenset({"inherit", "default", "opus", "sonnet", "haiku", "fable", "opusplan"})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def is_claude_model(value: object) -> bool:
|
|
379
|
+
"""True for a Claude Code model alias or a claude-* model id."""
|
|
380
|
+
text = str(value or "").strip().lower()
|
|
381
|
+
return not text or text in CLAUDE_MODEL_ALIASES or text.startswith("claude-")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Runtime output ownership and the sync.py --check / --prune reverse pass.
|
|
386
|
+
# This section implements the runtime output ownership design.
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclasses.dataclass(frozen=True)
|
|
391
|
+
class OwnedDir:
|
|
392
|
+
"""One target directory an adapter owns, and what belongs in it.
|
|
393
|
+
|
|
394
|
+
kind labels the reverse pass headings ("rule", "hook", "skill",
|
|
395
|
+
"agent", "command", "prompt"). path is the absolute target directory.
|
|
396
|
+
names is the set of relative names the current source produces here,
|
|
397
|
+
computed by the same name mapping the adapter's own copy loop uses, so
|
|
398
|
+
the reverse pass never reports a file sync() would itself write as an
|
|
399
|
+
orphan. When per_item is True, names are immediate subdirectories
|
|
400
|
+
(skills) rather than files, and the reverse pass treats each one as
|
|
401
|
+
one unit instead of walking into it. exclude names a present entry
|
|
402
|
+
that is never an orphan even though the current source does not
|
|
403
|
+
produce it, such as a skill directory listed in
|
|
404
|
+
skills/sync-exclude.json; the reverse pass reports it under
|
|
405
|
+
"excluded" instead.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
kind: str
|
|
409
|
+
path: Path
|
|
410
|
+
names: FrozenSet[str]
|
|
411
|
+
per_item: bool = False
|
|
412
|
+
exclude: FrozenSet[str] = frozenset()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def owned_dir_entries(base: Path, *, per_item: bool = False) -> Set[str]:
|
|
416
|
+
"""Names actually present under an owned directory, for the reverse pass.
|
|
417
|
+
|
|
418
|
+
Mirrors _walk_configuration's own exclusions (__pycache__, .pyc/.pyo)
|
|
419
|
+
so an interpreter cache is never reported as an orphan. Files are
|
|
420
|
+
returned as paths relative to base; per_item reports only the
|
|
421
|
+
immediate subdirectories, one name per unit, matching how a skill
|
|
422
|
+
directory is owned as a whole rather than file by file.
|
|
423
|
+
"""
|
|
424
|
+
if not base.is_dir():
|
|
425
|
+
return set()
|
|
426
|
+
if per_item:
|
|
427
|
+
return {p.name for p in base.iterdir() if p.is_dir()}
|
|
428
|
+
found: Set[str] = set()
|
|
429
|
+
for root_path, _dirs, files in _walk_configuration(base):
|
|
430
|
+
root = Path(root_path)
|
|
431
|
+
for fname in files:
|
|
432
|
+
found.add(str((root / fname).relative_to(base)))
|
|
433
|
+
return found
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def mirrored_names(
|
|
437
|
+
src_dir: Path,
|
|
438
|
+
*,
|
|
439
|
+
exclude_patterns: Optional[List[str]] = None,
|
|
440
|
+
file_glob: Optional[str] = None,
|
|
441
|
+
) -> FrozenSet[str]:
|
|
442
|
+
"""Relative names mirror_dir would place in the target for src_dir.
|
|
443
|
+
|
|
444
|
+
Applies the same walk, exclusion and glob rules mirror_dir itself
|
|
445
|
+
applies, so an adapter's owned_outputs never drifts from what its
|
|
446
|
+
copy loop actually produces.
|
|
447
|
+
"""
|
|
448
|
+
if not src_dir.is_dir():
|
|
449
|
+
return frozenset()
|
|
450
|
+
names: Set[str] = set()
|
|
451
|
+
for root_path, _dirs, files in _walk_configuration(src_dir):
|
|
452
|
+
root = Path(root_path)
|
|
453
|
+
for fname in files:
|
|
454
|
+
fpath = root / fname
|
|
455
|
+
if exclude_patterns and any(fpath.match(pat) for pat in exclude_patterns):
|
|
456
|
+
continue
|
|
457
|
+
if file_glob and not fpath.match(file_glob):
|
|
458
|
+
continue
|
|
459
|
+
names.add(str(fpath.relative_to(src_dir)))
|
|
460
|
+
return frozenset(names)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def source_hook_names(source: Path) -> Set[str]:
|
|
464
|
+
"""Basenames of the hook scripts the shared config ships.
|
|
465
|
+
|
|
466
|
+
Matches every adapter's own definition of "ours" for a hook
|
|
467
|
+
registration: a file directly under hooks/, suffix .sh, not a test.
|
|
468
|
+
"""
|
|
469
|
+
hooks_dir = source / "hooks"
|
|
470
|
+
if not hooks_dir.is_dir():
|
|
471
|
+
return set()
|
|
472
|
+
return {
|
|
473
|
+
f.name
|
|
474
|
+
for f in hooks_dir.iterdir()
|
|
475
|
+
if f.is_file() and f.suffix == ".sh" and not f.name.endswith(".test.sh")
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_SCRIPT_EXTENSIONS = (".sh", ".py", ".js", ".cjs", ".mjs", ".ts")
|
|
480
|
+
_COMMAND_TOKEN = re.compile(r"""[^\s'"]+""")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def extract_script_paths(command: str) -> List[str]:
|
|
484
|
+
"""Every distinct script path a hook *command* names.
|
|
485
|
+
|
|
486
|
+
Conservative by design: a token counts only when it contains a "/"
|
|
487
|
+
and ends in a known script extension, so a bare flag or word (`-x`,
|
|
488
|
+
`then`, `/bin/sh`) is never mistaken for the invoked script. $HOME and
|
|
489
|
+
a leading ~/ are expanded to the real home directory so results are
|
|
490
|
+
directly comparable to the filesystem. Order is preserved with
|
|
491
|
+
duplicates removed, so a guard clause that tests and then runs the
|
|
492
|
+
same path yields one entry.
|
|
493
|
+
"""
|
|
494
|
+
home_dir = str(home())
|
|
495
|
+
found: List[str] = []
|
|
496
|
+
for match in _COMMAND_TOKEN.finditer(command):
|
|
497
|
+
token = match.group(0).strip("'\";,()")
|
|
498
|
+
if "/" not in token or not token.endswith(_SCRIPT_EXTENSIONS):
|
|
499
|
+
continue
|
|
500
|
+
if token.startswith("$HOME"):
|
|
501
|
+
token = home_dir + token[len("$HOME") :]
|
|
502
|
+
elif token.startswith("~/"):
|
|
503
|
+
token = home_dir + token[1:]
|
|
504
|
+
if token not in found:
|
|
505
|
+
found.append(token)
|
|
506
|
+
return found
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
_TEST_CLAUSE = re.compile(r"""\[\s+!?\s*-[xfe]\s+(['"]?)([^\s'"]+)\1""")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def is_existence_guarded(command: str, paths: List[str]) -> bool:
|
|
513
|
+
"""True when *command* tests one of *paths* for existence before use.
|
|
514
|
+
|
|
515
|
+
A `[ -x '/path' ]` or `[ -f '/path' ]` clause guarding the very script
|
|
516
|
+
a command later invokes is a deliberate, portable registration meant
|
|
517
|
+
to be a no-op on a machine where that tool is not installed; it is
|
|
518
|
+
never classified dangling regardless of whether the path resolves on
|
|
519
|
+
this machine today. The negated spelling `[ ! -f '/path' ] || cmd
|
|
520
|
+
'/path'` (run only when the script exists, rather than skip only when
|
|
521
|
+
it exists) tests the same path and counts identically. A guard clause
|
|
522
|
+
that tests some other path does not count.
|
|
523
|
+
"""
|
|
524
|
+
home_dir = str(home())
|
|
525
|
+
tested: Set[str] = set()
|
|
526
|
+
for match in _TEST_CLAUSE.finditer(command):
|
|
527
|
+
token = match.group(2)
|
|
528
|
+
if token.startswith("$HOME"):
|
|
529
|
+
token = home_dir + token[len("$HOME") :]
|
|
530
|
+
elif token.startswith("~/"):
|
|
531
|
+
token = home_dir + token[1:]
|
|
532
|
+
tested.add(token)
|
|
533
|
+
return bool(tested & set(paths))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# The runtime target roots sync.py manages, from the runtime registry. A
|
|
537
|
+
# hook command's script path is only ever "ours" to classify as mislocated
|
|
538
|
+
# or dangling when it falls under one of these; a path outside all of them
|
|
539
|
+
# names a tool this repo has no jurisdiction over, however its basename
|
|
540
|
+
# reads, per disposition 5 of the ownership record.
|
|
541
|
+
_MANAGED_RUNTIME_MARKERS: Optional[Tuple[str, ...]] = None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _managed_runtime_markers() -> Tuple[str, ...]:
|
|
545
|
+
global _MANAGED_RUNTIME_MARKERS
|
|
546
|
+
if _MANAGED_RUNTIME_MARKERS is None:
|
|
547
|
+
_MANAGED_RUNTIME_MARKERS = tuple(
|
|
548
|
+
f"/{runtime.relative.strip('/')}/" for runtime in runtime_registry().values()
|
|
549
|
+
)
|
|
550
|
+
return _MANAGED_RUNTIME_MARKERS
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def in_managed_runtime_tree(path: str) -> bool:
|
|
554
|
+
"""True when *path* falls under one of the managed runtime roots."""
|
|
555
|
+
return any(marker in path for marker in _managed_runtime_markers())
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def classify_registration(command: str, *, source_names: Set[str], hooks_dir: Path) -> str:
|
|
559
|
+
"""Classify one hook registration's command for the reverse pass.
|
|
560
|
+
|
|
561
|
+
Returns "ok" (nothing to report), "mislocated" (names a source-shipped
|
|
562
|
+
script from the wrong path; repointed on the next sync), "dangling"
|
|
563
|
+
(a script this repo manages that exists nowhere; pruned by --prune),
|
|
564
|
+
or "foreign" (left to the ordinary foreign-hook-group report).
|
|
565
|
+
|
|
566
|
+
hooks_dir is this runtime's own owned hooks directory, e.g.
|
|
567
|
+
target / "hooks". A command with no extractable script path, one that
|
|
568
|
+
tests its own script's existence first, or one whose script lies
|
|
569
|
+
outside every managed runtime root, is never dangling: it is either
|
|
570
|
+
genuinely foreign or beyond what this mechanical classifier can safely
|
|
571
|
+
judge on its own, so it reports as foreign instead.
|
|
572
|
+
"""
|
|
573
|
+
paths = extract_script_paths(command)
|
|
574
|
+
if not paths:
|
|
575
|
+
return "foreign"
|
|
576
|
+
for p in paths:
|
|
577
|
+
name = Path(p).name
|
|
578
|
+
if name in source_names and in_managed_runtime_tree(p):
|
|
579
|
+
return "ok" if p == str(hooks_dir / name) else "mislocated"
|
|
580
|
+
if is_existence_guarded(command, paths):
|
|
581
|
+
return "foreign"
|
|
582
|
+
in_jurisdiction = [p for p in paths if in_managed_runtime_tree(p)]
|
|
583
|
+
if in_jurisdiction and all(not Path(p).exists() for p in in_jurisdiction):
|
|
584
|
+
return "dangling"
|
|
585
|
+
return "foreign"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def repoint_command(
|
|
589
|
+
command: str, *, source_names: Set[str], hooks_prefix: str, hooks_dir: Path
|
|
590
|
+
) -> str:
|
|
591
|
+
"""Rewrite a preserved foreign group's command onto this runtime's own
|
|
592
|
+
hooks directory when it names one of our shipped scripts from
|
|
593
|
+
somewhere else in the managed runtime tree (the mislocated case).
|
|
594
|
+
|
|
595
|
+
Only a path whose basename is source-shipped AND that lies under one
|
|
596
|
+
of the five managed runtime roots is rewritten, so a source name that
|
|
597
|
+
happens to appear in an unrelated absolute path is never touched.
|
|
598
|
+
Everything else in the command string, including an existence guard,
|
|
599
|
+
survives unchanged.
|
|
600
|
+
"""
|
|
601
|
+
result = command
|
|
602
|
+
home_dir = str(home())
|
|
603
|
+
for p in extract_script_paths(command):
|
|
604
|
+
name = Path(p).name
|
|
605
|
+
if name not in source_names or not in_managed_runtime_tree(p):
|
|
606
|
+
continue
|
|
607
|
+
if p == str(hooks_dir / name):
|
|
608
|
+
continue
|
|
609
|
+
correct = f"{hooks_prefix.rstrip('/')}/{name}"
|
|
610
|
+
result = result.replace(p, correct)
|
|
611
|
+
if p.startswith(home_dir):
|
|
612
|
+
result = result.replace("$HOME" + p[len(home_dir) :], correct)
|
|
613
|
+
return result
|