claude-dev-env 2.26.0 → 2.28.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/.agents/agents/clean-coder.md +95 -33
- package/.agents/agents/code-quality-agent.md +85 -24
- package/.agents/agents/pr-description-writer.md +2 -2
- package/.agents/agents/test_agent_frontmatter.py +626 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
- package/.agents/skills/hitl/SKILL.md +45 -0
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
- package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.agents-home.test.mjs +199 -8
- package/bin/install.mjs +15 -8
- package/bin/install.test.mjs +82 -2
- package/commands/sr-loop.md +10 -1
- package/docs/codex-compatibility.md +19 -0
- package/hooks/blocking/luna_fast_mode_gate.py +160 -0
- package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
- package/hooks/hooks.json +10 -5
- package/hooks/hooks_constants/AGENTS.md +1 -1
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
- package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
- package/hooks/session/AGENTS.md +2 -2
- package/hooks/validators/AGENTS.md +2 -1
- package/hooks/validators/conftest.py +21 -4
- package/hooks/validators/mypy_integration.py +77 -16
- package/hooks/validators/run_all_validators.py +2 -35
- package/hooks/validators/system_temporary_roots.py +90 -0
- package/hooks/validators/test_directory_exemption_constants.py +1 -1
- package/hooks/validators/test_mypy_integration.py +169 -0
- package/hooks/validators/test_system_temporary_roots.py +93 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +99 -12
- package/scripts/tests/test_codex_compat_materializer.py +70 -19
|
@@ -50,6 +50,25 @@ Limits:
|
|
|
50
50
|
|
|
51
51
|
- The installer skips Codex `hooks.json` for now. Wire it by hand.
|
|
52
52
|
|
|
53
|
+
## Luna fast-mode guard
|
|
54
|
+
|
|
55
|
+
`hooks/blocking/luna_fast_mode_gate.py` serves Claude and Codex. Codex reads its own `hooks.json`, so add this `PreToolUse` group to that file. Replace `<CODEX_HOOKS_ROOT>` with the directory that holds the shipped hook:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"matcher": "Agent|Task|multi_agent_v1__spawn_agent",
|
|
60
|
+
"hooks": [
|
|
61
|
+
{
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": "python <CODEX_HOOKS_ROOT>/blocking/luna_fast_mode_gate.py",
|
|
64
|
+
"timeout": 10
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The guard requires exact `fast` for Luna spawns through `Agent` and `Task`. Native Codex `multi_agent_v1__spawn_agent` accepts exact `fast` or `priority`. Other models and tools pass through.
|
|
71
|
+
|
|
53
72
|
## Roots and safety
|
|
54
73
|
|
|
55
74
|
Both roots are caller-supplied. The tool never writes to `.agents` or `CODEX_HOME` automatically; pass those locations explicitly when desired. No personal paths or secrets are embedded in the package.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: deny Luna spawns that use an unsupported service tier."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping, TextIO
|
|
10
|
+
|
|
11
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _hooks_dir not in sys.path:
|
|
13
|
+
sys.path.insert(0, _hooks_dir)
|
|
14
|
+
|
|
15
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
16
|
+
from hooks_constants.luna_fast_mode_gate_constants import ( # noqa: E402
|
|
17
|
+
ALL_SPAWN_TOOL_NAMES,
|
|
18
|
+
CALLING_HOOK_NAME,
|
|
19
|
+
CODEX_AGENT_TOOL_NAME,
|
|
20
|
+
DENY_ADDITIONAL_CONTEXT,
|
|
21
|
+
DENY_PREVIEW_TEMPLATE,
|
|
22
|
+
DENY_REASON,
|
|
23
|
+
FAST_SERVICE_TIER,
|
|
24
|
+
HOOK_EVENT_NAME,
|
|
25
|
+
LUNA_MODEL_ALIAS,
|
|
26
|
+
MAXIMUM_PREVIEW_FIELD_LENGTH,
|
|
27
|
+
MODEL_FIELD_NAME,
|
|
28
|
+
MODEL_SEGMENT_SPLIT_PATTERN,
|
|
29
|
+
PRIORITY_SERVICE_TIER,
|
|
30
|
+
SERVICE_TIER_FIELD_NAME,
|
|
31
|
+
TOOL_INPUT_FIELD_NAME,
|
|
32
|
+
TOOL_NAME_FIELD_NAME,
|
|
33
|
+
)
|
|
34
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
|
+
read_hook_input_dictionary_from_stdin,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _model_names_the_luna_tier(model_identifier: str) -> bool:
|
|
40
|
+
"""Report whether a model string names the Luna tier.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
model_identifier: The spawn's ``model`` string.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when a model segment reads ``luna`` in any letter case; False
|
|
47
|
+
for another tier.
|
|
48
|
+
"""
|
|
49
|
+
all_segments = MODEL_SEGMENT_SPLIT_PATTERN.split(model_identifier.strip().lower())
|
|
50
|
+
return LUNA_MODEL_ALIAS in all_segments
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_service_tier_allowed(tool_name: object, service_tier: object) -> bool:
|
|
54
|
+
if service_tier == FAST_SERVICE_TIER:
|
|
55
|
+
return True
|
|
56
|
+
return (
|
|
57
|
+
tool_name == CODEX_AGENT_TOOL_NAME
|
|
58
|
+
and service_tier == PRIORITY_SERVICE_TIER
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _denied_spawn_details(
|
|
63
|
+
all_payload_by_field: Mapping[str, object],
|
|
64
|
+
) -> tuple[str, object] | None:
|
|
65
|
+
"""Return model and service tier for a Luna spawn that must be denied.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
all_payload_by_field: The parsed PreToolUse payload.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The model and service tier for an invalid Luna spawn; None otherwise.
|
|
72
|
+
"""
|
|
73
|
+
tool_name = all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
74
|
+
if tool_name not in ALL_SPAWN_TOOL_NAMES:
|
|
75
|
+
return None
|
|
76
|
+
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
77
|
+
if not isinstance(tool_input, dict):
|
|
78
|
+
return None
|
|
79
|
+
model_identifier = tool_input.get(MODEL_FIELD_NAME)
|
|
80
|
+
if not isinstance(model_identifier, str):
|
|
81
|
+
return None
|
|
82
|
+
if not _model_names_the_luna_tier(model_identifier):
|
|
83
|
+
return None
|
|
84
|
+
service_tier = tool_input.get(SERVICE_TIER_FIELD_NAME)
|
|
85
|
+
if _is_service_tier_allowed(tool_name, service_tier):
|
|
86
|
+
return None
|
|
87
|
+
return model_identifier, service_tier
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_denial_preview(model_identifier: str, service_tier: object) -> str:
|
|
91
|
+
"""Build a bounded preview for the block log without recording the prompt.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
model_identifier: The Luna model string.
|
|
95
|
+
service_tier: The supplied service tier value.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A bounded preview naming only the model and service tier fields.
|
|
99
|
+
"""
|
|
100
|
+
model_text = model_identifier[:MAXIMUM_PREVIEW_FIELD_LENGTH]
|
|
101
|
+
service_tier_text = str(service_tier)[:MAXIMUM_PREVIEW_FIELD_LENGTH]
|
|
102
|
+
return DENY_PREVIEW_TEMPLATE.format(
|
|
103
|
+
model_text=model_text,
|
|
104
|
+
service_tier_text=service_tier_text,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _emit_denial(
|
|
109
|
+
decision_stream: TextIO,
|
|
110
|
+
all_payload_by_field: Mapping[str, object],
|
|
111
|
+
model_identifier: str,
|
|
112
|
+
service_tier: object,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Log the denied spawn and write the PreToolUse deny payload.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
decision_stream: Writable stream for the JSON decision.
|
|
118
|
+
all_payload_by_field: The parsed PreToolUse payload.
|
|
119
|
+
model_identifier: The denied Luna model string.
|
|
120
|
+
service_tier: The invalid service tier value.
|
|
121
|
+
"""
|
|
122
|
+
denial = {
|
|
123
|
+
"hookSpecificOutput": {
|
|
124
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
125
|
+
"permissionDecision": "deny",
|
|
126
|
+
"permissionDecisionReason": DENY_REASON,
|
|
127
|
+
"additionalContext": DENY_ADDITIONAL_CONTEXT,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
log_hook_block(
|
|
131
|
+
calling_hook_name=CALLING_HOOK_NAME,
|
|
132
|
+
hook_event=HOOK_EVENT_NAME,
|
|
133
|
+
block_reason=DENY_REASON,
|
|
134
|
+
tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
|
|
135
|
+
offending_input_preview=_build_denial_preview(model_identifier, service_tier),
|
|
136
|
+
)
|
|
137
|
+
decision_stream.write(json.dumps(denial) + "\n")
|
|
138
|
+
decision_stream.flush()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
"""Read the PreToolUse payload and deny an invalid Luna spawn."""
|
|
143
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
144
|
+
if hook_payload is None:
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
denied_details = _denied_spawn_details(hook_payload)
|
|
147
|
+
if denied_details is None:
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
denied_model, denied_service_tier = denied_details
|
|
150
|
+
_emit_denial(
|
|
151
|
+
sys.stdout,
|
|
152
|
+
hook_payload,
|
|
153
|
+
denied_model,
|
|
154
|
+
denied_service_tier,
|
|
155
|
+
)
|
|
156
|
+
sys.exit(0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
main()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Behavior tests for the Codex Luna fast-mode spawn gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
15
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
16
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
17
|
+
if each_path not in sys.path:
|
|
18
|
+
sys.path.insert(0, each_path)
|
|
19
|
+
|
|
20
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"luna_fast_mode_gate",
|
|
22
|
+
_HOOK_DIR / "luna_fast_mode_gate.py",
|
|
23
|
+
)
|
|
24
|
+
assert hook_spec is not None
|
|
25
|
+
assert hook_spec.loader is not None
|
|
26
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
27
|
+
hook_spec.loader.exec_module(hook_module)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.luna_fast_mode_gate_constants import ( # noqa: E402
|
|
30
|
+
AGENT_TOOL_NAME,
|
|
31
|
+
CALLING_HOOK_NAME,
|
|
32
|
+
CODEX_AGENT_TOOL_NAME,
|
|
33
|
+
FAST_SERVICE_TIER,
|
|
34
|
+
TASK_TOOL_NAME,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_MODEL_ID = "gpt-5.6-luna"
|
|
38
|
+
_NON_LUNA_MODEL_ID = "gpt-5.6-sol"
|
|
39
|
+
_UNRELATED_TOOL_NAME = "Write"
|
|
40
|
+
_SERVICE_TIER_FIELD_NAME = "service_tier"
|
|
41
|
+
_MODEL_FIELD_NAME = "model"
|
|
42
|
+
_DENY_DECISION = "deny"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _spawn_payload(
|
|
46
|
+
*,
|
|
47
|
+
tool_name: str = AGENT_TOOL_NAME,
|
|
48
|
+
model: object = _MODEL_ID,
|
|
49
|
+
service_tier: object = None,
|
|
50
|
+
include_service_tier: bool = True,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Build one PreToolUse payload for a spawn surface."""
|
|
53
|
+
tool_input: dict[str, object] = {
|
|
54
|
+
_MODEL_FIELD_NAME: model,
|
|
55
|
+
}
|
|
56
|
+
if include_service_tier:
|
|
57
|
+
tool_input[_SERVICE_TIER_FIELD_NAME] = service_tier
|
|
58
|
+
return {"tool_name": tool_name, "tool_input": tool_input}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_main(payload: object) -> str:
|
|
62
|
+
"""Run the production hook and return its stdout."""
|
|
63
|
+
with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
|
|
64
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
|
|
65
|
+
try:
|
|
66
|
+
hook_module.main()
|
|
67
|
+
except SystemExit:
|
|
68
|
+
pass
|
|
69
|
+
return captured_stdout.getvalue()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decision(payload: object) -> str:
|
|
73
|
+
"""Return the hook decision for a payload."""
|
|
74
|
+
return json.loads(_run_main(payload))["hookSpecificOutput"]["permissionDecision"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_blocks_luna_without_fast_for_headless_and_in_session_surfaces() -> None:
|
|
78
|
+
all_tool_names = (AGENT_TOOL_NAME, TASK_TOOL_NAME, CODEX_AGENT_TOOL_NAME)
|
|
79
|
+
for each_tool_name in all_tool_names:
|
|
80
|
+
payload = _spawn_payload(tool_name=each_tool_name, service_tier="flex")
|
|
81
|
+
assert _decision(payload) == _DENY_DECISION
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_allows_luna_with_exact_fast_service_tier() -> None:
|
|
85
|
+
for each_tool_name in (AGENT_TOOL_NAME, TASK_TOOL_NAME, CODEX_AGENT_TOOL_NAME):
|
|
86
|
+
payload = _spawn_payload(
|
|
87
|
+
tool_name=each_tool_name,
|
|
88
|
+
service_tier=FAST_SERVICE_TIER,
|
|
89
|
+
)
|
|
90
|
+
assert _run_main(payload) == ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_allows_luna_with_native_priority_service_tier() -> None:
|
|
94
|
+
payload = _spawn_payload(
|
|
95
|
+
tool_name=CODEX_AGENT_TOOL_NAME,
|
|
96
|
+
service_tier="priority",
|
|
97
|
+
)
|
|
98
|
+
assert _run_main(payload) == ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_blocks_luna_with_priority_for_agent_and_task_surfaces() -> None:
|
|
102
|
+
for each_tool_name in (AGENT_TOOL_NAME, TASK_TOOL_NAME):
|
|
103
|
+
payload = _spawn_payload(
|
|
104
|
+
tool_name=each_tool_name,
|
|
105
|
+
service_tier="priority",
|
|
106
|
+
)
|
|
107
|
+
assert _decision(payload) == _DENY_DECISION
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_blocks_missing_and_non_string_service_tier() -> None:
|
|
111
|
+
assert _decision(_spawn_payload(include_service_tier=False)) == _DENY_DECISION
|
|
112
|
+
assert _decision(_spawn_payload(service_tier=None)) == _DENY_DECISION
|
|
113
|
+
assert _decision(_spawn_payload(service_tier=True)) == _DENY_DECISION
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_blocks_default_and_wrong_case_fast_service_tiers() -> None:
|
|
117
|
+
assert _decision(_spawn_payload(service_tier="default")) == _DENY_DECISION
|
|
118
|
+
assert _decision(_spawn_payload(service_tier="FAST")) == _DENY_DECISION
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_allows_non_luna_and_unrelated_tool_payloads() -> None:
|
|
122
|
+
assert _run_main(_spawn_payload(model=_NON_LUNA_MODEL_ID)) == ""
|
|
123
|
+
assert _run_main(
|
|
124
|
+
_spawn_payload(tool_name=_UNRELATED_TOOL_NAME, service_tier="flex")
|
|
125
|
+
) == ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_recognizes_case_insensitive_luna_model_segments() -> None:
|
|
129
|
+
assert _decision(_spawn_payload(model="GPT-5.6-LUNA", service_tier="flex")) == (
|
|
130
|
+
_DENY_DECISION
|
|
131
|
+
)
|
|
132
|
+
assert _run_main(_spawn_payload(model="gpt-5.6-lunacy", service_tier="flex")) == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_malformed_outer_payload_allows() -> None:
|
|
136
|
+
assert _run_main({"tool_name": AGENT_TOOL_NAME}) == ""
|
|
137
|
+
assert _run_main({"tool_name": AGENT_TOOL_NAME, "tool_input": []}) == ""
|
|
138
|
+
assert _run_main("not an object") == ""
|
|
139
|
+
assert _run_main({"tool_name": AGENT_TOOL_NAME, "tool_input": {_MODEL_FIELD_NAME: 7}}) == ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_deny_payload_and_log_do_not_include_a_prompt() -> None:
|
|
143
|
+
payload = _spawn_payload(service_tier="default")
|
|
144
|
+
with mock.patch.object(hook_module, "log_hook_block") as recorded_block:
|
|
145
|
+
output = _run_main(payload)
|
|
146
|
+
parsed_output = json.loads(output)
|
|
147
|
+
assert parsed_output["hookSpecificOutput"]["permissionDecision"] == _DENY_DECISION
|
|
148
|
+
assert recorded_block.call_args.kwargs["calling_hook_name"] == CALLING_HOOK_NAME
|
|
149
|
+
assert "prompt" not in recorded_block.call_args.kwargs["offending_input_preview"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_hooks_json_registers_all_spawn_surfaces() -> None:
|
|
153
|
+
hooks_configuration = json.loads(
|
|
154
|
+
(_HOOKS_TREE / "hooks.json").read_text(encoding="utf-8")
|
|
155
|
+
)
|
|
156
|
+
matching_groups = [
|
|
157
|
+
each_group
|
|
158
|
+
for each_group in hooks_configuration["hooks"]["PreToolUse"]
|
|
159
|
+
if CALLING_HOOK_NAME
|
|
160
|
+
in " ".join(each_hook["command"] for each_hook in each_group["hooks"])
|
|
161
|
+
]
|
|
162
|
+
assert len(matching_groups) == 1
|
|
163
|
+
assert matching_groups[0]["matcher"] == (
|
|
164
|
+
f"{AGENT_TOOL_NAME}|{TASK_TOOL_NAME}|{CODEX_AGENT_TOOL_NAME}"
|
|
165
|
+
)
|
|
166
|
+
registered_command = matching_groups[0]["hooks"][0]["command"]
|
|
167
|
+
assert registered_command.endswith(
|
|
168
|
+
f"/hooks/blocking/{CALLING_HOOK_NAME}"
|
|
169
|
+
)
|
|
170
|
+
assert (_HOOKS_TREE / "blocking" / CALLING_HOOK_NAME).is_file()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_registered_command_runs_under_windows_cmd_for_allow_and_deny() -> None:
|
|
174
|
+
hooks_configuration = json.loads(
|
|
175
|
+
(_HOOKS_TREE / "hooks.json").read_text(encoding="utf-8")
|
|
176
|
+
)
|
|
177
|
+
matching_group = next(
|
|
178
|
+
each_group
|
|
179
|
+
for each_group in hooks_configuration["hooks"]["PreToolUse"]
|
|
180
|
+
if CALLING_HOOK_NAME
|
|
181
|
+
in " ".join(each_hook["command"] for each_hook in each_group["hooks"])
|
|
182
|
+
)
|
|
183
|
+
registered_command = matching_group["hooks"][0]["command"]
|
|
184
|
+
assert registered_command.startswith("python ")
|
|
185
|
+
command_line = registered_command.replace(
|
|
186
|
+
"${CLAUDE_PLUGIN_ROOT}",
|
|
187
|
+
str(_HOOKS_TREE.parent).replace("\\", "/"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for each_service_tier, expected_output in (
|
|
191
|
+
(FAST_SERVICE_TIER, ""),
|
|
192
|
+
("flex", _DENY_DECISION),
|
|
193
|
+
):
|
|
194
|
+
completed_process = subprocess.run(
|
|
195
|
+
command_line,
|
|
196
|
+
input=json.dumps(_spawn_payload(service_tier=each_service_tier)),
|
|
197
|
+
capture_output=True,
|
|
198
|
+
shell=True,
|
|
199
|
+
text=True,
|
|
200
|
+
check=False,
|
|
201
|
+
)
|
|
202
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
203
|
+
if expected_output == "":
|
|
204
|
+
assert completed_process.stdout == ""
|
|
205
|
+
else:
|
|
206
|
+
assert (
|
|
207
|
+
json.loads(completed_process.stdout)["hookSpecificOutput"][
|
|
208
|
+
"permissionDecision"
|
|
209
|
+
]
|
|
210
|
+
== expected_output
|
|
211
|
+
)
|
package/hooks/hooks.json
CHANGED
|
@@ -77,6 +77,16 @@
|
|
|
77
77
|
}
|
|
78
78
|
]
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
"matcher": "Agent|Task|multi_agent_v1__spawn_agent",
|
|
82
|
+
"hooks": [
|
|
83
|
+
{
|
|
84
|
+
"type": "command",
|
|
85
|
+
"command": "python ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/luna_fast_mode_gate.py",
|
|
86
|
+
"timeout": 10
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
},
|
|
80
90
|
{
|
|
81
91
|
"matcher": "ScheduleWakeup|CronCreate",
|
|
82
92
|
"hooks": [
|
|
@@ -149,11 +159,6 @@
|
|
|
149
159
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
|
|
150
160
|
"timeout": 10
|
|
151
161
|
},
|
|
152
|
-
{
|
|
153
|
-
"type": "command",
|
|
154
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
|
|
155
|
-
"timeout": 10
|
|
156
|
-
},
|
|
157
162
|
{
|
|
158
163
|
"type": "command",
|
|
159
164
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
|
|
@@ -45,7 +45,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
45
45
|
| `local_identity.py` | Loader for local machine values: NAS host, ssh user, and ssh port the ssh enforcer guards (`CLAUDE_NAS_*` / `~/.claude/local-identity.json` with placeholder defaults), the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`), and the per-repository allowlisted-values mapping (`pii_allowlisted_values`, keyed by owner/repo slug) read from the git-ignored local file whose path `CLAUDE_LOCAL_IDENTITY_PATH` may point elsewhere; also composes the ssh enforcer's two deny messages that quote the NAS values |
|
|
46
46
|
| `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
|
|
47
47
|
| `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
|
|
48
|
-
| `mypy_integration_constants.py` | Path markers (``.git``, ``.py``, ``pyproject.toml``) for mypy project-root resolution |
|
|
48
|
+
| `mypy_integration_constants.py` | Path markers (``.git``, ``.py``, ``pyproject.toml``) for mypy project-root resolution, plus detached-file `--follow-imports=skip` flags, subprocess timeout, and timeout skip message |
|
|
49
49
|
| `mypy_validator_cache_constants.py` | Cache paths and tunables for the mypy_validator per-session caches |
|
|
50
50
|
| `nas_ssh_binary_enforcer_constants.py` | Bash tool name, ssh-family basenames, OpenSSH binary path suffixes, and the batch-mode pattern for the NAS ssh binary enforcer; segment helpers come from `shell_command_segments.py` |
|
|
51
51
|
| `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Constants for the Codex Luna fast-mode spawn gate.
|
|
2
|
+
|
|
3
|
+
The gate checks the model and service tier fields on Agent, Task, and native
|
|
4
|
+
Codex multi-agent spawn calls. Agent and Task require exact ``fast``. Native
|
|
5
|
+
Codex accepts exact ``fast`` or ``priority`` for Luna models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
AGENT_TOOL_NAME: str = "Agent"
|
|
13
|
+
TASK_TOOL_NAME: str = "Task"
|
|
14
|
+
CODEX_AGENT_TOOL_NAME: str = "multi_agent_v1__spawn_agent"
|
|
15
|
+
ALL_SPAWN_TOOL_NAMES: frozenset[str] = frozenset(
|
|
16
|
+
{AGENT_TOOL_NAME, TASK_TOOL_NAME, CODEX_AGENT_TOOL_NAME}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
LUNA_MODEL_ALIAS: str = "luna"
|
|
20
|
+
FAST_SERVICE_TIER: str = "fast"
|
|
21
|
+
PRIORITY_SERVICE_TIER: str = "priority"
|
|
22
|
+
MODEL_SEGMENT_SPLIT_PATTERN: re.Pattern[str] = re.compile(r"[^a-z0-9]+")
|
|
23
|
+
|
|
24
|
+
TOOL_NAME_FIELD_NAME: str = "tool_name"
|
|
25
|
+
TOOL_INPUT_FIELD_NAME: str = "tool_input"
|
|
26
|
+
MODEL_FIELD_NAME: str = "model"
|
|
27
|
+
SERVICE_TIER_FIELD_NAME: str = "service_tier"
|
|
28
|
+
|
|
29
|
+
CALLING_HOOK_NAME: str = "luna_fast_mode_gate.py"
|
|
30
|
+
HOOK_EVENT_NAME: str = "PreToolUse"
|
|
31
|
+
|
|
32
|
+
DENY_REASON: str = (
|
|
33
|
+
"BLOCKED [luna-fast-mode-gate]: Agent and Task Luna spawns require "
|
|
34
|
+
"service_tier fast. Native Codex Luna spawns require service_tier fast "
|
|
35
|
+
"or priority. Retry with an allowed service_tier."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DENY_ADDITIONAL_CONTEXT: str = (
|
|
39
|
+
"[luna-fast-mode-gate] Agent and Task Luna spawns require service_tier "
|
|
40
|
+
"fast. Native Codex Luna spawns accept service_tier fast or priority. "
|
|
41
|
+
"Retry with an allowed service_tier."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
DENY_PREVIEW_TEMPLATE: str = "model={model_text} service_tier={service_tier_text}"
|
|
45
|
+
MAXIMUM_PREVIEW_FIELD_LENGTH: int = 40
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
"""Path markers
|
|
1
|
+
"""Path markers and detached-file mypy CLI flags.
|
|
2
2
|
|
|
3
3
|
The integration walks ancestors for a ``.git`` entry or a ``pyproject.toml``,
|
|
4
|
-
and filters the check list to ``.py`` files.
|
|
5
|
-
|
|
4
|
+
and filters the check list to ``.py`` files. A file with no project root is a
|
|
5
|
+
detached gate staging copy: mypy then skips followed imports and must finish
|
|
6
|
+
inside a short subprocess timeout so the 30-second PreToolUse hook can return.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"GIT_DIRECTORY_NAME",
|
|
10
11
|
"PYTHON_SOURCE_SUFFIX",
|
|
11
12
|
"PYPROJECT_FILENAME",
|
|
13
|
+
"FOLLOW_IMPORTS_FLAG",
|
|
14
|
+
"FOLLOW_IMPORTS_SKIP_VALUE",
|
|
15
|
+
"MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS",
|
|
16
|
+
"MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE",
|
|
12
17
|
]
|
|
13
18
|
|
|
14
19
|
GIT_DIRECTORY_NAME: str = ".git"
|
|
15
20
|
PYTHON_SOURCE_SUFFIX: str = ".py"
|
|
16
21
|
PYPROJECT_FILENAME: str = "pyproject.toml"
|
|
22
|
+
FOLLOW_IMPORTS_FLAG: str = "--follow-imports"
|
|
23
|
+
FOLLOW_IMPORTS_SKIP_VALUE: str = "skip"
|
|
24
|
+
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS: int = 8
|
|
25
|
+
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE: str = (
|
|
26
|
+
"mypy timed out on a detached file; skipping"
|
|
27
|
+
)
|
package/hooks/session/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hooks/session
|
|
2
2
|
|
|
3
|
-
SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup,
|
|
3
|
+
SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown. Also holds the UserPromptSubmit hook. It adds a style reminder to each message.
|
|
4
4
|
|
|
5
5
|
The working-style projection uses `~/.claude/rules/asd-ste100-language.md` for
|
|
6
6
|
user-facing word choice, sentence style, tone, punctuation, and prose form.
|
|
@@ -13,7 +13,7 @@ user-facing word choice, sentence style, tone, punctuation, and prose form.
|
|
|
13
13
|
| `gh_pr_author_session_cleanup.py` | SessionEnd | Clears any PR-author swap state left over from the current session |
|
|
14
14
|
| `session_edit_tracker_cleanup.py` | SessionStart, SessionEnd | Deletes the tracker file for the running Claude Code conversation from the system temp directory — at start for a clean slate and at end for a clean exit. A tracker is read only by the conversation that wrote it, so a live idle tracker is kept while a peer cleans up |
|
|
15
15
|
| `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
|
|
16
|
-
| `untracked_repo_detector.py` |
|
|
16
|
+
| `untracked_repo_detector.py` | — | Retired legacy script; no longer registered |
|
|
17
17
|
| `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
|
|
18
18
|
| `orchestrator_auto_starter.py` | SessionStart | Opt-in (`CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED`) consumer of the shared SessionStart injector; emits orchestrator skill context when enabled. Manual `/orchestrator` unchanged. |
|
|
19
19
|
| `issue_tracker_session_starter.py` | SessionStart | Opt-in (`CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED`) and repository-gated (git root in `~/.claude/project-paths.json`) issue-tracker skill context. |
|
|
@@ -13,6 +13,7 @@ A library of check modules used by the validation hooks. Each module focuses on
|
|
|
13
13
|
| `run_all_validators.py` | Entry point — runs every check module and aggregates results |
|
|
14
14
|
| `health_check.py` | Verifies that all validator dependencies (ruff, mypy) are reachable |
|
|
15
15
|
| `pyproject_config_discovery.py` | Shared walk-up primitive that resolves a tool's pyproject.toml config from an original target path, matching the `[tool.<name>]` table the tool owns |
|
|
16
|
+
| `system_temporary_roots.py` | Shared membership for OS temp roots (`gettempdir` plus `TEMP` / `TMP` / `TMPDIR` / `RUNNER_TEMP`); mypy walk stop and PreToolUse staging both call it |
|
|
16
17
|
| `python_style_helpers.py` | Shared source-line splitting and function-discovery helpers imported by `python_style_checks.py` |
|
|
17
18
|
|
|
18
19
|
## Check modules
|
|
@@ -25,7 +26,7 @@ A library of check modules used by the validation hooks. Each module focuses on
|
|
|
25
26
|
| `file_structure_checks.py` | File-level structural rules (line count, module layout) |
|
|
26
27
|
| `git_checks.py` | Git-state checks (untracked files, merge conflicts) |
|
|
27
28
|
| `magic_value_checks.py` | Magic numbers and strings |
|
|
28
|
-
| `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects |
|
|
29
|
+
| `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects; stops project-root walks at the system temp directory; detached gate files skip followed imports and time out as a skip (passed) |
|
|
29
30
|
| `pr_reference_checks.py` | PR references in commit messages and changelogs |
|
|
30
31
|
| `python_antipattern_checks.py` | Python-specific anti-patterns (bare `except`, `Any`, etc.) |
|
|
31
32
|
| `python_style_checks.py` | Python style rules (naming, imports, type hints) |
|
|
@@ -6,9 +6,26 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
VALIDATORS_DIRECTORY = Path(__file__).resolve().parent
|
|
8
8
|
HOOKS_DIRECTORY = VALIDATORS_DIRECTORY.parent
|
|
9
|
+
VALIDATORS_DIRECTORY_STRING = str(VALIDATORS_DIRECTORY)
|
|
10
|
+
HOOKS_DIRECTORY_STRING = str(HOOKS_DIRECTORY)
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
sys.path
|
|
12
|
+
for each_directory_string in (VALIDATORS_DIRECTORY_STRING, HOOKS_DIRECTORY_STRING):
|
|
13
|
+
if each_directory_string in sys.path:
|
|
14
|
+
sys.path.remove(each_directory_string)
|
|
15
|
+
sys.path.insert(0, each_directory_string)
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
for each_module_name in list(sys.modules):
|
|
18
|
+
if each_module_name != "hooks_constants" and not each_module_name.startswith(
|
|
19
|
+
"hooks_constants."
|
|
20
|
+
):
|
|
21
|
+
continue
|
|
22
|
+
loaded_file = getattr(sys.modules[each_module_name], "__file__", None)
|
|
23
|
+
if loaded_file is None:
|
|
24
|
+
sys.modules.pop(each_module_name, None)
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
is_from_this_tree = Path(loaded_file).resolve().is_relative_to(HOOKS_DIRECTORY)
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
is_from_this_tree = False
|
|
30
|
+
if not is_from_this_tree:
|
|
31
|
+
sys.modules.pop(each_module_name, None)
|