claude-dev-env 2.25.0 → 2.27.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 (45) hide show
  1. package/.agents/agents/clean-coder.md +95 -33
  2. package/.agents/agents/code-quality-agent.md +85 -24
  3. package/.agents/agents/pr-description-writer.md +2 -2
  4. package/.agents/agents/test_agent_frontmatter.py +626 -0
  5. package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
  6. package/.agents/skills/pr-cleanup/SKILL.md +1 -0
  7. package/.agents/skills/pr-plain-language-cleanup/SKILL.md +93 -0
  8. package/.agents/skills/pr-title-description/SKILL.md +92 -0
  9. package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
  10. package/bin/ever-shipped-skills.mjs +2 -0
  11. package/bin/install.agents-home.test.mjs +199 -8
  12. package/bin/install.mjs +15 -8
  13. package/bin/install.test.mjs +82 -2
  14. package/commands/sr-loop.md +10 -1
  15. package/docs/codex-compatibility.md +19 -0
  16. package/hooks/blocking/luna_fast_mode_gate.py +160 -0
  17. package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
  18. package/hooks/hooks.json +10 -5
  19. package/hooks/hooks_constants/AGENTS.md +1 -1
  20. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
  21. package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
  22. package/hooks/hooks_constants/session_start_injector.py +11 -4
  23. package/hooks/hooks_constants/test_session_start_injector.py +4 -2
  24. package/hooks/session/AGENTS.md +2 -2
  25. package/hooks/session/issue_tracker_session_starter.py +3 -2
  26. package/hooks/session/orchestrator_auto_starter.py +3 -2
  27. package/hooks/session/task_list_loop_starter.py +10 -1
  28. package/hooks/session/test_issue_tracker_session_starter.py +3 -1
  29. package/hooks/session/test_orchestrator_auto_starter.py +5 -3
  30. package/hooks/session/test_task_list_loop_starter.py +10 -8
  31. package/hooks/session/test_untracked_repo_detector.py +7 -5
  32. package/hooks/session/test_working_style_prompt.py +5 -3
  33. package/hooks/session/untracked_repo_detector.py +10 -1
  34. package/hooks/session/working_style_prompt.py +10 -1
  35. package/hooks/validators/AGENTS.md +2 -1
  36. package/hooks/validators/conftest.py +21 -4
  37. package/hooks/validators/mypy_integration.py +77 -16
  38. package/hooks/validators/run_all_validators.py +2 -35
  39. package/hooks/validators/system_temporary_roots.py +90 -0
  40. package/hooks/validators/test_directory_exemption_constants.py +1 -1
  41. package/hooks/validators/test_mypy_integration.py +169 -0
  42. package/hooks/validators/test_system_temporary_roots.py +93 -0
  43. package/package.json +1 -1
  44. package/scripts/codex_compat_materializer.py +4 -2
  45. package/scripts/tests/test_codex_compat_materializer.py +2 -2
@@ -24,6 +24,7 @@ import {
24
24
  INSTALL_GROUPS,
25
25
  FOLDED_HOOK_RELATIVE_PATHS,
26
26
  POST_FOLDED_HOOK_RELATIVE_PATHS,
27
+ RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
27
28
  pythonCandidatesForPlatform,
28
29
  isWindowsStorePythonStub,
29
30
  interpreterCommandFromPath,
@@ -718,6 +719,26 @@ test('mergeHooksIntoSettings matches full standalone arguments at boundaries', (
718
719
  });
719
720
 
720
721
 
722
+ test('mergeHooksIntoSettings substitutes an absolute interpreter path for the python prefix', () => {
723
+ const hooksConfig = {
724
+ hooks: {
725
+ PreToolUse: [
726
+ {
727
+ matcher: 'Agent',
728
+ hooks: [{ type: 'command', command: 'python ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/luna_fast_mode_gate.py' }],
729
+ },
730
+ ],
731
+ },
732
+ };
733
+ const settings = {};
734
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
735
+ assert.equal(
736
+ settings.hooks.PreToolUse[0].hooks[0].command,
737
+ 'C:/Python313/python.exe C:/Users/x/.claude/hooks/blocking/luna_fast_mode_gate.py',
738
+ );
739
+ });
740
+
741
+
721
742
  test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
722
743
  const hooksConfig = {
723
744
  hooks: {
@@ -1193,12 +1214,27 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
1193
1214
  };
1194
1215
 
1195
1216
 
1196
- test('FOLDED_HOOK_RELATIVE_PATHS contains all 14 hooks removed from hooks.json plus the retired md_to_html_blocker', () => {
1197
- assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 15);
1217
+ test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', () => {
1218
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 16);
1198
1219
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
1199
1220
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
1200
1221
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
1201
1222
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/md_to_html_blocker.py'));
1223
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('session/untracked_repo_detector.py'));
1224
+ });
1225
+
1226
+
1227
+ test('retired hook registrations stay managed so reinstall removes them', () => {
1228
+ const retiredPath = 'session/untracked_repo_detector.py';
1229
+ const shippedHooks = JSON.parse(
1230
+ readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
1231
+ );
1232
+ const shippedCommands = Object.values(shippedHooks.hooks).flatMap(matcherGroups => (
1233
+ matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
1234
+ ));
1235
+ assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, new Set([retiredPath]));
1236
+ assert.ok(managedHookScriptRelativePaths({ hooks: {} }).has(retiredPath));
1237
+ assert.ok(shippedCommands.every(command => !command.includes(retiredPath)));
1202
1238
  });
1203
1239
 
1204
1240
 
@@ -1221,6 +1257,7 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
1221
1257
  ];
1222
1258
  const retiredHooks = [
1223
1259
  'blocking/md_to_html_blocker.py',
1260
+ 'session/untracked_repo_detector.py',
1224
1261
  ];
1225
1262
  for (const hostedPath of dispatcherHostedHooks) {
1226
1263
  assert.ok(
@@ -1242,6 +1279,41 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
1242
1279
  });
1243
1280
 
1244
1281
 
1282
+ test('mergeHooksIntoSettings removes the retired SessionStart detector and keeps user hooks', () => {
1283
+ const retiredCommand = 'py -3 C:/Users/x/.claude/hooks/session/untracked_repo_detector.py';
1284
+ const userCommand = 'py -3 C:/Users/x/custom/session_gate.py';
1285
+ const settings = {
1286
+ hooks: {
1287
+ SessionStart: [{
1288
+ matcher: '',
1289
+ hooks: [
1290
+ { type: 'command', command: retiredCommand },
1291
+ { type: 'command', command: userCommand },
1292
+ ],
1293
+ }],
1294
+ },
1295
+ };
1296
+ const currentHooksConfig = {
1297
+ hooks: {
1298
+ SessionStart: [{
1299
+ matcher: '',
1300
+ hooks: [{
1301
+ type: 'command',
1302
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_data_dir_cleanup.py',
1303
+ }],
1304
+ }],
1305
+ },
1306
+ };
1307
+
1308
+ mergeHooksIntoSettings(settings, currentHooksConfig, 'C:/Users/x/.claude', 'py -3');
1309
+
1310
+ const commands = settings.hooks.SessionStart[0].hooks.map(hook => hook.command);
1311
+ assert.equal(commands.includes(retiredCommand), false);
1312
+ assert.equal(commands.includes(userCommand), true);
1313
+ assert.equal(commands.some(command => command.includes('plugin_data_dir_cleanup.py')), true);
1314
+ });
1315
+
1316
+
1245
1317
  test('managedHookScriptRelativePaths includes the dispatcher and all folded hooks so old entries are prunable', () => {
1246
1318
  const relativePaths = managedHookScriptRelativePaths(DISPATCHER_HOOKS_CONFIG);
1247
1319
  assert.ok(relativePaths.has('blocking/pre_tool_use_dispatcher.py'), 'dispatcher must be in managed set');
@@ -1319,6 +1391,14 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1319
1391
  .filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
1320
1392
  assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
1321
1393
 
1394
+ const allSessionStartCommands = (shippedHooksConfig.hooks.SessionStart || [])
1395
+ .flatMap(group => group.hooks.map(hook => hook.command));
1396
+ assert.equal(
1397
+ allSessionStartCommands.some(command => command.includes('untracked_repo_detector.py')),
1398
+ false,
1399
+ 'shipped hooks.json must not register the retired untracked-repo detector',
1400
+ );
1401
+
1322
1402
  const writePathCommands = allPreToolUseGroups
1323
1403
  .filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
1324
1404
  .flatMap(group => group.hooks.map(hook => hook.command));
@@ -10,6 +10,14 @@ tool and repeats it until a pass returns zero new findings.
10
10
  Use `rules/asd-ste100-language.md` for user-facing wording. Keep the loop's
11
11
  cleanup, verification, commit, push, and finish-report fields.
12
12
 
13
+ ### Nit-only terminal
14
+
15
+ When a pass finds only nits, apply them, run scoped checks, commit, and push.
16
+ Treat that phase as clean. Do not run another simplify or review pass without
17
+ the user's request. If later work creates an uncommitted out-of-scope diff,
18
+ stop and report it. Do not inspect, repair, commit, or adopt that diff without
19
+ new user instruction.
20
+
13
21
  ## Effort
14
22
 
15
23
  Set every model call to `max` effort. If that model has no `max` effort, use
@@ -32,7 +40,8 @@ stack's design, intent, and changes.
32
40
  written with the Write tool, Co-Authored-By line, 10-minute timeout — the
33
41
  pre-commit gate runs its own tests), and push to the PR head branch. The PR
34
42
  stays draft.
35
- 3. Repeat the invocation. **Skips are sticky:** carry every adjudicated skip
43
+ 3. Repeat the invocation only after substantive findings or a user request.
44
+ **Skips are sticky:** carry every adjudicated skip
36
45
  forward into the next pass as "already adjudicated — do not re-report: ..."
37
46
  context, or the loop never converges.
38
47
  4. Phase A converges when a full pass returns zero new findings, or when a
@@ -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 used when mypy resolves a target file's project root.
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. These names live here so the
5
- integration module carries no inline path or extension literals.
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
+ )