常规归档

This commit is contained in:
Myfro9
2026-05-24 21:20:26 -05:00
parent 15e8c5efa3
commit fda6c9a114
6 changed files with 821 additions and 4 deletions
+5 -4
View File
@@ -1,11 +1,11 @@
---
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
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
@@ -31,8 +31,9 @@ Use this skill to run the workflow implemented in `E:\LangGraph_Design\lab00`.
2. Start or resume the LangGraph app:
```powershell
# 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"]'
E:\LangGraph_Design\lab00\run_langgraph.ps1 `
# $task_path / $latest_discuss_path / etc. are Python Template placeholders — keep them literal.
$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> `
-Prompt "<original user request>" `
-CodexReviewCommand $codexCmd `
+79
View File
@@ -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())
+269
View File
@@ -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)