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.
- package/CLAUDE.md +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -2
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +36 -8
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +72 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +177 -22
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +129 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- 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
|