Skip to content

Commit 028261a

Browse files
Merge pull request #104 from robercano/feat/issue-85-loop-health-panel
feat(cockpit): loop-health panel — last tick, cadence, verdict history, stall detection (#85)
2 parents 10f3e73 + da4bd98 commit 028261a

4 files changed

Lines changed: 461 additions & 28 deletions

File tree

.claude/scripts/cockpit.sh

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
# no persistent server, no watch daemon (re-run this script, or wrap it in
1010
# `watch -n 30 bash .claude/scripts/cockpit.sh`).
1111
#
12+
# Issue #85 adds a "Loop health" panel, sourced from loop-tick.sh's tick
13+
# record log (see loop-tick.sh's write_tick_record): the last tick's verdict,
14+
# the current cadence (FAST/WATCH/IDLE), the full verdict history (newest
15+
# first), and a STALLED banner if no tick has landed in over 2x the cadence's
16+
# expected interval (FAST=60s -> 120s, WATCH=300s -> 600s, IDLE=900s -> 1800s).
17+
#
1218
# Usage:
1319
# cockpit.sh [--fixtures <dir>] [output-path]
1420
# cockpit.sh --parse-blocking
@@ -22,8 +28,9 @@
2228
# like `gh issue|pr list --json ...` output) instead of calling gh at all.
2329
# This is the offline seam cockpit.test.sh uses — no live gh/network in tests.
2430
# In this mode, the live-progress panel also reads <dir>/events.jsonl (if
25-
# present; missing = "no active workers") instead of the real event log, so
26-
# tests never touch .claude/state/.
31+
# present; missing = "no active workers") instead of the real event log, and
32+
# the loop-health panel likewise reads <dir>/loop-ticks.jsonl (if present;
33+
# missing = "loop not armed"), so tests never touch .claude/state/.
2734
#
2835
# Degrades gracefully: if a bot-gh.sh call fails (no network / no gh auth),
2936
# that section renders an "unavailable (gh/network)" placeholder instead of
@@ -212,6 +219,20 @@ else
212219
fi
213220
if [ -f "$events_file" ]; then cp "$events_file" "$tmpdir/events.jsonl"; else : >"$tmpdir/events.jsonl"; fi
214221

222+
# ---- loop tick records (issue #85, "Loop health" panel) --------------------
223+
# Same offline seam as the events.jsonl block above: fixtures mode reads
224+
# <dir>/loop-ticks.jsonl (if present); otherwise honors CLAUDE_TICKS_FILE for
225+
# parity with loop-tick.sh's own override, defaulting to the same gitignored
226+
# .claude/state/loop-ticks.jsonl. A missing/empty log just means the loop has
227+
# never ticked (or isn't armed yet) — rendered as a placeholder below, never
228+
# an error.
229+
if [ -n "$fixtures" ]; then
230+
ticks_file="$fixtures/loop-ticks.jsonl"
231+
else
232+
ticks_file="${CLAUDE_TICKS_FILE:-$root/.claude/state/loop-ticks.jsonl}"
233+
fi
234+
if [ -f "$ticks_file" ]; then cp "$ticks_file" "$tmpdir/loop-ticks.jsonl"; else : >"$tmpdir/loop-ticks.jsonl"; fi
235+
215236
# ---- active worktrees -----------------------------------------------------------
216237
node -e '
217238
const fs = require("fs");
@@ -233,6 +254,8 @@ COCKPIT_OUT="$out" \
233254
COCKPIT_ISSUES_UNAVAILABLE="$issues_unavailable" \
234255
COCKPIT_PRS_UNAVAILABLE="$prs_unavailable" \
235256
COCKPIT_GATES_REF="$gates_ref" \
257+
COCKPIT_NOW="${COCKPIT_NOW:-}" \
258+
COCKPIT_VERDICT_HISTORY_N="${COCKPIT_VERDICT_HISTORY_N:-10}" \
236259
node - <<'NODE_RENDER'
237260
const fs = require("fs");
238261
const path = require("path");
@@ -271,6 +294,26 @@ function readEvents() {
271294
}
272295
const events = readEvents();
273296
297+
// Loop tick records (issue #85): JSONL, one object per line, appended by
298+
// loop-tick.sh's write_tick_record — schema {ts, verdict, cadence, action,
299+
// issue, pr}. Same tolerate-and-skip contract as readEvents() above: a
300+
// blank/malformed line must never crash the whole render.
301+
function readTicks() {
302+
let text = "";
303+
try { text = fs.readFileSync(path.join(tmpdir, "loop-ticks.jsonl"), "utf8"); } catch (e) { return []; }
304+
const ticks = [];
305+
for (const line of text.split("\n")) {
306+
const trimmed = line.trim();
307+
if (!trimmed) continue;
308+
try {
309+
const obj = JSON.parse(trimmed);
310+
if (obj && typeof obj === "object" && !Array.isArray(obj)) ticks.push(obj);
311+
} catch (e) { /* skip malformed line */ }
312+
}
313+
return ticks;
314+
}
315+
const ticks = readTicks();
316+
274317
function esc(s) {
275318
return String(s == null ? "" : s)
276319
.replace(/&/g, "&amp;")
@@ -353,6 +396,61 @@ function renderLiveProgress() {
353396
return html;
354397
}
355398
399+
// ---- Loop health section (issue #85) ---------------------------------------
400+
// Sourced from loop-tick.sh's tick record log (loop-ticks.jsonl, one line per
401+
// firing, file order == append order == chronological). No records at all
402+
// (missing file, or a file with zero valid lines) means the loop has never
403+
// ticked in this environment -- rendered as "loop not armed", never a crash.
404+
// Otherwise: the last tick's ts/verdict, the current cadence, a STALLED
405+
// banner when now - lastTick exceeds 2x the cadence's expected interval, and
406+
// the last N verdict lines, newest-first (N is bounded, NOT the full
407+
// potentially ~2000-row retained log -- see COCKPIT_VERDICT_HISTORY_N below).
408+
const CADENCE_INTERVAL_SECONDS = { FAST: 60, WATCH: 300, IDLE: 900 };
409+
const nowMs = process.env.COCKPIT_NOW ? Date.parse(process.env.COCKPIT_NOW) : Date.now();
410+
// Verdict-history table depth: "the last N verdict lines, newest first"
411+
// (issue #85). Overridable for testability, consistent with the
412+
// COCKPIT_NOW/CLAUDE_TICKS_FILE override style used elsewhere in this file.
413+
// Falls back to 10 if unset/non-numeric/non-positive.
414+
const VERDICT_HISTORY_N = (() => {
415+
const n = parseInt(process.env.COCKPIT_VERDICT_HISTORY_N, 10);
416+
return Number.isFinite(n) && n > 0 ? n : 10;
417+
})();
418+
function renderLoopHealth() {
419+
let html = `<section id="loop-health"><h2>Loop health</h2>`;
420+
if (ticks.length === 0) {
421+
html += `<p class="muted">loop not armed</p></section>`;
422+
return html;
423+
}
424+
const last = ticks[ticks.length - 1]; // file order = append order -> last line = most recent tick
425+
const cadence = last.cadence != null ? String(last.cadence) : "";
426+
const intervalSec = CADENCE_INTERVAL_SECONDS[cadence];
427+
428+
html += `<p>Last tick: <code>${esc(last.ts)}</code> &middot; verdict <code>${esc(last.verdict)}</code></p>`;
429+
html += `<p>Cadence: <span class="badge muted">${esc(cadence || "(unknown)")}</span>`;
430+
if (intervalSec) html += ` <span class="muted">(every ${intervalSec}s)</span>`;
431+
html += `</p>`;
432+
433+
const lastMs = Date.parse(last.ts);
434+
let stalled = false;
435+
if (intervalSec && Number.isFinite(lastMs) && Number.isFinite(nowMs)) {
436+
stalled = nowMs - lastMs > intervalSec * 2 * 1000;
437+
}
438+
if (stalled) {
439+
html += `<p class="unavailable">STALLED — no tick in over ${intervalSec * 2}s (cadence ${esc(cadence)})</p>`;
440+
}
441+
442+
html += `<table class="routing"><thead><tr><th>Time</th><th>Verdict</th><th>Cadence</th></tr></thead><tbody>`;
443+
const historyStop = Math.max(0, ticks.length - VERDICT_HISTORY_N);
444+
for (let i = ticks.length - 1; i >= historyStop; i--) {
445+
const t = ticks[i];
446+
html += `<tr><td>${esc(t.ts)}</td><td><code>${esc(t.verdict)}</code></td><td>${esc(t.cadence)}</td></tr>`;
447+
}
448+
html += `</tbody></table>`;
449+
450+
html += `</section>`;
451+
return html;
452+
}
453+
356454
// ---- Issues section: group by module label, parse blocking graph per issue ----
357455
function renderIssues() {
358456
if (issuesUnavailable) {
@@ -479,7 +577,7 @@ function renderWorktrees() {
479577
return html;
480578
}
481579
482-
const generatedAt = new Date().toISOString();
580+
const generatedAt = Number.isFinite(nowMs) ? new Date(nowMs).toISOString() : new Date().toISOString();
483581
// Dark-theme stable marker (issue #69): the `data-theme="dark"` attribute
484582
// below is the CONTRACT a test/consumer can grep for to confirm the default
485583
// theme. The tiny <script> right after it restores a saved light-theme
@@ -548,8 +646,9 @@ const html = `<!doctype html>
548646
</head>
549647
<body>
550648
<h1>Cockpit <button id="theme-toggle" type="button">Toggle theme</button></h1>
551-
<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) &middot; re-run <code>cockpit.sh</code> to refresh (or run <code>cockpit-serve.sh</code> for live auto-update)</p>
649+
<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>
552650
${renderLiveProgress()}
651+
${renderLoopHealth()}
553652
${renderIssues()}
554653
${renderPRs()}
555654
${renderRouting()}

.claude/scripts/cockpit.test.sh

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
#!/usr/bin/env bash
22
# cockpit.test.sh — offline smoke test for cockpit.sh (issue #51, extended for
3-
# Phase 2 live progress in issue #52, and Phase 3a serve/theme/filter in
4-
# issue #69).
3+
# Phase 2 live progress in issue #52, Phase 3a serve/theme/filter in issue #69,
4+
# and the "Loop health" panel in issue #85).
55
#
6-
# Runs the generator against controlled FIXTURE issue/PR/events JSON (never
7-
# live gh/network, and never the real event log — see cockpit.sh's
8-
# --fixtures mode), then asserts the produced HTML contains every required
9-
# section (issues-by-module with blocking relationships, PRs with review/CI
10-
# badges, a routing table with a real `model:` value, a worktrees section,
11-
# a live-progress panel deduped to each worker's latest phase, the default
12-
# dark-theme marker) and that the blocking-relationship parser
13-
# (`cockpit.sh --parse-blocking`) produces the expected edges for a known
14-
# fixture body. Also exercises the "gh/network unavailable" degrade path via
15-
# COCKPIT_GH_BIN, entirely offline (no real gh call, no .env), and a serve-mode
16-
# smoke case (cockpit-serve.sh) against the SAME fixtures, over 127.0.0.1 only
17-
# — no real network/gh either way.
6+
# Runs the generator against controlled FIXTURE issue/PR/events/loop-ticks
7+
# JSON (never live gh/network, and never the real event/tick logs — see
8+
# cockpit.sh's --fixtures mode), then asserts the produced HTML contains every
9+
# required section (issues-by-module with blocking relationships, PRs with
10+
# review/CI badges, a routing table with a real `model:` value, a worktrees
11+
# section, a live-progress panel deduped to each worker's latest phase, the
12+
# default dark-theme marker, and a loop-health panel showing the last tick +
13+
# cadence + a verdict history capped to the last COCKPIT_VERDICT_HISTORY_N
14+
# ticks (newest first) + a STALLED banner once a tick is overdue) and that
15+
# the blocking-relationship parser (`cockpit.sh
16+
# --parse-blocking`) produces the expected edges for a known fixture body.
17+
# Also exercises the "gh/network unavailable" / "no log at all" degrade paths
18+
# via COCKPIT_GH_BIN / missing CLAUDE_EVENTS_FILE / CLAUDE_TICKS_FILE, entirely
19+
# offline (no real gh call, no .env), and a serve-mode smoke case
20+
# (cockpit-serve.sh) against the SAME fixtures, over 127.0.0.1 only — no real
21+
# network/gh either way.
1822
#
1923
# Exit 0 on success, non-zero if any assertion fails. Runnable bare:
2024
# bash .claude/scripts/cockpit.test.sh
@@ -121,9 +125,20 @@ cat > "$work/fixtures/events.jsonl" <<'EOF'
121125
{"ts":"2026-01-01T00:02:00Z","role":"reviewer","model":"opus","task":"52b","phase":"reviewing","lens":"correctness","detail":""}
122126
{"ts":"2026-01-01T00:03:00Z","role":"<script>xss()</script>\"","model":"sonnet","task":"52c","phase":"scoped","lens":"","detail":""}
123127
EOF
128+
# Loop tick fixture (issue #85, "Loop health" panel): two ticks, file order =
129+
# chronological, so the LAST line (FAST/advance) is the most recent tick and
130+
# must render as "Last tick" while BOTH rows appear in the verdict history,
131+
# newest first.
132+
cat > "$work/fixtures/loop-ticks.jsonl" <<'EOF'
133+
{"ts":"2026-01-01T00:00:00Z","verdict":"action=none","cadence":"IDLE","action":"none","issue":"","pr":""}
134+
{"ts":"2026-01-01T00:15:00Z","verdict":"action=advance issue=7","cadence":"FAST","action":"advance","issue":"7","pr":""}
135+
EOF
124136

125137
html="$work/cockpit.html"
126-
bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log"
138+
# COCKPIT_NOW pins "now" to 90s after the last tick above -- inside FAST's
139+
# 120s stall threshold, so this run must NOT show the STALLED banner (that
140+
# path is exercised separately in section 2b below).
141+
COCKPIT_NOW="2026-01-01T00:16:30Z" bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log"
127142
rc=$?
128143
check "generator exits 0 on fixture run" [ "$rc" -eq 0 ]
129144
check "generator prints the output path" grep -qF "$html" "$work/stdout.log"
@@ -208,6 +223,87 @@ check "dark theme marker present by default" grep -qF 'data-theme="dark"' "$html
208223
check "theme-toggle button present" grep -q 'id="theme-toggle"' "$html"
209224
check "issue rows carry data-module for the client-side filter" grep -q 'data-module="module:harness"' "$html"
210225

226+
# Loop health panel (issue #85): last tick, cadence, verdict history newest
227+
# first, and NO stall banner (COCKPIT_NOW above is only 90s past the last
228+
# tick, inside FAST's 120s threshold).
229+
check "loop health section present" grep -q '<section id="loop-health"' "$html"
230+
check "last tick's ts and verdict are rendered" grep -qF '<code>2026-01-01T00:15:00Z</code> &middot; verdict <code>action=advance issue=7</code>' "$html"
231+
check "current cadence (FAST) is rendered" grep -qF '<span class="badge muted">FAST</span>' "$html"
232+
check "no STALLED banner when the last tick is within the cadence threshold" bash -c '! grep -q "STALLED" "$1"' _ "$html"
233+
check "verdict history renders BOTH ticks, newest first" node -e '
234+
const fs = require("fs");
235+
const html = fs.readFileSync(process.argv[1], "utf8");
236+
const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
237+
if (!m) throw new Error("loop-health section not found");
238+
const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
239+
const want = ["action=advance issue=7", "action=none"];
240+
if (JSON.stringify(rows) !== JSON.stringify(want)) {
241+
throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
242+
}
243+
' "$html"
244+
245+
# ---------------------------------------------------------------------------
246+
# 2b. Loop health STALLED banner: a last tick far older than 2x its cadence's
247+
# expected interval must render the STALLED banner. Reuses the SAME
248+
# fixtures dir (issues/prs/events unrelated) but pins COCKPIT_NOW well
249+
# past the FAST tick's 120s threshold.
250+
# ---------------------------------------------------------------------------
251+
html_stalled="$work/cockpit-stalled.html"
252+
COCKPIT_NOW="2026-01-01T01:00:00Z" bash "$cockpit" --fixtures "$work/fixtures" "$html_stalled" >/dev/null 2>"$work/stderr-stalled.log"
253+
check "STALLED banner renders once the last tick exceeds 2x its cadence interval" grep -qF 'STALLED — no tick in over 120s (cadence FAST)' "$html_stalled"
254+
255+
# ---------------------------------------------------------------------------
256+
# 2c. Verdict-history cap (review fix for issue #85): the panel must show only
257+
# the last N verdict lines, newest first -- NOT every retained tick (the
258+
# ticks file itself may hold up to LOOP_TICKS_MAX_LINES/2000 rows). Uses a
259+
# dedicated fixtures dir with 5 DISTINGUISHABLE ticks (unique issue= per
260+
# line, mirroring the loop-tick.test.sh rotation fix) and
261+
# COCKPIT_VERDICT_HISTORY_N=3 so the cap is exercised deterministically
262+
# without needing a huge fixture.
263+
# ---------------------------------------------------------------------------
264+
mkdir -p "$work/fixtures-history"
265+
echo "[]" >"$work/fixtures-history/issues.json"
266+
echo "[]" >"$work/fixtures-history/prs.json"
267+
: >"$work/fixtures-history/events.jsonl"
268+
cat > "$work/fixtures-history/loop-ticks.jsonl" <<'EOF'
269+
{"ts":"2026-01-01T00:00:00Z","verdict":"action=advance issue=1","cadence":"FAST","action":"advance","issue":"1","pr":""}
270+
{"ts":"2026-01-01T00:01:00Z","verdict":"action=advance issue=2","cadence":"FAST","action":"advance","issue":"2","pr":""}
271+
{"ts":"2026-01-01T00:02:00Z","verdict":"action=advance issue=3","cadence":"FAST","action":"advance","issue":"3","pr":""}
272+
{"ts":"2026-01-01T00:03:00Z","verdict":"action=advance issue=4","cadence":"FAST","action":"advance","issue":"4","pr":""}
273+
{"ts":"2026-01-01T00:04:00Z","verdict":"action=advance issue=5","cadence":"FAST","action":"advance","issue":"5","pr":""}
274+
EOF
275+
html_history="$work/cockpit-history.html"
276+
COCKPIT_NOW="2026-01-01T00:04:30Z" COCKPIT_VERDICT_HISTORY_N=3 bash "$cockpit" --fixtures "$work/fixtures-history" "$html_history" >/dev/null 2>"$work/stderr-history.log"
277+
check "verdict-history cap: last tick is still the most recent (issue=5)" grep -qF '<code>2026-01-01T00:04:00Z</code> &middot; verdict <code>action=advance issue=5</code>' "$html_history"
278+
check "verdict-history cap: table renders exactly COCKPIT_VERDICT_HISTORY_N=3 rows, newest first" node -e '
279+
const fs = require("fs");
280+
const html = fs.readFileSync(process.argv[1], "utf8");
281+
const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
282+
if (!m) throw new Error("loop-health section not found");
283+
const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
284+
const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3"];
285+
if (JSON.stringify(rows) !== JSON.stringify(want)) {
286+
throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
287+
}
288+
' "$html_history"
289+
290+
# Default (COCKPIT_VERDICT_HISTORY_N unset) with only 5 ticks retained must
291+
# still render all 5 -- the default cap (10) must not truncate BELOW what's
292+
# actually there.
293+
html_history_default="$work/cockpit-history-default.html"
294+
COCKPIT_NOW="2026-01-01T00:04:30Z" bash "$cockpit" --fixtures "$work/fixtures-history" "$html_history_default" >/dev/null 2>"$work/stderr-history-default.log"
295+
check "verdict-history default cap (10) does not truncate a shorter (5-tick) history" node -e '
296+
const fs = require("fs");
297+
const html = fs.readFileSync(process.argv[1], "utf8");
298+
const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
299+
if (!m) throw new Error("loop-health section not found");
300+
const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
301+
const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3", "action=advance issue=2", "action=advance issue=1"];
302+
if (JSON.stringify(rows) !== JSON.stringify(want)) {
303+
throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
304+
}
305+
' "$html_history_default"
306+
211307
# ---------------------------------------------------------------------------
212308
# 3. GATES_FILE override is honored (self-host adapter), still with fixtures
213309
# (no gh/network either way).
@@ -227,16 +323,19 @@ exit 1
227323
EOF
228324
chmod +x "$fake_gh"
229325
html_unavail="$work/cockpit-unavail.html"
230-
# CLAUDE_EVENTS_FILE points at a guaranteed-missing path so this run is fully
231-
# offline/deterministic (never touches the real, gitignored event log) and
232-
# doubles as the "no events file at all" -> "no active workers" assertion.
233-
COCKPIT_GH_BIN="$fake_gh" CLAUDE_EVENTS_FILE="$work/no-such-events.jsonl" bash "$cockpit" "$html_unavail" >/dev/null 2>"$work/stderr-unavail.log"
326+
# CLAUDE_EVENTS_FILE/CLAUDE_TICKS_FILE point at guaranteed-missing paths so
327+
# this run is fully offline/deterministic (never touches the real,
328+
# gitignored logs) and doubles as the "no log at all" degrade assertions for
329+
# both the live-progress panel ("no active workers") and the loop-health
330+
# panel ("loop not armed", issue #85) -- neither must crash the render.
331+
COCKPIT_GH_BIN="$fake_gh" CLAUDE_EVENTS_FILE="$work/no-such-events.jsonl" CLAUDE_TICKS_FILE="$work/no-such-ticks.jsonl" bash "$cockpit" "$html_unavail" >/dev/null 2>"$work/stderr-unavail.log"
234332
rc_unavail=$?
235333
check "generator still exits 0 when gh is unavailable" [ "$rc_unavail" -eq 0 ]
236334
check "issues section shows unavailable placeholder" grep -q '<section id="issues"><h2>Open issues</h2><p class="unavailable">unavailable (gh/network)</p>' "$html_unavail"
237335
check "PRs section shows unavailable placeholder" grep -q '<section id="prs"><h2>Open PRs</h2><p class="unavailable">unavailable (gh/network)</p>' "$html_unavail"
238336
check "routing/worktrees sections still render (no crash) despite gh failure" bash -c 'grep -q "routing" "$1" && grep -q "worktrees" "$1"' _ "$html_unavail"
239337
check "missing events file renders 'no active workers' placeholder" grep -q '<section id="live"><h2>Live worker progress</h2><p class="muted">no active workers</p>' "$html_unavail"
338+
check "missing loop-ticks log renders 'loop not armed' placeholder, no crash" grep -qF '<section id="loop-health"><h2>Loop health</h2><p class="muted">loop not armed</p></section>' "$html_unavail"
240339

241340
# ---------------------------------------------------------------------------
242341
# 5. Serve mode (cockpit-serve.sh, issue #69): dashboard over HTTP + SSE live

0 commit comments

Comments
 (0)