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
57 changes: 55 additions & 2 deletions .claude/scripts/arm-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# then recreate).
#
# Usage:
# bash .claude/scripts/arm-loop.sh [--gates-file <path>] [--permission-mode <mode>] [--capacity N] [--rc-name <name>] [--spawn <mode>]
# bash .claude/scripts/arm-loop.sh [--gates-file <path>] [--permission-mode <mode>] [--capacity N] [--rc-name <name>] [--spawn <mode>] [--stop-after-days N]
#
# --gates-file <path> passed to pr-loop.service as GATES_FILE (e.g.
# .claude/self/gates.json for the self-hosted
Expand All @@ -30,13 +30,24 @@
# --spawn <mode> remote-control spawn mode: same-dir (default) or
# worktree. Passed explicitly so the server never
# blocks on its interactive first-run question.
# --stop-after-days N self-disarm horizon (issue #95): loop-tick.sh
# refuses every advance/feedback dispatch once
# armed_at + N days has passed, until re-armed.
# Defaults to budget.stop_after_days in the
# adapter picked by --gates-file (or the default
# .claude/gates.json when --gates-file is
# omitted), else 7. Every re-arm rewrites
# .claude/state/loop-arming.json fresh --
# clearing any prior expiry AND the one-time
# "disarmed" notification guard.
set -euo pipefail

gates_file=""
permission_mode=""
capacity="8"
rc_name=""
spawn_mode="same-dir"
stop_after_days=""
while [ "$#" -gt 0 ]; do
case "$1" in
--gates-file) gates_file="${2:?--gates-file needs a value}"; shift 2 ;;
Expand All @@ -49,8 +60,10 @@ while [ "$#" -gt 0 ]; do
--spawn) spawn_mode="${2:?--spawn needs a value}"; shift 2 ;;
--spawn=*) spawn_mode="${1#--spawn=}"; shift ;;
--capacity=*) capacity="${1#--capacity=}"; shift ;;
--stop-after-days) stop_after_days="${2:?--stop-after-days needs a value}"; shift 2 ;;
--stop-after-days=*) stop_after_days="${1#--stop-after-days=}"; shift ;;
-h|--help)
sed -n '2,32p' "$0"
sed -n '2,42p' "$0"
exit 0
;;
*) echo "arm-loop.sh: unknown argument '$1'" >&2; exit 2 ;;
Expand Down Expand Up @@ -96,6 +109,46 @@ if [ -n "$gates_file" ]; then
gates_env="Environment=GATES_FILE=$gates_file"
fi

# --- spend-ceiling arming state (issue #95) ---------------------------------
# Resolve the stop-after horizon: --stop-after-days wins; else
# budget.stop_after_days from the SAME adapter the armed daemon will read
# (gates_file, defaulting to .claude/gates.json); else 7. Always WRITE a
# fresh .claude/state/loop-arming.json on every arm/re-arm -- this is what
# clears a prior expiry and the one-time "disarmed" notification guard.
if [ -z "$stop_after_days" ]; then
adapter_for_stop_after="${gates_file:-.claude/gates.json}"
case "$adapter_for_stop_after" in
/*) ;;
*) adapter_for_stop_after="$repo_root/$adapter_for_stop_after" ;;
esac
stop_after_days="$(node -e '
try {
const g = require(process.argv[1]);
const d = g && g.budget && g.budget.stop_after_days;
if (Number.isFinite(d) && d > 0) { console.log(d); process.exit(0); }
} catch (e) {}
' "$adapter_for_stop_after" 2>/dev/null || true)"
stop_after_days="${stop_after_days:-7}"
fi
case "$stop_after_days" in
''|*[!0-9.]*) echo "arm-loop.sh: --stop-after-days must be a positive number (got '$stop_after_days')" >&2; exit 2 ;;
esac

arming_state_dir="$repo_root/.claude/state"
mkdir -p "$arming_state_dir"
arm_now="$(date -u +%FT%TZ)"
node -e '
const fs = require("fs");
const now = process.argv[2];
const days = parseFloat(process.argv[3]);
const expires = new Date(Date.parse(now) + days * 86400000).toISOString();
fs.writeFileSync(process.argv[1], JSON.stringify({
armed_at: now, expires_at: expires, stop_after_days: days,
notified_expired: false, notice_issue: null,
}, null, 2) + "\n");
' "$arming_state_dir/loop-arming.json" "$arm_now" "$stop_after_days"
echo "arm-loop.sh: armed until $(node -e 'const j=require(process.argv[1]);console.log(j.expires_at)' "$arming_state_dir/loop-arming.json") (stop_after_days=$stop_after_days) -- .claude/state/loop-arming.json"

# Absolute claude path, resolved HERE — this script runs in a real terminal
# with the user's full environment, while the installed unit runs under
# systemd's minimal PATH (gh but no nvm-provisioned node/claude). A bare
Expand Down
87 changes: 87 additions & 0 deletions .claude/scripts/cockpit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,26 @@ else
fi
if [ -f "$ticks_file" ]; then cp "$ticks_file" "$tmpdir/loop-ticks.jsonl"; else : >"$tmpdir/loop-ticks.jsonl"; fi

# ---- spend-ceiling state (issue #95, "Loop health" panel additions) --------
# Same offline seam as the events/ticks blocks above: fixtures mode reads
# <dir>/loop-arming.json, <dir>/loop-issue-attempts.json,
# <dir>/loop-daily-ceiling.json (if present); otherwise the real, gitignored
# .claude/state/ files loop-tick.sh itself reads/writes. A missing file just
# means that ceiling has never tripped/been armed yet -- rendered as a
# placeholder below, never an error.
if [ -n "$fixtures" ]; then
arming_file="$fixtures/loop-arming.json"
attempts_file="$fixtures/loop-issue-attempts.json"
daily_ceiling_file="$fixtures/loop-daily-ceiling.json"
else
arming_file="$root/.claude/state/loop-arming.json"
attempts_file="$root/.claude/state/loop-issue-attempts.json"
daily_ceiling_file="$root/.claude/state/loop-daily-ceiling.json"
fi
if [ -f "$arming_file" ]; then cp "$arming_file" "$tmpdir/loop-arming.json"; else echo "{}" >"$tmpdir/loop-arming.json"; fi
if [ -f "$attempts_file" ]; then cp "$attempts_file" "$tmpdir/loop-issue-attempts.json"; else echo "{}" >"$tmpdir/loop-issue-attempts.json"; fi
if [ -f "$daily_ceiling_file" ]; then cp "$daily_ceiling_file" "$tmpdir/loop-daily-ceiling.json"; else echo "{}" >"$tmpdir/loop-daily-ceiling.json"; fi

# ---- active worktrees -----------------------------------------------------------
node -e '
const fs = require("fs");
Expand Down Expand Up @@ -314,6 +334,13 @@ function readTicks() {
}
const ticks = readTicks();

// Spend-ceiling state (issue #95): three small JSON objects gathered by the
// bash prelude above. Each defaults to {} (never null/undefined) so the
// render code below can dot into them without a guard on every access.
const arming = readJson("loop-arming.json", {});
const issueAttempts = readJson("loop-issue-attempts.json", {});
const dailyCeiling = readJson("loop-daily-ceiling.json", {});

function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
Expand Down Expand Up @@ -553,10 +580,70 @@ function renderLoopHealth() {
}
html += `</tbody></table>`;

html += renderSpendCeilings();

html += `</section>`;
return html;
}

// ---- Spend ceilings sub-section (issue #95) --------------------------------
// Best-effort surfacing of the three loop spend ceilings inside the SAME
// "Loop health" section: stop-after expiry/countdown, today's dispatch count
// vs the daily ceiling, and per-issue attempt counts vs the per-issue budget.
// Reads adapter.budget for the configured thresholds (falling back to the
// same defaults loop-tick.sh itself uses when a key is absent), so this
// panel and the enforcement it describes never drift out of sync.
function renderSpendCeilings() {
const b = adapter.budget || {};
const perIssueAttempts = Number.isFinite(b.per_issue_attempts) ? b.per_issue_attempts : 5;
const dailyCeilingCfg = Number.isFinite(b.daily_action_ceiling) ? b.daily_action_ceiling : 50;

let html = `<h3>Spend ceilings</h3>`;

// --- stop-after expiry/countdown ---
if (arming && arming.expires_at) {
const expMs = Date.parse(arming.expires_at);
let line = `Stop-after: <code>${esc(arming.expires_at)}</code>`;
if (Number.isFinite(expMs)) {
const diffMs = expMs - nowMs;
if (diffMs > 0) {
const days = Math.floor(diffMs / 86400000);
const hours = Math.floor((diffMs % 86400000) / 3600000);
line += ` <span class="muted">(in ${days}d ${hours}h)</span>`;
} else {
line += ` <span class="badge unavailable">DISARMED — re-arm to resume</span>`;
}
}
html += `<p>${line}</p>`;
} else {
html += `<p class="muted">Stop-after: not armed yet</p>`;
}

// --- today's dispatch count vs the daily ceiling ---
const today = new Date(nowMs).toISOString().slice(0, 10);
const todaysCount = dailyCeiling && dailyCeiling.date === today ? (dailyCeiling.count || 0) : 0;
const overDaily = todaysCount >= dailyCeilingCfg;
html += `<p>Today's actions: <span class="badge ${overDaily ? "unavailable" : "muted"}">${todaysCount} / ${dailyCeilingCfg}</span></p>`;

// --- per-issue attempt counts vs the per-issue budget ---
const attemptEntries = Object.entries(issueAttempts || {}).filter(([, v]) => v && (v.attempts || 0) > 0);
if (attemptEntries.length === 0) {
html += `<p class="muted">Per-issue attempts: none tracked yet</p>`;
} else {
attemptEntries.sort((a, b2) => (b2[1].attempts || 0) - (a[1].attempts || 0));
html += `<table class="routing"><thead><tr><th>Issue</th><th>Attempts</th><th>Status</th></tr></thead><tbody>`;
for (const [k, v] of attemptEntries) {
const n = parseInt(k, 10);
const over = (v.attempts || 0) >= perIssueAttempts;
const status = over ? `<span class="badge unavailable">needs-human</span>` : "";
html += `<tr><td>${Number.isFinite(n) ? refLink(n) : esc(k)}</td><td>${esc(v.attempts)}/${perIssueAttempts}</td><td>${status}</td></tr>`;
}
html += `</tbody></table>`;
}

return html;
}

// ---- Issues section: group by module label, parse blocking graph per issue ----
function renderIssues() {
if (issuesUnavailable) {
Expand Down
59 changes: 59 additions & 0 deletions .claude/scripts/cockpit.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,65 @@ check "verdict-history default cap (10) does not truncate a shorter (5-tick) his
}
' "$html_history_default"

# ---------------------------------------------------------------------------
# 2d. Spend ceilings sub-panel (issue #95): stop-after countdown, today's
# action count vs the daily ceiling, and per-issue attempts vs the
# per-issue budget, sourced from loop-arming.json/loop-issue-attempts.json/
# loop-daily-ceiling.json. Missing files (exercised separately in section
# 4 below) must degrade to placeholders, never a crash.
# ---------------------------------------------------------------------------
mkdir -p "$work/fixtures-ceilings"
cat > "$work/fixtures-ceilings/issues.json" <<'EOF'
[{"number":100,"title":"Known issue","url":"https://example.com/100","labels":[],"body":""}]
EOF
echo "[]" >"$work/fixtures-ceilings/prs.json"
: >"$work/fixtures-ceilings/events.jsonl"
cat > "$work/fixtures-ceilings/loop-ticks.jsonl" <<'EOF'
{"ts":"2026-01-01T00:00:00Z","verdict":"action=none","cadence":"IDLE","action":"none","issue":"","pr":"","reason":"daily-ceiling"}
EOF
cat > "$work/fixtures-ceilings/loop-arming.json" <<'EOF'
{"armed_at":"2026-01-01T00:00:00Z","expires_at":"2026-01-05T00:00:00Z","stop_after_days":4,"notified_expired":false,"notice_issue":null}
EOF
cat > "$work/fixtures-ceilings/loop-issue-attempts.json" <<'EOF'
{"100":{"attempts":3,"escalated":false},"42":{"attempts":5,"escalated":true}}
EOF
cat > "$work/fixtures-ceilings/loop-daily-ceiling.json" <<'EOF'
{"date":"2026-01-01","count":37,"halted":false,"issue_number":null}
EOF
html_ceilings="$work/cockpit-ceilings.html"
COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings" "$html_ceilings" >/dev/null 2>"$work/stderr-ceilings.log"
check "spend ceilings sub-heading present" grep -qF '<h3>Spend ceilings</h3>' "$html_ceilings"
check "stop-after expiry + countdown rendered" grep -qF '<code>2026-01-05T00:00:00Z</code> <span class="muted">(in 3d 23h)</span>' "$html_ceilings"
check "today's action count vs the daily ceiling (adapter default 50) rendered" grep -qF '<span class="badge muted">37 / 50</span>' "$html_ceilings"
check "per-issue attempts table: over-budget issue 42 shows needs-human status" grep -qF '<tr><td>#42</td><td>5/5</td><td><span class="badge unavailable">needs-human</span></td></tr>' "$html_ceilings"
check "per-issue attempts table: known issue 100 links to its section anchor" grep -qF '<tr><td><a href="#issue-100">#100</a></td><td>3/5</td><td></td></tr>' "$html_ceilings"

# Expired stop-after (past expires_at) renders the DISARMED badge instead of a countdown.
cat > "$work/fixtures-ceilings/loop-arming.json" <<'EOF'
{"armed_at":"2025-12-01T00:00:00Z","expires_at":"2025-12-08T00:00:00Z","stop_after_days":7,"notified_expired":true,"notice_issue":123}
EOF
html_ceilings_expired="$work/cockpit-ceilings-expired.html"
COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings" "$html_ceilings_expired" >/dev/null 2>"$work/stderr-ceilings-expired.log"
check "expired stop-after renders the DISARMED badge, not a countdown" grep -qF 'DISARMED' "$html_ceilings_expired"

# The loop HAS ticked (so the section renders past the early "loop not armed"
# return) but none of the three spend-ceiling files exist yet -- read-only
# cockpit.sh never lazily creates them the way loop-tick.sh itself does; it
# must just degrade to placeholders, never crash.
mkdir -p "$work/fixtures-ceilings-missing"
cat > "$work/fixtures-ceilings-missing/issues.json" <<'EOF'
[]
EOF
echo "[]" >"$work/fixtures-ceilings-missing/prs.json"
: >"$work/fixtures-ceilings-missing/events.jsonl"
cp "$work/fixtures-ceilings/loop-ticks.jsonl" "$work/fixtures-ceilings-missing/loop-ticks.jsonl"
html_ceilings_missing="$work/cockpit-ceilings-missing.html"
COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings-missing" "$html_ceilings_missing" >/dev/null 2>"$work/stderr-ceilings-missing.log"
check "no spend-ceiling state files: generator still exits 0 (no crash)" [ -s "$html_ceilings_missing" ]
check "no spend-ceiling state files: stop-after degrades to 'not armed yet'" grep -qF 'Stop-after: not armed yet' "$html_ceilings_missing"
check "no spend-ceiling state files: per-issue attempts degrades to 'none tracked yet'" grep -qF 'Per-issue attempts: none tracked yet' "$html_ceilings_missing"
check "no spend-ceiling state files: today's actions default to 0 / adapter ceiling" grep -qF '<span class="badge muted">0 / 50</span>' "$html_ceilings_missing"

# ---------------------------------------------------------------------------
# 3. GATES_FILE override is honored (self-host adapter), still with fixtures
# (no gh/network either way).
Expand Down
Loading
Loading