syncade 0.6.2__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 (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/cli/gc_mode.py ADDED
@@ -0,0 +1,109 @@
1
+ """``syncade --gc`` mode handler.
2
+
3
+ A one-shot maintenance pass that prunes bulk transcripts from ``.syncade/runs/``
4
+ and removes ``/tmp/syncade/`` worktree leftovers, then safely reaps orphaned
5
+ subprocesses (cwd-scoped). Run history is never deleted. Split into its own
6
+ module so :mod:`syncade.cli.modes` stays under the blocking file-length cap;
7
+ re-exported from ``modes`` so the public import path is unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
16
+ from syncade.snapshot import SnapshotError, discover_repo_root
17
+
18
+
19
+ def _run_gc(args) -> int:
20
+ """Dispatch ``syncade --gc``: a one-shot maintenance pass that
21
+ prunes bulk transcripts from ``.syncade/runs/`` and removes
22
+ ``/tmp/syncade/`` worktree leftovers, then safely reaps orphaned
23
+ subprocesses (cwd-scoped). Run history is never deleted.
24
+
25
+ Thin wrapper around :func:`syncade.gc.plan_gc` → :func:`syncade.gc.execute_gc`:
26
+ resolve the git repo root, build a plan, execute it (honoring
27
+ ``--gc-dry-run``), and print a human-readable report. ``--gc`` operates
28
+ only on its own artifacts and is best-effort over them — per-item failures
29
+ are reported but never fail the pass.
30
+
31
+ Exit codes follow the CLI mode-handler convention (CLAUDE.md "Exit-code
32
+ convention for CLI mode handlers"): success → 0; repo-discovery failure →
33
+ 60 (WORKTREE_ERROR). GC never raises out and partial per-item failures
34
+ still exit 0.
35
+ """
36
+ # Local import keeps the gc module out of the default-import cost —
37
+ # operators not running --gc don't pay for it.
38
+ from syncade.cli.config_overrides import apply_worktree_base_override
39
+ from syncade.config_loader import ConfigError, load_config
40
+ from syncade.exit_codes import CONFIG_ERROR
41
+ from syncade.gc import execute_gc, plan_gc
42
+
43
+ repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
44
+ try:
45
+ repo_root = discover_repo_root(repo_root_hint)
46
+ except SnapshotError as exc:
47
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
48
+ return WORKTREE_ERROR
49
+
50
+ # A malformed [gc] value is a config ERROR, not a per-item GC failure: it fails exit 50 like
51
+ # every other config-consuming path (review, doctor), so a typo cannot silently run GC with the
52
+ # wrong retention. (--gc's never-raise contract is about per-item cleanup failures, not
53
+ # config validation.)
54
+ try:
55
+ # check_api_keys=False: --gc spawns no model actors, so a missing actor API key must not
56
+ # block maintenance — but a malformed [gc] value still fails schema validation → exit 50.
57
+ config = load_config(repo_root, preset=args.preset, check_api_keys=False)
58
+ except ConfigError as exc:
59
+ print(f"[syncade] config error: {exc}", file=sys.stderr)
60
+ return CONFIG_ERROR
61
+ config = apply_worktree_base_override(config, args) # --worktree-base cleans a custom base too
62
+
63
+ # Retention precedence: CLI --gc-keep/--gc-max-age-days → [gc] config → default (GcConfig's
64
+ # defaults reproduce gc.DEFAULT_KEEP / DEFAULT_MAX_AGE_DAYS). Flag defaults are None so "passed
65
+ # without a value" stays detectable in main()'s mutex.
66
+ keep = args.gc_keep if args.gc_keep is not None else config.gc.keep
67
+ max_age_days = (
68
+ args.gc_max_age_days if args.gc_max_age_days is not None else config.gc.max_age_days
69
+ )
70
+ # worktree_base: clean the base the runs actually use (config.worktree_base / --worktree-base),
71
+ # not the hardcoded default — else a relocated base leaves worktree leftovers uncollected.
72
+ plan = plan_gc(
73
+ repo_root, keep=keep, max_age_days=max_age_days, worktree_base=config.worktree_base
74
+ )
75
+ report = execute_gc(plan, dry_run=args.gc_dry_run, repo_root=repo_root)
76
+
77
+ dry = report.dry_run
78
+ prefix = "(dry run — nothing removed) " if dry else ""
79
+ print(
80
+ f"[syncade] {prefix}gc: {len(report.runs_slimmed)} run(s) "
81
+ f"{'would be ' if dry else ''}slimmed "
82
+ f"({_human_bytes(report.bytes_freed)} of transcripts "
83
+ f"{'would be ' if dry else ''}freed; run history kept), "
84
+ f"{len(plan.protected_run_ids)} protected, "
85
+ f"{len(report.worktrees_removed)} worktree(s) "
86
+ f"{'would be ' if dry else ''}removed, "
87
+ f"{len(report.pids_reaped)} process(es) {'would be reaped' if dry else 'reaped'}."
88
+ )
89
+ if not args.quiet:
90
+ for run_id in report.runs_slimmed:
91
+ print(f" {'would slim' if dry else 'slimmed'} run: {run_id}")
92
+ for tree in report.worktrees_removed:
93
+ print(f" {'would remove' if dry else 'removed'} worktree: {tree}")
94
+ for pid in report.pids_reaped:
95
+ print(f" {'would reap' if dry else 'reaped'} pid: {pid}")
96
+ for err in report.errors:
97
+ print(f"[syncade] gc: {err}", file=sys.stderr)
98
+
99
+ return SUCCESS
100
+
101
+
102
+ def _human_bytes(n: int) -> str:
103
+ if n < 1024:
104
+ return f"{n} B"
105
+ for unit in ("KB", "MB", "GB"):
106
+ n /= 1024.0 # type: ignore[assignment]
107
+ if n < 1024 or unit == "GB":
108
+ return f"{n:.1f} {unit}"
109
+ return f"{n:.1f} GB"
@@ -0,0 +1,514 @@
1
+ """``syncade --install-skill`` — lay the bundled Agent Skill into the harness dirs.
2
+
3
+ The skill is shipped as package data (``src/syncade/skills/{claude,codex}/``), so a
4
+ ``pip install``ed user with no repo checkout can still install the ``/syncade`` skill.
5
+ Read via :func:`importlib.resources.files` so it works from a wheel or an editable install
6
+ alike. This is the ONLY installer: a second, shell copy existed and was deleted in
7
+ PR-h-04.5 item 1. Two implementations of the same job is what made a PR-h-04 dogfood
8
+ round exist purely because a safety fix landed in one and not the other, and every rule
9
+ below would otherwise have to be written twice.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import shutil
18
+ import stat as _stat
19
+ import sys
20
+ import tempfile
21
+ from importlib.resources import files
22
+ from pathlib import Path
23
+
24
+ from syncade.exit_codes import CLI_USAGE_ERROR, SUCCESS, WORKTREE_ERROR
25
+
26
+ _VALID = ("all", "claude", "codex")
27
+
28
+ #: Written into each destination: the sha256 of every file this installer put there.
29
+ #: It is a RECORD, not a marker. A marker ("syncade was here") cannot tell an upgrade from
30
+ #: an edit — both PR-h-04 attempts that used one failed a blind panel. Hashes can: a file
31
+ #: whose bytes match what we recorded is ours whatever version wrote it, and a file whose
32
+ #: bytes match nothing we recorded is the operator's whatever it is named.
33
+ #:
34
+ #: **Its trust boundary, stated rather than implied** (PR-h-04.5 item 4). The record is not
35
+ #: an authentication token and cannot be one: syncade holds no secret, so a record that
36
+ #: parses is believed. That is bounded, and the bound is what makes it acceptable —
37
+ #: anyone able to edit the record already has write access to the destination and could
38
+ #: delete those files directly, so it confers no capability its writer did not already
39
+ #: have. What it must never do is reach FURTHER than that, which is why keys are
40
+ #: constrained to plain filenames and are used only for lookup, never as paths.
41
+ #:
42
+ #: It is also NOT exempt from the survey (the round-4 finding: "the sentinel was excluded
43
+ #: from its own loss detection"). It is skipped only when its self-hash verifies — a
44
+ #: parseable ``{"files": {…}}`` is necessary but not sufficient. The manifest stores the
45
+ #: SHA256 of its own inner dict (bundle hashes only, without the self-hash entry) under
46
+ #: its own filename key; ``_scan_casualties`` re-derives that hash from the current bytes
47
+ #: and skips the manifest only on a match. A parseable operator edit to any inner hash
48
+ #: value invalidates the self-hash and causes the manifest to be flagged as a casualty.
49
+ _MANIFEST = ".syncade-install.json"
50
+
51
+
52
+ def _sha(data: bytes) -> str:
53
+ return hashlib.sha256(data).hexdigest()
54
+
55
+
56
+ def _no_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
57
+ """``object_pairs_hook`` for ``json.loads`` that rejects duplicate object names.
58
+
59
+ A manifest with a duplicate key (e.g. two ``"README.md"`` entries) would be collapsed
60
+ silently by Python's default parser — last value wins, canonical dict unchanged, self-hash
61
+ still verifies, operator edit overwritten at exit 0. Raising here lets the caller's
62
+ ``except ValueError`` catch it and return ``{}``, so the manifest is treated as unknown.
63
+ """
64
+ seen: dict[str, object] = {}
65
+ for k, v in pairs:
66
+ if k in seen:
67
+ raise ValueError(f"duplicate JSON key: {k!r}")
68
+ seen[k] = v
69
+ return seen
70
+
71
+
72
+ def _parse_manifest(raw: bytes) -> dict[str, str]:
73
+ """Parse manifest bytes and verify the self-hash; return the verified prior dict or ``{}``.
74
+
75
+ The returned dict includes ``{_MANIFEST: inner_sha}`` when the self-hash verifies.
76
+ ``_scan_casualties`` uses that entry to confirm the manifest is our unmodified record.
77
+ Old-style manifests (no self-hash entry) return only the inner dict; the missing
78
+ ``_MANIFEST`` key causes ``_scan_casualties`` to flag the manifest as a casualty.
79
+
80
+ **Fails closed on any non-plain key, non-string value, or duplicate key**, rather than
81
+ filtering or collapsing them silently. Silent filtering or last-wins collapsing would let
82
+ the self-hash verify against a canonical dict that no longer represents the file's actual
83
+ content: an operator who added ``"../note": "value"``, ``"key": 42``, or a duplicate
84
+ ``"README.md"`` before the real one would find their edit invisible to the self-hash
85
+ check, so the manifest would pass as ours and be silently overwritten at exit 0.
86
+ """
87
+ try:
88
+ data = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_duplicate_keys)
89
+ if set(data.keys()) != {"files"}:
90
+ return {}
91
+ recorded = data["files"]
92
+ if not isinstance(recorded, dict):
93
+ return {}
94
+ # Every key and value must be a string. Non-string filtering would silently hide the
95
+ # entry from the self-hash check, making the self-hash verify against edited content.
96
+ if not all(isinstance(k, str) and isinstance(v, str) for k, v in recorded.items()):
97
+ return {}
98
+ raw_self = recorded.get(_MANIFEST)
99
+ inner = {k: v for k, v in recorded.items() if k != _MANIFEST}
100
+ # All non-manifest keys must be plain filenames. Filtering traversal keys silently
101
+ # has the same defect: self-hash verifies against a dict that hides the edit.
102
+ if any(not _is_plain_name(k) for k in inner):
103
+ return {}
104
+ if isinstance(raw_self, str):
105
+ canonical = json.dumps({"files": inner}, indent=2) + "\n"
106
+ if _sha(canonical.encode()) == raw_self:
107
+ # Verify raw bytes match the expected canonical form. A lexical edit
108
+ # (extra whitespace, alternative JSON escaping) preserves the parsed dict
109
+ # and inner hash, so the self-hash alone cannot detect it — the operator's
110
+ # edit looks identical to our canonical form after re-parsing. Byte
111
+ # comparison closes the gap: any deviation from canonical means operator.
112
+ expected = (
113
+ json.dumps({"files": {**inner, _MANIFEST: raw_self}}, indent=2) + "\n"
114
+ ).encode()
115
+ if raw != expected:
116
+ return {} # lexical edit: raw bytes diverge from canonical form
117
+ return {**inner, _MANIFEST: raw_self}
118
+ return {} # tampered: inner changed but self-hash not updated
119
+ return inner # old-style manifest; _MANIFEST absent from returned dict
120
+ except (ValueError, KeyError, AttributeError, TypeError):
121
+ return {}
122
+
123
+
124
+ def _read_manifest(dest: Path) -> dict[str, str]:
125
+ """``{filename: sha256}`` this installer last wrote here, or ``{}`` if unknown.
126
+
127
+ Unreadable or malformed reads as ``{}``, which means "we can prove nothing", which
128
+ means every differing file is treated as the operator's. Failing closed here costs one
129
+ refusal; failing open costs their file.
130
+
131
+ A parseable file with ANY extra top-level keys (e.g. an operator-added
132
+ ``"operator_note"``) also reads as ``{}`` — the manifest must have exactly the shape
133
+ ``{"files": {…}}`` this installer writes.
134
+
135
+ **lstats before reading.** A FIFO at the manifest path would block forever on
136
+ ``read_bytes``; a symlink could redirect to a blocking target. Both bypass the
137
+ ``_scan_casualties`` special-file protections for the manifest path specifically.
138
+ Returning ``{}`` here lets ``_scan_casualties`` classify the entry normally.
139
+ """
140
+ mpath = dest / _MANIFEST
141
+ try:
142
+ st = mpath.lstat()
143
+ except OSError:
144
+ return {}
145
+ if not _stat.S_ISREG(st.st_mode):
146
+ return {}
147
+ try:
148
+ return _parse_manifest(mpath.read_bytes())
149
+ except OSError:
150
+ return {}
151
+
152
+
153
+ def _is_plain_name(key: str) -> bool:
154
+ """A record key must be a bare filename — no separator, no ``..``, not absolute.
155
+
156
+ The bundle is flat, so those are the only keys this installer ever writes. Today the
157
+ keys are used ONLY for dictionary lookup against names derived from walking the
158
+ destination, so a crafted `../../../.bashrc` already matches nothing — measured. This
159
+ makes that safety structural instead of incidental: if a future change ever joins a key
160
+ onto a path, the traversal it would open is already closed here.
161
+ """
162
+ return bool(key) and key not in (".", "..") and "/" not in key and not Path(key).is_absolute()
163
+
164
+
165
+ def _dest(harness: str, home: Path, codex_home: Path) -> Path:
166
+ if harness == "claude":
167
+ return home / ".claude" / "skills" / "syncade"
168
+ return codex_home / "skills" / "syncade"
169
+
170
+
171
+ def _bundle(harness: str) -> dict[str, bytes]:
172
+ """The files this installer would write, by name."""
173
+ src = files("syncade") / "skills" / harness
174
+ return {e.name: e.read_bytes() for e in src.iterdir() if e.is_file()}
175
+
176
+
177
+ def _scan_casualties(
178
+ root: Path,
179
+ current: Path,
180
+ bundle: dict[str, bytes],
181
+ prior: dict[str, str],
182
+ out: list[str],
183
+ ) -> None:
184
+ """Walk ``current`` and append casualty descriptions to ``out``.
185
+
186
+ ``root`` is the top-level destination — used to compute names relative to it for
187
+ bundle/prior lookup. Does NOT report ``current`` itself; only its contents.
188
+
189
+ Uses ``os.scandir`` rather than ``rglob`` so that unreadable subdirectories are
190
+ explicitly caught (``rglob`` silently skips them, dropping their contents from the
191
+ survey). Special files (FIFOs, sockets) are classified by stat mode without opening
192
+ them — ``read_bytes`` on a FIFO blocks indefinitely. Empty subdirectories are reported
193
+ as casualties because ``rmtree`` removes them along with everything else.
194
+ """
195
+ try:
196
+ with os.scandir(current) as it:
197
+ entries = sorted(it, key=lambda e: e.name)
198
+ except OSError as exc:
199
+ out.append(f"{current} — would be LOST (directory unreadable: {exc})")
200
+ return
201
+ for entry in entries:
202
+ path = Path(entry.path)
203
+ try:
204
+ st = entry.stat(follow_symlinks=False)
205
+ except OSError:
206
+ out.append(f"{path} — would be LOST (unreadable, cannot be proven syncade's)")
207
+ continue
208
+ mode = st.st_mode
209
+ if _stat.S_ISLNK(mode):
210
+ # A symlink INSIDE the destination is not the same as a symlinked DESTINATION,
211
+ # and the "symlinks are not loss" rule must not be stretched to cover it.
212
+ out.append(f"{path} — would be DELETED (a symlink you made; its target is untouched)")
213
+ elif _stat.S_ISDIR(mode):
214
+ # Bundle is flat; any subdirectory is operator-added and rmtree removes it.
215
+ # Recurse so its contents are individually reported as casualties.
216
+ prev = len(out)
217
+ _scan_casualties(root, path, bundle, prior, out)
218
+ if len(out) == prev:
219
+ # Recursion added nothing: directory scanned successfully with 0 entries.
220
+ out.append(
221
+ f"{path}/ — would be DELETED (an empty directory syncade did not create)"
222
+ )
223
+ elif not _stat.S_ISREG(mode):
224
+ # FIFO, socket, device file — do not open; classify by type without reading.
225
+ out.append(f"{path} — would be DELETED (a special file syncade did not create)")
226
+ else:
227
+ name = str(path.relative_to(root))
228
+ try:
229
+ data = path.read_bytes()
230
+ except OSError:
231
+ out.append(f"{path} — would be LOST (unreadable, cannot be proven syncade's)")
232
+ continue
233
+ if name == _MANIFEST:
234
+ # Recognised by self-hash verification, not just by parsing. prior[_MANIFEST]
235
+ # is set only when _read_manifest verified self-consistency. An operator edit
236
+ # that keeps valid JSON invalidates the self-hash, causing _parse_manifest to
237
+ # return {} and prior[_MANIFEST] to be absent — so the manifest is flagged.
238
+ self_hash = prior.get(_MANIFEST)
239
+ if self_hash is not None and _parse_manifest(data).get(_MANIFEST) == self_hash:
240
+ continue
241
+ if bundle.get(name) == data:
242
+ continue # byte-identical to what we would write: rewriting changes nothing
243
+ if prior.get(name) == _sha(data):
244
+ continue # WE wrote these exact bytes on a previous install — an upgrade
245
+ verb = "OVERWRITTEN" if name in bundle else "DELETED"
246
+ why = (
247
+ "differs from the bundled copy"
248
+ if name in bundle
249
+ else "not part of the syncade skill"
250
+ )
251
+ out.append(f"{path} — would be {verb} ({why}, and syncade did not write it)")
252
+
253
+
254
+ def _casualties(dest: Path, bundle: dict[str, bytes], prior: dict[str, str]) -> list[str]:
255
+ """Everything under ``dest`` that installing would DESTROY, described for a human.
256
+
257
+ The installer replaces the destination wholesale, so "destroy" means two things: a file
258
+ that is not part of the bundle gets DELETED, and a bundled name whose bytes differ gets
259
+ OVERWRITTEN. Both are losses; neither was announced before PR-h-04.5.
260
+
261
+ A SYMLINKED destination is not loss and returns nothing. The README documents
262
+ symlink-to-checkout, re-running the installer over it has always replaced the link, and
263
+ unlinking a symlink destroys nothing — the checkout it pointed at is untouched. Refusing
264
+ there would break a documented path to protect data that was never at risk.
265
+
266
+ Scanned RECURSIVELY via ``_scan_casualties``: the bundle is flat, but ``rmtree`` takes
267
+ whole subdirectories, so an operator's ``notes/`` directory is exactly as destroyed as
268
+ their loose file. Empty and unreadable subdirectories are both reported as casualties.
269
+ Non-regular entries (FIFOs, sockets) are classified without opening them.
270
+ """
271
+ if dest.is_symlink():
272
+ return []
273
+ if not dest.exists():
274
+ return []
275
+ if dest.is_file():
276
+ # A regular file at the skill destination path will be unlinked and replaced with
277
+ # a directory. This is not a documented usage; a file here cannot be proven
278
+ # installer-owned, so it is always a casualty.
279
+ return [f"{dest} — would be DELETED (a regular file where the skill directory is expected)"]
280
+ out: list[str] = []
281
+ _scan_casualties(dest, dest, bundle, prior, out)
282
+ return out
283
+
284
+
285
+ def _restore(dest: Path, backup: Path) -> None:
286
+ """Move ``backup`` back to ``dest``, clearing anything syncade put in the way.
287
+
288
+ Restoring must not be blocked by an obstruction SYNCADE created. Reproduced: with
289
+ CODEX_HOME inside the claude destination, preparing codex made directories under a path
290
+ claude had already renamed to its backup, so ``dest`` existed again and rollback declined
291
+ — leaving the operator's working skill deleted and their backup stranded under a hidden
292
+ name. **A rollback that refuses to roll back turns a recoverable failure into data loss**,
293
+ which is worse than having no rollback at all.
294
+
295
+ Anything at ``dest`` now post-dates the backup being taken, so it cannot be the
296
+ operator's original — that was moved to ``backup``. Clear it, then restore. If even that
297
+ fails, say where the backup is rather than failing silently.
298
+ """
299
+ try:
300
+ if dest.is_symlink() or dest.is_file():
301
+ dest.unlink(missing_ok=True)
302
+ elif dest.is_dir():
303
+ shutil.rmtree(dest, ignore_errors=True)
304
+ backup.rename(dest)
305
+ except OSError:
306
+ print(
307
+ f"[syncade] could not restore {dest} — your previous skill is preserved at "
308
+ f"{backup}; move it back by hand.",
309
+ file=sys.stderr,
310
+ )
311
+
312
+
313
+ def install_skill(
314
+ target: str = "all",
315
+ *,
316
+ home: Path | None = None,
317
+ codex_home: Path | None = None,
318
+ force: bool = False,
319
+ ) -> int:
320
+ """Copy the bundled skill into the harness skill directories. Returns a CLI exit code.
321
+
322
+ **Refuses rather than destroying.** Before PR-h-04.5 this ran ``rmtree`` over the
323
+ destination and rewrote it: measured on a real tree, a hand-edited ``SKILL.md`` was
324
+ overwritten and two unrelated operator files were deleted, at exit 0 with no prompt,
325
+ warning or backup. Now every file that installing would destroy is surveyed FIRST; if
326
+ there are any, the install refuses (exit 60), names each one and what would happen to
327
+ it, and points at ``--force-install``.
328
+
329
+ ``force=True`` restores the old destructive behaviour deliberately. It is informed
330
+ consent, not a safe mode — the casualties are still printed.
331
+ """
332
+ if target not in _VALID:
333
+ print(
334
+ f"[syncade] unknown --install-skill target {target!r}; expected one of {_VALID}",
335
+ file=sys.stderr,
336
+ )
337
+ return CLI_USAGE_ERROR
338
+
339
+ home = home or Path.home()
340
+ if codex_home is None:
341
+ env_cx = os.environ.get("CODEX_HOME")
342
+ codex_home = Path(env_cx) if env_cx else home / ".codex"
343
+
344
+ harnesses = ["claude", "codex"] if target == "all" else [target]
345
+ plan = [
346
+ (h, d, _bundle(h), _read_manifest(d))
347
+ for h, d in ((h, _dest(h, home, codex_home)) for h in harnesses)
348
+ ]
349
+
350
+ # Refuse if any two harnesses resolve to the same destination. Duplicate destinations
351
+ # arise when CODEX_HOME == ~/.claude (or a symlink that resolves there): phase 1 stages
352
+ # both, then phase 2 crashes renaming the second tree over the already-populated dest
353
+ # and leaves staging artifacts behind. Detecting this before any I/O keeps it clean.
354
+ if len(plan) > 1:
355
+ # OVERLAP, not just equality. Equal destinations were the obvious case; CONTAINMENT
356
+ # is the damaging one, and it was reproduced: with CODEX_HOME inside the claude
357
+ # destination, preparing codex creates directories underneath a path claude had
358
+ # already renamed to a backup, phase 2 then cannot rename its staged tree over the
359
+ # now-non-empty destination, and rollback declined to restore because "the
360
+ # destination exists". Exit 60 with the operator's working skill GONE and the backup
361
+ # stranded under a hidden name — a refusal that destroys, which is the worst outcome
362
+ # this PR can produce. One harness's directory tree must never sit inside another's.
363
+ resolved = [(h, Path(str(d.resolve()))) for h, d, _, _ in plan]
364
+ for i, (h_a, a) in enumerate(resolved):
365
+ for h_b, b in resolved[i + 1 :]:
366
+ if a == b or a in b.parents or b in a.parents:
367
+ relation = (
368
+ "are the same directory" if a == b else "overlap — one contains the other"
369
+ )
370
+ print(
371
+ f"[syncade] cannot install 'all': the {h_a!r} and {h_b!r} destinations "
372
+ f"{relation}:\n {h_a}: {a}\n {h_b}: {b}\n"
373
+ "Installing both would have one harness write inside the other. Set "
374
+ "CODEX_HOME to a directory outside ~/.claude, or install one harness "
375
+ "at a time.",
376
+ file=sys.stderr,
377
+ )
378
+ return WORKTREE_ERROR
379
+
380
+ # Survey EVERY destination before writing ANY of them. The survey runs even under
381
+ # --force: consent to destroying files is not consent to being told nothing about
382
+ # which ones. Skipping it there would also make this function's own docstring false.
383
+ losses = [line for _, dest, bundle, prior in plan for line in _casualties(dest, bundle, prior)]
384
+ if losses:
385
+ listing = "\n".join(f" {line}" for line in losses)
386
+ if not force:
387
+ print(
388
+ f"[syncade] refusing to install: {len(losses)} file(s) would be lost.\n"
389
+ f"{listing}\n\n"
390
+ "syncade did not write these and will not destroy them. Move or delete them "
391
+ "yourself, or re-run with --force-install to overwrite deliberately.",
392
+ file=sys.stderr,
393
+ )
394
+ return WORKTREE_ERROR
395
+ print(
396
+ f"[syncade] --force-install: destroying {len(losses)} file(s) syncade did not "
397
+ f"write.\n{listing}",
398
+ file=sys.stderr,
399
+ )
400
+
401
+ # PHASE 1 — build every harness beside its destination, touching none of them.
402
+ #
403
+ # Two improvements over the previous version:
404
+ #
405
+ # (a) tempfile.mkdtemp produces a unique staging directory so a pre-existing path
406
+ # named ".<dest>.syncade-new" (operator content, stale run) is never deleted
407
+ # before ownership is proven. The old fixed name was blindly rmtree'd.
408
+ #
409
+ # (b) Any existing destination is RENAMED to a backup path before phase 2, proving
410
+ # it is removable while still reversible. The previous approach called
411
+ # shutil.rmtree in phase 2 AFTER the first harness was already swapped — a
412
+ # read-only destination (or one with a protected subdirectory) raised an
413
+ # uncaught PermissionError and left a half-applied install. Rename needs only
414
+ # write permission on the PARENT directory, not on the destination itself, so
415
+ # it succeeds in cases where rmtree would fail.
416
+ staged: list[tuple[str, Path, Path, Path | None]] = []
417
+ _current_tmp: Path | None = None # staging dir created but not yet in staged
418
+ try:
419
+ for harness, dest, bundle, _prior in plan:
420
+ dest.parent.mkdir(parents=True, exist_ok=True)
421
+ _current_tmp = Path(
422
+ tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.syncade-new-")
423
+ )
424
+ tmp = _current_tmp
425
+ for name, data in bundle.items():
426
+ (tmp / name).write_bytes(data)
427
+ # The record of what we wrote, so the NEXT install can tell an upgrade from an
428
+ # edit. Without it every version bump refuses (measured: exit 60 on an ordinary
429
+ # upgrade), which trains operators to pass --force-install reflexively and loses
430
+ # the protection entirely — rule 5's failure mode.
431
+ #
432
+ # The manifest also records its own SHA256 (the self-hash) so _scan_casualties
433
+ # can verify it is unmodified on re-install. The self-hash is the SHA of the
434
+ # INNER canonical form (bundle hashes only, without the self-hash entry itself),
435
+ # breaking the circularity: compute inner → hash it → embed that hash.
436
+ inner_files = {n: _sha(d) for n, d in bundle.items()}
437
+ inner_sha = _sha((json.dumps({"files": inner_files}, indent=2) + "\n").encode())
438
+ (tmp / _MANIFEST).write_text(
439
+ json.dumps({"files": {**inner_files, _MANIFEST: inner_sha}}, indent=2) + "\n",
440
+ encoding="utf-8",
441
+ )
442
+ # Move any existing destination to a UNIQUE backup path — proves it is removable
443
+ # while still reversible. The path is unique (mkdtemp-derived) so an operator file
444
+ # that happens to sit at the old predictable ".syncade-old" path is never deleted
445
+ # before ownership is proven. Rename needs only parent write permission, so a
446
+ # read-only destination that would block shutil.rmtree is still movable.
447
+ backup: Path | None = None
448
+ if dest.exists() or dest.is_symlink():
449
+ _bk = Path(tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.syncade-old-"))
450
+ _bk.rmdir() # free the path before rename; race is within the parent we control
451
+ backup = _bk
452
+ dest.rename(backup) # raises OSError if dest cannot be moved
453
+ staged.append((harness, dest, tmp, backup))
454
+ _current_tmp = None # now tracked by staged; don't double-clean on exception
455
+ except OSError as exc:
456
+ # Clean up any staging dir created for the current harness but not yet in staged.
457
+ if _current_tmp is not None:
458
+ shutil.rmtree(_current_tmp, ignore_errors=True)
459
+ # Restore any destinations already moved to backup, then remove staged dirs.
460
+ for _, d, t, bk in staged:
461
+ shutil.rmtree(t, ignore_errors=True)
462
+ if bk is not None:
463
+ _restore(d, bk)
464
+ print(
465
+ f"[syncade] could not prepare the skill install: {exc}\n"
466
+ "Nothing was changed — no harness was modified.",
467
+ file=sys.stderr,
468
+ )
469
+ return WORKTREE_ERROR
470
+
471
+ # PHASE 2 — rename each staged tree into place. Phase 1 already moved every prior
472
+ # destination to a backup, so only renames remain here — no rmtree of the old dest.
473
+ # Wrapped in try/except so a mid-phase failure (e.g. a race that repopulates the dest
474
+ # between phase 1 and phase 2) rolls back cleanly rather than leaving a partial install.
475
+ completed: list[tuple[str, Path, Path, Path | None]] = []
476
+ try:
477
+ for harness, dest, tmp, backup in staged:
478
+ tmp.rename(dest)
479
+ completed.append((harness, dest, tmp, backup))
480
+ print(f"[syncade] installed {harness} skill -> {dest}", file=sys.stderr)
481
+ except OSError as exc:
482
+ # Undo every successfully placed tree and restore its backup; remove remaining
483
+ # staged dirs. Best-effort: an operator can recover any surviving backup manually.
484
+ completed_dests = {d for _, d, _, _ in completed}
485
+ for _, d, t, bk in staged:
486
+ if d in completed_dests:
487
+ shutil.rmtree(d, ignore_errors=True)
488
+ else:
489
+ shutil.rmtree(t, ignore_errors=True)
490
+ if bk is not None:
491
+ _restore(d, bk)
492
+ print(
493
+ f"[syncade] could not complete the skill install: {exc}\n"
494
+ "Rolling back — attempting to restore the previous state.",
495
+ file=sys.stderr,
496
+ )
497
+ return WORKTREE_ERROR
498
+
499
+ # Clean up backups now that all swaps succeeded. ignore_errors because a backup
500
+ # with a protected subdirectory (0o555) may not fully rmtree; leaving it behind
501
+ # is preferred over blocking the install on cleanup.
502
+ # is_symlink() must precede is_dir(): Path.is_dir() follows symlinks, so a symlink
503
+ # to a directory evaluates to True and shutil.rmtree refuses the symlink silently,
504
+ # leaving a stale .syncade.syncade-old-* symlink in the skills directory.
505
+ for _, _, _, backup in staged:
506
+ if backup is None:
507
+ pass
508
+ elif backup.is_symlink() or backup.is_file():
509
+ backup.unlink(missing_ok=True)
510
+ elif backup.is_dir():
511
+ shutil.rmtree(backup, ignore_errors=True)
512
+
513
+ print("[syncade] done — restart your harness to pick up the skill.", file=sys.stderr)
514
+ return SUCCESS