diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index ec0f8c9..b246f35 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -351,7 +351,7 @@ function moduleLabelsOf(issue) { return (issue.labels || []).map((l) => l.name).filter((n) => typeof n === "string" && n.startsWith("module:")); } -// ---- Live worker progress section (issue #52) ----------------------------- +// ---- 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 // empty one, renders a muted "no active workers" placeholder — never a @@ -366,6 +366,63 @@ function phaseBadge(phase) { default: return { cls: "muted" }; } } + +// Normalize a task id into a group key (issue #92): "88", "issue-88", and +// "issue-70-worker-inspector" must all collapse to the SAME group, so pull +// out the first run of digits found anywhere in the id (covers a bare +// number, an "issue-N" prefix, or an "N" variant like "52b" from a +// second lens/attempt on the same task). Task ids with no digits at all +// group under their own raw id, per the instructions' degrade path. +function taskGroupKey(taskRaw) { + const s = taskRaw == null ? "" : String(taskRaw); + const m = s.match(/\d+/); + if (m) { + const num = parseInt(m[0], 10); + return { key: String(num), num }; + } + return { key: s || "(unknown)", num: null }; +} + +function issueByNumber(n) { + return issues.find((i) => i.number === n) || null; +} + +// Link a task group's header to the real GitHub issue when we have it (the +// issues array is the same open-issues fetch renderIssues() uses); degrade +// to plain "#N" text (no href) when the issue isn't in that list, mirroring +// refLink()'s own degrade contract above. +function taskIssueLink(n) { + const issue = issueByNumber(n); + return issue ? `#${n}` : `#${n}`; +} + +// Associate a PR with an issue number by the repo's branch-name convention +// (feat/issue--..., fix/issue--..., or a bare issue--... branch), +// falling back to a "#N" mention in the PR title. Reuses the SAME `prs` +// array renderPRs() already fetched (issue #92 asks us not to add a second +// gh call) — that fetch is `--state open` only, so every match found here IS +// currently open; `pr.state` is checked defensively in prBadge() below in +// case a future schema change ever adds it (e.g. a merged-PR fetch), without +// requiring one now. +function findPRForIssue(n) { + if (n == null || !Number.isFinite(n)) return null; + for (const pr of prs) { + const branch = String(pr.headRefName || ""); + const m = branch.match(/issue-(\d+)(?:[-_]|$)/i); + if (m && parseInt(m[1], 10) === n) return pr; + } + const titleRe = new RegExp("#" + n + "\\b"); + for (const pr of prs) { + if (titleRe.test(String(pr.title || ""))) return pr; + } + return null; +} +function prBadge(pr) { + const state = pr.state === "MERGED" ? "merged" : "open"; + const cls = state === "merged" ? "good" : "warn"; + return `#${pr.number} ${esc(state)}`; +} + function renderLiveProgress() { const latest = new Map(); // "role\u0000task" -> event for (const ev of events) { @@ -379,16 +436,65 @@ function renderLiveProgress() { if (workers.length === 0) { html += `

no active workers

`; } else { - html += ``; + // Group workers by normalized task key (issue #92) so every worker + // (orchestrator/implementer/reviewer x lens) working the same task + // renders together under one linked header, instead of one flat row per + // (role, task) pair. + const groups = new Map(); // groupKey -> { num, workers: [] } for (const w of workers) { - const badge = phaseBadge(w.phase); - html += ``; - html += ``; - html += ``; - // Hidden trailing cell: stable data-role/data-task hook for a FUTURE - // worker inspector (issue 3b). Appended AFTER every column the - // existing tests exact-match, so it never disturbs them. - html += ``; + const g = taskGroupKey(w.task); + if (!groups.has(g.key)) groups.set(g.key, { num: g.num, workers: [] }); + groups.get(g.key).workers.push(w); + } + // "Newest task first": numeric groups sorted by issue number descending + // (higher issue number == more recently filed task); unparseable groups + // (no leading number) sort after all numeric ones, alphabetically. + const groupKeys = [...groups.keys()].sort((a, b) => { + const ga = groups.get(a), gb = groups.get(b); + if (ga.num != null && gb.num != null) return gb.num - ga.num; + if (ga.num != null) return -1; + if (gb.num != null) return 1; + return a.localeCompare(b); + }); + + // Column headers carry data-sort-key hooks for the client-side sort + // script appended near the end of . The hidden trailing " header rows themselves never +// move) so the grouping stays coherent no matter which column is sorted. +// Pure client-side (compares each row's own `, idx88 + 1); + const segment = nextGroupIdx === -1 ? section.slice(idx88) : section.slice(idx88, nextGroupIdx); + if (!segment.includes("")) throw new Error("implementer/88 row not nested under the #88 group header"); + if (!segment.includes("")) throw new Error("reviewer/issue-88-x row not nested under the SAME #88 group header (normalization failed)"); +' "$html_groups" + +# (5) Self-contained + client-side sort hooks: the live-progress
RoleTaskModelPhaseLensUpdated
${esc(w.role)}${esc(w.task)}${esc(w.model || "(none)")}${esc(w.phase || "(unknown)")}${esc(w.lens || "")}${esc(w.ts)}
+ // mirrors the hidden per-row wrow-meta cell below and is left exactly as + // it was (no sort hook — it has no visible text to sort by). + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + for (const key of groupKeys) { + const g = groups.get(key); + let header = g.num != null ? `Task ${taskIssueLink(g.num)}` : `Task ${esc(key)}`; + if (g.num != null) { + const issue = issueByNumber(g.num); + if (issue && issue.title) header += ` ${esc(issue.title)}`; + const pr = findPRForIssue(g.num); + if (pr) header += ` · PR ${prBadge(pr)}`; + } + // Group-header row: a full-width instead of a bare . + html += ``; + const rows = g.workers.slice().sort((a, b) => { + const ar = String(a.role || ""), br = String(b.role || ""); + if (ar !== br) return ar.localeCompare(br); + return String(a.task || "").localeCompare(String(b.task || "")); + }); + for (const w of rows) { + const badge = phaseBadge(w.phase); + html += ``; + html += ``; + html += ``; + // Hidden trailing cell: stable data-role/data-task hook for the + // worker inspector (issue #70). Appended AFTER every column the + // existing tests exact-match, so it never disturbs them. + html += ``; + } } html += `
RoleTaskModelPhaseLensUpdated
so it never collides + // with the "
" pattern a plain worker row starts with (tests + // and the client sort script both rely on being able to tell the two + // apart) — it uses
${header}
${esc(w.role)}${esc(w.task)}${esc(w.model || "(none)")}${esc(w.phase || "(unknown)")}${esc(w.lens || "")}${esc(w.ts)}
`; } @@ -640,6 +746,11 @@ const html = ` .unavailable { color: var(--bad-fg); font-style: italic; } table.routing { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1rem; } table.routing th, table.routing td { border: 1px solid var(--border); padding: 0.3rem 0.6rem; text-align: left; font-size: 0.9rem; } + table.routing tr.task-group td { background: var(--muted-bg); } + table.routing thead th[data-sort-key] { cursor: pointer; user-select: none; } + table.routing thead th[data-sort-key]:hover { color: var(--link); } + table.routing thead th[data-sort-dir="asc"]::after { content: " \\25B2"; } + table.routing thead th[data-sort-dir="desc"]::after { content: " \\25BC"; } code { background: var(--code-bg); padding: 0.05rem 0.3rem; border-radius: 3px; } a { color: var(--link); } @@ -699,6 +810,70 @@ ${renderWorktrees()} }); } catch (e) { /* inert if the DOM is unavailable */ } })(); +// Sortable live-progress columns (issue #92): clicking a Role/Task/Model/ +// Phase/Lens/Updated header toggles asc/desc, sorting rows WITHIN each +// task-group (the "
textContent, never touches +// the server-rendered hidden wrow-meta cell), inert if #live's table is +// absent (e.g. the "no active workers" placeholder, or the gh-unavailable +// degrade path) -- same try/catch-guarded IIFE convention as the two +// scripts above, so it is harmless under file:// or any other odd +// environment, and works identically whether this HTML came from a static +// cockpit.sh run or cockpit-serve.sh (which injects its OWN separate SSE +// script after this one, never replacing it). +(function () { + try { + var table = document.querySelector("#live table.routing"); + if (!table) return; + var thead = table.querySelector("thead"); + var tbody = table.querySelector("tbody"); + if (!thead || !tbody) return; + var headers = Array.prototype.slice.call(thead.querySelectorAll("th[data-sort-key]")); + if (!headers.length) return; + + function groupRows() { + var groups = []; + var current = null; + Array.prototype.forEach.call(tbody.children, function (tr) { + if (tr.classList && tr.classList.contains("task-group")) { + current = { header: tr, members: [] }; + groups.push(current); + } else if (current) { + current.members.push(tr); + } else { + groups.push({ header: null, members: [tr] }); + } + }); + return groups; + } + + headers.forEach(function (th, colIndex) { + th.addEventListener("click", function () { + var dir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc"; + headers.forEach(function (t) { t.removeAttribute("data-sort-dir"); }); + th.setAttribute("data-sort-dir", dir); + + var groups = groupRows(); + groups.forEach(function (g) { + g.members.sort(function (a, b) { + var av = (a.children[colIndex] && a.children[colIndex].textContent || "").trim(); + var bv = (b.children[colIndex] && b.children[colIndex].textContent || "").trim(); + var cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: "base" }); + return dir === "asc" ? cmp : -cmp; + }); + }); + + var frag = document.createDocumentFragment(); + groups.forEach(function (g) { + if (g.header) frag.appendChild(g.header); + g.members.forEach(function (tr) { frag.appendChild(tr); }); + }); + tbody.appendChild(frag); + }); + }); + } catch (e) { /* inert if the DOM/table is unavailable */ } +})(); diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh index d39a981..b8d9c87 100755 --- a/.claude/scripts/cockpit.test.sh +++ b/.claude/scripts/cockpit.test.sh @@ -706,6 +706,121 @@ server_pid2="" IG worktree remove --force "$insp_root/.claude/worktrees/issue-70a" >/dev/null 2>&1 || true IG worktree remove --force "$insp_wt_70b" >/dev/null 2>&1 || true +# --------------------------------------------------------------------------- +# 7. Live-progress task GROUPING (issue #92): workers grouped by normalized +# task id (taskGroupKey), newest-issue-first ordering, an issue-linked +# group header (taskIssueLink), and a PR badge on the group header +# (findPRForIssue + prBadge). Uses an ISOLATED fixtures dir (does not +# reuse/mutate the shared "$work/fixtures" from section 2, so none of that +# section's exact-row-count/order assertions are perturbed by these extra +# task ids). +# +# Fixture shape: four distinct raw task ids across four groups — +# "91" -> group #91 (issue #91 is a KNOWN fixture issue, so its +# header must render a real link; also has an +# OPEN PR via headRefName feat/issue-91-groups) +# "88" -> group #88 (unknown issue -> plain "#88", no link) +# "issue-88-x" -> SAME group #88 as above (taskGroupKey normalization: +# first \d+ run == 88) -- a second worker, different +# raw task id, must nest under the SAME single header +# "70" -> group #70 (unknown issue -> plain "#70"; has a MERGED +# PR via headRefName feat/issue-70-merged-thing, to +# cover the merged-state pill since prBadge's state +# check is driven entirely by the fixture's pr.state) +# "50" -> group #50 (lowest number, no issue/PR match) +# Expected group order (numeric groups, descending by issue number): +# 91, 88, 70, 50. +# --------------------------------------------------------------------------- +mkdir -p "$work/fixtures-groups" +cat > "$work/fixtures-groups/issues.json" <<'EOF' +[ + {"number":91,"title":"Live progress task groups","url":"https://example.com/91","labels":[],"body":""} +] +EOF +cat > "$work/fixtures-groups/prs.json" <<'EOF' +[ + {"number":300,"title":"PR for 91","url":"https://example.com/pr/300","headRefName":"feat/issue-91-groups","state":"OPEN"}, + {"number":301,"title":"PR for 70 (merged)","url":"https://example.com/pr/301","headRefName":"feat/issue-70-merged-thing","state":"MERGED"} +] +EOF +cat > "$work/fixtures-groups/events.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","role":"implementer","model":"sonnet","task":"91","phase":"implementing","lens":"","detail":""} +{"ts":"2026-01-01T00:01:00Z","role":"implementer","model":"sonnet","task":"88","phase":"implementing","lens":"","detail":""} +{"ts":"2026-01-01T00:02:00Z","role":"reviewer","model":"opus","task":"issue-88-x","phase":"reviewing","lens":"tests","detail":""} +{"ts":"2026-01-01T00:03:00Z","role":"implementer","model":"sonnet","task":"50","phase":"implementing","lens":"","detail":""} +{"ts":"2026-01-01T00:04:00Z","role":"implementer","model":"sonnet","task":"70","phase":"implementing","lens":"","detail":""} +EOF + +html_groups="$work/cockpit-groups.html" +bash "$cockpit" --fixtures "$work/fixtures-groups" "$html_groups" >/dev/null 2>"$work/stderr-groups.log" +rc_groups=$? +check "task-grouping fixture run exits 0" [ "$rc_groups" -eq 0 ] + +# (1) Multi-group + ordering: exactly 4 numeric groups, newest issue first. +check "live progress renders one task-group header per distinct group, newest issue number first (91, 88, 70, 50)" node -e ' + const fs = require("fs"); + const html = fs.readFileSync(process.argv[1], "utf8"); + const m = html.match(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("live section not found"); + const section = m[0]; + const headers = [...section.matchAll(/
Task ([\s\S]*?)<\/strong><\/td><\/tr>/g)].map((r) => r[1]); + if (headers.length !== 4) throw new Error("expected 4 task-group headers, got " + headers.length + ": " + JSON.stringify(headers)); + const nums = headers.map((h) => { + const mm = h.match(/#(\d+)/); + if (!mm) throw new Error("could not find an issue number in header: " + h); + return parseInt(mm[1], 10); + }); + const want = [91, 88, 70, 50]; + if (JSON.stringify(nums) !== JSON.stringify(want)) { + throw new Error("expected group order " + JSON.stringify(want) + " (newest issue first), got " + JSON.stringify(nums)); + } +' "$html_groups" + +# (2) taskIssueLink: task #91 is a known fixture issue -> its group header +# must render a real link, not plain "#91" text. +check "task-group header links to the known fixture issue via taskIssueLink" grep -qF '#91' "$html_groups" + +# (3) findPRForIssue + prBadge: task #91's OPEN PR (matched via headRefName +# feat/issue-91-groups) renders an open-state pill on the group header; task +# #70's MERGED PR (feat/issue-70-merged-thing) renders a merged-state pill -- +# both driven purely through the fixture's pr.state, since findPRForIssue +# only fetches --state open PRs live but prBadge's state check is generic. +check "task-group header renders an OPEN PR badge (findPRForIssue + prBadge)" grep -qF '· PR #300 open' "$html_groups" +check "task-group header renders a MERGED PR badge (findPRForIssue + prBadge)" grep -qF '· PR #301 merged' "$html_groups" + +# (4) taskGroupKey normalization: raw task ids "88" and "issue-88-x" both +# normalize to group key "88" -> exactly ONE "Task #88" header (already +# proven by the 4-header count above), with BOTH workers nested under that +# single header (not scattered into their own groups). +check "taskGroupKey normalizes \"88\" and \"issue-88-x\" into the SAME single group, both workers nested under it" node -e ' + const fs = require("fs"); + const html = fs.readFileSync(process.argv[1], "utf8"); + const m = html.match(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("live section not found"); + const section = m[0]; + const idx88 = section.indexOf("Task #88"); + if (idx88 === -1) throw new Error("could not find the #88 group header"); + const nextGroupIdx = section.indexOf(String.raw`
implementer88reviewerissue-88-xs carry +# data-sort-key attributes, and the whole document stays self-contained HTML +# (no external