claude-dev-env 8.9.0 → 8.11.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/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.cursor-rules.test.mjs +6 -0
- package/bin/install.mjs +55 -33
- package/bin/install.test.mjs +37 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +27 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +16 -4
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/session/skill_loaded_reminder.py +28 -13
- package/hooks/session/test_skill_loaded_reminder.py +49 -2
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Behavior tests for the nested project hooks forwarder.
|
|
2
|
+
|
|
3
|
+
Each case builds a session directory that holds child checkouts, runs the hook
|
|
4
|
+
script the way Claude Code does (payload on stdin, ``CLAUDE_PROJECT_DIR`` set
|
|
5
|
+
to the session directory), and reads the exit code and output it produces.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
HOOK_SCRIPT_PATH = pathlib.Path(__file__).parent / "nested_project_hooks.py"
|
|
19
|
+
BLOCKING_EXIT_CODE = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_checkout(
|
|
23
|
+
session_directory: pathlib.Path,
|
|
24
|
+
checkout_name: str,
|
|
25
|
+
all_hook_groups_by_event: dict[str, list[dict[str, object]]],
|
|
26
|
+
) -> pathlib.Path:
|
|
27
|
+
checkout_directory = session_directory / checkout_name
|
|
28
|
+
(checkout_directory / ".git").mkdir(parents=True)
|
|
29
|
+
settings_directory = checkout_directory / ".claude"
|
|
30
|
+
settings_directory.mkdir()
|
|
31
|
+
(settings_directory / "settings.json").write_text(
|
|
32
|
+
json.dumps({"hooks": all_hook_groups_by_event}), encoding="utf-8"
|
|
33
|
+
)
|
|
34
|
+
return checkout_directory
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _command_group(matcher: str, command: str) -> dict[str, object]:
|
|
38
|
+
return {"matcher": matcher, "hooks": [{"type": "command", "command": command}]}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_hook(
|
|
42
|
+
session_directory: pathlib.Path, payload: dict[str, object]
|
|
43
|
+
) -> subprocess.CompletedProcess[str]:
|
|
44
|
+
hook_environment = dict(os.environ)
|
|
45
|
+
hook_environment["CLAUDE_PROJECT_DIR"] = str(session_directory)
|
|
46
|
+
return subprocess.run(
|
|
47
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
48
|
+
input=json.dumps(payload),
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
env=hook_environment,
|
|
52
|
+
check=False,
|
|
53
|
+
timeout=60,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pre_tool_use_payload(tool_name: str) -> dict[str, object]:
|
|
58
|
+
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": {}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _session_start_payload() -> dict[str, object]:
|
|
62
|
+
return {"hook_event_name": "SessionStart", "source": "startup"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_should_run_a_child_session_start_hook_inside_that_child(
|
|
66
|
+
tmp_path: pathlib.Path,
|
|
67
|
+
) -> None:
|
|
68
|
+
record_path = tmp_path / "record.txt"
|
|
69
|
+
checkout_directory = _make_checkout(
|
|
70
|
+
tmp_path / "session",
|
|
71
|
+
"alpha",
|
|
72
|
+
{
|
|
73
|
+
"SessionStart": [
|
|
74
|
+
_command_group(
|
|
75
|
+
"",
|
|
76
|
+
f'echo "$CLAUDE_PROJECT_DIR|$(pwd)" > "{record_path}"',
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
completed_process = _run_hook(tmp_path / "session", _session_start_payload())
|
|
83
|
+
|
|
84
|
+
assert completed_process.returncode == 0
|
|
85
|
+
resolved_checkout = str(checkout_directory.resolve())
|
|
86
|
+
project_directory, working_directory = (
|
|
87
|
+
record_path.read_text(encoding="utf-8").strip().split("|")
|
|
88
|
+
)
|
|
89
|
+
assert str(pathlib.Path(project_directory).resolve()) == resolved_checkout
|
|
90
|
+
assert str(pathlib.Path(working_directory).resolve()) == resolved_checkout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_should_carry_child_session_start_output_as_additional_context(
|
|
94
|
+
tmp_path: pathlib.Path,
|
|
95
|
+
) -> None:
|
|
96
|
+
_make_checkout(
|
|
97
|
+
tmp_path,
|
|
98
|
+
"alpha",
|
|
99
|
+
{"SessionStart": [_command_group("", "echo alpha is ready")]},
|
|
100
|
+
)
|
|
101
|
+
_make_checkout(
|
|
102
|
+
tmp_path,
|
|
103
|
+
"beta",
|
|
104
|
+
{"SessionStart": [_command_group("", "echo beta is ready")]},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
completed_process = _run_hook(tmp_path, _session_start_payload())
|
|
108
|
+
|
|
109
|
+
assert completed_process.returncode == 0
|
|
110
|
+
hook_output = json.loads(completed_process.stdout)
|
|
111
|
+
additional_context = hook_output["hookSpecificOutput"]["additionalContext"]
|
|
112
|
+
assert hook_output["hookSpecificOutput"]["hookEventName"] == "SessionStart"
|
|
113
|
+
assert "alpha is ready" in additional_context
|
|
114
|
+
assert "beta is ready" in additional_context
|
|
115
|
+
assert additional_context.index("alpha") < additional_context.index("beta")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_block_a_tool_call_when_a_child_hook_exits_two(
|
|
119
|
+
tmp_path: pathlib.Path,
|
|
120
|
+
) -> None:
|
|
121
|
+
_make_checkout(
|
|
122
|
+
tmp_path,
|
|
123
|
+
"alpha",
|
|
124
|
+
{"PreToolUse": [_command_group("Bash", "echo the gate refused this >&2; exit 2")]},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
128
|
+
|
|
129
|
+
assert completed_process.returncode == BLOCKING_EXIT_CODE
|
|
130
|
+
assert "the gate refused this" in completed_process.stderr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_should_pass_a_child_deny_decision_through(tmp_path: pathlib.Path) -> None:
|
|
134
|
+
deny_output = {
|
|
135
|
+
"hookSpecificOutput": {
|
|
136
|
+
"hookEventName": "PreToolUse",
|
|
137
|
+
"permissionDecision": "deny",
|
|
138
|
+
"permissionDecisionReason": "no receipt for this commit",
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
_make_checkout(
|
|
142
|
+
tmp_path,
|
|
143
|
+
"alpha",
|
|
144
|
+
{
|
|
145
|
+
"PreToolUse": [
|
|
146
|
+
_command_group(
|
|
147
|
+
"Bash|mcp__github__merge_pull_request",
|
|
148
|
+
f"echo '{json.dumps(deny_output)}'",
|
|
149
|
+
)
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
completed_process = _run_hook(
|
|
155
|
+
tmp_path, _pre_tool_use_payload("mcp__github__merge_pull_request")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert completed_process.returncode == 0
|
|
159
|
+
assert json.loads(completed_process.stdout) == deny_output
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_should_not_forward_a_child_allow_decision(tmp_path: pathlib.Path) -> None:
|
|
163
|
+
allow_output = {
|
|
164
|
+
"hookSpecificOutput": {
|
|
165
|
+
"hookEventName": "PreToolUse",
|
|
166
|
+
"permissionDecision": "allow",
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
_make_checkout(
|
|
170
|
+
tmp_path,
|
|
171
|
+
"alpha",
|
|
172
|
+
{"PreToolUse": [_command_group("", f"echo '{json.dumps(allow_output)}'")]},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
176
|
+
|
|
177
|
+
assert completed_process.returncode == 0
|
|
178
|
+
assert completed_process.stdout.strip() == ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_should_skip_a_child_hook_whose_matcher_names_another_tool(
|
|
182
|
+
tmp_path: pathlib.Path,
|
|
183
|
+
) -> None:
|
|
184
|
+
_make_checkout(
|
|
185
|
+
tmp_path,
|
|
186
|
+
"alpha",
|
|
187
|
+
{"PreToolUse": [_command_group("Write|Edit", "exit 2")]},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
191
|
+
|
|
192
|
+
assert completed_process.returncode == 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_should_do_nothing_when_the_session_directory_is_a_checkout(
|
|
196
|
+
tmp_path: pathlib.Path,
|
|
197
|
+
) -> None:
|
|
198
|
+
(tmp_path / ".git").mkdir()
|
|
199
|
+
_make_checkout(
|
|
200
|
+
tmp_path,
|
|
201
|
+
"alpha",
|
|
202
|
+
{"PreToolUse": [_command_group("", "exit 2")]},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
206
|
+
|
|
207
|
+
assert completed_process.returncode == 0
|
|
208
|
+
assert completed_process.stdout.strip() == ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_should_skip_a_child_directory_that_is_not_a_checkout(
|
|
212
|
+
tmp_path: pathlib.Path,
|
|
213
|
+
) -> None:
|
|
214
|
+
checkout_directory = _make_checkout(
|
|
215
|
+
tmp_path,
|
|
216
|
+
"alpha",
|
|
217
|
+
{"PreToolUse": [_command_group("", "exit 2")]},
|
|
218
|
+
)
|
|
219
|
+
(checkout_directory / ".git").rmdir()
|
|
220
|
+
|
|
221
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
222
|
+
|
|
223
|
+
assert completed_process.returncode == 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_should_let_the_call_through_when_a_child_hook_fails_to_start(
|
|
227
|
+
tmp_path: pathlib.Path,
|
|
228
|
+
) -> None:
|
|
229
|
+
_make_checkout(
|
|
230
|
+
tmp_path,
|
|
231
|
+
"alpha",
|
|
232
|
+
{"PreToolUse": [_command_group("", "a-command-that-does-not-exist")]},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
236
|
+
|
|
237
|
+
assert completed_process.returncode == 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@pytest.mark.parametrize("unreadable_settings", ["{not json", "[]", '{"hooks": []}'])
|
|
241
|
+
def test_should_skip_a_child_whose_settings_cannot_be_read(
|
|
242
|
+
tmp_path: pathlib.Path, unreadable_settings: str
|
|
243
|
+
) -> None:
|
|
244
|
+
checkout_directory = _make_checkout(tmp_path, "alpha", {})
|
|
245
|
+
(checkout_directory / ".claude" / "settings.json").write_text(
|
|
246
|
+
unreadable_settings, encoding="utf-8"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
250
|
+
|
|
251
|
+
assert completed_process.returncode == 0
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Hook that keeps poteto-mode loaded in every session, subagent and workflow helper.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
|
-
Agent
|
|
6
|
+
Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke pstack:poteto-mode"
|
|
7
|
+
Workflow script helper starts -> "invoke pstack:poteto-mode now"
|
|
7
8
|
context compacted mid-run -> "invoke pstack:poteto-mode again"
|
|
8
9
|
user turn, skill not loaded since compacting -> "invoke pstack:poteto-mode now"
|
|
9
10
|
user turn, skill already loaded -> nothing
|
|
@@ -25,7 +26,6 @@ if _hooks_dir not in sys.path:
|
|
|
25
26
|
|
|
26
27
|
from hooks_constants.skill_loaded_reminder_constants import (
|
|
27
28
|
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
28
|
-
ALL_SUBAGENT_TOOL_NAMES,
|
|
29
29
|
ASSISTANT_ENTRY_TYPE,
|
|
30
30
|
COMPACT_BOUNDARY_SUBTYPE,
|
|
31
31
|
COMPACTION_REMINDER,
|
|
@@ -37,10 +37,12 @@ from hooks_constants.skill_loaded_reminder_constants import (
|
|
|
37
37
|
SESSION_START_EVENT_NAME,
|
|
38
38
|
SKILL_TOOL_NAME,
|
|
39
39
|
SLASH_COMMAND_MARKER,
|
|
40
|
-
|
|
40
|
+
SUBAGENT_START_EVENT_NAME,
|
|
41
|
+
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
|
|
41
42
|
TOOL_USE_BLOCK_TYPE,
|
|
42
43
|
USER_ENTRY_TYPE,
|
|
43
44
|
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
45
|
+
WORKFLOW_SUBAGENT_TYPE,
|
|
44
46
|
)
|
|
45
47
|
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
46
48
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
@@ -48,25 +50,32 @@ from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY,
|
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
def subagent_input_with_poteto_mode(
|
|
53
|
+
tool_name: str,
|
|
51
54
|
all_tool_input_fields: dict[str, object],
|
|
52
55
|
) -> dict[str, object] | None:
|
|
53
|
-
"""Return the
|
|
56
|
+
"""Return the spawn input with the poteto-mode invocation first, or None to leave it.
|
|
54
57
|
|
|
55
58
|
::
|
|
56
59
|
|
|
57
|
-
{"prompt": "Reply leaf."}
|
|
58
|
-
{"
|
|
59
|
-
{"
|
|
60
|
+
Agent {"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
|
|
61
|
+
spawn_agent {"message": "Fix it."} -> {"message": "$pstack:poteto-mode\\n\\nFix it."}
|
|
62
|
+
Agent {"prompt": "Invoke pstack:poteto-mode, then ..."} -> None
|
|
63
|
+
Agent {"subagent_type": "pstack:poteto-agent", ...} -> None, it loads the skill
|
|
64
|
+
|
|
65
|
+
Claude Code spawns through Agent or Task and Codex through spawn_agent, so
|
|
66
|
+
each tool name carries its own prompt field and invocation text.
|
|
60
67
|
|
|
61
68
|
Args:
|
|
62
|
-
|
|
69
|
+
tool_name: The spawn tool the session called.
|
|
70
|
+
all_tool_input_fields: The spawn input the session is about to send.
|
|
63
71
|
"""
|
|
64
|
-
|
|
72
|
+
field_name, invocation_prefix = ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME[tool_name]
|
|
73
|
+
prompt = all_tool_input_fields.get(field_name)
|
|
65
74
|
if not isinstance(prompt, str) or POTETO_MODE_SKILL_NAME in prompt:
|
|
66
75
|
return None
|
|
67
76
|
if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
|
|
68
77
|
return None
|
|
69
|
-
return {**all_tool_input_fields,
|
|
78
|
+
return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
|
|
70
79
|
|
|
71
80
|
|
|
72
81
|
def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
|
|
@@ -149,6 +158,7 @@ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
149
158
|
::
|
|
150
159
|
|
|
151
160
|
SessionStart, source compact -> COMPACTION_REMINDER
|
|
161
|
+
SubagentStart, workflow-subagent -> NOT_LOADED_REMINDER
|
|
152
162
|
UserPromptSubmit, transcript lacks the skill -> NOT_LOADED_REMINDER
|
|
153
163
|
UserPromptSubmit, transcript loaded the skill -> None
|
|
154
164
|
|
|
@@ -158,6 +168,9 @@ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
158
168
|
event_name = all_hook_fields.get("hook_event_name")
|
|
159
169
|
if event_name == SESSION_START_EVENT_NAME:
|
|
160
170
|
return COMPACTION_REMINDER if all_hook_fields.get("source") == COMPACTION_SOURCE else None
|
|
171
|
+
if event_name == SUBAGENT_START_EVENT_NAME:
|
|
172
|
+
is_workflow_helper = all_hook_fields.get("agent_type") == WORKFLOW_SUBAGENT_TYPE
|
|
173
|
+
return NOT_LOADED_REMINDER if is_workflow_helper else None
|
|
161
174
|
if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
162
175
|
return None
|
|
163
176
|
if _is_loaded_in_transcript(all_hook_fields.get("transcript_path")):
|
|
@@ -168,7 +181,7 @@ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
168
181
|
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
169
182
|
return (
|
|
170
183
|
all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
|
|
171
|
-
and all_hook_fields.get("tool_name") in
|
|
184
|
+
and all_hook_fields.get("tool_name") in ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME
|
|
172
185
|
and isinstance(all_hook_fields.get("tool_input"), dict)
|
|
173
186
|
)
|
|
174
187
|
|
|
@@ -179,7 +192,9 @@ def main() -> None:
|
|
|
179
192
|
if hook_payload is None:
|
|
180
193
|
return
|
|
181
194
|
if _is_subagent_spawn(hook_payload):
|
|
182
|
-
rewritten_input = subagent_input_with_poteto_mode(
|
|
195
|
+
rewritten_input = subagent_input_with_poteto_mode(
|
|
196
|
+
hook_payload["tool_name"], hook_payload["tool_input"]
|
|
197
|
+
)
|
|
183
198
|
if rewritten_input is not None:
|
|
184
199
|
write_pre_tool_use_allow_to_stdout(rewritten_input)
|
|
185
200
|
return
|
|
@@ -132,14 +132,46 @@ class TestSubagentSpawn:
|
|
|
132
132
|
class TestSubagentInputWithPotetoMode:
|
|
133
133
|
def test_every_other_input_field_is_kept(self) -> None:
|
|
134
134
|
rewritten_input = reminder.subagent_input_with_poteto_mode(
|
|
135
|
-
{"prompt": "Go.", "model": "sonnet", "run_in_background": True}
|
|
135
|
+
"Agent", {"prompt": "Go.", "model": "sonnet", "run_in_background": True}
|
|
136
136
|
)
|
|
137
137
|
assert rewritten_input is not None
|
|
138
138
|
assert rewritten_input["model"] == "sonnet"
|
|
139
139
|
assert rewritten_input["run_in_background"] is True
|
|
140
140
|
|
|
141
141
|
def test_an_input_with_no_prompt_is_left_alone(self) -> None:
|
|
142
|
-
assert reminder.subagent_input_with_poteto_mode({"description": "Go"}) is None
|
|
142
|
+
assert reminder.subagent_input_with_poteto_mode("Agent", {"description": "Go"}) is None
|
|
143
|
+
|
|
144
|
+
def test_a_codex_spawn_input_with_only_items_is_left_alone(self) -> None:
|
|
145
|
+
assert (
|
|
146
|
+
reminder.subagent_input_with_poteto_mode(
|
|
147
|
+
"spawn_agent", {"items": [{"type": "text", "text": "Go."}]}
|
|
148
|
+
)
|
|
149
|
+
is None
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestCodexSpawn:
|
|
154
|
+
def test_a_codex_spawn_message_opens_with_the_skill_mention(self) -> None:
|
|
155
|
+
emitted = json.loads(
|
|
156
|
+
_run_main(
|
|
157
|
+
_agent_call(
|
|
158
|
+
{"message": "Fix the failing test.", "model": "gpt-6-luna"},
|
|
159
|
+
tool_name="spawn_agent",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
rewritten_input = emitted["hookSpecificOutput"]["updatedInput"]
|
|
164
|
+
assert rewritten_input["message"] == "$pstack:poteto-mode\n\nFix the failing test."
|
|
165
|
+
assert rewritten_input["model"] == "gpt-6-luna"
|
|
166
|
+
assert "prompt" not in rewritten_input
|
|
167
|
+
|
|
168
|
+
def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
|
|
169
|
+
assert (
|
|
170
|
+
_run_main(
|
|
171
|
+
_agent_call({"message": "$pstack:poteto-mode\n\nFix it."}, tool_name="spawn_agent")
|
|
172
|
+
)
|
|
173
|
+
== ""
|
|
174
|
+
)
|
|
143
175
|
|
|
144
176
|
|
|
145
177
|
class TestIsPotetoModeLoaded:
|
|
@@ -202,5 +234,20 @@ class TestReminderFor:
|
|
|
202
234
|
def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
|
|
203
235
|
assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
|
|
204
236
|
|
|
237
|
+
def test_a_workflow_helper_starting_is_told_to_invoke_the_skill(self) -> None:
|
|
238
|
+
emitted = json.loads(
|
|
239
|
+
_run_main({"hook_event_name": "SubagentStart", "agent_type": "workflow-subagent"})
|
|
240
|
+
)
|
|
241
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
242
|
+
assert hook_output["hookEventName"] == "SubagentStart"
|
|
243
|
+
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
244
|
+
|
|
245
|
+
def test_an_agent_tool_helper_starting_prints_nothing_since_its_prompt_carries_the_line(
|
|
246
|
+
self,
|
|
247
|
+
) -> None:
|
|
248
|
+
assert (
|
|
249
|
+
_run_main({"hook_event_name": "SubagentStart", "agent_type": "general-purpose"}) == ""
|
|
250
|
+
)
|
|
251
|
+
|
|
205
252
|
def test_a_payload_that_is_not_json_prints_nothing(self) -> None:
|
|
206
253
|
assert _run_main("not json") == ""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Behavior tests for the non-breaking-finding follow-up ledger."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -82,6 +83,23 @@ def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path)
|
|
|
82
83
|
assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
|
|
87
|
+
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
|
88
|
+
|
|
89
|
+
record_followup_finding(
|
|
90
|
+
tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
status_result = subprocess.run(
|
|
94
|
+
["git", "-C", str(tmp_path), "status", "--porcelain", "--untracked-files=all"],
|
|
95
|
+
check=True,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
assert followup_ledger_path(tmp_path).is_file()
|
|
100
|
+
assert status_result.stdout == ""
|
|
101
|
+
|
|
102
|
+
|
|
85
103
|
def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> None:
|
|
86
104
|
blocking_file = tmp_path / ".claude"
|
|
87
105
|
blocking_file.write_text("not a directory", encoding="utf-8")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.11.0",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"_shared/",
|
|
28
28
|
"audit-rubrics/",
|
|
29
29
|
"codex-rules/",
|
|
30
|
+
"pstack-codex-models.md",
|
|
30
31
|
"AGENTS.md",
|
|
31
32
|
".claude/CLAUDE.md",
|
|
32
33
|
"!**/__pycache__/**",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# pstack model configuration
|
|
2
|
+
|
|
3
|
+
feature, refactoring: gpt-6-sol
|
|
4
|
+
bug-fix: gpt-6-astra
|
|
5
|
+
perf-issue: gpt-6-sol
|
|
6
|
+
hillclimb: gpt-6-sol
|
|
7
|
+
judgment and prose: gpt-6-astra
|
|
8
|
+
strongest judgment: gpt-6-astra
|
|
9
|
+
how explorer: gpt-6-luna
|
|
10
|
+
how explainer: gpt-6-luna
|
|
11
|
+
why investigators: gpt-6-luna
|
|
12
|
+
why synthesizer: gpt-6-luna
|
|
13
|
+
reflect tooling: gpt-6-sol
|
|
14
|
+
reflect judgment, divergent, synthesizer: gpt-6-astra
|
|
15
|
+
arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
16
|
+
arena cross-judge pool: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
17
|
+
swarm workers: gpt-6-luna
|
|
18
|
+
architect runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
19
|
+
interrogate reviewers: gpt-6-astra, gpt-6-sol, gpt-6-luna
|
|
20
|
+
|
|
21
|
+
## Reasoning effort
|
|
22
|
+
|
|
23
|
+
Single-model Luna roles use max. Astra roles use low. Sol roles use medium.
|
|
24
|
+
Panel entries use low, max, xhigh in the listed order.
|
|
25
|
+
Keep duplicate panel entries. Each entry specifies one candidate.
|
|
26
|
+
The cross-judge pool retains the effort paired with each entry.
|
|
27
|
+
|
|
28
|
+
Apply these substitutions once to incoming model requests:
|
|
29
|
+
- Sol medium through Luna max requests use gpt-6-luna with max effort.
|
|
30
|
+
- Sol high or higher requests use gpt-6-astra with low effort.
|
|
31
|
+
- Terra requests use gpt-6-luna with xhigh effort.
|
|
32
|
+
- Existing Luna requests use gpt-6-luna with high effort.
|
|
33
|
+
|
|
34
|
+
Explicit role and panel settings above take precedence over substitutions.
|
|
35
|
+
Do not apply substitutions again to their output.
|
|
36
|
+
|
|
37
|
+
session hook: on
|
package/rules/bdd.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
1. **Deliberate Discovery** — Reduce uncertainty before code; surface what you do not know (Smart & Molak §5.4).
|
|
14
14
|
2. **Illustrate** — Explore goals, constraints, and concrete examples in chat; "given … when … then …" style outcomes.
|
|
15
15
|
3. **Formulate** — Express behavior as narrow **"should …"** specifications the user can approve.
|
|
16
|
-
4. **Automate** — Build each formulated behavior
|
|
16
|
+
4. **Automate** — Build each formulated behavior with tests. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it: CODE_RULES §8, as stated in [`code-standards.md`](code-standards.md). A prototype may run ahead of its tests and adds them before the pull request goes ready.
|
|
17
17
|
|
|
18
18
|
Conversation is the essential practice: if discovery is skipped, structured formats do not rescue the workflow (Minimal BDD).
|
|
19
19
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Build Needs a User
|
|
2
|
+
|
|
3
|
+
**When this applies:** Before you build a new tool, script, hook, gate, skill,
|
|
4
|
+
command, or workflow step. That includes a hook or check built to answer a
|
|
5
|
+
correction.
|
|
6
|
+
|
|
7
|
+
## Rule
|
|
8
|
+
|
|
9
|
+
Answer three questions before the first line of the build:
|
|
10
|
+
|
|
11
|
+
1. **Who calls it?** Name the hook entry, workflow step, skill, command, or
|
|
12
|
+
module that will run it in this same change.
|
|
13
|
+
2. **When does it first run?** Name the date or the event of first use.
|
|
14
|
+
3. **When was this job last done by hand?** Name the last time, and how often it
|
|
15
|
+
comes up.
|
|
16
|
+
|
|
17
|
+
Build when all three have a concrete answer. When the only answer is "it might
|
|
18
|
+
help" or "we could use it later", stop and send the owner one line: what the
|
|
19
|
+
build is for and which question has no answer. Keep the rest of the task moving
|
|
20
|
+
while that line waits.
|
|
21
|
+
|
|
22
|
+
Search first, per [`prefer-existing-tools.md`](prefer-existing-tools.md). A
|
|
23
|
+
tool that already exists answers the need, and the build is the glue.
|
|
24
|
+
|
|
25
|
+
## Size the first build to the first use
|
|
26
|
+
|
|
27
|
+
Build the smallest version that serves the first caller. A correction starts
|
|
28
|
+
as a row in a rule file. It becomes a hook or a lint only when the same
|
|
29
|
+
correction arrives a second time, per
|
|
30
|
+
[`correction-lens.md`](correction-lens.md).
|
|
31
|
+
|
|
32
|
+
## Where it is enforced
|
|
33
|
+
|
|
34
|
+
The staged policy lint's `uncalled-new-file` rule reads each code file a change
|
|
35
|
+
adds under `scripts/`, `hooks/`, or `bin/`. It reports the file when its name
|
|
36
|
+
appears only in its own tests, `CHANGELOG.md`, `README.md`, and
|
|
37
|
+
`bin/ever-shipped-skills.mjs`. CI runs that lint against the merge base, so a
|
|
38
|
+
file with no caller turns the pull request red.
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
The history of this package holds files that lived weeks and ran never. The
|
|
43
|
+
Codex compatibility watcher shipped 502 lines and 599 lines of tests in July,
|
|
44
|
+
and PR 1488 deleted it in September. Its own test was the only file that named
|
|
45
|
+
it.
|
|
46
|
+
|
|
47
|
+
## Sibling rules
|
|
48
|
+
|
|
49
|
+
| Rule | Role |
|
|
50
|
+
|---|---|
|
|
51
|
+
| [`prefer-existing-tools.md`](prefer-existing-tools.md) | Search before you build |
|
|
52
|
+
| [`correction-lens.md`](correction-lens.md) | A repeated correction moves up a layer |
|
|
53
|
+
| [`archiving-agent-config.md`](archiving-agent-config.md) | How an unused capability leaves service |
|
package/rules/code-standards.md
CHANGED
|
@@ -18,7 +18,7 @@ Load `CODE_RULES.md` when reviewing a PR, resolving a policy conflict, or genera
|
|
|
18
18
|
|
|
19
19
|
Two standards live in `CODE_RULES.md` in full:
|
|
20
20
|
|
|
21
|
-
- **TDD** — CODE_RULES §8
|
|
21
|
+
- **TDD** — CODE_RULES §8: red, green, refactor is the default loop for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug.
|
|
22
22
|
- **Right-sized engineering** — CODE_RULES §7 / AGENTS Design: functions over classes; concrete over abstract; add an abstraction at the commit that introduces its second concrete implementation. That count is a house call, one occurrence earlier than the rule of three Fowler credits to Don Roberts. The direction comes from the literature; the number does not, so read it as this package's setting rather than as a cited standard.
|
|
23
23
|
|
|
24
24
|
BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers and formulates the behavior a feature needs, then each formulated behavior is built through the TDD cycle.
|
|
@@ -108,6 +108,13 @@ naming a file that does not exist, an env-var row naming a file that never
|
|
|
108
108
|
reads the variable, a test outside the testpaths allowlist, and a tracked
|
|
109
109
|
secret.
|
|
110
110
|
|
|
111
|
+
`scripts/cde_lint.py` splits the policy-lint findings the same way.
|
|
112
|
+
`SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py`
|
|
113
|
+
carries one row per smell, and a check id with no row reads as breaking. Three
|
|
114
|
+
checks are smells there: `test-pairing` and the two paired-test coverage
|
|
115
|
+
checks. The lint prints each as a warning, records it in the ledger, and exits
|
|
116
|
+
zero when no error remains.
|
|
117
|
+
|
|
111
118
|
## Sibling rules
|
|
112
119
|
|
|
113
120
|
| Rule | Role |
|
|
@@ -20,6 +20,8 @@ When you add a public function to a module whose test suite already exercises th
|
|
|
20
20
|
|
|
21
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
|
+
Both checks record smells, per [`flag-non-breaking-findings.md`](flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it in `.claude/followups/smells.jsonl`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
|
|
24
|
+
|
|
23
25
|
`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
26
|
|
|
25
27
|
1. The target is production code — not a test module, hook infrastructure, config module, migration, workflow registry, or `__init__.py`.
|
|
@@ -33,8 +35,8 @@ A public function counts as covered when its name appears — imported, called,
|
|
|
33
35
|
|
|
34
36
|
## Relationship to the file-level TDD order
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
No hook or lint checks the order in which a test and its module are written. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it; a prototype may run ahead of its tests and adds them before the pull request goes ready. This check judges coverage one function at a time for a module that already carries a stem-matched test file, and reports any public function that file leaves uncovered.
|
|
37
39
|
|
|
38
40
|
## Why this check is mechanical
|
|
39
41
|
|
|
40
|
-
A public function with no test reads as covered when the module's test file sits right beside it and exercises its siblings. The gap survives review because the suite looks complete. Running the lint on every staged change
|
|
42
|
+
A public function with no test reads as covered when the module's test file sits right beside it and exercises its siblings. The gap survives review because the suite looks complete. Running the lint on every staged change records each gap in the follow-up ledger, where `cde followup list` names it until a pull request closes it.
|
package/rules/testing.md
CHANGED
|
@@ -25,4 +25,8 @@ Tests exercise production behavior, production data, and production code paths.
|
|
|
25
25
|
|
|
26
26
|
## No Gate Holds the Test-First Order
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Red, green, refactor is the default loop for a bug fix and for new behavior. The TDD skill (`pstack:tdd`) carries the procedure, and no hook or lint checks the order. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug. Review reads the tests on the diff.
|
|
29
|
+
|
|
30
|
+
## A Fix Carries Its Proof Test
|
|
31
|
+
|
|
32
|
+
The `Fix test proof` job in `.github/workflows/pr-check.yml` runs `_shared/pr-loop/scripts/fix_pr_test_proof.py` on every pull request whose title starts with `fix`. A fix that changes production code must change at least one Python test that fails on the base and passes on the head. A fix that changes only docs or CI config passes. Version one reads Python tests only, so a fix proven by a JavaScript or PowerShell test needs a Python test beside it.
|