chore: track ask-minimax-run, bg-model-call, wait-for-version, auto-draft (+spec/data) — were untracked
Claude-Session: https://claude.ai/code/session_015nXvGNNNixJPFwZKZHNyha
This commit is contained in:
3
data/auto-draft/example/dossier-a/01-empty.overview.md
Normal file
3
data/auto-draft/example/dossier-a/01-empty.overview.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 01 — The amplifier
|
||||||
|
|
||||||
|
A topic brief that has not yet been generated. Will appear in the generation queue.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 02 — Has generated
|
||||||
|
|
||||||
|
This is a generated draft for the topic. It has not yet been scored.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 03 — All paragraphs pass
|
||||||
|
|
||||||
|
Generated draft.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
This is a polished paragraph. <!-- score: 9 -->
|
||||||
|
|
||||||
|
This is also a polished paragraph. <!-- score: 8 -->
|
||||||
|
|
||||||
|
And this one is fine too. <!-- score: 9 -->
|
||||||
3
data/auto-draft/example/dossier-a/03-clean.overview.md
Normal file
3
data/auto-draft/example/dossier-a/03-clean.overview.md
Normal file
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 04 — Needs polish
|
||||||
|
|
||||||
|
Generated draft that needs further work.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
This is a polished paragraph. <!-- score: 9 -->
|
||||||
|
|
||||||
|
This is a middling paragraph that needs work. <!-- score: 5 -->
|
||||||
|
|
||||||
|
This is a polished paragraph. <!-- score: 8 -->
|
||||||
|
|
||||||
|
This is a poor paragraph that needs work. <!-- score: 4 -->
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 05 — Exempt only
|
||||||
|
|
||||||
|
Generated draft.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
This is a paragraph that is below the polish threshold but is already accepted. <!-- score: 5 --><!-- exempt: true -->
|
||||||
|
|
||||||
|
This is a paragraph that is below the polish threshold but is already accepted. <!-- score: 4 --><!-- exempt: true -->
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 06 — Cross folder
|
||||||
|
|
||||||
|
Generated draft.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 06 — Cross folder
|
||||||
|
|
||||||
|
Source topic in a different subfolder. Exercises recursive scan.
|
||||||
29
scripts/ask-minimax-run
Executable file
29
scripts/ask-minimax-run
Executable file
@@ -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 "$@"
|
||||||
309
scripts/auto-draft
Executable file
309
scripts/auto-draft
Executable file
@@ -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"<!--\s*score:\s*(\d+)\s*-->")
|
||||||
|
EXEMPT_RE = re.compile(r"<!--\s*exempt:\s*true\s*-->")
|
||||||
|
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())
|
||||||
435
scripts/bg-model-call
Executable file
435
scripts/bg-model-call
Executable file
@@ -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 <output>.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 <alias> --prompt <file> --output <file> [OPTIONS]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--model <alias> Model alias: minimax, sonnet, haiku, opus
|
||||||
|
--prompt <file> Path to file containing the prompt text
|
||||||
|
--output <file> Path where the result will be written
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--template <name> Override the task template (without .yaml)
|
||||||
|
--project <id> Project ID for CP grouping (default: bg-model-call)
|
||||||
|
--task-name <name> Task name shown in agent-monitor
|
||||||
|
(default: bg-model-call-<model>)
|
||||||
|
--url <cp-url> Control plane URL (default: $CP_URL or http://localhost:8100)
|
||||||
|
--timeout <seconds> Max seconds to wait for task (default: 1800)
|
||||||
|
--poll-interval <s> 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 (<output>.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
|
||||||
209
scripts/wait-for-version
Executable file
209
scripts/wait-for-version
Executable file
@@ -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
|
||||||
122
specs/auto-draft.spec.md
Normal file
122
specs/auto-draft.spec.md
Normal file
@@ -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 <provider> --threshold <pct> --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.<provider>.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.<provider>.md` exists but whose `*.ai_score.<provider>.md` does not. The job is to score the generated file.
|
||||||
|
- **Generation queue (lowest priority)** — for every title whose `*.ai_generated.<provider>.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.<provider>.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. <!-- score: 9 -->
|
||||||
|
|
||||||
|
This is a middling paragraph. <!-- score: 6 -->
|
||||||
|
|
||||||
|
This is already reviewed and acceptable. <!-- score: 5 --><!-- exempt: true -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Regex patterns:
|
||||||
|
- Score: `<!--\s*score:\s*(\d+)\s*-->`
|
||||||
|
- Exempt: `<!--\s*exempt:\s*true\s*-->`
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user