From 0ceb49044aaded69306fca66348c20f1d3a888ff Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Fri, 11 Sep 2026 06:15:14 +1200 Subject: [PATCH] =?UTF-8?q?chore:=20track=20ask-minimax-run,=20bg-model-ca?= =?UTF-8?q?ll,=20wait-for-version,=20auto-draft=20(+spec/data)=20=E2=80=94?= =?UTF-8?q?=20were=20untracked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_015nXvGNNNixJPFwZKZHNyha --- .../example/dossier-a/01-empty.overview.md | 3 + ...02-has-generated.ai_generated.anthropic.md | 3 + .../dossier-a/02-has-generated.overview.md | 3 + .../03-clean.ai_generated.anthropic.md | 3 + .../dossier-a/03-clean.ai_score.anthropic.md | 5 + .../example/dossier-a/03-clean.overview.md | 3 + .../04-needs-polish.ai_generated.anthropic.md | 3 + .../04-needs-polish.ai_score.anthropic.md | 7 + .../dossier-a/04-needs-polish.overview.md | 3 + .../05-exempt-only.ai_generated.anthropic.md | 3 + .../05-exempt-only.ai_score.anthropic.md | 3 + .../dossier-a/05-exempt-only.overview.md | 3 + .../06-cross-folder.ai_generated.anthropic.md | 3 + .../dossier-b/06-cross-folder.overview.md | 3 + scripts/ask-minimax-run | 29 ++ scripts/auto-draft | 309 +++++++++++++ scripts/bg-model-call | 435 ++++++++++++++++++ scripts/wait-for-version | 209 +++++++++ specs/auto-draft.spec.md | 122 +++++ 19 files changed, 1152 insertions(+) create mode 100644 data/auto-draft/example/dossier-a/01-empty.overview.md create mode 100644 data/auto-draft/example/dossier-a/02-has-generated.ai_generated.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/02-has-generated.overview.md create mode 100644 data/auto-draft/example/dossier-a/03-clean.ai_generated.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/03-clean.ai_score.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/03-clean.overview.md create mode 100644 data/auto-draft/example/dossier-a/04-needs-polish.ai_generated.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/04-needs-polish.ai_score.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/04-needs-polish.overview.md create mode 100644 data/auto-draft/example/dossier-a/05-exempt-only.ai_generated.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/05-exempt-only.ai_score.anthropic.md create mode 100644 data/auto-draft/example/dossier-a/05-exempt-only.overview.md create mode 100644 data/auto-draft/example/dossier-b/06-cross-folder.ai_generated.anthropic.md create mode 100644 data/auto-draft/example/dossier-b/06-cross-folder.overview.md create mode 100755 scripts/ask-minimax-run create mode 100755 scripts/auto-draft create mode 100755 scripts/bg-model-call create mode 100755 scripts/wait-for-version create mode 100644 specs/auto-draft.spec.md diff --git a/data/auto-draft/example/dossier-a/01-empty.overview.md b/data/auto-draft/example/dossier-a/01-empty.overview.md new file mode 100644 index 0000000..219edd7 --- /dev/null +++ b/data/auto-draft/example/dossier-a/01-empty.overview.md @@ -0,0 +1,3 @@ +# 01 — The amplifier + +A topic brief that has not yet been generated. Will appear in the generation queue. diff --git a/data/auto-draft/example/dossier-a/02-has-generated.ai_generated.anthropic.md b/data/auto-draft/example/dossier-a/02-has-generated.ai_generated.anthropic.md new file mode 100644 index 0000000..82b94a2 --- /dev/null +++ b/data/auto-draft/example/dossier-a/02-has-generated.ai_generated.anthropic.md @@ -0,0 +1,3 @@ +# 02 — Has generated + +This is a generated draft for the topic. It has not yet been scored. diff --git a/data/auto-draft/example/dossier-a/02-has-generated.overview.md b/data/auto-draft/example/dossier-a/02-has-generated.overview.md new file mode 100644 index 0000000..d39d64b --- /dev/null +++ b/data/auto-draft/example/dossier-a/02-has-generated.overview.md @@ -0,0 +1,3 @@ +# 02 — Has generated, no score + +Source topic. The generated file exists but no score file yet. Will appear in the scoring queue. diff --git a/data/auto-draft/example/dossier-a/03-clean.ai_generated.anthropic.md b/data/auto-draft/example/dossier-a/03-clean.ai_generated.anthropic.md new file mode 100644 index 0000000..e3493ef --- /dev/null +++ b/data/auto-draft/example/dossier-a/03-clean.ai_generated.anthropic.md @@ -0,0 +1,3 @@ +# 03 — All paragraphs pass + +Generated draft. diff --git a/data/auto-draft/example/dossier-a/03-clean.ai_score.anthropic.md b/data/auto-draft/example/dossier-a/03-clean.ai_score.anthropic.md new file mode 100644 index 0000000..9a10bd2 --- /dev/null +++ b/data/auto-draft/example/dossier-a/03-clean.ai_score.anthropic.md @@ -0,0 +1,5 @@ +This is a polished paragraph. + +This is also a polished paragraph. + +And this one is fine too. diff --git a/data/auto-draft/example/dossier-a/03-clean.overview.md b/data/auto-draft/example/dossier-a/03-clean.overview.md new file mode 100644 index 0000000..d9a156f --- /dev/null +++ b/data/auto-draft/example/dossier-a/03-clean.overview.md @@ -0,0 +1,3 @@ +# 03 — All paragraphs pass + +Source topic. The score file exists and every paragraph passes the polish score. Will not appear in any queue. diff --git a/data/auto-draft/example/dossier-a/04-needs-polish.ai_generated.anthropic.md b/data/auto-draft/example/dossier-a/04-needs-polish.ai_generated.anthropic.md new file mode 100644 index 0000000..d376c19 --- /dev/null +++ b/data/auto-draft/example/dossier-a/04-needs-polish.ai_generated.anthropic.md @@ -0,0 +1,3 @@ +# 04 — Needs polish + +Generated draft that needs further work. diff --git a/data/auto-draft/example/dossier-a/04-needs-polish.ai_score.anthropic.md b/data/auto-draft/example/dossier-a/04-needs-polish.ai_score.anthropic.md new file mode 100644 index 0000000..b4205ab --- /dev/null +++ b/data/auto-draft/example/dossier-a/04-needs-polish.ai_score.anthropic.md @@ -0,0 +1,7 @@ +This is a polished paragraph. + +This is a middling paragraph that needs work. + +This is a polished paragraph. + +This is a poor paragraph that needs work. diff --git a/data/auto-draft/example/dossier-a/04-needs-polish.overview.md b/data/auto-draft/example/dossier-a/04-needs-polish.overview.md new file mode 100644 index 0000000..c8280d1 --- /dev/null +++ b/data/auto-draft/example/dossier-a/04-needs-polish.overview.md @@ -0,0 +1,3 @@ +# 04 — Needs polish + +Source topic. The score file has paragraphs below the polish score. Will appear in the polish queue with 2 below. diff --git a/data/auto-draft/example/dossier-a/05-exempt-only.ai_generated.anthropic.md b/data/auto-draft/example/dossier-a/05-exempt-only.ai_generated.anthropic.md new file mode 100644 index 0000000..9e29236 --- /dev/null +++ b/data/auto-draft/example/dossier-a/05-exempt-only.ai_generated.anthropic.md @@ -0,0 +1,3 @@ +# 05 — Exempt only + +Generated draft. diff --git a/data/auto-draft/example/dossier-a/05-exempt-only.ai_score.anthropic.md b/data/auto-draft/example/dossier-a/05-exempt-only.ai_score.anthropic.md new file mode 100644 index 0000000..db69da7 --- /dev/null +++ b/data/auto-draft/example/dossier-a/05-exempt-only.ai_score.anthropic.md @@ -0,0 +1,3 @@ +This is a paragraph that is below the polish threshold but is already accepted. + +This is a paragraph that is below the polish threshold but is already accepted. diff --git a/data/auto-draft/example/dossier-a/05-exempt-only.overview.md b/data/auto-draft/example/dossier-a/05-exempt-only.overview.md new file mode 100644 index 0000000..fdf2219 --- /dev/null +++ b/data/auto-draft/example/dossier-a/05-exempt-only.overview.md @@ -0,0 +1,3 @@ +# 05 — Below score but exempt + +Source topic. The score file has only paragraphs that are below the polish score but marked exempt. Will not appear in any queue. diff --git a/data/auto-draft/example/dossier-b/06-cross-folder.ai_generated.anthropic.md b/data/auto-draft/example/dossier-b/06-cross-folder.ai_generated.anthropic.md new file mode 100644 index 0000000..95fe4c1 --- /dev/null +++ b/data/auto-draft/example/dossier-b/06-cross-folder.ai_generated.anthropic.md @@ -0,0 +1,3 @@ +# 06 — Cross folder + +Generated draft. diff --git a/data/auto-draft/example/dossier-b/06-cross-folder.overview.md b/data/auto-draft/example/dossier-b/06-cross-folder.overview.md new file mode 100644 index 0000000..e908854 --- /dev/null +++ b/data/auto-draft/example/dossier-b/06-cross-folder.overview.md @@ -0,0 +1,3 @@ +# 06 — Cross folder + +Source topic in a different subfolder. Exercises recursive scan. diff --git a/scripts/ask-minimax-run b/scripts/ask-minimax-run new file mode 100755 index 0000000..0cbabb6 --- /dev/null +++ b/scripts/ask-minimax-run @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Invoke claude with the oreillyit-minimax provider environment. +# Replicates what claude-profile does in Phase 3 for non-interactive (-p) use. +# Used by the /ask-minimax skill. +set -euo pipefail + +MINIMAX_DIR="${HOME}/.claude-oreillyit-minimax" +PROVIDER_ENV="${MINIMAX_DIR}/provider.env" + +[[ -f "$PROVIDER_ENV" ]] || { echo "error: $PROVIDER_ENV not found" >&2; exit 1; } + +# Parse provider.env — handle ANTHROPIC_API_KEY_FILE specially; export the rest +while IFS='=' read -r key value; do + [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue + if [[ "$key" == "ANTHROPIC_API_KEY_FILE" ]]; then + expanded="${value/#\~/$HOME}" + [[ -f "$expanded" ]] || { echo "error: API key file '$expanded' not found" >&2; exit 1; } + ANTHROPIC_API_KEY=$(cat "$expanded") + export ANTHROPIC_API_KEY + else + export "$key=$value" + fi +done < "$PROVIDER_ENV" + +export CLAUDE_CONFIG_DIR="$MINIMAX_DIR" +export CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1 +export COLORTERM=truecolor + +exec claude "$@" diff --git a/scripts/auto-draft b/scripts/auto-draft new file mode 100755 index 0000000..10e4b6c --- /dev/null +++ b/scripts/auto-draft @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""auto-draft — schedule ai-pipeline work based on spare subscription capacity. + +OUTLINE REVISION. This revision is dryrun-only: it probes the subscription, computes +the spare capacity, and prints the three priority queues. Job execution is not yet +implemented. See specs/auto-draft.spec.md for the full design. +""" + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# --- Constants -------------------------------------------------------------- + +VALID_PROVIDERS = ("anthropic", "minimax") +WEEKLY_PERIOD_DAYS = 7 +WEEKLY_PERIOD_SECONDS = WEEKLY_PERIOD_DAYS * 86400 + +# Inline HTML-comment scoring convention (see spec). +SCORE_RE = re.compile(r"") +EXEMPT_RE = re.compile(r"") +TITLE_RE = re.compile(r"^(\d+)-(.+)$") + +# --- ANSI colour ------------------------------------------------------------ + +_use_colour = sys.stdout.isatty() +_BOLD = "\033[1m" if _use_colour else "" +_DIM = "\033[2m" if _use_colour else "" +_GREEN = "\033[32m" if _use_colour else "" +_YELLOW = "\033[33m" if _use_colour else "" +_RED = "\033[31m" if _use_colour else "" +_CYAN = "\033[36m" if _use_colour else "" +_RESET = "\033[0m" if _use_colour else "" + + +# --- Helpers ---------------------------------------------------------------- + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _fmt_duration(seconds: float) -> str: + seconds = int(seconds) + if seconds < 0: + seconds = 0 + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + if days > 0: + return f"{days}d {hours}h" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + +def _fmt_pct(pct: float) -> str: + return f"{pct:.1f}%" + + +def _fmt_pct_signed(pct: float) -> str: + sign = "+" if pct >= 0 else "" + return f"{sign}{pct:.1f}%" + + +# --- Provider probe --------------------------------------------------------- + +def probe_provider(provider: str) -> dict | None: + """Run agent-subscriptions --output json and return the matching provider entry.""" + try: + result = subprocess.run( + ["agent-subscriptions", "--output", "json"], + capture_output=True, text=True, check=True, timeout=30, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + print(f"{_YELLOW}Warning: agent-subscriptions probe failed: {e}{_RESET}", file=sys.stderr) + return None + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"{_YELLOW}Warning: agent-subscriptions returned invalid JSON: {e}{_RESET}", file=sys.stderr) + return None + + for p in data.get("providers", []): + if p.get("provider", "").lower() == provider.lower(): + return p + return None + + +# --- Spare capacity --------------------------------------------------------- + +def compute_spare(provider_record: dict | None, threshold_pct: float) -> dict | None: + """Compute the linear limit and spare capacity for the seven-day window.""" + if not provider_record or not provider_record.get("available"): + return None + seven_day = provider_record.get("windows", {}).get("seven_day", {}) + reset_at_str = seven_day.get("reset_at") + usage_pct = seven_day.get("utilization_pct") + if not reset_at_str or usage_pct is None: + return None + + reset_at = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")) + renewed_at = reset_at - timedelta(seconds=WEEKLY_PERIOD_SECONDS) + now = _now() + elapsed_seconds = max(0.0, (now - renewed_at).total_seconds()) + elapsed_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * 100.0 + limit_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * threshold_pct + spare_pct = limit_pct - usage_pct + + return { + "reset_at": reset_at, + "renewed_at": renewed_at, + "elapsed_seconds": elapsed_seconds, + "elapsed_pct": elapsed_pct, + "threshold_pct": threshold_pct, + "limit_pct": limit_pct, + "usage_pct": usage_pct, + "spare_pct": spare_pct, + } + + +def print_spare_capacity(provider: str, spare: dict | None) -> None: + if spare is None: + print(f"{_YELLOW}[auto-draft] No spare capacity data for {provider} (provider unavailable or reset_at missing){_RESET}") + return + + print(f"{_BOLD}[auto-draft] Spare capacity for {provider}{_RESET}") + print(f" Reset at: {spare['reset_at'].isoformat()}") + print(f" Renewed at: {spare['renewed_at'].isoformat()}") + print(f" Elapsed: {_fmt_duration(spare['elapsed_seconds'])} of {WEEKLY_PERIOD_DAYS}d ({spare['elapsed_pct']:.1f}%)") + print(f" Threshold: {_fmt_pct(spare['threshold_pct'])}") + print(f" Linear limit: {_fmt_pct(spare['limit_pct'])}") + print(f" Current usage: {_fmt_pct(spare['usage_pct'])}") + + spare_pct = spare["spare_pct"] + if spare_pct >= 0: + colour = _GREEN + summary = "under budget" + else: + colour = _RED + summary = "over budget; do not queue any work" + print(f" Spare capacity: {colour}{_fmt_pct_signed(spare_pct)}{_RESET} ({summary})") + print() + + +# --- Title discovery -------------------------------------------------------- + +def discover_titles(base_dir: Path) -> list[tuple[int, str, str, Path]]: + """Return list of (title_id, title_slug, original_stem, parent_dir) sorted by title_id.""" + titles: list[tuple[int, str, str, Path]] = [] + seen: set[tuple[str, Path]] = set() + for f in sorted(base_dir.rglob("*.overview.md")): + stem = f.name.removesuffix(".overview.md") + key = (stem, f.parent) + if key in seen: + continue + seen.add(key) + m = TITLE_RE.match(stem) + if m: + title_id = int(m.group(1)) + title_slug = m.group(2) + else: + title_id = float("inf") + title_slug = stem + titles.append((title_id, title_slug, stem, f.parent)) + titles.sort(key=lambda x: (x[0], x[1])) + return titles + + +def _files_for(parent: Path, stem: str, provider: str) -> tuple[Path, Path]: + score = parent / f"{stem}.ai_score.{provider}.md" + generated = parent / f"{stem}.ai_generated.{provider}.md" + return score, generated + + +# --- Score parsing ---------------------------------------------------------- + +def parse_score_file(score_path: Path, polish_score: int) -> tuple[int, int]: + """Return (paragraphs_below, total_scored).""" + try: + content = score_path.read_text() + except OSError: + return 0, 0 + paragraphs = re.split(r"\n\s*\n", content) + below = 0 + scored = 0 + for para in paragraphs: + if not para.strip(): + continue + score_m = SCORE_RE.search(para) + if not score_m: + continue + scored += 1 + score = int(score_m.group(1)) + is_exempt = bool(EXEMPT_RE.search(para)) + if score < polish_score and not is_exempt: + below += 1 + return below, scored + + +# --- Queue building --------------------------------------------------------- + +def build_queues(base_dir: Path, provider: str, polish_score: int): + """Return (polish_queue, scoring_queue, generation_queue). Each queue is a list of dicts.""" + polish_q: list[dict] = [] + scoring_q: list[dict] = [] + generation_q: list[dict] = [] + + for title_id, title_slug, stem, parent in discover_titles(base_dir): + score_file, generated_file = _files_for(parent, stem, provider) + if score_file.exists(): + below, total = parse_score_file(score_file, polish_score) + if below > 0: + polish_q.append({ + "title_id": title_id, + "title_slug": title_slug, + "stem": stem, + "parent": parent, + "below": below, + "total_scored": total, + }) + elif generated_file.exists(): + scoring_q.append({ + "title_id": title_id, + "title_slug": title_slug, + "stem": stem, + "parent": parent, + }) + else: + generation_q.append({ + "title_id": title_id, + "title_slug": title_slug, + "stem": stem, + "parent": parent, + }) + + return polish_q, scoring_q, generation_q + + +def _sort_queue(q: list[dict]) -> list[dict]: + return sorted(q, key=lambda x: (x["title_id"], x["title_slug"])) + + +def print_queue(label: str, kind: str, queue: list[dict]) -> None: + queue = _sort_queue(queue) + plural = "s" if len(queue) != 1 else "" + print(f"{_BOLD}[auto-draft] {label} ({kind}) — {len(queue)} item{plural}{_RESET}") + if not queue: + print(f" {_DIM}(empty){_RESET}") + else: + for item in queue: + stem = item["stem"] + if kind == "polish": + print(f" {stem} {item['below']} paragraph(s) below score (of {item['total_scored']} scored)") + else: + print(f" {stem}") + print() + + +# --- Main ------------------------------------------------------------------- + +def parse_args(argv: list[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="auto-draft", + description="Schedule ai-pipeline work based on spare subscription capacity (outline revision).", + ) + p.add_argument("--provider", required=True, choices=VALID_PROVIDERS, + help="Subscription provider to draw capacity from") + p.add_argument("--threshold", required=True, type=float, + help="Maximum subscription utilization over the period, as a percentage") + p.add_argument("--polish-score", required=True, type=int, + help="Paragraph score below which a paragraph is queued for polishing") + p.add_argument("--base-dir", default=".", type=Path, + help="Root directory to scan for dossier files (default: current directory)") + p.add_argument("-n", "--dryrun", action="store_true", default=True, + help="Show capacity and queues without executing (default: dryrun)") + args = p.parse_args(argv) + if not (0 < args.threshold <= 100): + p.error("--threshold must be between 0 and 100 (exclusive of 0)") + if not (0 <= args.polish_score <= 10): + p.error("--polish-score must be between 0 and 10") + return args + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + base_dir = args.base_dir.expanduser().resolve() + if not base_dir.is_dir(): + print(f"Error: --base-dir {base_dir} is not a directory", file=sys.stderr) + return 1 + + provider_record = probe_provider(args.provider) + spare = compute_spare(provider_record, args.threshold) + print_spare_capacity(args.provider, spare) + + polish_q, scoring_q, generation_q = build_queues(base_dir, args.provider, args.polish_score) + print_queue("Polish queue", "polish", polish_q) + print_queue("Scoring queue", "scoring", scoring_q) + print_queue("Generation queue", "generation", generation_q) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bg-model-call b/scripts/bg-model-call new file mode 100755 index 0000000..f35d40d --- /dev/null +++ b/scripts/bg-model-call @@ -0,0 +1,435 @@ +#!/usr/bin/env bash +# bg-model-call — Submit a prompt to a third-party model via the agent-runtimes +# control plane, wait for completion, and write the result to a file. +# +# Designed for use with the ScheduleWakeup polling pattern: run this script in +# the background; your wakeup handler checks for the .done sentinel. +# +# See specs/bg-model-call.spec.md for the full spec. + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Model → template mapping (override with --template) +# --------------------------------------------------------------------------- +declare -A MODEL_TEMPLATES=( + [minimax]="minimax-planning" + [sonnet]="sonnet-code-repo" + [haiku]="haiku-code-repo" + [opus]="opus-code-repo" +) + +# --------------------------------------------------------------------------- +# Colour output helpers +# --------------------------------------------------------------------------- +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { printf "${CYAN}[bg-model-call]${NC} %s\n" "$*" >&2; } +warn() { printf "${YELLOW}[bg-model-call] WARNING:${NC} %s\n" "$*" >&2; } +error() { printf "${RED}[bg-model-call] ERROR:${NC} %s\n" "$*" >&2; } + +# --------------------------------------------------------------------------- +# Usage +# --------------------------------------------------------------------------- +usage() { + cat <<'EOF' +bg-model-call — Submit a prompt to a model via the agent-runtimes control plane + +Usage: + bg-model-call --model --prompt --output [OPTIONS] + +Required: + --model Model alias: minimax, sonnet, haiku, opus + --prompt Path to file containing the prompt text + --output Path where the result will be written + +Optional: + --template Override the task template (without .yaml) + --project Project ID for CP grouping (default: bg-model-call) + --task-name Task name shown in agent-monitor + (default: bg-model-call-) + --url Control plane URL (default: $CP_URL or http://localhost:8100) + --timeout Max seconds to wait for task (default: 1800) + --poll-interval Seconds between polls (default: 10) + --dryrun, -n Print what would be submitted without calling the CP + --help, -h Show this help and exit + +Model-to-template mapping: + minimax → minimax-planning + sonnet → sonnet-code-repo + haiku → haiku-code-repo + opus → opus-code-repo + +The .done sentinel (.done) is written on success — poll for this file +from a ScheduleWakeup handler. +EOF +} + +# --------------------------------------------------------------------------- +# Argument defaults +# --------------------------------------------------------------------------- +ARG_MODEL="" +ARG_TEMPLATE="" +ARG_PROMPT="" +ARG_OUTPUT="" +ARG_PROJECT="bg-model-call" +ARG_TASK_NAME="" +ARG_URL="" +ARG_TIMEOUT="1800" +ARG_POLL_INTERVAL="10" +DRYRUN=0 + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --model) + ARG_MODEL="${2:?--model requires a value}" + shift 2 + ;; + --template) + ARG_TEMPLATE="${2:?--template requires a value}" + shift 2 + ;; + --prompt) + ARG_PROMPT="${2:?--prompt requires a value}" + shift 2 + ;; + --output) + ARG_OUTPUT="${2:?--output requires a value}" + shift 2 + ;; + --project) + ARG_PROJECT="${2:?--project requires a value}" + shift 2 + ;; + --task-name) + ARG_TASK_NAME="${2:?--task-name requires a value}" + shift 2 + ;; + --url) + ARG_URL="${2:?--url requires a value}" + shift 2 + ;; + --timeout) + ARG_TIMEOUT="${2:?--timeout requires a value}" + shift 2 + ;; + --poll-interval) + ARG_POLL_INTERVAL="${2:?--poll-interval requires a value}" + shift 2 + ;; + --dryrun|-n) + DRYRUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + error "Unknown flag: $1" + usage + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Validate required args +# --------------------------------------------------------------------------- +if [[ -z "$ARG_PROMPT" ]]; then + error "--prompt is required" + exit 1 +fi +if [[ -z "$ARG_OUTPUT" ]]; then + error "--output is required" + exit 1 +fi +if [[ -z "$ARG_MODEL" && -z "$ARG_TEMPLATE" ]]; then + error "--model is required (or use --template to specify a template directly)" + exit 1 +fi + +# Validate numeric args +if ! [[ "$ARG_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then + error "--timeout must be a positive integer, got: $ARG_TIMEOUT" + exit 1 +fi +if ! [[ "$ARG_POLL_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then + error "--poll-interval must be a positive integer, got: $ARG_POLL_INTERVAL" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Resolve values +# --------------------------------------------------------------------------- + +# Resolve CP URL: --url flag → $CP_URL → default +if [[ -n "$ARG_URL" ]]; then + CP_URL_RESOLVED="$ARG_URL" +elif [[ -n "${CP_URL:-}" ]]; then + CP_URL_RESOLVED="$CP_URL" +else + CP_URL_RESOLVED="http://localhost:8100" +fi + +# Resolve template +if [[ -n "$ARG_TEMPLATE" ]]; then + RESOLVED_TEMPLATE="$ARG_TEMPLATE" +elif [[ -n "$ARG_MODEL" ]]; then + if [[ -z "${MODEL_TEMPLATES[$ARG_MODEL]:-}" ]]; then + error "Unknown model alias '$ARG_MODEL'. Valid values: ${!MODEL_TEMPLATES[*]}" + exit 1 + fi + RESOLVED_TEMPLATE="${MODEL_TEMPLATES[$ARG_MODEL]}" +fi + +# Resolve task name +if [[ -n "$ARG_TASK_NAME" ]]; then + RESOLVED_TASK_NAME="$ARG_TASK_NAME" +elif [[ -n "$ARG_MODEL" ]]; then + RESOLVED_TASK_NAME="bg-model-call-${ARG_MODEL}" +else + RESOLVED_TASK_NAME="bg-model-call-${RESOLVED_TEMPLATE}" +fi + +# Validate prompt file (must be readable even in dryrun) +if [[ ! -f "$ARG_PROMPT" ]]; then + error "Cannot read prompt file: $ARG_PROMPT" + exit 1 +fi +PROMPT_SIZE=$(wc -c < "$ARG_PROMPT") + +# Validate output directory exists +OUTPUT_DIR="$(dirname "$ARG_OUTPUT")" +if [[ ! -d "$OUTPUT_DIR" ]]; then + error "Output directory does not exist: $OUTPUT_DIR" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Locate dispatch-task and wait-for-tasks CLIs +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AR_SCRIPTS="${SCRIPT_DIR}/../../../projects/agent-runtimes/scripts" + +find_cli() { + local name="$1" + if command -v "$name" &>/dev/null; then + command -v "$name" + elif [[ -x "${AR_SCRIPTS}/${name}" ]]; then + echo "${AR_SCRIPTS}/${name}" + else + echo "" + fi +} + +DISPATCH_TASK_BIN="$(find_cli dispatch-task)" +WAIT_FOR_TASKS_BIN="$(find_cli wait-for-tasks)" + +# --------------------------------------------------------------------------- +# Dryrun: print resolved values and exit +# --------------------------------------------------------------------------- +if [[ "$DRYRUN" -eq 1 ]]; then + echo "[dryrun] bg-model-call — no changes will be made" + echo " CP URL: ${CP_URL_RESOLVED}" + if [[ -n "$ARG_MODEL" ]]; then + echo " Model: ${ARG_MODEL}" + fi + echo " Template: ${RESOLVED_TEMPLATE}" + echo " Task name: ${RESOLVED_TASK_NAME}" + echo " Project ID: ${ARG_PROJECT}" + echo " Prompt file: ${ARG_PROMPT}" + echo " Prompt size: ${PROMPT_SIZE} bytes" + echo " Output file: ${ARG_OUTPUT}" + echo " Sentinel: ${ARG_OUTPUT}.done" + echo " Timeout: ${ARG_TIMEOUT}s" + echo " Poll interval: ${ARG_POLL_INTERVAL}s" + if [[ -n "$DISPATCH_TASK_BIN" ]]; then + echo " dispatch-task: ${DISPATCH_TASK_BIN}" + else + echo " dispatch-task: not found (will use curl fallback)" + fi + if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then + echo " wait-for-tasks: ${WAIT_FOR_TASKS_BIN}" + else + echo " wait-for-tasks: not found (will use curl polling fallback)" + fi + exit 0 +fi + +# --------------------------------------------------------------------------- +# Helper: write failure sentinel and exit 1 +# --------------------------------------------------------------------------- +write_failure() { + local task_id="${1:-unknown}" state="${2:-failed}" detail="${3:-}" + { + echo "task_id=${task_id}" + echo "state=${state}" + if [[ -n "$detail" ]]; then + echo "error=${detail}" + fi + } > "${ARG_OUTPUT}.failed" + + # Remove tracking file if it exists + if [[ -n "${task_id}" && "${task_id}" != "unknown" ]]; then + rm -f "${OUTPUT_DIR}/.bg-model-call-${task_id}" + fi + + error "Task ${task_id} ${state}. See ${ARG_OUTPUT}.failed" + exit 1 +} + +# --------------------------------------------------------------------------- +# Submit task +# --------------------------------------------------------------------------- +info "Submitting task (model=${ARG_MODEL:-via-template}, template=${RESOLVED_TEMPLATE})" + +submit_via_dispatch_task() { + "$DISPATCH_TASK_BIN" \ + --name "$RESOLVED_TASK_NAME" \ + --project-id "$ARG_PROJECT" \ + --prompt-file "$ARG_PROMPT" \ + --template "$RESOLVED_TEMPLATE" \ + --cp-url "$CP_URL_RESOLVED" \ + --format json \ + --skip-tag-check 2>/dev/null +} + +submit_via_curl() { + local prompt_text + prompt_text="$(cat "$ARG_PROMPT")" + local payload + payload="$(printf '{"name":"%s","project_id":"%s","prompt":%s,"runtime":{"model":"%s","timeout":%s}}' \ + "$RESOLVED_TASK_NAME" \ + "$ARG_PROJECT" \ + "$(printf '%s' "$prompt_text" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ + "${ARG_MODEL:-${RESOLVED_TEMPLATE}}" \ + "$ARG_TIMEOUT")" + curl -s -X POST "${CP_URL_RESOLVED}/tasks" \ + -H "Content-Type: application/json" \ + -d "$payload" +} + +SUBMIT_RESPONSE="" +if [[ -n "$DISPATCH_TASK_BIN" ]]; then + SUBMIT_RESPONSE="$(submit_via_dispatch_task 2>&1)" || true +else + warn "dispatch-task not found; using curl fallback" + SUBMIT_RESPONSE="$(submit_via_curl 2>&1)" || true +fi + +# Extract task_id from JSON response +TASK_ID="$(printf '%s' "$SUBMIT_RESPONSE" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("task_id",""))' 2>/dev/null || true)" + +if [[ -z "$TASK_ID" ]]; then + error "Failed to submit task. Response: ${SUBMIT_RESPONSE}" + write_failure "unknown" "failed" "submission failed: ${SUBMIT_RESPONSE}" +fi + +info "Task submitted: ${TASK_ID}" + +# Write tracking file +{ + echo "task_id=${TASK_ID}" + echo "model=${ARG_MODEL:-}" + echo "template=${RESOLVED_TEMPLATE}" + echo "submitted_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +} > "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}" + +# --------------------------------------------------------------------------- +# Wait for task completion +# --------------------------------------------------------------------------- +info "Waiting for task ${TASK_ID} (timeout=${ARG_TIMEOUT}s, poll=${ARG_POLL_INTERVAL}s)" + +FINAL_STATE="" + +wait_via_wait_for_tasks() { + "$WAIT_FOR_TASKS_BIN" \ + --url "$CP_URL_RESOLVED" \ + --timeout "$ARG_TIMEOUT" \ + --interval "$ARG_POLL_INTERVAL" \ + --format text \ + "$TASK_ID" >/dev/null 2>&1 + # After wait-for-tasks exits, fetch the final state + local state_json + state_json="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}")" + printf '%s' "$state_json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown" +} + +wait_via_curl_poll() { + local elapsed=0 + local state="pending" + local terminal_states="succeeded failed timed_out cancelled" + + while true; do + local response + response="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}" 2>/dev/null)" || true + state="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown")" + + info " state=${state} (${elapsed}s elapsed)" + + # Check if terminal + for ts in $terminal_states; do + if [[ "$state" == "$ts" ]]; then + echo "$state" + return 0 + fi + done + + # Check timeout + if [[ "$elapsed" -ge "$ARG_TIMEOUT" ]]; then + echo "timed_out" + return 0 + fi + + sleep "$ARG_POLL_INTERVAL" + elapsed=$((elapsed + ARG_POLL_INTERVAL)) + done +} + +if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then + FINAL_STATE="$(wait_via_wait_for_tasks)" +else + warn "wait-for-tasks not found; using curl polling fallback" + FINAL_STATE="$(wait_via_curl_poll)" +fi + +info "Task ${TASK_ID} reached state: ${FINAL_STATE}" + +# --------------------------------------------------------------------------- +# Handle terminal state +# --------------------------------------------------------------------------- +if [[ "$FINAL_STATE" != "succeeded" ]]; then + write_failure "$TASK_ID" "$FINAL_STATE" "task did not succeed" +fi + +# --------------------------------------------------------------------------- +# Fetch logs and write output +# --------------------------------------------------------------------------- +info "Fetching task logs..." +LOGS_RESPONSE="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}/logs" 2>/dev/null)" || true + +if [[ -z "$LOGS_RESPONSE" ]]; then + warn "Empty logs response from CP; output file will be empty" +fi + +printf '%s\n' "$LOGS_RESPONSE" > "$ARG_OUTPUT" + +# Write .done sentinel +{ + echo "task_id=${TASK_ID}" + echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +} > "${ARG_OUTPUT}.done" + +# Remove tracking file +rm -f "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}" + +info "Done. Result written to ${ARG_OUTPUT}" +info "Sentinel written to ${ARG_OUTPUT}.done" +exit 0 diff --git a/scripts/wait-for-version b/scripts/wait-for-version new file mode 100755 index 0000000..c89a407 --- /dev/null +++ b/scripts/wait-for-version @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -uo pipefail + +DEFAULT_SERVER="https://gitea.oreillyit.nz" +DEFAULT_TIMEOUT=300 +DEFAULT_INTERVAL=15 +DEFAULT_WORKFLOW="version" + +usage() { + cat <<'EOF' +Usage: wait-for-version [OPTIONS] REPO + +Poll a Gitea repo's Actions runs until the CI workflow for HEAD of main +completes successfully, then print the latest release tag. + +Arguments: + REPO owner/name (e.g. skynet/hugo-accelerator) + +Options: + -t, --token TOKEN Gitea access token (else $GITEA_TOKEN / $GITHUB_TOKEN) + --workflow NAME Workflow name to wait for (default: version) + --timeout SECONDS Max total wait time (default: 300; 0 = poll once) + --interval SECONDS Polling interval (default: 15; min: 1) + --server URL Gitea base URL (else $GITHUB_SERVER_URL, else + https://gitea.oreillyit.nz) + -v, --verbose Print polling progress to stderr + -n, --dryrun Show resolved configuration without making API calls + -h, --help Show this help + +Exit codes: + 0 CI succeeded; latest release tag printed to stdout + 1 timeout expired, CI failed, or no release tag found + 2 usage error + 3 terminal API error (401, 403, 404) +EOF +} + +timeout=$DEFAULT_TIMEOUT +interval=$DEFAULT_INTERVAL +server="${GITHUB_SERVER_URL:-$DEFAULT_SERVER}" +token="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}" +workflow="$DEFAULT_WORKFLOW" +verbose=false +dryrun=false +repo="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -n|--dryrun) dryrun=true; shift ;; + -v|--verbose) verbose=true; shift ;; + -t|--token) + [[ $# -lt 2 ]] && { echo "Error: --token requires a value" >&2; exit 2; } + token="$2"; shift 2 ;; + --workflow) + [[ $# -lt 2 ]] && { echo "Error: --workflow requires a value" >&2; exit 2; } + workflow="$2"; shift 2 ;; + --timeout) + [[ $# -lt 2 ]] && { echo "Error: --timeout requires a value" >&2; exit 2; } + timeout="$2"; shift 2 ;; + --interval) + [[ $# -lt 2 ]] && { echo "Error: --interval requires a value" >&2; exit 2; } + interval="$2"; shift 2 ;; + --server) + [[ $# -lt 2 ]] && { echo "Error: --server requires a value" >&2; exit 2; } + server="$2"; shift 2 ;; + --) shift; break ;; + -*) echo "Error: Unknown option: $1" >&2; exit 2 ;; + *) + if [[ -z "$repo" ]]; then repo="$1" + else echo "Error: Unexpected argument: $1" >&2; exit 2 + fi + shift ;; + esac +done + +[[ -z "$repo" ]] && { echo "Error: REPO is required" >&2; echo >&2; usage >&2; exit 2; } +[[ "$repo" != */* ]] && { echo "Error: REPO must be in 'owner/name' form (got: $repo)" >&2; exit 2; } +[[ ! "$timeout" =~ ^[0-9]+$ ]] && { echo "Error: --timeout must be a non-negative integer (got: $timeout)" >&2; exit 2; } +[[ ! "$interval" =~ ^[0-9]+$ ]] && { echo "Error: --interval must be a positive integer (got: $interval)" >&2; exit 2; } +(( interval < 1 )) && { echo "Error: --interval must be >= 1" >&2; exit 2; } + +api_base="${server%/}/api/v1/repos/${repo}" + +if $dryrun; then + echo "[dryrun] Server: ${server}" + echo "[dryrun] Repo: ${repo}" + echo "[dryrun] Workflow: ${workflow}" + echo "[dryrun] Timeout: ${timeout}s" + echo "[dryrun] Interval: ${interval}s" + if [[ -n "$token" ]]; then + echo "[dryrun] Auth: yes" + else + echo "[dryrun] Auth: no" + fi + echo "[dryrun] Actions API: ${api_base}/actions/runs" + exit 0 +fi + +curl_args=(-sS -m 30 -w $'\n%{http_code}') +[[ -n "$token" ]] && curl_args+=(-H "Authorization: token ${token}") + +sha_response="$(curl "${curl_args[@]}" "${api_base}/branches/main" 2>/dev/null)" +sha_rc=$? +sha_http="${sha_response##*$'\n'}" +sha_body="${sha_response%$'\n'*}" + +if (( sha_rc != 0 )); then + echo "Error: curl failed resolving HEAD SHA for ${repo} (rc=${sha_rc})" >&2 + exit 3 +fi + +case "$sha_http" in + 200) + sha="$(printf '%s' "$sha_body" | jq -r '.commit.id // empty' 2>/dev/null)" + [[ -z "$sha" ]] && { echo "Error: could not extract HEAD SHA from branch response" >&2; exit 3; } + ;; + 401|403) echo "Error: Gitea API authentication failed (HTTP ${sha_http}) for ${repo}" >&2; exit 3 ;; + 404) echo "Error: repo not found: ${repo} (HTTP 404)" >&2; exit 3 ;; + *) echo "Error: unexpected HTTP ${sha_http} resolving HEAD SHA for ${repo}" >&2; exit 3 ;; +esac + +echo "Waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}..." >&2 + +start=$(date +%s) +deadline=$(( start + timeout )) +poll=0 + +while true; do + poll=$(( poll + 1 )) + + runs_response="$(curl "${curl_args[@]}" \ + "${api_base}/actions/runs?head_sha=${sha}&limit=50" 2>/dev/null)" + runs_rc=$? + + if (( runs_rc != 0 )); then + $verbose && echo "wait-for-version: poll ${poll}: curl error (rc=${runs_rc})" >&2 + else + http_code="${runs_response##*$'\n'}" + body="${runs_response%$'\n'*}" + + case "$http_code" in + 200) + status="$(printf '%s' "$body" | jq -r \ + --arg w "$workflow" \ + '[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .status // "pending"' \ + 2>/dev/null)" + conclusion="$(printf '%s' "$body" | jq -r \ + --arg w "$workflow" \ + '[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .conclusion // ""' \ + 2>/dev/null)" + + if [[ "$status" == "completed" ]]; then + if [[ "$conclusion" == "success" ]]; then + # Resolve tag by SHA match — avoids races with release creation + # and works regardless of whether the repo uses Gitea releases. + # When multiple tags share the SHA (e.g. v0.0.8 and v0.0.8.14), + # prefer the 3-component semver root (fewest dot-components). + tag_response="$(curl "${curl_args[@]}" "${api_base}/tags?limit=50" 2>/dev/null)" + tag_http="${tag_response##*$'\n'}" + tag_body="${tag_response%$'\n'*}" + if [[ "$tag_http" != "200" ]]; then + echo "Error: could not fetch tags for ${repo} (HTTP ${tag_http})" >&2 + exit 1 + fi + tag="$(printf '%s' "$tag_body" | jq -r --arg sha "$sha" \ + '[.[] | select(.commit.sha == $sha)] | + sort_by(.name | ltrimstr("v") | split(".") | length) | + first | .name // empty' 2>/dev/null)" + if [[ -n "$tag" ]]; then + $verbose && echo "wait-for-version: poll ${poll}: success, tag=${tag}" >&2 + echo "$tag" + exit 0 + else + echo "Error: no tag found for ${repo} @ ${sha:0:8}" >&2 + exit 1 + fi + else + echo "Error: CI failed for ${repo} @ ${sha:0:8} (conclusion: ${conclusion})" >&2 + exit 1 + fi + fi + $verbose && echo "wait-for-version: poll ${poll}: status=${status:-pending}, retrying" >&2 + ;; + 401|403) + echo "Error: Gitea API authentication failed (HTTP ${http_code}) for ${repo}" >&2 + exit 3 ;; + 404) + echo "Error: repo not found: ${repo} (HTTP 404)" >&2 + exit 3 ;; + *) + $verbose && echo "wait-for-version: poll ${poll}: HTTP ${http_code}, retrying" >&2 + ;; + esac + fi + + now=$(date +%s) + if (( now >= deadline )); then + break + fi + remaining=$(( deadline - now )) + sleep_time=$interval + (( sleep_time > remaining )) && sleep_time=$remaining + $verbose && echo "wait-for-version: poll ${poll}: sleeping ${sleep_time}s (${remaining}s remaining)" >&2 + sleep "$sleep_time" +done + +echo "Error: timeout after ${timeout}s waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}" >&2 +exit 1 diff --git a/specs/auto-draft.spec.md b/specs/auto-draft.spec.md new file mode 100644 index 0000000..3d457d6 --- /dev/null +++ b/specs/auto-draft.spec.md @@ -0,0 +1,122 @@ +# auto-draft + +## Purpose + +Scan the writing dossiers for AI-pipeline work and schedule jobs in priority order, using only the "spare" capacity of the current subscription window. Designed to be run from a cron job (hourly) so that it picks up work as spare capacity appears. + +The script is **dryrun-only** in this revision: it shows the spare capacity and the three priority queues, but does not execute any jobs. Job execution is a later stage. + +## Usage + +``` +auto-draft --provider --threshold --polish-score <0-10> [OPTIONS] + +Required: + --provider PROVIDER Subscription provider to draw capacity from: anthropic or minimax + --threshold PERCENT Maximum subscription utilisation allowed over the period, as a + percentage (e.g. 50 = use up to 50% of the weekly allowance). + The script computes the linear limit at the current point in the + period and reports the spare capacity. + --polish-score N Paragraph score below which a paragraph is queued for polishing + (e.g. 7 means "polish any paragraph with score < 7"). + +Options: + --base-dir PATH Root directory to scan for dossier files. Defaults to ".". + Recurses into subdirectories. + -n, --dryrun Show capacity and queues without executing. This is the default + behaviour for this revision; the flag is explicit so the cron + command line is self-documenting. + -h, --help Show this help message and exit. +``` + +## Behaviour + +1. Probe the live subscription for the given provider. The script shells out to `agent-subscriptions --output json` and reads the relevant provider's `seven_day` window. +2. Compute the spare capacity against the supplied threshold: + - `last_renewed = reset_at - 7 days` (the weekly window is treated as a rolling 7-day period) + - `period_seconds = 7 * 86400` + - `elapsed_seconds = max(0, now - last_renewed)` + - `limit_pct = elapsed_seconds / period_seconds * threshold` (the linear allowance at this point in the period) + - `spare_pct = limit_pct - utilization_pct` (positive = under budget, negative = over budget) +3. Discover titles by scanning for `*.overview.md` source files under `--base-dir` (recursively). Each title is the stem of the source file (e.g. `01-ai-is-an-amplifier`); the numeric ID is the leading digits before the first `-`. +4. Build three priority queues, in priority order: + - **Polish queue (highest priority)** — for every title whose `*.ai_score..md` exists, parse the file and find paragraphs with `score < polish_score` and not marked exempt. If any such paragraph exists, the whole title is queued for polish. + - **Scoring queue (second priority)** — for every title whose `*.ai_generated..md` exists but whose `*.ai_score..md` does not. The job is to score the generated file. + - **Generation queue (lowest priority)** — for every title whose `*.ai_generated..md` does not exist. The job is to generate the draft from the source brief. +5. Sort each queue by numeric ID ascending (titles without a numeric ID sort last). +6. Print the spare capacity and the three sorted queues. + +## Score file format + +The `*.ai_score..md` files embed per-paragraph scores and an exempt marker as inline HTML comments after each paragraph: + +```markdown +This is a great paragraph with polish. + +This is a middling paragraph. + +This is already reviewed and acceptable. +``` + +Regex patterns: +- Score: `` +- Exempt: `` + +A paragraph is queued for polish if it has a score comment with a value below `polish-score` AND does not have an exempt comment. Paragraphs without a score comment are not eligible for polishing (they are skipped), even if the file contains other scored paragraphs. + +## Spare capacity output + +``` +[auto-draft] Spare capacity for Anthropic + Reset at: 2026-08-07T23:00:00+00:00 + Renewed at: 2026-07-31T23:00:00+00:00 + Elapsed: 16h 18m of 7d (9.7%) + Threshold: 50.0% + Linear limit: 4.9% + Current usage: 41.2% + Spare capacity: -36.4% (over budget; do not queue any work) +``` + +For a healthy budget: +``` + Spare capacity: 23.5% (under budget) +``` + +## Queue output + +``` +[auto-draft] Polish queue (priority 1) — 3 items + 01-ai-is-an-amplifier 2 paragraphs below 7 (of 4 scored) + 02-five-layers-of-an-agent-harness 1 paragraph below 7 + 05-the-compound-engineering-loop 3 paragraphs below 7 + +[auto-draft] Scoring queue (priority 2) — 1 item + 03-shadow-agents-are-the-new-shadow-it + +[auto-draft] Generation queue (priority 3) — 8 items + 04-risk-proportionate-delivery + 06-backpressure-is-coming-for-your-ci + 07-tests-are-law-llms-are-not-the-judge + ... +``` + +## Edge cases + +- If the provider probe fails (`available: false`), print a warning and skip the capacity section. Still build and print the queues. +- If the seven-day `reset_at` is missing, skip the capacity section (cannot compute `last_renewed`). Still build and print the queues. +- If a title has no numeric ID prefix, sort it last in every queue. +- If the base directory contains no source files, print all three queues as empty. +- If a source file's directory also contains the `ai_score` and `ai_generated` files, the title is in the polish queue (or none, if all paragraphs pass). Otherwise scoring or generation. +- Exit 0 unless the CLI args are invalid. The script is informational; one bad provider probe should not fail the cron. + +## Dryrun behaviour + +This revision is dryrun-only. The script always prints the capacity and queues. Job execution is out of scope until the design is approved. + +## Examples + +```sh +auto-draft --provider anthropic --threshold 50 --polish-score 7 +auto-draft --provider anthropic --threshold 50 --polish-score 7 --base-dir /home/paul/dev/claude/writing/oreillyconsulting +auto-draft --provider minimax --threshold 70 --polish-score 8 --dryrun +```