常规归档
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: langgraph-plan-review
|
name: langgraph-plan-review
|
||||||
description: Use the LangGraph plan-review workflow in E:\LangGraph_Design\lab00 from the current Claude Code session. Trigger when the user asks to create Taskxx plans, run the Claude/Codex review loop, generate or revise Notes/Taskxx.md, or use the LangGraph workflow described by Task00.md. This skill uses the current Claude Code environment for Claude steps and only invokes the Python LangGraph app for Codex review and discuss file numbering.
|
description: Use the LangGraph plan-review workflow from the current Claude Code session. Trigger when the user asks to create Taskxx plans, run the Claude/Codex review loop, generate or revise Notes/Taskxx.md, or use the LangGraph workflow described by Task00.md. This skill uses the current Claude Code environment for Claude steps and only invokes the Python LangGraph app for Codex review and discuss file numbering.
|
||||||
---
|
---
|
||||||
|
|
||||||
# LangGraph Plan Review Workflow
|
# LangGraph Plan Review Workflow
|
||||||
|
|
||||||
Use this skill to run the workflow implemented in `E:\LangGraph_Design\lab00`.
|
Use this skill to run the LangGraph plan-review workflow. All scripts are bundled under the skill's own `scripts\` directory (`C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\`) — no dependency on `E:\LangGraph_Design\lab00`.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -31,8 +31,9 @@ Use this skill to run the workflow implemented in `E:\LangGraph_Design\lab00`.
|
|||||||
2. Start or resume the LangGraph app:
|
2. Start or resume the LangGraph app:
|
||||||
```powershell
|
```powershell
|
||||||
# Use double backslashes (\\) in the JSON string so Windows paths are valid JSON.
|
# Use double backslashes (\\) in the JSON string so Windows paths are valid JSON.
|
||||||
$codexCmd = '["C:\\Users\\bowei\\.conda\\envs\\langgraph\\python.exe","review_task.py","$task_path","$latest_discuss_path","$codex_session_id","$work_dir"]'
|
# $task_path / $latest_discuss_path / etc. are Python Template placeholders — keep them literal.
|
||||||
E:\LangGraph_Design\lab00\run_langgraph.ps1 `
|
$codexCmd = '["C:\\Users\\bowei\\.conda\\envs\\langgraph\\python.exe","C:\\Users\\bowei\\.claude\\skills\\langgraph-plan-review\\scripts\\review_task.py","$task_path","$latest_discuss_path","$codex_session_id","$work_dir"]'
|
||||||
|
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\run_langgraph.ps1 `
|
||||||
-TaskId <TaskId> `
|
-TaskId <TaskId> `
|
||||||
-Prompt "<original user request>" `
|
-Prompt "<original user request>" `
|
||||||
-CodexReviewCommand $codexCmd `
|
-CodexReviewCommand $codexCmd `
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReviewResult:
|
||||||
|
has_serious_issue: bool
|
||||||
|
comment: str = ""
|
||||||
|
codex_session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_args(command: Sequence[str], values: Mapping[str, str]) -> list[str]:
|
||||||
|
return [Template(part).safe_substitute(values) for part in command]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command(command: Sequence[str], values: Mapping[str, str], timeout: int) -> str:
|
||||||
|
args = _format_args(command, values)
|
||||||
|
logging.info("Running: %s", args[0] if args else "")
|
||||||
|
t0 = time.monotonic()
|
||||||
|
result = subprocess.run(
|
||||||
|
args,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
logging.info("Finished in %.1fs, exit=%d", elapsed, result.returncode)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Command failed with exit code {result.returncode}: {args}\n"
|
||||||
|
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunner:
|
||||||
|
"""Boundary around Codex CLI calls.
|
||||||
|
|
||||||
|
The Claude Code steps run in the current Claude Code session through the
|
||||||
|
skill instructions. This runner only invokes Codex review commands.
|
||||||
|
Commands are injected by passing argv lists with Template placeholders.
|
||||||
|
Supported placeholders: task_id, user_prompt, work_dir, task_path,
|
||||||
|
latest_discuss_path, discuss_path, notes_dir, codex_session_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
codex_review_command: Sequence[str],
|
||||||
|
timeout: int = 600,
|
||||||
|
) -> None:
|
||||||
|
self.codex_review_command = codex_review_command
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def review(self, values: Mapping[str, str]) -> ReviewResult:
|
||||||
|
enriched = {**values, "timeout": str(self.timeout)}
|
||||||
|
output = _run_command(self.codex_review_command, enriched, self.timeout)
|
||||||
|
if not output:
|
||||||
|
return ReviewResult(has_serious_issue=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(output)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return ReviewResult(has_serious_issue=True, comment=output)
|
||||||
|
|
||||||
|
return ReviewResult(
|
||||||
|
has_serious_issue=bool(payload.get("has_serious_issue")),
|
||||||
|
comment=str(payload.get("comment", "")).strip(),
|
||||||
|
codex_session_id=str(payload.get("codex_session_id", "")).strip(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Codex review shim called by agents.py.
|
||||||
|
|
||||||
|
argv: task_path latest_discuss_path codex_session_id work_dir
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||||
|
session_id = ""
|
||||||
|
final_message = ""
|
||||||
|
text_parts: list[str] = []
|
||||||
|
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event: dict[str, Any] = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
text_parts.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"session_id",
|
||||||
|
"sessionId",
|
||||||
|
"thread_id",
|
||||||
|
"threadId",
|
||||||
|
"conversation_id",
|
||||||
|
"conversationId",
|
||||||
|
):
|
||||||
|
value = event.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
session_id = value
|
||||||
|
|
||||||
|
event_type = str(event.get("type", ""))
|
||||||
|
if event_type in {"session.started", "session.created", "thread.started"}:
|
||||||
|
value = event.get("id") or event.get("session_id") or event.get("thread_id")
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
session_id = value
|
||||||
|
|
||||||
|
if event_type == "item.completed":
|
||||||
|
item = event.get("item")
|
||||||
|
if isinstance(item, dict) and item.get("type") == "agent_message":
|
||||||
|
value = item.get("text")
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
text_parts.append(value)
|
||||||
|
|
||||||
|
if event_type in {"agent_message", "message", "response.output_text.done"}:
|
||||||
|
value = event.get("text") or event.get("message") or event.get("content")
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
text_parts.append(value)
|
||||||
|
|
||||||
|
if event_type in {"exec.completed", "turn.completed", "task.completed"}:
|
||||||
|
value = event.get("last_message") or event.get("message") or event.get("output")
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
final_message = value
|
||||||
|
|
||||||
|
comment = (final_message or "\n".join(text_parts)).strip()
|
||||||
|
return session_id, comment
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_codex() -> str:
|
||||||
|
import shutil
|
||||||
|
# 1. Explicit env var
|
||||||
|
exe = os.environ.get("CODEX_EXE", "")
|
||||||
|
if exe and Path(exe).exists():
|
||||||
|
return exe
|
||||||
|
# 2. PATH lookup
|
||||||
|
found = shutil.which("codex") or shutil.which("codex.cmd")
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
# 3. Hardcoded fallback for the original developer machine
|
||||||
|
fallback = r"C:\Users\bowei\.conda\envs\langgraph\codex.cmd"
|
||||||
|
if Path(fallback).exists():
|
||||||
|
return fallback
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Cannot find codex executable. Set CODEX_EXE environment variable or add codex to PATH."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
task_path = sys.argv[1]
|
||||||
|
latest_discuss = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else ""
|
||||||
|
codex_session_id = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else ""
|
||||||
|
work_dir = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else str(Path.cwd())
|
||||||
|
timeout = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5] else 300
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"Working directory: {work_dir}\n"
|
||||||
|
f"Review the plan document at {task_path}.\n"
|
||||||
|
"Only report severe issues.\n"
|
||||||
|
"Final answer rules: if there is no severe issue, output exactly NO_SERIOUS_ISSUE.\n"
|
||||||
|
"If there is a severe issue, briefly describe it in one paragraph.\n"
|
||||||
|
)
|
||||||
|
if latest_discuss:
|
||||||
|
prompt += f"Also verify that issues raised in {latest_discuss} were addressed.\n"
|
||||||
|
|
||||||
|
codex = resolve_codex()
|
||||||
|
|
||||||
|
fd, last_message_name = tempfile.mkstemp(
|
||||||
|
prefix="codex-last-message-",
|
||||||
|
suffix=".txt",
|
||||||
|
dir=None,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
last_message_path = Path(last_message_name)
|
||||||
|
try:
|
||||||
|
flags = ["--json", "--skip-git-repo-check", "--output-last-message", str(last_message_path)]
|
||||||
|
if codex_session_id:
|
||||||
|
command_args = ["cmd", "/c", codex, "exec", "resume"] + flags + [codex_session_id, "-"]
|
||||||
|
else:
|
||||||
|
command_args = ["cmd", "/c", codex, "exec"] + flags + ["-"]
|
||||||
|
logging.info("Invoking codex: %s", command_args[0] if command_args else "")
|
||||||
|
t0 = time.monotonic()
|
||||||
|
result = subprocess.run(
|
||||||
|
command_args,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
input=prompt,
|
||||||
|
cwd=work_dir,
|
||||||
|
)
|
||||||
|
logging.info("Codex finished in %.1fs, exit=%d", time.monotonic() - t0, result.returncode)
|
||||||
|
session_id, stream_comment = parse_jsonl(result.stdout)
|
||||||
|
comment = last_message_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not comment:
|
||||||
|
comment = stream_comment
|
||||||
|
if comment.strip() == "NO_SERIOUS_ISSUE":
|
||||||
|
comment = ""
|
||||||
|
if codex_session_id:
|
||||||
|
session_id = codex_session_id
|
||||||
|
elif not session_id:
|
||||||
|
session_id = codex_session_id
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
last_message_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result.returncode != 0 and not comment:
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"has_serious_issue": bool(comment),
|
||||||
|
"comment": comment,
|
||||||
|
"codex_session_id": session_id,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$TaskId,
|
||||||
|
|
||||||
|
[string]$Prompt = "",
|
||||||
|
|
||||||
|
[string]$NotesDir = "Notes",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CodexReviewCommand,
|
||||||
|
|
||||||
|
[string]$CondaExe = "",
|
||||||
|
[string]$CondaEnv = "langgraph",
|
||||||
|
[string]$CodexHome = "",
|
||||||
|
[int]$Timeout = 600,
|
||||||
|
[string]$ThreadId = "",
|
||||||
|
[string]$Resume = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
function Resolve-CondaExe {
|
||||||
|
param([string]$ExplicitPath)
|
||||||
|
|
||||||
|
if ($ExplicitPath) {
|
||||||
|
if (-not (Test-Path -LiteralPath $ExplicitPath)) {
|
||||||
|
throw "Conda executable does not exist: $ExplicitPath"
|
||||||
|
}
|
||||||
|
return (Resolve-Path -LiteralPath $ExplicitPath).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:CONDA_EXE -and (Test-Path -LiteralPath $env:CONDA_EXE)) {
|
||||||
|
return (Resolve-Path -LiteralPath $env:CONDA_EXE).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$CondaCommand = Get-Command conda -ErrorAction SilentlyContinue
|
||||||
|
if ($CondaCommand -and $CondaCommand.Source) {
|
||||||
|
return $CondaCommand.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
$UserProfile = [Environment]::GetFolderPath("UserProfile")
|
||||||
|
if ($UserProfile) {
|
||||||
|
$UserFallback = Join-Path $UserProfile "miniconda3\Scripts\conda.exe"
|
||||||
|
if (Test-Path -LiteralPath $UserFallback) {
|
||||||
|
return (Resolve-Path -LiteralPath $UserFallback).Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$LegacyFallback = "C:\Users\bowei\miniconda3\Scripts\conda.exe"
|
||||||
|
if (Test-Path -LiteralPath $LegacyFallback) {
|
||||||
|
return $LegacyFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Unable to locate conda. Set -CondaExe or CONDA_EXE, or ensure conda is on PATH."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-CodexHome {
|
||||||
|
param([string]$ExplicitPath)
|
||||||
|
|
||||||
|
if ($ExplicitPath) {
|
||||||
|
return $ExplicitPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:CODEX_HOME) {
|
||||||
|
return $env:CODEX_HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
$UserProfile = [Environment]::GetFolderPath("UserProfile")
|
||||||
|
if (-not $UserProfile) {
|
||||||
|
throw "Unable to determine user profile for CODEX_HOME. Set -CodexHome or CODEX_HOME."
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Join-Path $UserProfile ".codex")
|
||||||
|
}
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$LaunchWorkDir = (Get-Location).Path
|
||||||
|
Push-Location $PSScriptRoot
|
||||||
|
# Ensure conda run child processes can encode Unicode (Chinese, etc.) output
|
||||||
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
try {
|
||||||
|
$env:CODEX_HOME = Resolve-CodexHome -ExplicitPath $CodexHome
|
||||||
|
|
||||||
|
$Conda = Resolve-CondaExe -ExplicitPath $CondaExe
|
||||||
|
$ArgsList = @(
|
||||||
|
"run", "-n", $CondaEnv, "python", "run_workflow.py",
|
||||||
|
"--task-id", $TaskId,
|
||||||
|
"--notes-dir", $NotesDir,
|
||||||
|
"--work-dir", $LaunchWorkDir,
|
||||||
|
"--timeout", "$Timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Prompt) { $ArgsList += @("--prompt", $Prompt) }
|
||||||
|
$CodexReviewCommandB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CodexReviewCommand))
|
||||||
|
$ArgsList += @("--codex-review-command-b64", $CodexReviewCommandB64)
|
||||||
|
if ($ThreadId) { $ArgsList += @("--thread-id", $ThreadId) }
|
||||||
|
if ($Resume) {
|
||||||
|
$ResumeB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Resume))
|
||||||
|
$ArgsList += @("--resume-b64", $ResumeB64)
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Conda @ArgsList
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from agents import AgentRunner
|
||||||
|
from workflow import build_workflow, resolve_notes_dir, resolve_work_dir
|
||||||
|
|
||||||
|
|
||||||
|
DB_FILENAME = "workflow_state.db"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command(value: str | None) -> list[str] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Fallback: the string may contain Windows paths with unescaped backslashes
|
||||||
|
# that make it invalid JSON. Re-escape backslashes and retry before giving up.
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
# Replace bare backslashes (not already doubled) with \\
|
||||||
|
re_escaped = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value)
|
||||||
|
parsed = json.loads(re_escaped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
stripped = value.strip()
|
||||||
|
if stripped.startswith("[") and stripped.endswith("]"):
|
||||||
|
# Last-resort comma split; strip surrounding quotes from each item.
|
||||||
|
parsed = [item.strip().strip('"').strip("'")
|
||||||
|
for item in stripped[1:-1].split(",")]
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
|
||||||
|
raise argparse.ArgumentTypeError("command must be a JSON string array")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command_b64(value: str | None) -> list[str] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value).decode("utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"invalid base64 command: {exc}") from exc
|
||||||
|
return parse_command(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_b64(value: str | None):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value).decode("utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"invalid base64 JSON: {exc}") from exc
|
||||||
|
return json.loads(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def run_workflow(
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
user_prompt: str = "",
|
||||||
|
notes_dir: str = "Notes",
|
||||||
|
work_dir: str = "",
|
||||||
|
runner: AgentRunner,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
resume_payload=None,
|
||||||
|
) -> dict:
|
||||||
|
effective_thread = thread_id or task_id
|
||||||
|
resolved_work_dir = resolve_work_dir(work_dir)
|
||||||
|
resolved_notes_dir = resolve_notes_dir(resolved_work_dir, notes_dir)
|
||||||
|
resolved_notes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path = resolved_notes_dir / DB_FILENAME
|
||||||
|
|
||||||
|
with SqliteSaver.from_conn_string(str(db_path)) as checkpointer:
|
||||||
|
app = build_workflow(runner, checkpointer=checkpointer)
|
||||||
|
config = {"configurable": {"thread_id": effective_thread}}
|
||||||
|
|
||||||
|
if resume_payload is not None:
|
||||||
|
result = app.invoke(Command(resume=resume_payload), config=config)
|
||||||
|
else:
|
||||||
|
result = app.invoke(
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"user_prompt": user_prompt,
|
||||||
|
"notes_dir": str(resolved_notes_dir),
|
||||||
|
"work_dir": str(resolved_work_dir),
|
||||||
|
},
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
interrupts = result.get("__interrupt__", [])
|
||||||
|
if interrupts:
|
||||||
|
intr = interrupts[0]
|
||||||
|
action_required = intr.value if hasattr(intr, "value") else intr
|
||||||
|
return {
|
||||||
|
"status": "waiting",
|
||||||
|
"thread_id": effective_thread,
|
||||||
|
"db_path": str(db_path),
|
||||||
|
"action_required": action_required,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "done",
|
||||||
|
"thread_id": effective_thread,
|
||||||
|
"db_path": str(db_path),
|
||||||
|
"task_path": result.get("task_path", ""),
|
||||||
|
"latest_discuss_path": result.get("latest_discuss_path", ""),
|
||||||
|
"work_dir": result.get("work_dir", ""),
|
||||||
|
"codex_session_id": result.get("codex_session_id", ""),
|
||||||
|
"has_serious_issue": result.get("has_serious_issue", False),
|
||||||
|
"should_exit": result.get("should_exit", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run the LangGraph Claude/Codex review workflow."
|
||||||
|
)
|
||||||
|
parser.add_argument("--task-id", required=True, help="Task id, for example 02")
|
||||||
|
parser.add_argument("--prompt", default="", help="User request recorded for traceability")
|
||||||
|
parser.add_argument("--notes-dir", default="Notes", help="Directory for Task/discuss files")
|
||||||
|
parser.add_argument("--work-dir", default="", help="Claude Code launch working directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--codex-review-command",
|
||||||
|
type=parse_command,
|
||||||
|
default=None,
|
||||||
|
help="JSON argv for Codex review step.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--codex-review-command-b64",
|
||||||
|
type=parse_command_b64,
|
||||||
|
default=None,
|
||||||
|
help="Base64-encoded JSON argv for Codex review step.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=int, default=600)
|
||||||
|
parser.add_argument(
|
||||||
|
"--thread-id",
|
||||||
|
default="",
|
||||||
|
help="Thread ID for resuming an existing run (defaults to task-id)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resume",
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"JSON payload for Command(resume=...). "
|
||||||
|
"Use true for plan/revise/rejection steps; {\"accepts\":true} for decision step."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resume-b64",
|
||||||
|
default="",
|
||||||
|
help="Base64-encoded JSON payload for Command(resume=...).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging to stderr.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.verbose:
|
||||||
|
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
codex_review_command = args.codex_review_command_b64 or args.codex_review_command
|
||||||
|
if not codex_review_command:
|
||||||
|
parser.error("--codex-review-command or --codex-review-command-b64 is required")
|
||||||
|
|
||||||
|
runner = AgentRunner(
|
||||||
|
codex_review_command=codex_review_command,
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
# resume=True tells LangGraph to resume from the last interrupt without a specific value.
|
||||||
|
# Callers should pass 'true' (JSON boolean) for plan/revise/rejection steps.
|
||||||
|
resume_payload = parse_json_b64(args.resume_b64) if args.resume_b64 else (
|
||||||
|
json.loads(args.resume) if args.resume else None
|
||||||
|
)
|
||||||
|
|
||||||
|
output = run_workflow(
|
||||||
|
task_id=args.task_id,
|
||||||
|
user_prompt=args.prompt,
|
||||||
|
notes_dir=args.notes_dir,
|
||||||
|
work_dir=args.work_dir,
|
||||||
|
runner=runner,
|
||||||
|
thread_id=args.thread_id or None,
|
||||||
|
resume_payload=resume_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
|
||||||
|
from agents import AgentRunner
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowState(TypedDict):
|
||||||
|
task_id: str
|
||||||
|
user_prompt: str
|
||||||
|
work_dir: str
|
||||||
|
yy: int
|
||||||
|
notes_dir: str
|
||||||
|
task_path: str
|
||||||
|
latest_discuss_path: str
|
||||||
|
codex_session_id: str
|
||||||
|
has_serious_issue: bool
|
||||||
|
review_comment: str
|
||||||
|
should_exit: bool
|
||||||
|
claude_accepts: bool
|
||||||
|
rejection_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def format_yy(value: int) -> str:
|
||||||
|
return f"{value:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def discuss_path(notes_dir: str, task_id: str, yy: int) -> str:
|
||||||
|
return str(Path(notes_dir) / f"discuss{task_id}-{format_yy(yy)}.md")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_work_dir(value: str) -> Path:
|
||||||
|
return Path(value or ".").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_notes_dir(work_dir: Path, value: str) -> Path:
|
||||||
|
notes_dir = Path(value or "Notes")
|
||||||
|
if not notes_dir.is_absolute():
|
||||||
|
notes_dir = work_dir / notes_dir
|
||||||
|
return notes_dir.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def next_discuss_index(notes_dir: str, task_id: str) -> int:
|
||||||
|
notes = Path(notes_dir)
|
||||||
|
if not notes.exists():
|
||||||
|
return 0
|
||||||
|
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
|
||||||
|
indexes: list[int] = []
|
||||||
|
for path in notes.iterdir():
|
||||||
|
match = pattern.match(path.name)
|
||||||
|
if match:
|
||||||
|
indexes.append(int(match.group(1)))
|
||||||
|
return max(indexes, default=-1) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def latest_discuss_path(notes_dir: str, task_id: str) -> str:
|
||||||
|
notes = Path(notes_dir)
|
||||||
|
if not notes.exists():
|
||||||
|
return ""
|
||||||
|
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
|
||||||
|
candidates: list[tuple[int, Path]] = []
|
||||||
|
for path in notes.iterdir():
|
||||||
|
match = pattern.match(path.name)
|
||||||
|
if match:
|
||||||
|
candidates.append((int(match.group(1)), path))
|
||||||
|
if not candidates:
|
||||||
|
return ""
|
||||||
|
return str(max(candidates, key=lambda item: item[0])[1])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_task_file(task_path: str) -> None:
|
||||||
|
path = Path(task_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Task file does not exist. Current Claude Code session must write it first: {task_path}"
|
||||||
|
)
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError(f"Task file is empty: {task_path}")
|
||||||
|
|
||||||
|
# Count level-2 headings (lines starting with ## )
|
||||||
|
lines = text.split('\n')
|
||||||
|
level2_count = sum(1 for line in lines if line.startswith('## '))
|
||||||
|
|
||||||
|
if level2_count < 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"Task file must contain at least 3 level-2 sections (## ...). Found: {level2_count}. "
|
||||||
|
f"File: {task_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rejection_file(path: str) -> None:
|
||||||
|
rejection_path = Path(path)
|
||||||
|
if not rejection_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Rejection file was not written. Current Claude Code session must "
|
||||||
|
f"write it first: {path}"
|
||||||
|
)
|
||||||
|
text = rejection_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError(f"Rejection file is empty: {path}")
|
||||||
|
required_markers = ["Claude Code Rejection", "拒绝"]
|
||||||
|
if not any(marker in text for marker in required_markers):
|
||||||
|
raise ValueError(
|
||||||
|
f"Rejection file must contain a Claude rejection marker: {path}"
|
||||||
|
)
|
||||||
|
if len(text) < 30:
|
||||||
|
raise ValueError(
|
||||||
|
f"Rejection file is too short to contain a meaningful reason: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def values_from_state(state: WorkflowState) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"task_id": state["task_id"],
|
||||||
|
"user_prompt": state["user_prompt"],
|
||||||
|
"work_dir": state["work_dir"],
|
||||||
|
"yy": format_yy(state["yy"]),
|
||||||
|
"notes_dir": state["notes_dir"],
|
||||||
|
"task_path": state["task_path"],
|
||||||
|
"discuss_path": discuss_path(state["notes_dir"], state["task_id"], state["yy"]),
|
||||||
|
"latest_discuss_path": state["latest_discuss_path"],
|
||||||
|
"codex_session_id": state["codex_session_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow(runner: AgentRunner, *, checkpointer=None):
|
||||||
|
graph = StateGraph(WorkflowState)
|
||||||
|
|
||||||
|
def initialize(state: WorkflowState) -> WorkflowState:
|
||||||
|
work_dir = resolve_work_dir(state.get("work_dir", ""))
|
||||||
|
notes_dir = resolve_notes_dir(work_dir, state.get("notes_dir") or "Notes")
|
||||||
|
task_id = state["task_id"]
|
||||||
|
task_path = str(notes_dir / f"Task{task_id}.md")
|
||||||
|
yy = next_discuss_index(str(notes_dir), task_id)
|
||||||
|
latest_path = latest_discuss_path(str(notes_dir), task_id)
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"work_dir": str(work_dir),
|
||||||
|
"yy": yy,
|
||||||
|
"notes_dir": str(notes_dir),
|
||||||
|
"task_path": task_path,
|
||||||
|
"latest_discuss_path": latest_path,
|
||||||
|
"codex_session_id": state.get("codex_session_id", ""),
|
||||||
|
"has_serious_issue": False,
|
||||||
|
"review_comment": "",
|
||||||
|
"should_exit": False,
|
||||||
|
"claude_accepts": False,
|
||||||
|
"rejection_reason": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def request_plan(state: WorkflowState) -> WorkflowState:
|
||||||
|
interrupt({
|
||||||
|
"action": "write_plan",
|
||||||
|
"task_path": state["task_path"],
|
||||||
|
"user_prompt": state["user_prompt"],
|
||||||
|
})
|
||||||
|
validate_task_file(state["task_path"])
|
||||||
|
return state
|
||||||
|
|
||||||
|
def review(state: WorkflowState) -> WorkflowState:
|
||||||
|
result = runner.review(values_from_state(state))
|
||||||
|
updates = {
|
||||||
|
**state,
|
||||||
|
"has_serious_issue": result.has_serious_issue,
|
||||||
|
"review_comment": result.comment,
|
||||||
|
"should_exit": not result.has_serious_issue,
|
||||||
|
}
|
||||||
|
if result.codex_session_id:
|
||||||
|
updates["codex_session_id"] = result.codex_session_id
|
||||||
|
return updates
|
||||||
|
|
||||||
|
def write_discuss(state: WorkflowState) -> WorkflowState:
|
||||||
|
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
|
||||||
|
comment = state["review_comment"].strip()
|
||||||
|
if not comment:
|
||||||
|
comment = "Codex reported a serious issue but did not provide details."
|
||||||
|
|
||||||
|
Path(path).write_text(
|
||||||
|
"\n".join([
|
||||||
|
f"# discuss{state['task_id']}-{format_yy(state['yy'])}",
|
||||||
|
"",
|
||||||
|
"## Codex Review",
|
||||||
|
"",
|
||||||
|
comment,
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"latest_discuss_path": path,
|
||||||
|
"yy": state["yy"] + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def request_decision(state: WorkflowState) -> WorkflowState:
|
||||||
|
result = interrupt({
|
||||||
|
"action": "decide",
|
||||||
|
"discuss_path": state["latest_discuss_path"],
|
||||||
|
})
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"claude_accepts": bool(result.get("accepts", False)),
|
||||||
|
"rejection_reason": str(result.get("reason", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def request_revision(state: WorkflowState) -> WorkflowState:
|
||||||
|
interrupt({
|
||||||
|
"action": "revise",
|
||||||
|
"task_path": state["task_path"],
|
||||||
|
"discuss_path": state["latest_discuss_path"],
|
||||||
|
})
|
||||||
|
validate_task_file(state["task_path"])
|
||||||
|
return state
|
||||||
|
|
||||||
|
def request_rejection(state: WorkflowState) -> WorkflowState:
|
||||||
|
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
|
||||||
|
interrupt({
|
||||||
|
"action": "write_rejection",
|
||||||
|
"output_path": path,
|
||||||
|
"reason_hint": state["rejection_reason"],
|
||||||
|
"required_heading": "## Claude Code Rejection",
|
||||||
|
})
|
||||||
|
validate_rejection_file(path)
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"latest_discuss_path": path,
|
||||||
|
"yy": state["yy"] + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def review_route(state: WorkflowState) -> str:
|
||||||
|
return "done" if state["should_exit"] else "write_discuss"
|
||||||
|
|
||||||
|
def decision_route(state: WorkflowState) -> str:
|
||||||
|
return "revise" if state["claude_accepts"] else "reject"
|
||||||
|
|
||||||
|
graph.add_node("initialize", initialize)
|
||||||
|
graph.add_node("request_plan", request_plan)
|
||||||
|
graph.add_node("review", review)
|
||||||
|
graph.add_node("write_discuss", write_discuss)
|
||||||
|
graph.add_node("request_decision", request_decision)
|
||||||
|
graph.add_node("request_revision", request_revision)
|
||||||
|
graph.add_node("request_rejection", request_rejection)
|
||||||
|
|
||||||
|
graph.add_edge(START, "initialize")
|
||||||
|
graph.add_edge("initialize", "request_plan")
|
||||||
|
graph.add_edge("request_plan", "review")
|
||||||
|
graph.add_conditional_edges(
|
||||||
|
"review",
|
||||||
|
review_route,
|
||||||
|
{"done": END, "write_discuss": "write_discuss"},
|
||||||
|
)
|
||||||
|
graph.add_edge("write_discuss", "request_decision")
|
||||||
|
graph.add_conditional_edges(
|
||||||
|
"request_decision",
|
||||||
|
decision_route,
|
||||||
|
{"revise": "request_revision", "reject": "request_rejection"},
|
||||||
|
)
|
||||||
|
graph.add_edge("request_revision", "review")
|
||||||
|
graph.add_edge("request_rejection", "review")
|
||||||
|
|
||||||
|
return graph.compile(checkpointer=checkpointer)
|
||||||
Reference in New Issue
Block a user