claude-dev-env 8.96.8 → 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-pstack-plugin.mjs +31 -4
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.prompt-context.test.mjs +3 -1
- 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/features/session-context.md +4 -0
- package/hooks/hooks.json +25 -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/plugin_session_baseline_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/plugin_session_baseline.py +190 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/session/test_plugin_change_warning.py +216 -0
- package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
- package/hooks/test_context_budget_hooks.py +2 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_plugin_session_baseline.py +98 -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
|
@@ -318,6 +318,23 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
318
318
|
return { host, executable, status: 'installed', warning: null };
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Build the notice that tells the user to restart each session the install reached.
|
|
323
|
+
*
|
|
324
|
+
* A running Claude Code or Codex session keeps the plugin files it loaded at
|
|
325
|
+
* start, so a plugin installed or updated under it stays unavailable there
|
|
326
|
+
* until the session restarts.
|
|
327
|
+
*
|
|
328
|
+
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins the install wrote.
|
|
329
|
+
* @param {ReadonlyArray<string>} allInstalledHosts The hosts the install reached.
|
|
330
|
+
* @returns {string} The restart notice.
|
|
331
|
+
*/
|
|
332
|
+
export function restartNotice(allSpecs, allInstalledHosts) {
|
|
333
|
+
const pluginLabels = allSpecs.map(eachSpec => eachSpec.label).join(', ');
|
|
334
|
+
return `Restart each open ${allInstalledHosts.join(' and ')} session to load ${pluginLabels}. `
|
|
335
|
+
+ 'A running session keeps the plugin version it loaded at start.';
|
|
336
|
+
}
|
|
337
|
+
|
|
321
338
|
/**
|
|
322
339
|
* Install a set of plugins from one marketplace into each host's own home.
|
|
323
340
|
*
|
|
@@ -334,7 +351,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
334
351
|
* `cmd.exe`'s command-not-found exit code on Windows, and both read as
|
|
335
352
|
* skipped. When a host cannot replace a plugin folder because something holds
|
|
336
353
|
* it, the warning names each process that holds it, so the user knows what to
|
|
337
|
-
* close before running the install again.
|
|
354
|
+
* close before running the install again. When any host installs, the step
|
|
355
|
+
* prints one restart notice, because an open session keeps the plugin
|
|
356
|
+
* version it loaded at start.
|
|
338
357
|
*
|
|
339
358
|
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
340
359
|
* @param {object} [options] Install targets.
|
|
@@ -342,8 +361,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
342
361
|
* @param {string} [options.codexHome] The Codex home to install into.
|
|
343
362
|
* @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
|
|
344
363
|
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
345
|
-
* @param {object} [dependencies] Seams for the command runner
|
|
346
|
-
* @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
|
|
364
|
+
* @param {object} [dependencies] Seams for the command runner, the folder locker search, and the line writer.
|
|
365
|
+
* @returns {{status: string, hosts: object[], warning: string|null, restartNotice: string|null}} The outcome per host.
|
|
347
366
|
*/
|
|
348
367
|
export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
|
|
349
368
|
const environment = options.environment ?? process.env;
|
|
@@ -365,7 +384,15 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
|
|
|
365
384
|
const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
|
|
366
385
|
const status = failedCount > 0 ? 'failed' : (installedCount > 0 ? 'installed' : 'skipped');
|
|
367
386
|
const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
|
|
368
|
-
|
|
387
|
+
const allInstalledHosts = hostOutcomes
|
|
388
|
+
.filter(outcome => outcome.status === 'installed')
|
|
389
|
+
.map(outcome => outcome.host);
|
|
390
|
+
const notice = allInstalledHosts.length > 0 ? restartNotice(allSpecs, allInstalledHosts) : null;
|
|
391
|
+
if (notice) {
|
|
392
|
+
const writeLine = dependencies.writeLine ?? (line => console.log(line));
|
|
393
|
+
writeLine(notice);
|
|
394
|
+
}
|
|
395
|
+
return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null, restartNotice: notice };
|
|
369
396
|
}
|
|
370
397
|
|
|
371
398
|
/**
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeOrphanedPluginVersions,
|
|
18
18
|
removeStaleMarketplaceClone,
|
|
19
19
|
removeRetiredMarketplacePlugins,
|
|
20
|
+
restartNotice,
|
|
20
21
|
shouldInstallMarketplacePlugin,
|
|
21
22
|
} from './install-pstack-plugin.mjs';
|
|
22
23
|
|
|
@@ -500,3 +501,25 @@ test('should delete a half-made marketplace clone before the shared marketplace
|
|
|
500
501
|
test('should report no removal when no stale marketplace clone exists', () => {
|
|
501
502
|
assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
|
|
502
503
|
});
|
|
504
|
+
|
|
505
|
+
test('an install prints one restart notice that names each plugin and host', () => {
|
|
506
|
+
const allLoggedLines = [];
|
|
507
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
508
|
+
...recordingRunner(),
|
|
509
|
+
writeLine: line => allLoggedLines.push(line),
|
|
510
|
+
});
|
|
511
|
+
assert.deepEqual(allLoggedLines, [restartNotice([PSTACK_PLUGIN_SPEC], ['claude', 'codex'])]);
|
|
512
|
+
assert.equal(outcome.restartNotice, allLoggedLines[0]);
|
|
513
|
+
assert.match(allLoggedLines[0], /Restart each open claude and codex session to load Pstack\./);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('a run that installs nothing prints no restart notice', () => {
|
|
517
|
+
const allLoggedLines = [];
|
|
518
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
519
|
+
...recordingRunner(() => ({ status: null, error: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) })),
|
|
520
|
+
writeLine: line => allLoggedLines.push(line),
|
|
521
|
+
});
|
|
522
|
+
assert.equal(outcome.status, 'skipped');
|
|
523
|
+
assert.deepEqual(allLoggedLines, []);
|
|
524
|
+
assert.equal(outcome.restartNotice, null);
|
|
525
|
+
});
|
|
@@ -35,11 +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\/open_task_ask_recorder\.py$/);
|
|
44
|
+
assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
|
|
43
45
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
44
46
|
|
|
45
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:
|