claude-dev-env 7.7.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.agents/agents/clean-coder.md +5 -6
  2. package/.agents/agents/issue-tracker.md +1 -1
  3. package/.agents/agents/pr-description-writer.md +1 -1
  4. package/.agents/agents/skill-writer-agent.md +1 -12
  5. package/.agents/agents/test_agent_frontmatter.py +2 -19
  6. package/.agents/skills/e-code-review/reference/fix.md +4 -2
  7. package/.agents/skills/e-code-review/reference/loop.md +4 -2
  8. package/.agents/skills/issue-tracker/SKILL.md +1 -1
  9. package/.agents/skills/orchestrator/SKILL.md +4 -5
  10. package/.agents/skills/pull-request/SKILL.md +8 -7
  11. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  12. package/.agents/skills/team-advisor/SKILL.md +2 -3
  13. package/_shared/advisor/advisor-protocol.md +0 -1
  14. package/_shared/advisor/reference/lifecycle.md +0 -1
  15. package/_shared/advisor/reference/warm-up.md +1 -2
  16. package/audit-rubrics/prompts/category-e-dead-code.md +3 -3
  17. package/bin/install-constants.mjs +0 -5
  18. package/bin/install-pstack-plugin.mjs +192 -0
  19. package/bin/install-pstack-plugin.test.mjs +181 -0
  20. package/bin/install.agents-home.test.mjs +0 -4
  21. package/bin/install.codex-rules.test.mjs +6 -0
  22. package/bin/install.cursor-rules.test.mjs +3 -253
  23. package/bin/install.mjs +55 -160
  24. package/bin/install.test.mjs +110 -149
  25. package/bin/prune-retired-pstack-store.mjs +83 -0
  26. package/bin/prune-retired-pstack-store.test.mjs +105 -0
  27. package/bin/prune-session-continuity.mjs +57 -0
  28. package/bin/prune-session-continuity.test.mjs +70 -0
  29. package/bin/resolve-install-root.mjs +2 -0
  30. package/bin/resolve-install-root.test.mjs +69 -0
  31. package/bin/subagent-model-policy.test.mjs +56 -19
  32. package/docs/CODE_RULES.md +1 -1
  33. package/docs/agent-spawn-protocol.md +1 -1
  34. package/docs/codex-compatibility.md +4 -16
  35. package/docs/nas-ssh-invocation.md +1 -1
  36. package/docs/references/prose-style-enforcement.md +2 -2
  37. package/docs/subagent-model-routing.md +21 -2
  38. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +1 -3
  39. package/hooks/blocking/code_rules_constants_config.py +1 -108
  40. package/hooks/blocking/code_rules_enforcer.py +9 -5
  41. package/hooks/blocking/code_rules_test_layout.py +6 -7
  42. package/hooks/blocking/test_code_rules_constants_config.py +13 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  44. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +15 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +0 -60
  46. package/hooks/blocking/test_context_control_policy_files.py +0 -11
  47. package/hooks/hooks_constants/blocking_check_limits.py +1 -1
  48. package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -1
  49. package/hooks/hooks_constants/setup_project_paths_constants.py +1 -2
  50. package/hooks/routing/subagent_model_routing.test.mjs +65 -28
  51. package/hooks/session/test_untracked_repo_detector.py +1 -1
  52. package/hooks/validators/code_quality_checks.py +15 -2
  53. package/hooks/validators/test_code_quality_checks.py +22 -0
  54. package/package.json +1 -2
  55. package/rules/ci-owns-the-gate.md +58 -0
  56. package/rules/code-standards.md +1 -2
  57. package/rules/docstring-prose-matches-implementation.md +2 -2
  58. package/rules/failure-blast-radius.md +1 -1
  59. package/rules/git-workflow.md +23 -11
  60. package/rules/orphan-css-class.md +1 -1
  61. package/rules/paired-test-coverage.md +1 -1
  62. package/rules/retired-hook-prose.md +1 -1
  63. package/rules/subagent-model-policy.json +1 -1
  64. package/rules/verify-before-asking.md +1 -1
  65. package/rules/windows-filesystem-safe.md +1 -1
  66. package/rules/workflow-substitution-slots.md +1 -1
  67. package/scripts/Sync-RepoMain.ps1 +71 -6
  68. package/scripts/policy_lint/adapter_detectors.py +116 -10
  69. package/scripts/policy_lint/config/approved_test_pairs.py +12 -0
  70. package/scripts/policy_lint/config/constants.py +3 -0
  71. package/scripts/tests/Sync-RepoMain.Tests.ps1 +108 -0
  72. package/scripts/tests/test_policy_lint_rules_validator_baseline.py +126 -0
  73. package/.agents/skills/session-continuity/AGENTS.md +0 -1
  74. package/.agents/skills/session-continuity/CLAUDE.md +0 -1
  75. package/.agents/skills/session-continuity/README.md +0 -223
  76. package/.agents/skills/session-continuity/SKILL.md +0 -125
  77. package/.agents/skills/session-continuity/continuity.mjs +0 -445
  78. package/.agents/skills/session-continuity/continuity.test.mjs +0 -381
  79. package/bin/install-session-continuity.mjs +0 -131
  80. package/bin/install-session-continuity.test.mjs +0 -106
  81. package/bin/install.pstack-plugin.test.mjs +0 -115
  82. package/bin/pstack-codex-discovery.test.mjs +0 -79
  83. package/bin/pstack-model-preferences.claude.json +0 -23
  84. package/bin/pstack-model-preferences.codex.json +0 -24
  85. package/bin/pstack.mjs +0 -392
  86. package/bin/pstack.test.mjs +0 -347
  87. package/bin/refresh-pstack-plugin-skills.test.mjs +0 -211
  88. package/bin/select-pstack-models.test.mjs +0 -727
  89. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +0 -231
  90. package/rules/agent-spawn-protocol.md +0 -9
  91. package/rules/ask-user-question-required.md +0 -31
  92. package/rules/claims-as-quotes.md +0 -55
  93. package/rules/confirm-implementation-forks.md +0 -11
  94. package/rules/file-global-constants.md +0 -81
  95. package/rules/hedging-claims.md +0 -7
  96. package/rules/measurement-denominators.md +0 -39
  97. package/rules/prompt-workflow-context-controls.md +0 -19
  98. package/rules/pstack-host-mapping.md +0 -48
  99. package/rules/pstack-models.md +0 -142
  100. package/rules/vault-context.md +0 -5
  101. package/scripts/pstack-adapters/common.md +0 -17
  102. package/scripts/pstack-adapters/create-skill.md +0 -14
  103. package/scripts/pstack-adapters/host-claude.md +0 -7
  104. package/scripts/pstack-adapters/host-codex.md +0 -11
  105. package/scripts/pstack-adapters/host-cursor.md +0 -7
  106. package/scripts/pstack.lock.json +0 -11
  107. package/scripts/refresh_pstack_plugin_skills.mjs +0 -200
  108. package/scripts/select_pstack_models.mjs +0 -469
  109. /package/.agents/{skills → skills-archived}/source-command-logifix/SKILL.md +0 -0
@@ -1,9 +1,15 @@
1
1
  """Code quality checks validator.
2
2
 
3
3
  Implements:
4
- - Check 30: Function too long (max 30 lines)
4
+ - Check 30: Function too long (max 30 lines), production files only
5
5
  - Check 31: Nesting too deep (max 2 levels)
6
- - Check 32: File too long (max 400 lines)
6
+ - Check 32: File too long (max 400 lines), production files only
7
+
8
+ The two length caps read production files only. A test module grows with the
9
+ surface it covers, and a linear list of assertions is what a reader wants there,
10
+ so capping its length pushes a contributor to split a suite for the counter
11
+ rather than for the reader. ``magic_value_checks`` short-circuits on the same
12
+ category through the same helper.
7
13
  """
8
14
 
9
15
  import ast
@@ -11,6 +17,7 @@ import sys
11
17
  from pathlib import Path
12
18
  from typing import List
13
19
 
20
+ from .exempt_paths import is_test_file
14
21
  from .validator_base import Violation, source_text, syntax_tree
15
22
 
16
23
 
@@ -22,6 +29,9 @@ MAX_FILE_LINES = 400
22
29
  def check_function_length(tree: ast.AST, filename: str) -> List[Violation]:
23
30
  violations: List[Violation] = []
24
31
 
32
+ if is_test_file(filename):
33
+ return violations
34
+
25
35
  for node in ast.walk(tree):
26
36
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
27
37
  if node.end_lineno and node.lineno:
@@ -74,6 +84,9 @@ def check_file_length(file_path: Path) -> List[Violation]:
74
84
  violations: List[Violation] = []
75
85
  filename = str(file_path)
76
86
 
87
+ if is_test_file(filename):
88
+ return violations
89
+
77
90
  try:
78
91
  lines = source_text(file_path).splitlines()
79
92
  except Exception as error:
@@ -127,3 +127,25 @@ class TestFileLength:
127
127
  assert "400" in violations[0].message or "lines" in violations[0].message.lower()
128
128
  finally:
129
129
  temp_path.unlink()
130
+
131
+
132
+ class TestTestFileExemption:
133
+ def test_long_function_in_a_test_file_passes(self) -> None:
134
+ tree = ast.parse(BAD_LONG_FUNCTION)
135
+ violations = check_function_length(tree, "packages/app/test_widget.py")
136
+ assert violations == []
137
+
138
+ def test_long_function_outside_a_test_file_still_fails(self) -> None:
139
+ tree = ast.parse(BAD_LONG_FUNCTION)
140
+ violations = check_function_length(tree, "packages/app/widget.py")
141
+ assert len(violations) == 1
142
+
143
+ def test_long_test_file_passes(self, tmp_path: Path) -> None:
144
+ temp_path = tmp_path / "test_widget.py"
145
+ temp_path.write_text("x = 1\n" * 450, encoding="utf-8")
146
+ assert check_file_length(temp_path) == []
147
+
148
+ def test_long_conftest_passes(self, tmp_path: Path) -> None:
149
+ temp_path = tmp_path / "conftest.py"
150
+ temp_path.write_text("x = 1\n" * 450, encoding="utf-8")
151
+ assert check_file_length(temp_path) == []
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.7.0",
3
+ "version": "8.0.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-dev-env": "bin/install.mjs",
8
- "cde-pstack": "bin/pstack.mjs",
9
8
  "codex-compat": "bin/codex-compat.mjs",
10
9
  "cde": "bin/cde.mjs"
11
10
  },
@@ -0,0 +1,58 @@
1
+ # CI Owns the Gate
2
+
3
+ **When this applies:** Before pushing a branch, and any time a repository's full
4
+ check suite or policy gate is about to run on your own machine.
5
+
6
+ ## Rule
7
+
8
+ The gate runs once, and it runs on CI. Push the branch, read the verdict, act on
9
+ what it says.
10
+
11
+ The inner development loop stays yours. Run the single test you are writing, as
12
+ often as it helps. That is how the change gets built. This rule governs the
13
+ second full pass, the one whose only product is a prediction of CI's answer.
14
+ Push instead, and spend the wait on the next piece of work.
15
+
16
+ ## Why
17
+
18
+ **A pinned gate answers only from its pinned revision.** The workflow names an
19
+ exact revision of the policy package, and that revision decides which rules run.
20
+ The copy under your home directory is a separate artifact at its own revision. A
21
+ run against the home copy reports on those rules, which are a different question
22
+ from the one CI asks. Treat its exit code as information about the home copy
23
+ alone.
24
+
25
+ **Self-hosted runners often share your machine.** Where the runners execute on
26
+ the same host as your shell, a local suite run draws its processor time from the
27
+ runners, and it does so while they work on the branch you just pushed. Read
28
+ where the runners live, and count a local run against the same budget.
29
+
30
+ **One authoritative answer beats two.** Where both runs agree, the second one
31
+ restated the first. Where they differ, the environments differ, and CI is the
32
+ environment that decides.
33
+
34
+ ## Running a gate locally
35
+
36
+ Clone the revision the workflow pins, then point the gate at that clone. That
37
+ run asks CI's question and its answer carries. Report a local result by naming
38
+ the revision it used, so a reader can tell which question it answered.
39
+
40
+ ## The verdict belongs to CI
41
+
42
+ CI decides whether a change passed, from evidence CI gathered. Keep that loop
43
+ closed. A flag, trailer, receipt, or environment variable through which the
44
+ change under test announces its own result hands the verdict to the subject.
45
+ Where the runner and the agent share one host, a signature names the same party
46
+ twice, so it carries the claim no further.
47
+
48
+ A cache stays available on one condition. CI derives the key itself from the
49
+ tree it is about to test, looks for a previous run under that key, and
50
+ republishes that result. CI computes, CI verifies, CI decides.
51
+
52
+ ## Sibling rules
53
+
54
+ | Rule | Role |
55
+ |---|---|
56
+ | [`git-workflow.md`](git-workflow.md) | Confirm each required context fired after the push |
57
+ | [`verify-runtime-state.md`](verify-runtime-state.md) | A status field is a report; read the thing the work was meant to make |
58
+ | [`falsify-before-green.md`](falsify-before-green.md) | A green counts as evidence once the check has run red on a deliberate break |
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Canonical review contract:** [`CODE_RULES.md`](../docs/CODE_RULES.md) — the human and AI review contract for code quality, loaded on demand.
4
4
  > **Checked-in pointer:** [`.cursor/BUGBOT.md`](../../../.cursor/BUGBOT.md) — the file Cursor BugBot reads; it points at `CODE_RULES.md`.
5
- > **Production enforcement.** The staged policy lint runs `hooks/blocking/code_rules_enforcer.py` over each changed file. No write-time hook runs it. Use `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit, and CI runs the same lint against the merge base. Each mechanical rule carries a synchronization test.
5
+ > **Production enforcement.** The staged policy lint runs `hooks/blocking/code_rules_enforcer.py` over each changed file. No write-time hook runs it. CI runs that lint against the merge base. Each mechanical rule carries a synchronization test.
6
6
 
7
7
  ## Policy surface map
8
8
 
@@ -27,7 +27,6 @@ BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers
27
27
 
28
28
  | Concern | Rule file |
29
29
  |---|---|
30
- | Question routing | [`ask-user-question-required.md`](ask-user-question-required.md) |
31
30
  | Task tracking / worker completion | [`workers-done-before-complete.md`](workers-done-before-complete.md) |
32
31
  | Multi-step task list | skill `task-build` (see agents catalog) |
33
32
 
@@ -28,13 +28,13 @@ Read the body and the docstring side by side. Apply each check that matches the
28
28
  - **Gate-outcome status flags** — an outcome routed to a blocker (`blocker = ...; break`) reads as blocked everywhere, never as a bypass.
29
29
  - **Returns / Raises / Note claims** — each free-form claim matches the body.
30
30
 
31
- Many deterministic shapes of this drift are checked in `code_rules_docstrings.py` (and the JS and `.mjs` slices in `code_rules_imports_logging.py`). The staged policy lint reaches both through `code_rules_enforcer.py`, and no write-time hook runs them. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. Free-form rest is judgment.
31
+ Many deterministic shapes of this drift are checked in `code_rules_docstrings.py` (and the JS and `.mjs` slices in `code_rules_imports_logging.py`). The staged policy lint reaches both through `code_rules_enforcer.py`, and CI runs it against the merge base. Free-form rest is judgment.
32
32
 
33
33
  ## Hook prose matches its detector
34
34
 
35
35
  A hook module is the sharpest case of the same rule: its docstring lead narrative and its `CORRECTIVE_MESSAGE` describe exactly the shapes the detector flags, and claim no broader trigger surface than the regex enforces.
36
36
 
37
- The staged policy lint carries this as its `hook-prose-consistency` rule, covering hook modules and their `*_constants.py` companions. It reports prose that claims a trigger the detector never fires on, and names the fix. No write-time hook runs it, so run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit.
37
+ The staged policy lint carries this as its `hook-prose-consistency` rule, covering hook modules and their `*_constants.py` companions. It reports prose that claims a trigger the detector never fires on, and names the fix. No write-time hook runs it, so CI is where it reports.
38
38
 
39
39
  After writing a hook, ask: would a token matching every word of this message actually trip the detector? When the message names a shape the regex skips, rewrite the message to name only what the regex catches. The path-shape case is the common overstatement — a detector that keys off a path separator must not claim it blocks an "output-key segment". The corrective message spells the rewrite.
40
40
 
@@ -64,7 +64,7 @@ The report then presents each candidate to the owner for a durable-fix decision,
64
64
 
65
65
  ## Enforcement
66
66
 
67
- `code_rules_blast_radius.py` runs inside `code_rules_enforcer.py`, which the staged policy lint applies to each changed file under its `code-rules` rule. No write-time hook runs it, so run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
67
+ `code_rules_blast_radius.py` runs inside `code_rules_enforcer.py`, which the staged policy lint applies to each changed file under its `code-rules` rule. No write-time hook runs it, so CI is where it reports. CI runs the same lint against the merge base.
68
68
 
69
69
  The check requires each raised type written directly inside a loop body to end in `RunFatal` or `ItemBlocked`. The lexical check covers raises written directly in loop bodies. Shared helpers carry multiple caller contexts, so their callers classify the boundary.
70
70
 
@@ -29,9 +29,10 @@ opens with the bot's own marker line. The description rules in this file, the
29
29
  failure signature in the release job log reads
30
30
  `could not parse pull request body as a release PR`.
31
31
 
32
- Use the `pr-description-writer` agent before creating a pull request or
33
- rewriting its full description. Publish its title and body file through
34
- `.agents/skills/pull-request/scripts/pull_request.py`.
32
+ The `pr-description-writer` agent writes a title and body from the diff when
33
+ you want one. Publish the title and body file through
34
+ `~/.agents/skills/pull-request/scripts/pull_request.py`. That path is under the
35
+ agents home, not the repository. A worktree holds no `.agents/` copy.
35
36
 
36
37
  Resolve the active managed root (`CLAUDE_CONFIG_DIR` when set, `~/.claude`
37
38
  otherwise), then run `<managed-root>/scripts/durable_post_lint.py` before any
@@ -50,14 +51,25 @@ untouched.
50
51
  - Before push: `gh pr ready --undo`
51
52
  - After review approved: `gh pr ready`
52
53
 
53
- ## Run the required checks locally before the first push
54
+ ## Confirm the required checks fired, and let CI run them
54
55
 
55
- Read the branch ruleset for the required check contexts before you push a branch,
56
- or any level of a stack: `gh api repos/<owner>/<repo>/rules/branches/<trunk>`.
57
- Then run that exact gate command locally against that level's own base, on every
58
- level. A required check that never fired is invisible debt at every level, and it
59
- surfaces only after the whole stack is pushed, when the repair costs a second pass
60
- over every branch.
56
+ The gate runs once, and it runs on CI. Push the branch and read its verdict.
57
+ [`ci-owns-the-gate.md`](ci-owns-the-gate.md) holds the reasoning and the shape
58
+ a local run takes when one is warranted.
59
+
60
+ Read the branch ruleset for the required check contexts before you push a
61
+ branch, or any level of a stack: `gh api repos/<owner>/<repo>/rules/branches/<trunk>`.
62
+ Read it to learn which checks must report. After the push, confirm each of those
63
+ contexts appears on that level's head. A required check that never fired is
64
+ invisible debt at every level, and it surfaces only after the whole stack is
65
+ pushed, when the repair costs a second pass over every branch.
66
+
67
+ A red required check blocks the branch, whoever owns the failing line. The gate
68
+ charges a change for the whole file it touches, so "pre-existing on main" is a
69
+ blame note, not a pass. Fix the line in the next push or report the branch
70
+ blocked. Read the gate's own report rather than a narrower substitute. A
71
+ single-file mypy call cannot see sibling modules and reports false import
72
+ errors, so it neither clears nor convicts a change.
61
73
 
62
74
  A checks listing that reports nothing on the branch is a finding, not a neutral
63
75
  state. Find out whether the workflow's event filters exclude the branch, or whether
@@ -82,7 +94,7 @@ what it calls.
82
94
  Force-with-lease protects the ref. It protects nobody's understanding of what the
83
95
  branch now holds. When a rewrite moves content between levels of a stack, or
84
96
  otherwise changes what a branch contains, refresh that pull request's description
85
- through the `pr-description-writer` agent before you ask anyone to read or merge it.
97
+ before you ask anyone to read or merge it.
86
98
 
87
99
  ## Never commit working documents or images
88
100
 
@@ -15,7 +15,7 @@ When you add a `class="..."` attribute, add its `.<class>` selector to the `<sty
15
15
 
16
16
  ## What the check covers
17
17
 
18
- The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` reaches production Python through the staged policy lint, whose `code-rules` rule loads `code_rules_enforcer.py` and applies it to each changed file. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. It:
18
+ The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` reaches production Python through the staged policy lint, whose `code-rules` rule loads `code_rules_enforcer.py` and applies it to each changed file. CI runs that lint against the merge base. It:
19
19
 
20
20
  1. Collects each class name referenced in a `class="..."` attribute across the file's string literals.
21
21
  2. Collects each class selector defined in a `<style>` block — both in the file under edit and in every Python module beside it (its own directory and immediate child directories), since a markup module commonly imports its style constant from a companion package directory.
@@ -18,7 +18,7 @@ When you add a public function to a module whose test suite already exercises th
18
18
 
19
19
  ## What the check covers
20
20
 
21
- Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. The two checks cover the two write orders.
21
+ Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so CI is where they report, against the merge base. The two checks cover the two write orders.
22
22
 
23
23
  `check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
24
24
 
@@ -7,7 +7,7 @@ paths:
7
7
 
8
8
  A hook module runs when `hooks/hooks.json` registers it or a dispatcher roster hosts it. A module can survive on disk while appearing in neither place, and it then reaches no tool call. Prose that gives such a module a present-tense action describes a gate the reader never faces, and a reader who trusts it skips the check they were meant to run by hand.
9
9
 
10
- The staged policy lint carries this check as its `retired-hook-prose` rule. It reads each Markdown file under a `rules/` directory, takes every backticked hook module name, and reports the ones that are registered nowhere and that carry a present-tense action verb in the rest of the sentence. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
10
+ The staged policy lint carries this check as its `retired-hook-prose` rule. It reads each Markdown file under a `rules/` directory, takes every backticked hook module name, and reports the ones that are registered nowhere and that carry a present-tense action verb in the rest of the sentence. CI runs that lint against the merge base.
11
11
 
12
12
  A module name counts as a hook when it ends in one of the hook families, such as `_blocker`, `_enforcer`, `_gate`, `_tracker`, or `_dispatcher`, and when it either sits under `hooks/` or appears in `RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS` in `bin/install.mjs`. A support module inside the hooks tree carries no such family suffix, so it stays out of the check. A hook deleted from disk without an entry in that roster resolves nowhere, so add the roster entry when you retire a hook.
13
13
 
@@ -53,7 +53,6 @@
53
53
  {"model": "luna", "effort": "high"},
54
54
  {"model": "luna", "effort": "xhigh"},
55
55
  {"model": "luna", "effort": "max"},
56
- {"model": "sol", "effort": "medium"},
57
56
  {"model": "astra", "effort": "low"},
58
57
  {"model": "astra", "effort": "medium"},
59
58
  {"model": "astra", "effort": "high", "roles": ["advisor"]}
@@ -66,6 +65,7 @@
66
65
  {"requested": {"model": "luna", "effort": "low"}, "selected": {"model": "luna", "effort": "high"}},
67
66
  {"requested": {"model": "luna", "effort": "medium"}, "selected": {"model": "luna", "effort": "high"}},
68
67
  {"requested": {"model": "sol", "effort": "low"}, "selected": {"model": "luna", "effort": "xhigh"}},
68
+ {"requested": {"model": "sol", "effort": "medium"}, "selected": {"model": "luna", "effort": "max"}},
69
69
  {"requested": {"model": "terra", "effort": "low"}, "selected": {"model": "luna", "effort": "xhigh"}},
70
70
  {"requested": {"model": "terra", "effort": "medium"}, "selected": {"model": "luna", "effort": "xhigh"}},
71
71
  {"requested": {"model": "terra", "effort": "high"}, "selected": {"model": "luna", "effort": "xhigh"}},
@@ -27,7 +27,7 @@ Only after confirming the answer cannot be obtained through any available tool,
27
27
  A path, port, branch name, or config value you recall from an earlier session counts as unanswered until a tool re-checks it this session. Memory records what was true when it was written; the file may have moved, the port may be down, the branch may have merged. Treat every recalled fact as a claim to re-ground, not an answer to reuse.
28
28
 
29
29
  - When a tool can settle it, re-check in silence and act on the fresh result — no question to the user.
30
- - When no tool can settle it and the user has a stake in the answer, ask through `AskUserQuestion` (see [`confirm-implementation-forks`](confirm-implementation-forks.md)).
30
+ - When no tool can settle it and the user has a stake in the answer, ask through `AskUserQuestion`.
31
31
 
32
32
  ## Questions That Belong to the User
33
33
 
@@ -12,6 +12,6 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
12
12
 
13
13
  In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
14
14
 
15
- The staged policy lint carries this check as its `rmtree-safety` rule. It reports the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
15
+ The staged policy lint carries this check as its `rmtree-safety` rule. It reports the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code. CI runs that lint against the merge base.
16
16
 
17
17
  Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first, so a fix lands in one and the other keeps the bug (CODE_RULES.md, CORE PRINCIPLES, "Reuse before create"). The same `rmtree-safety` lint rule reports a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` check, which a copy between two distant packages slips past.
@@ -9,4 +9,4 @@ In a `.workflow.js` agent-prompt template, every per-call or per-iteration value
9
9
 
10
10
  When a loop builds a per-iteration path or output key, write the index as a slot — `cand_<i>` — or spell out `replace <i> with the iteration index 0, 1, 2` in the step text. Every per-call value in a `.workflow.js` template carries angle brackets so an agent fills in a fresh value per call.
11
11
 
12
- The staged policy lint carries this check as its `workflow-substitution` rule. It reports a `.workflow.js` file whose looped content holds a bare `<word>_<i|j|k>` token as a per-iteration path segment. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. No write-time hook reports this, so a bare token stays on disk until the lint runs.
12
+ The staged policy lint carries this check as its `workflow-substitution` rule. It reports a `.workflow.js` file whose looped content holds a bare `<word>_<i|j|k>` token as a per-iteration path segment. CI runs that lint against the merge base. No write-time hook reports this, so a bare token stays on disk until the lint runs.
@@ -11,7 +11,10 @@
11
11
  - Only runs when the checked-out branch is the named branch
12
12
  - Only runs when there are no tracked changes (untracked files are fine),
13
13
  unless -StashDirty is passed
14
- - Uses merge --ff-only only (never force, never hard reset, never clean)
14
+ - Uses merge --ff-only (never force, never hard reset, never clean)
15
+ - When the fast-forward fails because the files were already copied in,
16
+ moves HEAD with a mixed reset, which writes no files. It keeps that move
17
+ only when every file the remote changed now matches the remote
15
18
  - Logs and exits non-zero on skip, diverge, or failure so a Task Scheduler
16
19
  history row can surface a problem instead of a silent no-op
17
20
 
@@ -128,6 +131,64 @@ function Invoke-GitOrExit {
128
131
  return $result.Output
129
132
  }
130
133
 
134
+ function Get-NulSeparatedPaths {
135
+ param(
136
+ [Parameter(Mandatory)]
137
+ [AllowEmptyString()]
138
+ [string]$Output
139
+ )
140
+ return @($Output -split "`0" | Where-Object { $_ })
141
+ }
142
+
143
+ function Invoke-AdoptCopiedTree {
144
+ <#
145
+ Handles a mirror whose files are refreshed by copy while its HEAD stays
146
+ behind. The fast-forward then refuses to overwrite the copied files,
147
+ even though they already hold the remote content.
148
+
149
+ A mixed reset moves HEAD and the index to the remote tip and writes no
150
+ files. If any file the remote changed still differs afterwards, the
151
+ reset goes back to the old tip, so the tree ends as it started.
152
+ #>
153
+ param(
154
+ [Parameter(Mandatory)]
155
+ [string]$LocalTip,
156
+ [Parameter(Mandatory)]
157
+ [string]$RemoteRef
158
+ )
159
+ $stagedResult = Invoke-Git -Arguments @('diff', '--cached', '--quiet')
160
+ if ($stagedResult.ExitCode -ne 0) {
161
+ Write-SyncLog -Level 'ERROR' -Message 'adopt skipped: the index holds staged changes'
162
+ return $false
163
+ }
164
+ $changedResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z', $LocalTip, $RemoteRef)
165
+ if ($changedResult.ExitCode -ne 0) {
166
+ Write-SyncLog -Level 'ERROR' -Message "adopt skipped: cannot list remote changes: $($changedResult.Output)"
167
+ return $false
168
+ }
169
+ $allRemoteChangedPaths = [System.Collections.Generic.HashSet[string]]::new([string[]](Get-NulSeparatedPaths -Output $changedResult.Output))
170
+
171
+ $resetResult = Invoke-Git -Arguments @('reset', '-q', $RemoteRef)
172
+ if ($resetResult.ExitCode -ne 0) {
173
+ Write-SyncLog -Level 'ERROR' -Message "adopt skipped: mixed reset failed: $($resetResult.Output)"
174
+ return $false
175
+ }
176
+ $differResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z')
177
+ $allMismatchedPaths = @(Get-NulSeparatedPaths -Output $differResult.Output | Where-Object { $allRemoteChangedPaths.Contains($_) })
178
+ if ($differResult.ExitCode -eq 0 -and $allMismatchedPaths.Count -eq 0) {
179
+ return $true
180
+ }
181
+
182
+ $revertResult = Invoke-Git -Arguments @('reset', '-q', $LocalTip)
183
+ if ($revertResult.ExitCode -ne 0) {
184
+ Write-SyncLog -Level 'ERROR' -Message "adopt revert to $LocalTip failed; HEAD is at $RemoteRef with no file changed: $($revertResult.Output)"
185
+ return $false
186
+ }
187
+ $shownPaths = ($allMismatchedPaths | Select-Object -First 20) -join '; '
188
+ Write-SyncLog -Level 'ERROR' -Message "adopt refused: $($allMismatchedPaths.Count) file(s) the remote changed differ from it; HEAD kept at ${LocalTip}: $shownPaths"
189
+ return $false
190
+ }
191
+
131
192
  try {
132
193
  if (-not (Test-Path -LiteralPath $RepoPath)) {
133
194
  Write-SyncLog -Level 'ERROR' -Message "repo path missing: $RepoPath"
@@ -194,14 +255,18 @@ try {
194
255
  Write-SyncLog -Level 'ERROR' -Message "merge --ff-only failed: $($mergeResult.Output)"
195
256
  if ($didStash) {
196
257
  $popResult = Invoke-Git -Arguments @('stash', 'pop')
197
- if ($popResult.ExitCode -eq 0) {
198
- Write-SyncLog -Level 'OK' -Message "restored the stashed edits to the working tree after the failed fast-forward"
199
- }
200
- else {
258
+ if ($popResult.ExitCode -ne 0) {
201
259
  Write-SyncLog -Level 'ERROR' -Message ("stash pop failed after the failed fast-forward; the edits are still stashed as '" + $stashMessage + "' — recover them with git stash list and git stash pop: " + $popResult.Output)
260
+ exit 1
202
261
  }
262
+ Write-SyncLog -Level 'OK' -Message "restored the stashed edits to the working tree after the failed fast-forward"
203
263
  }
204
- exit 1
264
+ if (-not (Invoke-AdoptCopiedTree -LocalTip $localTip -RemoteRef "$Remote/$Branch")) {
265
+ exit 1
266
+ }
267
+ $subject = Get-GitDecoration -Arguments @('log', '-1', '--format=%h %s') -Placeholder '<subject unavailable>'
268
+ Write-SyncLog -Level 'OK' -Message "$Branch $localTip -> $remoteTip adopted (files already matched) | $subject"
269
+ exit 0
205
270
  }
206
271
 
207
272
  $subject = Get-GitDecoration -Arguments @('log', '-1', '--format=%h %s') -Placeholder '<subject unavailable>'
@@ -91,31 +91,137 @@ def _messages_for_validator_outcomes(
91
91
  )
92
92
 
93
93
 
94
+ def _validator_messages_for_text(
95
+ document: Document,
96
+ absolute_path: Path,
97
+ text: str,
98
+ validator_module: ModuleType,
99
+ ) -> tuple[str, ...]:
100
+ all_validator_outcomes = validator_module.validate_proposed_file(
101
+ absolute_path.as_posix(),
102
+ text,
103
+ config_source_path=absolute_path,
104
+ include_ruff=False,
105
+ excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
106
+ )
107
+ return _messages_for_validator_outcomes(document, all_validator_outcomes)
108
+
109
+
110
+ def _message_body(message: str) -> str:
111
+ _head, separator, body = message.partition(
112
+ constants.VALIDATOR_MESSAGE_PREFIX_SEPARATOR
113
+ )
114
+ return body if separator and body else message
115
+
116
+
117
+ def _message_identity(message: str) -> str:
118
+ return constants.VALIDATOR_MESSAGE_DIGIT_PATTERN.sub(
119
+ constants.VALIDATOR_MESSAGE_DIGIT_PLACEHOLDER, _message_body(message)
120
+ )
121
+
122
+
123
+ def _all_measured_sizes(message: str) -> tuple[int, ...]:
124
+ return tuple(
125
+ int(each_digit_run)
126
+ for each_digit_run in constants.VALIDATOR_MESSAGE_DIGIT_PATTERN.findall(
127
+ _message_body(message)
128
+ )
129
+ )
130
+
131
+
132
+ def _has_grown_since(message: str, prior_message: str) -> bool:
133
+ return any(
134
+ each_size > each_prior_size
135
+ for each_size, each_prior_size in zip(
136
+ _all_measured_sizes(message), _all_measured_sizes(prior_message)
137
+ )
138
+ )
139
+
140
+
141
+ def _prior_messages_by_identity(
142
+ all_prior_messages: tuple[str, ...],
143
+ ) -> dict[str, list[str]]:
144
+ all_prior_by_identity: dict[str, list[str]] = {}
145
+ for each_prior_message in all_prior_messages:
146
+ all_prior_by_identity.setdefault(
147
+ _message_identity(each_prior_message), []
148
+ ).append(each_prior_message)
149
+ return all_prior_by_identity
150
+
151
+
152
+ def _is_introduced_message(
153
+ message: str, all_prior_by_identity: dict[str, list[str]]
154
+ ) -> bool:
155
+ all_matches = all_prior_by_identity.get(_message_identity(message))
156
+ if not all_matches:
157
+ return True
158
+ return _has_grown_since(message, all_matches.pop())
159
+
160
+
161
+ def _messages_introduced_by_the_change(
162
+ all_candidate_messages: tuple[str, ...],
163
+ all_prior_messages: tuple[str, ...],
164
+ ) -> tuple[str, ...]:
165
+ """Keep the messages the prior text did not already carry.
166
+
167
+ ::
168
+
169
+ prior: Function 'main' is 47 lines (max 30)
170
+ now: Function 'main' is 47 lines (max 30) -> dropped, untouched debt
171
+ now: Function 'main' is 61 lines (max 30) -> kept, the change grew it
172
+ now: Function 'added' is 47 lines (max 30) -> kept, a new breach
173
+
174
+ A message keeps its identity when it moves down the file or its measured
175
+ size falls, so only a new breach or a worse one survives. Each prior message
176
+ covers one candidate.
177
+
178
+ Args:
179
+ all_candidate_messages: Messages the post-edit text produced.
180
+ all_prior_messages: Messages the prior text produced.
181
+
182
+ Returns:
183
+ The messages this change is answerable for.
184
+ """
185
+ all_prior_by_identity = _prior_messages_by_identity(all_prior_messages)
186
+ return tuple(
187
+ each_message
188
+ for each_message in all_candidate_messages
189
+ if _is_introduced_message(each_message, all_prior_by_identity)
190
+ )
191
+
192
+
94
193
  def validator_diagnostics(
95
194
  document: Document,
96
195
  repository_root: Path,
97
196
  load_module: adapter_support.HookModuleLoader,
98
197
  ) -> tuple[Diagnostic, ...]:
99
- """Run the fast validator set on one document.
198
+ """Run the fast validator set on one document, scoped to the change.
199
+
200
+ A module already over the file-length cap reported that breach on every edit
201
+ it received, so a one-line change failed on debt it did not create. Grading
202
+ the prior text the same way and subtracting leaves the breaches this change
203
+ introduced or made worse.
100
204
 
101
205
  Args:
102
- document: Current source text and path.
206
+ document: Current source text, prior text, and path.
103
207
  repository_root: Request repository root for configuration resolution.
104
208
  load_module: Hook module loader.
105
209
 
106
210
  Returns:
107
- Path-normalized diagnostics for failed validators.
211
+ Path-normalized diagnostics for the failures this change is answerable for.
108
212
  """
109
213
  absolute_path = adapter_support._document_path(repository_root, document)
110
214
  validator_module = load_module("validators.run_all_validators")
111
- all_validator_outcomes = validator_module.validate_proposed_file(
112
- absolute_path.as_posix(),
113
- document.text,
114
- config_source_path=absolute_path,
115
- include_ruff=False,
116
- excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
215
+ all_messages = _validator_messages_for_text(
216
+ document, absolute_path, document.text, validator_module
117
217
  )
118
- all_messages = _messages_for_validator_outcomes(document, all_validator_outcomes)
218
+ if document.prior_text is not None:
219
+ all_prior_messages = _validator_messages_for_text(
220
+ document, absolute_path, document.prior_text, validator_module
221
+ )
222
+ all_messages = _messages_introduced_by_the_change(
223
+ all_messages, all_prior_messages
224
+ )
119
225
  return adapter_support._diagnostics_for_messages(document, "validators", all_messages)
120
226
 
121
227
 
@@ -30,6 +30,18 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
30
30
  ),
31
31
  }
32
32
  ),
33
+ PurePosixPath(
34
+ "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
35
+ ): frozenset(
36
+ {
37
+ PurePosixPath(
38
+ "packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py"
39
+ ),
40
+ PurePosixPath(
41
+ "packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_cap_meta.py"
42
+ ),
43
+ }
44
+ ),
33
45
  PurePosixPath(
34
46
  "packages/claude-dev-env/hooks/git-hooks/verification_notice_context.py"
35
47
  ): frozenset(
@@ -72,6 +72,9 @@ FAST_SAVE_VALIDATORS_STEM = "fast_save_validators"
72
72
  ALL_OVERLAPPING_VALIDATOR_NAMES = frozenset(
73
73
  {"Python Style", "Magic Values", "Type Safety", "Test Safety", "React"}
74
74
  )
75
+ VALIDATOR_MESSAGE_PREFIX_SEPARATOR = ": "
76
+ VALIDATOR_MESSAGE_DIGIT_PATTERN = re.compile(r"\d+")
77
+ VALIDATOR_MESSAGE_DIGIT_PLACEHOLDER = "#"
75
78
  ALL_PR_LOOP_SCRIPTS_PATH_SEGMENTS = ("_shared", "pr-loop", "scripts")
76
79
  TERMINOLOGY_SWEEP_MODULE_NAME = "terminology_sweep"
77
80
  TERMINOLOGY_SWEEP_RULE_ID = "terminology-sweep"