claude-dev-env 8.106.1 → 8.106.3
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-constants.mjs +0 -5
- package/bin/install.cursor-rules.test.mjs +11 -6
- package/bin/install.mjs +3 -2
- package/bin/install.test.mjs +1 -0
- package/docs/codex-compatibility.md +2 -2
- package/hooks/blocking/reply_length_gate.py +229 -106
- package/hooks/blocking/test_reply_length_gate.py +77 -0
- package/hooks/features/README.md +1 -1
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/features/lifecycle-cleanup.md +2 -2
- package/hooks/features/observability.md +1 -5
- package/hooks/features/session-context.md +4 -4
- package/hooks/hooks.json +1 -16
- package/hooks/hooks_constants/hook_specific_output_keys.py +2 -0
- package/hooks/hooks_constants/nested_project_hooks_constants.py +3 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -23
- package/hooks/hooks_constants/test_hook_specific_output_keys.py +1 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +1 -0
- package/hooks/lifecycle/nested_project_hooks.py +52 -12
- package/hooks/lifecycle/test_nested_project_hooks.py +81 -0
- package/hooks/session/skill_loaded_reminder.py +2 -52
- package/hooks/session/test_skill_loaded_reminder.py +22 -85
- package/hooks/test_visual_reply_rules.py +12 -0
- package/hooks/visual_reply_rules.py +29 -12
- package/package.json +1 -1
- package/hooks/workflow/investigation_tracker_reset.py +0 -48
- package/hooks/workflow/test_investigation_tracker_reset.py +0 -90
|
@@ -4,11 +4,11 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
|
-
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
7
|
+
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. One refusal names every broken rule and every offending sentence, numbered, and ends with "Fix every item above in your next write." With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
8
|
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
|
|
9
9
|
- `blocking/open_tasks_stop_gate.py` holds the end of a turn once when the prompt that started it asks for work, the turn ran a work tool, and the turn made no tracker call: TaskCreate, TaskUpdate, TodoWrite, the Codex `update_plan`, or the desktop `mcp__ccd_turn__update_plan`. A work tool is any tool but a tracker call, the session title tool, `AskUserQuestion`, a hearthbot decision card, Codex `request_user_input`, `ToolSearch`, `TaskList`, or `TaskGet`. It never reads the reply text. `open_task_asks.is_ask` decides whether a prompt asks for work: a question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, a prompt of only `<system-reminder>` blocks, and an envelope such as `<agent-message>` or `<task-notification>` do not. `blocking/open_tasks_transcript.py` replays the transcript for it: the turn's prompt, its tool calls, and the task list. `session_task_items` merges the task store with the transcript items whose subject the store does not hold, for the session title gate.
|
|
10
10
|
- `session/tracking_nudge.py` adds one `additionalContext` note to a prompt that asks for work. The note asks for a task list item. When the title shows the red flag or the check mark, it also asks for the hourglass before other work. It never blocks.
|
|
11
|
-
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies
|
|
11
|
+
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies every claim, numbered in one refusal, about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
12
12
|
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|
|
13
13
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
14
14
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
@@ -63,7 +63,7 @@ Preconditions:
|
|
|
63
63
|
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
64
64
|
- **Open tasks.** Input is a transcript whose last prompt asks for work and whose turn ran a Bash call and no tracker call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes one `block` decision; the same turn with a TaskCreate, TaskUpdate, TodoWrite, or desktop plan call passes. A question, an acknowledgement, a `<task-notification>` envelope, a tool-free turn, and a held stop pass. The same file covers Codex and Cursor payloads.
|
|
65
65
|
- **Ask nudge.** Input is a `UserPromptSubmit` payload with a work prompt, a question, and a `<task-notification>` envelope, after a transcript whose title shows the red flag. Run `python -m pytest packages/claude-dev-env/hooks/session/test_tracking_nudge.py -q`. The adjacent test observes a task list note and a title note for the work prompt, and empty stdout for the other two.
|
|
66
|
-
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]:
|
|
66
|
+
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Facts with no source: 1. ...`, numbering every failing claim. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
|
|
67
67
|
- **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
|
|
68
68
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
69
69
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
@@ -4,7 +4,7 @@ This family runs nested checkout hooks, refreshes a worktree base, and clears st
|
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
|
-
- `lifecycle/nested_project_hooks.py` runs hook registrations from checkouts nested one level below a session directory.
|
|
7
|
+
- `lifecycle/nested_project_hooks.py` runs hook registrations from checkouts nested one level below a session directory. When several nested hooks refuse a tool call, the one refusal it returns carries every refusing hook's reason.
|
|
8
8
|
- `lifecycle/enter_worktree_origin_prefetch.py` fetches the default branch before a fresh worktree is created.
|
|
9
9
|
- `lifecycle/session_end_cleanup.py` removes old context cache files, temporary files, and transcript backups at session end.
|
|
10
10
|
- `session/plugin_data_dir_cleanup.py` removes empty plugin data directories at session start.
|
|
@@ -34,7 +34,7 @@ Preconditions:
|
|
|
34
34
|
|
|
35
35
|
## Gotchas
|
|
36
36
|
|
|
37
|
-
- The nested checkout runner forwards child denials and asks. It drops child allow decisions so a child cannot widen permission.
|
|
37
|
+
- The nested checkout runner forwards child denials and asks, a denial ahead of an ask. It drops child allow decisions so a child cannot widen permission.
|
|
38
38
|
- Worktree prefetch applies to fresh creation and exits zero after a failed fetch.
|
|
39
39
|
- Session-end cleanup has no adjacent behavior test. Its direct probe calls `purge_old_entries` with an isolated directory.
|
|
40
40
|
- The session edit tracker survives a compact or resume. Cleanup targets the current session only.
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
# Observability
|
|
2
2
|
|
|
3
|
-
This family records instruction loads and file edits for later checks.
|
|
3
|
+
This family records instruction loads and file edits for later checks.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `observability/instructions_loaded_logger.py` writes selected instruction-load fields to a JSONL log.
|
|
8
8
|
- `observability/session_file_edit_tracker.py` records edited file paths in a per-session tracker for the stage gate.
|
|
9
|
-
- `workflow/investigation_tracker_reset.py` clears the investigation tracker after delegation.
|
|
10
9
|
|
|
11
10
|
## When it fires
|
|
12
11
|
|
|
13
12
|
- `observability/instructions_loaded_logger.py` runs on `InstructionsLoaded`, matcher `session_start|nested_traversal|path_glob_match|include|compact`, timeout `10` seconds in `hooks.json`.
|
|
14
13
|
- `observability/session_file_edit_tracker.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `30` seconds in `hooks.json`.
|
|
15
|
-
- `workflow/investigation_tracker_reset.py` runs on `PostToolUse`, matcher `Agent|Task|TeamCreate`, timeout `30` seconds in `hooks.json`.
|
|
16
14
|
|
|
17
15
|
## Proving it
|
|
18
16
|
|
|
@@ -22,10 +20,8 @@ Preconditions:
|
|
|
22
20
|
|
|
23
21
|
- **Instruction log.** Input is an `InstructionsLoaded` payload with a file path and load reason. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_instructions_loaded_logger.py -q`. The adjacent test observes one JSONL record with the selected fields.
|
|
24
22
|
- **Edit tracker.** Input is a Write payload with a file path and session ID. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_session_file_edit_tracker.py -q`. The adjacent test observes the resolved path in that session's tracker.
|
|
25
|
-
- **Investigation reset.** Input is a completed Agent, Task, or TeamCreate call. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_investigation_tracker_reset.py -q`. The adjacent test observes removal of the tracker file.
|
|
26
23
|
|
|
27
24
|
## Gotchas
|
|
28
25
|
|
|
29
26
|
- The instruction logger keeps only selected payload fields. It exits zero if logging fails.
|
|
30
27
|
- The edit tracker never blocks a tool call. Its per-session file is read later by the stage gate.
|
|
31
|
-
- The investigation reset applies to delegation tools. Its companion investigation gate lives outside this Claude hook registration map.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Session context
|
|
2
2
|
|
|
3
|
-
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt,
|
|
3
|
+
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt, starts a workflow helper, starts an artifact page, or hits an auto mode denial. The startup scripts leave the session quiet when their opt-in conditions fail.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
17
17
|
|
|
18
18
|
## When it fires
|
|
19
19
|
|
|
20
|
-
- `session/skill_loaded_reminder.py` runs on `
|
|
20
|
+
- `session/skill_loaded_reminder.py` runs on `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
21
21
|
- `session/compaction_recall_prompt.py` runs on `SessionStart` matcher `compact` at `10` seconds in `hooks.json`. The installer also writes it to the Codex `hooks.json`, the only `SessionStart` hook Codex gets.
|
|
22
22
|
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
23
23
|
- `session/plugin_session_baseline_writer.py` runs on `SessionStart`, matcher empty, at `10` seconds, so it sees every source. `session/plugin_change_warning.py` runs on `UserPromptSubmit`, matcher empty, at `10` seconds in `hooks.json`.
|
|
@@ -30,7 +30,7 @@ Preconditions:
|
|
|
30
30
|
|
|
31
31
|
- Run each command from the repository root with Python and pytest available. Enable opt-in flags only inside the tests.
|
|
32
32
|
|
|
33
|
-
- **Skill reminder.** Input is a
|
|
33
|
+
- **Skill reminder.** Input is a compaction, a user turn, or a workflow helper start. Run `python -m pytest packages/claude-dev-env/hooks/session/test_skill_loaded_reminder.py -q`. The adjacent test observes the reminder, silence on a spawn payload, and no `PreToolUse` registration in `hooks.json`.
|
|
34
34
|
- **Compaction recall.** Input is a Claude Code transcript or a Codex rollout with typed, meta, sidechain, peer, and harness entries across a compaction. Run `python -m pytest packages/claude-dev-env/hooks/session/test_compaction_user_messages.py packages/claude-dev-env/hooks/session/test_compaction_trim.py packages/claude-dev-env/hooks/session/test_compaction_recall_prompt.py packages/claude-dev-env/hooks/session/test_compaction_trim_eval.py -q`. The tests observe only typed messages, a stable result across a second compaction, the trim rules, the recall budget, and the trimmed lines in the hook's `additionalContext`. `python packages/claude-dev-env/hooks/session/compaction_trim_eval.py` trims each fixture transcript with planted facts, prints the share of facts that survive, the reversals marked, and the compression ratio, and exits 1 on any loss.
|
|
35
35
|
- **Task tracking.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_task_tool_prompt.py -q`. The adjacent test observes a `SessionStart` context directive that names the task tool.
|
|
36
36
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
@@ -44,7 +44,7 @@ Preconditions:
|
|
|
44
44
|
|
|
45
45
|
- The recall keeps the first message and the newest that fit in 6,000 characters, under the 2,500 token preview limit Codex applies to hook context. A message over 1,500 characters is cut, and the line says how much was left out.
|
|
46
46
|
- The overruled mark comes from cue phrases such as `instead` or `change of plan` and shared words. A reversal with no cue phrase gets no mark, and the later line still overrides by its place. A cue in a message that agrees with an earlier one, such as `dont use haiku` after `use Python trim instead of Haiku`, marks that earlier line too, so the context tells the agent to follow the later message where the two differ.
|
|
47
|
-
- `session/skill_loaded_reminder.py` has
|
|
47
|
+
- `session/skill_loaded_reminder.py` has three registration points. An `Agent` or `Task` helper gets no poteto-mode line from it; a brief that needs the skill names it, or spawns `pstack:poteto-agent`. Its tests cover the compaction restore, the workflow helper reminder, silence on a user turn while the skill stays loaded, silence on a spawn payload, and no `PreToolUse` registration in `hooks.json`.
|
|
48
48
|
- The advisor prompt depends on settings and an environment disable flag. The two starter scripts each use separate opt-in checks.
|
|
49
49
|
- The issue tracker starter requires the checkout to appear in the project path registry.
|
|
50
50
|
- `advisory/auto_mode_denial_quick_fix.py` lives under `advisory/` but belongs here because its only output is agent context and a one-line user message.
|
package/hooks/hooks.json
CHANGED
|
@@ -5,11 +5,6 @@
|
|
|
5
5
|
{
|
|
6
6
|
"matcher": "Agent|Task",
|
|
7
7
|
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
},
|
|
13
8
|
{
|
|
14
9
|
"type": "command",
|
|
15
10
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
|
|
@@ -187,7 +182,7 @@
|
|
|
187
182
|
"hooks": [
|
|
188
183
|
{
|
|
189
184
|
"type": "prompt",
|
|
190
|
-
"prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"
|
|
185
|
+
"prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Facts with no source: 1. <first failing claim> 2. <second failing claim> ... For each, put the file:line, the quoted output, or the link that shows it in the message, run the check first, or leave the claim out. Fix every item above in your next write.\"} List every failing claim, numbered in message order, never only the first.",
|
|
191
186
|
"timeout": 30,
|
|
192
187
|
"continueOnBlock": true,
|
|
193
188
|
"statusMessage": "claude-dev-env: checking each claim for its source"
|
|
@@ -526,16 +521,6 @@
|
|
|
526
521
|
}
|
|
527
522
|
]
|
|
528
523
|
},
|
|
529
|
-
{
|
|
530
|
-
"matcher": "Agent|Task|TeamCreate",
|
|
531
|
-
"hooks": [
|
|
532
|
-
{
|
|
533
|
-
"type": "command",
|
|
534
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
|
|
535
|
-
"timeout": 30
|
|
536
|
-
}
|
|
537
|
-
]
|
|
538
|
-
},
|
|
539
524
|
{
|
|
540
525
|
"matcher": "Bash|PowerShell",
|
|
541
526
|
"hooks": [
|
|
@@ -6,10 +6,12 @@ __all__ = [
|
|
|
6
6
|
"HOOK_SPECIFIC_OUTPUT_KEY",
|
|
7
7
|
"HOOK_EVENT_NAME_KEY",
|
|
8
8
|
"PERMISSION_DECISION_KEY",
|
|
9
|
+
"PERMISSION_DECISION_REASON_KEY",
|
|
9
10
|
"UPDATED_INPUT_KEY",
|
|
10
11
|
]
|
|
11
12
|
|
|
12
13
|
HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
|
|
13
14
|
HOOK_EVENT_NAME_KEY: str = "hookEventName"
|
|
14
15
|
PERMISSION_DECISION_KEY: str = "permissionDecision"
|
|
16
|
+
PERMISSION_DECISION_REASON_KEY: str = "permissionDecisionReason"
|
|
15
17
|
UPDATED_INPUT_KEY: str = "updatedInput"
|
|
@@ -31,6 +31,8 @@ ALL_SHELL_PROGRAM_NAMES: tuple[str, ...] = ("bash", "sh")
|
|
|
31
31
|
SHELL_COMMAND_FLAG: str = "-c"
|
|
32
32
|
|
|
33
33
|
BLOCKING_EXIT_CODE: int = 2
|
|
34
|
-
|
|
34
|
+
DENY_PERMISSION_DECISION: str = "deny"
|
|
35
|
+
ASK_PERMISSION_DECISION: str = "ask"
|
|
36
|
+
ALL_FORWARDED_PERMISSION_DECISIONS: tuple[str, ...] = (DENY_PERMISSION_DECISION, ASK_PERMISSION_DECISION)
|
|
35
37
|
ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
|
|
36
38
|
CONTEXT_SECTION_SEPARATOR: str = "\n\n"
|
|
@@ -44,6 +44,8 @@ SENTENCE_PREVIEW_WORD_COUNT = 6
|
|
|
44
44
|
WORD_SEPARATOR = " "
|
|
45
45
|
SENTENCE_PREVIEW_SUFFIX = "..."
|
|
46
46
|
RETRY_INSTRUCTION = " Cut the text and resend the call."
|
|
47
|
+
FIX_EVERY_ITEM_INSTRUCTION = " Fix every item above in your next write."
|
|
48
|
+
FINDING_SEPARATOR = "\n"
|
|
47
49
|
TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
|
|
48
50
|
CARD_CONTEXT_TOO_LONG_MESSAGE = (
|
|
49
51
|
"Card context too long: {sentence_count} sentences, limit {sentence_limit}."
|
|
@@ -3,52 +3,29 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
-
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
6
|
"COMPACTION_REMINDER",
|
|
8
7
|
"COMPACTION_SOURCE",
|
|
9
8
|
"NOT_LOADED_REMINDER",
|
|
10
9
|
"ALL_POTETO_MODE_SKILL_NAMES",
|
|
11
|
-
"PRE_TOOL_USE_EVENT_NAME",
|
|
12
|
-
"PROMPT_SEPARATOR",
|
|
13
10
|
"SESSION_START_EVENT_NAME",
|
|
14
11
|
"ALL_SLASH_COMMAND_MARKERS",
|
|
15
12
|
"SUBAGENT_START_EVENT_NAME",
|
|
16
|
-
"CLAUDE_SUBAGENT_PROMPT_PREFIX",
|
|
17
|
-
"CODEX_SUBAGENT_PROMPT_PREFIX",
|
|
18
|
-
"ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME",
|
|
19
13
|
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
20
14
|
"WORKFLOW_SUBAGENT_TYPE",
|
|
21
15
|
]
|
|
22
16
|
|
|
23
|
-
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
24
17
|
SESSION_START_EVENT_NAME = "SessionStart"
|
|
25
18
|
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
26
19
|
COMPACTION_SOURCE = "compact"
|
|
27
20
|
SUBAGENT_START_EVENT_NAME = "SubagentStart"
|
|
28
21
|
WORKFLOW_SUBAGENT_TYPE = "workflow-subagent"
|
|
29
22
|
|
|
30
|
-
ALL_SELF_LOADING_SUBAGENT_TYPES = frozenset({"pstack:poteto-agent", "poteto-agent"})
|
|
31
|
-
|
|
32
23
|
ALL_POTETO_MODE_SKILL_NAMES = ("poteto-mode", "pstack:poteto-mode")
|
|
33
24
|
SKILL_TOOL_NAME = "Skill"
|
|
34
25
|
ALL_SLASH_COMMAND_MARKERS = (
|
|
35
26
|
"<command-name>/poteto-mode</command-name>",
|
|
36
27
|
"<command-name>/pstack:poteto-mode</command-name>",
|
|
37
28
|
)
|
|
38
|
-
PROMPT_SEPARATOR = "\n\n"
|
|
39
|
-
|
|
40
|
-
CLAUDE_SUBAGENT_PROMPT_PREFIX = (
|
|
41
|
-
"Before any other work, invoke the poteto-mode skill with the Skill tool. "
|
|
42
|
-
"Every prompt you write for a subagent opens with the same instruction."
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
CODEX_SUBAGENT_PROMPT_PREFIX = "$poteto-mode"
|
|
46
|
-
|
|
47
|
-
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME = {
|
|
48
|
-
"Agent": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
|
|
49
|
-
"Task": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
|
|
50
|
-
"spawn_agent": ("message", CODEX_SUBAGENT_PROMPT_PREFIX),
|
|
51
|
-
}
|
|
52
29
|
|
|
53
30
|
NOT_LOADED_REMINDER = (
|
|
54
31
|
"The poteto-mode skill is not loaded in this context. "
|
|
@@ -13,5 +13,6 @@ def test_keys_match_the_harness_hook_specific_output_field_names() -> None:
|
|
|
13
13
|
"HOOK_SPECIFIC_OUTPUT_KEY": "hookSpecificOutput",
|
|
14
14
|
"HOOK_EVENT_NAME_KEY": "hookEventName",
|
|
15
15
|
"PERMISSION_DECISION_KEY": "permissionDecision",
|
|
16
|
+
"PERMISSION_DECISION_REASON_KEY": "permissionDecisionReason",
|
|
16
17
|
"UPDATED_INPUT_KEY": "updatedInput",
|
|
17
18
|
}
|
|
@@ -66,6 +66,7 @@ RESULT_TEXT_SEPARATOR = "\n"
|
|
|
66
66
|
|
|
67
67
|
ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
|
|
68
68
|
TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
|
|
69
|
+
VIOLATION_LINE_SEPARATOR = "\n"
|
|
69
70
|
NO_VISUAL_MESSAGE = "This reply has {sentence_count} sentences and no visual this turn. {reminder}"
|
|
70
71
|
SECOND_REPLY_MESSAGE = "A reply already went out this turn. {reminder}"
|
|
71
72
|
NOT_FLOWCHART_MESSAGE = "This widget is not a flowchart. {reminder}"
|
|
@@ -13,7 +13,8 @@ hooks, and tool guards then never run. This hook runs them::
|
|
|
13
13
|
|
|
14
14
|
Each child hook receives the same stdin payload, runs inside its checkout, and
|
|
15
15
|
keeps its own matcher and timeout. A child ``exit 2`` blocks the tool call and a
|
|
16
|
-
child ``deny
|
|
16
|
+
child ``deny``, else a child ``ask``, passes through, a ``deny`` carrying every
|
|
17
|
+
refusing child's reason; a child ``allow`` is dropped,
|
|
17
18
|
so no child widens permissions for the session. The ``additionalContext`` of
|
|
18
19
|
every child, at SessionStart, UserPromptSubmit, and PreToolUse, joins into one
|
|
19
20
|
``additionalContext``.
|
|
@@ -48,6 +49,7 @@ from hooks_constants.nested_project_hooks_constants import (
|
|
|
48
49
|
COMMAND_KEY,
|
|
49
50
|
CONTEXT_SECTION_SEPARATOR,
|
|
50
51
|
DEFAULT_HOOK_TIMEOUT_SECONDS,
|
|
52
|
+
DENY_PERMISSION_DECISION,
|
|
51
53
|
HOOK_EVENT_NAME_KEY,
|
|
52
54
|
HOOK_TYPE_KEY,
|
|
53
55
|
HOOKS_KEY,
|
|
@@ -67,6 +69,7 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
67
69
|
HOOK_EVENT_NAME_KEY as HOOK_EVENT_NAME_OUTPUT_KEY,
|
|
68
70
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
69
71
|
PERMISSION_DECISION_KEY,
|
|
72
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
70
73
|
)
|
|
71
74
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
72
75
|
|
|
@@ -276,41 +279,78 @@ def _permission_decision(all_output_fields: dict[str, object]) -> object:
|
|
|
276
279
|
return specific_output.get(PERMISSION_DECISION_KEY)
|
|
277
280
|
|
|
278
281
|
|
|
282
|
+
def _deny_reason(all_output_fields: dict[str, object]) -> str:
|
|
283
|
+
if _permission_decision(all_output_fields) != DENY_PERMISSION_DECISION:
|
|
284
|
+
return ""
|
|
285
|
+
specific_output = all_output_fields.get(HOOK_SPECIFIC_OUTPUT_KEY)
|
|
286
|
+
reason = (
|
|
287
|
+
specific_output.get(PERMISSION_DECISION_REASON_KEY)
|
|
288
|
+
if isinstance(specific_output, dict)
|
|
289
|
+
else None
|
|
290
|
+
)
|
|
291
|
+
return reason.strip() if isinstance(reason, str) else ""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _joined_reasons(all_reasons: Iterable[str]) -> str:
|
|
295
|
+
return CONTEXT_SECTION_SEPARATOR.join(dict.fromkeys(_non_empty_sections(all_reasons)))
|
|
296
|
+
|
|
297
|
+
|
|
279
298
|
def pre_tool_use_verdict(
|
|
280
299
|
all_results: list[subprocess.CompletedProcess[str]],
|
|
281
300
|
) -> HookVerdict:
|
|
282
301
|
"""Fold nested PreToolUse results into the one verdict Claude Code reads.
|
|
283
302
|
|
|
303
|
+
Claude Code shows one reason per refused call, so every refusing child's
|
|
304
|
+
reason travels in that one reason::
|
|
305
|
+
|
|
306
|
+
alpha exits 2 "no tests", beta denies "no link" -> exit 2, both reasons
|
|
307
|
+
alpha denies "no link", beta denies "too long" -> deny, both reasons
|
|
308
|
+
alpha asks, beta denies "no link" -> deny, "no link"
|
|
309
|
+
|
|
284
310
|
Args:
|
|
285
311
|
all_results: The completed nested hooks, in run order.
|
|
286
312
|
|
|
287
313
|
Returns:
|
|
288
|
-
A blocking exit
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
314
|
+
A blocking exit when any child exits 2, its stderr holding every
|
|
315
|
+
exit-2 child's stderr and every child ``deny`` reason; else the first
|
|
316
|
+
child ``deny`` output, its reason holding every child ``deny``
|
|
317
|
+
reason, or with no ``deny`` the first child ``ask`` output, either
|
|
318
|
+
carrying one copy of each distinct child ``additionalContext``; else
|
|
319
|
+
a pass carrying that context, else an empty pass.
|
|
292
320
|
"""
|
|
293
|
-
for each_result in all_results:
|
|
294
|
-
if each_result.returncode == BLOCKING_EXIT_CODE:
|
|
295
|
-
return HookVerdict(BLOCKING_EXIT_CODE, "", each_result.stderr)
|
|
296
321
|
all_parsed_outputs = [
|
|
297
322
|
_parsed_hook_output(each_result.stdout)
|
|
298
323
|
for each_result in all_results
|
|
299
324
|
if each_result.returncode == 0
|
|
300
325
|
]
|
|
301
326
|
all_parsed_outputs = [each_output for each_output in all_parsed_outputs if each_output is not None]
|
|
327
|
+
all_deny_reasons = [_deny_reason(each_output) for each_output in all_parsed_outputs]
|
|
328
|
+
all_blocking_stderr = [
|
|
329
|
+
each_result.stderr.strip()
|
|
330
|
+
for each_result in all_results
|
|
331
|
+
if each_result.returncode == BLOCKING_EXIT_CODE
|
|
332
|
+
]
|
|
333
|
+
if all_blocking_stderr:
|
|
334
|
+
return HookVerdict(
|
|
335
|
+
BLOCKING_EXIT_CODE, "", _joined_reasons([*all_blocking_stderr, *all_deny_reasons])
|
|
336
|
+
)
|
|
302
337
|
all_context_sections = _non_empty_sections(
|
|
303
338
|
_json_additional_context(each_parsed_output) for each_parsed_output in all_parsed_outputs
|
|
304
339
|
)
|
|
305
340
|
all_distinct_context_sections = list(dict.fromkeys(all_context_sections))
|
|
306
341
|
joined_context = CONTEXT_SECTION_SEPARATOR.join(all_distinct_context_sections)
|
|
307
|
-
for
|
|
308
|
-
|
|
342
|
+
for each_decision in ALL_FORWARDED_PERMISSION_DECISIONS:
|
|
343
|
+
for each_parsed_output in all_parsed_outputs:
|
|
344
|
+
if _permission_decision(each_parsed_output) != each_decision:
|
|
345
|
+
continue
|
|
309
346
|
decision_output = dict(each_parsed_output)
|
|
347
|
+
decision_specific_output = dict(decision_output[HOOK_SPECIFIC_OUTPUT_KEY])
|
|
310
348
|
if joined_context:
|
|
311
|
-
decision_specific_output = dict(decision_output[HOOK_SPECIFIC_OUTPUT_KEY])
|
|
312
349
|
decision_specific_output[ADDITIONAL_CONTEXT_KEY] = joined_context
|
|
313
|
-
|
|
350
|
+
joined_deny_reasons = _joined_reasons(all_deny_reasons)
|
|
351
|
+
if each_decision == DENY_PERMISSION_DECISION and joined_deny_reasons:
|
|
352
|
+
decision_specific_output[PERMISSION_DECISION_REASON_KEY] = joined_deny_reasons
|
|
353
|
+
decision_output[HOOK_SPECIFIC_OUTPUT_KEY] = decision_specific_output
|
|
314
354
|
return HookVerdict(0, json.dumps(decision_output), "")
|
|
315
355
|
if not joined_context:
|
|
316
356
|
return HookVerdict(0, "", "")
|
|
@@ -189,6 +189,87 @@ def test_should_pass_a_child_deny_decision_through(tmp_path: pathlib.Path) -> No
|
|
|
189
189
|
assert json.loads(completed_process.stdout) == deny_output
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def _deny_output(reason: str) -> dict[str, object]:
|
|
193
|
+
return {
|
|
194
|
+
"hookSpecificOutput": {
|
|
195
|
+
"hookEventName": "PreToolUse",
|
|
196
|
+
"permissionDecision": "deny",
|
|
197
|
+
"permissionDecisionReason": reason,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_carry_every_child_deny_reason_in_one_refusal(
|
|
203
|
+
tmp_path: pathlib.Path,
|
|
204
|
+
) -> None:
|
|
205
|
+
_make_checkout(
|
|
206
|
+
tmp_path,
|
|
207
|
+
"alpha",
|
|
208
|
+
{
|
|
209
|
+
"PreToolUse": [
|
|
210
|
+
_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'"),
|
|
211
|
+
_command_group("Bash", f"echo '{json.dumps(_deny_output('too long'))}'"),
|
|
212
|
+
]
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
_make_checkout(
|
|
216
|
+
tmp_path,
|
|
217
|
+
"beta",
|
|
218
|
+
{"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
222
|
+
|
|
223
|
+
assert completed_process.returncode == 0
|
|
224
|
+
assert json.loads(completed_process.stdout) == _deny_output("no link\n\ntoo long")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_should_carry_child_deny_reasons_into_a_blocking_exit(
|
|
228
|
+
tmp_path: pathlib.Path,
|
|
229
|
+
) -> None:
|
|
230
|
+
_make_checkout(
|
|
231
|
+
tmp_path,
|
|
232
|
+
"alpha",
|
|
233
|
+
{"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
|
|
234
|
+
)
|
|
235
|
+
_make_checkout(
|
|
236
|
+
tmp_path,
|
|
237
|
+
"beta",
|
|
238
|
+
{"PreToolUse": [_command_group("Bash", "echo no tests ran >&2; exit 2")]},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
242
|
+
|
|
243
|
+
assert completed_process.returncode == BLOCKING_EXIT_CODE
|
|
244
|
+
assert completed_process.stderr == "no tests ran\n\nno link"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_should_forward_a_later_child_deny_ahead_of_an_earlier_ask(
|
|
248
|
+
tmp_path: pathlib.Path,
|
|
249
|
+
) -> None:
|
|
250
|
+
ask_output = {
|
|
251
|
+
"hookSpecificOutput": {
|
|
252
|
+
"hookEventName": "PreToolUse",
|
|
253
|
+
"permissionDecision": "ask",
|
|
254
|
+
"permissionDecisionReason": "publishing needs a person",
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
_make_checkout(
|
|
258
|
+
tmp_path,
|
|
259
|
+
"alpha",
|
|
260
|
+
{"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(ask_output)}'")]},
|
|
261
|
+
)
|
|
262
|
+
_make_checkout(
|
|
263
|
+
tmp_path,
|
|
264
|
+
"beta",
|
|
265
|
+
{"PreToolUse": [_command_group("Bash", f"echo '{json.dumps(_deny_output('no link'))}'")]},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
|
|
269
|
+
|
|
270
|
+
assert json.loads(completed_process.stdout) == _deny_output("no link")
|
|
271
|
+
|
|
272
|
+
|
|
192
273
|
def test_should_not_forward_a_child_allow_decision(tmp_path: pathlib.Path) -> None:
|
|
193
274
|
allow_output = {
|
|
194
275
|
"hookSpecificOutput": {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Hook that restores poteto-mode after compaction and loads it for helpers.
|
|
2
|
+
"""Hook that restores poteto-mode after compaction and loads it for workflow helpers.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
|
-
Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke poteto-mode"
|
|
7
6
|
Workflow script helper starts -> "invoke poteto-mode now"
|
|
8
7
|
compact SessionStart, skill invoked before -> "invoke the skill again"
|
|
9
8
|
compact SessionStart, no prior invocation
|
|
@@ -28,55 +27,21 @@ if _hooks_dir not in sys.path:
|
|
|
28
27
|
sys.path.insert(0, _hooks_dir)
|
|
29
28
|
|
|
30
29
|
from hooks_constants.skill_loaded_reminder_constants import (
|
|
31
|
-
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
32
30
|
COMPACTION_REMINDER,
|
|
33
31
|
COMPACTION_SOURCE,
|
|
34
32
|
NOT_LOADED_REMINDER,
|
|
35
33
|
ALL_POTETO_MODE_SKILL_NAMES,
|
|
36
|
-
PRE_TOOL_USE_EVENT_NAME,
|
|
37
|
-
PROMPT_SEPARATOR,
|
|
38
34
|
SESSION_START_EVENT_NAME,
|
|
39
35
|
ALL_SLASH_COMMAND_MARKERS,
|
|
40
36
|
SUBAGENT_START_EVENT_NAME,
|
|
41
|
-
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
|
|
42
37
|
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
43
38
|
WORKFLOW_SUBAGENT_TYPE,
|
|
44
39
|
)
|
|
45
|
-
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
46
40
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
47
41
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
42
|
from transcript_skill_scan import skill_invocation_status
|
|
49
43
|
|
|
50
44
|
|
|
51
|
-
def subagent_input_with_poteto_mode(
|
|
52
|
-
tool_name: str,
|
|
53
|
-
all_tool_input_fields: dict[str, object],
|
|
54
|
-
) -> dict[str, object] | None:
|
|
55
|
-
"""Return the spawn input with the poteto-mode invocation first, or None to leave it.
|
|
56
|
-
|
|
57
|
-
::
|
|
58
|
-
|
|
59
|
-
Agent {"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
|
|
60
|
-
spawn_agent {"message": "Fix it."} -> {"message": "$poteto-mode\\n\\nFix it."}
|
|
61
|
-
Agent {"prompt": "Invoke poteto-mode, then ..."} -> None
|
|
62
|
-
Agent {"subagent_type": "poteto-agent", ...} -> None, it loads the skill
|
|
63
|
-
|
|
64
|
-
Claude Code spawns through Agent or Task and Codex through spawn_agent, so
|
|
65
|
-
each tool name carries its own prompt field and invocation text.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
tool_name: The spawn tool the session called.
|
|
69
|
-
all_tool_input_fields: The spawn input the session is about to send.
|
|
70
|
-
"""
|
|
71
|
-
field_name, invocation_prefix = ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME[tool_name]
|
|
72
|
-
prompt = all_tool_input_fields.get(field_name)
|
|
73
|
-
if not isinstance(prompt, str) or ALL_POTETO_MODE_SKILL_NAMES[0] in prompt:
|
|
74
|
-
return None
|
|
75
|
-
if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
|
|
76
|
-
return None
|
|
77
|
-
return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
|
|
78
|
-
|
|
79
|
-
|
|
80
45
|
def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
|
|
81
46
|
"""Report whether the skill was invoked and whether it remains loaded.
|
|
82
47
|
|
|
@@ -160,26 +125,11 @@ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
160
125
|
return NOT_LOADED_REMINDER if was_invoked and not is_loaded else None
|
|
161
126
|
|
|
162
127
|
|
|
163
|
-
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
164
|
-
return (
|
|
165
|
-
all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
|
|
166
|
-
and all_hook_fields.get("tool_name") in ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME
|
|
167
|
-
and isinstance(all_hook_fields.get("tool_input"), dict)
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
128
|
def main() -> None:
|
|
172
|
-
"""
|
|
129
|
+
"""Print the reminder its event needs, or stay quiet."""
|
|
173
130
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
174
131
|
if hook_payload is None:
|
|
175
132
|
return
|
|
176
|
-
if _is_subagent_spawn(hook_payload):
|
|
177
|
-
rewritten_input = subagent_input_with_poteto_mode(
|
|
178
|
-
hook_payload["tool_name"], hook_payload["tool_input"]
|
|
179
|
-
)
|
|
180
|
-
if rewritten_input is not None:
|
|
181
|
-
write_pre_tool_use_allow_to_stdout(rewritten_input)
|
|
182
|
-
return
|
|
183
133
|
reminder = reminder_for(hook_payload)
|
|
184
134
|
if reminder is None:
|
|
185
135
|
return
|