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.

Files changed (122) hide show
  1. stratarc/__init__.py +3 -0
  2. stratarc/__main__.py +6 -0
  3. stratarc/adapters/__init__.py +0 -0
  4. stratarc/adapters/_common.py +613 -0
  5. stratarc/adapters/_components.py +627 -0
  6. stratarc/adapters/_foreign.py +155 -0
  7. stratarc/adapters/_inventory.py +437 -0
  8. stratarc/adapters/_supports.py +67 -0
  9. stratarc/adapters/_text.py +172 -0
  10. stratarc/adapters/claude.py +478 -0
  11. stratarc/adapters/codex.py +913 -0
  12. stratarc/adapters/cursor.py +308 -0
  13. stratarc/adapters/gemini.py +564 -0
  14. stratarc/adapters/opencode.py +580 -0
  15. stratarc/api.py +626 -0
  16. stratarc/budgets.py +282 -0
  17. stratarc/changelog.py +742 -0
  18. stratarc/cli.py +639 -0
  19. stratarc/components.py +1354 -0
  20. stratarc/config.py +107 -0
  21. stratarc/config_cmd.py +349 -0
  22. stratarc/control_plane.py +304 -0
  23. stratarc/data/bin/caffeinate-shim.sh +7 -0
  24. stratarc/data/ci/attribution-check-comment.yml +28 -0
  25. stratarc/data/ci/attribution-check.py +298 -0
  26. stratarc/data/ci/attribution-check.yml +37 -0
  27. stratarc/data/git-hooks/check-staged-prose.sh +91 -0
  28. stratarc/data/git-hooks/check-staged-secrets.sh +25 -0
  29. stratarc/data/git-hooks/commit-msg +14 -0
  30. stratarc/data/git-hooks/post-commit +48 -0
  31. stratarc/data/git-hooks/pre-commit +15 -0
  32. stratarc/data/git-hooks/strip-commit-attribution.sh +66 -0
  33. stratarc/data/hooks/agent-graph-pre-edit.sh +122 -0
  34. stratarc/data/hooks/agent-graph-session-start.sh +70 -0
  35. stratarc/data/hooks/claude-agent-graph-hooks.json +25 -0
  36. stratarc/data/hooks/claude-worktree-hooks.json +36 -0
  37. stratarc/data/hooks/env-dump-guard.sh +109 -0
  38. stratarc/data/hooks/hooks.json +158 -0
  39. stratarc/data/hooks/lib/attribution-detect.py +2706 -0
  40. stratarc/data/hooks/lib/env-dump-detect.py +959 -0
  41. stratarc/data/hooks/lib/fleet-cap.py +304 -0
  42. stratarc/data/hooks/lib/guard-log.sh +183 -0
  43. stratarc/data/hooks/lib/guard-utils.sh +289 -0
  44. stratarc/data/hooks/lib/log.sh +21 -0
  45. stratarc/data/hooks/lib/program-summary.py +353 -0
  46. stratarc/data/hooks/lib/prompt-capture.json +22 -0
  47. stratarc/data/hooks/lib/prompt-capture.py +1267 -0
  48. stratarc/data/hooks/lib/prose-detect.py +598 -0
  49. stratarc/data/hooks/lib/public-targets.py +180 -0
  50. stratarc/data/hooks/lib/runtime-usage.py +556 -0
  51. stratarc/data/hooks/lib/secret-scan.sh +13 -0
  52. stratarc/data/hooks/lib/session-card.py +1025 -0
  53. stratarc/data/hooks/lib/session-footprint.py +236 -0
  54. stratarc/data/hooks/lib/session-statusline.py +349 -0
  55. stratarc/data/hooks/lib/subagent-cap.py +262 -0
  56. stratarc/data/hooks/lib/tool-budget.py +775 -0
  57. stratarc/data/hooks/lib/worktree.sh +38 -0
  58. stratarc/data/hooks/no-attribution-guard.sh +162 -0
  59. stratarc/data/hooks/opencode-runtime-hooks.ts +130 -0
  60. stratarc/data/hooks/prompt-capture.sh +77 -0
  61. stratarc/data/hooks/prose-guard.sh +115 -0
  62. stratarc/data/hooks/reconcile-control-plane.sh +121 -0
  63. stratarc/data/hooks/require-pr-on-stop.sh +421 -0
  64. stratarc/data/hooks/retired.json +15 -0
  65. stratarc/data/hooks/session-start.sh +171 -0
  66. stratarc/data/hooks/subagent-cap-guard.sh +124 -0
  67. stratarc/data/hooks/tool-budget-guard.sh +86 -0
  68. stratarc/data/hooks/worktree-create.sh +96 -0
  69. stratarc/data/hooks/worktree-remove.sh +45 -0
  70. stratarc/data/hooks/worktree-validate.sh +41 -0
  71. stratarc/data/schema/README.md +7 -0
  72. stratarc/data/schema/adapter-manifest.schema.json +38 -0
  73. stratarc/data/schema/api-v1.schema.json +306 -0
  74. stratarc/data/schema/components.schema.json +482 -0
  75. stratarc/data/schema/hooks.schema.json +54 -0
  76. stratarc/data/schema/permissions.schema.json +67 -0
  77. stratarc/data/schema/provider.schema.json +37 -0
  78. stratarc/data/templates/source-root/AGENTS.md +23 -0
  79. stratarc/data/templates/source-root/agents/README.md +3 -0
  80. stratarc/data/templates/source-root/commands/README.md +3 -0
  81. stratarc/data/templates/source-root/components.json +39 -0
  82. stratarc/data/templates/source-root/control-plane.md +3 -0
  83. stratarc/data/templates/source-root/hooks/hooks.json +3 -0
  84. stratarc/data/templates/source-root/permissions.json +31 -0
  85. stratarc/data/templates/source-root/projects-root/README.md +3 -0
  86. stratarc/data/templates/source-root/rules/README.md +3 -0
  87. stratarc/data/templates/source-root/rules/tiers.json +5 -0
  88. stratarc/data/templates/source-root/skills/README.md +3 -0
  89. stratarc/data/templates/source-root/stratarc.toml +34 -0
  90. stratarc/deploy_guard.py +323 -0
  91. stratarc/doctor.py +251 -0
  92. stratarc/gen_rules_digest.py +175 -0
  93. stratarc/home_layout.py +447 -0
  94. stratarc/layers.py +739 -0
  95. stratarc/log_cmd.py +194 -0
  96. stratarc/messages.py +551 -0
  97. stratarc/paths.py +130 -0
  98. stratarc/permissions.py +360 -0
  99. stratarc/project_permissions.py +517 -0
  100. stratarc/project_plugins_ingest.py +556 -0
  101. stratarc/projects.py +2294 -0
  102. stratarc/providers.py +336 -0
  103. stratarc/public_targets.py +55 -0
  104. stratarc/reconcile.py +789 -0
  105. stratarc/registry.py +575 -0
  106. stratarc/resources.py +16 -0
  107. stratarc/resources_cmd.py +1460 -0
  108. stratarc/rules_digest.py +365 -0
  109. stratarc/source_graph.py +1126 -0
  110. stratarc/staging.py +437 -0
  111. stratarc/strip_provenance.py +88 -0
  112. stratarc/sync.py +1521 -0
  113. stratarc/ui/__init__.py +39 -0
  114. stratarc/ui/app.py +203 -0
  115. stratarc/ui/model.py +298 -0
  116. stratarc/validate.py +758 -0
  117. stratarc/verify.py +389 -0
  118. stratarc-0.1.0.dist-info/METADATA +69 -0
  119. stratarc-0.1.0.dist-info/RECORD +122 -0
  120. stratarc-0.1.0.dist-info/WHEEL +4 -0
  121. stratarc-0.1.0.dist-info/entry_points.txt +2 -0
  122. stratarc-0.1.0.dist-info/licenses/LICENSE +21 -0
stratarc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """stratarc: one agent configuration source, rendered into every agent runtime."""
2
+
3
+ __version__ = "0.1.0"
stratarc/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from stratarc.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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