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
5 changes: 4 additions & 1 deletion .claude/gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,8 @@
"policy": "pr-per-agent",
"_policy_options": "pr-per-agent | orchestrated-sequential-merge",
"baseBranch": "main"
}
},

"_notify_note": "issue #99 — needs-human push-notification seam, read by .claude/scripts/notify.sh (GATES_FILE-aware; same empty-means-skip convention as `gates`). Empty string = disabled (offline/CI-safe no-op) — the default; fill in a command to enable push notifications when the autonomous loop blocks on YOU (PR ready for review, CHANGES_REQUESTED addressed and awaiting re-review, attempt-budget/stall escalation, ...). Contract: severity/title/body-line reach the command BOTH as positional args ($1/$2/$3) and as NOTIFY_SEVERITY/NOTIFY_TITLE/NOTIFY_BODY env vars; throttled to one notification per (kind,target) per window (default 1800s — see notify.sh). Example commands (uncomment/adapt ONE):\n ntfy: curl -s -d \"$NOTIFY_BODY\" -H \"Title: $NOTIFY_TITLE\" -H \"Priority: $NOTIFY_SEVERITY\" ntfy.sh/<your-topic>\n notify-send: notify-send \"$NOTIFY_TITLE\" \"$NOTIFY_BODY\"\n webhook: curl -s -X POST -H 'Content-Type: application/json' -d \"{\\\"severity\\\":\\\"$NOTIFY_SEVERITY\\\",\\\"title\\\":\\\"$NOTIFY_TITLE\\\",\\\"body\\\":\\\"$NOTIFY_BODY\\\"}\" https://example.invalid/hook",
"notify": ""
}
99 changes: 95 additions & 4 deletions .claude/scripts/cockpit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@
# appended near the end of <body>; cockpit-serve.sh (serve mode) injects
# ITS OWN separate SSE/refresh client script by string-replacing </body>,
# so the live-stream code never ships in this static output.
#
# Issue #99 adds a "Needs you" strip at the VERY TOP of <body> (before every
# other section): anything labeled `needs-human` (issues OR PRs — see
# needs-human.sh) or awaiting your first/re-review (a PR with passing CI that
# the owner hasn't approved/rejected yet), grouped by reason, with links. An
# all-clear message renders when nothing qualifies. Sourced from the SAME
# issues/prs arrays every other section already fetches (no extra gh call) —
# the live PR fetch now also asks for `labels` alongside the fields it always
# fetched, so a needs-human-labeled PR is visible without a second round trip.
set -uo pipefail

# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project.
Expand Down Expand Up @@ -169,7 +178,7 @@ prs_unavailable=0
if [ -n "$fixtures" ]; then
if [ -f "$fixtures/prs.json" ]; then cp "$fixtures/prs.json" "$tmpdir/prs.json"; else echo "[]" >"$tmpdir/prs.json"; fi
else
if ! gh pr list --state open --limit 200 --json number,title,url,headRefName,reviewDecision,statusCheckRollup >"$tmpdir/prs.json" 2>"$tmpdir/prs.err"; then
if ! gh pr list --state open --limit 200 --json number,title,url,headRefName,reviewDecision,statusCheckRollup,labels >"$tmpdir/prs.json" 2>"$tmpdir/prs.err"; then
prs_unavailable=1
fi
if [ "$prs_unavailable" -eq 0 ] && ! valid_json "$tmpdir/prs.json"; then prs_unavailable=1; fi
Expand Down Expand Up @@ -385,6 +394,14 @@ function moduleLabelsOf(issue) {
return (issue.labels || []).map((l) => l.name).filter((n) => typeof n === "string" && n.startsWith("module:"));
}

// hasLabel: works for both the issues.json and prs.json label shapes (an
// array of {name} objects — the same `gh ... --json ...,labels` shape both
// fetches above already use), so the SAME helper serves renderNeedsYou()
// below for either an issue or a PR object.
function hasLabel(obj, name) {
return (obj.labels || []).some((l) => l && l.name === name);
}

// ---- Live worker progress section (issue #52, grouped by task in #92) -----
// Derive the CURRENT state per worker keyed by (role, task): keep the LATEST
// event (by file order, i.e. append order) per key. No event log, or an
Expand Down Expand Up @@ -819,6 +836,77 @@ function renderRouting() {
return html;
}

// ---- "Needs you" strip (issue #99) -----------------------------------------
// The FIRST thing the dashboard answers: does anything need the owner right
// now? Two reasons, each its own group (grouped/annotated per the issue's
// acceptance criteria), sourced from the SAME issues/prs arrays every other
// section already fetched (no extra gh call):
// - "needs-human": an issue OR PR carrying the `needs-human` label (see
// needs-human.sh — the loop's escalation/PR-review/re-review points all
// apply this label through the one shared helper).
// - "awaiting your review": a PR with passing CI, OR with NO CI checks at
// all (ciBadge's "no checks" — a PR the adapter has no checks configured
// for is not stuck on a red/pending build; it's simply waiting on the
// owner, same as a "passing" one), whose review decision is neither
// APPROVED nor CHANGES_REQUESTED (i.e. REVIEW_REQUIRED or no review yet)
// — the owner hasn't weighed in yet. CHANGES_REQUESTED is deliberately
// excluded here: that PR is in the BOT's court (pr-feedback.sh dispatches
// a fix), not the owner's, until it's addressed (which is when it picks
// up the `needs-human` label instead — see pr-feedback.sh). A "failing"
// or "pending" PR is also excluded: that one's blocked on CI, not on you.
// Degrades to an empty group set (never a crash) when issues/prs are
// unavailable, matching every other section's degrade contract; renders a
// clear all-clear state when the total across both groups is zero.
function renderNeedsYou() {
const groups = new Map(); // reason -> item[]
const push = (reason, item) => {
if (!groups.has(reason)) groups.set(reason, []);
groups.get(reason).push(item);
};

if (!issuesUnavailable) {
for (const issue of issues) {
if (hasLabel(issue, "needs-human")) {
push("needs-human", { num: issue.number, url: issue.url, title: issue.title });
}
}
}
if (!prsUnavailable) {
for (const pr of prs) {
if (hasLabel(pr, "needs-human")) {
push("needs-human", { num: pr.number, url: pr.url, title: pr.title });
continue;
}
const ci = ciBadge(pr.statusCheckRollup);
const rd = pr.reviewDecision;
if ((ci.label === "passing" || ci.label === "no checks") && rd !== "APPROVED" && rd !== "CHANGES_REQUESTED") {
push("awaiting your review", { num: pr.number, url: pr.url, title: pr.title });
}
}
}

const total = [...groups.values()].reduce((acc, list) => acc + list.length, 0);
let html = `<section id="needs-you">`;
if (total === 0) {
html += `<h2>Needs you</h2><p class="badge good needs-you-clear">all clear — nothing needs you right now</p>`;
} else {
html += `<h2>Needs you (${total})</h2>`;
// Stable group order regardless of Map insertion order: needs-human first
// (the more urgent/explicit signal), then awaiting-review.
for (const reason of ["needs-human", "awaiting your review"]) {
const list = groups.get(reason);
if (!list || list.length === 0) continue;
html += `<h3>${esc(reason)}</h3><ul class="needs-you-list">`;
for (const it of list.sort((a, b) => a.num - b.num)) {
html += `<li><a href="${esc(it.url || "#")}">#${it.num}</a> ${esc(it.title)}</li>`;
}
html += `</ul>`;
}
}
html += `</section>`;
return html;
}

// ---- Active worktrees section ---------------------------------------------------
function renderWorktrees() {
let html = `<section id="worktrees"><h2>Active worktrees</h2>`;
Expand Down Expand Up @@ -882,8 +970,10 @@ const html = `<!doctype html>
h2 { margin-top: 0; border-bottom: 1px solid var(--border); padding-bottom: 0.4rem; }
h3 { margin-bottom: 0.3rem; color: var(--text-dim); cursor: pointer; user-select: none; }
#issues h3:hover { color: var(--link); }
ul.issue-list, ul.pr-list { list-style: none; padding-left: 0; }
ul.issue-list li, ul.pr-list li { padding: 0.4rem 0; border-bottom: 1px dashed var(--border); }
ul.issue-list, ul.pr-list, ul.needs-you-list { list-style: none; padding-left: 0; }
ul.issue-list li, ul.pr-list li, ul.needs-you-list li { padding: 0.4rem 0; border-bottom: 1px dashed var(--border); }
#needs-you { margin-bottom: 1rem; }
.needs-you-clear { font-size: 1rem; padding: 0.3rem 0.8rem; }
.rel { font-size: 0.85rem; color: var(--text-dim); margin-top: 0.2rem; }
.badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-left: 0.3rem; }
.badge.good { background: var(--good-bg); color: var(--good-fg); }
Expand All @@ -905,7 +995,8 @@ const html = `<!doctype html>
</head>
<body>
<h1>Cockpit <button id="theme-toggle" type="button">Toggle theme</button></h1>
<p class="meta">Generated ${esc(generatedAt)} &middot; read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) + Phase 3a serve/theme/filter (issue #69) + loop health panel (issue #85) &middot; re-run <code>cockpit.sh</code> to refresh (or run <code>cockpit-serve.sh</code> for live auto-update)</p>
<p class="meta">Generated ${esc(generatedAt)} &middot; read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) + Phase 3a serve/theme/filter (issue #69) + loop health panel (issue #85) + needs-you strip (issue #99) &middot; re-run <code>cockpit.sh</code> to refresh (or run <code>cockpit-serve.sh</code> for live auto-update)</p>
${renderNeedsYou()}
${renderLiveProgress()}
${renderLoopHealth()}
${renderIssues()}
Expand Down
79 changes: 79 additions & 0 deletions .claude/scripts/cockpit.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,85 @@ check "no spend-ceiling state files: stop-after degrades to 'not armed yet'" gre
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"

# ---------------------------------------------------------------------------
# 2e. "Needs you" strip (issue #99): the VERY FIRST section in <body>, sourced
# from the SAME issues.json/prs.json every other section already reads
# (no extra gh call). Two groups:
# - "needs-human": an issue OR PR carrying the `needs-human` label.
# - "awaiting your review": a PR with passing CI (OR no CI checks
# configured at all -- re-review finding, a review-ready PR with zero
# checks must not be silently omitted) and a review decision that is
# neither APPROVED nor CHANGES_REQUESTED (the latter is the BOT's
# court via pr-feedback.sh, not the owner's).
# PR 200 (APPROVED, passing) and PR 201 (CHANGES_REQUESTED, failing) must
# NOT appear in either group.
# ---------------------------------------------------------------------------
mkdir -p "$work/fixtures-needs-you"
cat > "$work/fixtures-needs-you/issues.json" <<'EOF'
[
{"number":106,"title":"Issue needing a human","url":"https://example.com/106","labels":[{"name":"needs-human"}],"body":""},
{"number":100,"title":"Ordinary issue","url":"https://example.com/100","labels":[],"body":""}
]
EOF
cat > "$work/fixtures-needs-you/prs.json" <<'EOF'
[
{"number":200,"title":"Approved PR","url":"https://example.com/pr/200","headRefName":"feat/x","reviewDecision":"APPROVED","statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED","name":"build"}],"labels":[]},
{"number":201,"title":"Changes requested PR","url":"https://example.com/pr/201","headRefName":"feat/y","reviewDecision":"CHANGES_REQUESTED","statusCheckRollup":[{"conclusion":"FAILURE","status":"COMPLETED","name":"test"}],"labels":[]},
{"number":202,"title":"Needs-human PR","url":"https://example.com/pr/202","headRefName":"feat/z","reviewDecision":null,"statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED","name":"build"}],"labels":[{"name":"needs-human"}]},
{"number":203,"title":"Awaiting review PR","url":"https://example.com/pr/203","headRefName":"feat/w","reviewDecision":"REVIEW_REQUIRED","statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED","name":"build"}],"labels":[]},
{"number":204,"title":"No-checks PR awaiting review","url":"https://example.com/pr/204","headRefName":"feat/v","reviewDecision":null,"statusCheckRollup":[],"labels":[]}
]
EOF
: >"$work/fixtures-needs-you/events.jsonl"
: >"$work/fixtures-needs-you/loop-ticks.jsonl"
html_needs_you="$work/cockpit-needs-you.html"
bash "$cockpit" --fixtures "$work/fixtures-needs-you" "$html_needs_you" >/dev/null 2>"$work/stderr-needs-you.log"
check "needs-you generator run exits 0" [ -s "$html_needs_you" ]
check "needs-you section is the FIRST section in <body> (before live/issues/prs)" node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const bodyIdx = html.indexOf("<body>");
const needsYouIdx = html.indexOf("<section id=\"needs-you\"");
const liveIdx = html.indexOf("<section id=\"live\"");
const issuesIdx = html.indexOf("<section id=\"issues\"");
if (bodyIdx < 0 || needsYouIdx < 0 || liveIdx < 0 || issuesIdx < 0) throw new Error("missing section(s)");
if (!(bodyIdx < needsYouIdx && needsYouIdx < liveIdx && needsYouIdx < issuesIdx)) {
throw new Error("needs-you is not the first section after <body>");
}
' "$html_needs_you"
check "needs-you total count is 4 (issue 106 + PR 202 + PR 203 + PR 204)" grep -qF '<h2>Needs you (4)</h2>' "$html_needs_you"
needs_you_section="$(node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const m = html.match(/<section id="needs-you">[\s\S]*?<\/section>/);
if (!m) throw new Error("needs-you section not found");
process.stdout.write(m[0]);
' "$html_needs_you")"
check "needs-human group heading present" bash -c 'printf "%s" "$1" | grep -qF "<h3>needs-human</h3>"' _ "$needs_you_section"
check "awaiting-your-review group heading present" bash -c 'printf "%s" "$1" | grep -qF "<h3>awaiting your review</h3>"' _ "$needs_you_section"
check "needs-human group lists issue #106" bash -c 'printf "%s" "$1" | grep -qF "<a href=\"https://example.com/106\">#106</a> Issue needing a human"' _ "$needs_you_section"
check "needs-human group lists PR #202 (not the awaiting-review group)" bash -c 'printf "%s" "$1" | grep -qF "<a href=\"https://example.com/pr/202\">#202</a> Needs-human PR"' _ "$needs_you_section"
check "awaiting-your-review group lists PR #203" bash -c 'printf "%s" "$1" | grep -qF "<a href=\"https://example.com/pr/203\">#203</a> Awaiting review PR"' _ "$needs_you_section"
check "awaiting-your-review group ALSO lists PR #204 (no CI checks configured, must not be silently omitted)" bash -c 'printf "%s" "$1" | grep -qF "<a href=\"https://example.com/pr/204\">#204</a> No-checks PR awaiting review"' _ "$needs_you_section"
check "approved PR #200 does NOT appear in the needs-you strip" bash -c '! printf "%s" "$1" | grep -qF "#200"' _ "$needs_you_section"
check "changes-requested PR #201 does NOT appear in the needs-you strip (bot's court, not owner's)" bash -c '! printf "%s" "$1" | grep -qF "#201"' _ "$needs_you_section"
check "ordinary issue #100 does NOT appear in the needs-you strip" bash -c '! printf "%s" "$1" | grep -qF "#100"' _ "$needs_you_section"

# All-clear state: nothing labeled needs-human, nothing awaiting review.
mkdir -p "$work/fixtures-needs-you-clear"
echo "[]" >"$work/fixtures-needs-you-clear/issues.json"
cat > "$work/fixtures-needs-you-clear/prs.json" <<'EOF'
[{"number":300,"title":"All good PR","url":"https://example.com/pr/300","headRefName":"feat/all-good","reviewDecision":"APPROVED","statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED","name":"build"}],"labels":[]}]
EOF
: >"$work/fixtures-needs-you-clear/events.jsonl"
: >"$work/fixtures-needs-you-clear/loop-ticks.jsonl"
html_needs_you_clear="$work/cockpit-needs-you-clear.html"
bash "$cockpit" --fixtures "$work/fixtures-needs-you-clear" "$html_needs_you_clear" >/dev/null 2>"$work/stderr-needs-you-clear.log"
check "all-clear generator run exits 0" [ -s "$html_needs_you_clear" ]
check "all-clear message rendered when nothing needs the owner" grep -qF 'all clear — nothing needs you right now' "$html_needs_you_clear"
check "all-clear run: no needs-human group heading" bash -c '! grep -qF "<h3>needs-human</h3>" "$1"' _ "$html_needs_you_clear"
check "all-clear run: no awaiting-your-review group heading" bash -c '! grep -qF "<h3>awaiting your review</h3>" "$1"' _ "$html_needs_you_clear"

# ---------------------------------------------------------------------------
# 3. GATES_FILE override is honored (self-host adapter), still with fixtures
# (no gh/network either way).
Expand Down
10 changes: 8 additions & 2 deletions .claude/scripts/loop-ceilings.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ new_fixture() {
rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself
cp "$loop_tick_src" "$dir/loop-tick.sh"
cp "$resolve_roots_src" "$dir/resolve-roots.sh"
# needs-human.sh/notify.sh (issue #99): loop-tick.sh sources needs-human.sh
# unconditionally when present -- copy the REAL implementations so the
# attempt-budget escalation scenarios (5, 6) exercise the real seam, with
# gh calls still landing only in this fixture's own logging bot-gh.sh.
cp "$script_dir/needs-human.sh" "$dir/needs-human.sh"
cp "$script_dir/notify.sh" "$dir/notify.sh"

cat > "$dir/loop-census.sh" <<EOF
#!/usr/bin/env bash
Expand Down Expand Up @@ -220,7 +226,7 @@ node -e '
out5="$(run_tick "$dir5")"
check "scenario 5 (attempts 5 >= budget 5): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5"
check "scenario 5: diagnostic cites the attempt budget" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out5"
check "scenario 5: exactly 3 gh calls (label create, issue edit, issue comment)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 3 ]' _ "$dir5"
check "scenario 5: exactly 4 gh calls (label-presence read, label create, issue edit, issue comment)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 4 ]' _ "$dir5"
check "scenario 5: the issue itself (not a PR) was labeled needs-human" bash -c 'gh_calls "$1" | grep -q "^issue edit 42 --add-label needs-human"' _ "$dir5"
check "scenario 5: escalated is now persisted true, attempts unchanged at 5" node -e '
const fs = require("fs");
Expand All @@ -229,7 +235,7 @@ check "scenario 5: escalated is now persisted true, attempts unchanged at 5" nod
' "$dir5/../state/loop-issue-attempts.json"
out5b="$(run_tick "$dir5")"
check "scenario 5b (still over budget, second tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5b"
check "scenario 5b: no additional gh calls (escalated guard held) -- still exactly 3" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 3 ]' _ "$dir5"
check "scenario 5b: no additional gh calls (escalated guard held) -- still exactly 4" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 4 ]' _ "$dir5"

# ---------------------------------------------------------------------------
# 6. Per-issue attempt budget applies across advance AND feedback phases of
Expand Down
9 changes: 8 additions & 1 deletion .claude/scripts/loop-census.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,14 @@ open_pr_branches=$(gh pr list -R "$repo" --state open --base "$base" --json head
# i.e. the blocking gate degrades to a no-op (same as before this feature).
open_issue_set=$(gh issue list -R "$repo" --state open --json number --jq '.[].number' --limit 200 2>/dev/null) || true

feedback_prs=$(bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true)
# PR_FEEDBACK_COUNT_ONLY=1 (issue #99 re-review finding #2): census is a
# read-only report -- it must NEVER mutate GitHub state. pr-feedback.sh's own
# per-PR loop calls needs_human_flag/needs_human_clear (label/comment/notify)
# as a side effect of its real dispatch role; count-only mode suppresses all
# of that while still printing the identical TSV this line counts. The real,
# side-effecting invocation stays in loop-tick.sh, which actually dispatches
# fixes for the PRs this counts.
feedback_prs=$(PR_FEEDBACK_COUNT_ONLY=1 bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true)
echo "feedback_prs=$feedback_prs"

# Open `planned` issues carrying any of the adapter's module labels, ascending.
Expand Down
Loading
Loading