Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 92 additions & 1 deletion plugin/taskrunner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 <<MISSION
Expand All @@ -374,7 +465,7 @@ Read files before modifying them. Be thorough.

## Task
${title}
${fingerprint_section}
${blast_warning_section}${fingerprint_section}

## Instructions
${prompt}
Expand Down
141 changes: 141 additions & 0 deletions taskrunner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,119 @@ build_fingerprint() {
echo "$output" | head -n 80
}

# ─── Blast radius preflight (optional) ──────────────────────
# Calls `charter blast --format json` on files referenced by the task
# prompt to compute the blast radius: which other files transitively
# import the seeds. Used as a preflight gate — tasks classified as
# `auto_safe` are downgraded to require operator approval when the
# blast radius is critical (default ≥50 files).
#
# Severity ladder:
# low : 0–4 affected files
# medium : 5–19 affected files
# high : 20–49 affected files (emits warning in mission brief)
# critical : 50+ affected files (blocks auto_safe execution)
#
# Environment knobs:
# CC_DISABLE_BLAST=1 — opt out entirely
# CC_BLAST_WARN=<n> — high threshold (default: 20)
# CC_BLAST_BLOCK=<n> — critical threshold (default: 50)
# CC_BLAST_TIMEOUT=<seconds> — 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() {
Expand Down Expand Up @@ -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 <<MISSION
Expand All @@ -487,6 +627,7 @@ Read files before modifying them. Be thorough.

## Task
${title}
${blast_warning_section}
${fingerprint_section}

## Instructions
Expand Down