chore: initial monorepo scaffold + WDS Phase 1+2 artifacts
- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24) - apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004) - apps/web: React 19 + Vite 8 (ESM) - libs/shared/api-interface: Zod contract base - Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit - WDS artifacts: - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs) - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact) - Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md - AGENTS.md + README.md como entrada para devs/agentes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
171
.claude/skills/bmad-eval-runner/scripts/pty_runner.py
Normal file
171
.claude/skills/bmad-eval-runner/scripts/pty_runner.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# ///
|
||||
"""Run claude interactively via PTY so the Skill tool is available.
|
||||
|
||||
In `claude -p` (print mode) the Skill tool is never offered — Claude handles
|
||||
everything inline. Running `claude` in interactive mode activates the Skill
|
||||
tool so dependency skills installed in .claude/skills/ can be properly invoked.
|
||||
|
||||
The PTY tricks claude into thinking it has a terminal (interactive mode) while
|
||||
we capture its stream-json output programmatically.
|
||||
|
||||
Usage:
|
||||
python3 pty_runner.py --prompt-file /path/to/prompt.txt \\
|
||||
--output /path/to/transcript.jsonl \\
|
||||
[--timeout 600]
|
||||
python3 pty_runner.py --prompt "Run headless. ..." --output transcript.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])|\r")
|
||||
|
||||
# How long to wait for claude to initialize before sending the prompt.
|
||||
# Claude loads skill registry, checks credentials, etc. on startup.
|
||||
INIT_WAIT_S = 5.0
|
||||
|
||||
# How long to wait after the stream-json 'result' event before killing claude.
|
||||
# Trailing tool-result output sometimes follows the result event.
|
||||
POST_RESULT_S = 4.0
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def run_interactive(prompt: str, output: Path, timeout: int = 600) -> None:
|
||||
"""Spawn claude interactively via PTY, send one prompt, capture transcript."""
|
||||
master, slave = pty.openpty()
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"claude",
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--dangerously-skip-permissions",
|
||||
],
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=slave,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave)
|
||||
|
||||
json_lines: list[str] = []
|
||||
buf = b""
|
||||
prompt_sent = False
|
||||
done_at: float | None = None
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.time() - start
|
||||
if elapsed > timeout:
|
||||
print(f"[pty_runner] timeout after {elapsed:.0f}s", file=sys.stderr)
|
||||
break
|
||||
if done_at is not None and (time.time() - done_at) > POST_RESULT_S:
|
||||
break
|
||||
|
||||
# Short select so we stay responsive but don't spin.
|
||||
r, _, _ = select.select([master], [], [], 0.3)
|
||||
|
||||
if r:
|
||||
try:
|
||||
chunk = os.read(master, 8192)
|
||||
except OSError:
|
||||
break # PTY closed — claude exited
|
||||
buf += chunk
|
||||
|
||||
# Process all complete lines in buffer.
|
||||
while b"\n" in buf:
|
||||
raw, buf = buf.split(b"\n", 1)
|
||||
line = _strip_ansi(raw.decode("utf-8", errors="replace")).strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
json_lines.append(line)
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
# 'result' marks end of a claude turn.
|
||||
if obj.get("type") == "result" and done_at is None:
|
||||
done_at = time.time()
|
||||
print(
|
||||
f"[pty_runner] result event at t={time.time()-start:.1f}s "
|
||||
f"({len(json_lines)} lines so far)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
# Silence window — send prompt once claude has had time to init.
|
||||
if not prompt_sent and (time.time() - start) >= INIT_WAIT_S:
|
||||
os.write(master, (prompt + "\n").encode())
|
||||
prompt_sent = True
|
||||
print(
|
||||
f"[pty_runner] prompt sent at t={time.time()-start:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Politely ask claude to exit, then hard-kill if needed.
|
||||
try:
|
||||
os.write(master, b"exit\n")
|
||||
time.sleep(0.3)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.close(master)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "\n".join(json_lines) + ("\n" if json_lines else "")
|
||||
output.write_text(content, encoding="utf-8")
|
||||
print(
|
||||
f"[pty_runner] wrote {len(json_lines)} transcript lines → {output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Run claude interactively via PTY and capture stream-json transcript"
|
||||
)
|
||||
grp = p.add_mutually_exclusive_group(required=True)
|
||||
grp.add_argument("--prompt", help="Prompt text")
|
||||
grp.add_argument("--prompt-file", type=Path, help="File containing the prompt")
|
||||
p.add_argument("--output", type=Path, required=True, help="Output .jsonl transcript file")
|
||||
p.add_argument("--timeout", type=int, default=600, help="Hard timeout in seconds")
|
||||
args = p.parse_args()
|
||||
|
||||
prompt = (
|
||||
args.prompt_file.read_text(encoding="utf-8").strip()
|
||||
if args.prompt_file
|
||||
else args.prompt
|
||||
)
|
||||
run_interactive(prompt, args.output, args.timeout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user