From 6c4bf449aa7ce0dfbe8034167a1a96781da10ef2 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Sun, 2 Aug 2026 21:18:02 +1200 Subject: [PATCH] idle-draft: idle-subscription dispatcher for the writing pipeline Elapsed-paced gates, bounded-parallel event-driven dispatch, filesystem- derived stages with thin human-gate state, citation validation with quarantine, credential parity check, prompt templates as data. Per IDLE-DRAFT-PLAN.md in writing/oreillyconsulting. Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc --- data/idle-draft/config.example.json | 24 + data/idle-draft/prompts/draft.md | 55 + data/idle-draft/prompts/research.md | 89 ++ data/idle-draft/prompts/review-suggest.md | 62 + data/idle-draft/prompts/topic-ideas.md | 67 ++ scripts/idle-draft | 1273 +++++++++++++++++++++ specs/idle-draft.spec.md | 355 ++++++ tests/test-idle-draft.sh | 473 ++++++++ 8 files changed, 2398 insertions(+) create mode 100644 data/idle-draft/config.example.json create mode 100644 data/idle-draft/prompts/draft.md create mode 100644 data/idle-draft/prompts/research.md create mode 100644 data/idle-draft/prompts/review-suggest.md create mode 100644 data/idle-draft/prompts/topic-ideas.md create mode 100755 scripts/idle-draft create mode 100644 specs/idle-draft.spec.md create mode 100755 tests/test-idle-draft.sh diff --git a/data/idle-draft/config.example.json b/data/idle-draft/config.example.json new file mode 100644 index 0000000..186a7a1 --- /dev/null +++ b/data/idle-draft/config.example.json @@ -0,0 +1,24 @@ +{ + "parallel": 2, + "providers": { + "anthropic": { "profile": "~/.claude-oreillyit-anthropic", "threshold_pct": 80, + "five_hour_ceiling": 50, "min_idle": 5 }, + "minimax": { "profile": "~/.claude-oreillyit-minimax", "threshold_pct": 80, + "five_hour_ceiling": 50, "min_idle": 5 } + }, + "work_types": { + "review": { "providers": ["anthropic"] }, + "draft": { "providers": ["anthropic"] }, + "research": { "providers": ["anthropic", "minimax"] }, + "topic_ideas": { "providers": ["anthropic", "minimax"] } + }, + "dossiers": ["ai", "ai-technical", "devops-2020", "devops-2020-technical"], + "review_score_threshold": 11, + "max_unreviewed_research_per_dossier": 3, + "max_open_topic_proposals": 6, + "evidence_dirs": [ + "~/dev/claude/octopus/goes", + "~/dev/claude/projects/agent-runtimes", + "~/dev/claude/projects/agent-runtimes-work-items" + ] +} diff --git a/data/idle-draft/prompts/draft.md b/data/idle-draft/prompts/draft.md new file mode 100644 index 0000000..2475e94 --- /dev/null +++ b/data/idle-draft/prompts/draft.md @@ -0,0 +1,55 @@ +You are producing the **published-track draft article** for one topic in an O'Reilly +Consulting writing dossier. This is unattended, headless work — a human edits this +draft before it moves further, but there is no human in the loop while you write it. +Follow this brief exactly; do not invent structure. + +## Item + +- Dossier: `$dossier` +- Topic: `$slug` +- Topic brief: $overview_path +- Commissioning brief — **this is the contract for the article; follow its required + structure exactly**: $agent_path +- Research file — your evidence source, already validated: $research_path +- Dossier commissioning guide: $dossier_agents_path +- Project-root voice, style, and citation rules — read before writing a single line: + $agents_root_path +- Voice reference (read this one in full; it is the canonical synthesis): $style_dir/goes-target-voice.md + +## What to produce + +Follow the commissioning brief's required structure exactly — it is the contract, not +a suggestion. Do not add sections it doesn't name; do not omit sections it does. + +## Voice rules (non-negotiable) + +- **Sentence-case headings** everywhere: "The decision," not "The Decision." +- **Consulting dossiers** (`ai`, `devops-2020`): open with a judgment the reader would + pay for, not a category they already know. **Do not name vendors in prose.** If a + source credits a specific vendor or product, say "the platform team" or "their + engineering organisation" instead — vendor names stay in the source register and the + commissioning brief, never in the published copy. +- **Technical dossiers** (`ai-technical`, `devops-2020-technical`): open with the + specific toil the reader lives with, not the category a vendor markets. Naming a + specific tool, script, or system by its actual identifier is fine here — this is + implementation detail, not vendor marketing. +- **Cite the file, not the concept**, for any code or spec reference (path + line + range). **Make a claim, cite the case, name the source**, for any research finding. + Use the dossier's correct status-label family (see `$agents_root_path`) on every + cited claim. +- **Distinguish research from implementation.** Do not conflate "the research finds X" + with "the implementation does Y." The research is the question; the implementation + is one possible answer; state the connection explicitly. +- No hedge words where the evidence supports a direct claim. No marketing language. No + AI tells (see the burn list in `$style_dir/tone-and-voice.md` if you are unsure what + that means). + +## Output + +Write the complete Markdown article to exactly this path, and nothing else: + + $output_path + +The dispatcher validates this file (non-empty, starts with a top-level heading) and +promotes it to its canonical location itself. A human edits this draft next — write it +as if a careful editor, not a lenient one, will read it in the next hour. diff --git a/data/idle-draft/prompts/research.md b/data/idle-draft/prompts/research.md new file mode 100644 index 0000000..8e9fd9f --- /dev/null +++ b/data/idle-draft/prompts/research.md @@ -0,0 +1,89 @@ +You are producing the **research file** for one topic in an O'Reilly Consulting +writing dossier. This is unattended, headless work — there is no human in the loop +until a reviewer samples your output later. Follow this brief exactly. + +## Item + +- Dossier: `$dossier` +- Topic: `$slug` +- Topic brief (read first, in full): $overview_path +- Commissioning brief (read second, in full): $agent_path +- Dossier commissioning guide (dossier-specific rules): $dossier_agents_path +- Project-root voice and citation rules (read before writing a single line): $agents_root_path +- Source register (existing sources for this dossier — read before searching for new + ones; do not duplicate a source it already lists): $source_register_path + +Evidence you may cite must live under one of these directories (you have read access +via `--add-dir`; do not invent paths outside them): + +$evidence_dirs + +## What to produce + +Write a Markdown research file with two kinds of content, matching what the topic +brief's evidence section and the commissioning brief already point at, extended with +anything new and relevant you find under the evidence directories above: + +1. **Objective evidence** — concrete examples, code references (file path + line range, + not just "the script does X"), measurables, named findings. Every claim is sourced. +2. **Subjective stories** — case-study-like narratives that make the evidence concrete + for a reader (a scene, a named organisation's experience, a practitioner's account) + — still sourced, but framed as narrative rather than a bare statistic. + +## Citation rules (non-negotiable — read `$agents_root_path` for the full statement) + +- **Cite the file, not the concept.** Any code or spec reference must name the file + path and the line range: `path/to/file.py:120-145`, not "the script does X." +- **Cite the requirement ID where one exists** (e.g. `H-*`, `AU-*`, `WT-*`, `KX-*`, + `BRK-*`, `WF-*` prefixes in the agent-runtimes spec) — quote the ID, not a prose + paraphrase. +- **Quote metric markers verbatim** where GOES research uses them (`{p.3}`, `{s.4}`, + `{t.00:14:32}`) — do not renumber or invent markers. +- Use the dossier's status-label family and apply it correctly: + - `ai` / `ai-technical` dossiers use **implementation maturity**: "Implemented and + running in production" / "Designed and specified, implementation in progress" / + "Spec-only / future." A claim about an `implemented` feature may report a deployed + behaviour or measured metric; a `spec-only` claim may not. + - `devops-2020` / `devops-2020-technical` dossiers use **evidence quality**: "Named + organisation, self-reported" / "Research-survey finding" / "GOES synthesis / bet" / + "Vendor data, treat with caution." Label every cited source with the correct one. +- Every source you cite gets an explicit status label. Do not present an unlabelled + claim as settled fact. + +## Required structure + +```markdown +# Research: + +## Objective evidence + + + +## Subjective stories + + + +## Register delta + + +``` + +All headings are **sentence case** ("Objective evidence," not "Objective Evidence"). + +## Output + +Write the complete Markdown file to exactly this path, and nothing else — do not touch +any other file in the repository, do not edit the source register, do not create the +canonical `$slug.research.md` file yourself: + + $output_path + +The dispatcher validates this file (non-empty, starts with a top-level heading, every +absolute `/home/...` path you cite resolves on disk) and promotes it to its canonical +location itself. If any cited absolute path does not exist, your output will be +rejected — verify paths before citing them, don't guess. diff --git a/data/idle-draft/prompts/review-suggest.md b/data/idle-draft/prompts/review-suggest.md new file mode 100644 index 0000000..1fb81bc --- /dev/null +++ b/data/idle-draft/prompts/review-suggest.md @@ -0,0 +1,62 @@ +You are producing the **review file** for one human-edited draft in an O'Reilly +Consulting writing dossier. The draft has already been scored per-paragraph on the +5-dimension `/15` rubric (Evidence, Judgment, Voice, Rhythm, Register — see +`score-paragraphs`); your job is the **second pass**: concrete, actionable improvement +suggestions for every paragraph that scored below the threshold. + +## Item + +- Dossier: `$dossier` +- Topic: `$slug` +- Human-edited draft (already scored, score blocks inline): $draft_path +- Grading template — apply the same rubric and voice bar when suggesting fixes: + $review_prompt_path +- Voice reference: $style_dir/goes-target-voice.md +- Score threshold on the `/15` scale: **$review_score_threshold** — any paragraph + scoring below this needs a suggestion; paragraphs at or above it need none. + +## What to produce + +For every paragraph in the draft whose inline score block shows a total below +`$review_score_threshold`: + +- Quote the paragraph's opening few words so a human can locate it. +- Name which of the five dimensions is weakest and why, in one sentence. +- Give a **concrete rewrite direction** — not "improve the evidence," but what + specific evidence, source, or file/line citation would raise the score, or what + specific sentence-level change (cut a hedge word, vary sentence length, cut a + vendor name, cite the file instead of the concept) would fix it. + +Do not rewrite the paragraph yourself — suggest, don't replace. The human author makes +the edit. + +## Required structure + +```markdown +# Review: + +## Summary + + + +## Suggestions + +### Paragraph opening: "" + +- Score: /15 (weakest dimension: ) +- Suggestion: + + +``` + +All headings are **sentence case**. + +## Output + +Write the complete Markdown file to exactly this path, and nothing else: + + $output_path + +The dispatcher validates this file (non-empty, starts with a top-level heading) and +promotes it to its canonical location itself. diff --git a/data/idle-draft/prompts/topic-ideas.md b/data/idle-draft/prompts/topic-ideas.md new file mode 100644 index 0000000..1376842 --- /dev/null +++ b/data/idle-draft/prompts/topic-ideas.md @@ -0,0 +1,67 @@ +You are proposing **new candidate topics** for one dossier in the O'Reilly Consulting +writing pipeline. This runs only when no other work is currently eligible — the +dossier's existing topics are all blocked on a human step or already have enough work +in flight. Do not invent structure; follow this brief. + +## Dossier + +- Dossier: `$dossier` +- Dossier commissioning guide: $dossier_agents_path +- Project-root voice and citation rules: $agents_root_path +- Voice reference: $style_dir/goes-target-voice.md + +Evidence you may cite must live under one of these directories (you have read access +via `--add-dir`): + +$evidence_dirs + +## Do not duplicate an existing topic + +These titles already exist as numbered topic briefs or open proposals in this dossier. +Do **not** propose anything that duplicates or trivially rephrases one of these: + +$existing_titles + +## What to produce + +Propose **up to $max_new** new candidate topics for this dossier, each grounded in +something you actually found under the evidence directories above — not a generic +industry-trend guess. For each candidate: + +- A working title (sentence case, matching the dossier's existing topic-title style). +- A one-paragraph pitch: the decision or toil the topic would address, matching the + audience of this dossier (CTO/VP for consulting dossiers, implementing engineers for + technical dossiers — see `$dossier_agents_path` for which this is). +- 2–4 evidence pointers: file paths under the evidence directories, with a one-line + note on what each contains. Use the dossier's correct status-label family (see + `$agents_root_path`) on each. + +You are proposing, not commissioning: **never create a numbered `NN-slug` file.** A +human promotes a proposal to a numbered brief later. + +## Required structure (append this, do not replace the file) + +```markdown +## + +**Pitch:** + +**Evidence:** + +- +- +``` + +One `##` block per proposed topic. All headings sentence case. No vendor names in +prose for consulting dossiers (`ai`, `devops-2020`) — vendor names may stay in the +evidence pointers' one-line notes only. + +## Output + +Write **only the new `##` blocks** (not the whole file — this content will be appended +to the dossier's existing `TOPIC-PROPOSALS.md`) to exactly this path: + + $output_path + +The dispatcher validates this file (non-empty, starts with a top-level `##` heading) +and appends it to the canonical proposals file itself. diff --git a/scripts/idle-draft b/scripts/idle-draft new file mode 100755 index 0000000..ec40c89 --- /dev/null +++ b/scripts/idle-draft @@ -0,0 +1,1273 @@ +#!/usr/bin/env python3 +"""Idle-subscription dispatcher for the O'Reilly Consulting writing pipeline. + +Spec: specs/idle-draft.spec.md +Design contract: ~/dev/claude/writing/oreillyconsulting/IDLE-DRAFT-PLAN.md +""" + +from __future__ import annotations + +import concurrent.futures +import fcntl +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from string import Template + +# === Constants replicated from scripts/agent-subscriptions (keep in sync) === +# idle-draft must resolve the same credential paths agent-subscriptions probes +# with, to run the credential-parity check (IDLE-DRAFT-PLAN.md, finding C2). + +ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser() +MINIMAX_SOPS_PATH = Path( + "~/dev/claude/projects/agent-runtime-secrets/providers/minimax/v1/provider.sops.env" +).expanduser() +MINIMAX_SOPS_KEY = Path("~/dev/claude/secrets/sops/provider-age-key.txt").expanduser() +MINIMAX_DOTENV_KEY = "ANTHROPIC_AUTH_TOKEN" + +# === Work-item / pipeline constants === + +WORK_TYPES = ("research", "draft", "review", "topic_ideas") +STAGE_RANK = {"review": 3, "draft": 2, "research": 1, "topic_ideas": 0} +SUFFIX = {"research": "research", "draft": "draft", "review": "review"} +PROMPT_TEMPLATE_FILES = { + "research": "research.md", + "draft": "draft.md", + "review": "review-suggest.md", + "topic_ideas": "topic-ideas.md", +} +# Expected first-line heading prefix per work type, used by output validation. +EXPECTED_HEADING_PREFIX = { + "research": "# ", + "draft": "# ", + "review": "# ", + "topic_ideas": "## ", +} +ALLOWED_ITEM_KEYS = { + "human_edit_done", + "research_sampled", + "approved", + "blocked", + "attempts", +} +ALLOWED_ATTEMPT_WORK_TYPES = {"research", "draft", "review"} + +DEFAULT_MAX_TURNS = 25 +DEFAULT_TASK_TIMEOUT_SECONDS = 1800 +DEFAULT_MAX_ATTEMPTS = 2 +DEFAULT_PARALLEL = 2 + +REQUIRED_CONFIG_KEYS = ( + "parallel", + "providers", + "work_types", + "dossiers", + "review_score_threshold", + "max_unreviewed_research_per_dossier", + "max_open_topic_proposals", + "evidence_dirs", +) + +RETRYABLE_STDERR_MARKERS = ( + "429", + "rate limit", + "rate_limit", + "500", + "502", + "503", + "504", + "overloaded", + "temporarily unavailable", + "connection reset", + "econnreset", + "timed out", + "timeout", +) + +ITEM_FILENAME_RE = re.compile(r"^(\d+)-(.+)\.overview\.md$") +ABS_PATH_RE = re.compile(r"/home/[^\s`)\]\"'>,;]+") + +SCRIPT_PATH = Path(__file__).resolve() +REPO_SELF = SCRIPT_PATH.parent.parent # small-scripts repo (holds data/idle-draft) +PROMPTS_DIR = REPO_SELF / "data" / "idle-draft" / "prompts" + + +# === Errors === + + +class ConfigError(Exception): + pass + + +class StateValidationError(Exception): + pass + + +# === Small utilities === + + +def expand(p: str | Path) -> Path: + return Path(str(p)).expanduser() + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_text(text: str) -> str: + return sha256_bytes(text.encode("utf-8")) + + +def atomic_write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") + try: + with os.fdopen(fd, "w") as f: + f.write(text) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +# === Logging === + + +def rotate_log_if_needed(repo: Path) -> None: + log_path = repo / "idle-draft.log" + try: + if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024: + rotated = repo / "idle-draft.log.1" + os.replace(log_path, rotated) + except OSError: + pass + + +def log_event(repo: Path, message: str) -> None: + line = f"{iso_now()} {message}" + print(line, file=sys.stderr) + try: + log_path = repo / "idle-draft.log" + with open(log_path, "a") as f: + f.write(line + "\n") + except OSError: + pass + + +# === Locking === + + +def acquire_lock(repo: Path): + """Returns an open file object holding the lock, or None if already locked.""" + lock_path = repo / ".idle-draft.lock" + f = open(lock_path, "w") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + f.close() + return None + return f + + +# === Config === + + +def load_config(path: Path) -> dict: + if not path.exists(): + raise ConfigError(f"config file not found: {path}") + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise ConfigError(f"config file is not valid JSON: {e}") from e + if not isinstance(data, dict): + raise ConfigError("config file must be a JSON object") + missing = [k for k in REQUIRED_CONFIG_KEYS if k not in data] + if missing: + raise ConfigError(f"config missing required key(s): {', '.join(missing)}") + if not isinstance(data["providers"], dict) or not data["providers"]: + raise ConfigError("config 'providers' must be a non-empty object") + if not isinstance(data["work_types"], dict) or not data["work_types"]: + raise ConfigError("config 'work_types' must be a non-empty object") + if not isinstance(data["dossiers"], list) or not data["dossiers"]: + raise ConfigError("config 'dossiers' must be a non-empty array") + if not isinstance(data["evidence_dirs"], list): + raise ConfigError("config 'evidence_dirs' must be an array") + for pname, pcfg in data["providers"].items(): + for key in ("profile", "threshold_pct", "five_hour_ceiling", "min_idle"): + if key not in pcfg: + raise ConfigError(f"provider '{pname}' missing key '{key}'") + for wtype, wcfg in data["work_types"].items(): + if "providers" not in wcfg or not isinstance(wcfg["providers"], list): + raise ConfigError(f"work_type '{wtype}' missing 'providers' list") + return data + + +# === State === + + +def default_state() -> dict: + return {"items": {}} + + +def load_state(path: Path, repo: Path) -> dict: + if not path.exists(): + return default_state() + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise StateValidationError(f"state file is not valid JSON: {e}") from e + validate_state(data, repo) + return data + + +def validate_state(data: dict, repo: Path) -> None: + if not isinstance(data, dict): + raise StateValidationError("state file must be a JSON object") + extra_top = set(data.keys()) - {"items"} + if extra_top: + raise StateValidationError(f"state file has unknown top-level key(s): {sorted(extra_top)}") + items = data.get("items", {}) + if not isinstance(items, dict): + raise StateValidationError("state 'items' must be an object") + for key, entry in items.items(): + if "/" not in key: + raise StateValidationError(f"state item key '{key}' is not of the form dossier/NN-slug") + dossier, slug = key.split("/", 1) + overview = repo / dossier / f"{slug}.overview.md" + if not overview.exists(): + raise StateValidationError( + f"state item key '{key}' does not resolve to an existing overview file: {overview}" + ) + if not isinstance(entry, dict): + raise StateValidationError(f"state item '{key}' value must be an object") + extra_keys = set(entry.keys()) - ALLOWED_ITEM_KEYS + if extra_keys: + raise StateValidationError(f"state item '{key}' has unknown key(s): {sorted(extra_keys)}") + for bkey in ("human_edit_done", "research_sampled", "approved"): + if bkey in entry and not isinstance(entry[bkey], bool): + raise StateValidationError(f"state item '{key}'.{bkey} must be a bool") + if "blocked" in entry and entry["blocked"] is not None and not isinstance(entry["blocked"], str): + raise StateValidationError(f"state item '{key}'.blocked must be a string or null") + if "attempts" in entry: + attempts = entry["attempts"] + if not isinstance(attempts, dict): + raise StateValidationError(f"state item '{key}'.attempts must be an object") + for wtype, count in attempts.items(): + if wtype not in ALLOWED_ATTEMPT_WORK_TYPES: + raise StateValidationError( + f"state item '{key}'.attempts has unknown work type '{wtype}'" + ) + if not isinstance(count, int) or isinstance(count, bool) or count < 0: + raise StateValidationError( + f"state item '{key}'.attempts.{wtype} must be a non-negative int" + ) + + +def get_item_state(state: dict, key: str) -> dict: + entry = state.get("items", {}).get(key, {}) + return { + "human_edit_done": entry.get("human_edit_done", False), + "research_sampled": entry.get("research_sampled", False), + "approved": entry.get("approved", False), + "blocked": entry.get("blocked"), + "attempts": dict(entry.get("attempts", {})), + } + + +def save_state_atomic(path: Path, state: dict) -> None: + atomic_write_json(path, state) + + +# === provider.env parsing (replicates scripts/claude-profile ~L320-440) === + + +def parse_provider_env(path: Path) -> dict: + """Parse a provider.env file the way claude-profile does. + + Returns {"model_id": str|None, "base_url": str|None, "api_key_file": str|None, + "extra": {KEY: value, ...}}. Lines are KEY=value; blank lines and lines starting + with '#' are skipped. Unrecognised keys fall into 'extra' verbatim, mirroring + claude-profile's PROVIDER_EXTRA_ENV accumulation. + """ + result: dict = {"model_id": None, "base_url": None, "api_key_file": None, "extra": {}} + if not path.exists(): + return result + for raw_line in path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if key == "MODEL_ID": + result["model_id"] = value + elif key == "ANTHROPIC_BASE_URL": + result["base_url"] = value + elif key == "ANTHROPIC_API_KEY_FILE": + result["api_key_file"] = value + else: + result["extra"][key] = value + return result + + +def build_child_env(profile_dir: Path, base_env: dict | None = None) -> dict: + """Build the environment for a headless `claude -p` child, replicating + claude-profile's export logic (L423-435) without invoking claude-profile.""" + env = dict(base_env if base_env is not None else os.environ) + env["CLAUDE_CONFIG_DIR"] = str(profile_dir) + provider_env_path = profile_dir / "provider.env" + parsed = parse_provider_env(provider_env_path) + if parsed["base_url"]: + env["ANTHROPIC_BASE_URL"] = parsed["base_url"] + if parsed["api_key_file"]: + key_file = expand(parsed["api_key_file"]) + if key_file.exists(): + env["ANTHROPIC_API_KEY"] = key_file.read_text().strip() + for k, v in parsed["extra"].items(): + env[k] = v + return env + + +def resolve_model_id(profile_dir: Path) -> str | None: + parsed = parse_provider_env(profile_dir / "provider.env") + return parsed["model_id"] + + +# === Credential parity (IDLE-DRAFT-PLAN.md finding C2) === + + +def _credential_parity_from_values(profile_value: str, probe_value: str) -> tuple[bool, str]: + """Pure comparison: do two credential strings hash the same? Testable without + touching any file, secret, or subprocess.""" + if not profile_value or not probe_value: + return False, "one or both credential values are empty" + a, b = sha256_text(profile_value), sha256_text(probe_value) + if a == b: + return True, "match" + return False, f"hash mismatch (profile={a[:12]}... probe={b[:12]}...)" + + +def resolve_profile_credential_value(provider_name: str, profile_dir: Path) -> str: + """Read the credential the given profile would export. Raises on failure.""" + parsed = parse_provider_env(profile_dir / "provider.env") + if parsed["api_key_file"]: + key_file = expand(parsed["api_key_file"]) + return key_file.read_text().strip() + if provider_name == "anthropic": + return ANTHROPIC_TOKEN_PATH.read_text().strip() + raise ValueError( + f"profile '{profile_dir}' has no provider.env ANTHROPIC_API_KEY_FILE and " + f"provider '{provider_name}' has no fallback credential source" + ) + + +def read_anthropic_probe_credential() -> str: + return ANTHROPIC_TOKEN_PATH.read_text().strip() + + +def _parse_dotenv_key(content: str, key: str) -> str: + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + k, _, v = line.partition("=") + elif ": " in line: + k, _, v = line.partition(": ") + else: + continue + if k.strip() == key: + v = v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"): + v = v[1:-1] + return v + raise KeyError(f"Key '{key}' not found in decrypted content") + + +def read_minimax_probe_credential() -> str: + env = {**os.environ, "SOPS_AGE_KEY_FILE": str(MINIMAX_SOPS_KEY)} + result = subprocess.run( + ["sops", "--decrypt", "--output-type", "dotenv", str(MINIMAX_SOPS_PATH)], + capture_output=True, text=True, check=True, env=env, + ) + return _parse_dotenv_key(result.stdout, MINIMAX_DOTENV_KEY) + + +PROBE_CREDENTIAL_READERS = { + "anthropic": read_anthropic_probe_credential, + "minimax": read_minimax_probe_credential, +} + + +def credential_parity(provider_name: str, profile_dir: Path, probe_credential_fn=None) -> tuple[bool, str]: + """Full parity check for one provider: resolves both sides and compares. + + `probe_credential_fn` is injectable (tests pass a fixture callable instead of + hitting sops/secrets); defaults to the real reader for `provider_name`. + """ + reader = probe_credential_fn or PROBE_CREDENTIAL_READERS.get(provider_name) + if reader is None: + return False, f"no probe credential reader for provider '{provider_name}'" + try: + profile_value = resolve_profile_credential_value(provider_name, profile_dir) + except Exception as e: + return False, f"profile credential unreadable: {e}" + try: + probe_value = reader() + except Exception as e: + return False, f"probe credential unreadable: {e}" + return _credential_parity_from_values(profile_value, probe_value) + + +# === Probe === + + +def run_probe(probe_json_path: Path | None) -> dict: + if probe_json_path is not None: + return json.loads(probe_json_path.read_text()) + result = subprocess.run( + ["agent-subscriptions", "--output", "json"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"agent-subscriptions exited {result.returncode}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def provider_probe_lookup(report: dict, provider_key: str) -> dict | None: + name_map = {"anthropic": "anthropic", "minimax": "minimax"} + wanted = name_map.get(provider_key, provider_key).lower() + for entry in report.get("providers", []): + if str(entry.get("provider", "")).lower() == wanted: + return entry + return None + + +# === Gates === + + +def compute_gate(provider_cfg: dict, provider_probe: dict | None) -> dict: + result = { + "eligible": False, + "idle_points": None, + "five_hour_pct": None, + "seven_day_pct": None, + "elapsed_pct": None, + "reason": "", + } + if provider_probe is None: + result["reason"] = "no probe data for this provider" + return result + if not provider_probe.get("available"): + result["reason"] = f"provider unavailable: {provider_probe.get('error', 'unknown')}" + return result + windows = provider_probe.get("windows", {}) + sd = windows.get("seven_day", {}) or {} + fh = windows.get("five_hour", {}) or {} + elapsed = sd.get("elapsed_pct") + usage_sd = sd.get("utilization_pct") + usage_fh = fh.get("utilization_pct") + result["elapsed_pct"] = elapsed + result["seven_day_pct"] = usage_sd + result["five_hour_pct"] = usage_fh + if elapsed is None: + result["reason"] = "seven_day.elapsed_pct is null (cannot pace)" + return result + if usage_sd is None: + result["reason"] = "seven_day.utilization_pct missing" + return result + if usage_fh is None: + result["reason"] = "five_hour.utilization_pct missing" + return result + threshold_pct = provider_cfg["threshold_pct"] + min_idle = provider_cfg["min_idle"] + five_hour_ceiling = provider_cfg["five_hour_ceiling"] + idle_points = threshold_pct * elapsed - usage_sd + result["idle_points"] = idle_points + if idle_points <= min_idle: + result["reason"] = f"idle_points {idle_points:.2f} <= min_idle {min_idle}" + return result + if not (usage_fh < five_hour_ceiling): + result["reason"] = f"five_hour.utilization_pct {usage_fh} >= ceiling {five_hour_ceiling}" + return result + result["eligible"] = True + result["reason"] = "ok" + return result + + +def compute_all_gates(config: dict, probe_report: dict) -> dict: + gates = {} + for pname, pcfg in config["providers"].items(): + probe = provider_probe_lookup(probe_report, pname) + gates[pname] = compute_gate(pcfg, probe) + return gates + + +# === Work-item discovery / stage derivation === + + +def list_items(repo: Path, dossier: str) -> list[tuple[str, str]]: + """Returns [(nn_str, slug), ...] sorted by numeric prefix ascending.""" + dossier_dir = repo / dossier + if not dossier_dir.is_dir(): + return [] + found = [] + for f in dossier_dir.glob("*.overview.md"): + m = ITEM_FILENAME_RE.match(f.name) + if m: + found.append((m.group(1), m.group(2))) + found.sort(key=lambda t: int(t[0])) + return found + + +def item_files(repo: Path, dossier: str, nn_slug: str) -> dict: + """nn_slug is the full on-disk identifier, e.g. '03-shadow-agents...' — + NOT the bare slug returned as the second element of list_items()'s tuples.""" + base = repo / dossier + return { + "overview": (base / f"{nn_slug}.overview.md").exists(), + "agent": (base / f"{nn_slug}.agent.md").exists(), + "research": (base / f"{nn_slug}.research.md").exists(), + "draft": (base / f"{nn_slug}.draft.md").exists(), + "review": (base / f"{nn_slug}.review.md").exists(), + } + + +def next_work_type_for_item(files: dict, state_entry: dict) -> tuple[str | None, str]: + if state_entry.get("blocked"): + return None, f"blocked: {state_entry['blocked']}" + if state_entry.get("approved"): + return None, "approved (terminal)" + if not files["agent"]: + return None, "missing commissioning brief (.agent.md)" + if not files["research"]: + return "research", "research not yet produced" + if not files["draft"]: + return "draft", "research complete, draft not yet produced" + if files["review"]: + return None, "review complete, awaiting human revise/approve" + if not state_entry.get("human_edit_done"): + return None, "waiting on human edit of draft" + return "review", "draft human-edited, ready for review" + + +def count_unreviewed_research(repo: Path, dossier: str, state: dict) -> int: + count = 0 + for nn, slug in list_items(repo, dossier): + files = item_files(repo, dossier, f"{nn}-{slug}") + if not files["research"]: + continue + entry = get_item_state(state, f"{dossier}/{nn}-{slug}") + if not entry["research_sampled"]: + count += 1 + return count + + +def count_open_topic_proposals(repo: Path, dossier: str) -> int: + proposals_path = repo / dossier / "TOPIC-PROPOSALS.md" + if not proposals_path.exists(): + return 0 + text = proposals_path.read_text() + return len(re.findall(r"^##\s+", text, flags=re.MULTILINE)) + + +def build_ready_queue(repo: Path, config: dict, state: dict, in_flight: set) -> list[dict]: + candidates: list[dict] = [] + dossiers = config["dossiers"] + for idx, dossier in enumerate(dossiers): + unreviewed = count_unreviewed_research(repo, dossier, state) + max_unreviewed = config["max_unreviewed_research_per_dossier"] + for nn, slug in list_items(repo, dossier): + key = f"{dossier}/{nn}-{slug}" + if key in in_flight: + continue + files = item_files(repo, dossier, f"{nn}-{slug}") + entry = get_item_state(state, key) + wtype, reason = next_work_type_for_item(files, entry) + if wtype == "research" and unreviewed >= max_unreviewed: + continue + if wtype is None: + continue + candidates.append({ + "kind": "item", + "dossier": dossier, + "slug": f"{nn}-{slug}", + "item_key": key, + "work_type": wtype, + "rank": STAGE_RANK[wtype], + "nn": int(nn), + "dossier_idx": idx, + "reason": reason, + }) + + if not candidates: + for idx, dossier in enumerate(dossiers): + if f"{dossier}/topic_ideas" in in_flight: + continue + open_count = count_open_topic_proposals(repo, dossier) + if open_count < config["max_open_topic_proposals"]: + candidates.append({ + "kind": "dossier", + "dossier": dossier, + "slug": None, + "item_key": f"{dossier}/topic_ideas", + "work_type": "topic_ideas", + "rank": STAGE_RANK["topic_ideas"], + "nn": 0, + "dossier_idx": idx, + "reason": f"no other work eligible; {open_count} open proposals", + }) + + candidates.sort(key=lambda c: (-c["rank"], c["nn"], c["dossier_idx"])) + return candidates + + +def select_provider(work_type: str, config: dict, gates: dict, credential_ok: dict) -> tuple[str | None, str]: + providers = config["work_types"].get(work_type, {}).get("providers", []) + for pname in providers: + gate = gates.get(pname, {}) + if gate.get("eligible") and credential_ok.get(pname, (False, ""))[0]: + return pname, "eligible" + return None, "no eligible provider" + + +# === Prompt rendering === + + +def load_prompt_template(work_type: str) -> str: + fname = PROMPT_TEMPLATE_FILES[work_type] + path = PROMPTS_DIR / fname + return path.read_text() + + +def render_prompt(work_type: str, mapping: dict) -> str: + template = Template(load_prompt_template(work_type)) + return template.substitute(mapping) + + +def build_prompt_mapping(repo: Path, dossier: str, slug: str | None, work_type: str, + config: dict, output_path: Path) -> dict: + base = repo / dossier + evidence_dirs_text = "\n".join(f"- {d}" for d in config["evidence_dirs"]) + mapping = { + "dossier": dossier, + "slug": slug or "", + "overview_path": str(base / f"{slug}.overview.md") if slug else "", + "agent_path": str(base / f"{slug}.agent.md") if slug else "", + "research_path": str(base / f"{slug}.research.md") if slug else "", + "draft_path": str(base / f"{slug}.draft.md") if slug else "", + "output_path": str(output_path), + "style_dir": str(repo / "style"), + "source_register_path": str(base / "SOURCE-REGISTER.md"), + "agents_root_path": str(repo / "AGENTS.md"), + "dossier_agents_path": str(base / "AGENTS.md"), + "review_prompt_path": str(repo / "style" / "review-prompt.md"), + "review_score_threshold": str(config["review_score_threshold"]), + "evidence_dirs": evidence_dirs_text, + "existing_titles": "", + "max_new": str(config["max_open_topic_proposals"]), + } + if work_type == "topic_ideas": + mapping["existing_titles"] = "\n".join(f"- {t}" for t in collect_existing_titles(repo, dossier)) + return mapping + + +def collect_existing_titles(repo: Path, dossier: str) -> list[str]: + titles = [] + for nn, slug in list_items(repo, dossier): + overview = repo / dossier / f"{nn}-{slug}.overview.md" + try: + first_line = overview.read_text().splitlines()[0] + titles.append(first_line.lstrip("#").strip()) + except (IndexError, OSError): + titles.append(f"{nn}-{slug}") + proposals_path = repo / dossier / "TOPIC-PROPOSALS.md" + if proposals_path.exists(): + for line in proposals_path.read_text().splitlines(): + if line.startswith("## "): + titles.append(line[3:].strip()) + return titles + + +# === Output validation === + + +def extract_absolute_paths(text: str) -> list[str]: + return ABS_PATH_RE.findall(text) + + +def validate_output(work_type: str, text: str) -> tuple[bool, str]: + if not text or not text.strip(): + return False, "output is empty" + first_line = next((l for l in text.splitlines() if l.strip()), "") + prefix = EXPECTED_HEADING_PREFIX[work_type] + if not first_line.startswith(prefix): + return False, f"first non-blank line does not start with '{prefix}': {first_line!r}" + if work_type == "research": + dead = [p for p in extract_absolute_paths(text) if not Path(p.rstrip(".,;:")).exists()] + if dead: + return False, f"dead cited path(s): {dead}" + return True, "ok" + + +# === Claude argv / execution === + + +def build_claude_argv(model_id: str | None, max_turns: int, add_dirs: list[str]) -> list[str]: + argv = ["claude", "-p", "--max-turns", str(max_turns)] + if model_id: + argv += ["--model", model_id] + for d in add_dirs: + argv += ["--add-dir", d] + return argv + + +def classify_failure(returncode: int, stderr: str, timed_out: bool) -> str | None: + if timed_out: + return "transient" + if returncode == 0: + return None + lowered = (stderr or "").lower() + if any(marker in lowered for marker in RETRYABLE_STDERR_MARKERS): + return "transient" + return "content" + + +def canonical_output_path(repo: Path, candidate: dict) -> Path: + dossier = candidate["dossier"] + work_type = candidate["work_type"] + if work_type == "topic_ideas": + return repo / dossier / "TOPIC-PROPOSALS.md" + slug = candidate["slug"] + return repo / dossier / f"{slug}.{SUFFIX[work_type]}.md" + + +def rejected_output_path(canonical: Path) -> Path: + return canonical.with_name(canonical.name + ".rejected") + + +def git_commit(repo: Path, paths: list[str], message: str) -> tuple[bool, str]: + add = subprocess.run(["git", "-C", str(repo), "add", "--"] + paths, capture_output=True, text=True) + if add.returncode != 0: + return False, f"git add failed: {add.stderr.strip()}" + commit = subprocess.run( + ["git", "-C", str(repo), "commit", "-m", message, "--"] + paths, + capture_output=True, text=True, + ) + if commit.returncode != 0: + return False, f"git commit failed: {commit.stderr.strip()}" + return True, "ok" + + +def resolve_add_dirs(config: dict, work_type: str) -> list[str]: + if work_type in ("research", "topic_ideas"): + return [str(expand(d)) for d in config["evidence_dirs"]] + return [] + + +def dispatch_preview(repo: Path, config: dict, candidate: dict, max_turns: int) -> dict: + """Compute everything a dryrun needs to print, without executing anything.""" + dossier = candidate["dossier"] + work_type = candidate["work_type"] + provider = candidate["provider"] + profile_dir = expand(config["providers"][provider]["profile"]) + model_id = resolve_model_id(profile_dir) + add_dirs = resolve_add_dirs(config, work_type) + argv = build_claude_argv(model_id, max_turns, add_dirs) + canonical = canonical_output_path(repo, candidate) + tmp_path = canonical.with_name(canonical.name + ".tmp") + parsed_env = parse_provider_env(profile_dir / "provider.env") + return { + "dossier": dossier, + "slug": candidate["slug"], + "work_type": work_type, + "provider": provider, + "prompt_template": str(PROMPTS_DIR / PROMPT_TEMPLATE_FILES[work_type]), + "argv": argv, + "cwd": str(repo), + "env_config_dir": str(profile_dir), + "env_base_url": parsed_env["base_url"], + "env_has_api_key": bool(parsed_env["api_key_file"]), + "output_tmp_path": str(tmp_path), + "output_canonical_path": str(canonical), + } + + +# === Real task execution === + + +def run_task(repo: Path, config: dict, candidate: dict, max_turns: int, task_timeout: int) -> dict: + dossier = candidate["dossier"] + work_type = candidate["work_type"] + provider = candidate["provider"] + slug = candidate["slug"] + + canonical = canonical_output_path(repo, candidate) + tmp_path = canonical.with_name(canonical.name + ".tmp") + + mapping = build_prompt_mapping(repo, dossier, slug, work_type, config, tmp_path) + prompt_text = render_prompt(work_type, mapping) + + profile_dir = expand(config["providers"][provider]["profile"]) + model_id = resolve_model_id(profile_dir) + add_dirs = resolve_add_dirs(config, work_type) + argv = build_claude_argv(model_id, max_turns, add_dirs) + env = build_child_env(profile_dir) + + timed_out = False + returncode = -1 + stderr = "" + try: + proc = subprocess.run( + argv, cwd=str(repo), env=env, input=prompt_text, + capture_output=True, text=True, timeout=task_timeout, + ) + returncode = proc.returncode + stderr = proc.stderr + except subprocess.TimeoutExpired: + timed_out = True + + failure = classify_failure(returncode, stderr, timed_out) + + if failure == "transient": + if tmp_path.exists(): + tmp_path.unlink() + return {"outcome": "transient", "candidate": candidate, "reason": "timeout" if timed_out else stderr} + + if failure == "content": + rejected = rejected_output_path(canonical) + if tmp_path.exists(): + os.replace(tmp_path, rejected) + return {"outcome": "content_failure", "candidate": candidate, "reason": stderr or "non-zero exit"} + + # returncode == 0: validate + text = tmp_path.read_text() if tmp_path.exists() else "" + ok, reason = validate_output(work_type, text) + if not ok: + rejected = rejected_output_path(canonical) + if tmp_path.exists(): + os.replace(tmp_path, rejected) + return {"outcome": "content_failure", "candidate": candidate, "reason": reason} + + if work_type == "topic_ideas": + existing = canonical.read_text() if canonical.exists() else "" + atomic_write_text(canonical, existing + ("\n" if existing and not existing.endswith("\n") else "") + text) + if tmp_path.exists(): + tmp_path.unlink() + else: + os.replace(tmp_path, canonical) + + return {"outcome": "success", "candidate": candidate, "canonical": canonical} + + +# === Dispatch loop === + + +def get_config_defaults(config: dict) -> dict: + return { + "max_turns": config.get("max_turns", DEFAULT_MAX_TURNS), + "task_timeout": config.get("task_timeout_seconds", DEFAULT_TASK_TIMEOUT_SECONDS), + "max_attempts": config.get("max_attempts", DEFAULT_MAX_ATTEMPTS), + } + + +def evaluate_credentials(config: dict) -> dict: + result = {} + for pname in config["providers"]: + profile_dir = expand(config["providers"][pname]["profile"]) + result[pname] = credential_parity(pname, profile_dir) + return result + + +def log_gates(repo: Path, gates: dict, credential_ok: dict) -> None: + for pname, gate in gates.items(): + cred_ok, cred_reason = credential_ok.get(pname, (False, "not checked")) + eligible = gate["eligible"] and cred_ok + log_event( + repo, + f"gate {pname}: idle_points={gate['idle_points']} five_hour_pct={gate['five_hour_pct']} " + f"eligible={eligible} reason={gate['reason']!r} credential_parity={cred_ok} ({cred_reason})", + ) + + +def apply_success_state(state: dict, candidate: dict) -> None: + # Successful dispatch doesn't itself set a human gate; nothing to flip here. + # (human_edit_done / research_sampled / approved are human-only via `mark`.) + # Present for symmetry with apply_failure_state and as the extension point + # if a future work type needs to record something on success. + return + + +def apply_failure_state(state: dict, candidate: dict, max_attempts: int) -> str | None: + if candidate["kind"] != "item": + return None + key = candidate["item_key"] + work_type = candidate["work_type"] + if work_type not in ALLOWED_ATTEMPT_WORK_TYPES: + return None + items = state.setdefault("items", {}) + entry = items.setdefault(key, {}) + attempts = entry.setdefault("attempts", {}) + attempts[work_type] = attempts.get(work_type, 0) + 1 + blocked_reason = None + if attempts[work_type] >= max_attempts: + blocked_reason = f"{work_type} failed {attempts[work_type]} times (max_attempts={max_attempts})" + entry["blocked"] = blocked_reason + return blocked_reason + + +def run_dispatch(args, config: dict, repo: Path, state_path: Path) -> int: + rotate_log_if_needed(repo) + lock = None + if not args.dryrun: + lock = acquire_lock(repo) + if lock is None: + log_event(repo, "lock held by another invocation; exiting") + return 0 + + try: + state = load_state(state_path, repo) + except StateValidationError as e: + log_event(repo, f"FATAL: state validation failed: {e}") + return 2 + + defaults = get_config_defaults(config) + parallel = args.parallel or config.get("parallel", DEFAULT_PARALLEL) + + try: + probe_report = run_probe(args.probe_json) + except Exception as e: + log_event(repo, f"FATAL: probe failed: {e}") + return 1 + + credential_ok = evaluate_credentials(config) + gates = compute_all_gates(config, probe_report) + log_gates(repo, gates, credential_ok) + + in_flight: set = set() + queue = build_ready_queue(repo, config, state, in_flight) + + if args.dryrun: + slots = 1 if args.once else parallel + picked = [] + for cand in queue: + if len(picked) >= slots: + break + provider, why = select_provider(cand["work_type"], config, gates, credential_ok) + if provider is None: + continue + cand = dict(cand) + cand["provider"] = provider + in_flight.add(cand["item_key"]) + picked.append(cand) + if not picked: + print("[dryrun] No eligible (item, provider) pair to dispatch this cycle.") + return 0 + print(f"[dryrun] Would dispatch {len(picked)} task(s) (initial wave; re-probe not simulated):") + for cand in picked: + preview = dispatch_preview(repo, config, cand, defaults["max_turns"]) + print(f"\n item: {preview['dossier']}/{preview['slug'] or '(dossier-level)'}") + print(f" work_type: {preview['work_type']}") + print(f" provider: {preview['provider']}") + print(f" prompt: {preview['prompt_template']}") + print(f" cwd: {preview['cwd']}") + print(f" env: CLAUDE_CONFIG_DIR={preview['env_config_dir']}" + + (f" ANTHROPIC_BASE_URL={preview['env_base_url']}" if preview['env_base_url'] else "") + + (" ANTHROPIC_API_KEY=" if preview['env_has_api_key'] else "")) + print(f" argv: {preview['argv']}") + print(f" output: {preview['output_tmp_path']} -> {preview['output_canonical_path']}") + return 0 + + dispatched_count = 0 + fatal = False + + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, parallel)) as executor: + futures = {} + + def try_submit(): + nonlocal dispatched_count + if args.once and dispatched_count >= 1: + return + nonlocal queue + queue = build_ready_queue(repo, config, state, in_flight) + for cand in queue: + provider, _ = select_provider(cand["work_type"], config, gates, credential_ok) + if provider is None: + continue + cand = dict(cand) + cand["provider"] = provider + cand["before_pct"] = gates.get(provider, {}).get("seven_day_pct") + in_flight.add(cand["item_key"]) + dispatched_count += 1 + log_event(repo, f"dispatch: {cand['item_key']} work_type={cand['work_type']} provider={provider}") + fut = executor.submit(run_task, repo, config, cand, defaults["max_turns"], defaults["task_timeout"]) + futures[fut] = cand + return + + try_submit() + + while futures: + done, _ = concurrent.futures.wait(futures, return_when=concurrent.futures.FIRST_COMPLETED) + for fut in done: + cand = futures.pop(fut) + in_flight.discard(cand["item_key"]) + try: + result = fut.result() + except Exception as e: + log_event(repo, f"FATAL task exception for {cand['item_key']}: {e}") + fatal = True + continue + + outcome = result["outcome"] + if outcome == "success": + apply_success_state(state, cand) + save_state_atomic(state_path, state) + canonical = result["canonical"] + try: + after_probe = run_probe(args.probe_json) + after_gate = compute_gate(config["providers"][cand["provider"]], + provider_probe_lookup(after_probe, cand["provider"])) + after_pct = after_gate["seven_day_pct"] + gates.update(compute_all_gates(config, after_probe)) + except Exception as e: + log_event(repo, f"re-probe after completion failed: {e}") + after_pct = None + before_pct = cand.get("before_pct") + msg = ( + f"idle-draft: {cand['work_type']} {cand['item_key']} via {cand['provider']} " + f"(7d {before_pct}%→{after_pct}%)" + ) + rel_canonical = str(Path(canonical).relative_to(repo)) + ok, reason = git_commit(repo, [rel_canonical, "idle-draft.state.json"], msg) + log_event(repo, f"success: {cand['item_key']} -> {rel_canonical}; commit={ok} ({reason})") + elif outcome == "content_failure": + blocked_reason = apply_failure_state(state, cand, defaults["max_attempts"]) + save_state_atomic(state_path, state) + log_event( + repo, + f"content failure: {cand['item_key']} work_type={cand['work_type']} " + f"reason={result['reason']!r} blocked={blocked_reason!r}", + ) + else: # transient + log_event(repo, f"transient failure: {cand['item_key']} reason={result['reason']!r}") + + if not (args.once and dispatched_count >= 1): + try_submit() + + if lock is not None: + lock.close() + + return 1 if fatal else 0 + + +# === status / mark === + + +def cmd_status(args, config: dict, repo: Path, state_path: Path) -> int: + try: + state = load_state(state_path, repo) + except StateValidationError as e: + print(f"FATAL: state validation failed: {e}", file=sys.stderr) + return 2 + + try: + probe_report = run_probe(args.probe_json) + credential_ok = evaluate_credentials(config) + gates = compute_all_gates(config, probe_report) + except Exception as e: + print(f"Warning: probe failed: {e}", file=sys.stderr) + gates, credential_ok = {}, {} + + print("Providers") + print("=========") + for pname, gate in gates.items(): + cred_ok, cred_reason = credential_ok.get(pname, (False, "not checked")) + print(f" {pname}: idle_points={gate['idle_points']} five_hour_pct={gate['five_hour_pct']} " + f"eligible={gate['eligible'] and cred_ok} reason={gate['reason']} credential_parity={cred_ok} ({cred_reason})") + + print() + print("Items") + print("=====") + for dossier in config["dossiers"]: + for nn, slug in list_items(repo, dossier): + key = f"{dossier}/{nn}-{slug}" + files = item_files(repo, dossier, f"{nn}-{slug}") + entry = get_item_state(state, key) + wtype, reason = next_work_type_for_item(files, entry) + flags = [] + if entry["human_edit_done"]: + flags.append("edited") + if entry["research_sampled"]: + flags.append("sampled") + if entry["approved"]: + flags.append("approved") + if entry["blocked"]: + flags.append(f"blocked({entry['blocked']})") + flags_str = ",".join(flags) if flags else "-" + next_action = wtype or "none" + print(f" {key}: next={next_action} flags=[{flags_str}] reason={reason!r}") + return 0 + + +def cmd_mark(args, config: dict, repo: Path, state_path: Path) -> int: + item = args.item + action = args.action + if "/" not in item: + print(f"Error: item must be of the form dossier/NN-slug, got: {item}", file=sys.stderr) + return 2 + dossier, slug = item.split("/", 1) + overview = repo / dossier / f"{slug}.overview.md" + if not overview.exists(): + print(f"Error: no such item (overview file not found): {overview}", file=sys.stderr) + return 2 + + try: + state = load_state(state_path, repo) + except StateValidationError as e: + print(f"FATAL: state validation failed: {e}", file=sys.stderr) + return 2 + + items = state.setdefault("items", {}) + entry = items.setdefault(item, {}) + + if action == "edited": + entry["human_edit_done"] = True + elif action == "sampled": + entry["research_sampled"] = True + elif action == "approved": + entry["approved"] = True + elif action == "unblock": + entry["blocked"] = None + else: + print(f"Error: unknown mark action: {action}", file=sys.stderr) + return 2 + + validate_state(state, repo) + save_state_atomic(state_path, state) + print(f"Marked {item}: {action}") + return 0 + + +# === CLI === + + +HELP = """\ +idle-draft — idle-subscription dispatcher for the writing pipeline + +Usage: + idle-draft [OPTIONS] + idle-draft mark edited|sampled|approved|unblock [OPTIONS] + idle-draft status [OPTIONS] + +Options: + --config FILE Path to config file (default: ./idle-draft.config.json) + --repo DIR Writing repo root (default: config file's directory) + --once Dispatch at most one task, then exit + --parallel N Max concurrent tasks (overrides config) + --dryrun, -n Preview dispatch without executing or mutating anything + --probe-json FILE Read probe output from FILE instead of running agent-subscriptions + --help, -h Show this help and exit + +Spec: specs/idle-draft.spec.md +""" + + +def parse_args(argv: list[str]): + class Args: + pass + + args = Args() + args.subcommand = None + args.item = None + args.action = None + args.config = Path("idle-draft.config.json") + args.repo = None + args.once = False + args.parallel = None + args.dryrun = False + args.probe_json = None + + rest = list(argv) + if rest and rest[0] in ("mark", "status"): + args.subcommand = rest[0] + rest = rest[1:] + if args.subcommand == "mark": + positionals = [a for a in rest if not a.startswith("-")] + if len(positionals) < 2: + print("Error: mark requires ", file=sys.stderr) + sys.exit(2) + args.item, args.action = positionals[0], positionals[1] + rest = [a for a in rest if a not in (args.item, args.action)] + + i = 0 + while i < len(rest): + a = rest[i] + if a in ("-h", "--help"): + print(HELP) + sys.exit(0) + elif a == "--config": + args.config = Path(rest[i + 1]); i += 2 + elif a == "--repo": + args.repo = Path(rest[i + 1]); i += 2 + elif a == "--once": + args.once = True; i += 1 + elif a == "--parallel": + args.parallel = int(rest[i + 1]); i += 2 + elif a in ("--dryrun", "-n"): + args.dryrun = True; i += 1 + elif a == "--probe-json": + args.probe_json = Path(rest[i + 1]); i += 2 + else: + print(f"Error: unknown option: {a}", file=sys.stderr) + sys.exit(2) + + return args + + +def main() -> None: + args = parse_args(sys.argv[1:]) + + try: + config = load_config(args.config) + except ConfigError as e: + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(2) + + repo = args.repo if args.repo is not None else args.config.resolve().parent + state_path = repo / "idle-draft.state.json" + + if args.subcommand == "mark": + sys.exit(cmd_mark(args, config, repo, state_path)) + elif args.subcommand == "status": + sys.exit(cmd_status(args, config, repo, state_path)) + else: + sys.exit(run_dispatch(args, config, repo, state_path)) + + +if __name__ == "__main__": + main() diff --git a/specs/idle-draft.spec.md b/specs/idle-draft.spec.md new file mode 100644 index 0000000..00f2310 --- /dev/null +++ b/specs/idle-draft.spec.md @@ -0,0 +1,355 @@ +# idle-draft + +## Purpose + +Cron-invoked dispatcher that consumes idle Anthropic/MiniMax subscription capacity to +advance the O'Reilly Consulting writing pipeline (`~/dev/claude/writing/oreillyconsulting`) +unattended: probes usage via `agent-subscriptions`, computes idle capacity per provider, +dispatches ready work items to headless `claude -p` with bounded parallelism, re-probes +on every completion, validates output, and commits results. + +Design contract: `~/dev/claude/writing/oreillyconsulting/IDLE-DRAFT-PLAN.md`. This spec +translates that plan into the repo's implementation contract; where the two disagree, +the plan wins and this file should be corrected. + +## Usage + +``` +idle-draft [OPTIONS] +idle-draft mark edited|sampled|approved|unblock [OPTIONS] +idle-draft status [OPTIONS] +``` + +### Options (dispatch mode and `status`) + +| Flag | Default | Description | +|---|---|---| +| `--config FILE` | `./idle-draft.config.json` | Path to the config file | +| `--repo DIR` | config file's directory | Writing repo root (contains the dossiers) | +| `--once` | off | Dispatch at most one task, then exit (pilot mode) | +| `--parallel N` | from config, or `2` | Max concurrent tasks (overrides config) | +| `--dryrun`, `-n` | off | Probe + gate + select, print what would be dispatched, execute and mutate nothing | +| `--probe-json FILE` | none | Read probe output from FILE instead of running `agent-subscriptions` (test hook / pilot aid) | +| `--help`, `-h` | — | Show usage and exit 0 | + +### `mark` subcommand + +``` +idle-draft mark edited|sampled|approved|unblock [--config FILE] [--repo DIR] +``` + +The only human mutation path into `idle-draft.state.json`. Validates the item resolves +to an existing `//NN-slug.overview.md`, loads and schema-validates the +state file, applies exactly one field mutation, writes atomically. + +| Action | Effect | +|---|---| +| `edited` | `human_edit_done = true` | +| `sampled` | `research_sampled = true` | +| `approved` | `approved = true` | +| `unblock` | `blocked = null` | + +### `status` subcommand + +Human-readable report: per-item derived stage, human-gate flags, next eligible action +(or the reason it is not eligible), and per-provider gate values (`idle_points`, +`five_hour_pct`, eligibility) from a live probe (or `--probe-json`). Read-only; takes no +lock, mutates nothing. + +## Behaviour + +### 1. Locking + +Before doing anything else (dispatch mode only — `mark` and `status` do not take the +lock), `flock` (`fcntl.flock`, `LOCK_EX | LOCK_NB`) on `/.idle-draft.lock`. If +already locked by another invocation, log one line and exit **0** — a concurrent cron +tick is not a failure. + +### 2. Log rotation + +At startup, if `/idle-draft.log` exceeds 5 MB, rename it to `idle-draft.log.1` +(overwriting any existing `.1`) before appending further. + +### 3. Config load + +Parse `--config` as JSON. Required top-level keys: `parallel`, `providers`, `work_types`, +`dossiers`, `review_score_threshold`, `max_unreviewed_research_per_dossier`, +`max_open_topic_proposals`, `evidence_dirs` — matching `data/idle-draft/config.example.json` +verbatim. Missing required keys or malformed structure → log loudly, exit **2**. +`--parallel` on the CLI overrides the config value. + +### 4. State load and validation + +Read `/idle-draft.state.json`. Missing file is not an error — treat as +`{"items": {}}`. If present: + +- Must parse as JSON. +- Top level must be an object with **only** the key `items` (unknown top-level keys + rejected). +- `items` must be an object. Each key must be of the form `/` and + must resolve to an existing `//.overview.md` — an item entry + for a file that doesn't exist is rejected. +- Each item value must be an object containing **only** these keys (all optional, + defaults shown): `human_edit_done` (bool, default `false`), `research_sampled` (bool, + default `false`), `approved` (bool, default `false`), `blocked` (string or `null`, + default `null`), `attempts` (object mapping `research`/`draft`/`review` → non-negative + int, default `{}`). Any other key, or a wrong-typed value, is rejected. + +Any validation failure aborts the run loudly (message to stderr and the log) with +**exit 2** — never "best effort," never silently ignored. This applies to `mark` and +`status` too (both load and validate the state file before proceeding). + +### 5. Probe + +Run `agent-subscriptions --output json` as a subprocess and parse stdout as JSON, +**unless** `--probe-json FILE` is given, in which case that file's contents are used +verbatim instead (no subprocess call — the test hook / pilot aid). A subprocess +failure (non-zero exit, unparseable stdout, timeout) is logged loudly; the run exits +**1** if no usable probe data was obtained at all (dispatch loop never starts). + +### 6. Gates (evaluated per provider, every cycle — including on re-probe after each +completion) + +For each provider in `config["providers"]`, using that provider's `seven_day` and +`five_hour` window records from the probe report: + +``` +idle_points = threshold_pct × elapsed_pct − usage_pct (on the seven_day window) +``` + +A provider is **eligible** for a new dispatch iff **all** of: + +- the probe marks it `available: true` +- `seven_day.elapsed_pct` is non-null (null → "cannot pace", fail closed) +- `seven_day.utilization_pct` is non-null +- `five_hour.utilization_pct` is non-null +- `idle_points > min_idle` +- `five_hour.utilization_pct < five_hour_ceiling` +- the credential-parity check (§7) passes for that provider + +All three gate values (`idle_points`, `five_hour.utilization_pct`, eligibility) are +logged for every provider on every cycle, whether or not the provider ends up used. + +### 7. Credential parity + +Before a provider can be selected, idle-draft resolves the credential its profile would +export at launch and compares it (by content, via SHA-256) against the credential +`agent-subscriptions` used to probe that same provider: + +- **Profile side:** if `/provider.env` defines `ANTHROPIC_API_KEY_FILE`, read + that file's content. If the profile has no `provider.env` (the default Anthropic + profile), use the same token file `agent-subscriptions` reads for Anthropic + (`~/dev/claude/secrets/anthropic/api_key`). +- **Probe side:** for `anthropic`, the same `~/dev/claude/secrets/anthropic/api_key` + file content. For `minimax`, the SOPS-decrypted `ANTHROPIC_AUTH_TOKEN` value from + `agent-subscriptions`' MiniMax path (same SOPS file, same key file, same dotenv key + — these constants are replicated locally and must be kept in sync with + `scripts/agent-subscriptions` if that script's paths change). + +A mismatch (or either side unreadable) makes the provider ineligible for this cycle and +logs a loud line: `credential mismatch: profile≠probe ()`. The gate +must meter the account that actually spends, not the account the profile file merely +names. + +### 8. Work-item discovery and stage derivation + +For each dossier in `config["dossiers"]`, glob `//*.overview.md` matching +`^(\d+)-(.+)\.overview\.md$`. For each `NN-slug`, derive the next eligible work type +(or "not eligible, because...") purely from which sibling files exist plus the item's +state entry: + +| Condition | Result | +|---|---| +| `state.blocked` is set | not eligible: blocked | +| `state.approved` is true | not eligible: approved (terminal) | +| `NN-slug.agent.md` missing | not eligible: no commissioning brief | +| `NN-slug.research.md` missing | next: `research` | +| `NN-slug.draft.md` missing | next: `draft` | +| `NN-slug.review.md` exists | not eligible: awaiting human revise/approve | +| `state.human_edit_done` is not true | not eligible: waiting on human edit | +| (all of the above pass) | next: `review` | + +In-flight items (a task currently dispatched for that item in this run) are excluded +from consideration for further dispatch until the in-flight task completes. + +**Cold-start throttle:** per dossier, count `NN-slug.research.md` files that exist and +whose state entry does **not** have `research_sampled: true`. Once that count reaches +`max_unreviewed_research_per_dossier`, no further `research` candidates are offered for +that dossier this cycle (draft/review candidates in that dossier are unaffected). + +**`topic_ideas`:** dossier-level (not tied to an `NN-slug`), considered only when the +combined candidate list above (across all dossiers) is empty. For each dossier, eligible +iff the count of proposals already recorded in `/TOPIC-PROPOSALS.md` (one `##` +heading per proposal; file absent counts as 0) is below `max_open_topic_proposals`. + +### 9. Prioritisation + +Ready `(item, work_type)` candidates (plus, only when the list would otherwise be +empty, `(dossier, topic_ideas)` candidates) are sorted by: + +1. Stage rank, descending: `review` (3) > `draft` (2) > `research` (1) > `topic_ideas` (0). +2. Numeric filename prefix, ascending (`topic_ideas` sorts as `0`, always last within + its own rank tier — moot since it only appears when nothing else is ready). +3. Dossier config order (`config["dossiers"]` index), ascending, as the tiebreak. + +For each candidate in this order, the provider is the **first** entry in +`config["work_types"][work_type]["providers"]` that is currently eligible (§6, §7). A +candidate with no eligible provider is skipped (not dispatched this cycle, tried again +next cycle); the walk continues to the next candidate. + +### 10. Dispatch loop (worker pool, event-driven) + +Up to `parallel` tasks run concurrently (`concurrent.futures.ThreadPoolExecutor`). +Each completion is handled **serially** in the main thread (state updates and git +commits never race). On every completion — and before the very first dispatch — the +gates (§6) are recomputed from a fresh probe (§5); a task is submitted to fill a free +worker slot only while an eligible `(item, work_type, provider)` triple remains. The +loop exits (dispatch mode, non-dryrun) when no eligible candidate remains. `--once` +dispatches at most one task total, then exits without waiting for further slots. + +### 11. Task execution + +For the selected `(item_or_dossier, work_type, provider)`: + +1. Resolve the prompt template: `data/idle-draft/prompts/{research,draft,review-suggest,topic-ideas}.md` + (`review` work type uses `review-suggest.md`). +2. Render the template (`string.Template`, `$placeholder` substitution) with the + resolved paths for that item (overview, agent, research, draft as applicable), + dossier name, slug, style directory, source register path, and a temp output path. +3. Resolve the profile directory from `config["providers"][provider]["profile"]` + (`~` expanded). Build the child environment: `CLAUDE_CONFIG_DIR=`, plus — + if `/provider.env` exists — `ANTHROPIC_BASE_URL` (if set), + `ANTHROPIC_API_KEY` (read from `ANTHROPIC_API_KEY_FILE`, if set), and any other + `KEY=value` line verbatim. A profile with no `provider.env` (plain Anthropic) gets + only `CLAUDE_CONFIG_DIR`. This logic is a local re-implementation of + `scripts/claude-profile` lines ~320–440 — **`claude-profile` itself is never + invoked** (it has interactive pickers and terminal theming unsuitable for headless + cron use). +4. Build the argv: `claude -p --max-turns ` (`N` = 25 unless overridden), plus + `--model ` if `provider.env` sets `MODEL_ID`, plus `--add-dir ` for each + entry in `config["evidence_dirs"]` **only** for `research` and `topic_ideas` work + types (the only ones that cite external evidence). +5. Run the subprocess: `cwd=`, `env=`, prompt piped via **stdin** + (not as an argv element — avoids `ARG_MAX` on large rendered prompts, same lesson + `claude-profile` already applies to its system-prompt injection), timeout = per-task + timeout (1800s unless overridden). +6. Classify the result: + - **Timeout** → transient failure. Do not increment `attempts`. + - **Non-zero exit** whose stderr matches a retryable signature (`429`, `5xx`, + `rate limit`, `overloaded`, `timeout`, `temporarily unavailable`, connection-reset + markers) → transient failure. Do not increment `attempts`. + - **Non-zero exit**, no retryable signature, or **exit 0 but validation fails below** + → content failure. Increment `state.items[item].attempts[work_type]`. At + `max_attempts` (2) content failures for that work type, set + `state.items[item].blocked` with a reason (the item is then excluded from all + further dispatch until a human runs `mark unblock`). + - **Exit 0 and validation passes** → success. +7. Validation (on exit 0, before promotion): the temp output file must be non-empty and + its first non-blank line must be a top-level Markdown heading (`# ...`). For + `research` work type specifically: every absolute path matching `/home/\S+` cited in + the file must exist on disk (`Path.exists()`); any dead path fails validation. +8. On validation pass: `os.replace()` the temp file to the canonical path + (`//..md`, or append to + `/TOPIC-PROPOSALS.md` for `topic_ideas`) — atomic, never a partial file + visible under the canonical name. +9. On content-failure: `os.replace()` the temp file to + `//..md.rejected` (kept for human inspection, + never promoted, never committed to the canonical name; `topic_ideas` content + failures are simply discarded — nothing is appended, nothing is blocked, since + `topic_ideas` has no per-item state entry to carry an attempt counter). +10. On transient failure: temp file is discarded; no state change; no commit; the + candidate may be retried on a later cycle. +11. On success: write `idle-draft.state.json` atomically (temp file + `os.replace()` in + the same directory), then: + ``` + git -C add -- idle-draft.state.json + git -C commit -m "" -- idle-draft.state.json + ``` + Never `git add -A`, never `git commit -a`. Commit message: + `idle-draft: via (7d %→%)`, where + before/after are that provider's `seven_day.utilization_pct` immediately before + dispatch and immediately after re-probe on completion. No push. + +### 12. Logging + +One line per event (gate decision, dispatch, completion, failure, commit) appended to +`/idle-draft.log` with an ISO-8601 timestamp, mirrored to stderr. + +## Dryrun behaviour + +`--dryrun` runs the full probe (or reads `--probe-json`), computes all gates, builds +the ready queue, and selects up to `parallel` (or `1`, under `--once`) `(item, +work_type, provider)` triples exactly as the real dispatch loop's first wave would — +**without** simulating gate depletion across a re-probe (a real run only knows that +after actually dispatching; the preview shows the initial wave only, noted as such). +For each selected triple it prints, and executes nothing: + +- item or dossier identifier, work type, chosen provider +- resolved prompt template path +- the exact `claude` argv list that would run +- the child env deltas (`CLAUDE_CONFIG_DIR`, `ANTHROPIC_BASE_URL` if set, whether + `ANTHROPIC_API_KEY` would be exported) +- the temp output path and the canonical destination path + +No subprocess is run, no file is written, no state is mutated, no lock is required to +be free for the preview to work (the lock is still attempted and its outcome reported, +but a held lock does not block the dryrun preview from computing and printing — real +dispatch mode is what respects the lock as a hard gate). Exit 0. + +## State schema + +`idle-draft.state.json`, repo root — see §4. Humans never hand-edit this file; the +`mark` subcommand is the only mutation path. Derived state (anything the filesystem +already says) is never written here. + +## Config schema + +`idle-draft.config.json` — see `data/idle-draft/config.example.json` for the exact +structure. All top-level keys listed in §3 are required. + +## Failure classes + +| Class | Examples | `attempts` effect | Item outcome | +|---|---|---|---| +| Transient | timeout, HTTP 429/5xx, network reset | not incremented | retried next eligible cycle | +| Content | empty output, missing heading, dead citation path, non-retryable non-zero exit | incremented | `.rejected` kept; `blocked` at `max_attempts` (2) | + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Nothing to do, or all dispatched tasks completed without a fatal problem (individual task failures are logged, not fatal) | +| 1 | Transient/probe problem prevented the run from proceeding (no usable probe data, lock held is exit 0 not 1 — see §1) | +| 2 | Config or state validation failure | + +## Edge cases + +| Scenario | Handling | +|---|---| +| Lock already held | Log one line, exit 0 (not an error — another cron tick is running) | +| `idle-draft.state.json` missing | Treated as `{"items": {}}`, not an error | +| `idle-draft.state.json` present but invalid | Exit 2, loud message, run never starts | +| Item's `.agent.md` missing | Item excluded from all dispatch (commissioning briefs exist today for every current item; this guards future additions) | +| `seven_day.elapsed_pct` null | Provider ineligible this cycle, never assumed idle | +| Provider `available: false` | Provider ineligible this cycle | +| Credential mismatch | Provider ineligible this cycle, loud log line | +| `research` output cites a path that doesn't exist | Content failure, quarantined as `.rejected`, attempts incremented | +| `max_attempts` reached | Item `blocked`, excluded until `mark unblock` | +| `topic_ideas` content failure | Discarded silently (logged, not blocked — no per-item state key exists for a dossier-level work type) | +| Two work types both ready in the same dossier | Higher stage rank wins (review > draft > research); `topic_ideas` never competes (last-resort only) | +| `--probe-json` combined with `--dryrun` | Composes normally — no subprocess call either way | +| `--once` with no eligible work | Exits 0 immediately, no task dispatched | +| Config missing a required key | Exit 2 | +| `agent-subscriptions` subprocess fails entirely | Exit 1, loud log line, no dispatch attempted | + +## Examples + +```sh +idle-draft --dryrun # preview one dispatch wave +idle-draft --once # pilot: one real task, then exit +idle-draft --probe-json /tmp/fake-probe.json --dryrun # preview against fixture data +idle-draft --config ~/dev/claude/writing/oreillyconsulting/idle-draft.config.json +idle-draft mark ai/03-shadow-agents-are-the-new-shadow-it edited +idle-draft mark ai/03-shadow-agents-are-the-new-shadow-it sampled +idle-draft status +``` diff --git a/tests/test-idle-draft.sh b/tests/test-idle-draft.sh new file mode 100755 index 0000000..dc9bad8 --- /dev/null +++ b/tests/test-idle-draft.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# Test script for idle-draft: exercises --dryrun / --probe-json against fixture +# dirs, plus direct unit tests of the pure logic functions (gates, stage +# derivation, prioritisation, credential parity, citation validation, state +# validation) by importing the script as a Python module. +# +# No API call, no `claude` invocation, and no real profile dir is ever touched. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/idle-draft" + +RED='\033[31m'; GREEN='\033[32m'; RESET='\033[0m' +PASS=0 +FAIL=0 + +pass() { printf " ${GREEN}PASS${RESET}: %s\n" "$1"; PASS=$((PASS + 1)); } +fail() { printf " ${RED}FAIL${RESET}: %s\n" "$1"; [[ -n "${2:-}" ]] && printf " %s\n" "$2"; FAIL=$((FAIL + 1)); } + +assert_contains() { + local output="$1" expected="$2" label="$3" + if echo "$output" | grep -qF -- "$expected"; then pass "$label"; else fail "$label" "Expected to find: $expected"; fi +} +assert_not_contains() { + local output="$1" unexpected="$2" label="$3" + if echo "$output" | grep -qF -- "$unexpected"; then fail "$label" "Did not expect: $unexpected"; else pass "$label"; fi +} +assert_exit_code() { + local actual="$1" expected="$2" label="$3" + if [[ "$actual" -eq "$expected" ]]; then pass "$label"; else fail "$label" "Expected exit $expected, got $actual"; fi +} + +TMPDIR="" +cleanup() { [[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"; } +trap cleanup EXIT +TMPDIR=$(mktemp -d) + +FIXTURE_REPO="$TMPDIR/repo" +FIXTURE_CONFIG="$TMPDIR/idle-draft.config.json" +FIXTURE_PROBE="$TMPDIR/probe.json" +PROFILE_ANTHROPIC="$TMPDIR/profiles/anthropic" +PROFILE_MINIMAX="$TMPDIR/profiles/minimax" + +# --- Build a fixture writing-repo tree --------------------------------- +mkdir -p "$FIXTURE_REPO/ai" "$FIXTURE_REPO/style" "$PROFILE_ANTHROPIC" "$PROFILE_MINIMAX" +( + cd "$FIXTURE_REPO" && git init -q && git config user.email t@example.com && git config user.name test +) +printf '# AGENTS root\n' > "$FIXTURE_REPO/AGENTS.md" +printf '# ai AGENTS\n' > "$FIXTURE_REPO/ai/AGENTS.md" +printf '# Source register\n' > "$FIXTURE_REPO/ai/SOURCE-REGISTER.md" +printf '# goes target voice\n' > "$FIXTURE_REPO/style/goes-target-voice.md" +printf '# review prompt\n' > "$FIXTURE_REPO/style/review-prompt.md" + +# Item 01: only overview+agent -> next = research +printf '# Topic one\n\nPitch.\n' > "$FIXTURE_REPO/ai/01-topic-one.overview.md" +printf '# Commissioning brief: topic one\n' > "$FIXTURE_REPO/ai/01-topic-one.agent.md" + +# Item 02: overview+agent+research -> next = draft +printf '# Topic two\n\nPitch.\n' > "$FIXTURE_REPO/ai/02-topic-two.overview.md" +printf '# Commissioning brief: topic two\n' > "$FIXTURE_REPO/ai/02-topic-two.agent.md" +printf '# Research: topic two\n\nEvidence.\n' > "$FIXTURE_REPO/ai/02-topic-two.research.md" + +# Item 03: research+draft, human_edit_done -> next = review +printf '# Topic three\n\nPitch.\n' > "$FIXTURE_REPO/ai/03-topic-three.overview.md" +printf '# Commissioning brief: topic three\n' > "$FIXTURE_REPO/ai/03-topic-three.agent.md" +printf '# Research: topic three\n\nEvidence.\n' > "$FIXTURE_REPO/ai/03-topic-three.research.md" +printf '# Topic three draft\n\nBody.\n' > "$FIXTURE_REPO/ai/03-topic-three.draft.md" + +cat > "$FIXTURE_REPO/idle-draft.state.json" <<'EOF' +{ + "items": { + "ai/03-topic-three": { "human_edit_done": true } + } +} +EOF + +(cd "$FIXTURE_REPO" && git add -A && git commit -q -m init) + +cat > "$FIXTURE_CONFIG" < "$FIXTURE_PROBE" <<'EOF' +{ + "probed_at": "2026-08-02T00:00:00+00:00", + "providers": [ + {"provider": "Anthropic", "available": true, "windows": { + "five_hour": {"utilization_pct": 10.0, "reset_at": null, "reset_in_seconds": 1000, "window_seconds": 18000, "elapsed_pct": 0.5}, + "seven_day": {"utilization_pct": 5.0, "reset_at": null, "reset_in_seconds": 100000, "window_seconds": 604800, "elapsed_pct": 0.5} + }}, + {"provider": "MiniMax", "available": true, "windows": { + "five_hour": {"utilization_pct": 10.0, "reset_at": null, "reset_in_seconds": 1000, "window_seconds": 18000, "elapsed_pct": 0.5}, + "seven_day": {"utilization_pct": 5.0, "reset_at": null, "reset_in_seconds": 100000, "window_seconds": 604800, "elapsed_pct": 0.5} + }} + ] +} +EOF + +# ============================================================ +echo "=== idle-draft: CLI / dryrun tests ===" +echo "" + +echo "-- help --" +output=$("$SCRIPT" --help 2>&1); code=$? +assert_exit_code "$code" 0 "--help exits 0" +assert_contains "$output" "Usage:" "--help shows usage" +assert_contains "$output" "--dryrun" "--help mentions --dryrun" +assert_contains "$output" "mark" "--help mentions mark subcommand" +assert_contains "$output" "status" "--help mentions status subcommand" + +echo "-- dryrun: highest-priority ready item wins (review > draft > research) --" +output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun --parallel 1 2>&1) +code=$? +assert_exit_code "$code" 0 "dryrun exits 0" +assert_contains "$output" "item: ai/03-topic-three" "dryrun picks item 03 (review-ready, highest stage rank)" +assert_contains "$output" "work_type: review" "dryrun names work_type review" +assert_contains "$output" "provider: anthropic" "dryrun names provider anthropic" +assert_not_contains "$output" "would run" "dryrun does not execute claude" + +echo "-- dryrun: exact resolved claude argv for a research item --" +# Force only the research-ready item to be eligible by pointing --repo at a +# single-item fixture (item 01 only). +SOLO="$TMPDIR/solo-repo" +mkdir -p "$SOLO/ai" "$SOLO/style" +cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$SOLO/ai/" +cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$SOLO/ai/" +cp "$FIXTURE_REPO/AGENTS.md" "$SOLO/AGENTS.md" +cp -r "$FIXTURE_REPO/style" "$SOLO/style" +cp "$FIXTURE_REPO/ai/SOURCE-REGISTER.md" "$SOLO/ai/SOURCE-REGISTER.md" +(cd "$SOLO" && git init -q && git config user.email t@example.com && git config user.name test && git add -A && git commit -q -m init) +output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$SOLO" --probe-json "$FIXTURE_PROBE" --dryrun --once 2>&1) +assert_contains "$output" "work_type: research" "dryrun (solo fixture) picks research" +assert_contains "$output" "argv: ['claude', '-p', '--max-turns', '25', '--add-dir'" "dryrun prints resolved claude argv with --add-dir for research" +assert_contains "$output" "$TMPDIR/evidence" "dryrun argv includes configured evidence dir" + +echo "-- dryrun: no mutation --" +before_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD) +"$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun >/dev/null 2>&1 +after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD) +[[ "$before_hash" == "$after_hash" ]] && pass "dryrun makes no git commits" || fail "dryrun makes no git commits" +[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "dryrun writes no output file" || fail "dryrun writes no output file" + +echo "-- state validation failure exits 2 --" +BADSTATE="$TMPDIR/badstate-repo" +mkdir -p "$BADSTATE/ai" +cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$BADSTATE/ai/" +cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$BADSTATE/ai/" +echo '{"items": {"ai/01-topic-one": {"unknown_field": true}}}' > "$BADSTATE/idle-draft.state.json" +output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$BADSTATE" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1) +code=$? +assert_exit_code "$code" 2 "unknown item field in state exits 2" +assert_contains "$output" "unknown key" "state error names the unknown key" + +echo '{"items": {"ai/does-not-exist": {}}}' > "$BADSTATE/idle-draft.state.json" +output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$BADSTATE" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1) +code=$? +assert_exit_code "$code" 2 "state item not resolving to overview.md exits 2" + +echo "-- config validation failure exits 2 --" +BADCONFIG="$TMPDIR/bad.config.json" +echo '{"parallel": 2}' > "$BADCONFIG" +output=$("$SCRIPT" --config "$BADCONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1) +code=$? +assert_exit_code "$code" 2 "config missing required keys exits 2" + +echo "-- mark subcommand round-trip --" +MARKREPO="$TMPDIR/mark-repo" +mkdir -p "$MARKREPO/ai" +cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$MARKREPO/ai/" +cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$MARKREPO/ai/" +output=$("$SCRIPT" mark ai/01-topic-one edited --config "$FIXTURE_CONFIG" --repo "$MARKREPO" 2>&1) +code=$? +assert_exit_code "$code" 0 "mark edited exits 0" +assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"human_edit_done": true' "mark edited sets human_edit_done" + +"$SCRIPT" mark ai/01-topic-one sampled --config "$FIXTURE_CONFIG" --repo "$MARKREPO" >/dev/null 2>&1 +assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"research_sampled": true' "mark sampled sets research_sampled" + +"$SCRIPT" mark ai/01-topic-one approved --config "$FIXTURE_CONFIG" --repo "$MARKREPO" >/dev/null 2>&1 +assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"approved": true' "mark approved sets approved" + +output=$("$SCRIPT" mark ai/99-nope edited --config "$FIXTURE_CONFIG" --repo "$MARKREPO" 2>&1); code=$? +assert_exit_code "$code" 2 "mark on nonexistent item exits 2" + +echo "-- status subcommand --" +output=$("$SCRIPT" status --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" 2>&1) +code=$? +assert_exit_code "$code" 0 "status exits 0" +assert_contains "$output" "ai/01-topic-one" "status lists item 01" +assert_contains "$output" "next=research" "status shows item 01 next=research" +assert_contains "$output" "ai/03-topic-three" "status lists item 03" +assert_contains "$output" "next=review" "status shows item 03 next=review" +assert_contains "$output" "anthropic:" "status shows provider gate line" + +echo "" +echo "=== idle-draft: pure-logic unit tests (imported module) ===" +echo "" + +PYOUT=$(python3 - "$SCRIPT" "$TMPDIR" <<'PYEOF' +import sys, json, importlib.util, importlib.machinery +from pathlib import Path + +script_path, tmpdir = sys.argv[1], Path(sys.argv[2]) +loader = importlib.machinery.SourceFileLoader("idle_draft_under_test", script_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) + +results = [] + +def check(name, cond, detail=""): + results.append(("PY-PASS" if cond else "PY-FAIL", name, detail)) + +# --- Gate math --- +pcfg = {"threshold_pct": 80, "min_idle": 5, "five_hour_ceiling": 50} + +def probe(elapsed, usage_sd, usage_fh, available=True): + return { + "available": available, + "windows": { + "seven_day": {"elapsed_pct": elapsed, "utilization_pct": usage_sd}, + "five_hour": {"utilization_pct": usage_fh}, + }, + } + +g = m.compute_gate(pcfg, probe(0.5, 5.0, 10.0)) +check("gate: eligible when idle_points > min_idle and 5h < ceiling", + g["eligible"] is True and abs(g["idle_points"] - 35.0) < 1e-9, g) + +g = m.compute_gate(pcfg, probe(0.5, 75.0, 10.0)) +check("gate: ineligible when idle_points <= min_idle", + g["eligible"] is False and g["idle_points"] is not None, g) + +g = m.compute_gate(pcfg, probe(0.5, 5.0, 60.0)) +check("gate: ineligible when five_hour >= ceiling", g["eligible"] is False, g) + +g = m.compute_gate(pcfg, probe(None, 5.0, 10.0)) +check("gate: null elapsed_pct -> ineligible, idle_points None, fail-closed", + g["eligible"] is False and g["idle_points"] is None and "cannot pace" in g["reason"], g) + +g = m.compute_gate(pcfg, probe(0.5, None, 10.0)) +check("gate: null seven_day utilization -> ineligible", g["eligible"] is False, g) + +g = m.compute_gate(pcfg, probe(0.5, 5.0, None)) +check("gate: null five_hour utilization -> ineligible", g["eligible"] is False, g) + +g = m.compute_gate(pcfg, {"available": False, "error": "boom"}) +check("gate: unavailable provider -> ineligible", g["eligible"] is False and "unavailable" in g["reason"], g) + +g = m.compute_gate(pcfg, None) +check("gate: no probe data -> ineligible", g["eligible"] is False, g) + +g = m.compute_gate(pcfg, probe(1.0, 5.0, 10.0)) +check("gate: elapsed_pct=1.0 (fully elapsed) computes idle_points=threshold-usage", + abs(g["idle_points"] - 75.0) < 1e-9, g) + +# --- Stage derivation --- +def files(agent=True, research=False, draft=False, review=False): + return {"overview": True, "agent": agent, "research": research, "draft": draft, "review": review} + +wt, reason = m.next_work_type_for_item(files(agent=False), {}) +check("stage: missing agent.md -> None", wt is None and "commissioning brief" in reason, reason) + +wt, reason = m.next_work_type_for_item(files(agent=True), {}) +check("stage: agent only -> research", wt == "research", (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True, research=True), {}) +check("stage: research done -> draft", wt == "draft", (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True), {"human_edit_done": False}) +check("stage: draft done, not human-edited -> None (waiting on human)", + wt is None and "human" in reason, (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True), {"human_edit_done": True}) +check("stage: draft done, human-edited -> review", wt == "review", (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True, review=True), {"human_edit_done": True}) +check("stage: review.md exists -> None (awaiting human revise/approve)", wt is None, (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True), {"blocked": "too many failures"}) +check("stage: blocked item -> None regardless of files", wt is None and "blocked" in reason, (wt, reason)) + +wt, reason = m.next_work_type_for_item(files(agent=True), {"approved": True}) +check("stage: approved item -> None (terminal)", wt is None and "approved" in reason, (wt, reason)) + +# --- Prioritisation / cold-start / topic_ideas via build_ready_queue on the fixture repo --- +repo = tmpdir / "repo" +config = json.loads((tmpdir / "idle-draft.config.json").read_text()) +state = m.load_state(repo / "idle-draft.state.json", repo) +queue = m.build_ready_queue(repo, config, state, set()) +work_types_in_order = [c["work_type"] for c in queue] +check("priority: review-ready item ranks before draft/research candidates", + work_types_in_order[0] == "review", work_types_in_order) +check("priority: full queue is review, draft, research (stage rank descending)", + work_types_in_order == ["review", "draft", "research"], work_types_in_order) + +# Cold-start throttle: two dossiers' worth of unreviewed research under a low cap +throttle_repo = tmpdir / "throttle-repo" +(throttle_repo / "ai").mkdir(parents=True, exist_ok=True) +for n, slug in [("01", "a"), ("02", "b"), ("03", "c")]: + base = throttle_repo / "ai" / f"{n}-{slug}" + (base.with_suffix("")).parent.mkdir(exist_ok=True, parents=True) + (throttle_repo / "ai" / f"{n}-{slug}.overview.md").write_text(f"# {slug}\n") + (throttle_repo / "ai" / f"{n}-{slug}.agent.md").write_text("# brief\n") +(throttle_repo / "ai" / "01-a.research.md").write_text("# Research: a\n") +(throttle_repo / "ai" / "02-b.research.md").write_text("# Research: b\n") +throttle_config = dict(config) +throttle_config["max_unreviewed_research_per_dossier"] = 2 +throttle_state = m.default_state() +tqueue = m.build_ready_queue(throttle_repo, throttle_config, throttle_state, set()) +research_candidates = [c["slug"] for c in tqueue if c["work_type"] == "research"] +check("cold-start throttle: item 03's research is suppressed once 2 unreviewed research files exist", + "03-c" not in research_candidates, research_candidates) +draft_candidates = [c["slug"] for c in tqueue if c["work_type"] == "draft"] +check("cold-start throttle: draft candidates for 01/02 unaffected by the research throttle", + set(draft_candidates) == {"01-a", "02-b"}, draft_candidates) + +# topic_ideas: only offered when nothing else is eligible +idle_repo = tmpdir / "idle-repo" +(idle_repo / "ai").mkdir(parents=True, exist_ok=True) +(idle_repo / "ai" / "01-done.overview.md").write_text("# done\n") +(idle_repo / "ai" / "01-done.agent.md").write_text("# brief\n") +idle_state = {"items": {"ai/01-done": {"approved": True}}} +iqueue = m.build_ready_queue(idle_repo, config, idle_state, set()) +check("topic_ideas: offered when no other work is eligible", + len(iqueue) == 1 and iqueue[0]["work_type"] == "topic_ideas", iqueue) + +(idle_repo / "ai" / "TOPIC-PROPOSALS.md").write_text("\n".join(f"## Idea {i}" for i in range(6))) +capped_config = dict(config) +capped_config["max_open_topic_proposals"] = 6 +iqueue2 = m.build_ready_queue(idle_repo, capped_config, idle_state, set()) +check("topic_ideas: suppressed once max_open_topic_proposals reached", + len(iqueue2) == 0, iqueue2) + +# --- Credential parity --- +ok, why = m._credential_parity_from_values("secret-abc", "secret-abc") +check("credential parity: matching values -> True", ok is True, why) +ok, why = m._credential_parity_from_values("secret-abc", "secret-xyz") +check("credential parity: mismatched values -> False", ok is False, why) +ok, why = m._credential_parity_from_values("", "secret-abc") +check("credential parity: empty profile value -> False", ok is False, why) + +cred_repo = tmpdir / "cred" +cred_repo.mkdir(exist_ok=True) +profile_dir = cred_repo / "profile" +profile_dir.mkdir(exist_ok=True) +key_file = cred_repo / "key.txt" +key_file.write_text("shared-secret-value\n") +(profile_dir / "provider.env").write_text(f"ANTHROPIC_BASE_URL=https://example.invalid\nANTHROPIC_API_KEY_FILE={key_file}\n") +resolved = m.resolve_profile_credential_value("minimax", profile_dir) +check("credential parity: resolves ANTHROPIC_API_KEY_FILE from provider.env", resolved == "shared-secret-value", resolved) + +ok, why = m.credential_parity("minimax", profile_dir, probe_credential_fn=lambda: "shared-secret-value") +check("credential parity: full check matches via injected probe reader", ok is True, why) +ok, why = m.credential_parity("minimax", profile_dir, probe_credential_fn=lambda: "different-secret") +check("credential parity: full check flags mismatch via injected probe reader", ok is False, why) + +# --- provider.env parsing (replicates claude-profile) --- +parsed = m.parse_provider_env(profile_dir / "provider.env") +check("provider.env: base_url parsed", parsed["base_url"] == "https://example.invalid", parsed) +check("provider.env: api_key_file parsed", parsed["api_key_file"] == str(key_file), parsed) + +extra_env_dir = cred_repo / "profile-extra" +extra_env_dir.mkdir(exist_ok=True) +(extra_env_dir / "provider.env").write_text("MODEL_ID=claude-haiku-4-5-20251001\nSOME_EXTRA_VAR=hello\n") +parsed2 = m.parse_provider_env(extra_env_dir / "provider.env") +check("provider.env: MODEL_ID recognised", parsed2["model_id"] == "claude-haiku-4-5-20251001", parsed2) +check("provider.env: unrecognised key falls into extra", parsed2["extra"].get("SOME_EXTRA_VAR") == "hello", parsed2) + +env = m.build_child_env(profile_dir, base_env={}) +check("build_child_env: CLAUDE_CONFIG_DIR set", env["CLAUDE_CONFIG_DIR"] == str(profile_dir), env) +check("build_child_env: ANTHROPIC_BASE_URL exported from provider.env", env.get("ANTHROPIC_BASE_URL") == "https://example.invalid", env) +check("build_child_env: ANTHROPIC_API_KEY read from key file", env.get("ANTHROPIC_API_KEY") == "shared-secret-value", env) + +no_provider_env_dir = cred_repo / "profile-plain" +no_provider_env_dir.mkdir(exist_ok=True) +env2 = m.build_child_env(no_provider_env_dir, base_env={}) +check("build_child_env: profile without provider.env gets only CLAUDE_CONFIG_DIR", + "ANTHROPIC_BASE_URL" not in env2 and "ANTHROPIC_API_KEY" not in env2 and env2["CLAUDE_CONFIG_DIR"] == str(no_provider_env_dir), + env2) + +# --- Citation validation --- +real_path = cred_repo / "exists.txt" +real_path.write_text("x") +good_text = f"# Research: topic\n\nSee {real_path} for detail.\n" +ok, why = m.validate_output("research", good_text) +check("citation validation: accepts research output with an existing cited path", ok is True, why) + +bad_text = "# Research: topic\n\nSee /home/nonexistent-user/definitely-not-here.md for detail.\n" +ok, why = m.validate_output("research", bad_text) +check("citation validation: rejects research output citing a dead path", ok is False, why) + +ok, why = m.validate_output("research", "") +check("citation validation: rejects empty output", ok is False, why) + +ok, why = m.validate_output("research", "no heading here\njust text\n") +check("citation validation: rejects output missing a top-level heading", ok is False, why) + +ok, why = m.validate_output("topic_ideas", "## A proposal\n\nBody.\n") +check("citation validation: topic_ideas accepts ## as its top-level heading", ok is True, why) + +ok, why = m.validate_output("topic_ideas", "# Wrong heading level\n") +check("citation validation: topic_ideas rejects a single # heading", ok is False, why) + +# --- State validation --- +try: + m.validate_state({"items": {}, "bogus": 1}, repo) + check("state validation: unknown top-level key raises", False) +except m.StateValidationError: + check("state validation: unknown top-level key raises", True) + +try: + m.validate_state({"items": {"ai/does-not-exist": {}}}, repo) + check("state validation: item not resolving to overview.md raises", False) +except m.StateValidationError: + check("state validation: item not resolving to overview.md raises", True) + +try: + m.validate_state({"items": {"ai/01-topic-one": {"attempts": {"bogus_type": 1}}}}, repo) + check("state validation: unknown attempts work_type raises", False) +except m.StateValidationError: + check("state validation: unknown attempts work_type raises", True) + +try: + m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": "yes"}}}, repo) + check("state validation: wrong-typed bool field raises", False) +except m.StateValidationError: + check("state validation: wrong-typed bool field raises", True) + +# Valid state should not raise +m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": True, "attempts": {"draft": 1}}}}, repo) +check("state validation: well-formed state passes", True) + +for status, name, detail in results: + print(f"{status}: {name}", detail if detail else "") + +n_fail = sum(1 for s, _, _ in results if s == "PY-FAIL") +sys.exit(1 if n_fail else 0) +PYEOF +) +PY_EXIT=$? + +echo "$PYOUT" +py_pass=$(echo "$PYOUT" | grep -c '^PY-PASS' || true) +py_fail=$(echo "$PYOUT" | grep -c '^PY-FAIL' || true) +PASS=$((PASS + py_pass)) +FAIL=$((FAIL + py_fail)) + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +printf "${GREEN}All tests passed.${RESET}\n"