claude-dev-env 2.4.0 → 2.7.0

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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,1087 @@
1
+ """Deterministic, additive Claude-agent to Codex-agent materialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import stat
12
+ import tempfile
13
+ import tomllib
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Callable, Iterable
17
+
18
+
19
+ ManagedContent = str | bytes
20
+
21
+ path_separator = "/"
22
+ toml_suffix = ".toml"
23
+ reparse_point_attribute_name = "FILE_ATTRIBUTE_REPARSE_POINT"
24
+ manifest_indentation_width = 2
25
+ publish_plan_max_positional_arguments = 3
26
+ publish_plan_failure_injector_position = 2
27
+ frontmatter_required_fields = ("name", "description")
28
+ frontmatter_unsupported_fields = ("tools", "model", "color")
29
+ full_prune_opt_in_flag = "--allow-prune-all"
30
+ unreadable_source_root_message = (
31
+ "source root is missing or is not a directory, so nothing was planned or changed; "
32
+ "check the source root path and re-run"
33
+ )
34
+ reparse_source_root_message = "source root is a reparse point, so nothing was planned or changed"
35
+ full_prune_refusal_message = (
36
+ "refusing to delete every managed file: the plan is empty while the manifest still records "
37
+ "{count} of them, so the target root was left untouched; re-run with " + full_prune_opt_in_flag
38
+ + " to remove them"
39
+ )
40
+ unmanaged_target_message = (
41
+ "unmanaged file at planned target {path}: the compatibility manifest does not record it, so it "
42
+ "may be yours and it was not overwritten. Review it, then move or delete {path} inside the "
43
+ "target root and re-run. An interrupted run leaves a file whose bytes already match the plan, "
44
+ "and such a file is adopted automatically"
45
+ )
46
+
47
+
48
+ class MaterializerError(ValueError):
49
+ """Raised when a materialization request cannot be safely planned."""
50
+
51
+
52
+ class ArgumentParserError(ValueError):
53
+ """Raised when command-line arguments cannot be parsed."""
54
+
55
+
56
+ class MaterializerArgumentParser(argparse.ArgumentParser):
57
+ """Parse materializer arguments while keeping errors in the JSON contract."""
58
+
59
+ def error(self, message: str) -> None:
60
+ """Raise a reportable parser error instead of writing process output."""
61
+ raise ArgumentParserError(message)
62
+
63
+
64
+ report_categories = (
65
+ "written", "unchanged", "adopted", "unmanaged_collision", "modified_managed",
66
+ "stale_managed", "deleted", "unsupported", "conflicted", "errors",
67
+ )
68
+ report_categories_public_name = "REPORT_CATEGORIES"
69
+ frontmatter_allowed_fields = {"name", "description", "tools", "model", "color"}
70
+ line_separator = "\n"
71
+ comma_separator = ", "
72
+
73
+
74
+ def __getattr__(name: str) -> tuple[str, ...]:
75
+ if name == report_categories_public_name:
76
+ return report_categories
77
+ raise AttributeError(name)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class MaterializerConfig:
82
+ source_root: Path
83
+ target_root: Path
84
+ manifest_path: Path | None = None
85
+ should_apply: bool = False
86
+ should_allow_full_prune: bool = False
87
+
88
+ def __post_init__(self) -> None:
89
+ source = self.source_root.expanduser().resolve()
90
+ target = self.target_root.expanduser().resolve()
91
+ if source == target or source in target.parents or target in source.parents:
92
+ raise MaterializerError("source and target roots must not overlap")
93
+ object.__setattr__(self, "source_root", source)
94
+ object.__setattr__(self, "target_root", target)
95
+ manifest = (self.manifest_path or target / ".codex-compat-manifest.json").expanduser().resolve()
96
+ if target not in manifest.parents:
97
+ raise MaterializerError("manifest must be inside the target root")
98
+ object.__setattr__(self, "manifest_path", manifest)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class ClaudeAgent:
103
+ source_path: Path
104
+ relative_source: str
105
+ name: str
106
+ description: str
107
+ tools: tuple[str, ...] = ()
108
+ model: str | None = None
109
+ color: str | None = None
110
+ unsupported: tuple[str, ...] = ()
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ManifestRecord:
115
+ """One compatibility-manifest entry, as the publication logic reads it.
116
+
117
+ A field that failed validation is carried as ``None``, so ownership questions
118
+ read as attribute checks rather than repeated isinstance guards::
119
+
120
+ {"hash": "ab12..", "ownership": "codex-compat"} -> ok: owned, refreshable
121
+ {"hash": "ab12.."} -> flag: unowned, preserved
122
+
123
+ The manifest also stores ``source`` and ``marker`` for inspection. No code path
124
+ reads either, so neither appears here.
125
+
126
+ Args:
127
+ content_hash: Hash the tool recorded when it last published the file.
128
+ ownership: Ownership marker the tool recorded alongside that hash.
129
+ """
130
+
131
+ content_hash: str | None
132
+ ownership: str | None
133
+
134
+ @property
135
+ def is_owned_by_tool(self) -> bool:
136
+ """Report whether this entry carries both fields that prove tool ownership."""
137
+ return self.content_hash is not None and self.ownership is not None
138
+
139
+
140
+ ManifestRecordByPath = dict[str, ManifestRecord | None]
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class PlannedFile:
145
+ source_identity: str
146
+ target_relative_path: str
147
+ content: ManagedContent
148
+ content_hash: str
149
+ ownership: str = "codex-compat"
150
+ generated_marker: str = "codex-compat-generated-v1"
151
+ action: str = "write"
152
+
153
+
154
+ @dataclass
155
+ class MaterializationReport:
156
+ written: int = 0
157
+ unchanged: int = 0
158
+ adopted: int = 0
159
+ unmanaged_collision: int = 0
160
+ modified_managed: int = 0
161
+ stale_managed: int = 0
162
+ deleted: int = 0
163
+ unsupported: int = 0
164
+ conflicted: int = 0
165
+ errors: int = 0
166
+ error_details: list[str] = field(default_factory=list)
167
+ details: dict[str, list[str]] = field(default_factory=lambda: {each_category: [] for each_category in report_categories})
168
+ planned_files: list[PlannedFile] = field(default_factory=list)
169
+ is_generation_incomplete: bool = False
170
+ is_reconciliation_required: bool = False
171
+
172
+ @property
173
+ def incomplete_generation(self) -> bool:
174
+ return self.is_generation_incomplete
175
+
176
+ @incomplete_generation.setter
177
+ def incomplete_generation(self, is_incomplete: bool) -> None:
178
+ self.is_generation_incomplete = is_incomplete
179
+
180
+ @property
181
+ def reconcile_required(self) -> bool:
182
+ return self.is_reconciliation_required
183
+
184
+ @reconcile_required.setter
185
+ def reconcile_required(self, is_required: bool) -> None:
186
+ self.is_reconciliation_required = is_required
187
+
188
+ @property
189
+ def preserved(self) -> int:
190
+ """Return the legacy name for unchanged planned files."""
191
+ return self.unchanged + self.modified_managed + self.stale_managed
192
+
193
+ def add_detail(self, category: str, relative_path: str) -> None:
194
+ self.details[category].append(relative_path)
195
+
196
+ def add_error(self, message: str) -> None:
197
+ self.errors += 1
198
+ self.error_details.append(message)
199
+ self.add_detail("errors", message)
200
+
201
+
202
+ def _normalize_relative_path(path: str) -> str:
203
+ canonical_path = path.replace("\\", path_separator)
204
+ if not canonical_path or canonical_path.startswith(("/", "//")) or re.match(r"^[A-Za-z]:($|/)", canonical_path):
205
+ raise MaterializerError("rooted path is not allowed")
206
+ path_parts = canonical_path.split(path_separator)
207
+ if any(each_part in ("", ".", "..") for each_part in path_parts) or canonical_path != path_separator.join(path_parts):
208
+ raise MaterializerError("path is not normalized or contains traversal")
209
+ return canonical_path
210
+
211
+
212
+ def _casefold_normalized_path(path: Path) -> str:
213
+ return path.as_posix().casefold()
214
+
215
+
216
+ def _validate_source_identity(relative_source: str) -> str:
217
+ canonical_source = _normalize_relative_path(relative_source)
218
+ if canonical_source.startswith(".") or canonical_source.casefold().startswith(("private/", "private\\")):
219
+ raise MaterializerError("private source identity is not allowed")
220
+ return canonical_source
221
+
222
+
223
+ def _is_reparse_point(path: Path) -> bool:
224
+ if path.is_symlink():
225
+ return True
226
+ try:
227
+ attributes = path.stat(follow_symlinks=False).st_file_attributes
228
+ except (AttributeError, OSError):
229
+ return False
230
+ return bool(attributes & getattr(stat, reparse_point_attribute_name, 0))
231
+
232
+
233
+ def _validate_containment(root: Path, candidate: Path) -> Path:
234
+ resolved = candidate.resolve()
235
+ if root not in (resolved, *resolved.parents):
236
+ raise MaterializerError("path resolves outside target root")
237
+ return resolved
238
+
239
+
240
+ def validate_target_path(target_root: Path, relative_path: str) -> Path:
241
+ """Resolve a relative target path inside a safe target root.
242
+
243
+ Args:
244
+ target_root: Directory that must contain the resolved path.
245
+ relative_path: Normalized path relative to ``target_root``.
246
+
247
+ Returns:
248
+ The resolved target path.
249
+
250
+ Raises:
251
+ MaterializerError: If the path is rooted, unsafe, or crosses a reparse point.
252
+ """
253
+ canonical_path = _normalize_relative_path(relative_path)
254
+ root = target_root.resolve()
255
+ candidate = root.joinpath(*canonical_path.split(path_separator))
256
+ for each_parent in (root, *candidate.parents):
257
+ if each_parent.exists() and _is_reparse_point(each_parent):
258
+ raise MaterializerError("target path crosses a reparse point")
259
+ return _validate_containment(root, candidate)
260
+
261
+
262
+ def _parse_frontmatter_scalar(serialized_field_text: str) -> str | tuple[str, ...] | None:
263
+ normalized_field_text = serialized_field_text.strip()
264
+ if not normalized_field_text:
265
+ return ""
266
+ if normalized_field_text.startswith("["):
267
+ return _parse_frontmatter_list(normalized_field_text)
268
+ try:
269
+ parsed = ast.literal_eval(normalized_field_text)
270
+ except (SyntaxError, ValueError):
271
+ if normalized_field_text[:1] in {'"', "'"} or normalized_field_text[:1] == "[":
272
+ raise MaterializerError("malformed frontmatter value")
273
+ return normalized_field_text
274
+ if isinstance(parsed, str):
275
+ return parsed
276
+ if isinstance(parsed, list) and all(isinstance(each_entry, str) for each_entry in parsed):
277
+ return tuple(parsed)
278
+ raise MaterializerError("frontmatter value must be a string or string list")
279
+
280
+
281
+ def _parse_frontmatter_list(serialized_list: str) -> tuple[str, ...]:
282
+ if not serialized_list.endswith("]"):
283
+ raise MaterializerError("malformed frontmatter value")
284
+ all_entries: list[str] = []
285
+ entry_start = 1
286
+ quote: str | None = None
287
+ has_escape_pending = False
288
+ for each_index, each_character in enumerate(serialized_list[1:-1], 1):
289
+ if has_escape_pending:
290
+ has_escape_pending = False
291
+ continue
292
+ if quote == '"' and each_character == "\\":
293
+ has_escape_pending = True
294
+ continue
295
+ if each_character in {'"', "'"}:
296
+ if quote is None:
297
+ quote = each_character
298
+ elif quote == each_character:
299
+ quote = None
300
+ continue
301
+ if each_character == "," and quote is None:
302
+ all_entries.append(_parse_frontmatter_list_entry(serialized_list[entry_start:each_index]))
303
+ entry_start = each_index + 1
304
+ if quote is not None or has_escape_pending:
305
+ raise MaterializerError("malformed frontmatter value")
306
+ all_entries.append(_parse_frontmatter_list_entry(serialized_list[entry_start:-1]))
307
+ return tuple(all_entries) if all_entries != [""] else ()
308
+
309
+
310
+ def _parse_frontmatter_list_entry(raw_entry: str) -> str:
311
+ entry = raw_entry.strip()
312
+ if not entry:
313
+ raise MaterializerError("malformed frontmatter value")
314
+ try:
315
+ parsed = ast.literal_eval(entry)
316
+ except (SyntaxError, ValueError):
317
+ if entry[0] in {'"', "'"}:
318
+ raise MaterializerError("malformed frontmatter value")
319
+ if any(character in entry for character in "[]{}:"):
320
+ raise MaterializerError("malformed frontmatter value")
321
+ return entry
322
+ if not isinstance(parsed, str):
323
+ raise MaterializerError("frontmatter list entries must be strings")
324
+ return parsed
325
+
326
+
327
+ def parse_frontmatter(source_path: Path, source_text: str, relative_source: str) -> ClaudeAgent:
328
+ """Parse one Claude agent's frontmatter.
329
+
330
+ Args:
331
+ source_path: Path used in validation errors.
332
+ source_text: Markdown source containing the frontmatter block.
333
+ relative_source: Safe source identity recorded in the manifest.
334
+
335
+ Returns:
336
+ The parsed Claude agent.
337
+
338
+ Raises:
339
+ MaterializerError: If frontmatter syntax or required fields are invalid.
340
+ """
341
+ source_identity = _validate_source_identity(relative_source)
342
+ lines = source_text.splitlines()
343
+ if len(lines) < 3 or lines[0].strip() != "---":
344
+ raise MaterializerError(f"malformed frontmatter: {source_path}")
345
+ delimiters = [each_index for each_index, line in enumerate(lines[1:], 1) if line.strip() == "---"]
346
+ if len(delimiters) != 1:
347
+ raise MaterializerError(f"malformed frontmatter delimiters: {source_path}")
348
+ all_fields: dict[str, str | tuple[str, ...] | None] = {}
349
+ for each_line in lines[1 : delimiters[0]]:
350
+ if not each_line.strip() or ":" not in each_line:
351
+ raise MaterializerError(f"malformed frontmatter: {source_path}")
352
+ key, serialized_field_text = each_line.split(":", 1)
353
+ key = key.strip()
354
+ if key in all_fields or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", key):
355
+ raise MaterializerError(f"malformed frontmatter key: {source_path}")
356
+ all_fields[key] = _parse_frontmatter_scalar(serialized_field_text)
357
+ unknown = tuple(sorted(each_key for each_key in all_fields if each_key not in frontmatter_allowed_fields))
358
+ if unknown:
359
+ raise MaterializerError(f"unknown frontmatter keys: {source_path}")
360
+ unsupported = tuple(sorted(each_key for each_key in all_fields if each_key in frontmatter_unsupported_fields))
361
+ if any(not isinstance(all_fields.get(each_key), str) or not all_fields[each_key] for each_key in frontmatter_required_fields):
362
+ raise MaterializerError(f"name and description are required: {source_path}")
363
+ tools = all_fields.get("tools", ())
364
+ if isinstance(tools, str):
365
+ tools = (tools,)
366
+ if not isinstance(tools, tuple):
367
+ raise MaterializerError(f"tools must be a list: {source_path}")
368
+ return ClaudeAgent(source_path, source_identity, all_fields["name"], all_fields["description"], tools, all_fields.get("model"), all_fields.get("color"), unsupported)
369
+
370
+
371
+ def convert_agent(agent: ClaudeAgent) -> str:
372
+ """Convert a Claude agent to validated Codex TOML content.
373
+
374
+ Args:
375
+ agent: Parsed Claude agent to convert.
376
+
377
+ Returns:
378
+ UTF-8 text containing the generated TOML document.
379
+
380
+ Raises:
381
+ MaterializerError: If the generated TOML cannot be validated.
382
+ """
383
+ toml_string = lambda text: json.dumps(text, ensure_ascii=False)
384
+ content = line_separator.join((f"name = {toml_string(agent.name)}", f"description = {toml_string(agent.description)}", f"developer_instructions = {toml_string('Claude tools: ' + comma_separator.join(agent.tools))}")) + line_separator
385
+ tomllib.loads(content)
386
+ return content
387
+
388
+
389
+ def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
390
+ """Discover and parse Markdown agents below the source root.
391
+
392
+ Args:
393
+ config: Materializer paths and application settings.
394
+
395
+ An unreachable source root is an error rather than an empty discovery, so a
396
+ mistyped path or an offline share never reads as "this tree holds no agents"::
397
+
398
+ source root missing -> flag: MaterializerError, nothing planned
399
+ source root empty -> ok: [] , and publication asks for prune consent
400
+
401
+ Returns:
402
+ Agents discovered in deterministic relative-path order.
403
+
404
+ Raises:
405
+ MaterializerError: If the source root is unreachable, or a source path is unsafe or malformed.
406
+ """
407
+ if not config.source_root.is_dir():
408
+ raise MaterializerError(f"{unreadable_source_root_message}: {config.source_root}")
409
+ if _is_reparse_point(config.source_root):
410
+ raise MaterializerError(f"{reparse_source_root_message}: {config.source_root}")
411
+ all_agents: list[ClaudeAgent] = []
412
+ for each_path in sorted(config.source_root.rglob("*.md"), key=lambda path: path.as_posix().casefold()):
413
+ if _is_reparse_point(each_path):
414
+ raise MaterializerError(f"source reparse point is not allowed: {each_path}")
415
+ relative_source = each_path.relative_to(config.source_root).as_posix()
416
+ _validate_containment(config.source_root, each_path)
417
+ all_agents.append(parse_frontmatter(each_path, each_path.read_text(encoding="utf-8"), relative_source))
418
+ return all_agents
419
+
420
+
421
+ def _case_fold_collision_error(target_relative_path: str) -> MaterializerError:
422
+ """Build the error for two target names that differ only by letter case."""
423
+ return MaterializerError(f"case-fold collision: {target_relative_path}")
424
+
425
+
426
+ def _validate_orphan_target_is_adoptable(
427
+ config: MaterializerConfig,
428
+ existing_path: Path | None,
429
+ target_relative_path: str,
430
+ content: ManagedContent,
431
+ ) -> None:
432
+ """Allow an unrecorded target file only when its bytes already match the plan.
433
+
434
+ A run interrupted between the file replacement and the manifest save leaves a
435
+ file the manifest does not record. Byte-identical content proves the tool wrote
436
+ it, so the next run adopts it instead of stopping forever::
437
+
438
+ Nova.toml bytes == planned bytes -> ok: adopted, publication continues
439
+ Nova.toml bytes != planned bytes -> flag: MaterializerError naming the remedy
440
+
441
+ Args:
442
+ config: Materializer paths and application settings.
443
+ existing_path: Target-root path already holding the planned name, or ``None``.
444
+ target_relative_path: Normalized relative path the plan publishes.
445
+ content: Content the plan would publish at that path.
446
+
447
+ Raises:
448
+ MaterializerError: If the existing file differs from the plan or differs only in case.
449
+ """
450
+ if existing_path is None:
451
+ return
452
+ if existing_path.relative_to(config.target_root).as_posix() != target_relative_path:
453
+ raise _case_fold_collision_error(target_relative_path)
454
+ if not existing_path.is_file() or existing_path.read_bytes() != content_to_bytes(content):
455
+ raise MaterializerError(unmanaged_target_message.format(path=target_relative_path))
456
+
457
+
458
+ def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -> tuple[list[PlannedFile], MaterializationReport]:
459
+ """Build planned agent publications and their report.
460
+
461
+ Args:
462
+ config: Materializer paths and application settings.
463
+ all_agents: Optional parsed agents used by callers that inject discovery results.
464
+
465
+ Returns:
466
+ Planned files and the report describing unsupported agent fields.
467
+
468
+ Raises:
469
+ MaterializerError: If a source or target path collides or is unsafe.
470
+ """
471
+ report = MaterializationReport()
472
+ planned: list[PlannedFile] = []
473
+ target_by_name: dict[str, str] = {}
474
+ previous_records = _manifest_record_by_path(load_manifest(config.manifest_path))
475
+ existing_by_name = {
476
+ each_path.relative_to(config.target_root).as_posix().casefold(): each_path
477
+ for each_path in config.target_root.rglob("*")
478
+ if not _is_known_managed_path(config.target_root, each_path, previous_records)
479
+ } if config.target_root.exists() else {}
480
+ for each_agent in list(all_agents):
481
+ source_identity = _validate_source_identity(each_agent.relative_source)
482
+ target_relative_path = _normalize_relative_path(each_agent.name + toml_suffix)
483
+ folded_path = target_relative_path.casefold()
484
+ if folded_path in target_by_name:
485
+ raise _case_fold_collision_error(target_relative_path)
486
+ content = convert_agent(each_agent)
487
+ _validate_orphan_target_is_adoptable(config, existing_by_name.get(folded_path), target_relative_path, content)
488
+ target_by_name[folded_path] = target_relative_path
489
+ target_path = validate_target_path(config.target_root, target_relative_path)
490
+ if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
491
+ raise MaterializerError("planned target collides with compatibility manifest")
492
+ planned.append(PlannedFile(source_identity, target_relative_path, content, hash_content(content)))
493
+ report.unsupported += len(each_agent.unsupported)
494
+ report.details["unsupported"].extend(f"{source_identity}:{each_key}" for each_key in each_agent.unsupported)
495
+ report.planned_files = planned
496
+ return planned, report
497
+
498
+
499
+ def build_plan(config: MaterializerConfig, *all_arguments: object, **all_keywords: object) -> tuple[list[PlannedFile], MaterializationReport]:
500
+ """Build a plan while preserving the legacy optional-agent call form.
501
+
502
+ Args:
503
+ config: Materializer paths and application settings.
504
+ all_arguments: Optional positional discovered-agent iterable.
505
+ all_keywords: Optional ``all_agents`` keyword argument.
506
+
507
+ Returns:
508
+ Planned files and the report describing unsupported agent fields.
509
+
510
+ Raises:
511
+ TypeError: If more than one agent iterable or an unknown keyword is supplied.
512
+ MaterializerError: If a source or target path collides or is unsafe.
513
+ """
514
+ supplied_agents = all_keywords.pop("all_agents", None)
515
+ if all_keywords or len(all_arguments) > 1:
516
+ raise TypeError("build_plan accepts at most one agent iterable")
517
+ if all_arguments:
518
+ if supplied_agents is not None:
519
+ raise TypeError("build_plan received duplicate all_agents")
520
+ supplied_agents = all_arguments[0]
521
+ discovered_agents = discover_agents(config) if supplied_agents is None else supplied_agents
522
+ return _build_plan(config, discovered_agents)
523
+
524
+
525
+ def content_to_bytes(content: ManagedContent) -> bytes:
526
+ """Encode managed text while preserving already-encoded bytes.
527
+
528
+ Args:
529
+ content: Text or bytes intended for publication.
530
+
531
+ Returns:
532
+ UTF-8 bytes for the managed content.
533
+ """
534
+ if isinstance(content, bytes):
535
+ return content
536
+ return content.encode("utf-8")
537
+
538
+
539
+ def hash_content(content: ManagedContent) -> str:
540
+ return hashlib.sha256(content_to_bytes(content)).hexdigest()
541
+
542
+
543
+ def _atomic_write(
544
+ target_path: Path,
545
+ content: ManagedContent,
546
+ failure_injector: Callable[[str], None] | None,
547
+ ) -> None:
548
+ """Write managed content through a temporary file and replace atomically.
549
+
550
+ Args:
551
+ target_path: Destination path for the replacement.
552
+ content: Text or bytes to write.
553
+ failure_injector: Test seam invoked before replacement, or ``None``.
554
+
555
+ Raises:
556
+ OSError: If the temporary file or replacement cannot be written.
557
+ RuntimeError: If the failure injector requests a failed publication.
558
+ ValueError: If the content cannot be encoded.
559
+
560
+ The optional injector is a test seam. When supplied, it runs after the
561
+ temporary file is durable and before the destination replacement.
562
+
563
+ Raises:
564
+ OSError: If the temporary file or replacement cannot be written.
565
+ RuntimeError: If the failure injector requests a failed publication.
566
+ ValueError: If the content cannot be encoded.
567
+ """
568
+ target_path.parent.mkdir(parents=True, exist_ok=True)
569
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target_path.name}.", dir=target_path.parent)
570
+ try:
571
+ with os.fdopen(descriptor, "wb") as temporary_file:
572
+ temporary_file.write(content_to_bytes(content))
573
+ temporary_file.flush()
574
+ os.fsync(temporary_file.fileno())
575
+ if failure_injector is not None:
576
+ failure_injector(str(target_path))
577
+ os.replace(temporary_name, target_path)
578
+ except (OSError, RuntimeError, ValueError):
579
+ Path(temporary_name).unlink(missing_ok=True)
580
+ raise
581
+
582
+
583
+ def atomic_write(target_path: Path, content: ManagedContent, *all_arguments: object, **all_keywords: object) -> None:
584
+ """Preserve the legacy atomic-write call form while using an explicit core.
585
+
586
+ Args:
587
+ target_path: Destination path for the replacement.
588
+ content: Text or bytes to write.
589
+ all_arguments: Legacy positional failure-injector argument.
590
+ all_keywords: Legacy keyword failure-injector argument.
591
+
592
+ Raises:
593
+ TypeError: If more than one failure injector is supplied or it is not callable.
594
+ OSError, RuntimeError, ValueError: If the atomic replacement cannot be completed.
595
+ """
596
+ failure_injector = all_keywords.pop("failure_injector", None)
597
+ if all_keywords or len(all_arguments) > 1:
598
+ raise TypeError("atomic_write accepts at most one failure injector")
599
+ if all_arguments:
600
+ failure_injector = all_arguments[0]
601
+ if failure_injector is not None and not callable(failure_injector):
602
+ raise TypeError("failure injector must be callable")
603
+ _atomic_write(target_path, content, failure_injector)
604
+
605
+
606
+ def load_manifest(manifest_path: Path) -> dict[str, object]:
607
+ """Load a compatibility manifest or return its empty schema.
608
+
609
+ Args:
610
+ manifest_path: Manifest file to read.
611
+
612
+ Returns:
613
+ A validated manifest mapping.
614
+
615
+ Raises:
616
+ MaterializerError: If the manifest has an unsupported shape.
617
+ """
618
+ if not manifest_path.exists():
619
+ return {"version": 1, "files": {}}
620
+ parsed = json.loads(manifest_path.read_text(encoding="utf-8"))
621
+ if not isinstance(parsed, dict) or parsed.get("version") != 1 or not isinstance(parsed.get("files"), dict):
622
+ raise MaterializerError("invalid compatibility manifest")
623
+ return parsed
624
+
625
+
626
+ def save_manifest(manifest_path: Path, all_manifest: dict[str, object], failure_injector: Callable[[str], None] | None = None) -> None:
627
+ """Atomically save the compatibility manifest last.
628
+
629
+ Args:
630
+ manifest_path: Destination manifest path.
631
+ all_manifest: Manifest mapping to serialize.
632
+ failure_injector: Optional test seam invoked before replacement.
633
+
634
+ Raises:
635
+ OSError: If the temporary file or replacement cannot be written.
636
+ RuntimeError: If the failure injector requests a failed publication.
637
+ """
638
+ content = json.dumps(all_manifest, ensure_ascii=False, sort_keys=True, indent=manifest_indentation_width) + "\n"
639
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
640
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{manifest_path.name}.", dir=manifest_path.parent)
641
+ try:
642
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as temporary_file:
643
+ temporary_file.write(content)
644
+ temporary_file.flush()
645
+ os.fsync(temporary_file.fileno())
646
+ if failure_injector is not None:
647
+ failure_injector("manifest_before_replace")
648
+ os.replace(temporary_name, manifest_path)
649
+ except (OSError, RuntimeError, ValueError):
650
+ Path(temporary_name).unlink(missing_ok=True)
651
+ raise
652
+
653
+
654
+ def _parse_manifest_record(raw_record: object) -> ManifestRecord | None:
655
+ """Read one manifest entry into a record, or ``None`` when it is not an object."""
656
+ if not isinstance(raw_record, dict):
657
+ return None
658
+ expected_hash = raw_record.get("hash")
659
+ ownership = raw_record.get("ownership")
660
+ return ManifestRecord(
661
+ expected_hash if isinstance(expected_hash, str) else None,
662
+ ownership if isinstance(ownership, str) else None,
663
+ )
664
+
665
+
666
+ def _manifest_record_by_path(all_previous_manifest: dict[str, object]) -> ManifestRecordByPath:
667
+ """Parse the manifest's file entries once, so untyped JSON stops here.
668
+
669
+ Every path stays in the mapping even when its entry is unreadable, because the
670
+ case-fold checks count the names the manifest claims::
671
+
672
+ {"Luna.toml": {"hash": "ab12..", "ownership": "codex-compat"}} -> ok: record
673
+ {"Luna.toml": 7} -> flag: None
674
+
675
+ Args:
676
+ all_previous_manifest: Validated manifest mapping from ``load_manifest``.
677
+
678
+ Returns:
679
+ Each manifest path mapped to its record, or to ``None`` when unreadable.
680
+
681
+ Raises:
682
+ MaterializerError: If the manifest's file entries are not a mapping.
683
+ """
684
+ records = all_previous_manifest["files"]
685
+ if not isinstance(records, dict):
686
+ raise MaterializerError("invalid compatibility manifest files")
687
+ return {each_path: _parse_manifest_record(each_record) for each_path, each_record in records.items()}
688
+
689
+
690
+ def _find_manifest_record(all_previous_records: ManifestRecordByPath, target_relative_path: str) -> ManifestRecord | None:
691
+ for each_path, each_record in all_previous_records.items():
692
+ if each_path.casefold() == target_relative_path.casefold():
693
+ return each_record
694
+ return None
695
+
696
+
697
+ def _is_known_managed_path(target_root: Path, target_path: Path, all_previous_records: ManifestRecordByPath) -> bool:
698
+ relative_path = target_path.relative_to(target_root).as_posix()
699
+ previous_record = _find_manifest_record(all_previous_records, relative_path)
700
+ return previous_record is not None and previous_record.is_owned_by_tool
701
+
702
+
703
+ def _is_pristine_managed(previous_record: ManifestRecord | None, current_bytes: bytes) -> bool:
704
+ """Report whether on-disk bytes are exactly what the tool last published there."""
705
+ if previous_record is None or not previous_record.is_owned_by_tool:
706
+ return False
707
+ return hash_content(current_bytes) == previous_record.content_hash
708
+
709
+
710
+ def _record_target_conflict(report: MaterializationReport, target_relative_path: str, previous_record: ManifestRecord | None) -> None:
711
+ report.conflicted += 1
712
+ report.add_detail("conflicted", target_relative_path)
713
+ if previous_record is None:
714
+ report.unmanaged_collision += 1
715
+ report.add_detail("unmanaged_collision", target_relative_path)
716
+ return
717
+ report.modified_managed += 1
718
+ report.add_detail("modified_managed", target_relative_path)
719
+
720
+
721
+ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile, all_previous_records: ManifestRecordByPath, report: MaterializationReport) -> tuple[Path, bytes | None, bool]:
722
+ """Classify one planned target and say whether publication may overwrite it.
723
+
724
+ The manifest hash decides ownership, so a file the tool wrote is refreshed and a
725
+ file the user edited is preserved::
726
+
727
+ target absent -> ok: publish
728
+ on-disk bytes == planned bytes -> ok: unchanged, no write
729
+ on-disk hash == manifest hash -> ok: publish, the tool owns these bytes
730
+ on-disk hash != manifest hash -> flag: conflicted, preserved untouched
731
+
732
+ Args:
733
+ config: Materializer paths and application settings.
734
+ planned_file: Planned publication for this target.
735
+ all_previous_records: Manifest records from the last successful run.
736
+ report: Report object to update in place.
737
+
738
+ Returns:
739
+ The resolved target path, its current bytes when it exists, and whether to publish.
740
+ """
741
+ target_path = validate_target_path(config.target_root, planned_file.target_relative_path)
742
+ if not target_path.exists():
743
+ return target_path, None, True
744
+ current_bytes = target_path.read_bytes()
745
+ previous_record = _find_manifest_record(all_previous_records, planned_file.target_relative_path)
746
+ if current_bytes == content_to_bytes(planned_file.content):
747
+ _record_matching_target(report, planned_file.target_relative_path, previous_record)
748
+ return target_path, current_bytes, False
749
+ if _is_pristine_managed(previous_record, current_bytes):
750
+ return target_path, current_bytes, True
751
+ _record_target_conflict(report, planned_file.target_relative_path, previous_record)
752
+ return target_path, current_bytes, False
753
+
754
+
755
+ def _record_matching_target(report: MaterializationReport, target_relative_path: str, previous_record: ManifestRecord | None) -> None:
756
+ report.unchanged += 1
757
+ report.add_detail("unchanged", target_relative_path)
758
+ if previous_record is not None:
759
+ return
760
+ report.adopted += 1
761
+ report.add_detail("adopted", target_relative_path)
762
+
763
+
764
+ def _remove_stale_files(config: MaterializerConfig, all_previous_records: ManifestRecordByPath, all_planned_files: list[PlannedFile], report: MaterializationReport, all_backups: dict[Path, bytes | None]) -> None:
765
+ current_names = {each_planned_file.target_relative_path.casefold() for each_planned_file in all_planned_files}
766
+ for each_relative_path, each_record in sorted(all_previous_records.items(), key=lambda pair: pair[0].casefold()):
767
+ if each_relative_path.casefold() in current_names or each_record is None:
768
+ continue
769
+ target_path = validate_target_path(config.target_root, each_relative_path)
770
+ if not target_path.exists():
771
+ report.add_error(f"missing managed path: {each_relative_path}")
772
+ continue
773
+ current_bytes = target_path.read_bytes()
774
+ expected_hash = each_record.content_hash
775
+ if expected_hash is not None and hash_content(current_bytes) == expected_hash:
776
+ all_backups[target_path] = current_bytes
777
+ target_path.unlink()
778
+ report.deleted += 1
779
+ report.add_detail("deleted", each_relative_path)
780
+ continue
781
+ if expected_hash is not None:
782
+ report.modified_managed += 1
783
+ report.add_detail("modified_managed", each_relative_path)
784
+
785
+
786
+ def _sort_report_details(report: MaterializationReport) -> None:
787
+ for each_category in report_categories:
788
+ if each_category == "errors":
789
+ continue
790
+ report.details[each_category].sort(key=str.casefold)
791
+
792
+
793
+ def _validate_planned_targets(
794
+ all_planned_files: list[PlannedFile],
795
+ all_previous_records: ManifestRecordByPath,
796
+ ) -> None:
797
+ folded_manifest_names = {each_key.casefold() for each_key in all_previous_records}
798
+ if len(folded_manifest_names) != len(all_previous_records):
799
+ raise MaterializerError("case-fold collision in compatibility manifest")
800
+ planned_names: set[str] = set()
801
+ for each_file in all_planned_files:
802
+ folded_target = each_file.target_relative_path.casefold()
803
+ if folded_target in planned_names:
804
+ raise _case_fold_collision_error(each_file.target_relative_path)
805
+ planned_names.add(folded_target)
806
+ has_manifest_owner = any(
807
+ each_path.casefold() == folded_target
808
+ and each_record is not None
809
+ and each_record.ownership is not None
810
+ for each_path, each_record in all_previous_records.items()
811
+ )
812
+ if folded_target in folded_manifest_names and not has_manifest_owner:
813
+ raise MaterializerError("case-fold collision in compatibility manifest")
814
+
815
+
816
+ def _publish_planned_targets(
817
+ config: MaterializerConfig,
818
+ all_planned_files: list[PlannedFile],
819
+ all_previous_records: ManifestRecordByPath,
820
+ report: MaterializationReport,
821
+ all_backups: dict[Path, bytes | None],
822
+ failure_injector: Callable[[str], None] | None,
823
+ ) -> None:
824
+ for each_planned_file in all_planned_files:
825
+ target_path, current_bytes, is_publishable = _record_target_state(config, each_planned_file, all_previous_records, report)
826
+ if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
827
+ raise MaterializerError("planned target collides with compatibility manifest")
828
+ if not is_publishable:
829
+ continue
830
+ all_backups[target_path] = current_bytes
831
+ atomic_write(target_path, each_planned_file.content, failure_injector)
832
+ report.written += 1
833
+ report.add_detail("written", each_planned_file.target_relative_path)
834
+
835
+
836
+ def _build_manifest(all_planned_files: list[PlannedFile]) -> dict[str, object]:
837
+ return {
838
+ "version": 1,
839
+ "files": {
840
+ each_file.target_relative_path: {
841
+ "source": each_file.source_identity,
842
+ "hash": each_file.content_hash,
843
+ "ownership": each_file.ownership,
844
+ "marker": each_file.generated_marker,
845
+ }
846
+ for each_file in all_planned_files
847
+ },
848
+ }
849
+
850
+
851
+ def _rollback_publication(
852
+ all_backups: dict[Path, bytes | None],
853
+ report: MaterializationReport,
854
+ initial_written: int,
855
+ initial_deleted: int,
856
+ ) -> None:
857
+ for each_target_path, each_previous_content in reversed(tuple(all_backups.items())):
858
+ try:
859
+ if each_previous_content is None:
860
+ each_target_path.unlink(missing_ok=True)
861
+ else:
862
+ atomic_write(each_target_path, each_previous_content)
863
+ except OSError:
864
+ report.reconcile_required = True
865
+ report.add_error(f"rollback failed: {each_target_path}")
866
+ report.incomplete_generation = True
867
+ report.reconcile_required = True
868
+ report.written = initial_written
869
+ report.deleted = initial_deleted
870
+ report.details["written"] = report.details["written"][:initial_written]
871
+ report.details["deleted"] = report.details["deleted"][:initial_deleted]
872
+ report.add_error("incomplete_generation/reconcile_required")
873
+ _sort_report_details(report)
874
+
875
+
876
+ def _validate_full_prune_consent(
877
+ config: MaterializerConfig,
878
+ all_planned_files: list[PlannedFile],
879
+ all_previous_records: ManifestRecordByPath,
880
+ ) -> None:
881
+ """Require an explicit opt-in before an empty plan erases every managed file.
882
+
883
+ An empty plan means every managed file is stale, so publication would delete the
884
+ whole set. That is a legitimate request and also what a mistyped source root
885
+ produces, so the caller has to ask for it by name::
886
+
887
+ empty plan, empty manifest -> ok: nothing to delete
888
+ empty plan, managed files, opt-in given -> ok: prune proceeds
889
+ empty plan, managed files, no opt-in -> flag: MaterializerError, nothing deleted
890
+
891
+ Args:
892
+ config: Materializer paths and application settings.
893
+ all_planned_files: Files the current run would publish.
894
+ all_previous_records: Manifest records from the last successful run.
895
+
896
+ Raises:
897
+ MaterializerError: If the run would delete every managed file without the opt-in.
898
+ """
899
+ if all_planned_files or not all_previous_records or config.should_allow_full_prune:
900
+ return
901
+ raise MaterializerError(full_prune_refusal_message.format(count=len(all_previous_records)))
902
+
903
+
904
+ def _publish_plan(
905
+ config: MaterializerConfig,
906
+ all_planned_files: Iterable[PlannedFile],
907
+ report: MaterializationReport,
908
+ failure_injector: Callable[[str], None] | None,
909
+ ) -> MaterializationReport:
910
+ """Publish planned files with rollback and manifest-last semantics.
911
+
912
+ Args:
913
+ config: Materializer paths and application settings.
914
+ all_planned_files: Files to publish, including generic non-TOML content.
915
+ report: Report object to update in place.
916
+ failure_injector: Test seam invoked before each replacement, or ``None``.
917
+
918
+ Returns:
919
+ The updated materialization report.
920
+
921
+ Raises:
922
+ OSError, RuntimeError, ValueError: If publication fails after rollback.
923
+ """
924
+ publication = report
925
+ all_planned_files = list(all_planned_files)
926
+ publication.planned_files = all_planned_files
927
+ if not config.should_apply:
928
+ return publication
929
+ previous_manifest = load_manifest(config.manifest_path)
930
+ previous_records = _manifest_record_by_path(previous_manifest)
931
+ _validate_full_prune_consent(config, all_planned_files, previous_records)
932
+ backups: dict[Path, bytes | None] = {}
933
+ initial_written = publication.written
934
+ initial_deleted = publication.deleted
935
+ try:
936
+ _validate_planned_targets(all_planned_files, previous_records)
937
+ _publish_planned_targets(
938
+ config, all_planned_files, previous_records, publication, backups, failure_injector
939
+ )
940
+ _remove_stale_files(config, previous_records, all_planned_files, publication, backups)
941
+ save_manifest(config.manifest_path, _build_manifest(all_planned_files), failure_injector)
942
+ except (OSError, RuntimeError, ValueError) as error:
943
+ _rollback_publication(backups, publication, initial_written, initial_deleted)
944
+ raise error
945
+ _sort_report_details(publication)
946
+ return publication
947
+
948
+
949
+ def publish_plan(config: MaterializerConfig, *all_arguments: object, **all_keywords: object) -> MaterializationReport:
950
+ """Publish a plan while preserving the legacy optional-argument call form.
951
+
952
+ Args:
953
+ config: Materializer paths and application settings.
954
+ all_arguments: Planned files, optional report, and optional injector.
955
+ all_keywords: ``all_planned_files``, ``report``, or ``failure_injector``.
956
+
957
+ Returns:
958
+ The updated materialization report.
959
+
960
+ Raises:
961
+ TypeError: If required data is missing or arguments are duplicated.
962
+ OSError, RuntimeError, ValueError: If publication fails after rollback.
963
+ """
964
+ planned_files = all_keywords.pop("all_planned_files", None)
965
+ report = all_keywords.pop("report", None)
966
+ failure_injector = all_keywords.pop("failure_injector", None)
967
+ if all_keywords or (planned_files is None and not all_arguments) or len(all_arguments) > publish_plan_max_positional_arguments:
968
+ raise TypeError("publish_plan requires planned files and accepts at most three values")
969
+ if planned_files is not None and all_arguments:
970
+ raise TypeError("publish_plan received duplicate planned files")
971
+ if planned_files is None:
972
+ planned_files = all_arguments[0]
973
+ if len(all_arguments) > 1:
974
+ if report is not None:
975
+ raise TypeError("publish_plan received duplicate report")
976
+ report = all_arguments[1]
977
+ if len(all_arguments) > publish_plan_failure_injector_position:
978
+ if failure_injector is not None:
979
+ raise TypeError("publish_plan received duplicate failure injector")
980
+ failure_injector = all_arguments[2]
981
+ publication = report if isinstance(report, MaterializationReport) else MaterializationReport()
982
+ return _publish_plan(config, planned_files, publication, failure_injector)
983
+
984
+
985
+ def _redact_private_paths(
986
+ message: str,
987
+ config: MaterializerConfig | None,
988
+ all_private_paths: Iterable[Path],
989
+ ) -> str:
990
+ redacted_message = message
991
+ configured_paths = (
992
+ (config.source_root, config.target_root, config.manifest_path)
993
+ if config is not None
994
+ else ()
995
+ )
996
+ for each_private_path in (*configured_paths, *all_private_paths):
997
+ redacted_message = redacted_message.replace(str(each_private_path), "<private-path>")
998
+ return redacted_message
999
+
1000
+
1001
+ def _build_report_payload(
1002
+ report: MaterializationReport,
1003
+ config: MaterializerConfig | None,
1004
+ should_apply: bool,
1005
+ all_private_paths: Iterable[Path],
1006
+ ) -> dict[str, object]:
1007
+ report_payload = {each_category: getattr(report, each_category) for each_category in report_categories}
1008
+ report_payload["incomplete_generation"] = report.incomplete_generation
1009
+ report_payload["reconcile_required"] = report.reconcile_required
1010
+ report_payload["error_details"] = [
1011
+ _redact_private_paths(each_message, config, all_private_paths)
1012
+ for each_message in sorted(report.error_details, key=str.casefold)
1013
+ ]
1014
+ report_payload["details"] = {
1015
+ each_category: [_redact_private_paths(each_message, config, all_private_paths) for each_message in messages]
1016
+ for each_category, messages in report.details.items()
1017
+ }
1018
+ report_payload["dry_run"] = not should_apply
1019
+ return report_payload
1020
+
1021
+
1022
+ def create_argument_parser() -> argparse.ArgumentParser:
1023
+ """Create the command-line parser for compatibility materialization.
1024
+
1025
+ Returns:
1026
+ A parser accepting source and target roots, the apply flag, and the prune opt-in.
1027
+ """
1028
+ parser = MaterializerArgumentParser(description=__doc__)
1029
+ parser.add_argument("source_root", type=Path)
1030
+ parser.add_argument("target_root", type=Path)
1031
+ parser.add_argument("--apply", dest="should_apply", action="store_true")
1032
+ parser.add_argument(full_prune_opt_in_flag, dest="should_allow_full_prune", action="store_true")
1033
+ return parser
1034
+
1035
+
1036
+ def main(*all_arguments: object) -> int:
1037
+ """Run materialization from command-line arguments.
1038
+
1039
+ Args:
1040
+ all_arguments: Optional argument list; no value uses process arguments.
1041
+
1042
+ Returns:
1043
+ Zero after reporting the materialization result.
1044
+
1045
+ Raises:
1046
+ TypeError: If the optional argument list has an invalid shape or type.
1047
+ MaterializerError: If the source and target configuration is unsafe.
1048
+ OSError, RuntimeError, ValueError: If discovery, planning, or publication fails.
1049
+ """
1050
+ report = MaterializationReport()
1051
+ config: MaterializerConfig | None = None
1052
+ should_apply = False
1053
+ source_root: Path | None = None
1054
+ target_root: Path | None = None
1055
+ try:
1056
+ if len(all_arguments) > 1:
1057
+ raise TypeError("main accepts at most one argument list")
1058
+ cli_arguments = all_arguments[0] if all_arguments else None
1059
+ if cli_arguments is not None and not isinstance(cli_arguments, list):
1060
+ raise TypeError("main argument must be a list of command-line strings")
1061
+ options = create_argument_parser().parse_args(cli_arguments)
1062
+ should_apply = options.should_apply
1063
+ source_root = options.source_root
1064
+ target_root = options.target_root
1065
+ config = MaterializerConfig(
1066
+ source_root,
1067
+ target_root,
1068
+ should_apply=should_apply,
1069
+ should_allow_full_prune=options.should_allow_full_prune,
1070
+ )
1071
+ discovered_agents = discover_agents(config)
1072
+ planned, report = build_plan(config, all_agents=discovered_agents)
1073
+ publish_plan(config, all_planned_files=planned, report=report, failure_injector=None)
1074
+ except (MaterializerError, OSError, RuntimeError, ValueError) as error:
1075
+ report.add_error(str(error))
1076
+ all_private_paths = tuple(
1077
+ each_path
1078
+ for each_path in (source_root, target_root)
1079
+ if each_path is not None
1080
+ )
1081
+ report_payload = _build_report_payload(report, config, should_apply, all_private_paths)
1082
+ print(json.dumps(report_payload, sort_keys=True))
1083
+ return 1 if report.errors or report.conflicted else 0
1084
+
1085
+
1086
+ if __name__ == "__main__":
1087
+ raise SystemExit(main())