Use plan-status as the only machine state source for plan queueing, claiming, and finishing. Route record-plan through queue insertion and update templates/prompts to match the single-state model. BREAKING CHANGE: playbook.py -record-spec and main_loop.py record are removed.
621 lines
20 KiB
Python
621 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
import getpass
|
|
import os
|
|
import platform
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
try:
|
|
import fcntl
|
|
except ImportError: # pragma: no cover
|
|
fcntl = None
|
|
|
|
try:
|
|
import msvcrt
|
|
except ImportError: # pragma: no cover
|
|
msvcrt = None
|
|
|
|
PLAN_STATUS_START = "<!-- plan-status:start -->"
|
|
PLAN_STATUS_END = "<!-- plan-status:end -->"
|
|
WORKFLOW_STATE_START = "<!-- workflow-state:start -->"
|
|
WORKFLOW_STATE_END = "<!-- workflow-state:end -->"
|
|
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
|
|
PLAN_LINE_RE = re.compile(
|
|
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` "
|
|
r"(?P<status>done|blocked|pending|in-progress|skipped)"
|
|
r"(?:: (?P<note>.*))?$"
|
|
)
|
|
FINISH_STATUSES = {"done", "blocked", "skipped"}
|
|
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
|
|
PLAN_META_REQUIRED_FIELDS = (
|
|
"Verification Scope",
|
|
"Verification Gate",
|
|
)
|
|
THREAD_LOCKS: dict[str, threading.Lock] = {}
|
|
THREAD_LOCKS_GUARD = threading.Lock()
|
|
|
|
|
|
def usage() -> str:
|
|
return (
|
|
"Usage:\n"
|
|
" python scripts/main_loop.py claim -plans <dir> -progress <file>\n"
|
|
" python scripts/main_loop.py finish -plan <path> -status <status> "
|
|
"-progress <file> [-note <text>] [-verified <text>]\n"
|
|
" python scripts/main_loop.py status -plans <dir> -progress <file>\n"
|
|
" python scripts/main_loop.py -h\n"
|
|
"Options:\n"
|
|
" -plans DIR\n"
|
|
" -plan PATH\n"
|
|
" -status done|blocked|skipped\n"
|
|
" -progress FILE\n"
|
|
" -note TEXT\n"
|
|
" -owner NAME\n"
|
|
" -verified TEXT\n"
|
|
" -h, -help Show this help.\n"
|
|
)
|
|
|
|
|
|
def parse_flags(args: list[str]) -> dict[str, str]:
|
|
flags: dict[str, str] = {}
|
|
idx = 0
|
|
while idx < len(args):
|
|
arg = args[idx]
|
|
if arg in ("-h", "-help"):
|
|
raise ValueError("help")
|
|
if not arg.startswith("-"):
|
|
raise ValueError(f"unexpected arg: {arg}")
|
|
if idx + 1 >= len(args):
|
|
raise ValueError(f"missing value for {arg}")
|
|
flags[arg] = args[idx + 1]
|
|
idx += 2
|
|
return flags
|
|
|
|
|
|
def normalize_plan_key(plan_value: str) -> str:
|
|
raw = plan_value.strip().replace("\\", "/")
|
|
raw = raw.lstrip("./")
|
|
if raw.startswith("docs/superpowers/plans/"):
|
|
return raw[len("docs/superpowers/plans/") :]
|
|
marker = "/docs/superpowers/plans/"
|
|
if marker in raw:
|
|
return raw.split(marker, 1)[1]
|
|
return raw
|
|
|
|
|
|
def normalize_note(note: str) -> str:
|
|
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
|
|
|
|
|
|
def now_utc() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
"+00:00", "Z"
|
|
)
|
|
|
|
|
|
def default_claim_owner() -> str:
|
|
owner = os.environ.get("PLAYBOOK_MAIN_LOOP_OWNER")
|
|
if owner:
|
|
return normalize_note(owner)
|
|
host = socket.gethostname() or "unknown-host"
|
|
try:
|
|
user = getpass.getuser()
|
|
except Exception: # pragma: no cover
|
|
user = "unknown-user"
|
|
return f"{user}@{host}:{os.getpid()}"
|
|
|
|
|
|
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
|
|
checked = "x" if status == "done" else " "
|
|
suffix = status
|
|
if note:
|
|
suffix += f": {note}"
|
|
return f"- [{checked}] `{plan_key}` {suffix}"
|
|
|
|
|
|
def list_plan_files(plans_dir: Path) -> list[str]:
|
|
entries: list[str] = []
|
|
for path in plans_dir.iterdir():
|
|
if not path.is_file():
|
|
continue
|
|
if not PLAN_FILE_RE.match(path.name):
|
|
continue
|
|
entries.append(path.name)
|
|
return sorted(entries)
|
|
|
|
|
|
def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
|
|
start_idx = None
|
|
for idx, line in enumerate(lines):
|
|
if line.strip() == PLAN_STATUS_START:
|
|
start_idx = idx
|
|
break
|
|
if start_idx is None:
|
|
return None
|
|
for idx in range(start_idx + 1, len(lines)):
|
|
if lines[idx].strip() == PLAN_STATUS_END:
|
|
return start_idx, idx
|
|
return None
|
|
|
|
|
|
def find_named_block(
|
|
lines: list[str], start_marker: str, end_marker: str
|
|
) -> Optional[tuple[int, int]]:
|
|
start_idx = None
|
|
for idx, line in enumerate(lines):
|
|
if line.strip() == start_marker:
|
|
start_idx = idx
|
|
break
|
|
if start_idx is None:
|
|
return None
|
|
for idx in range(start_idx + 1, len(lines)):
|
|
if lines[idx].strip() == end_marker:
|
|
return start_idx, idx
|
|
return None
|
|
|
|
|
|
def parse_entries(
|
|
lines: list[str], start_idx: int, end_idx: int
|
|
) -> list[tuple[str, str, Optional[str], int]]:
|
|
entries: list[tuple[str, str, Optional[str], int]] = []
|
|
for idx in range(start_idx + 1, end_idx):
|
|
line = lines[idx].strip()
|
|
match = PLAN_LINE_RE.match(line)
|
|
if not match:
|
|
continue
|
|
plan_key = normalize_plan_key(match.group("plan"))
|
|
status = match.group("status")
|
|
note = match.group("note")
|
|
entries.append((plan_key, status, note, idx))
|
|
return entries
|
|
|
|
|
|
def render_progress_lines(plans: list[str]) -> list[str]:
|
|
lines = [
|
|
"# 当前进展",
|
|
"",
|
|
"## Plan Status",
|
|
"",
|
|
PLAN_STATUS_START,
|
|
]
|
|
for plan_key in plans:
|
|
lines.append(render_plan_line(plan_key, "pending", None))
|
|
lines.append(PLAN_STATUS_END)
|
|
return lines
|
|
|
|
|
|
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
|
|
if not note:
|
|
return None
|
|
match = ENV_BLOCKED_RE.match(note)
|
|
if not match:
|
|
return None
|
|
return match.group(1), match.group(2)
|
|
|
|
|
|
def detect_env() -> Optional[str]:
|
|
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
|
|
return mapping.get(platform.system().lower())
|
|
|
|
|
|
def load_progress_lines(progress_path: Path) -> list[str]:
|
|
progress_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if progress_path.exists():
|
|
return progress_path.read_text(encoding="utf-8").splitlines()
|
|
return []
|
|
|
|
|
|
def write_progress_lines(progress_path: Path, lines: list[str]) -> None:
|
|
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
|
|
|
|
def get_thread_lock(lock_path: Path) -> threading.Lock:
|
|
key = str(lock_path.resolve())
|
|
with THREAD_LOCKS_GUARD:
|
|
lock = THREAD_LOCKS.get(key)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
THREAD_LOCKS[key] = lock
|
|
return lock
|
|
|
|
|
|
@contextmanager
|
|
def locked_progress(progress_path: Path):
|
|
progress_path.parent.mkdir(parents=True, exist_ok=True)
|
|
lock_path = progress_path.with_name(f"{progress_path.name}.lock")
|
|
thread_lock = get_thread_lock(lock_path)
|
|
with thread_lock:
|
|
with lock_path.open("a+b") as lock_file:
|
|
if fcntl is not None:
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
elif msvcrt is not None: # pragma: no cover
|
|
while True:
|
|
try:
|
|
lock_file.seek(0)
|
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
break
|
|
except OSError:
|
|
time.sleep(0.05)
|
|
try:
|
|
hold_ms = os.environ.get("PLAYBOOK_MAIN_LOOP_HOLD_LOCK_MS")
|
|
if hold_ms:
|
|
time.sleep(max(0.0, float(hold_ms) / 1000.0))
|
|
yield
|
|
finally:
|
|
if fcntl is not None:
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
elif msvcrt is not None: # pragma: no cover
|
|
lock_file.seek(0)
|
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
|
|
|
|
def ensure_section(lines: list[str], heading: str) -> list[str]:
|
|
if any(line.strip() == heading for line in lines):
|
|
return lines
|
|
if lines and lines[-1] != "":
|
|
lines.append("")
|
|
lines.extend([heading, ""])
|
|
return lines
|
|
|
|
|
|
def ensure_block_with_lines(
|
|
lines: list[str],
|
|
start_marker: str,
|
|
end_marker: str,
|
|
default_lines: list[str],
|
|
heading: Optional[str] = None,
|
|
) -> tuple[list[str], int, int]:
|
|
block = find_named_block(lines, start_marker, end_marker)
|
|
if block:
|
|
return lines, block[0], block[1]
|
|
|
|
if not lines:
|
|
lines = ["# 当前进展", ""]
|
|
if heading:
|
|
lines = ensure_section(lines, heading)
|
|
if lines and lines[-1] != "":
|
|
lines.append("")
|
|
insert_at = len(lines)
|
|
lines[insert_at:insert_at] = default_lines
|
|
return lines, insert_at, insert_at + len(default_lines) - 1
|
|
|
|
|
|
def ensure_plan_block(
|
|
lines: list[str], progress_path: Path, plan_keys: list[str]
|
|
) -> tuple[list[str], int, int]:
|
|
lines = remove_workflow_state_block(lines)
|
|
lines, start_idx, end_idx = ensure_block_with_lines(
|
|
lines,
|
|
PLAN_STATUS_START,
|
|
PLAN_STATUS_END,
|
|
[PLAN_STATUS_START, PLAN_STATUS_END],
|
|
"## Plan Status",
|
|
)
|
|
write_progress_lines(progress_path, lines)
|
|
return lines, start_idx, end_idx
|
|
|
|
|
|
def remove_workflow_state_block(lines: list[str]) -> list[str]:
|
|
block = find_named_block(lines, WORKFLOW_STATE_START, WORKFLOW_STATE_END)
|
|
if not block:
|
|
return lines
|
|
|
|
start_idx, end_idx = block
|
|
remove_start = start_idx
|
|
if (
|
|
remove_start >= 2
|
|
and lines[remove_start - 1].strip() == ""
|
|
and lines[remove_start - 2].strip() == "## Workflow State"
|
|
):
|
|
remove_start -= 2
|
|
if remove_start > 0 and lines[remove_start - 1].strip() == "":
|
|
remove_start -= 1
|
|
|
|
remove_end = end_idx + 1
|
|
if remove_end < len(lines) and lines[remove_end].strip() == "":
|
|
remove_end += 1
|
|
del lines[remove_start:remove_end]
|
|
return lines
|
|
|
|
|
|
def ensure_all_plans_present(
|
|
lines: list[str],
|
|
start_idx: int,
|
|
end_idx: int,
|
|
progress_path: Path,
|
|
plan_keys: list[str],
|
|
) -> list[tuple[str, str, Optional[str], int]]:
|
|
entries = parse_entries(lines, start_idx, end_idx)
|
|
existing = {plan_key for plan_key, _, _, _ in entries}
|
|
missing = [plan_key for plan_key in plan_keys if plan_key not in existing]
|
|
if missing:
|
|
insert_lines = [
|
|
render_plan_line(plan_key, "pending", None) for plan_key in missing
|
|
]
|
|
lines[end_idx:end_idx] = insert_lines
|
|
write_progress_lines(progress_path, lines)
|
|
end_idx += len(insert_lines)
|
|
entries = parse_entries(lines, start_idx, end_idx)
|
|
return entries
|
|
|
|
|
|
def ensure_plan_status_entry(lines: list[str], plan_key: str) -> list[str]:
|
|
lines = remove_workflow_state_block(lines)
|
|
lines, start_idx, end_idx = ensure_block_with_lines(
|
|
lines,
|
|
PLAN_STATUS_START,
|
|
PLAN_STATUS_END,
|
|
[PLAN_STATUS_START, PLAN_STATUS_END],
|
|
"## Plan Status",
|
|
)
|
|
entries = parse_entries(lines, start_idx, end_idx)
|
|
if plan_key not in {entry_plan for entry_plan, _, _, _ in entries}:
|
|
lines[end_idx:end_idx] = [render_plan_line(plan_key, "pending", None)]
|
|
return lines
|
|
|
|
|
|
def filter_existing_entries(
|
|
entries: list[tuple[str, str, Optional[str], int]], plan_keys: list[str]
|
|
) -> list[tuple[str, str, Optional[str], int]]:
|
|
available = set(plan_keys)
|
|
return [entry for entry in entries if entry[0] in available]
|
|
|
|
|
|
def validate_plan_meta(plan_path: Path) -> list[str]:
|
|
text = plan_path.read_text(encoding="utf-8")
|
|
missing: list[str] = []
|
|
if not re.search(r"(?im)^##\s+Plan Meta\s*$", text):
|
|
missing.append("Plan Meta")
|
|
for field in PLAN_META_REQUIRED_FIELDS:
|
|
pattern = rf"(?im)^\s*[-*]\s+(?:\*\*)?{re.escape(field)}(?:\*\*)?\s*:"
|
|
if not re.search(pattern, text):
|
|
missing.append(field)
|
|
return missing
|
|
|
|
|
|
def validate_plan_files(plans_dir: Path, plan_keys: list[str]) -> Optional[str]:
|
|
for plan_key in plan_keys:
|
|
missing = validate_plan_meta(plans_dir / plan_key)
|
|
if missing:
|
|
fields = ", ".join(missing)
|
|
return f"ERROR: {plan_key} missing required Plan Meta fields: {fields}"
|
|
return None
|
|
|
|
|
|
def choose_claim_entry(
|
|
entries: list[tuple[str, str, Optional[str], int]],
|
|
current_env: Optional[str],
|
|
plan_keys: list[str],
|
|
) -> Optional[tuple[str, Optional[str], int]]:
|
|
available = set(plan_keys)
|
|
ordered_entries = [entry for entry in entries if entry[0] in available]
|
|
|
|
for plan_key, status, note, idx in ordered_entries:
|
|
if status == "in-progress":
|
|
return plan_key, note, idx
|
|
|
|
for plan_key, status, note, idx in ordered_entries:
|
|
if status == "pending":
|
|
return plan_key, note, idx
|
|
if status != "blocked" or not current_env:
|
|
continue
|
|
env_info = parse_env_blocked_note(note)
|
|
if env_info and env_info[0] == current_env:
|
|
return plan_key, note, idx
|
|
|
|
return None
|
|
|
|
|
|
def claim_plan(
|
|
plans_dir: Path, progress_path: Path, owner: Optional[str] = None
|
|
) -> tuple[int, str]:
|
|
if not plans_dir.is_dir():
|
|
return 2, f"ERROR: plans dir not found: {plans_dir}"
|
|
|
|
plan_keys = list_plan_files(plans_dir)
|
|
if not plan_keys:
|
|
return 2, "ERROR: no plan files found"
|
|
plan_error = validate_plan_files(plans_dir, plan_keys)
|
|
if plan_error:
|
|
return 2, plan_error
|
|
|
|
with locked_progress(progress_path):
|
|
lines = load_progress_lines(progress_path)
|
|
try:
|
|
lines, start_idx, end_idx = ensure_plan_block(
|
|
lines, progress_path, plan_keys
|
|
)
|
|
except ValueError as exc:
|
|
return 2, f"ERROR: {exc}"
|
|
|
|
entries = ensure_all_plans_present(
|
|
lines, start_idx, end_idx, progress_path, plan_keys
|
|
)
|
|
entries = filter_existing_entries(entries, plan_keys)
|
|
chosen = choose_claim_entry(entries, detect_env(), plan_keys)
|
|
if not chosen:
|
|
return 0, "NOOP: no claimable plans"
|
|
|
|
plan_key, note, idx = chosen
|
|
claim_note = (
|
|
f"claimed_by: {normalize_note(owner) if owner else default_claim_owner()}; "
|
|
f"claimed_at: {now_utc()}"
|
|
)
|
|
updated_note = f"{note}; {claim_note}" if note else claim_note
|
|
lines[idx] = render_plan_line(plan_key, "in-progress", updated_note)
|
|
write_progress_lines(progress_path, lines)
|
|
|
|
output = [f"PLAN={(plans_dir / plan_key).as_posix()}"]
|
|
if note:
|
|
output.append(f"NOTE={note}")
|
|
return 0, "\n".join(output)
|
|
|
|
|
|
def finish_plan(
|
|
plan: str,
|
|
status: str,
|
|
progress_path: Path,
|
|
note: Optional[str],
|
|
verified: Optional[str] = None,
|
|
) -> tuple[int, str]:
|
|
if status not in FINISH_STATUSES:
|
|
return 2, f"ERROR: invalid status: {status}"
|
|
if not plan:
|
|
return 2, "ERROR: plan is required"
|
|
if verified and status != "done":
|
|
return 2, "ERROR: -verified is only valid with -status done"
|
|
|
|
plan_key = normalize_plan_key(plan)
|
|
with locked_progress(progress_path):
|
|
lines = load_progress_lines(progress_path)
|
|
|
|
try:
|
|
lines, start_idx, end_idx = ensure_plan_block(
|
|
lines, progress_path, [plan_key]
|
|
)
|
|
except ValueError as exc:
|
|
return 2, f"ERROR: {exc}"
|
|
|
|
entries = parse_entries(lines, start_idx, end_idx)
|
|
rendered_note = normalize_note(note) if note else None
|
|
rendered_verified = normalize_note(verified) if verified else None
|
|
if rendered_verified:
|
|
verified_note = f"verified: {rendered_verified}"
|
|
rendered_note = (
|
|
f"{verified_note}; note: {rendered_note}"
|
|
if rendered_note
|
|
else verified_note
|
|
)
|
|
updated_line = render_plan_line(plan_key, status, rendered_note)
|
|
|
|
for entry_plan, _, _, idx in entries:
|
|
if entry_plan == plan_key:
|
|
lines[idx] = updated_line
|
|
write_progress_lines(progress_path, lines)
|
|
return 0, updated_line
|
|
|
|
lines[end_idx:end_idx] = [updated_line]
|
|
write_progress_lines(progress_path, lines)
|
|
return 0, updated_line
|
|
|
|
|
|
def status_report(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
|
if not plans_dir.is_dir():
|
|
return 2, f"ERROR: plans dir not found: {plans_dir}"
|
|
|
|
plan_keys = list_plan_files(plans_dir)
|
|
lines = load_progress_lines(progress_path)
|
|
block = find_block(lines)
|
|
entries: list[tuple[str, str, Optional[str], int]] = []
|
|
if block:
|
|
entries = filter_existing_entries(parse_entries(lines, *block), plan_keys)
|
|
|
|
entry_by_plan = {plan_key: (status, note) for plan_key, status, note, _ in entries}
|
|
counts = {status: 0 for status in ("pending", "in-progress", "done", "blocked", "skipped")}
|
|
rows: list[str] = []
|
|
for plan_key in plan_keys:
|
|
status, note = entry_by_plan.get(plan_key, ("pending", None))
|
|
counts[status] += 1
|
|
suffix = f": {note}" if note else ""
|
|
rows.append(f"PLAN {plan_key} {status}{suffix}")
|
|
|
|
output = [
|
|
"STATUS "
|
|
f"total={len(plan_keys)} "
|
|
f"pending={counts['pending']} "
|
|
f"in-progress={counts['in-progress']} "
|
|
f"done={counts['done']} "
|
|
f"blocked={counts['blocked']} "
|
|
f"skipped={counts['skipped']}"
|
|
]
|
|
output.extend(rows)
|
|
return 0, "\n".join(output)
|
|
|
|
|
|
def record_plan(progress_path: Path, plan: str) -> tuple[int, str]:
|
|
with locked_progress(progress_path):
|
|
lines = load_progress_lines(progress_path)
|
|
lines = ensure_plan_status_entry(lines, normalize_plan_key(plan))
|
|
write_progress_lines(progress_path, lines)
|
|
return 0, "OK"
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if not argv:
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
if argv[0] in ("-h", "-help"):
|
|
print(usage())
|
|
return 0
|
|
|
|
mode = argv[0]
|
|
if mode not in {"claim", "finish", "status"}:
|
|
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
flags = parse_flags(argv[1:])
|
|
except ValueError as exc:
|
|
if str(exc) == "help":
|
|
print(usage())
|
|
return 0
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
|
|
if mode == "claim":
|
|
plans = flags.get("-plans")
|
|
progress = flags.get("-progress")
|
|
owner = flags.get("-owner")
|
|
if not plans or not progress:
|
|
print("ERROR: -plans and -progress are required", file=sys.stderr)
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
code, message = claim_plan(Path(plans), Path(progress), owner)
|
|
if code != 0:
|
|
print(message, file=sys.stderr)
|
|
return code
|
|
print(message)
|
|
return 0
|
|
|
|
if mode == "status":
|
|
plans = flags.get("-plans")
|
|
progress = flags.get("-progress")
|
|
if not plans or not progress:
|
|
print("ERROR: -plans and -progress are required", file=sys.stderr)
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
code, message = status_report(Path(plans), Path(progress))
|
|
if code != 0:
|
|
print(message, file=sys.stderr)
|
|
return code
|
|
print(message)
|
|
return 0
|
|
|
|
plan = flags.get("-plan")
|
|
status = flags.get("-status")
|
|
progress = flags.get("-progress")
|
|
note = flags.get("-note")
|
|
verified = flags.get("-verified")
|
|
if not plan or not status or not progress:
|
|
print("ERROR: -plan, -status, and -progress are required", file=sys.stderr)
|
|
print(usage(), file=sys.stderr)
|
|
return 2
|
|
code, message = finish_plan(plan, status, Path(progress), note, verified)
|
|
if code != 0:
|
|
print(message, file=sys.stderr)
|
|
return code
|
|
print(message)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|