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:
Paul O'Reilly
2026-09-11 06:15:14 +12:00
parent 9a6d7135c5
commit 0ceb49044a
19 changed files with 1152 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# 01 — The amplifier
A topic brief that has not yet been generated. Will appear in the generation queue.

View File

@@ -0,0 +1,3 @@
# 02 — Has generated
This is a generated draft for the topic. It has not yet been scored.

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
# 03 — All paragraphs pass
Generated draft.

View File

@@ -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 -->

View 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.

View File

@@ -0,0 +1,3 @@
# 04 — Needs polish
Generated draft that needs further work.

View File

@@ -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 -->

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
# 05 — Exempt only
Generated draft.

View File

@@ -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 -->

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
# 06 — Cross folder
Generated draft.

View File

@@ -0,0 +1,3 @@
# 06 — Cross folder
Source topic in a different subfolder. Exercises recursive scan.

29
scripts/ask-minimax-run Executable file
View 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
View 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
View 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
View 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
View 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
```