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,599 @@
1
+ from pathlib import Path
2
+ from threading import Event, Lock, Thread
3
+
4
+ import pytest
5
+
6
+ from codex_compat_watcher import (
7
+ DebouncedReconciler,
8
+ LinkCapabilities,
9
+ LinkDecision,
10
+ LinkSpec,
11
+ LinkPlan,
12
+ LinkReport,
13
+ WatchEvent,
14
+ build_link_plan,
15
+ publish_link_plan,
16
+ probe_link_capabilities,
17
+ )
18
+
19
+
20
+ def test_junction_capability_without_creator_fails_truthfully(tmp_path: Path) -> None:
21
+ source_root = tmp_path / "source"
22
+ source_root.mkdir()
23
+ (source_root / "source").write_text("source", encoding="utf-8")
24
+ plan = build_link_plan(
25
+ [LinkSpec("source", "target", requested_kind="junction")],
26
+ source_root,
27
+ tmp_path / "target",
28
+ LinkCapabilities(False, True),
29
+ should_apply=True,
30
+ should_allow_links=True,
31
+ )
32
+
33
+ report = publish_link_plan(plan, source_root, tmp_path / "target")
34
+
35
+ assert report.applied == 0
36
+ assert report.is_reconcile_required
37
+ assert "unsupported" in report.errors[0]
38
+ assert not (tmp_path / "target").exists()
39
+
40
+
41
+ def test_link_report_preserves_reconcile_required_read_write_api() -> None:
42
+ report = LinkReport()
43
+
44
+ assert report.reconcile_required is False
45
+ report.reconcile_required = True
46
+ assert report.is_reconcile_required is True
47
+
48
+
49
+ def test_unavailable_junction_capability_falls_back_to_copy(tmp_path: Path) -> None:
50
+ plan = build_link_plan(
51
+ [LinkSpec("source", "target", requested_kind="junction")],
52
+ tmp_path / "source",
53
+ tmp_path / "target",
54
+ LinkCapabilities(False, False),
55
+ )
56
+
57
+ assert [(decision.kind, decision.reason) for decision in plan.decisions] == [
58
+ ("copy", "link capability unavailable")
59
+ ]
60
+
61
+
62
+ def test_default_copier_publishes_file_and_directory(tmp_path: Path) -> None:
63
+ source_root = tmp_path / "source"
64
+ target_root = tmp_path / "target"
65
+ source_root.mkdir()
66
+ (source_root / "file.txt").write_text("file", encoding="utf-8")
67
+ (source_root / "tree").mkdir()
68
+ (source_root / "tree" / "nested.txt").write_text("nested", encoding="utf-8")
69
+ plan = build_link_plan(
70
+ [LinkSpec("file.txt", "file.txt"), LinkSpec("tree", "tree")],
71
+ source_root,
72
+ target_root,
73
+ LinkCapabilities(False, False),
74
+ should_apply=True,
75
+ should_allow_links=True,
76
+ )
77
+
78
+ report = publish_link_plan(plan, source_root, target_root)
79
+
80
+ assert report.applied == 2
81
+ assert (target_root / "file.txt").read_text(encoding="utf-8") == "file"
82
+ assert (target_root / "tree" / "nested.txt").read_text(encoding="utf-8") == "nested"
83
+
84
+
85
+ def test_default_copier_rejects_reparse_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
86
+ source_root = tmp_path / "source"
87
+ source_root.mkdir()
88
+ (source_root / "file.txt").write_text("file", encoding="utf-8")
89
+ plan = build_link_plan(
90
+ [LinkSpec("file.txt", "file.txt")],
91
+ source_root,
92
+ tmp_path / "target",
93
+ LinkCapabilities(False, False),
94
+ should_apply=True,
95
+ should_allow_links=True,
96
+ )
97
+ monkeypatch.setattr("codex_compat_watcher._is_reparse_point", lambda source_path: True)
98
+
99
+ report = publish_link_plan(plan, source_root, tmp_path / "target")
100
+
101
+ assert report.applied == 0
102
+ assert report.is_reconcile_required
103
+ assert not (tmp_path / "target" / "file.txt").exists()
104
+
105
+
106
+ def test_link_publication_rejects_source_symlink_without_callback(
107
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
108
+ ) -> None:
109
+ source_root = tmp_path / "source"
110
+ source_root.mkdir()
111
+ source_path = source_root / "linked.txt"
112
+ source_path.write_text("source", encoding="utf-8")
113
+ original_is_symlink = Path.is_symlink
114
+ monkeypatch.setattr(
115
+ Path,
116
+ "is_symlink",
117
+ lambda each_path: each_path == source_path or original_is_symlink(each_path),
118
+ )
119
+ publication_calls: list[tuple[Path, Path]] = []
120
+ plan = LinkPlan([LinkDecision("linked.txt", "linked.txt", "symlink", "apply", "test")], is_dry_run=False)
121
+
122
+ report = publish_link_plan(
123
+ plan,
124
+ source_root,
125
+ tmp_path / "target",
126
+ symlink_creator=lambda source, target, is_directory: publication_calls.append((source, target)),
127
+ )
128
+
129
+ assert report.applied == 0
130
+ assert report.is_reconcile_required
131
+ assert publication_calls == []
132
+ assert not (tmp_path / "target").exists()
133
+
134
+
135
+ def test_link_publication_rejects_source_tree_reparse_without_callback(
136
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
137
+ ) -> None:
138
+ source_root = tmp_path / "source"
139
+ source_root.mkdir()
140
+ (source_root / "tree").mkdir()
141
+ nested_path = source_root / "tree" / "nested.txt"
142
+ nested_path.write_text("source", encoding="utf-8")
143
+ monkeypatch.setattr(
144
+ "codex_compat_watcher._is_reparse_point",
145
+ lambda each_path: each_path == nested_path,
146
+ )
147
+ publication_calls: list[tuple[Path, Path]] = []
148
+ plan = LinkPlan([LinkDecision("tree", "tree", "junction", "apply", "test")], is_dry_run=False)
149
+
150
+ report = publish_link_plan(
151
+ plan,
152
+ source_root,
153
+ tmp_path / "target",
154
+ junction_creator=lambda source, target: publication_calls.append((source, target)),
155
+ )
156
+
157
+ assert report.applied == 0
158
+ assert report.is_reconcile_required
159
+ assert publication_calls == []
160
+ assert not (tmp_path / "target").exists()
161
+
162
+
163
+ def test_link_publication_rejects_symlink_source_root_without_callback(tmp_path: Path) -> None:
164
+ real_source_root = tmp_path / "real-source"
165
+ real_source_root.mkdir()
166
+ (real_source_root / "source.txt").write_text("source", encoding="utf-8")
167
+ linked_source_root = tmp_path / "source"
168
+ try:
169
+ linked_source_root.symlink_to(real_source_root, target_is_directory=True)
170
+ except OSError as error:
171
+ pytest.skip(f"directory symlinks unavailable: {error}")
172
+ publication_calls: list[str] = []
173
+ plan = build_link_plan(
174
+ [LinkSpec("source.txt", "target.txt")],
175
+ linked_source_root,
176
+ tmp_path / "target",
177
+ LinkCapabilities(False, False),
178
+ should_apply=True,
179
+ should_allow_links=True,
180
+ )
181
+
182
+ report = publish_link_plan(
183
+ plan,
184
+ linked_source_root,
185
+ tmp_path / "target",
186
+ copier=lambda source_path, target_path: publication_calls.append("copy"),
187
+ )
188
+
189
+ assert report.applied == 0
190
+ assert report.is_reconcile_required
191
+ assert "source_root_path cannot be a symlink or reparse point" in report.errors
192
+ assert publication_calls == []
193
+ assert not (tmp_path / "target").exists()
194
+
195
+
196
+ @pytest.mark.parametrize("publication_kind", ["copy", "symlink", "junction", "materialize"])
197
+ def test_link_publication_rejects_missing_source_without_callback(
198
+ tmp_path: Path, publication_kind: str
199
+ ) -> None:
200
+ publication_calls: list[str] = []
201
+ plan = LinkPlan(
202
+ [LinkDecision("missing", "target", publication_kind, "apply", "test")],
203
+ is_dry_run=False,
204
+ )
205
+
206
+ report = publish_link_plan(
207
+ plan,
208
+ tmp_path / "source",
209
+ tmp_path / "target",
210
+ copier=lambda source_path, target_path: publication_calls.append("copy"),
211
+ symlink_creator=lambda source_path, target_path, is_directory: publication_calls.append(
212
+ "symlink"
213
+ ),
214
+ junction_creator=lambda source_path, target_path: publication_calls.append("junction"),
215
+ materializer=lambda: publication_calls.append("materialize"),
216
+ )
217
+
218
+ assert report.applied == 0
219
+ assert report.is_reconcile_required
220
+ assert "publication source does not exist" in report.errors
221
+ assert publication_calls == []
222
+ assert not (tmp_path / "target").exists()
223
+
224
+
225
+ def test_default_copier_reports_copy_failure_without_application(tmp_path: Path) -> None:
226
+ source_root = tmp_path / "source"
227
+ source_root.mkdir()
228
+ (source_root / "file.txt").write_text("file", encoding="utf-8")
229
+ plan = build_link_plan(
230
+ [LinkSpec("file.txt", "file.txt")],
231
+ source_root,
232
+ tmp_path / "target",
233
+ LinkCapabilities(False, False),
234
+ should_apply=True,
235
+ should_allow_links=True,
236
+ )
237
+
238
+ def fail_copy(source_path: Path, target_path: Path) -> None:
239
+ raise OSError("copy failed")
240
+
241
+ report = publish_link_plan(plan, source_root, tmp_path / "target", copier=fail_copy)
242
+
243
+ assert report.applied == 0
244
+ assert report.reconcile_required
245
+ assert "copy failed" in report.errors
246
+
247
+
248
+ def test_default_copier_never_overwrites_unmanaged_target(tmp_path: Path) -> None:
249
+ source_root = tmp_path / "source"
250
+ target_root = tmp_path / "target"
251
+ source_root.mkdir()
252
+ target_root.mkdir()
253
+ (source_root / "file.txt").write_text("new", encoding="utf-8")
254
+ (target_root / "file.txt").write_text("existing", encoding="utf-8")
255
+ plan = LinkPlan([LinkDecision("file.txt", "file.txt", "copy", "apply", "test")], is_dry_run=False)
256
+
257
+ report = publish_link_plan(plan, source_root, target_root)
258
+
259
+ assert report.applied == 0
260
+ assert (target_root / "file.txt").read_text(encoding="utf-8") == "existing"
261
+
262
+
263
+ def test_explicit_materialize_invokes_materializer_once(tmp_path: Path) -> None:
264
+ calls: list[str] = []
265
+ source_root = tmp_path / "source"
266
+ source_root.mkdir()
267
+ (source_root / "converted").write_text("source", encoding="utf-8")
268
+ plan = LinkPlan(
269
+ decisions=[
270
+ LinkDecision(
271
+ "converted", "target", "materialize", "apply", "explicit materialization"
272
+ )
273
+ ],
274
+ is_dry_run=False,
275
+ )
276
+
277
+ report = publish_link_plan(
278
+ plan,
279
+ source_root,
280
+ tmp_path / "target",
281
+ materializer=lambda: calls.append("materialize"),
282
+ )
283
+
284
+ assert report.applied == 1
285
+ assert calls == ["materialize"]
286
+
287
+
288
+ def test_source_only_and_unsupported_never_invoke_materializer(tmp_path: Path) -> None:
289
+ calls: list[str] = []
290
+ plan = build_link_plan(
291
+ [
292
+ LinkSpec("source", "source", classification="source_only"),
293
+ LinkSpec("unsupported", "unsupported", classification="unsupported"),
294
+ ],
295
+ tmp_path / "source",
296
+ tmp_path / "target",
297
+ LinkCapabilities(True, True),
298
+ should_apply=True,
299
+ should_allow_links=True,
300
+ should_allow_activation=True,
301
+ )
302
+
303
+ report = publish_link_plan(
304
+ plan,
305
+ tmp_path / "source",
306
+ tmp_path / "target",
307
+ materializer=lambda: calls.append("materialize"),
308
+ )
309
+
310
+ assert report.applied == 0
311
+ assert report.skipped == 2
312
+ assert calls == []
313
+
314
+
315
+ def test_capability_fallback_and_classification(tmp_path: Path) -> None:
316
+ plan = build_link_plan(
317
+ [
318
+ LinkSpec("source.txt", "target.txt"),
319
+ LinkSpec("converted.md", "converted.toml", is_byte_compatible=False),
320
+ ],
321
+ tmp_path / "source",
322
+ tmp_path / "target",
323
+ LinkCapabilities(False, False),
324
+ )
325
+
326
+ assert [(decision.kind, decision.reason) for decision in plan.decisions] == [
327
+ ("copy", "link capability unavailable"),
328
+ ("materialize", "converted asset requires materializer"),
329
+ ]
330
+
331
+
332
+ def test_source_only_and_unsupported_assets_never_activate_or_link(tmp_path: Path) -> None:
333
+ plan = build_link_plan(
334
+ [
335
+ LinkSpec("source.md", "source.md", classification="source_only"),
336
+ LinkSpec("unsupported.md", "unsupported.md", classification="unsupported"),
337
+ ],
338
+ tmp_path / "source",
339
+ tmp_path / "target",
340
+ LinkCapabilities(True, True),
341
+ should_apply=True,
342
+ should_allow_links=True,
343
+ should_allow_activation=True,
344
+ )
345
+
346
+ assert [decision.action for decision in plan.decisions] == ["report", "report"]
347
+ assert publish_link_plan(plan, tmp_path / "source", tmp_path / "target").applied == 0
348
+
349
+
350
+ def test_default_capability_probe_uses_copy_fallback() -> None:
351
+ assert probe_link_capabilities() == LinkCapabilities(False, False)
352
+
353
+
354
+ def test_capability_probe_uses_injected_results_only_when_opted_in() -> None:
355
+ assert probe_link_capabilities(lambda: True, lambda: True) == LinkCapabilities(False, False)
356
+ assert probe_link_capabilities(lambda: True, lambda: True, should_allow_temporary_probe=True) == LinkCapabilities(True, True)
357
+
358
+
359
+ def test_build_link_plan_forwards_optional_capability_probes(tmp_path: Path) -> None:
360
+ source_root = tmp_path / "source"
361
+ source_root.mkdir()
362
+
363
+ plan = build_link_plan(
364
+ [LinkSpec("source.txt", "target.txt")],
365
+ source_root,
366
+ tmp_path / "target",
367
+ should_allow_temporary_probe=True,
368
+ symlink_probe=lambda: True,
369
+ junction_probe=lambda: True,
370
+ )
371
+
372
+ assert plan.decisions[0].kind == "symlink"
373
+
374
+
375
+ def test_dry_run_never_creates_links_or_activates(tmp_path: Path) -> None:
376
+ plan = build_link_plan(
377
+ [LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
378
+ tmp_path / "source",
379
+ tmp_path / "target",
380
+ LinkCapabilities(True, True),
381
+ should_apply=True,
382
+ should_allow_links=False,
383
+ should_allow_activation=False,
384
+ )
385
+ report = publish_link_plan(plan, tmp_path / "source", tmp_path / "target")
386
+
387
+ assert report.applied == 0
388
+ assert report.skipped == 1
389
+ assert not (tmp_path / "target" / "hook.py").exists()
390
+
391
+
392
+ def test_link_publication_requires_apply_and_both_permissions(tmp_path: Path) -> None:
393
+ publication_calls: list[tuple[Path, Path]] = []
394
+
395
+ def record_publication(source_path: Path, target_path: Path) -> None:
396
+ publication_calls.append((source_path, target_path))
397
+
398
+ for should_apply, should_allow_links, should_allow_activation in ((False, True, True), (True, False, True)):
399
+ plan = build_link_plan(
400
+ [LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
401
+ tmp_path / "source",
402
+ tmp_path / "target",
403
+ LinkCapabilities(True, True),
404
+ should_apply=should_apply,
405
+ should_allow_links=should_allow_links,
406
+ should_allow_activation=should_allow_activation,
407
+ )
408
+ publish_link_plan(plan, tmp_path / "source", tmp_path / "target", copier=record_publication)
409
+
410
+ assert publication_calls == []
411
+
412
+
413
+ def test_authorized_activation_reaches_injected_link_operation(tmp_path: Path) -> None:
414
+ publication_calls: list[tuple[Path, Path]] = []
415
+ source_root = tmp_path / "source"
416
+ source_root.mkdir()
417
+ (source_root / "hook.py").write_text("hook", encoding="utf-8")
418
+
419
+ plan = build_link_plan(
420
+ [LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
421
+ source_root,
422
+ tmp_path / "target",
423
+ LinkCapabilities(True, True),
424
+ should_apply=True,
425
+ should_allow_links=True,
426
+ should_allow_activation=True,
427
+ )
428
+ report = publish_link_plan(
429
+ plan,
430
+ source_root,
431
+ tmp_path / "target",
432
+ copier=lambda source_path, target_path: publication_calls.append((source_path, target_path)),
433
+ symlink_creator=lambda source_path, target_path, is_directory: publication_calls.append((source_path, target_path)),
434
+ )
435
+
436
+ assert report.applied == 1
437
+ assert len(publication_calls) == 1
438
+
439
+
440
+ def test_path_safety_and_unmanaged_collision(tmp_path: Path) -> None:
441
+ target_root = tmp_path / "target"
442
+ target_root.mkdir()
443
+ (target_root / "existing.txt").write_text("owned by user", encoding="utf-8")
444
+ plan = build_link_plan(
445
+ [LinkSpec("../escape", "escape"), LinkSpec("source", "existing.txt")],
446
+ tmp_path / "source",
447
+ target_root,
448
+ LinkCapabilities(True, False),
449
+ )
450
+
451
+ assert len(plan.errors) == 2
452
+ assert all("\\" not in error for error in plan.errors)
453
+
454
+
455
+ def test_debounced_reconciler_coalesces_and_suppresses_self_events() -> None:
456
+ calls: list[str] = []
457
+ reconciler = DebouncedReconciler(lambda: calls.append("run"), debounce_seconds=60.0)
458
+
459
+ reconciler.notify(WatchEvent("agents", is_self_event=True))
460
+ assert reconciler.flush(should_force=True) is None
461
+ reconciler.notify(WatchEvent("agents"))
462
+ reconciler.notify(WatchEvent("skills"))
463
+ assert reconciler.flush(should_force=True) is None
464
+ assert calls == ["run"]
465
+
466
+
467
+ def test_each_configured_root_triggers_reconciliation() -> None:
468
+ calls: list[str] = []
469
+ reconciler = DebouncedReconciler(lambda: calls.append("run") or "run")
470
+
471
+ for each_source_root in ("agents", "commands", "hooks", "rules", "skills"):
472
+ reconciler.notify(WatchEvent(each_source_root))
473
+
474
+ assert reconciler.flush(should_force=True) == "run"
475
+ assert calls == ["run"]
476
+
477
+
478
+ def test_self_event_is_ignored_before_unsafe_event_handling() -> None:
479
+ reconciler = DebouncedReconciler(lambda: "run")
480
+
481
+ reconciler.notify(WatchEvent("hooks", kind="overflow", is_self_event=True, path="hooks/events"))
482
+
483
+ assert reconciler.flush(should_force=True) is None
484
+
485
+
486
+ def test_flush_serializes_reconciliation_callbacks() -> None:
487
+ callback_started = Event()
488
+ release_callback = Event()
489
+ callback_lock = Lock()
490
+ active_callbacks = 0
491
+ maximum_active_callbacks = 0
492
+
493
+ def reconcile() -> str:
494
+ nonlocal active_callbacks, maximum_active_callbacks
495
+ with callback_lock:
496
+ active_callbacks += 1
497
+ maximum_active_callbacks = max(maximum_active_callbacks, active_callbacks)
498
+ callback_started.set()
499
+ release_callback.wait()
500
+ with callback_lock:
501
+ active_callbacks -= 1
502
+ return "run"
503
+
504
+ reconciler = DebouncedReconciler(reconcile)
505
+ reconciler.notify(WatchEvent("hooks"))
506
+ first_flush = Thread(target=reconciler.flush, kwargs={"should_force": True})
507
+ first_flush.start()
508
+ assert callback_started.wait()
509
+
510
+ reconciler.notify(WatchEvent("rules"))
511
+ assert reconciler.flush(should_force=True) is None
512
+ release_callback.set()
513
+ first_flush.join()
514
+
515
+ assert reconciler.flush(should_force=True) == "run"
516
+ assert maximum_active_callbacks == 1
517
+
518
+
519
+ def test_reconciler_failure_requests_reconciliation() -> None:
520
+ def fail() -> None:
521
+ raise OSError("inaccessible")
522
+
523
+ reconciler = DebouncedReconciler(fail)
524
+ reconciler.notify(WatchEvent("hooks"))
525
+
526
+ assert reconciler.flush(should_force=True) == {
527
+ "reconcile_required": True,
528
+ "errors": ["inaccessible"],
529
+ "reconcile_reasons": [],
530
+ "reconcile_paths": [],
531
+ }
532
+
533
+
534
+ def test_unsafe_event_is_reconciled_with_reason_and_path() -> None:
535
+ reconciler = DebouncedReconciler(lambda: {"written": 1})
536
+
537
+ reconciler.notify(WatchEvent("hooks", kind="overflow", path="hooks/events"))
538
+
539
+ assert reconciler.flush(should_force=True) == {
540
+ "written": 1,
541
+ "reconcile_required": True,
542
+ "reconcile_reasons": ["overflow"],
543
+ "reconcile_paths": ["hooks/events"],
544
+ }
545
+
546
+
547
+ def test_out_of_scope_unsafe_events_are_ignored() -> None:
548
+ reconciler = DebouncedReconciler(lambda: {"written": 1})
549
+
550
+ reconciler.notify(WatchEvent("private", kind="overflow", path="private/events"))
551
+
552
+ assert reconciler.flush(should_force=True) is None
553
+
554
+
555
+ def test_unsafe_event_details_are_sorted() -> None:
556
+ reconciler = DebouncedReconciler(lambda: {"written": 1})
557
+ reconciler.notify(WatchEvent("hooks", kind="unknown", path="z"))
558
+ reconciler.notify(WatchEvent("agents", kind="overflow", path="a"))
559
+
560
+ assert reconciler.flush(should_force=True) == {
561
+ "written": 1,
562
+ "reconcile_required": True,
563
+ "reconcile_reasons": ["overflow", "unknown"],
564
+ "reconcile_paths": ["a", "z"],
565
+ }
566
+
567
+
568
+ def test_materialized_reconciliation_delegates_to_materializer(monkeypatch: pytest.MonkeyPatch) -> None:
569
+ calls: list[str] = []
570
+
571
+ def fake_build_plan(config: object) -> tuple[list[object], object]:
572
+ calls.append("build")
573
+ return [], "report"
574
+
575
+ def fake_publish_plan(config: object, planned_files: list[object], report: object) -> object:
576
+ calls.append("publish")
577
+ return report
578
+
579
+ monkeypatch.setattr("codex_compat_watcher.build_plan", fake_build_plan)
580
+ monkeypatch.setattr("codex_compat_watcher.publish_plan", fake_publish_plan)
581
+
582
+ assert __import__("codex_compat_watcher").reconcile_materialized_agents(object()) == "report"
583
+ assert calls == ["build", "publish"]
584
+
585
+
586
+ def test_private_paths_are_rejected(tmp_path: Path) -> None:
587
+ safe_plan = build_link_plan(
588
+ [LinkSpec("safe/source", "safe")],
589
+ tmp_path / "source",
590
+ tmp_path / "target",
591
+ )
592
+ assert len(safe_plan.decisions) == 1
593
+
594
+ with pytest.raises(ValueError):
595
+ build_link_plan(
596
+ [LinkSpec(str(tmp_path / "private" / "secret"), "safe")],
597
+ tmp_path / "source",
598
+ tmp_path / "target",
599
+ )
@@ -38,7 +38,6 @@ def _minimal_rule_files(claude_rules: Path) -> None:
38
38
  "---\npaths:\n - \"Y:/x/**\"\n---\n\n# Tasklings\n",
39
39
  encoding="utf-8",
40
40
  )
41
- (claude_rules / "right-sized-engineering.md").write_text("# RSE\n", encoding="utf-8")
42
41
  (claude_rules / "bdd.md").write_text("# BDD\n", encoding="utf-8")
43
42
  (claude_rules / "testing.md").write_text(
44
43
  "---\npaths:\n - \"**/test_*.py\"\n---\n\n# Testing\n",
package/skills/CLAUDE.md CHANGED
@@ -49,6 +49,8 @@ Skills install to `~/.claude/skills/<skill-name>/` via `packages/claude-dev-env/
49
49
  - `session-log` — logs a session report to the Obsidian vault
50
50
  - `session-tidy` — tidies the session folder
51
51
  - `task-build` — gathers open tasks
52
+ - `issue-tracker` — one consistent way to create, update in place, and close GitHub issues for a work-stream: one epic parent with native sub-issues, dedup-first, marker-delimited body sections edited in place, and an epic checklist mirroring the children
53
+ - `closeout` — session-end entry that harvests obstacles into issue-candidate records and delegates filing to the `issue-tracker` agent (skill fallback), keeping the user-validation gate on each draft
52
54
  - `privacy-hygiene` — full-repo personal-data and secret sweep plus remediation guide
53
55
  - `update` — updates the dev-env package
54
56
  - `fresh-branch` — creates a clean branch off main