diff --git a/CHANGELOG.md b/CHANGELOG.md index f6216d0..029833b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to cc-taskrunner will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). +## [1.5.0] — 2026-04-09 + +### Added +- **Blast radius preflight gate** (#20) — Mission briefs now compute a blast radius via `charter blast --format json` on file paths extracted from the prompt. Severity ladder: `low` (0–4 affected), `medium` (5–19), `high` (20–49), `critical` (50+). Tasks classified as `auto_safe` with `critical` severity are **refused** — the runner logs `TASK_BLOCKED`, marks the task failed with an explanation, and returns without spawning Claude. `high` and `critical` severities inject a `## Blast Radius Warning` section into the mission brief so the agent knows the scope. + + Requires `@stackbilt/cli >= 0.10.0` on PATH. Graceful no-op when charter is unavailable. Opt out entirely via `CC_DISABLE_BLAST=1`. Thresholds configurable via `CC_BLAST_WARN` (default `20`) and `CC_BLAST_BLOCK` (default `50`). Timeout via `CC_BLAST_TIMEOUT` (default `60s`). Seed file count capped at 10 to prevent runaway prompts from exploding the blast call. + + Applied symmetrically to `taskrunner.sh` and `plugin/taskrunner.sh`. + ## [1.4.1] — 2026-04-09 ### Fixed diff --git a/README.md b/README.md index 5fb6d9a..27ac9db 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,58 @@ If a blocker fails, all tasks that depend on it are automatically cancelled. | `CC_MAX_TURNS` | `25` | Default Claude Code turns per task | | `CC_REPOS_DIR` | *(unset)* | Base directory for repo lookups (e.g. `~/repos`) | | `CC_REPO_ALIASES` | `./repo-aliases.conf` | Path to repo alias file (`name=directory` per line) | +| `CC_DISABLE_FINGERPRINT` | `0` | Set to `1` to skip the `charter surface` fingerprint injection | +| `CC_FINGERPRINT_TIMEOUT` | `60` | Timeout in seconds for `charter surface` (per task) | +| `CC_DISABLE_BLAST` | `0` | Set to `1` to skip the `charter blast` preflight gate entirely | +| `CC_BLAST_WARN` | `20` | Blast radius threshold for `high` severity (warning injected into mission brief) | +| `CC_BLAST_BLOCK` | `50` | Blast radius threshold for `critical` severity (auto_safe execution refused) | +| `CC_BLAST_TIMEOUT` | `60` | Timeout in seconds for `charter blast` (per task) | + +## Charter Integration (optional) + +cc-taskrunner can optionally call [`@stackbilt/cli`](https://github.com/Stackbilt-dev/charter) during preflight to make mission briefs smarter. Both integrations are **no-ops when charter isn't installed**, so this is strictly additive. + +### 1. Project fingerprint — `charter surface` + +When `charter surface --markdown` is available on `PATH`, the runner injects a `## Project Context (auto-generated)` section into the mission brief. The section lists HTTP routes (Hono/Express/itty-router) and D1 schema tables so the agent starts with layout awareness instead of burning turns exploring the codebase. + +- Output is capped at 80 lines to protect the prompt budget +- Opt out: `CC_DISABLE_FINGERPRINT=1` +- Timeout: `CC_FINGERPRINT_TIMEOUT` (default `60s`) + +### 2. Blast radius preflight gate — `charter blast` + +When `charter blast --format json` is available, the runner extracts file paths from the task prompt and computes the blast radius — the set of files that transitively import the seeds. If the blast is large enough, the gate refuses to execute `auto_safe` tasks before any turns are burned. + +**Severity ladder:** + +| Affected files | Severity | Behavior | +|---|---|---| +| 0–4 | `low` | silent | +| 5–19 | `medium` | silent | +| 20–49 | `high` | warning injected into mission brief | +| 50+ | `critical` | warning injected; **`auto_safe` execution refused** | + +**When the gate fires,** the runner logs `⚠ GATE: blast radius critical ...`, calls `update_task_status` with `status=failed` and a `TASK_BLOCKED: blast_radius_critical` result, and returns without spawning Claude. The operator can force execution by changing the task's `authority` to `operator` and re-queuing. + +**When the warning is injected** (high or critical), the mission brief gains a section like: + +```markdown +## Blast Radius Warning +- Severity: **CRITICAL** — 72 files affected +- Seed files: src/kernel/dispatch.ts +- One or more seeds are in the top 20 most-imported files (architectural hub) +- Treat this as CROSS_CUTTING: review carefully before merging +``` + +**Tuning:** +- Opt out entirely: `CC_DISABLE_BLAST=1` +- Raise/lower thresholds: `CC_BLAST_WARN=30`, `CC_BLAST_BLOCK=100` +- Timeout: `CC_BLAST_TIMEOUT` (default `60s`) +- Seed file count is internally capped at 10 to prevent runaway prompts from exploding the blast call +- Only `.ts` / `.tsx` / `.js` / `.jsx` / `.mjs` / `.cjs` files are recognized as seeds + +**Requires:** `@stackbilt/cli >= 0.10.0` on `PATH`. Install with `npm install -g @stackbilt/cli`. ## Safety Architecture diff --git a/plugin/taskrunner.sh b/plugin/taskrunner.sh index c3aa4da..f32bade 100644 --- a/plugin/taskrunner.sh +++ b/plugin/taskrunner.sh @@ -76,6 +76,73 @@ build_fingerprint() { echo "$output" | head -n 80 } +# ─── Blast radius preflight (optional) ────────────────────── +# Runs `charter blast` on files referenced by the task prompt. Severity +# ladder: low (0-4) / medium (5-19) / high (20-49) / critical (50+). +# Used as a gate — auto_safe tasks with critical blast are refused. +# +# Env knobs: CC_DISABLE_BLAST, CC_BLAST_WARN, CC_BLAST_BLOCK, CC_BLAST_TIMEOUT +compute_blast_radius() { + local prompt="$1" repo_path="$2" + local disabled="${CC_DISABLE_BLAST:-0}" + if [[ "$disabled" = "1" ]]; then return 0; fi + if ! command -v charter >/dev/null 2>&1; then return 0; fi + if [[ ! -d "$repo_path" ]]; then return 0; fi + + local files + files=$(echo "$prompt" | grep -oE '[a-zA-Z0-9_./-]+\.(ts|tsx|js|jsx|mjs|cjs)' | sort -u) + if [[ -z "$files" ]]; then return 0; fi + + local seed_args=() + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if [[ -f "${repo_path}/${f}" ]]; then seed_args+=("$f"); fi + done <<< "$files" + if [[ ${#seed_args[@]} -eq 0 ]]; then return 0; fi + if [[ ${#seed_args[@]} -gt 10 ]]; then seed_args=("${seed_args[@]:0:10}"); fi + + local blast_timeout="${CC_BLAST_TIMEOUT:-60}" + local blast_json + blast_json=$(cd "$repo_path" && timeout "$blast_timeout" charter blast "${seed_args[@]}" --format json 2>/dev/null || true) + if [[ -z "$blast_json" ]]; then return 0; fi + + BLAST_RAW="$blast_json" BLAST_WARN="${CC_BLAST_WARN:-20}" BLAST_BLOCK="${CC_BLAST_BLOCK:-50}" python3 -c ' +import json, os, sys +try: d = json.loads(os.environ["BLAST_RAW"]) +except Exception: sys.exit(0) +warn = int(os.environ.get("BLAST_WARN", "20")) +block = int(os.environ.get("BLAST_BLOCK", "50")) +affected = int(d.get("summary", {}).get("totalAffected", 0)) +seeds = d.get("seeds", []) or [] +hot_files = d.get("hotFiles", []) or [] +if affected >= block: severity = "critical" +elif affected >= warn: severity = "high" +elif affected >= 5: severity = "medium" +else: severity = "low" +hot_set = {h.get("file") for h in hot_files[:20]} +print(json.dumps({"seeds": seeds, "affected": affected, "severity": severity, "hot_file": any(s in hot_set for s in seeds), "top_hot_files": hot_files[:5]})) +' +} + +render_blast_warning() { + local blast_json="$1" + [[ -z "$blast_json" ]] && return 0 + BLAST="$blast_json" python3 -c ' +import json, os, sys +try: b = json.loads(os.environ["BLAST"]) +except Exception: sys.exit(0) +sev = b.get("severity", "") +if sev not in ("high", "critical"): sys.exit(0) +affected = b.get("affected", 0) +seed_list = ", ".join(b.get("seeds", [])) +lines = ["", "## Blast Radius Warning", f"- Severity: **{sev.upper()}** — {affected} files affected", f"- Seed files: {seed_list}"] +if b.get("hot_file"): + lines.append("- One or more seeds are in the top 20 most-imported files (architectural hub)") +lines.append("- Treat this as CROSS_CUTTING: review carefully before merging") +print("\n".join(lines)) +' +} + # ─── Queue management ─────────────────────────────────────── init_queue() { @@ -364,6 +431,30 @@ FPRINT log "│ Fingerprint: $(echo "$fingerprint" | grep -cE '^- ' || echo 0) items injected" fi + # Blast radius preflight gate — refuse auto_safe execution on critical radius + local blast_json blast_warning_section="" + blast_json="$(compute_blast_radius "$prompt" "$repo_path" || true)" + if [[ -n "$blast_json" ]]; then + local blast_severity blast_affected + blast_severity=$(echo "$blast_json" | python3 -c 'import json,sys +try: print(json.load(sys.stdin).get("severity","")) +except: print("")' 2>/dev/null || echo "") + blast_affected=$(echo "$blast_json" | python3 -c 'import json,sys +try: print(int(json.load(sys.stdin).get("affected",0))) +except: print(0)' 2>/dev/null || echo "0") + + if [[ "$blast_severity" = "critical" && "$authority" = "auto_safe" ]]; then + log "│ ⚠ GATE: blast radius critical (${blast_affected} files) — refusing auto_safe execution" + update_task_status "$task_id" "failed" "TASK_BLOCKED: blast_radius_critical — ${blast_affected} files affected. Requires operator approval." + return 0 + fi + + blast_warning_section="$(render_blast_warning "$blast_json")" + if [[ -n "$blast_warning_section" ]]; then + log "│ Blast radius: ${blast_severity} (${blast_affected} files) — warning injected" + fi + fi + # Build mission prompt local mission_prompt mission_prompt="$(cat < — high threshold (default: 20) +# CC_BLAST_BLOCK= — critical threshold (default: 50) +# CC_BLAST_TIMEOUT= — charter blast timeout (default: 60) +# +# Emits compact JSON on stdout when successful, empty when skipped. +# Gracefully no-ops when charter is unavailable or no files are found. +compute_blast_radius() { + local prompt="$1" repo_path="$2" + local disabled="${CC_DISABLE_BLAST:-0}" + if [[ "$disabled" = "1" ]]; then return 0; fi + if ! command -v charter >/dev/null 2>&1; then return 0; fi + if [[ ! -d "$repo_path" ]]; then return 0; fi + + # Extract file paths from the prompt and filter to ones that exist + local files + files=$(echo "$prompt" | grep -oE '[a-zA-Z0-9_./-]+\.(ts|tsx|js|jsx|mjs|cjs)' | sort -u) + if [[ -z "$files" ]]; then return 0; fi + + local seed_args=() + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if [[ -f "${repo_path}/${f}" ]]; then + seed_args+=("$f") + fi + done <<< "$files" + if [[ ${#seed_args[@]} -eq 0 ]]; then return 0; fi + + # Cap seeds at 10 so a runaway prompt can't explode the blast call + if [[ ${#seed_args[@]} -gt 10 ]]; then + seed_args=("${seed_args[@]:0:10}") + fi + + local blast_timeout="${CC_BLAST_TIMEOUT:-60}" + local blast_json + blast_json=$(cd "$repo_path" && timeout "$blast_timeout" charter blast "${seed_args[@]}" --format json 2>/dev/null || true) + if [[ -z "$blast_json" ]]; then return 0; fi + + local warn_threshold="${CC_BLAST_WARN:-20}" + local block_threshold="${CC_BLAST_BLOCK:-50}" + BLAST_RAW="$blast_json" BLAST_WARN="$warn_threshold" BLAST_BLOCK="$block_threshold" python3 -c ' +import json, os, sys +try: + d = json.loads(os.environ["BLAST_RAW"]) +except Exception: + sys.exit(0) +warn = int(os.environ.get("BLAST_WARN", "20")) +block = int(os.environ.get("BLAST_BLOCK", "50")) +affected = int(d.get("summary", {}).get("totalAffected", 0)) +seeds = d.get("seeds", []) or [] +hot_files = d.get("hotFiles", []) or [] +if affected >= block: + severity = "critical" +elif affected >= warn: + severity = "high" +elif affected >= 5: + severity = "medium" +else: + severity = "low" +hot_set = {h.get("file") for h in hot_files[:20]} +hot_file = any(s in hot_set for s in seeds) +print(json.dumps({ + "seeds": seeds, + "affected": affected, + "severity": severity, + "hot_file": hot_file, + "top_hot_files": hot_files[:5], +})) +' +} + +# Render blast radius as a mission brief warning section. +# Returns non-empty string only for `high` and `critical` severities. +render_blast_warning() { + local blast_json="$1" + [[ -z "$blast_json" ]] && return 0 + BLAST="$blast_json" python3 -c ' +import json, os, sys +try: + b = json.loads(os.environ["BLAST"]) +except Exception: + sys.exit(0) +sev = b.get("severity", "") +if sev not in ("high", "critical"): + sys.exit(0) +affected = b.get("affected", 0) +seed_list = ", ".join(b.get("seeds", [])) +lines = [] +lines.append("") +lines.append("## Blast Radius Warning") +lines.append(f"- Severity: **{sev.upper()}** — {affected} files affected") +lines.append(f"- Seed files: {seed_list}") +if b.get("hot_file"): + lines.append("- One or more seeds are in the top 20 most-imported files (architectural hub)") +lines.append("- Treat this as CROSS_CUTTING: review carefully before merging") +print("\n".join(lines)) +' +} + # ─── Queue management ─────────────────────────────────────── init_queue() { @@ -477,6 +590,33 @@ FPRINT log "│ Fingerprint: $(echo "$fingerprint" | grep -cE '^- ' || echo 0) items injected" fi + # ─── Blast radius preflight gate ───────────────────────── + # If charter blast reports a critical radius on files referenced by the + # task prompt, refuse to execute auto_safe tasks. Forces operator review. + local blast_json blast_warning_section="" + blast_json="$(compute_blast_radius "$prompt" "$repo_path" || true)" + if [[ -n "$blast_json" ]]; then + local blast_severity blast_affected + blast_severity=$(echo "$blast_json" | python3 -c 'import json,sys +try: print(json.load(sys.stdin).get("severity","")) +except: print("")' 2>/dev/null || echo "") + blast_affected=$(echo "$blast_json" | python3 -c 'import json,sys +try: print(int(json.load(sys.stdin).get("affected",0))) +except: print(0)' 2>/dev/null || echo "0") + + if [[ "$blast_severity" = "critical" && "$authority" = "auto_safe" ]]; then + log "│ ⚠ GATE: blast radius critical (${blast_affected} files) — refusing auto_safe execution" + update_task_status "$task_id" "failed" "TASK_BLOCKED: blast_radius_critical — ${blast_affected} files affected. Requires operator approval (change authority to 'operator' and re-queue)." + return 0 + fi + + # High/critical render a warning into the mission brief (non-empty for those tiers only) + blast_warning_section="$(render_blast_warning "$blast_json")" + if [[ -n "$blast_warning_section" ]]; then + log "│ Blast radius: ${blast_severity} (${blast_affected} files) — warning injected" + fi + fi + # Build mission prompt local mission_prompt mission_prompt="$(cat <