claude-dev-env 8.98.8 → 8.98.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +12 -12
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
package/AGENTS.md
CHANGED
|
@@ -4,7 +4,7 @@ Every rule below is always on. A rule with a link keeps its full text in that fi
|
|
|
4
4
|
- **Diffs.** Show the hunk. No prose stand-in.
|
|
5
5
|
- **Verdicts.** One line. Read files first. Quote file:line. Code beats brief. No flip-flops.
|
|
6
6
|
- **Evidence** ([`~/.claude/docs/agents-guidance/evidence.md`](docs/agents-guidance/evidence.md)). Act and state only on what you or your workers saw, ran, or tested. Settle an open fact with a tool before you answer. When no permitted tool reaches it, name the check and who can run it.
|
|
7
|
-
- **Asks** ([`~/.claude/docs/agents-guidance/asks.md`](docs/agents-guidance/asks.md)). Work every ask from the user now, without pause, until it is done with proof you saw or blocked on something only the user can give. No deadlines and no "later". Each ask is a task list item
|
|
7
|
+
- **Asks** ([`~/.claude/docs/agents-guidance/asks.md`](docs/agents-guidance/asks.md)). Work every ask from the user now, without pause, until it is done with proof you saw or blocked on something only the user can give. No deadlines and no "later". Each ask is a task list item, and the user follows your work there. Keep each item's status current as the work moves. The Stop hook `open_tasks_stop_gate.py` holds a turn once when it worked on an ask and left the task list unchanged.
|
|
8
8
|
- **Writes.** Write/Edit only. LF. After: git diff --check, file <path>. ASCII unless the file already isn't.
|
|
9
9
|
- **Edit gate.** Own worktree. Path overlap with another session → stop. Behind origin/main → rebase or report.
|
|
10
10
|
- **Scope.** Min diff. No drive-by reformat. No blanket autofix without tests and revert on break.
|
|
@@ -40,7 +40,7 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
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\/tracking_nudge\.py$/);
|
|
44
44
|
assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
|
|
45
45
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
46
46
|
|
|
@@ -7,11 +7,9 @@
|
|
|
7
7
|
- Work every ask as soon as it arrives, and keep working it until it is done with proof you saw. Asks have no due dates and no deadlines. Every ask is worked now, continuously.
|
|
8
8
|
- Stop working an ask only when it is done with proof, or when it is blocked on something only the user can give: a decision, an approval, an account, or a credential. Put a blocked ask in front of the user in the same reply.
|
|
9
9
|
- "Later", "next time", "follow-up", and "parked" are not outcomes.
|
|
10
|
-
- Record each ask as one item in the session tracker: `TaskCreate`, `TodoWrite`, or the Codex `update_plan` tool.
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
- `Open: <item> - <the step you take next>`
|
|
14
|
-
- An `Open:` line is a report. Take its next step at once.
|
|
10
|
+
- Record each ask as one item in the session tracker: `TaskCreate`, `TodoWrite`, or the Codex `update_plan` tool. The user follows your work there.
|
|
11
|
+
- Keep each item's status current as the work moves: in progress when you start it, completed only after you saw the proof. A long turn updates the list at each step, so the list never lags the work.
|
|
12
|
+
- An item blocked on the user stays open, and the reply says what the user must give.
|
|
15
13
|
|
|
16
14
|
## Asks ledger
|
|
17
15
|
|
|
@@ -25,16 +23,19 @@ When the host keeps an asks ledger with `ledger.py` and `asks_check.py`, record
|
|
|
25
23
|
|
|
26
24
|
The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
|
|
27
25
|
|
|
28
|
-
- Claude Code: `hooks.json`. A block returns the
|
|
26
|
+
- Claude Code: `hooks.json`. A block returns the reason, and the session updates its task list and ends the turn. An install into `~/.claude` also adds the same Stop entry to every profile under `~/.claude-profiles` that has its own `settings.json` and does not already run its own copy of the gate. A profile whose `settings.json` links to `~/.claude/settings.json` already has it. Uninstall removes only that entry. On install, a profile `settings.json` that is not valid JSON is left unchanged and listed under Action needed with its path and parse error, and the rest of the install goes on. On uninstall, such a file that still names the gate stops the uninstall before anything changes, so the manifest stays and a rerun after the fix removes the gate; one that does not name the gate is left as it is.
|
|
29
27
|
- Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
|
|
30
28
|
- 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
29
|
|
|
32
|
-
The gate
|
|
30
|
+
The gate holds a turn once when all three hold:
|
|
33
31
|
|
|
34
|
-
|
|
32
|
+
- The prompt that started the turn asks for work. A question, an acknowledgement such as `thanks!`, and an envelope such as `<task-notification>` do not.
|
|
33
|
+
- The turn ran a work tool: any tool but a tracker call, the session title tool, `AskUserQuestion`, a hearthbot decision card, Codex `request_user_input`, `ToolSearch`, `TaskList`, or `TaskGet`. A turn that only asks the user a question passes.
|
|
34
|
+
- The turn made no tracker call: TaskCreate, TaskUpdate, TodoWrite, the Codex `update_plan`, or the Claude Code desktop `mcp__ccd_turn__update_plan`.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
The gate never reads the reply text. A held turn passes, so the gate adds at most one step to a turn.
|
|
37
|
+
|
|
38
|
+
In Claude Code, `tracking_nudge.py` runs on `UserPromptSubmit`. For a prompt that asks for work, it adds one note that asks for a task list item, so the agent tracks the ask before it starts. The note never blocks.
|
|
38
39
|
|
|
39
40
|
## Cursor
|
|
40
41
|
|
|
@@ -43,7 +44,7 @@ Cursor keeps user rules in its app settings. The installer adds a native `sessio
|
|
|
43
44
|
What has been seen to work, in an interactive `cursor-agent` session (version 2026.10.16, Windows):
|
|
44
45
|
|
|
45
46
|
- The `sessionStart` entry runs, and the session can quote the Asks line. Without it, the session cannot.
|
|
46
|
-
- The `stop` entry runs at the end of each turn. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
|
|
47
|
+
- The `stop` entry runs at the end of each turn. This test ran on the earlier version of the gate, which read `Blocked:` lines in the reply. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
|
|
47
48
|
- A turn cancelled with Esc sends `stop` with status `aborted`, then `error`. The gate stays silent for both.
|
|
48
49
|
- On Windows, each hook command runs in PowerShell: Cursor writes it into a temporary `.ps1` script and starts `pwsh -NonInteractive -File` on it. A command that starts with a quote gets the `&` call operator first, so a quoted Python path with a space runs. A probe from `C:/.../py space/venv/Scripts/python.exe` ran with both a bare and a quoted script path. The Cursor editor (3.24.12) has the same `&` rule in its hook runner.
|
|
49
50
|
|
|
@@ -51,5 +52,5 @@ Limits:
|
|
|
51
52
|
|
|
52
53
|
- `cursor-agent -p` (print mode) does not send `stop` or `beforeSubmitPrompt`, so the gate never runs there.
|
|
53
54
|
- The Cursor editor has not been tested.
|
|
54
|
-
- Cursor also runs the hooks in `~/.claude/settings.json` by default. When Claude Code hooks from this package are installed in the same home, the gate runs twice at each Cursor stop. A stop hook that comes from that file has no follow-up limit in Cursor, so the gate passes
|
|
55
|
+
- Cursor also runs the hooks in `~/.claude/settings.json` by default. When Claude Code hooks from this package are installed in the same home, the gate runs twice at each Cursor stop. A stop hook that comes from that file has no follow-up limit in Cursor, so the gate passes any stop whose `loop_count` is above 0.
|
|
55
56
|
- One entry in the Claude Code format inside `~/.cursor/hooks.json` makes Cursor skip every hook in that file. The installer writes only native entries and converts older ones.
|
|
@@ -4,15 +4,15 @@ Back to the [rules index](../../rules/index.md).
|
|
|
4
4
|
|
|
5
5
|
## In brief
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Keep the title's status current: set `<emoji> <name>` when the status changes, and set the hourglass when a new ask starts work. Use the red flag when the user must act, the construction sign when an outside dependency blocks the work, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
|
|
8
8
|
|
|
9
|
-
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` holds a turn
|
|
9
|
+
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` holds a turn once when the title shows a stale status.
|
|
10
10
|
|
|
11
11
|
**When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
|
|
12
12
|
|
|
13
13
|
## Rule
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Set the title to `<emoji> <name>` when the session's status changes, at any point in a turn. A title that still fits needs no new call. When a new ask arrives while the title shows the red flag or the check mark, set the hourglass before other work, so the user sees the session working. Get the session id from `get_session` with no `session_id` when the tool asks for one.
|
|
16
16
|
|
|
17
17
|
Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it. When the Stop gate holds the turn after the answer went out, call the title tool and close with one short sentence. Leave the answer as it stands.
|
|
18
18
|
|
|
@@ -31,4 +31,15 @@ The name has 25 characters or fewer and names the main change or outcome in conc
|
|
|
31
31
|
|
|
32
32
|
## Enforcement
|
|
33
33
|
|
|
34
|
-
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
34
|
+
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
35
|
+
|
|
36
|
+
`hooks/blocking/session_title_turn_gate.py` is a Stop hook. It holds the end of a turn once when the shown title does not fit the session's state:
|
|
37
|
+
|
|
38
|
+
- No title is set yet.
|
|
39
|
+
- The red flag shows, and the prompt that started the turn asks for work.
|
|
40
|
+
- The check mark shows, and the task list has open items.
|
|
41
|
+
- The hourglass shows, and every task list item is completed or deleted.
|
|
42
|
+
|
|
43
|
+
A turn that set a title passes, because the agent's choice stands. A turn that called no tool other than the title tool passes, and so does a held turn. It applies when the transcript shows the title tool.
|
|
44
|
+
|
|
45
|
+
`hooks/session/tracking_nudge.py` runs when the user sends a prompt. When the prompt asks for work and the title shows the red flag or the check mark, it adds a note that asks for the hourglass before other work. The note never blocks.
|
|
@@ -1,69 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Stop hook that keeps
|
|
3
|
-
|
|
4
|
-
The session's
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
decision, choose, choice, permission, consent, say-so, your call, or prefer.
|
|
37
|
-
Any other ``Blocked:`` line names an input the user
|
|
38
|
-
supplies, such as a secret, a file, or a step on a device, and needs no card::
|
|
39
|
-
|
|
40
|
-
block: Blocked: #ask-1 - your OK to merge the app branch first (no card this turn)
|
|
41
|
-
allow: the same line after an AskUserQuestion call this turn
|
|
42
|
-
allow: Blocked: #ask-1 - the new deploy key secret, pasted into the session
|
|
43
|
-
|
|
44
|
-
The cards are ``AskUserQuestion``, ``mcp__hearthbot__ask_decision``,
|
|
45
|
-
``mcp__hearthbot__request_decision``, and Codex ``request_user_input``. The
|
|
46
|
-
check runs whether or not any item is open. A Cursor payload skips it, because
|
|
47
|
-
the gate reads no card call from a Cursor transcript.
|
|
48
|
-
|
|
49
|
-
A block sends the open items back as the reason, so the model keeps working or
|
|
50
|
-
reports them. Claude Code ends the turn after eight blocks in a row. Codex
|
|
51
|
-
turns the reason into a continuation prompt. A Cursor payload (it carries
|
|
52
|
-
``conversation_id``) also gets the reason as ``followup_message``, which
|
|
53
|
-
Cursor submits as the next user message; Cursor sends no closing reply, so
|
|
54
|
-
the gate reads the last assistant text from the transcript. A Cursor payload
|
|
55
|
-
whose ``status`` is ``aborted`` or ``error`` is allowed, because Cursor would
|
|
56
|
-
submit the follow-up after the user stopped the run, and so is one whose
|
|
57
|
-
``loop_count`` shows five follow-ups already sent. A TodoWrite call with
|
|
58
|
-
``merge`` set updates the earlier items by id, and a Cursor ``CallDynamicTool``
|
|
59
|
-
call that wraps TodoWrite counts as that TodoWrite call.
|
|
2
|
+
"""Stop hook that keeps the task list in step with the work an ask started.
|
|
3
|
+
|
|
4
|
+
The user follows a session's work in its task list. A turn that works on an ask
|
|
5
|
+
leaves a trace there: at least one tracker call after the prompt::
|
|
6
|
+
|
|
7
|
+
TaskCreate / TaskUpdate (Claude Code)
|
|
8
|
+
TodoWrite (Claude Code and Cursor)
|
|
9
|
+
update_plan (Codex)
|
|
10
|
+
mcp__ccd_turn__update_plan (Claude Code desktop plan)
|
|
11
|
+
|
|
12
|
+
The gate holds the turn once when all three hold::
|
|
13
|
+
|
|
14
|
+
the prompt that started the turn asks for work (open_task_asks.is_ask)
|
|
15
|
+
the turn ran a work tool: any tool but a tracker call, the title tool,
|
|
16
|
+
AskUserQuestion, a hearthbot decision card, ToolSearch, TaskList, TaskGet,
|
|
17
|
+
or Codex request_user_input
|
|
18
|
+
the turn made no tracker call
|
|
19
|
+
|
|
20
|
+
block: "Export the APKs" -> Bash, Edit -> stop
|
|
21
|
+
allow: "Export the APKs" -> TaskCreate, Bash, Edit -> stop
|
|
22
|
+
allow: "did the export fail?" -> Bash -> stop
|
|
23
|
+
allow: "Export the APKs" -> reply only -> stop
|
|
24
|
+
allow: "Export the APKs" -> AskUserQuestion -> stop
|
|
25
|
+
|
|
26
|
+
The reply text is never checked. One tracker call releases the turn, and a
|
|
27
|
+
turn the gate already held ends on the next stop, so the gate costs at most one
|
|
28
|
+
extra step per turn.
|
|
29
|
+
|
|
30
|
+
Claude Code marks a held stop with ``stop_hook_active``. Codex turns the reason
|
|
31
|
+
into a continuation prompt. A Cursor payload (it carries ``conversation_id``)
|
|
32
|
+
gets the reason as ``followup_message``, which Cursor submits as the next user
|
|
33
|
+
message; a Cursor stop whose ``loop_count`` shows a follow-up already sent
|
|
34
|
+
passes, and so does one whose ``status`` is ``aborted`` or ``error``. A TodoWrite
|
|
35
|
+
call that a Cursor ``CallDynamicTool`` call wraps counts as a TodoWrite call.
|
|
60
36
|
"""
|
|
61
37
|
|
|
62
38
|
from __future__ import annotations
|
|
63
39
|
|
|
64
40
|
import json
|
|
65
41
|
import os
|
|
66
|
-
import re
|
|
67
42
|
import sys
|
|
68
43
|
from pathlib import Path
|
|
69
44
|
|
|
@@ -76,47 +51,21 @@ if blocking_directory not in sys.path:
|
|
|
76
51
|
|
|
77
52
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
78
53
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
79
|
-
from open_task_asks import
|
|
54
|
+
from open_task_asks import claude_home, is_ask
|
|
80
55
|
from open_tasks_transcript import TaskItem, TranscriptReplay, normalized_text, parsed_object, replayed_transcript
|
|
81
56
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
82
57
|
ALLOW_EXIT_CODE,
|
|
83
|
-
ASK_CLOSE_SUFFIX,
|
|
84
|
-
ASK_ID_PREFIX,
|
|
85
|
-
ASK_LINE_TEMPLATE,
|
|
86
|
-
ASK_REFERENCE_PATTERN,
|
|
87
|
-
ASK_SUBJECT_CHARACTER_LIMIT,
|
|
88
|
-
ASK_SUBJECT_ELLIPSIS,
|
|
89
58
|
BLOCK_DECISION,
|
|
90
|
-
|
|
91
|
-
BLOCK_REASON_SUFFIX,
|
|
92
|
-
BLOCKED_REPORT_KIND,
|
|
59
|
+
BLOCK_REASON,
|
|
93
60
|
CURSOR_COMPLETED_STATUS,
|
|
94
61
|
CURSOR_CONVERSATION_ID_KEY,
|
|
95
|
-
CURSOR_FOLLOWUP_LIMIT,
|
|
96
62
|
CURSOR_FOLLOWUP_MESSAGE_KEY,
|
|
97
63
|
CURSOR_LOOP_COUNT_KEY,
|
|
98
64
|
CURSOR_STATUS_KEY,
|
|
99
|
-
DECISION_BLOCK_REASON_PREFIX,
|
|
100
|
-
DECISION_BLOCK_REASON_SUFFIX,
|
|
101
65
|
DECISION_KEY,
|
|
102
|
-
DECISION_LINE_TEMPLATE,
|
|
103
|
-
DECISION_WORD_PATTERN,
|
|
104
|
-
DONE_LINE_PATTERN,
|
|
105
|
-
DONE_TEXT_GROUP_INDEX,
|
|
106
|
-
DROPPED_LINE_PATTERN,
|
|
107
|
-
DROPPED_QUOTE_GROUP_INDEX,
|
|
108
|
-
DROPPED_TARGET_GROUP_INDEX,
|
|
109
66
|
HOOK_EVENT_NAME,
|
|
110
|
-
INCOMPLETE_STATUSES,
|
|
111
|
-
ITEM_LINE_TEMPLATE,
|
|
112
|
-
LAST_ASSISTANT_MESSAGE_KEY,
|
|
113
|
-
PENDING_STATUS,
|
|
114
67
|
REASON_KEY,
|
|
115
|
-
|
|
116
|
-
REPORT_LINE_PATTERN,
|
|
117
|
-
REPLY_TEXT_SEPARATOR,
|
|
118
|
-
REPORTED_TEXT_GROUP_INDEX,
|
|
119
|
-
SESSION_ID_KEY,
|
|
68
|
+
STOP_HOOK_ACTIVE_KEY,
|
|
120
69
|
TASK_FILE_GLOB,
|
|
121
70
|
TASK_ID_KEY,
|
|
122
71
|
TASK_LIST_ID_ENVIRONMENT_VARIABLE,
|
|
@@ -126,7 +75,6 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
126
75
|
TASKS_DIRECTORY_NAME,
|
|
127
76
|
TRANSCRIPT_PATH_KEY,
|
|
128
77
|
UTF8_ENCODING,
|
|
129
|
-
WORD_BOUNDARY_PATTERN,
|
|
130
78
|
)
|
|
131
79
|
|
|
132
80
|
|
|
@@ -157,80 +105,6 @@ def task_store_items(session_id: object) -> list[TaskItem] | None:
|
|
|
157
105
|
return [each_item for each_item in all_items if each_item is not None]
|
|
158
106
|
|
|
159
107
|
|
|
160
|
-
def _line_matches(line_pattern: str, all_texts: list[str]) -> list[re.Match[str]]:
|
|
161
|
-
all_matches = (
|
|
162
|
-
re.match(line_pattern, each_line, re.IGNORECASE) for each_text in all_texts for each_line in each_text.splitlines()
|
|
163
|
-
)
|
|
164
|
-
return [each_match for each_match in all_matches if each_match]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _reported_texts(reply_text: str) -> list[str]:
|
|
168
|
-
return [
|
|
169
|
-
normalized_text(each_match.group(REPORTED_TEXT_GROUP_INDEX))
|
|
170
|
-
for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
|
|
171
|
-
]
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def is_reported(task_item: TaskItem, reply_text: str) -> bool:
|
|
175
|
-
"""Return True when a Blocked: or Open: line of the reply names the item."""
|
|
176
|
-
subject_text = normalized_text(task_item.subject)
|
|
177
|
-
number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
|
|
178
|
-
return any(
|
|
179
|
-
(subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
|
|
180
|
-
for each_text in _reported_texts(reply_text)
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def _is_quoted_after(quote_text: str, all_later_prompts: list[RecordedPrompt]) -> bool:
|
|
185
|
-
normalized_quote = normalized_text(quote_text)
|
|
186
|
-
return bool(normalized_quote) and any(
|
|
187
|
-
normalized_quote in normalized_text(each_prompt.text) for each_prompt in all_later_prompts
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _ask_subject(prompt_text: str) -> str:
|
|
192
|
-
first_line = prompt_text.strip().splitlines()[0]
|
|
193
|
-
if len(first_line) <= ASK_SUBJECT_CHARACTER_LIMIT:
|
|
194
|
-
return first_line
|
|
195
|
-
return first_line[:ASK_SUBJECT_CHARACTER_LIMIT] + ASK_SUBJECT_ELLIPSIS
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def open_ask_items(all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> list[TaskItem]:
|
|
199
|
-
"""Return each recorded ask that no ``Done:`` line and no quoted ``Dropped:`` line closes."""
|
|
200
|
-
all_done_ids = {
|
|
201
|
-
each_reference
|
|
202
|
-
for each_match in _line_matches(DONE_LINE_PATTERN, all_closing_texts)
|
|
203
|
-
for each_reference in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DONE_TEXT_GROUP_INDEX))
|
|
204
|
-
}
|
|
205
|
-
all_drop_matches = _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
|
|
206
|
-
all_open_asks = []
|
|
207
|
-
for each_position, each_prompt in enumerate(all_prompts):
|
|
208
|
-
if not each_prompt.ask_id or each_prompt.ask_id in all_done_ids:
|
|
209
|
-
continue
|
|
210
|
-
is_dropped = any(
|
|
211
|
-
each_prompt.ask_id in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DROPPED_TARGET_GROUP_INDEX))
|
|
212
|
-
and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_prompts[each_position + 1 :])
|
|
213
|
-
for each_match in all_drop_matches
|
|
214
|
-
)
|
|
215
|
-
if not is_dropped:
|
|
216
|
-
all_open_asks.append(TaskItem(each_prompt.ask_id, _ask_subject(each_prompt.text), PENDING_STATUS))
|
|
217
|
-
return all_open_asks
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def is_dropped_carried_item(task_item: TaskItem, all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> bool:
|
|
221
|
-
"""Return True when a ``Dropped:`` line names the carried item and quotes a prompt sent after its list."""
|
|
222
|
-
listed_at = task_item.listed_at
|
|
223
|
-
subject_text = normalized_text(task_item.subject)
|
|
224
|
-
if not task_item.is_carried or listed_at is None or not subject_text:
|
|
225
|
-
return False
|
|
226
|
-
all_later_prompts = [each_prompt for each_prompt in all_prompts if each_prompt.recorded_at > listed_at]
|
|
227
|
-
return any(
|
|
228
|
-
subject_text in normalized_text(each_match.group(DROPPED_TARGET_GROUP_INDEX))
|
|
229
|
-
and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_later_prompts)
|
|
230
|
-
for each_match in _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
234
108
|
def _subject_key(task_item: TaskItem) -> str:
|
|
235
109
|
return normalized_text(task_item.subject) or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)
|
|
236
110
|
|
|
@@ -249,117 +123,44 @@ def deduplicated_by_subject(all_sources: list[list[TaskItem]]) -> list[TaskItem]
|
|
|
249
123
|
return all_kept_items
|
|
250
124
|
|
|
251
125
|
|
|
252
|
-
def
|
|
253
|
-
"""Return the
|
|
254
|
-
|
|
255
|
-
return reply_value if isinstance(reply_value, str) else replay.last_assistant_text
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def uncarded_decision_lines(reply_text: str, replay: TranscriptReplay) -> list[str]:
|
|
259
|
-
"""Return each ``Blocked:`` line that asks for a decision when no question card ran this turn."""
|
|
260
|
-
if replay.has_turn_decision_card:
|
|
261
|
-
return []
|
|
262
|
-
return [
|
|
263
|
-
each_match.group(0).strip()
|
|
264
|
-
for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
|
|
265
|
-
if each_match.group(REPORT_KIND_GROUP_INDEX).lower() == BLOCKED_REPORT_KIND
|
|
266
|
-
and re.search(DECISION_WORD_PATTERN, each_match.group(REPORTED_TEXT_GROUP_INDEX), re.IGNORECASE)
|
|
267
|
-
]
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def unreported_open_items(all_hook_input_fields: dict[str, object], replay: TranscriptReplay) -> list[TaskItem]:
|
|
271
|
-
"""Return the open task items and asks the closing reply does not report."""
|
|
272
|
-
session_id = all_hook_input_fields.get(SESSION_ID_KEY)
|
|
273
|
-
reply_text = closing_reply_text(all_hook_input_fields, replay)
|
|
274
|
-
all_closing_texts = [*replay.all_assistant_texts, reply_text]
|
|
275
|
-
all_prompts = recorded_prompts(session_id)
|
|
276
|
-
all_replayed_items = [
|
|
277
|
-
each_item
|
|
278
|
-
for each_item in replay.items() or []
|
|
279
|
-
if not is_dropped_carried_item(each_item, all_prompts, all_closing_texts)
|
|
280
|
-
]
|
|
281
|
-
all_items = [
|
|
282
|
-
*deduplicated_by_subject([task_store_items(session_id) or [], all_replayed_items]),
|
|
283
|
-
*open_ask_items(all_prompts, all_closing_texts),
|
|
284
|
-
]
|
|
285
|
-
return [
|
|
286
|
-
each_item
|
|
287
|
-
for each_item in all_items
|
|
288
|
-
if each_item.status in INCOMPLETE_STATUSES and not is_reported(each_item, reply_text)
|
|
289
|
-
]
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def _item_line(task_item: TaskItem) -> str:
|
|
293
|
-
if task_item.item_id.startswith(ASK_ID_PREFIX):
|
|
294
|
-
return ASK_LINE_TEMPLATE.format(ask_id=task_item.item_id, subject=task_item.subject)
|
|
295
|
-
return ITEM_LINE_TEMPLATE.format(subject=task_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id))
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def decision_block_reason_for(all_decision_lines: list[str]) -> str:
|
|
299
|
-
"""Return the block reason that quotes each decision line and names the question card."""
|
|
300
|
-
all_quoted_lines = "".join(DECISION_LINE_TEMPLATE.format(line=each_line) for each_line in all_decision_lines)
|
|
301
|
-
return DECISION_BLOCK_REASON_PREFIX + all_quoted_lines + DECISION_BLOCK_REASON_SUFFIX
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def block_reason_for(all_open_items: list[TaskItem]) -> str:
|
|
305
|
-
"""Return the block reason that lists each open item, and how to close an ask when one is open."""
|
|
306
|
-
has_open_ask = any(each_item.item_id.startswith(ASK_ID_PREFIX) for each_item in all_open_items)
|
|
307
|
-
all_item_lines = "".join(_item_line(each_item) for each_item in all_open_items)
|
|
308
|
-
return BLOCK_REASON_PREFIX + all_item_lines + BLOCK_REASON_SUFFIX + (ASK_CLOSE_SUFFIX if has_open_ask else "")
|
|
126
|
+
def session_task_items(session_id: object, replay: TranscriptReplay) -> list[TaskItem]:
|
|
127
|
+
"""Return the session's task list: the task store first, then transcript items it does not hold."""
|
|
128
|
+
return deduplicated_by_subject([task_store_items(session_id) or [], replay.items() or []])
|
|
309
129
|
|
|
310
130
|
|
|
311
|
-
def
|
|
312
|
-
"""Return the
|
|
313
|
-
|
|
314
|
-
if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
315
|
-
block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
|
|
316
|
-
return block_output
|
|
131
|
+
def is_untracked_work_turn(replay: TranscriptReplay) -> bool:
|
|
132
|
+
"""Return True when the turn worked on an ask and made no tracker call."""
|
|
133
|
+
return replay.has_turn_work_tool and not replay.has_turn_tracker_write and is_ask(replay.turn_prompt_text)
|
|
317
134
|
|
|
318
135
|
|
|
319
|
-
def
|
|
320
|
-
"""Return True
|
|
136
|
+
def is_held_stop(all_hook_input_fields: dict[str, object]) -> bool:
|
|
137
|
+
"""Return True when a stop hook already held this turn once, or the Cursor run did not complete."""
|
|
138
|
+
if all_hook_input_fields.get(STOP_HOOK_ACTIVE_KEY) is True:
|
|
139
|
+
return True
|
|
321
140
|
if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
322
141
|
return False
|
|
323
142
|
stop_status = all_hook_input_fields.get(CURSOR_STATUS_KEY)
|
|
324
|
-
|
|
325
|
-
|
|
143
|
+
loop_count = all_hook_input_fields.get(CURSOR_LOOP_COUNT_KEY)
|
|
144
|
+
has_sent_followup = isinstance(loop_count, int) and loop_count > 0
|
|
145
|
+
return has_sent_followup or (stop_status is not None and stop_status != CURSOR_COMPLETED_STATUS)
|
|
326
146
|
|
|
327
|
-
def has_spent_cursor_followups(all_hook_input_fields: dict[str, object]) -> bool:
|
|
328
|
-
"""Return True for a Cursor stop that already sent the follow-up limit.
|
|
329
147
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return isinstance(loop_count, int) and loop_count >= CURSOR_FOLLOWUP_LIMIT
|
|
148
|
+
def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
|
|
149
|
+
"""Return the block output; a Cursor payload also gets the reason as ``followup_message``."""
|
|
150
|
+
block_output = {DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}
|
|
151
|
+
if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
152
|
+
block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
|
|
153
|
+
return block_output
|
|
337
154
|
|
|
338
155
|
|
|
339
156
|
def main() -> int:
|
|
340
157
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
341
|
-
if hook_input is None or
|
|
158
|
+
if hook_input is None or is_held_stop(hook_input):
|
|
342
159
|
return ALLOW_EXIT_CODE
|
|
343
|
-
|
|
344
|
-
all_open_items = unreported_open_items(hook_input, replay)
|
|
345
|
-
all_decision_lines = (
|
|
346
|
-
[] if hook_input.get(CURSOR_CONVERSATION_ID_KEY) else uncarded_decision_lines(closing_reply_text(hook_input, replay), replay)
|
|
347
|
-
)
|
|
348
|
-
if not all_open_items and not all_decision_lines:
|
|
160
|
+
if not is_untracked_work_turn(replayed_transcript(hook_input.get(TRANSCRIPT_PATH_KEY))):
|
|
349
161
|
return ALLOW_EXIT_CODE
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
*([decision_block_reason_for(all_decision_lines)] if all_decision_lines else []),
|
|
353
|
-
]
|
|
354
|
-
block_reason = REPLY_TEXT_SEPARATOR.join(all_reason_parts)
|
|
355
|
-
reply_value = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
356
|
-
log_hook_block(
|
|
357
|
-
Path(__file__).name,
|
|
358
|
-
HOOK_EVENT_NAME,
|
|
359
|
-
block_reason,
|
|
360
|
-
offending_input_preview=reply_value if isinstance(reply_value, str) else None,
|
|
361
|
-
)
|
|
362
|
-
sys.stdout.write(json.dumps(block_output_for(hook_input, block_reason)))
|
|
162
|
+
log_hook_block(Path(__file__).name, HOOK_EVENT_NAME, BLOCK_REASON)
|
|
163
|
+
sys.stdout.write(json.dumps(block_output_for(hook_input, BLOCK_REASON)))
|
|
363
164
|
return ALLOW_EXIT_CODE
|
|
364
165
|
|
|
365
166
|
|