claude-dev-env 8.97.0 → 8.98.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/bin/install.prompt-context.test.mjs +3 -2
- package/bin/install.settings-defaults.test.mjs +4 -3
- package/docs/agents-guidance/asks.md +6 -1
- package/hooks/blocking/open_tasks_stop_gate.py +128 -34
- package/hooks/blocking/open_tasks_transcript.py +112 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
- package/hooks/blocking/python_windows_invocation_gate.py +249 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
- package/hooks/blocking/test_open_tasks_transcript.py +89 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
- package/hooks/blocking/verify_before_commit_gate.py +278 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +10 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
- package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
- package/hooks/open_task_asks.py +163 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_transcript_skill_scan.py +24 -0
- package/hooks/transcript_skill_scan.py +54 -3
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +2 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: stop two Python invocations that fail on Windows.
|
|
3
|
+
|
|
4
|
+
``python -I`` turns on isolated mode, which ignores every ``PYTHON*``
|
|
5
|
+
environment variable, ``PYTHONUTF8`` included. On Windows the interpreter then
|
|
6
|
+
writes with the console code page, and non-ASCII output stops with
|
|
7
|
+
``UnicodeEncodeError: 'charmap'``. On a Windows host a command segment is
|
|
8
|
+
denied when it runs ``python``, ``python3`` or ``py`` with ``-I`` and without
|
|
9
|
+
``-X utf8``. The option scan reads clusters, so ``-IX utf8``, ``-IXutf8`` and
|
|
10
|
+
``-I -X utf8`` all pass. It stops at the script name, ``-c`` or ``-m``, so an
|
|
11
|
+
``-I`` meant for the script passes. Linux runs ``-I`` in UTF-8 already, so the
|
|
12
|
+
check stays quiet there.
|
|
13
|
+
|
|
14
|
+
Under the PowerShell tool a command is also denied when a ``python -c "..."``
|
|
15
|
+
argument holds ``\\"``. PowerShell reads the backslash as a plain character and
|
|
16
|
+
ends the string at that quote. The deny reason names a script file or a
|
|
17
|
+
single-quoted here-string. Bash reads ``\\"`` as an escaped quote, so the Bash
|
|
18
|
+
tool passes it.
|
|
19
|
+
|
|
20
|
+
Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
|
|
21
|
+
PowerShell tools.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
34
|
+
if _hooks_root_directory not in sys.path:
|
|
35
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
36
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
37
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
38
|
+
DENY_DECISION,
|
|
39
|
+
HOOK_EVENT_NAME,
|
|
40
|
+
POWERSHELL_TOOL_NAME,
|
|
41
|
+
REASON_JOIN_SEPARATOR,
|
|
42
|
+
)
|
|
43
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
44
|
+
from hooks_constants.piped_pytest_blocker_constants import (
|
|
45
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN,
|
|
46
|
+
)
|
|
47
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
48
|
+
from hooks_constants.python_windows_invocation_gate_constants import (
|
|
49
|
+
ALL_COMMAND_ENDING_OPTION_LETTERS,
|
|
50
|
+
ALL_UTF8_MODE_OPTION_VALUES,
|
|
51
|
+
ALL_VALUE_LONG_OPTIONS,
|
|
52
|
+
ALL_VALUE_OPTION_LETTERS,
|
|
53
|
+
END_OF_OPTIONS_MARKER,
|
|
54
|
+
GATE_HOOK_NAME,
|
|
55
|
+
IMPLEMENTATION_OPTION_LETTER,
|
|
56
|
+
ISOLATED_MODE_OPTION_LETTER,
|
|
57
|
+
ISOLATED_PYTHON_DENY_REASON,
|
|
58
|
+
OPTION_PREFIX,
|
|
59
|
+
POWERSHELL_BACKSLASH_QUOTE_DENY_REASON,
|
|
60
|
+
PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN,
|
|
61
|
+
WINDOWS_OS_NAME,
|
|
62
|
+
)
|
|
63
|
+
from hooks_constants.shell_command_wrappers import (
|
|
64
|
+
all_wrapped_command_segments,
|
|
65
|
+
segment_program_and_arguments,
|
|
66
|
+
)
|
|
67
|
+
except ImportError as import_error:
|
|
68
|
+
raise ImportError(
|
|
69
|
+
"The Python Windows invocation gate cannot import its dependencies; "
|
|
70
|
+
"ensure the hooks directory is importable."
|
|
71
|
+
) from import_error
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_windows_host() -> bool:
|
|
75
|
+
"""Return True when the hook runs on Windows, where the shell commands run too."""
|
|
76
|
+
return os.name == WINDOWS_OS_NAME
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_short_option_cluster(argument: str) -> bool:
|
|
80
|
+
return (
|
|
81
|
+
argument.startswith(OPTION_PREFIX)
|
|
82
|
+
and not argument.startswith(END_OF_OPTIONS_MARKER)
|
|
83
|
+
and argument != OPTION_PREFIX
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_long_option(argument: str) -> bool:
|
|
88
|
+
return argument.startswith(END_OF_OPTIONS_MARKER) and argument != END_OF_OPTIONS_MARKER
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _skip_long_option_value(argument: str, remaining_arguments: Iterator[str]) -> None:
|
|
92
|
+
if argument in ALL_VALUE_LONG_OPTIONS:
|
|
93
|
+
next(remaining_arguments, "")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cluster_options(
|
|
97
|
+
cluster_letters: str, remaining_arguments: Iterator[str]
|
|
98
|
+
) -> list[tuple[str, str]]:
|
|
99
|
+
"""Return each option letter in one short-option cluster with its value.
|
|
100
|
+
|
|
101
|
+
A letter that takes a value uses the rest of the cluster, or the next
|
|
102
|
+
argument when the rest is empty, so ``-IXutf8`` and ``-IX utf8`` both read
|
|
103
|
+
as ``I`` and ``X`` with the value ``utf8``.
|
|
104
|
+
"""
|
|
105
|
+
all_options: list[tuple[str, str]] = []
|
|
106
|
+
for each_index, each_letter in enumerate(cluster_letters):
|
|
107
|
+
if each_letter not in ALL_VALUE_OPTION_LETTERS:
|
|
108
|
+
all_options.append((each_letter, ""))
|
|
109
|
+
continue
|
|
110
|
+
inline_value = cluster_letters[each_index + 1 :]
|
|
111
|
+
all_options.append((each_letter, inline_value or next(remaining_arguments, "")))
|
|
112
|
+
return all_options
|
|
113
|
+
return all_options
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _interpreter_options(all_arguments: list[str]) -> list[tuple[str, str]]:
|
|
117
|
+
"""Return the interpreter option letters and values ahead of the script, ``-c`` or ``-m``.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
all_arguments: The tokens after the interpreter program name.
|
|
121
|
+
"""
|
|
122
|
+
all_options: list[tuple[str, str]] = []
|
|
123
|
+
remaining_arguments = iter(all_arguments)
|
|
124
|
+
for each_argument in remaining_arguments:
|
|
125
|
+
if _is_long_option(each_argument):
|
|
126
|
+
_skip_long_option_value(each_argument, remaining_arguments)
|
|
127
|
+
continue
|
|
128
|
+
if not _is_short_option_cluster(each_argument):
|
|
129
|
+
break
|
|
130
|
+
all_options.extend(
|
|
131
|
+
_cluster_options(each_argument[len(OPTION_PREFIX) :], remaining_arguments)
|
|
132
|
+
)
|
|
133
|
+
if all_options[-1][0] in ALL_COMMAND_ENDING_OPTION_LETTERS:
|
|
134
|
+
break
|
|
135
|
+
return all_options
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _isolated_without_utf8_mode(all_arguments: list[str]) -> bool:
|
|
139
|
+
"""Return True when the interpreter options set ``-I`` and leave out ``-X utf8``.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
all_arguments: The tokens after the interpreter program name.
|
|
143
|
+
"""
|
|
144
|
+
all_options = _interpreter_options(all_arguments)
|
|
145
|
+
is_isolated = any(
|
|
146
|
+
each_letter == ISOLATED_MODE_OPTION_LETTER for each_letter, _ in all_options
|
|
147
|
+
)
|
|
148
|
+
has_utf8_mode = any(
|
|
149
|
+
each_letter == IMPLEMENTATION_OPTION_LETTER
|
|
150
|
+
and each_value in ALL_UTF8_MODE_OPTION_VALUES
|
|
151
|
+
for each_letter, each_value in all_options
|
|
152
|
+
)
|
|
153
|
+
return is_isolated and not has_utf8_mode
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def runs_isolated_python_without_utf8_mode(command: str) -> bool:
|
|
157
|
+
"""Return True when any command segment runs Python with ``-I`` and without ``-X utf8``.
|
|
158
|
+
|
|
159
|
+
::
|
|
160
|
+
|
|
161
|
+
python -I script.py -> True
|
|
162
|
+
py -3 -I -c pass -> True
|
|
163
|
+
python -IX utf8 script.py -> False
|
|
164
|
+
python -I -X utf8 script.py -> False
|
|
165
|
+
python script.py -I -> False
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
command: The shell command text the agent is about to run.
|
|
169
|
+
"""
|
|
170
|
+
for each_segment in all_wrapped_command_segments(command):
|
|
171
|
+
program_name, all_arguments = segment_program_and_arguments(each_segment)
|
|
172
|
+
if PYTHON_INTERPRETER_BASENAME_PATTERN.fullmatch(program_name.lower()) is None:
|
|
173
|
+
continue
|
|
174
|
+
if _isolated_without_utf8_mode(all_arguments):
|
|
175
|
+
return True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def has_backslash_quote_in_python_code_argument(command: str) -> bool:
|
|
180
|
+
"""Return True when a ``python -c "..."`` argument holds ``\\"`` in the raw command text.
|
|
181
|
+
|
|
182
|
+
::
|
|
183
|
+
|
|
184
|
+
python -c "print(\\"x\\")" -> True
|
|
185
|
+
python -c 'print("x")' -> False
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
command: The shell command text the agent is about to run.
|
|
189
|
+
"""
|
|
190
|
+
return PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN.search(command) is not None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def all_deny_reasons(command: str, tool_name: str) -> list[str]:
|
|
194
|
+
"""Return the deny reason for each Windows Python failure the command would hit.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
command: The shell command text the agent is about to run.
|
|
198
|
+
tool_name: The tool that runs the command, Bash or PowerShell.
|
|
199
|
+
"""
|
|
200
|
+
all_reasons: list[str] = []
|
|
201
|
+
if is_windows_host() and runs_isolated_python_without_utf8_mode(command):
|
|
202
|
+
all_reasons.append(ISOLATED_PYTHON_DENY_REASON)
|
|
203
|
+
if (
|
|
204
|
+
tool_name == POWERSHELL_TOOL_NAME
|
|
205
|
+
and has_backslash_quote_in_python_code_argument(command)
|
|
206
|
+
):
|
|
207
|
+
all_reasons.append(POWERSHELL_BACKSLASH_QUOTE_DENY_REASON)
|
|
208
|
+
return all_reasons
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_deny_decision(reason: str) -> None:
|
|
212
|
+
sys.stdout.write(
|
|
213
|
+
json.dumps(
|
|
214
|
+
{
|
|
215
|
+
"hookSpecificOutput": {
|
|
216
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
217
|
+
"permissionDecision": DENY_DECISION,
|
|
218
|
+
"permissionDecisionReason": reason,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
sys.stdout.flush()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> None:
|
|
227
|
+
"""Deny a Python invocation that fails on Windows, or stay quiet."""
|
|
228
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
229
|
+
if hook_payload is None:
|
|
230
|
+
return
|
|
231
|
+
tool_name = hook_payload.get("tool_name")
|
|
232
|
+
if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
|
|
233
|
+
return
|
|
234
|
+
tool_input = hook_payload.get("tool_input")
|
|
235
|
+
if not isinstance(tool_input, dict):
|
|
236
|
+
return
|
|
237
|
+
command = tool_input.get("command", "")
|
|
238
|
+
if not isinstance(command, str):
|
|
239
|
+
return
|
|
240
|
+
all_reasons = all_deny_reasons(command, str(tool_name))
|
|
241
|
+
if not all_reasons:
|
|
242
|
+
return
|
|
243
|
+
reason = REASON_JOIN_SEPARATOR.join(all_reasons)
|
|
244
|
+
log_hook_block(GATE_HOOK_NAME, HOOK_EVENT_NAME, reason, str(tool_name), command)
|
|
245
|
+
_write_deny_decision(reason)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
|
|
26
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
27
27
|
BashHostedHookEntry(
|
|
28
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -42,4 +42,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None
|
|
|
42
42
|
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
43
43
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
44
44
|
),
|
|
45
|
+
BashHostedHookEntry(
|
|
46
|
+
script_relative_path="blocking/python_windows_invocation_gate.py",
|
|
47
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
48
|
+
),
|
|
45
49
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import importlib
|
|
1
2
|
import io
|
|
2
3
|
import json
|
|
3
4
|
import subprocess
|
|
@@ -295,3 +296,204 @@ def test_should_allow_a_cursor_stop_after_five_followups(
|
|
|
295
296
|
transcript_path = write_cursor_transcript(tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Done.")])
|
|
296
297
|
assert "rotate the deploy key" in run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=4)["followup_message"]
|
|
297
298
|
assert run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=5) == {}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def ccd_plan_record(all_plan_input: dict[str, object]) -> dict[str, object]:
|
|
302
|
+
tool_use = {"type": "tool_use", "id": "p1", "name": "mcp__ccd_turn__update_plan", "input": all_plan_input}
|
|
303
|
+
return {"type": "assistant", "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def assistant_text_record(reply_text: str) -> dict[str, object]:
|
|
307
|
+
return {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": reply_text}]}}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def write_records(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
|
|
311
|
+
transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
|
|
312
|
+
return transcript_path
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def block_reason(
|
|
316
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
317
|
+
capsys: pytest.CaptureFixture[str],
|
|
318
|
+
reply_text: str,
|
|
319
|
+
transcript_path: Path | None = None,
|
|
320
|
+
) -> str:
|
|
321
|
+
_, stdout_text = run_gate(monkeypatch, capsys, reply_text, transcript_path)
|
|
322
|
+
return json.loads(stdout_text)["reason"] if stdout_text else ""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def record_ask(prompt_text: str) -> str | None:
|
|
326
|
+
return importlib.import_module("open_task_asks").record_prompt(SESSION_ID, prompt_text)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def submit_and_replace_list(tmp_path: Path) -> Path:
|
|
330
|
+
return write_records(
|
|
331
|
+
tmp_path / "s.jsonl",
|
|
332
|
+
[
|
|
333
|
+
ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
|
|
334
|
+
ccd_plan_record({"items": [{"content": "Set rows in Neon", "status": "completed"}], "scope": "continuation"}),
|
|
335
|
+
],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_should_hold_an_interrupted_ask_after_the_user_redirects(
|
|
340
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
341
|
+
) -> None:
|
|
342
|
+
assert record_ask("Export the OneUI8 and OneUI9 APKs and submit them") == "ask-1"
|
|
343
|
+
assert record_ask("Set the 8 rows to Ready to Export in Neon") == "ask-2"
|
|
344
|
+
transcript_path = write_records(
|
|
345
|
+
tmp_path / "s.jsonl",
|
|
346
|
+
[ccd_plan_record({"items": [{"content": "Set the 8 rows in Neon", "status": "completed"}], "scope": "continuation"})],
|
|
347
|
+
)
|
|
348
|
+
reason_text = block_reason(monkeypatch, capsys, "Rows set.\nDone: #ask-2", transcript_path)
|
|
349
|
+
assert "#ask-1" in reason_text
|
|
350
|
+
assert "OneUI9" in reason_text
|
|
351
|
+
assert "#ask-2" not in reason_text
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_should_hold_plan_items_a_continuation_list_dropped(
|
|
355
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
356
|
+
) -> None:
|
|
357
|
+
first_items = [
|
|
358
|
+
{"content": "Export the OneUI8 APK", "status": "in_progress"},
|
|
359
|
+
{"content": "Submit both APKs", "status": "pending"},
|
|
360
|
+
]
|
|
361
|
+
transcript_path = write_records(
|
|
362
|
+
tmp_path / "s.jsonl",
|
|
363
|
+
[
|
|
364
|
+
ccd_plan_record({"items": first_items, "scope": "first_task"}),
|
|
365
|
+
ccd_plan_record({"scope": "continuation", "items": [{"content": "Set rows in Neon", "status": "completed"}]}),
|
|
366
|
+
],
|
|
367
|
+
)
|
|
368
|
+
reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
|
|
369
|
+
assert "Export the OneUI8 APK" in reason_text
|
|
370
|
+
assert "Submit both APKs" in reason_text
|
|
371
|
+
assert "Set rows in Neon" not in reason_text
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_should_hold_an_ask_that_never_reached_a_plan(
|
|
375
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
376
|
+
) -> None:
|
|
377
|
+
record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
|
|
378
|
+
reason_text = block_reason(monkeypatch, capsys, "Done.")
|
|
379
|
+
assert "#ask-1: Export the OneUI8 and OneUI9 APKs and submit them" in reason_text
|
|
380
|
+
assert "Done: #ask-N" in reason_text
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_should_merge_the_task_store_with_the_ccd_plan(
|
|
384
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
385
|
+
) -> None:
|
|
386
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
|
|
387
|
+
transcript_path = write_records(
|
|
388
|
+
tmp_path / "s.jsonl",
|
|
389
|
+
[ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
|
|
390
|
+
)
|
|
391
|
+
reason_text = block_reason(monkeypatch, capsys, "Done.", transcript_path)
|
|
392
|
+
assert "Rotate the deploy key" in reason_text
|
|
393
|
+
assert "Port the hook" in reason_text
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_should_hold_an_ask_whose_subject_matches_a_completed_task(
|
|
397
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
398
|
+
) -> None:
|
|
399
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Fix login", "status": "completed"}])
|
|
400
|
+
assert record_ask("Fix login") == "ask-1"
|
|
401
|
+
reason_text = block_reason(monkeypatch, capsys, "Done.")
|
|
402
|
+
assert "#ask-1: Fix login" in reason_text
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_should_record_nothing_for_a_question_only_prompt(
|
|
406
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
407
|
+
) -> None:
|
|
408
|
+
assert record_ask("wait, did the export fail?") is None
|
|
409
|
+
assert record_ask("are you monitoring these? yes or no") is None
|
|
410
|
+
assert run_gate(monkeypatch, capsys, "No, it passed.") == (0, "")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_should_not_close_a_plan_item_on_a_done_line(
|
|
414
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
415
|
+
) -> None:
|
|
416
|
+
transcript_path = write_records(
|
|
417
|
+
tmp_path / "s.jsonl",
|
|
418
|
+
[ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
|
|
419
|
+
)
|
|
420
|
+
assert "Port the hook" in block_reason(monkeypatch, capsys, "Done: Port the hook", transcript_path)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_should_close_an_ask_and_a_carried_item_on_a_dropped_line_quoting_the_user(
|
|
424
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
425
|
+
) -> None:
|
|
426
|
+
record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
|
|
427
|
+
transcript_path = submit_and_replace_list(tmp_path)
|
|
428
|
+
record_ask("Skip the APK export for now, set the rows instead")
|
|
429
|
+
reply_text = (
|
|
430
|
+
"Rows set.\nDone: #ask-2\n"
|
|
431
|
+
'Dropped: #ask-1 - "skip the APK export for now"\n'
|
|
432
|
+
'Dropped: Submit both APKs - "Skip the APK export"'
|
|
433
|
+
)
|
|
434
|
+
assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
|
|
435
|
+
with transcript_path.open("a", encoding="utf-8") as transcript_handle:
|
|
436
|
+
transcript_handle.write("\n" + json.dumps(assistant_text_record(reply_text)))
|
|
437
|
+
assert run_gate(monkeypatch, capsys, "Nothing else.", transcript_path) == (0, "")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_should_hold_a_dropped_line_whose_quote_the_user_never_wrote(
|
|
441
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
442
|
+
) -> None:
|
|
443
|
+
record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
|
|
444
|
+
transcript_path = submit_and_replace_list(tmp_path)
|
|
445
|
+
record_ask("Set the rows next")
|
|
446
|
+
reply_text = 'Dropped: #ask-1 - "the user cancelled the export"\nDropped: Submit both APKs - "skip it, it is done"'
|
|
447
|
+
reason_text = block_reason(monkeypatch, capsys, reply_text, transcript_path)
|
|
448
|
+
assert "#ask-1" in reason_text
|
|
449
|
+
assert "Submit both APKs" in reason_text
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_should_hold_a_dropped_line_quoting_the_ask_itself(
|
|
453
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
454
|
+
) -> None:
|
|
455
|
+
record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
|
|
456
|
+
reason_text = block_reason(monkeypatch, capsys, 'Dropped: #ask-1 - "Export the OneUI8"')
|
|
457
|
+
assert "#ask-1" in reason_text
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_should_hold_an_ask_again_when_the_next_reply_omits_its_open_line(
|
|
461
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
462
|
+
) -> None:
|
|
463
|
+
record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
|
|
464
|
+
first_reply = "Open: #ask-1 - the export job runs next"
|
|
465
|
+
assert run_gate(monkeypatch, capsys, first_reply) == (0, "")
|
|
466
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [assistant_text_record(first_reply)])
|
|
467
|
+
assert "#ask-1" in block_reason(monkeypatch, capsys, "Still running.", transcript_path)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_should_allow_the_stop_once_a_carried_item_is_listed_completed(
|
|
471
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
472
|
+
) -> None:
|
|
473
|
+
transcript_path = submit_and_replace_list(tmp_path)
|
|
474
|
+
assert "Submit both APKs" in block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
|
|
475
|
+
with transcript_path.open("a", encoding="utf-8") as transcript_handle:
|
|
476
|
+
relisted = ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "completed"}], "scope": "continuation"})
|
|
477
|
+
transcript_handle.write("\n" + json.dumps(relisted))
|
|
478
|
+
assert run_gate(monkeypatch, capsys, "Both submitted.", transcript_path) == (0, "")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
|
|
482
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
483
|
+
) -> None:
|
|
484
|
+
replacing_items = [
|
|
485
|
+
{"content": "Read the schema", "status": "in_progress"},
|
|
486
|
+
{"content": "Set rows in Neon", "status": "pending"},
|
|
487
|
+
]
|
|
488
|
+
transcript_path = write_records(
|
|
489
|
+
tmp_path / "s.jsonl",
|
|
490
|
+
[
|
|
491
|
+
ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
|
|
492
|
+
ccd_plan_record({"items": replacing_items, "scope": "continuation"}),
|
|
493
|
+
ccd_plan_record({"done": [1, 2]}),
|
|
494
|
+
],
|
|
495
|
+
)
|
|
496
|
+
reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
|
|
497
|
+
assert "Submit both APKs" in reason_text
|
|
498
|
+
assert "Read the schema" not in reason_text
|
|
499
|
+
assert "Set rows in Neon" not in reason_text
|
|
@@ -52,3 +52,92 @@ def test_should_parse_only_json_objects() -> None:
|
|
|
52
52
|
assert parsed_object('{"a": 1}') == {"a": 1}
|
|
53
53
|
assert parsed_object("[1]") is None
|
|
54
54
|
assert parsed_object("not json") is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ccd_plan_record(all_plan_input: dict[str, object], record_type: str = "assistant") -> dict[str, object]:
|
|
58
|
+
tool_use = {"type": "tool_use", "id": "p1", "name": "mcp__ccd_turn__update_plan", "input": all_plan_input}
|
|
59
|
+
return {"type": record_type, "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plan_statuses(transcript_path: Path) -> list[tuple[str, str, bool]]:
|
|
63
|
+
all_items = replayed_transcript(str(transcript_path)).items() or []
|
|
64
|
+
return [(each_item.subject, each_item.status, each_item.is_carried) for each_item in all_items]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_path: Path) -> None:
|
|
68
|
+
all_items = [
|
|
69
|
+
{"content": "Read the gate", "status": "in_progress"},
|
|
70
|
+
{"content": "Write the tests", "status": "pending"},
|
|
71
|
+
{"content": "Open the PR", "status": "pending"},
|
|
72
|
+
]
|
|
73
|
+
transcript_path = write_transcript(
|
|
74
|
+
tmp_path / "ccd.jsonl",
|
|
75
|
+
[
|
|
76
|
+
ccd_plan_record({"items": all_items, "scope": "first_task"}),
|
|
77
|
+
ccd_plan_record({"advance": True}),
|
|
78
|
+
ccd_plan_record({"advance": True}, record_type="api-request-blob"),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
assert plan_statuses(transcript_path) == [
|
|
82
|
+
("Read the gate", "completed", False),
|
|
83
|
+
("Write the tests", "in_progress", False),
|
|
84
|
+
("Open the PR", "pending", False),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_should_carry_unfinished_items_after_the_replacing_list(tmp_path: Path) -> None:
|
|
89
|
+
first_items = [
|
|
90
|
+
{"content": "Export the OneUI8 APK", "status": "in_progress"},
|
|
91
|
+
{"content": "Submit both APKs", "status": "pending"},
|
|
92
|
+
{"content": "Find the rows", "status": "completed"},
|
|
93
|
+
]
|
|
94
|
+
transcript_path = write_transcript(
|
|
95
|
+
tmp_path / "ccd.jsonl",
|
|
96
|
+
[
|
|
97
|
+
ccd_plan_record({"items": first_items, "scope": "first_task"}),
|
|
98
|
+
ccd_plan_record({"scope": "continuation", "items": [{"content": "Set rows in Neon", "status": "in_progress"}]}),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
assert plan_statuses(transcript_path) == [
|
|
102
|
+
("Set rows in Neon", "in_progress", False),
|
|
103
|
+
("Export the OneUI8 APK", "in_progress", True),
|
|
104
|
+
("Submit both APKs", "pending", True),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_point_done_and_start_indices_at_the_tools_own_items(tmp_path: Path) -> None:
|
|
109
|
+
transcript_path = write_transcript(
|
|
110
|
+
tmp_path / "ccd.jsonl",
|
|
111
|
+
[
|
|
112
|
+
ccd_plan_record({"items": [{"content": "Alpha", "status": "pending"}], "scope": "first_task"}),
|
|
113
|
+
ccd_plan_record(
|
|
114
|
+
{
|
|
115
|
+
"items": [{"content": "Beta", "status": "in_progress"}, {"content": "Gamma", "status": "pending"}],
|
|
116
|
+
"scope": "continuation",
|
|
117
|
+
}
|
|
118
|
+
),
|
|
119
|
+
ccd_plan_record({"done": [1, 3], "start": 2}),
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
assert plan_statuses(transcript_path) == [
|
|
123
|
+
("Beta", "completed", False),
|
|
124
|
+
("Gamma", "in_progress", False),
|
|
125
|
+
("Alpha", "pending", True),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_let_a_later_list_close_a_carried_item_by_subject(tmp_path: Path) -> None:
|
|
130
|
+
transcript_path = write_transcript(
|
|
131
|
+
tmp_path / "ccd.jsonl",
|
|
132
|
+
[
|
|
133
|
+
ccd_plan_record({"items": [{"content": "Alpha", "status": "pending"}], "scope": "first_task"}),
|
|
134
|
+
ccd_plan_record({"items": [{"content": "Beta", "status": "pending"}], "scope": "continuation"}),
|
|
135
|
+
ccd_plan_record(
|
|
136
|
+
{
|
|
137
|
+
"items": [{"content": "Beta", "status": "completed"}, {"content": "alpha", "status": "completed"}],
|
|
138
|
+
"scope": "continuation",
|
|
139
|
+
}
|
|
140
|
+
),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
assert plan_statuses(transcript_path) == [("Beta", "completed", False), ("alpha", "completed", False)]
|
|
@@ -66,6 +66,14 @@ def test_unloaded_pull_request_command_is_denied(tmp_path: Path) -> None:
|
|
|
66
66
|
assert "same command again" in decision["permissionDecisionReason"]
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def test_executable_word_lists_reads_chains_prefixes_and_shell_wrappers() -> None:
|
|
70
|
+
assert gate.executable_word_lists("cd repo && env A=1 git commit -m x") == [
|
|
71
|
+
["cd", "repo"],
|
|
72
|
+
["git", "commit", "-m", "x"],
|
|
73
|
+
]
|
|
74
|
+
assert gate.executable_word_lists("bash -c 'git add x; git commit'")[-2:] == [["git", "add", "x"], ["git", "commit"]]
|
|
75
|
+
|
|
76
|
+
|
|
69
77
|
def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
|
|
70
78
|
path = _transcript(tmp_path, "plugin:pr-lifecycle")
|
|
71
79
|
assert _run_main(_payload("gh pr view", path)) == (0, "")
|