claude-dev-env 2.5.0 → 2.7.1

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 (153) hide show
  1. package/CLAUDE.md +20 -57
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  12. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  13. package/_shared/pr-loop/worker-spawn.md +3 -1
  14. package/agents/CLAUDE.md +1 -1
  15. package/agents/code-verifier.md +36 -7
  16. package/bin/codex-compat.mjs +104 -0
  17. package/bin/codex-compat.test.mjs +51 -0
  18. package/codex-capability-map.json +13 -0
  19. package/docs/CODE_RULES.md +2 -0
  20. package/docs/codex-compatibility.md +25 -0
  21. package/docs/nas-ssh-invocation.md +96 -12
  22. package/docs/references/code-review-enforcement.md +31 -6
  23. package/hooks/blocking/CLAUDE.md +3 -0
  24. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  25. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  26. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  27. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  28. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  29. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  30. package/hooks/blocking/state_description_blocker.py +1 -1
  31. package/hooks/blocking/stop_dispatcher.py +1 -1
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  33. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  34. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  35. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  36. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  38. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  39. package/hooks/git-hooks/CLAUDE.md +3 -0
  40. package/hooks/git-hooks/conftest.py +30 -0
  41. package/hooks/git-hooks/gate_utils.py +2 -2
  42. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  43. package/hooks/git-hooks/pre_push.py +75 -4
  44. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  45. package/hooks/git-hooks/test_config.py +0 -15
  46. package/hooks/git-hooks/test_gate_utils.py +3 -15
  47. package/hooks/git-hooks/test_pre_commit.py +1 -15
  48. package/hooks/git-hooks/test_pre_push.py +236 -27
  49. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  50. package/hooks/hooks.json +0 -12
  51. package/hooks/hooks_constants/CLAUDE.md +5 -1
  52. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  53. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  54. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  55. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  56. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  57. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  58. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  59. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  60. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  61. package/package.json +4 -2
  62. package/rules/CLAUDE.md +17 -23
  63. package/rules/agent-spawn-protocol.md +6 -6
  64. package/rules/anti-corollary-tests.md +1 -1
  65. package/rules/bdd.md +1 -1
  66. package/rules/cleanup-temp-files.md +10 -4
  67. package/rules/code-standards.md +7 -0
  68. package/rules/conservative-action.md +1 -5
  69. package/rules/context7.md +0 -4
  70. package/rules/destructive-commands.md +47 -0
  71. package/rules/doc-inventory-integrity.md +48 -0
  72. package/rules/doc-prose-cuts.md +58 -0
  73. package/rules/docstring-prose-matches-implementation.md +10 -2
  74. package/rules/durable-post-artifacts.md +0 -4
  75. package/rules/eli11-replies.md +31 -0
  76. package/rules/explore-thoroughly.md +4 -4
  77. package/rules/falsify-before-green.md +68 -0
  78. package/rules/file-global-constants.md +1 -1
  79. package/rules/filesystem-search.md +51 -0
  80. package/rules/gh-cli-conventions.md +27 -0
  81. package/rules/git-workflow.md +26 -0
  82. package/rules/hedging-claims.md +9 -0
  83. package/rules/long-horizon-autonomy.md +0 -4
  84. package/rules/measurement-denominators.md +48 -0
  85. package/rules/nas-ssh-invocation.md +23 -5
  86. package/rules/parallel-tools.md +2 -2
  87. package/rules/plain-illustrative-docstrings.md +3 -7
  88. package/rules/plain-language.md +2 -0
  89. package/rules/proof-of-work-pr-comments.md +0 -4
  90. package/rules/re-stage-before-commit.md +2 -0
  91. package/rules/research-mode.md +10 -0
  92. package/rules/shell-invocation.md +21 -0
  93. package/rules/testing.md +4 -0
  94. package/rules/verified-commit-gate-skip.md +3 -27
  95. package/rules/verify-before-asking.md +5 -0
  96. package/rules/windows-filesystem-safe.md +1 -1
  97. package/rules/workers-done-before-complete.md +4 -0
  98. package/scripts/CLAUDE.md +3 -3
  99. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  100. package/scripts/codex_capability_bridge.py +171 -0
  101. package/scripts/codex_compat_materializer.py +1087 -0
  102. package/scripts/codex_compat_watcher.py +502 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  104. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  105. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
  106. package/scripts/grok_headless_runner.py +213 -16
  107. package/scripts/invoke_code_review.py +11 -4
  108. package/scripts/resolve_worker_spawn.py +56 -10
  109. package/scripts/spawn_grok_batch.py +43 -22
  110. package/scripts/sync_to_cursor/rules.py +0 -10
  111. package/scripts/test_grok_headless_runner.py +592 -10
  112. package/scripts/test_invoke_code_review.py +143 -0
  113. package/scripts/test_invoke_code_review_chain.py +1 -1
  114. package/scripts/test_invoke_code_review_contract.py +1 -1
  115. package/scripts/test_resolve_worker_spawn.py +179 -15
  116. package/scripts/test_spawn_grok_batch.py +225 -22
  117. package/scripts/tests/test_code_review_constants.py +80 -0
  118. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  119. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  120. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  121. package/scripts/tests/test_sync_to_cursor.py +0 -1
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
  123. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
  124. package/skills/autoconverge/workflow/converge.mjs +64 -15
  125. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  126. package/skills/condensing-instructions/SKILL.md +42 -51
  127. package/skills/fresh-branch/CLAUDE.md +1 -1
  128. package/skills/fresh-branch/SKILL.md +5 -6
  129. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  131. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  132. package/skills/grok-spawn/SKILL.md +5 -3
  133. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  134. package/skills/orchestrator/SKILL.md +23 -9
  135. package/skills/orchestrator-refresh/SKILL.md +20 -1
  136. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  137. package/skills/session-log/SKILL.md +1 -1
  138. package/rules/claude-md-orphan-file.md +0 -28
  139. package/rules/cleanup-command-forms.md +0 -23
  140. package/rules/code-reviews.md +0 -11
  141. package/rules/env-var-table-code-drift.md +0 -10
  142. package/rules/gh-body-file.md +0 -5
  143. package/rules/gh-paginate.md +0 -3
  144. package/rules/hook-prose-matches-detector.md +0 -15
  145. package/rules/no-historical-clutter.md +0 -26
  146. package/rules/no-inline-destructive-literals.md +0 -9
  147. package/rules/no-justification-noise.md +0 -61
  148. package/rules/package-inventory-stale-entry.md +0 -25
  149. package/rules/right-sized-engineering.md +0 -28
  150. package/rules/self-contained-docs.md +0 -17
  151. package/rules/shell-invocation-policy.md +0 -5
  152. package/rules/state-what-is.md +0 -25
  153. package/rules/tdd.md +0 -7
@@ -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",
@@ -434,7 +434,7 @@ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse fo
434
434
  );
435
435
  });
436
436
 
437
- test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids edits', () => {
437
+ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids editing the tree under verification', () => {
438
438
  for (const verifyFunctionName of ['runVerifierTask']) {
439
439
  const verifyBody = lensPromptBody(verifyFunctionName);
440
440
  assert.match(
@@ -454,8 +454,8 @@ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and fo
454
454
  );
455
455
  assert.match(
456
456
  verifyBody,
457
- /do no edits|make no edits|not edit|no file edits/i,
458
- `expected ${verifyFunctionName} to be told to make no edits`,
457
+ /(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
458
+ `expected ${verifyFunctionName} to forbid editing the tree under verification (a deliberate break off that tree stays allowed)`,
459
459
  );
460
460
  }
461
461
  });
@@ -467,7 +467,7 @@ test('runFixerTask never verifies — verification belongs to the separate verif
467
467
  assert.match(fixerBody, /agentType:\s*'clean-coder'/, 'expected the fixer to use clean-coder for its commit and recovery edits');
468
468
  });
469
469
 
470
- test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids edits', () => {
470
+ test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids editing the tree under verification', () => {
471
471
  const verifyBody = lensPromptBody('runVerifierTask');
472
472
  assert.match(
473
473
  verifyBody,
@@ -476,8 +476,8 @@ test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch
476
476
  );
477
477
  assert.match(
478
478
  verifyBody,
479
- /do no edits|make no edits|not edit|no file edits/i,
480
- 'expected the verifier to be told to make no edits',
479
+ /(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
480
+ 'expected the verifier to forbid editing the tree under verification (a deliberate break off that tree stays allowed)',
481
481
  );
482
482
  });
483
483
 
@@ -901,6 +901,28 @@ test('convergeReadOnlyAgent prepends HEADLESS_READONLY_PREAMBLE and the worktree
901
901
  );
902
902
  });
903
903
 
904
+ test('the read-only destructive pointer scopes its no-edit clause to the tree it reads', () => {
905
+ const destructivePointer = convergeSource
906
+ .split('\n')
907
+ .find((eachLine) => eachLine.includes('Never run a destructive command'));
908
+ assert.ok(destructivePointer, 'expected the read-only destructive pointer to be declared');
909
+ assert.match(
910
+ destructivePointer,
911
+ /Never run a destructive command/,
912
+ 'expected the destructive-command prohibition to stay absolute',
913
+ );
914
+ assert.match(
915
+ destructivePointer,
916
+ /no edit to the tree it reads/,
917
+ 'expected the no-edit clause to be scoped to the tree under verification',
918
+ );
919
+ assert.doesNotMatch(
920
+ destructivePointer,
921
+ /edits nothing/,
922
+ 'expected no blanket edits-nothing wording, which forbids the deliberate break off that tree',
923
+ );
924
+ });
925
+
904
926
  const taskDispatchers = [
905
927
  { name: 'runGitTask', isAsync: false },
906
928
  { name: 'runFixerTask', isAsync: false },