claude-dev-env 1.65.1 → 1.66.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 (23) hide show
  1. package/agents/plan-packet-validator.md +34 -0
  2. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +6 -0
  3. package/commands/plan.md +6 -52
  4. package/hooks/blocking/code_rules_enforcer.py +2 -0
  5. package/hooks/blocking/code_rules_test_assertions.py +123 -1
  6. package/hooks/blocking/open_questions_in_plans_blocker.py +8 -1
  7. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +90 -0
  8. package/hooks/blocking/test_open_questions_in_plans_blocker.py +43 -0
  9. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  10. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +4 -0
  11. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +13 -1
  12. package/package.json +1 -1
  13. package/skills/anthropic-plan/SKILL.md +46 -85
  14. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/__init__.py +0 -0
  15. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/validate_packet_constants.py +33 -0
  16. package/skills/anthropic-plan/scripts/test_validate_packet.py +405 -0
  17. package/skills/anthropic-plan/scripts/validate_packet.py +397 -0
  18. package/skills/anthropic-plan/templates/README.md +20 -0
  19. package/skills/anthropic-plan/templates/build-prompt.md +9 -0
  20. package/skills/anthropic-plan/templates/source-map.md +5 -0
  21. package/skills/anthropic-plan/test_skill_contract.py +53 -0
  22. package/skills/anthropic-plan/workflow/plan-packet.contract.test.mjs +79 -0
  23. package/skills/anthropic-plan/workflow/plan-packet.mjs +299 -0
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: plan-packet-validator
3
+ description: Fresh-context validator for workflow-generated plan packets. Use after a plan packet is written under docs/plans/<slug>/ to verify source accuracy, completeness, TDD readiness, scope control, handoff quality, and no invented repo behavior. Read-only; never edits files.
4
+ tools: Read, Grep, Glob, Bash
5
+ model: inherit
6
+ color: purple
7
+ ---
8
+
9
+ You validate plan packets. You are not the planner and you do not repair docs.
10
+
11
+ ## Rules
12
+
13
+ - Never edit files.
14
+ - Require each material claim to be source-backed, user-confirmed, or explicitly listed as a packet assumption.
15
+ - Treat every packet claim as untrusted until you verify it against source files, repo docs, user-confirmed decisions, or packet assumptions.
16
+ - Return findings only for problems that would make a blind build agent implement the wrong thing or need to rediscover core context.
17
+ - Do not raise style-only findings.
18
+
19
+ ## Checks
20
+
21
+ 1. Read `README.md`, `packet.json`, `context/source-map.md`, `implementation/steps.md`, `implementation/tdd-plan.md`, `spec/acceptance.md`, and `handoff/build-prompt.md`.
22
+ 2. Read or search the source files named in `source-map.md` and `packet.json`.
23
+ 3. Verify referenced paths exist unless the packet clearly labels them as new files.
24
+ 4. Verify source facts match actual files.
25
+ 5. Verify the implementation steps are enough for a blind build agent.
26
+ 6. Verify the TDD sequence starts with failing tests and names the behavior those tests prove.
27
+ 7. Verify scope matches the user request and non-goals.
28
+ 8. Verify no commands, APIs, schemas, hooks, workflows, agents, or repo conventions are invented.
29
+ 9. Verify acceptance criteria prove the requested behavior end to end.
30
+ 10. Verify `handoff/build-prompt.md` stands alone without chat history.
31
+
32
+ ## Output
33
+
34
+ Return the requested structured schema. Set `allPassed` to true only when every check is clean. Each finding must name the packet file, the check, and the exact source-grounded problem.
@@ -37,6 +37,12 @@ Customize per-artifact: a pure-function test corpus with no scenario claims redu
37
37
 
38
38
  ---
39
39
 
40
+ ## Write-time advisory for the flag-gated N1 slice
41
+
42
+ `check_flag_gated_scenario_test_naming` in `code_rules_test_assertions.py` (wired into `code_rules_enforcer.py`) catches the deterministic N1 slice at Write/Edit time. When two or more sibling tests in a file `monkeypatch.setattr` the same module-level UPPER_SNAKE flag, that flag governs which branch the code under test runs. A `test_*` whose name carries a scenario clause (`_when_`, `_passes`, `_succeeds`, `_on_clean`) but never patches that flag runs under the flag's default value, so its named condition may not be in effect. The check prints an advisory to stderr and never blocks the write — the breadth of the sibling-pattern heuristic suits a judgment lane, where the author decides whether to patch the flag (and assert the gated path runs) or rename the test. The audit still owns the full N1–N10 surface; the advisory only surfaces this one mechanically-detectable shape early.
43
+
44
+ ---
45
+
40
46
  ## Sample prompt
41
47
 
42
48
  The reusable Variant C template for Category N is in [`../prompts/category-n-test-name-scenario-verifier.md`](../prompts/category-n-test-name-scenario-verifier.md). Inline every changed test function under `## Source material` along with the production function it claims to cover, so the audit can compare the named scenario against the body's act phase.
package/commands/plan.md CHANGED
@@ -1,60 +1,14 @@
1
- Plan a feature with full validation workflow.
1
+ Plan a feature through the workflow-backed `anthropic-plan` skill.
2
2
 
3
- > **MANDATORY:** Load `~/.claude/docs/CODE_RULES.md` for all code standards.
3
+ Invoke `anthropic-plan` with the user request. The skill launches the Claude Code Workflow at:
4
4
 
5
- ## Workflow (MANDATORY - NO SKIPPING STEPS)
5
+ `$HOME/.claude/skills/anthropic-plan/workflow/plan-packet.mjs`
6
6
 
7
- ### Phase 1: Design
8
- 1. **Invoke `plan` skill** - Discover configs, design through dialogue
7
+ The workflow creates a validated `docs/plans/<slug>/` packet, spawns the fresh `plan-packet-validator` agent, repairs packet findings, and stops before implementation.
9
8
 
10
- ### Phase 2: Plan
11
- 2. **Invoke `write-plan` skill** - Create detailed TDD implementation plan (CODE_RULES.md compliant)
12
- 3. **Invoke `review-plan` skill** - Validate plan against CODE_RULES.md standards
13
- 4. **Fix violations** - If review-plan finds issues, fix before proceeding
9
+ Usage:
14
10
 
15
- ### Phase 3: Approval (NEW PRs only)
16
- 5. **Invoke `plan-checkpoint` skill** - Generate summary gist for reviewer approval
17
- 6. **Wait for approval** - Do not proceed until approved
18
-
19
- ### Phase 4: Execute
20
- 7. **Invoke `plan-executor` agent** - Execute with full standards enforcement
21
- 8. **Invoke `readability-review` skill** - Validate written code against CODE_RULES.md
22
-
23
- ### Phase 5: Commit & Review
24
- 9. **Invoke `/commit`** - Create atomic commits
25
- 10. **Push branch** - Push to GitHub (NO PR yet); the git pre-push hook installed via `npx claude-dev-env` fires automatically and blocks on any violation
26
- 11. **Wait for commit review** - User reviews on GitHub
27
- 12. **Create PR** - Only after user approves commit
28
-
29
- ## Key Rules
30
-
31
- - NEVER offer execution until review-plan passes with ZERO violations
32
- - For NEW PRs: wait for reviewer approval on checkpoint before implementing
33
- - For PR REVIEW FIXES: skip checkpoint, proceed directly to execution
34
- - NEVER push if the git pre-push hook fails (it fires automatically via `npx claude-dev-env`)
35
- - NEVER create PR until user explicitly approves pushed commit
36
-
37
- ## Skills & Agents Reference
38
-
39
- | Step | Tool | Purpose |
40
- |------|------|---------|
41
- | Design | `plan` skill | Collaborative design with config discovery |
42
- | Write | `write-plan` skill | TDD plan with CODE_RULES.md compliance |
43
- | Review Plan | `review-plan` skill | Validate plan against standards |
44
-
45
- ## Standards Reference
46
-
47
- All code quality standards are in `~/.claude/docs/CODE_RULES.md`:
48
- - Self-documenting code (no comments)
49
- - Centralized configs (reuse existing)
50
- - No magic values
51
- - No abbreviations
52
- - Complete type hints
53
- - All imports shown
54
-
55
- ## Usage
56
-
57
- ```
11
+ ```text
58
12
  /plan add user authentication
59
13
  /plan refactor the payment system
60
14
  ```
@@ -117,6 +117,7 @@ from code_rules_string_magic import ( # noqa: E402
117
117
  from code_rules_test_assertions import ( # noqa: E402
118
118
  check_constant_equality_tests,
119
119
  check_existence_check_tests,
120
+ check_flag_gated_scenario_test_naming,
120
121
  check_skip_decorators_in_tests,
121
122
  )
122
123
  from code_rules_test_branching_except import ( # noqa: E402
@@ -274,6 +275,7 @@ def validate_content(
274
275
  )
275
276
  all_issues.extend(check_existence_check_tests(content, file_path))
276
277
  all_issues.extend(check_constant_equality_tests(content, file_path))
278
+ check_flag_gated_scenario_test_naming(content, file_path)
277
279
  all_issues.extend(check_unused_optional_parameters(content, file_path))
278
280
  all_issues.extend(check_collection_prefix(content, file_path))
279
281
  all_issues.extend(check_stuttering_collection_prefix(content, file_path))
@@ -1,9 +1,12 @@
1
- """Skip-decorator, existence-only, and constant-equality test-quality checks."""
1
+ """Skip-decorator, existence-only, constant-equality, and flag-gated scenario test-quality checks."""
2
2
 
3
3
  import ast
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
+ _SCENARIO_NAME_CLAUSES = ("_when_", "_passes", "_succeeds", "_on_clean")
8
+ _MINIMUM_SIBLING_PATCH_COUNT = 2
9
+
7
10
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
8
11
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
9
12
  if _BLOCKING_DIRECTORY not in sys.path:
@@ -224,3 +227,122 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
224
227
  )
225
228
 
226
229
  return issues
230
+
231
+
232
+ def _flag_symbol_from_setattr_target(target_node: ast.expr) -> str | None:
233
+ """Return the UPPER_SNAKE flag symbol a monkeypatch.setattr target names.
234
+
235
+ Accepts both target shapes monkeypatch.setattr supports: a dotted string
236
+ path (``"pkg.module.FLAG"``) and an attribute access (``module.FLAG``). The
237
+ flag is the final dotted segment when that segment is UPPER_SNAKE_CASE; any
238
+ other segment shape returns None so only module-level boolean flags qualify.
239
+
240
+ Args:
241
+ target_node: The first positional argument of a ``monkeypatch.setattr``
242
+ call.
243
+
244
+ Returns:
245
+ The UPPER_SNAKE flag name, or None when the target names no such symbol.
246
+ """
247
+ if isinstance(target_node, ast.Constant) and isinstance(target_node.value, str):
248
+ final_segment = target_node.value.rsplit(".", 1)[-1]
249
+ return final_segment if _is_upper_snake_name(final_segment) else None
250
+ if isinstance(target_node, ast.Attribute):
251
+ return target_node.attr if _is_upper_snake_name(target_node.attr) else None
252
+ return None
253
+
254
+
255
+ def _is_monkeypatch_setattr(call_node: ast.Call) -> bool:
256
+ """Return True when a Call node is a ``monkeypatch.setattr(...)`` invocation."""
257
+ function_reference = call_node.func
258
+ return (
259
+ isinstance(function_reference, ast.Attribute)
260
+ and function_reference.attr == "setattr"
261
+ and isinstance(function_reference.value, ast.Name)
262
+ and function_reference.value.id == "monkeypatch"
263
+ )
264
+
265
+
266
+ def _flags_patched_in_test(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
267
+ """Return the set of UPPER_SNAKE flag symbols a test patches via monkeypatch.setattr."""
268
+ patched_flags: set[str] = set()
269
+ for each_node in ast.walk(function_node):
270
+ if not isinstance(each_node, ast.Call):
271
+ continue
272
+ if not _is_monkeypatch_setattr(each_node) or not each_node.args:
273
+ continue
274
+ flag_symbol = _flag_symbol_from_setattr_target(each_node.args[0])
275
+ if flag_symbol is not None:
276
+ patched_flags.add(flag_symbol)
277
+ return patched_flags
278
+
279
+
280
+ def _name_encodes_scenario(test_name: str) -> bool:
281
+ """Return True when a test name carries a scenario clause asserting a condition."""
282
+ return any(each_clause in test_name for each_clause in _SCENARIO_NAME_CLAUSES)
283
+
284
+
285
+ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[str]:
286
+ """Flag a scenario-named test that omits a flag its siblings establish.
287
+
288
+ When two or more sibling tests in a file monkeypatch the same module-level
289
+ UPPER_SNAKE flag, that flag governs which branch the code under test runs.
290
+ A test whose name asserts a scenario (``_when_``, ``_passes``, ``_succeeds``,
291
+ ``_on_clean``) but never patches that flag runs under the flag's default
292
+ value, so its named condition may not be in effect — the audit category N
293
+ test-name-scenario mismatch. Advisory only; emitted to stderr, never blocks.
294
+ Only applies to test files; production files are exempt.
295
+
296
+ Args:
297
+ content: The file body under validation.
298
+ file_path: Path to the file, used for the test-file gate.
299
+
300
+ Returns:
301
+ An empty list; advisories print to stderr so the write proceeds.
302
+ """
303
+ if not is_test_file(file_path):
304
+ return []
305
+
306
+ try:
307
+ syntax_tree = ast.parse(content)
308
+ except SyntaxError:
309
+ return []
310
+
311
+ test_functions = [
312
+ each_node
313
+ for each_node in ast.walk(syntax_tree)
314
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
315
+ and each_node.name.startswith("test")
316
+ ]
317
+ flags_patched_by_test = {
318
+ each_test.name: _flags_patched_in_test(each_test) for each_test in test_functions
319
+ }
320
+ sibling_patch_count_by_flag: dict[str, int] = {}
321
+ for patched_flags in flags_patched_by_test.values():
322
+ for each_flag in patched_flags:
323
+ sibling_patch_count_by_flag[each_flag] = (
324
+ sibling_patch_count_by_flag.get(each_flag, 0) + 1
325
+ )
326
+ established_flags = {
327
+ each_flag
328
+ for each_flag, patch_count in sibling_patch_count_by_flag.items()
329
+ if patch_count >= _MINIMUM_SIBLING_PATCH_COUNT
330
+ }
331
+ if not established_flags:
332
+ return []
333
+
334
+ for each_test in test_functions:
335
+ if not _name_encodes_scenario(each_test.name):
336
+ continue
337
+ unpatched_flags = established_flags - flags_patched_by_test[each_test.name]
338
+ if unpatched_flags:
339
+ flag_list = ", ".join(sorted(unpatched_flags))
340
+ print(
341
+ f"ADVISORY [CODE_RULES] Line {each_test.lineno}: scenario test"
342
+ f" {each_test.name!r} never patches {flag_list}, which sibling tests"
343
+ f" establish — the named scenario may run under the flag default."
344
+ f" Patch the flag (and assert the gated path runs) or rename the test.",
345
+ file=sys.stderr,
346
+ )
347
+
348
+ return []
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """PreToolUse:Write|Edit|MultiEdit hook — blocks plan files that contain an "Open Questions" section.
3
3
 
4
- Plans under `~/.claude/plans/` (or any `.claude/plans/` directory) must not be
4
+ Plans under `~/.claude/plans/` (or any `.claude/plans/` directory) and packet
5
+ docs under any repo-local `docs/plans/` directory must not be
5
6
  written with an unresolved "Open Questions" section. When detected, the agent is
6
7
  forced to (1) investigate the codebase for answers itself first, then (2) confirm
7
8
  its interpretations via the AskUserQuestion tool in plain everyday language, and
@@ -20,6 +21,8 @@ if _hooks_dir not in sys.path:
20
21
 
21
22
  from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa: E402
22
23
  CODE_FENCE_PATTERN,
24
+ DOCS_PLANS_PATH_PREFIX,
25
+ DOCS_PLANS_PATH_SEGMENT,
23
26
  INLINE_CODE_PATTERN,
24
27
  MARKDOWN_EXTENSION,
25
28
  OPEN_QUESTIONS_HEADING_PATTERN,
@@ -41,6 +44,10 @@ def _is_inside_plans_directory(file_path: str) -> bool:
41
44
  return True
42
45
  if normalized.startswith(PLANS_PATH_PREFIX):
43
46
  return True
47
+ if DOCS_PLANS_PATH_SEGMENT in normalized:
48
+ return True
49
+ if normalized.startswith(DOCS_PLANS_PATH_PREFIX):
50
+ return True
44
51
  return False
45
52
 
46
53
 
@@ -6,6 +6,8 @@ import sys
6
6
  from pathlib import Path
7
7
  from types import SimpleNamespace
8
8
 
9
+ import pytest
10
+
9
11
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
10
12
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
11
13
  if _BLOCKING_DIRECTORY not in sys.path:
@@ -15,14 +17,34 @@ if _HOOKS_DIRECTORY not in sys.path:
15
17
 
16
18
  from code_rules_test_assertions import ( # noqa: E402
17
19
  check_constant_equality_tests,
20
+ check_flag_gated_scenario_test_naming,
18
21
  )
19
22
 
20
23
  code_rules_enforcer = SimpleNamespace(
21
24
  check_constant_equality_tests=check_constant_equality_tests,
25
+ check_flag_gated_scenario_test_naming=check_flag_gated_scenario_test_naming,
22
26
  )
23
27
 
24
28
 
25
29
  CONSTANT_EQUALITY_TEST_FILE_PATH = "packages/app/tests/test_constants.py"
30
+ SCENARIO_TEST_FILE_PATH = "packages/app/tests/test_submission_runner_loop.py"
31
+
32
+ _THREE_SIBLINGS_PATCH_THE_FLAG_ONE_SCENARIO_TEST_DOES_NOT = (
33
+ "def test_should_submit_when_gate_passes(monkeypatch) -> None:\n"
34
+ " assert run() == 'submitted'\n"
35
+ "\n"
36
+ "def test_should_fail_when_reader_raises(monkeypatch) -> None:\n"
37
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
38
+ " assert run() == 'failed'\n"
39
+ "\n"
40
+ "def test_should_soft_skip_when_mismatch(monkeypatch) -> None:\n"
41
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
42
+ " assert run() == 'skipped'\n"
43
+ "\n"
44
+ "def test_should_hard_stop_when_unhealthy(monkeypatch) -> None:\n"
45
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
46
+ " assert run() == 'hard_stop'\n"
47
+ )
26
48
 
27
49
 
28
50
  def test_should_not_flag_two_named_constants_compared_to_each_other() -> None:
@@ -54,3 +76,71 @@ def test_should_flag_named_constant_compared_to_literal() -> None:
54
76
  assert any("constant-value test" in issue for issue in issues), (
55
77
  f"Expected flag when UPPER_SNAKE compared to literal, got: {issues}"
56
78
  )
79
+
80
+
81
+ def test_should_advise_when_scenario_test_omits_flag_its_siblings_patch(
82
+ capsys: pytest.CaptureFixture[str],
83
+ ) -> None:
84
+ issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
85
+ _THREE_SIBLINGS_PATCH_THE_FLAG_ONE_SCENARIO_TEST_DOES_NOT,
86
+ SCENARIO_TEST_FILE_PATH,
87
+ )
88
+ advisory_text = capsys.readouterr().err
89
+ assert issues == [], "Advisory check must never add a blocking issue"
90
+ assert "test_should_submit_when_gate_passes" in advisory_text, (
91
+ f"Expected an advisory naming the un-patched scenario test, got: {advisory_text!r}"
92
+ )
93
+ assert "IS_STAGED_VERIFICATION_ENABLED" in advisory_text, (
94
+ f"Expected the advisory to name the established flag, got: {advisory_text!r}"
95
+ )
96
+
97
+
98
+ def test_should_stay_silent_when_scenario_test_patches_the_flag(
99
+ capsys: pytest.CaptureFixture[str],
100
+ ) -> None:
101
+ source = (
102
+ "def test_should_submit_when_gate_passes(monkeypatch) -> None:\n"
103
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
104
+ " assert run() == 'submitted'\n"
105
+ "\n"
106
+ "def test_should_fail_when_reader_raises(monkeypatch) -> None:\n"
107
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
108
+ " assert run() == 'failed'\n"
109
+ )
110
+ issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
111
+ source, SCENARIO_TEST_FILE_PATH
112
+ )
113
+ advisory_text = capsys.readouterr().err
114
+ assert issues == []
115
+ assert advisory_text == "", (
116
+ f"Expected silence when the scenario test patches the flag, got: {advisory_text!r}"
117
+ )
118
+
119
+
120
+ def test_should_stay_silent_when_only_one_sibling_patches_the_flag(
121
+ capsys: pytest.CaptureFixture[str],
122
+ ) -> None:
123
+ source = (
124
+ "def test_should_submit_when_gate_passes(monkeypatch) -> None:\n"
125
+ " assert run() == 'submitted'\n"
126
+ "\n"
127
+ "def test_should_fail_when_reader_raises(monkeypatch) -> None:\n"
128
+ " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
129
+ " assert run() == 'failed'\n"
130
+ )
131
+ issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
132
+ source, SCENARIO_TEST_FILE_PATH
133
+ )
134
+ advisory_text = capsys.readouterr().err
135
+ assert issues == []
136
+ assert advisory_text == "", (
137
+ f"One sibling patch is not an established flag; expected silence, got: {advisory_text!r}"
138
+ )
139
+
140
+
141
+ def test_should_not_advise_for_production_file() -> None:
142
+ issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
143
+ _THREE_SIBLINGS_PATCH_THE_FLAG_ONE_SCENARIO_TEST_DOES_NOT,
144
+ "packages/app/services/submission_pipeline.py",
145
+ )
146
+ assert issues == []
@@ -1,5 +1,6 @@
1
1
  """Tests for open_questions_in_plans_blocker hook."""
2
2
 
3
+ import ast
3
4
  import json
4
5
  import os
5
6
  import subprocess
@@ -10,6 +11,13 @@ HOOK_SCRIPT_PATH = os.path.join(
10
11
  os.path.dirname(__file__), "open_questions_in_plans_blocker.py"
11
12
  )
12
13
 
14
+
15
+ def _read_hook_module_docstring() -> str:
16
+ source_text = open(HOOK_SCRIPT_PATH, encoding="utf-8").read()
17
+ module_docstring = ast.get_docstring(ast.parse(source_text))
18
+ assert module_docstring is not None
19
+ return module_docstring
20
+
13
21
  _plan_with_open_questions = (
14
22
  "## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n"
15
23
  )
@@ -75,6 +83,41 @@ def test_blocks_project_local_plans_directory():
75
83
  assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
76
84
 
77
85
 
86
+ def test_module_docstring_names_docs_plans_directory_family():
87
+ """The docstring enumerates every directory family the detector fires on,
88
+ including the repo-local `docs/plans/` family it now blocks."""
89
+ module_docstring = _read_hook_module_docstring()
90
+
91
+ assert "docs/plans/" in module_docstring
92
+
93
+
94
+ def test_blocks_repo_docs_plan_packet_directory():
95
+ """Repo-local `docs/plans/<slug>/` packet docs are plan files too."""
96
+ result = _run_hook(
97
+ "Write",
98
+ {
99
+ "file_path": "docs/plans/add-login/spec/scope.md",
100
+ "content": _plan_with_open_questions,
101
+ },
102
+ )
103
+ assert result.returncode == 0
104
+ output = json.loads(result.stdout)
105
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
106
+
107
+
108
+ def test_blocks_windows_style_repo_docs_plan_packet_directory():
109
+ result = _run_hook(
110
+ "Write",
111
+ {
112
+ "file_path": "docs\\plans\\add-login\\spec\\scope.md",
113
+ "content": _plan_with_open_questions,
114
+ },
115
+ )
116
+ assert result.returncode == 0
117
+ output = json.loads(result.stdout)
118
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
119
+
120
+
78
121
  def test_blocks_case_insensitive_heading():
79
122
  result = _run_hook(
80
123
  "Write",
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  ALL_CONFIG_DIRECTORY_NAMES = frozenset(
15
15
  {
16
+ "anthropic_plan_scripts_constants",
16
17
  "config",
17
18
  "hooks_constants",
18
19
  "git_hooks_constants",
@@ -9,6 +9,8 @@ MARKDOWN_EXTENSION: str = ".md"
9
9
 
10
10
  PLANS_PATH_SEGMENT: str = "/.claude/plans/"
11
11
  PLANS_PATH_PREFIX: str = ".claude/plans/"
12
+ DOCS_PLANS_PATH_SEGMENT: str = "/docs/plans/"
13
+ DOCS_PLANS_PATH_PREFIX: str = "docs/plans/"
12
14
 
13
15
  PLAN_FILE_ENCODING: str = "utf-8"
14
16
 
@@ -25,6 +27,8 @@ INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`\n]+``|`[^`\n]+`")
25
27
 
26
28
  __all__ = [
27
29
  "CODE_FENCE_PATTERN",
30
+ "DOCS_PLANS_PATH_PREFIX",
31
+ "DOCS_PLANS_PATH_SEGMENT",
28
32
  "INLINE_CODE_PATTERN",
29
33
  "MARKDOWN_EXTENSION",
30
34
  "OPEN_QUESTIONS_HEADING_PATTERN",
@@ -27,6 +27,16 @@ def test_plans_path_prefix_matches_project_local_plans_directory() -> None:
27
27
  assert "PLANS_PATH_PREFIX" in constants_module.__all__
28
28
 
29
29
 
30
+ def test_docs_plans_path_segment_matches_repo_packet_directory() -> None:
31
+ assert constants_module.DOCS_PLANS_PATH_SEGMENT == "/docs/plans/"
32
+ assert "DOCS_PLANS_PATH_SEGMENT" in constants_module.__all__
33
+
34
+
35
+ def test_docs_plans_path_prefix_matches_repo_packet_directory() -> None:
36
+ assert constants_module.DOCS_PLANS_PATH_PREFIX == "docs/plans/"
37
+ assert "DOCS_PLANS_PATH_PREFIX" in constants_module.__all__
38
+
39
+
30
40
  def test_open_questions_heading_pattern_matches_atx_heading() -> None:
31
41
  assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("## Open Questions\n")
32
42
 
@@ -111,9 +121,11 @@ def test_unreadable_file_synthetic_content_triggers_heading_pattern() -> None:
111
121
  assert "UNREADABLE_FILE_SYNTHETIC_CONTENT" in constants_module.__all__
112
122
 
113
123
 
114
- def test_all_exports_enumerates_eight_public_constants_in_sorted_order() -> None:
124
+ def test_all_exports_enumerates_ten_public_constants_in_sorted_order() -> None:
115
125
  expected_exports = [
116
126
  "CODE_FENCE_PATTERN",
127
+ "DOCS_PLANS_PATH_PREFIX",
128
+ "DOCS_PLANS_PATH_SEGMENT",
117
129
  "INLINE_CODE_PATTERN",
118
130
  "MARKDOWN_EXTENSION",
119
131
  "OPEN_QUESTIONS_HEADING_PATTERN",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.65.1",
3
+ "version": "1.66.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {