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
|
@@ -35,12 +35,13 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
35
35
|
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
|
|
36
36
|
|
|
37
37
|
const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
|
|
38
|
-
assert.equal(userPromptHooks.length,
|
|
38
|
+
assert.equal(userPromptHooks.length, 6);
|
|
39
39
|
assert.equal(userPromptHooks[0].command, userCommand);
|
|
40
40
|
assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
|
|
41
41
|
assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
|
|
42
42
|
assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
|
|
43
|
-
assert.match(userPromptHooks[4].command, /session\/
|
|
43
|
+
assert.match(userPromptHooks[4].command, /session\/open_task_ask_recorder\.py$/);
|
|
44
|
+
assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
|
|
44
45
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
45
46
|
|
|
46
47
|
const firstMerge = JSON.stringify(settings);
|
|
@@ -268,7 +268,7 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
|
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
-
test('package settings.json publishes the session agent, advisor model, auto-compact, auto mode
|
|
271
|
+
test('package settings.json publishes the session agent, advisor model, auto-compact, auto mode, idle compaction and Python UTF-8 defaults', () => {
|
|
272
272
|
const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
|
|
273
273
|
const settingsDefaults = settingsDefaultsFromPackageSettings(packageSettings);
|
|
274
274
|
assert.deepEqual(
|
|
@@ -276,7 +276,7 @@ test('package settings.json publishes the session agent, advisor model, auto-com
|
|
|
276
276
|
['advisorModel', 'agent', 'autoCompactWindow', 'autoMode', 'env', 'modelSettings'],
|
|
277
277
|
);
|
|
278
278
|
assert.equal(settingsDefaults.autoCompactWindow, 350000);
|
|
279
|
-
assert.deepEqual(settingsDefaults.env, { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' });
|
|
279
|
+
assert.deepEqual(settingsDefaults.env, { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000', PYTHONUTF8: '1' });
|
|
280
280
|
assert.deepEqual(settingsDefaults.modelSettings, {
|
|
281
281
|
'claude-haiku-5-5': { autoCompactWindow: 100000 },
|
|
282
282
|
'claude-haiku-4-5': { autoCompactWindow: 100000 },
|
|
@@ -307,7 +307,7 @@ test('a missing default is added and a user-set value is kept', () => {
|
|
|
307
307
|
assert.equal(userSettings.advisorModel, 'sonnet');
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
-
test('install adds the idle compaction
|
|
310
|
+
test('install adds the idle compaction and Python UTF-8 variables to an env block and keeps a user value on rerun', () => {
|
|
311
311
|
const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
|
|
312
312
|
try {
|
|
313
313
|
const settingsPath = join(sandboxHome, '.claude', 'settings.json');
|
|
@@ -318,6 +318,7 @@ test('install adds the idle compaction variable to an env block and keeps a user
|
|
|
318
318
|
assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).env, {
|
|
319
319
|
EDITOR: 'vim',
|
|
320
320
|
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000',
|
|
321
|
+
PYTHONUTF8: '1',
|
|
321
322
|
});
|
|
322
323
|
const userChoice = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
323
324
|
userChoice.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS = '300000';
|
|
@@ -29,7 +29,12 @@ The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
|
|
|
29
29
|
- Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
|
|
30
30
|
- Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
|
|
31
31
|
|
|
32
|
-
The gate
|
|
32
|
+
The gate merges the task store, the transcript TaskCreate, TaskUpdate, and TodoWrite calls, the Codex `update_plan` calls, and the Claude Code desktop `mcp__ccd_turn__update_plan` calls. Each new desktop plan list keeps the unfinished items of the list before it. In Claude Code, `open_task_ask_recorder.py` also records each prompt that asks for work as `#ask-N`. The gate ends the hold when each open item is named on a `Blocked:` or `Open:` line of the closing reply, and that line releases only the current stop.
|
|
33
|
+
|
|
34
|
+
Close an item for good with one of these lines:
|
|
35
|
+
|
|
36
|
+
- `Done: #ask-N` for a finished ask. A plan item closes only when the plan marks it completed.
|
|
37
|
+
- `Dropped: <#ask-N or carried item> - "<the user's words>"` for an ask or carried plan item the user called off. The quote must come from a prompt the user sent after that ask or after the plan list that held the item.
|
|
33
38
|
|
|
34
39
|
## Cursor
|
|
35
40
|
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Stop hook that keeps a session working while its task list has unreported open items.
|
|
3
3
|
|
|
4
|
-
The session's
|
|
4
|
+
The session's open items merge these sources, one item per subject::
|
|
5
5
|
|
|
6
|
-
~/.claude/tasks/<task list id>/*.json
|
|
7
|
-
transcript TaskCreate / TaskUpdate calls
|
|
8
|
-
transcript TodoWrite input.todos
|
|
9
|
-
transcript Codex update_plan arguments
|
|
6
|
+
~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
|
|
7
|
+
transcript TaskCreate / TaskUpdate calls (replayed in order)
|
|
8
|
+
transcript TodoWrite input.todos (last call wins)
|
|
9
|
+
transcript Codex update_plan arguments (last call wins)
|
|
10
|
+
transcript mcp__ccd_turn__update_plan calls (unfinished items carry into a replacing list)
|
|
11
|
+
~/.claude/open-task-asks/<session id>.json (each prompt that asks for work, as #ask-N)
|
|
10
12
|
|
|
11
|
-
An item with status ``pending`` or ``in_progress`` is open
|
|
12
|
-
|
|
13
|
+
An item with status ``pending`` or ``in_progress`` is open, and so is an ask
|
|
14
|
+
until a reply closes it. The turn may end only when the closing reply names
|
|
15
|
+
every open item on a report line::
|
|
13
16
|
|
|
14
17
|
allow: Blocked: Rotate the deploy key - the owner must approve the org secret
|
|
15
18
|
allow: - Open: #3 - waits on the release checks
|
|
19
|
+
allow: Open: #ask-2 - the export job runs next
|
|
16
20
|
block: (reply never mentions "Rotate the deploy key")
|
|
17
21
|
|
|
22
|
+
A report line releases only the current stop. Two lines in any assistant text
|
|
23
|
+
of the session close an item for good::
|
|
24
|
+
|
|
25
|
+
Done: #ask-2 closes an ask; never a plan item
|
|
26
|
+
Dropped: #ask-1 - "skip the APK export" closes an ask
|
|
27
|
+
Dropped: Submit both APKs - "skip the APK export" closes a carried plan item
|
|
28
|
+
|
|
29
|
+
A ``Dropped:`` quote must appear in a prompt the user sent after that ask, or
|
|
30
|
+
after the last plan list that held the item.
|
|
31
|
+
|
|
18
32
|
A block sends the open items back as the reason, so the model keeps working or
|
|
19
33
|
reports them. Claude Code ends the turn after eight blocks in a row. Codex
|
|
20
34
|
turns the reason into a continuation prompt. A Cursor payload (it carries
|
|
@@ -45,14 +59,19 @@ if blocking_directory not in sys.path:
|
|
|
45
59
|
|
|
46
60
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
47
61
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
48
|
-
from
|
|
62
|
+
from open_task_asks import RecordedPrompt, claude_home, recorded_prompts
|
|
63
|
+
from open_tasks_transcript import TaskItem, normalized_text, parsed_object, replayed_transcript
|
|
49
64
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
50
65
|
ALLOW_EXIT_CODE,
|
|
66
|
+
ASK_CLOSE_SUFFIX,
|
|
67
|
+
ASK_ID_PREFIX,
|
|
68
|
+
ASK_LINE_TEMPLATE,
|
|
69
|
+
ASK_REFERENCE_PATTERN,
|
|
70
|
+
ASK_SUBJECT_CHARACTER_LIMIT,
|
|
71
|
+
ASK_SUBJECT_ELLIPSIS,
|
|
51
72
|
BLOCK_DECISION,
|
|
52
73
|
BLOCK_REASON_PREFIX,
|
|
53
74
|
BLOCK_REASON_SUFFIX,
|
|
54
|
-
CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
55
|
-
CLAUDE_HOME_DIRECTORY_NAME,
|
|
56
75
|
CURSOR_COMPLETED_STATUS,
|
|
57
76
|
CURSOR_CONVERSATION_ID_KEY,
|
|
58
77
|
CURSOR_FOLLOWUP_LIMIT,
|
|
@@ -60,10 +79,16 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
60
79
|
CURSOR_LOOP_COUNT_KEY,
|
|
61
80
|
CURSOR_STATUS_KEY,
|
|
62
81
|
DECISION_KEY,
|
|
82
|
+
DONE_LINE_PATTERN,
|
|
83
|
+
DONE_TEXT_GROUP_INDEX,
|
|
84
|
+
DROPPED_LINE_PATTERN,
|
|
85
|
+
DROPPED_QUOTE_GROUP_INDEX,
|
|
86
|
+
DROPPED_TARGET_GROUP_INDEX,
|
|
63
87
|
HOOK_EVENT_NAME,
|
|
64
88
|
INCOMPLETE_STATUSES,
|
|
65
89
|
ITEM_LINE_TEMPLATE,
|
|
66
90
|
LAST_ASSISTANT_MESSAGE_KEY,
|
|
91
|
+
PENDING_STATUS,
|
|
67
92
|
REASON_KEY,
|
|
68
93
|
REPORT_LINE_PATTERN,
|
|
69
94
|
REPORTED_TEXT_GROUP_INDEX,
|
|
@@ -78,17 +103,9 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
78
103
|
TRANSCRIPT_PATH_KEY,
|
|
79
104
|
UTF8_ENCODING,
|
|
80
105
|
WORD_BOUNDARY_PATTERN,
|
|
81
|
-
WORD_SEPARATOR,
|
|
82
106
|
)
|
|
83
107
|
|
|
84
108
|
|
|
85
|
-
def _claude_home() -> Path:
|
|
86
|
-
configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
87
|
-
if configured_directory:
|
|
88
|
-
return Path(configured_directory)
|
|
89
|
-
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
|
|
90
|
-
|
|
91
|
-
|
|
92
109
|
def _task_file_item(task_file: Path) -> TaskItem | None:
|
|
93
110
|
try:
|
|
94
111
|
task_text = task_file.read_text(encoding=UTF8_ENCODING)
|
|
@@ -109,25 +126,30 @@ def task_store_items(session_id: object) -> list[TaskItem] | None:
|
|
|
109
126
|
task_list_id = os.environ.get(TASK_LIST_ID_ENVIRONMENT_VARIABLE) or session_id
|
|
110
127
|
if not isinstance(task_list_id, str) or not task_list_id:
|
|
111
128
|
return None
|
|
112
|
-
task_directory =
|
|
129
|
+
task_directory = claude_home() / TASKS_DIRECTORY_NAME / task_list_id
|
|
113
130
|
if not task_directory.is_dir():
|
|
114
131
|
return None
|
|
115
132
|
all_items = (_task_file_item(each_file) for each_file in sorted(task_directory.glob(TASK_FILE_GLOB)))
|
|
116
133
|
return [each_item for each_item in all_items if each_item is not None]
|
|
117
134
|
|
|
118
135
|
|
|
119
|
-
def
|
|
120
|
-
|
|
136
|
+
def _line_matches(line_pattern: str, all_texts: list[str]) -> list[re.Match[str]]:
|
|
137
|
+
all_matches = (
|
|
138
|
+
re.match(line_pattern, each_line, re.IGNORECASE) for each_text in all_texts for each_line in each_text.splitlines()
|
|
139
|
+
)
|
|
140
|
+
return [each_match for each_match in all_matches if each_match]
|
|
121
141
|
|
|
122
142
|
|
|
123
143
|
def _reported_texts(reply_text: str) -> list[str]:
|
|
124
|
-
|
|
125
|
-
|
|
144
|
+
return [
|
|
145
|
+
normalized_text(each_match.group(REPORTED_TEXT_GROUP_INDEX))
|
|
146
|
+
for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
|
|
147
|
+
]
|
|
126
148
|
|
|
127
149
|
|
|
128
150
|
def is_reported(task_item: TaskItem, reply_text: str) -> bool:
|
|
129
151
|
"""Return True when a Blocked: or Open: line of the reply names the item."""
|
|
130
|
-
subject_text =
|
|
152
|
+
subject_text = normalized_text(task_item.subject)
|
|
131
153
|
number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
|
|
132
154
|
return any(
|
|
133
155
|
(subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
|
|
@@ -135,14 +157,82 @@ def is_reported(task_item: TaskItem, reply_text: str) -> bool:
|
|
|
135
157
|
)
|
|
136
158
|
|
|
137
159
|
|
|
160
|
+
def _is_quoted_after(quote_text: str, all_later_prompts: list[RecordedPrompt]) -> bool:
|
|
161
|
+
normalized_quote = normalized_text(quote_text)
|
|
162
|
+
return bool(normalized_quote) and any(
|
|
163
|
+
normalized_quote in normalized_text(each_prompt.text) for each_prompt in all_later_prompts
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ask_subject(prompt_text: str) -> str:
|
|
168
|
+
first_line = prompt_text.strip().splitlines()[0]
|
|
169
|
+
if len(first_line) <= ASK_SUBJECT_CHARACTER_LIMIT:
|
|
170
|
+
return first_line
|
|
171
|
+
return first_line[:ASK_SUBJECT_CHARACTER_LIMIT] + ASK_SUBJECT_ELLIPSIS
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def open_ask_items(all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> list[TaskItem]:
|
|
175
|
+
"""Return each recorded ask that no ``Done:`` line and no quoted ``Dropped:`` line closes."""
|
|
176
|
+
all_done_ids = {
|
|
177
|
+
each_reference
|
|
178
|
+
for each_match in _line_matches(DONE_LINE_PATTERN, all_closing_texts)
|
|
179
|
+
for each_reference in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DONE_TEXT_GROUP_INDEX))
|
|
180
|
+
}
|
|
181
|
+
all_drop_matches = _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
|
|
182
|
+
all_open_asks = []
|
|
183
|
+
for each_position, each_prompt in enumerate(all_prompts):
|
|
184
|
+
if not each_prompt.ask_id or each_prompt.ask_id in all_done_ids:
|
|
185
|
+
continue
|
|
186
|
+
is_dropped = any(
|
|
187
|
+
each_prompt.ask_id in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DROPPED_TARGET_GROUP_INDEX))
|
|
188
|
+
and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_prompts[each_position + 1 :])
|
|
189
|
+
for each_match in all_drop_matches
|
|
190
|
+
)
|
|
191
|
+
if not is_dropped:
|
|
192
|
+
all_open_asks.append(TaskItem(each_prompt.ask_id, _ask_subject(each_prompt.text), PENDING_STATUS))
|
|
193
|
+
return all_open_asks
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_dropped_carried_item(task_item: TaskItem, all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> bool:
|
|
197
|
+
"""Return True when a ``Dropped:`` line names the carried item and quotes a prompt sent after its list."""
|
|
198
|
+
listed_at = task_item.listed_at
|
|
199
|
+
subject_text = normalized_text(task_item.subject)
|
|
200
|
+
if not task_item.is_carried or listed_at is None or not subject_text:
|
|
201
|
+
return False
|
|
202
|
+
all_later_prompts = [each_prompt for each_prompt in all_prompts if each_prompt.recorded_at > listed_at]
|
|
203
|
+
return any(
|
|
204
|
+
subject_text in normalized_text(each_match.group(DROPPED_TARGET_GROUP_INDEX))
|
|
205
|
+
and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_later_prompts)
|
|
206
|
+
for each_match in _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def deduplicated_by_subject(all_items: list[TaskItem]) -> list[TaskItem]:
|
|
211
|
+
"""Keep the first item for each subject, so the task store wins over the transcript."""
|
|
212
|
+
item_by_key: dict[str, TaskItem] = {}
|
|
213
|
+
for each_item in all_items:
|
|
214
|
+
item_key = normalized_text(each_item.subject) or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=each_item.item_id)
|
|
215
|
+
item_by_key.setdefault(item_key, each_item)
|
|
216
|
+
return list(item_by_key.values())
|
|
217
|
+
|
|
218
|
+
|
|
138
219
|
def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
|
|
139
|
-
"""Return the open task items the closing reply does not report."""
|
|
220
|
+
"""Return the open task items and asks the closing reply does not report."""
|
|
140
221
|
replay = replayed_transcript(all_hook_input_fields.get(TRANSCRIPT_PATH_KEY))
|
|
141
|
-
|
|
142
|
-
if all_items is None:
|
|
143
|
-
all_items = replay.items() or []
|
|
222
|
+
session_id = all_hook_input_fields.get(SESSION_ID_KEY)
|
|
144
223
|
reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
145
224
|
reply_text = reply_value if isinstance(reply_value, str) else replay.last_assistant_text
|
|
225
|
+
all_closing_texts = [*replay.all_assistant_texts, reply_text]
|
|
226
|
+
all_prompts = recorded_prompts(session_id)
|
|
227
|
+
all_replayed_items = [
|
|
228
|
+
each_item
|
|
229
|
+
for each_item in replay.items() or []
|
|
230
|
+
if not is_dropped_carried_item(each_item, all_prompts, all_closing_texts)
|
|
231
|
+
]
|
|
232
|
+
all_items = [
|
|
233
|
+
*deduplicated_by_subject([*(task_store_items(session_id) or []), *all_replayed_items]),
|
|
234
|
+
*open_ask_items(all_prompts, all_closing_texts),
|
|
235
|
+
]
|
|
146
236
|
return [
|
|
147
237
|
each_item
|
|
148
238
|
for each_item in all_items
|
|
@@ -150,13 +240,17 @@ def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[Task
|
|
|
150
240
|
]
|
|
151
241
|
|
|
152
242
|
|
|
243
|
+
def _item_line(task_item: TaskItem) -> str:
|
|
244
|
+
if task_item.item_id.startswith(ASK_ID_PREFIX):
|
|
245
|
+
return ASK_LINE_TEMPLATE.format(ask_id=task_item.item_id, subject=task_item.subject)
|
|
246
|
+
return ITEM_LINE_TEMPLATE.format(subject=task_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id))
|
|
247
|
+
|
|
248
|
+
|
|
153
249
|
def block_reason_for(all_open_items: list[TaskItem]) -> str:
|
|
154
|
-
"""Return the block reason that lists each open item."""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
return BLOCK_REASON_PREFIX + "".join(all_item_lines) + BLOCK_REASON_SUFFIX
|
|
250
|
+
"""Return the block reason that lists each open item, and how to close an ask when one is open."""
|
|
251
|
+
has_open_ask = any(each_item.item_id.startswith(ASK_ID_PREFIX) for each_item in all_open_items)
|
|
252
|
+
all_item_lines = "".join(_item_line(each_item) for each_item in all_open_items)
|
|
253
|
+
return BLOCK_REASON_PREFIX + all_item_lines + BLOCK_REASON_SUFFIX + (ASK_CLOSE_SUFFIX if has_open_ask else "")
|
|
160
254
|
|
|
161
255
|
|
|
162
256
|
def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Rebuild a session task list and its last assistant text from a transcript.
|
|
3
3
|
|
|
4
|
-
The open tasks Stop gate reads
|
|
4
|
+
The open tasks Stop gate reads four transcript shapes, replayed in order::
|
|
5
5
|
|
|
6
6
|
TaskCreate / TaskUpdate calls (Claude Code)
|
|
7
7
|
TodoWrite input.todos (Claude Code and Cursor; ``merge`` updates by id)
|
|
8
8
|
update_plan function_call arguments (Codex rollout)
|
|
9
|
+
mcp__ccd_turn__update_plan input (Claude Code desktop plan)
|
|
10
|
+
|
|
11
|
+
A desktop plan call either lists items or moves the current list::
|
|
12
|
+
|
|
13
|
+
{"items": [...], "scope": "continuation"} -> new list, unfinished old items carried after it
|
|
14
|
+
{"done": [2], "start": 3} -> 1-based positions in the tool's own items
|
|
15
|
+
{"advance": true} -> complete the in_progress item, start the next pending
|
|
16
|
+
|
|
17
|
+
Only ``assistant`` records count for a desktop plan call, because Claude Code
|
|
18
|
+
also copies each call into ``api-request-blob`` records.
|
|
9
19
|
|
|
10
20
|
The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
|
|
11
21
|
TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
|
|
@@ -16,6 +26,7 @@ from __future__ import annotations
|
|
|
16
26
|
import json
|
|
17
27
|
import re
|
|
18
28
|
import sys
|
|
29
|
+
from datetime import datetime
|
|
19
30
|
from pathlib import Path
|
|
20
31
|
from typing import NamedTuple
|
|
21
32
|
|
|
@@ -24,7 +35,15 @@ if hooks_root_directory not in sys.path:
|
|
|
24
35
|
sys.path.insert(0, hooks_root_directory)
|
|
25
36
|
|
|
26
37
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
38
|
+
ASSISTANT_RECORD_TYPE,
|
|
27
39
|
ASSISTANT_ROLE,
|
|
40
|
+
CCD_PLAN_ADVANCE_KEY,
|
|
41
|
+
CCD_PLAN_CONTENT_KEY,
|
|
42
|
+
CCD_PLAN_DONE_KEY,
|
|
43
|
+
CCD_PLAN_ITEM_ID_TEMPLATE,
|
|
44
|
+
CCD_PLAN_ITEMS_KEY,
|
|
45
|
+
CCD_PLAN_START_KEY,
|
|
46
|
+
CCD_PLAN_TOOL_NAME,
|
|
28
47
|
CODEX_ARGUMENTS_KEY,
|
|
29
48
|
CODEX_FUNCTION_CALL_TYPE,
|
|
30
49
|
CODEX_PAYLOAD_KEY,
|
|
@@ -32,14 +51,18 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
32
51
|
CODEX_PLAN_TOOL_NAME,
|
|
33
52
|
CODEX_RESPONSE_ITEM_TYPE,
|
|
34
53
|
CODEX_STEP_KEY,
|
|
54
|
+
COMPLETED_STATUS,
|
|
35
55
|
CONTENT_KEY,
|
|
36
56
|
CREATED_TASK_ID_GROUP_INDEX,
|
|
37
57
|
DYNAMIC_TOOL_ARGUMENTS_KEY,
|
|
38
58
|
DYNAMIC_TOOL_CALL_NAME,
|
|
39
59
|
DYNAMIC_TOOL_NAME_KEY,
|
|
40
60
|
FIRST_ITEM_NUMBER,
|
|
61
|
+
INCOMPLETE_STATUSES,
|
|
62
|
+
IN_PROGRESS_STATUS,
|
|
41
63
|
MESSAGE_KEY,
|
|
42
64
|
PENDING_STATUS,
|
|
65
|
+
RECORD_TIMESTAMP_KEY,
|
|
43
66
|
RECORD_TYPE_KEY,
|
|
44
67
|
REPLY_TEXT_SEPARATOR,
|
|
45
68
|
ROLE_KEY,
|
|
@@ -61,6 +84,8 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
61
84
|
TOOL_RESULT_BLOCK_TYPE,
|
|
62
85
|
TOOL_USE_BLOCK_TYPE,
|
|
63
86
|
TOOL_USE_ID_KEY,
|
|
87
|
+
UTC_OFFSET_TEXT,
|
|
88
|
+
UTC_SUFFIX,
|
|
64
89
|
UTF8_ENCODING,
|
|
65
90
|
WORD_SEPARATOR,
|
|
66
91
|
)
|
|
@@ -73,11 +98,31 @@ class TaskItem(NamedTuple):
|
|
|
73
98
|
item_id: The task number or position, as text.
|
|
74
99
|
subject: The item's wording.
|
|
75
100
|
status: The item's status, such as ``pending`` or ``completed``.
|
|
101
|
+
listed_at: For a desktop plan item, seconds since the epoch of the last list that held it.
|
|
102
|
+
is_carried: True for a desktop plan item a replacing list left out.
|
|
76
103
|
"""
|
|
77
104
|
|
|
78
105
|
item_id: str
|
|
79
106
|
subject: str
|
|
80
107
|
status: str
|
|
108
|
+
listed_at: float | None = None
|
|
109
|
+
is_carried: bool = False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def normalized_text(text: str) -> str:
|
|
113
|
+
"""Return the text lowercased with each run of whitespace collapsed to one space."""
|
|
114
|
+
return WORD_SEPARATOR.join(text.lower().split())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def record_epoch(all_record_fields: dict[str, object]) -> float | None:
|
|
118
|
+
"""Return the record's ISO ``timestamp`` as seconds since the epoch, or None."""
|
|
119
|
+
timestamp_value = all_record_fields.get(RECORD_TIMESTAMP_KEY)
|
|
120
|
+
if not isinstance(timestamp_value, str):
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
return datetime.fromisoformat(timestamp_value.replace(UTC_SUFFIX, UTC_OFFSET_TEXT)).timestamp()
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
81
126
|
|
|
82
127
|
|
|
83
128
|
def parsed_object(json_text: str) -> dict[str, object] | None:
|
|
@@ -171,6 +216,22 @@ def codex_plan_items(all_record_fields: dict[str, object]) -> list[TaskItem] | N
|
|
|
171
216
|
return _numbered_items(all_plan_arguments.get(CODEX_PLAN_KEY), CODEX_STEP_KEY)
|
|
172
217
|
|
|
173
218
|
|
|
219
|
+
def advanced_items(all_own_items: list[TaskItem]) -> list[TaskItem]:
|
|
220
|
+
"""Complete the in_progress item and start the next pending one after it, as ``advance`` does."""
|
|
221
|
+
all_advanced_items = list(all_own_items)
|
|
222
|
+
search_start = 0
|
|
223
|
+
for each_position, each_item in enumerate(all_advanced_items):
|
|
224
|
+
if each_item.status == IN_PROGRESS_STATUS:
|
|
225
|
+
all_advanced_items[each_position] = each_item._replace(status=COMPLETED_STATUS)
|
|
226
|
+
search_start = each_position + 1
|
|
227
|
+
break
|
|
228
|
+
for each_position in range(search_start, len(all_advanced_items)):
|
|
229
|
+
if all_advanced_items[each_position].status == PENDING_STATUS:
|
|
230
|
+
all_advanced_items[each_position] = all_advanced_items[each_position]._replace(status=IN_PROGRESS_STATUS)
|
|
231
|
+
break
|
|
232
|
+
return all_advanced_items
|
|
233
|
+
|
|
234
|
+
|
|
174
235
|
def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) -> tuple[str, dict[str, object]]:
|
|
175
236
|
"""Return the inner tool name and arguments of a Cursor ``CallDynamicTool`` call, or the call itself.
|
|
176
237
|
|
|
@@ -191,7 +252,10 @@ class _TranscriptReplay:
|
|
|
191
252
|
self.latest_items: list[TaskItem] | None = None
|
|
192
253
|
self.created_by_id: dict[str, TaskItem] = {}
|
|
193
254
|
self.subject_by_tool_use_id: dict[str, str] = {}
|
|
255
|
+
self.plan_items: list[TaskItem] | None = None
|
|
256
|
+
self.own_plan_count = 0
|
|
194
257
|
self.last_assistant_text = ""
|
|
258
|
+
self.all_assistant_texts: list[str] = []
|
|
195
259
|
|
|
196
260
|
def apply_record(self, all_record_fields: dict[str, object]) -> None:
|
|
197
261
|
plan_items = codex_plan_items(all_record_fields)
|
|
@@ -199,10 +263,46 @@ class _TranscriptReplay:
|
|
|
199
263
|
self.latest_items = plan_items
|
|
200
264
|
return
|
|
201
265
|
all_blocks = _content_blocks(all_record_fields)
|
|
266
|
+
is_assistant_record = all_record_fields.get(RECORD_TYPE_KEY) == ASSISTANT_RECORD_TYPE
|
|
202
267
|
for each_block in all_blocks:
|
|
203
|
-
|
|
268
|
+
plan_input = each_block.get(TOOL_INPUT_KEY)
|
|
269
|
+
if is_assistant_record and each_block.get(TOOL_NAME_KEY) == CCD_PLAN_TOOL_NAME and isinstance(plan_input, dict):
|
|
270
|
+
self.apply_desktop_plan(plan_input, record_epoch(all_record_fields))
|
|
271
|
+
else:
|
|
272
|
+
self.apply_block(each_block)
|
|
204
273
|
self.note_assistant_text(all_record_fields, all_blocks)
|
|
205
274
|
|
|
275
|
+
def apply_desktop_plan(self, all_plan_fields: dict[str, object], listed_at: float | None) -> None:
|
|
276
|
+
all_listed_entries = all_plan_fields.get(CCD_PLAN_ITEMS_KEY)
|
|
277
|
+
if isinstance(all_listed_entries, list):
|
|
278
|
+
self.replace_desktop_plan(all_listed_entries, listed_at)
|
|
279
|
+
return
|
|
280
|
+
all_own_items = (self.plan_items or [])[: self.own_plan_count]
|
|
281
|
+
all_done_positions = all_plan_fields.get(CCD_PLAN_DONE_KEY)
|
|
282
|
+
for each_position in all_done_positions if isinstance(all_done_positions, list) else []:
|
|
283
|
+
if isinstance(each_position, int) and FIRST_ITEM_NUMBER <= each_position <= len(all_own_items):
|
|
284
|
+
all_own_items[each_position - FIRST_ITEM_NUMBER] = all_own_items[each_position - FIRST_ITEM_NUMBER]._replace(status=COMPLETED_STATUS)
|
|
285
|
+
start_position = all_plan_fields.get(CCD_PLAN_START_KEY)
|
|
286
|
+
if isinstance(start_position, int) and FIRST_ITEM_NUMBER <= start_position <= len(all_own_items):
|
|
287
|
+
all_own_items[start_position - FIRST_ITEM_NUMBER] = all_own_items[start_position - FIRST_ITEM_NUMBER]._replace(status=IN_PROGRESS_STATUS)
|
|
288
|
+
if all_plan_fields.get(CCD_PLAN_ADVANCE_KEY) is True:
|
|
289
|
+
all_own_items = advanced_items(all_own_items)
|
|
290
|
+
self.plan_items = all_own_items + (self.plan_items or [])[self.own_plan_count :]
|
|
291
|
+
|
|
292
|
+
def replace_desktop_plan(self, all_listed_entries: list[object], listed_at: float | None) -> None:
|
|
293
|
+
all_own_items = [
|
|
294
|
+
each_item._replace(item_id=CCD_PLAN_ITEM_ID_TEMPLATE.format(position=each_item.item_id), listed_at=listed_at)
|
|
295
|
+
for each_item in _numbered_items(all_listed_entries, CCD_PLAN_CONTENT_KEY)
|
|
296
|
+
]
|
|
297
|
+
all_listed_subjects = {normalized_text(each_item.subject) for each_item in all_own_items}
|
|
298
|
+
all_carried_items = [
|
|
299
|
+
each_item._replace(is_carried=True)
|
|
300
|
+
for each_item in self.plan_items or []
|
|
301
|
+
if each_item.status in INCOMPLETE_STATUSES and normalized_text(each_item.subject) not in all_listed_subjects
|
|
302
|
+
]
|
|
303
|
+
self.plan_items = all_own_items + all_carried_items
|
|
304
|
+
self.own_plan_count = len(all_own_items)
|
|
305
|
+
|
|
206
306
|
def note_assistant_text(self, all_record_fields: dict[str, object], all_blocks: list[dict[str, object]]) -> None:
|
|
207
307
|
message_body = all_record_fields.get(MESSAGE_KEY)
|
|
208
308
|
message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
|
|
@@ -213,6 +313,7 @@ class _TranscriptReplay:
|
|
|
213
313
|
).strip()
|
|
214
314
|
if record_text:
|
|
215
315
|
self.last_assistant_text = record_text
|
|
316
|
+
self.all_assistant_texts.append(record_text)
|
|
216
317
|
|
|
217
318
|
def apply_block(self, all_block_fields: dict[str, object]) -> None:
|
|
218
319
|
block_type = all_block_fields.get(RECORD_TYPE_KEY)
|
|
@@ -252,9 +353,15 @@ class _TranscriptReplay:
|
|
|
252
353
|
self.created_by_id[created_id] = TaskItem(created_id, created_subject, PENDING_STATUS)
|
|
253
354
|
|
|
254
355
|
def items(self) -> list[TaskItem] | None:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
356
|
+
all_sources = [
|
|
357
|
+
list(self.created_by_id.values()) if self.created_by_id else None,
|
|
358
|
+
self.latest_items,
|
|
359
|
+
self.plan_items,
|
|
360
|
+
]
|
|
361
|
+
all_present_sources = [each_source for each_source in all_sources if each_source is not None]
|
|
362
|
+
if not all_present_sources:
|
|
363
|
+
return None
|
|
364
|
+
return [each_item for each_source in all_present_sources for each_item in each_source]
|
|
258
365
|
|
|
259
366
|
|
|
260
367
|
def replayed_transcript(transcript_path: object) -> _TranscriptReplay:
|
|
@@ -91,9 +91,21 @@ def _command_segments(command: str) -> list[list[str]]:
|
|
|
91
91
|
return [each_group for each_group in all_segments if each_group]
|
|
92
92
|
|
|
93
93
|
|
|
94
|
-
def
|
|
94
|
+
def next_command_word(
|
|
95
95
|
all_words: list[str], start: int, all_options_with_value: frozenset[str]
|
|
96
96
|
) -> tuple[str | None, int]:
|
|
97
|
+
"""Return the first word from ``start`` that is no option, and the index after it.
|
|
98
|
+
|
|
99
|
+
::
|
|
100
|
+
|
|
101
|
+
["git", "-C", "repo", "commit"], 1, {"-C"} -> ("commit", 4)
|
|
102
|
+
["git", "--no-pager"], 1, {"-C"} -> (None, 2)
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
all_words: One command's words, executable first.
|
|
106
|
+
start: The index to start reading at.
|
|
107
|
+
all_options_with_value: Options whose next word is their value.
|
|
108
|
+
"""
|
|
97
109
|
index = start
|
|
98
110
|
while index < len(all_words):
|
|
99
111
|
each_word = all_words[index]
|
|
@@ -108,7 +120,7 @@ def _next_command_word(
|
|
|
108
120
|
|
|
109
121
|
|
|
110
122
|
def _is_git_action(all_words: list[str]) -> bool:
|
|
111
|
-
action, _ =
|
|
123
|
+
action, _ = next_command_word(all_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE)
|
|
112
124
|
return action in GIT_ACTIONS
|
|
113
125
|
|
|
114
126
|
|
|
@@ -143,32 +155,62 @@ def _words_from_executable(all_words: list[str]) -> list[str]:
|
|
|
143
155
|
return []
|
|
144
156
|
|
|
145
157
|
|
|
146
|
-
def
|
|
158
|
+
def file_name(path_word: str) -> str:
|
|
159
|
+
"""Return the lowercased last path part of a word, such as ``git.exe`` for ``C:\\Git\\git.exe``.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path_word: A command word that may carry a path.
|
|
163
|
+
"""
|
|
147
164
|
return re.split(PATH_SEPARATOR_PATTERN, path_word)[-1].lower()
|
|
148
165
|
|
|
149
166
|
|
|
150
167
|
def _runs_pull_request_script(all_words: list[str]) -> bool:
|
|
151
|
-
script, _ =
|
|
152
|
-
return script is not None and
|
|
168
|
+
script, _ = next_command_word(all_words, 1, PYTHON_OPTIONS_WITH_VALUE)
|
|
169
|
+
return script is not None and file_name(script) == PULL_REQUEST_SCRIPT_NAME
|
|
153
170
|
|
|
154
171
|
|
|
155
|
-
def
|
|
172
|
+
def executable_word_lists(command: str) -> list[list[str]]:
|
|
173
|
+
"""Return each command's words from its executable on, with shell wrapper arguments read as commands.
|
|
174
|
+
|
|
175
|
+
::
|
|
176
|
+
|
|
177
|
+
"cd repo && git commit -m x" -> [["cd", "repo"], ["git", "commit", "-m", "x"]]
|
|
178
|
+
"env A=1 git push" -> [["git", "push"]]
|
|
179
|
+
"bash -c 'git add x; git commit'" -> ends with [["git", "add", "x"], ["git", "commit"]]
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
command: The shell command text.
|
|
183
|
+
"""
|
|
184
|
+
all_word_lists: list[list[str]] = []
|
|
156
185
|
for each_segment in _command_segments(command):
|
|
157
186
|
all_command_words = _words_from_executable(each_segment)
|
|
158
187
|
if not all_command_words:
|
|
159
188
|
continue
|
|
160
|
-
|
|
189
|
+
all_word_lists.append(all_command_words)
|
|
190
|
+
all_word_lists.extend(_wrapped_word_lists(all_command_words))
|
|
191
|
+
return all_word_lists
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _wrapped_word_lists(all_command_words: list[str]) -> list[list[str]]:
|
|
195
|
+
if file_name(all_command_words[0]) not in ALL_SHELL_WRAPPER_EXECUTABLES:
|
|
196
|
+
return []
|
|
197
|
+
return [
|
|
198
|
+
each_word_list
|
|
199
|
+
for each_argument in all_command_words[1:]
|
|
200
|
+
for each_word_list in executable_word_lists(each_argument)
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _matches_shell_command(command: str) -> bool:
|
|
205
|
+
for each_command_words in executable_word_lists(command):
|
|
206
|
+
executable = file_name(each_command_words[0])
|
|
161
207
|
if executable == PULL_REQUEST_SCRIPT_NAME:
|
|
162
208
|
return True
|
|
163
|
-
if executable in ALL_PYTHON_EXECUTABLES and _runs_pull_request_script(
|
|
164
|
-
return True
|
|
165
|
-
if executable in ALL_SHELL_WRAPPER_EXECUTABLES and any(
|
|
166
|
-
_matches_shell_command(each_argument) for each_argument in all_command_words[1:]
|
|
167
|
-
):
|
|
209
|
+
if executable in ALL_PYTHON_EXECUTABLES and _runs_pull_request_script(each_command_words):
|
|
168
210
|
return True
|
|
169
|
-
if executable in {"git", "git.exe"} and _is_git_action(
|
|
211
|
+
if executable in {"git", "git.exe"} and _is_git_action(each_command_words):
|
|
170
212
|
return True
|
|
171
|
-
if executable in {"gh", "gh.exe"} and _is_gh_action(
|
|
213
|
+
if executable in {"gh", "gh.exe"} and _is_gh_action(each_command_words):
|
|
172
214
|
return True
|
|
173
215
|
return False
|
|
174
216
|
|