-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add qyl-continuation plugin (smart auto-continuation, v1.0.0) (#158) #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "name": "qyl-continuation", | ||
| "version": "1.0.0", | ||
| "description": "Smart auto-continuation for Claude Code. Heuristic pre-filter eliminates ~80% of unnecessary Haiku calls; improved judge prompt handles the rest. Based on double-shot-latte (MIT).", | ||
| "author": { | ||
| "name": "AncpLua", | ||
| "url": "https://github.com/ANcpLua" | ||
| }, | ||
| "repository": "https://github.com/ANcpLua/ancplua-claude-plugins", | ||
| "license": "MIT", | ||
| "keywords": ["hooks", "continuation", "auto-continue", "workflow", "qyl"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # qyl-continuation | ||
|
|
||
| Smart auto-continuation for Claude Code. Heuristic pre-filter eliminates ~80% of unnecessary Haiku calls; improved judge prompt handles the rest. | ||
|
|
||
| Based on [double-shot-latte](https://github.com/anthropics/claude-code-plugins/tree/main/plugins/double-shot-latte) (MIT). | ||
|
|
||
| ## How It Works | ||
|
|
||
| **Phase 1 — Heuristics (no LLM call):** | ||
| - H1: Assistant asked user a question → stop | ||
| - H2: Completion signal without stated next steps → stop | ||
| - H3: Tool results already addressed by assistant → stop | ||
| - H4: Substantial text-only response (no pending tool calls) → stop | ||
|
|
||
| **Phase 2 — Haiku judge (~20% of cases):** | ||
| - Only fires when heuristics are inconclusive | ||
| - Structured JSON output: `should_continue` + `reasoning` | ||
| - Throttled: max 3 continuations per 5-minute window | ||
|
|
||
| ## Configuration | ||
|
|
||
| | Environment Variable | Default | Description | | ||
| |---------------------|---------|-------------| | ||
| | `QYL_CONTINUATION_TIMEOUT` | `30` | Haiku judge timeout (seconds) | | ||
| | `QYL_CONTINUATION_MODEL` | `haiku` | Judge model | | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "hooks": { | ||
| "Stop": [ | ||
| { | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop-judge.py", | ||
| "timeout": 35, | ||
| "statusMessage": "Evaluating continuation..." | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,235 @@ | ||||||||||||||||||||||||||||
| #!/usr/bin/env python3 | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| qyl-continuation stop hook. | ||||||||||||||||||||||||||||
| Based on double-shot-latte (MIT) by Jesse/Anthropic. | ||||||||||||||||||||||||||||
| Phase 1: Heuristic pre-filter (~80% of stops, no Haiku call). | ||||||||||||||||||||||||||||
| Phase 2: Haiku judge (ambiguous ~20% only). | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||
| from typing import NoReturn | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| MAX_CONTINUATIONS = 3 | ||||||||||||||||||||||||||||
| WINDOW_SECONDS = 300 | ||||||||||||||||||||||||||||
| TAIL_LINES = 6 | ||||||||||||||||||||||||||||
| MAX_CONTEXT_BYTES = 32_000 | ||||||||||||||||||||||||||||
| JUDGE_TIMEOUT = int(os.environ.get("QYL_CONTINUATION_TIMEOUT", "30")) | ||||||||||||||||||||||||||||
| JUDGE_MODEL = os.environ.get("QYL_CONTINUATION_MODEL", "haiku") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def approve(reason: str) -> NoReturn: | ||||||||||||||||||||||||||||
| json.dump({"decision": "approve", "reason": reason}, sys.stdout) | ||||||||||||||||||||||||||||
| sys.exit(0) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def block(reason: str) -> NoReturn: | ||||||||||||||||||||||||||||
| json.dump({"decision": "block", "reason": reason}, sys.stdout) | ||||||||||||||||||||||||||||
| sys.exit(0) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if os.environ.get("CLAUDE_HOOK_JUDGE_MODE") == "true": | ||||||||||||||||||||||||||||
| approve("Judge mode, allowing stop") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| event = json.loads(sys.stdin.read()) | ||||||||||||||||||||||||||||
| transcript_path = event.get("transcript_path", "") | ||||||||||||||||||||||||||||
| session_id = event.get("session_id", "unknown") | ||||||||||||||||||||||||||||
| throttle_file = Path(f"/tmp/.qyl-continue-{session_id.replace('/', '_')}") | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The script creates a throttle file in
Suggested change
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def read_throttle() -> tuple[int, float]: | ||||||||||||||||||||||||||||
| if not throttle_file.exists(): | ||||||||||||||||||||||||||||
| return 0, 0.0 | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| parts = throttle_file.read_text().strip().split(":") | ||||||||||||||||||||||||||||
| return int(parts[0]), float(parts[1]) | ||||||||||||||||||||||||||||
| except (ValueError, IndexError): | ||||||||||||||||||||||||||||
| return 0, 0.0 | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def write_throttle(count: int) -> None: | ||||||||||||||||||||||||||||
| throttle_file.write_text(f"{count}:{time.time()}") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def clear_throttle() -> None: | ||||||||||||||||||||||||||||
| throttle_file.unlink(missing_ok=True) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if event.get("stop_hook_active", False): | ||||||||||||||||||||||||||||
| count, last_time = read_throttle() | ||||||||||||||||||||||||||||
| if time.time() - last_time > WINDOW_SECONDS: | ||||||||||||||||||||||||||||
| count = 0 | ||||||||||||||||||||||||||||
| if count >= MAX_CONTINUATIONS: | ||||||||||||||||||||||||||||
| clear_throttle() | ||||||||||||||||||||||||||||
| approve("Max continuation cycles reached") | ||||||||||||||||||||||||||||
|
Comment on lines
+63
to
+69
|
||||||||||||||||||||||||||||
| if event.get("stop_hook_active", False): | |
| count, last_time = read_throttle() | |
| if time.time() - last_time > WINDOW_SECONDS: | |
| count = 0 | |
| if count >= MAX_CONTINUATIONS: | |
| clear_throttle() | |
| approve("Max continuation cycles reached") | |
| count, last_time = read_throttle() | |
| if time.time() - last_time > WINDOW_SECONDS: | |
| count = 0 | |
| if count >= MAX_CONTINUATIONS: | |
| clear_throttle() | |
| approve("Max continuation cycles reached") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The transcript_path provided in the event input is used directly to read a file without validation. An attacker who can influence the event data could potentially point this to arbitrary files on the system. While the script only reads the last few lines and expects JSONL format, it still constitutes a path traversal vulnerability. Other plugins in this repository (like hookify) explicitly validate this path to prevent access outside of intended directories.
| if not transcript_path or not Path(transcript_path).exists(): | |
| approve("No transcript") | |
| if not transcript_path or not Path(transcript_path).exists() or os.path.isabs(transcript_path) or '..' in transcript_path.split(os.path.sep): | |
| approve("Invalid or missing transcript") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove shell tail dependency from transcript loading
The hook shells out to tail to read transcript lines; on environments without that binary (notably non-Unix setups), this path falls into the exception handler and approves stopping with "Failed to read transcript", effectively disabling continuation for the entire plugin. Reading the file tail in Python would avoid this platform-specific failure mode.
Useful? React with 👍 / 👎.
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
text_of() reads msg.get("content"), but the Claude transcript JSONL entries appear to store the payload under message.content (see e.g. metacognitive-guard Stop hook parsing ... | .message.content[]). As written, last_assistant will usually be empty, so H1/H2/H4 won’t trigger and the judge prompt context will be less accurate. Update text_of() to extract text/tool_result blocks from msg.get("message", {}).get("content", ...) (and keep the current fallback for any legacy formats).
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
has_block_type() checks msg.get("content"), but transcript entries for tool_use/tool_result blocks are typically nested under message.content. This makes the tool-result/pending-tool heuristics (H3/H4) effectively inoperative. Consider checking both msg.get("message", {}).get("content") and msg.get("content") for compatibility.
| content = msg.get("content", "") | |
| content = msg.get("content", "") | |
| if not isinstance(content, list): | |
| # Some transcript entries nest blocks under message.content | |
| content = msg.get("message", {}).get("content", "") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Allow continuation when assistant states pending next action
This heuristic unconditionally approves stopping whenever any tool result has been followed by a long assistant message and the last message is from the assistant, but it never checks for explicit pending-next-step language (e.g., “Next I’ll run tests”). In that common flow, the hook exits here before the Haiku judge runs, so the session stops even though the assistant explicitly indicated autonomous work remains.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slicing a JSON string by bytes with [:MAX_CONTEXT_BYTES] is unsafe as it can create invalid JSON by cutting in the middle of a multi-byte character or a structural element. This could cause the downstream claude process to fail when parsing its input. A safer approach is to encode to bytes, slice, and then decode while ignoring errors. This is still not perfectly safe for the JSON structure but does handle character encoding issues correctly.
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] | |
| transcript_json = json.dumps(messages, ensure_ascii=False).encode('utf-8')[:MAX_CONTEXT_BYTES].decode('utf-8', 'ignore') |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MAX_CONTEXT_BYTES is enforced via json.dumps(... )[:MAX_CONTEXT_BYTES], but slicing a Python str limits characters, not UTF-8 bytes. With non-ASCII content this can exceed the intended byte budget (or cut mid-codepoint). If you want a true byte limit, encode to UTF-8, slice bytes, then decode with errors="ignore"/"replace" (or rename the constant to reflect char-count semantics).
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] | |
| raw_transcript = json.dumps(messages, ensure_ascii=False) | |
| transcript_bytes = raw_transcript.encode("utf-8") | |
| if len(transcript_bytes) > MAX_CONTEXT_BYTES: | |
| transcript_json = transcript_bytes[:MAX_CONTEXT_BYTES].decode("utf-8", errors="ignore") | |
| else: | |
| transcript_json = raw_transcript |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parsing of the judge's output can be made more robust. If structured_output is present but null, get("structured_output", {}) returns None, causing an AttributeError on the next line. While the broad except block catches this, handling this case explicitly would make the code clearer and less reliant on exception handling for control flow.
| evaluation = json.loads(proc.stdout).get("structured_output", {}) | |
| except (json.JSONDecodeError, AttributeError, TypeError): | |
| approve("Judge response unparseable, allowing stop") | |
| output = json.loads(proc.stdout) | |
| evaluation = (output.get("structured_output") or {}) if isinstance(output, dict) else {} | |
| except json.JSONDecodeError: | |
| approve("Judge response unparseable, allowing stop") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The configuration table uses
||to start rows, which breaks standard Markdown table syntax (it should be single| ... | ... | ... |). As-is, this won’t render as a table in most Markdown viewers/linters.