Skip to content

feat(claude-ops): surface review findings stranded on merged pull requests - #1781

Merged
kyle-sexton merged 3 commits into
mainfrom
feat/1777-stranded-review-findings
Jul 30, 2026
Merged

feat(claude-ops): surface review findings stranded on merged pull requests#1781
kyle-sexton merged 3 commits into
mainfrom
feat/1777-stranded-review-findings

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Why

A review that lands after a merge has nowhere to go:

  • the ruleset's required_review_thread_resolution is a merge-time predicate that already passed;
  • the babysit lane works open PRs, and a merged PR leaves that queue;
  • nothing on a merged PR surfaces its open threads — GitHub shows the merge, not the findings.

Six findings — one P1 — posted 46 seconds after #1720 merged and sat unread for a day. They
surfaced only because a later session happened to audit that merge batch. Nothing was bypassed; the
gate was satisfied because the threads did not yet exist.

The morning brief is the right home: read-only, unattended, and already where attention signals land.

What it does

Compares each unresolved thread's first-comment timestamp against the PR's mergedAt, and
reports only threads the gate could never have seen. A thread that predates the merge was visible to
the gate — that is an ordinary unresolved thread, not this failure mode, and it stays out.

  • One line per PR, at that PR's worst severity, with a finding count. Several findings on one PR
    are one thing to go look at; repeating the title per thread buries every other PR. Collapsing on
    the worst severity means a P0 sitting beside advisory findings can never be softened.
  • Severity survives to the operator — a stranded P1 must not read like a P3.
  • --stranded-days (default 3) — wide enough to cover slow bot review and an operator-absent
    weekend.

It fails loud, not clear

A GraphQL error document is well-formed JSON that simply carries no data. The extraction would
yield an empty list and render "every merged PR in the window is clear" — an all-clear asserted
from an answer never received, which is the same fail-open shape this section exists to catch.

This is not hypothetical: a rate-limit error did exactly that during development. An API error now
says explicitly that it is not an all-clear, and prints the message. Covered by a regression case.

This is a standing leak, not a one-off

Its first live run against this repository immediately surfaced four more stranded findings on
other merged PRs — including a P1 on #1694 (merged 05:04:45Z, finding posted 05:05:20Z, 35
seconds later) recording that a shipped autonomy cell never reached installations.

Verification

  • morning-brief.test.sh: 30 → 63 cases, 0 failures.
  • The negative cases carry the weight — a pre-merge thread, an already-resolved post-merge
    thread, and a merge outside the window must all stay silent, or the section is noise rather than
    signal. Plus: collapse-does-not-soften-severity, highest-severity-first, window-widening, and the
    API-error case above.
  • The fixture mirrors the real feat(claude-ops): consume lane restart-requests via scheduled headless reader #1720 shape, including the 46-second gap.
  • shellcheck -x on script and test — clean. One SC2016 is declared, not blanket-suppressed: the
    $owner/$name/$endCursor in the GraphQL query are server-side variables bound by -F and
    must reach the server unexpanded.
  • node scripts/validate-plugin-contracts.mjs — 43 setup skills, 2153 files, pass.
  • npx markdownlint-cli2 on both changed markdown files — 0 errors.

Live run — posted in full in the comments below

A live run on the current branch found 44 merged PRs carrying post-merge findings in a five-day
window: 0 P0, 10 P1, 34 P2.
Among the P1s: #1503, a guardrail-bypass fix whose own review
landed unread, and #1322 with 5 findings.

Read the second comment for the authoritative figures — the first was produced by the
pre-review severity logic and reported a false P0, which review then caught. No truncation warning
fired, so the read is complete.

The five-day window filter was spot-checked against mergedAt (a PR numbered #969 in a 5-day window
looks wrong until you check: it merged 2026-07-25, 4.2 days before the run).

This is a far larger leak than the six findings that exposed it.

Related

Closes #1777
Refs #1720
Refs #1759

…uests

A review that lands after a merge has nowhere to go. The ruleset's
required_review_thread_resolution is a merge-time predicate that already
passed, the babysit lane works only open pull requests, and nothing on a merged
pull request surfaces its open threads. Six findings — one P1 — posted 46
seconds after #1720 merged sat unread for a day, and surfaced only because a
later session happened to audit that merge batch.

The morning brief is the right home: read-only, unattended, and already the
place attention signals land.

The discriminator is each thread's first-comment timestamp against the pull
request's mergedAt, so only threads the gate could never have seen are
reported; a thread predating the merge is an ordinary unresolved thread and is
excluded. Findings collapse to one line per pull request at that pull request's
WORST severity with a count, so a P0 beside advisory findings is never softened
and one noisy pull request cannot bury the rest.

The section fails loud rather than clear. A GraphQL error document is
well-formed JSON carrying no `data`, so an unread API would extract to an empty
list and render as "every merged PR in the window is clear" — an all-clear
asserted from an answer never received, the same fail-open shape this section
exists to catch. A rate-limit error did exactly that during development.

Its first live run against this repository immediately found four more stranded
findings on other merged pull requests, including a P1 recording that a shipped
plugin cell never reached installations. This is a standing leak, not a one-off.

Tests: 30 -> 63 cases, 0 failures. The negative cases carry the weight — a
pre-merge thread, an already-resolved one, and a merge outside the window must
all stay silent, or the section is noise rather than signal.

Closes #1777

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 26s —— View job


Review: feat(claude-ops): surface review findings stranded on merged pull requests

The problem statement is well-argued, the fail-loud design is sound, and the test suite's negative cases carry real weight. One correctness bug in the jq severity logic, two documentation gaps, and two minor test coverage gaps.


[CORRECTNESS] Severity collapsing picks "--" over real severity labels

File: morning-brief.sh line 594

sev: (map(.sev) | sort | .[0])

sort on these strings is lexicographic: "--" (ASCII 45-45) sorts before "P0" (ASCII 80-48). So .[0] — the minimum — is "--" whenever any thread on the PR lacks a recognized severity marker. A PR with a P1 thread and an unranked "--" thread collapses to [--] rather than [P1]. A stranded P1 beside a bot-generated advisory note reads like an unknown, which is exactly the softening the PR description says can never happen.

The same lexicographic order affects sort_by(.sev, .pr) at line 596: a "--" PR sorts before "P0" and "P1" PRs, so unknown-severity findings appear at the top of the section.

The test fixture for #1720 has two threads both with recognized badges (![P1 Badge] and ![P2 Badge]), so sort | .[0] gives "P1" and the test passes — but the "--" case is never exercised.

Fix: Replace the string-minimum sort with an explicit priority ordering before picking:

sev: (
  map(.sev)
  | map(if . == "P0" then 0 elif . == "P1" then 1 elif . == "P2" then 2 else 9 end)
  | min
  | if . == 0 then "P0" elif . == 1 then "P1" elif . == 2 then "P2" else "--" end
)

And for the output sort:

| sort_by(([{"P0":0,"P1":1,"P2":2}[.sev]] | first // 9), .pr)

Fix this →


[DOCUMENTATION] --stranded-days and --merged-json absent from --help

File: morning-brief.sh lines 17–35

The usage() function prints the leading comment block verbatim. --stranded-days is not listed in the usage flags, and --merged-json is not listed under fixture flags. A user running --help won't discover either option.

Add both to the comment block:

#   morning-brief.sh --stranded-days N       look-back window for stranded findings (default 3)
#   --merged-json FILE        array of paged GraphQL results for the stranded section

Fix this →


[DOCUMENTATION] argument-hint in SKILL.md omits --stranded-days

File: SKILL.md line 7

argument-hint: "[--repo owner/name] [--telemetry-issue N] [--stale-hours N] — read-only; omit to view the current repo"

--stranded-days is the only operator-tunable parameter added by this PR and it's not in the hint. Operators who widen the window won't discover it here.

Fix this →


[TESTING] No validation tests for --stranded-days (non-numeric, octal)

File: morning-brief.test.sh

The existing --stale-hours and --rec-maxlen flags each have two tests: one for a non-numeric value and one for a leading-zero octal normalization. --stranded-days has neither, even though the flag has the same validation and base-10 coercion code. The gap is minor given the code is copy-pasted from the same pattern, but it's inconsistent.


[TESTING] BSD date fallback path for the stranded section is not exercised

File: morning-brief.test.sh lines 348–359

The BSD date stub test run doesn't pass --merged-json, so print_stranded takes the graceful-degrade path ("no repo resolved") rather than the timestamp comparison path. The to_epoch calls inside the jq filter (fromdateiso8601 is jq-native and portable) are fine, but the shell-side cutoff arithmetic (NOW_EPOCH - STRANDED_DAYS * 86400) is never exercised under the BSD stub. Low risk given the math is trivial and already covered on the GNU path, but worth noting.


What's well done

  • Fail-loud design is correct and tested: the error-document detection ([ .. | objects | select(has("errors")) | .errors[]?.message ] | first // empty) handles the observed live failure mode — a rate-limit error that rendered as an all-clear. The regression test covers it.
  • Negative test cases carry the weight: pre-merge thread, resolved post-merge thread, and out-of-window merge each have dedicated exclusion tests. These are the cases that make the signal trustworthy.
  • Fixture shape handles both --paginate and array: the [ .. | objects | select(has("data")) ] walk is correct — it handles the bare sequence of page documents that gh --paginate emits as well as the already-wrapped array a fixture file produces.
  • shellcheck disable=SC2016 is declared and justified: single-variable suppression with an accurate comment rather than a blanket ignore.
  • 10# octal guard: applied consistently to --stranded-days, matching the existing pattern.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 52s —— View job


Security Review

  • Load tooling
  • Read changed files (morning-brief.sh +147, morning-brief.test.sh +121, SKILL.md, plugin.json, CHANGELOG.md)
  • Analyze for injection surfaces, trust-boundary issues, token/secret handling, logic flaws
  • Post findings

No critical or important security vulnerabilities found. One suggestion-level finding, and one noteworthy positive security improvement worth calling out explicitly.


SUGGESTION — Unfiltered GitHub API data echoed to terminal

Confidence: PLAUSIBLE

morning-brief.sh L601–602 and L544:

# L601-602
echo "$stranded"       # contains PR titles and author.login from GitHub API

# L544
echo "    $api_err"    # contains GitHub GraphQL error message text

jq -r outputs raw strings with no ANSI/control-character stripping. A PR contributor (or — far less likely — a MITM on the gh API call) could embed terminal escape sequences in a PR title or author display name, which would be rendered by the operator's terminal emulator. Most of the jq filters in the existing sections have the same property, so this is a systemic pattern in the script, not unique to this addition.

Practical exposure is low: the operator controls which repository is targeted, gh uses TLS, and GitHub normalizes usernames. The risk surface is a PR author with a crafted title on a repo the operator has chosen to brief. Modern terminal emulators are also increasingly hardened against malicious escape sequences.

No remediation required to accept this PR. If hardening is ever desired, piping the jq output through cat -v or sed 's/\x1b\[[0-9;]*[mGKHF]//g' before echo would strip common ANSI sequences.


Positive security finding — Fail-loud API error handling

L535–547 correctly detects the fail-open shape where a well-formed GraphQL error document (no data key) would otherwise produce an empty finding list and print "every merged PR in the window is clear." The explicit jq walk for errors[]?.message and the NOT-an-all-clear message close that channel. The regression case in the test suite (merged-apierror.json) makes this durable. This is the right approach.


Other areas reviewed (no findings)

Area Verdict
GraphQL query construction (-F owner=…, -F name=…, single-quoted query body) Safe — variables bound via -F, query never expanded
REPO shell expansion (${REPO%%/*}, ${REPO##*/}) in double-quoted context Safe — no word splitting, no flag injection
--stranded-days input: ^[0-9]+$ + $((10#$2)) Safe — digit-only gate + octal guard
--now value passed to date -d "$s" Safe — no code execution; date -d parses only, does not eval
Fixture file path via --merged-json Safe — require_file validates existence before use
Token / credential handling None in script; gh CLI handles auth internally
gh api graphql 2>/dev/null error suppression + empty-merged fallback Correct — degrades to "unable to read" not all-clear
ANY_LIVE gate excludes MERGED_JSON Minor correctness gap (gh unchecked when all 4 other fixtures provided + --repo set), but merged="" degrades gracefully — not a security issue

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Live verification — the gap this closes is far larger than the case that found it

The known gap in the PR body is now closed. Fresh live run against this repository, on the current
branch, after the JSON-shape refactor:

bash plugins/claude-ops/skills/morning-brief/scripts/morning-brief.sh \
  --repo melodic-software/claude-code-plugins --stranded-days 5

43 merged pull requests are carrying review findings that were posted after they merged — in a
five-day window alone:

Severity Stranded PRs
P0 1
P1 8
P2 34

The P0 is #969 (feat(autonomy): deterministic lane-stop gate + operator STOP notification),
merged 2026-07-25T23:18:16Z, carrying 3 findings. Among the P1s: #1322 with 5 findings,
#1425 with 3, and #1503fix(guardrails): close git-alias-chain re-expansion bypass of both git guards, a guardrail bypass fix whose own review landed unread.

This started from six findings on one PR. It is a standing leak at a scale nobody had measured,
because until now nothing could see it.

The window filter is correct

Spot-checked, because a PR numbered #969 appearing in a five-day window looks wrong at a glance:

  • #969 merged 2026-07-25T23:18:16Z — 4.2 days before the run at 2026-07-30T04:44:30Z. Inside.
  • #1025 merged 2026-07-25T21:09:44Z. Inside.

Low PR number, recent merge. The gate filters on mergedAt, not on PR number or creation date.

Triage note

At least one of these is already handled in substance: #1694's P1 asked for an autonomy version
bump, and that same PR did bump 0.11.4 → 0.11.5 (commit 7cbca800). The thread was simply never
resolved.

That is the section behaving correctly rather than a false positive — an unresolved thread is
unfinished bookkeeping, and the tool cannot know the fix landed without reading it. But it means the
43 above are PRs to look at, not 43 confirmed live defects. Triage is a separate pass, tracked
outside this PR.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ec9634d6f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/morning-brief/scripts/morning-brief.sh Outdated
Comment thread plugins/claude-ops/skills/morning-brief/scripts/morning-brief.sh Outdated
Comment thread plugins/claude-ops/skills/morning-brief/scripts/morning-brief.sh Outdated
…report truncation

Three review findings on the PR that added this section, each a way the section
could mislead the operator it exists to inform.

- Severity was tested against the whole comment body, so a P2 titled "Preserve
  P1 labels" reported as P1, and any finding merely discussing CRITICAL or
  SECURITY was promoted to P0. Severity now comes only from the structured
  marker — the badge alt-text, the shields badge URL, or a leading bracket.
- Ranking compared display strings, and "--" sorts before "P0", so a single
  unclassified thread beside a genuine P0 collapsed the whole PR to "[--]" and
  hid it. Ranking is numeric; unrecognized sorts last.
- The thread connection requested one page with no pageInfo, and --paginate
  follows only the outer cursor, so a PR with more than 100 review threads was
  silently truncated. It now requests the page maximum, reads hasNextPage, and
  says the read was PARTIAL rather than letting a truncated read render as an
  all-clear.

Also fixes a jq abort found while testing the above: `capture(...).s` and
`.captures[0]` both index an object when the regex does not match, which killed
the whole program and rendered EVERY pull request as clear — the exact
fail-open this section exists to catch, reintroduced by the fix for it. The
match now yields empty and falls through to the next alternative. A second
instance mapped over grouped objects rather than their `.sev` strings.

`\s` in the test file is a GNU ERE extension the portability lint bans; replaced
with `[[:space:]]`. No suppression added.

Tests: 63 -> 71 cases, 0 failures. Every finding above gets both directions —
prose must not promote, an unclassified thread must not mask a P0, both marker
forms must be recognized, and a truncated read must announce itself.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 2s —— View job


Review: follow-up pass after e561a35

  • Gather context and understand the request
  • Read the current diff (e561a35 — severity fix + pagination)
  • Review morning-brief.sh changes at HEAD
  • Review morning-brief.test.sh changes at HEAD
  • Check documentation gaps (SKILL.md, usage block)
  • Assess remaining open findings
  • Post complete review

The latest commit (e561a35 fix(claude-ops): read severity from the marker, rank it numerically, report truncation) closes three of the five issues from the prior review and all three Codex findings. What it fixed is correct; what it didn't touch is noted below.


What e561a35 fixes — verified correct

Severity collapsing (prior review P1 / Codex P1)

The numeric ranking is the right fix:

sev: (map(.sev) | map(if . == "" then 99 else (.[1:] | tonumber) end) | min
      | if . == 99 then "--" else "P\(.)" end)
| sort_by(if .sev == "--" then 99 else (.sev[1:] | tonumber) end, .pr)

"P\(.)" reconstructs from the numeric rank, so the output label can never drift from the comparison value. Mapping "" to 99 and testing == 99 at the collapse step is robust: min on a non-empty group is never null, and a group is only created when at least one thread exists. ✓

Structured marker extraction (Codex P2 — prose substring promotion)

The three-pattern [ match(…) ] idiom is correct: wrapping match in [ … ] produces [] on no-match rather than triggering an error path, and // empty inside map correctly drops null slots. The badge alt-text pattern !\\[[[:space:]]*(P[0-9])[ _-]?Badge matches ![P2 Badge], capturing P2. Tested by fixture #300 (![](x) Preserve P1 labels when CRITICAL SECURITY findings appear[P2] #300). ✓

Thread pagination limit (Codex P2 — silent truncation)

first:100 is the field maximum; --paginate follows only the outer PR cursor so inner thread pagination would require N+1 calls per PR. The chosen approach is the right tradeoff: cap at the maximum, read pageInfo.hasNextPage, and surface a named warning:

WARNING: more than 100 review threads on #400 — this read is PARTIAL, not an all-clear

The test case (#400, hasNextPage: true, resolved thread) verifies the warning fires even when no stranded findings exist — the important case, since that's when the truncation is invisible without the warning. ✓


Still open from the prior review

[DOCUMENTATION] --stranded-days and --merged-json absent from --help

morning-brief.sh lines 17–30 — the usage() function prints this block verbatim. Neither --stranded-days nor --merged-json appears. A user running --help cannot discover the only tunable parameter or the stranded-section fixture flag.

#   morning-brief.sh --stale-hours N          age past which a lane is STALE (default 6)
#   morning-brief.sh --rec-maxlen N           truncate RECOMMENDED previews (default 240; 0 = full)
+#   morning-brief.sh --stranded-days N        post-merge review-findings window (default 3)
#   morning-brief.sh --help
#
# Fixture flags (skip the network; used by the test suite and for reuse):
#   --now ISO                 fixed clock for deterministic staleness
# ...
#   --telemetry-json FILE     array of {body} (the telemetry issue's comments)
+#   --merged-json FILE        array of paged GraphQL results for the stranded section

Fix this →


[DOCUMENTATION] --stranded-days absent from argument-hint in SKILL.md

SKILL.md line 4:

argument-hint: "[--repo owner/name] [--telemetry-issue N] [--stale-hours N] — read-only; omit to view the current repo"

--stranded-days is the only operator-tunable parameter this PR adds and it is not in the hint. An operator widening the window will not discover it here.

Fix this →


[TESTING] No validation tests for --stranded-days (non-numeric, octal) — minor

The existing --stale-hours and --rec-maxlen flags each have two tests: one for a non-numeric value and one for leading-zero normalization. --stranded-days shares the identical validation and 10# coercion code but has neither test. Gap is low-risk given the code is a copy of the same pattern, but the inconsistency is worth closing.


No new concerns from e561a35

The jq for the severity extraction and the numeric ranking handles all edge cases correctly — empty string maps to 99 (renders as --), min on a non-empty group is never null, and the P\(.) reconstruction round-trips through the same digit used for comparison. No regressions in the truncation detection path.


What's still well done (unchanged)

  • Fail-loud API error handling is correct and regression-tested (merged-apierror.json, NOT an all-clear).
  • Negative cases in the test suite carry the weight: pre-merge thread, resolved post-merge thread, out-of-window merge each have dedicated exclusion tests.
  • Fixture shape correctness: the [ .. | objects | select(has("data")) ] walk handles both --paginate bare page sequences and already-wrapped fixture arrays.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 2s —— View job


Security Review

  • Load tooling
  • Read PR diff and changed files (both commits: ec9634d feature, e561a35 fix)
  • Analyze for injection surfaces, trust-boundary issues, token/secret handling, logic flaws
  • Post findings

No critical or important security vulnerabilities found. One suggestion-level finding in the fail-loud path, and one positive security finding worth calling out explicitly.


SUGGESTION — Non-JSON stdout bypasses the fail-loud guard and produces a silent false all-clear

Confidence: PLAUSIBLE

morning-brief.sh L536–553

if [[ -z "$merged" ]]; then
    echo "  (unable to read merged PRs)"
    ...
fi
# FAIL LOUD.
api_err="$(jq -s -r '... | first // empty' <<<"$merged" 2>/dev/null)"
if [[ -n "$api_err" ]]; then ...

The [[ -z "$merged" ]] guard catches empty stdout from gh api graphql. But if gh writes non-JSON content to stdout — for example, an HTML error page returned by a network interception, captive portal, or unexpected gh stdout path — $merged is non-empty and this guard does not fire. Both subsequent jq calls (api_err at L547, stranded at L559) receive invalid JSON, fail silently via 2>/dev/null, and produce empty output. The script then prints:

(none — every merged PR in the window is clear)

No "NOT an all-clear" qualifier. The section designed specifically to prevent silent false all-clears produces one.

The previous security review's table entry marked this case as "Correct — degrades to 'unable to read' not all-clear," but that conclusion only holds when $merged is empty. The non-empty non-JSON path is the gap.

Practical exposure is low. gh api uses TLS and normally writes only valid JSON to stdout; a non-JSON response requires network interception that defeats TLS, or a future gh stdout behavior change. But the consequence — a silent false all-clear in the one section that exists to catch silent false all-clears — is the right kind of thing to surface.

A targeted fix: validate that $merged is parseable JSON before entering the error-detection logic:

if ! jq -e . >/dev/null 2>&1 <<<"$merged"; then
  echo "  (unable to read merged PRs — response is not valid JSON, this is NOT an all-clear)"
  echo
  return
fi

Fix this →


Positive security finding — Severity inflation via body-wide string matching is closed in e561a35

The initial commit (ec9634d) used:

| if test("P0|CRITICAL|SECURITY"; "i") then "P0"
  elif test("P1"; "i") then "P1"
  elif test("P2"; "i") then "P2"
  else "--" end

Any comment body containing the word "CRITICAL" or "SECURITY" anywhere — including in a P2 advisory note discussing "critical infrastructure" — would be promoted to P0 in the morning brief. This is a semantic integrity issue in the opposite direction: instead of hiding a high-severity finding, an operator could see a false P0 with nothing to act on, eroding trust in the section.

The fix commit (e561a35) replaces this with structured-marker-only regex matching (![Px Badge] alt-text, shields badge URL, leading bracket). The PR title test case in merged-severity.json"![](x) Preserve P1 labels when CRITICAL SECURITY findings appear" — covers both the false-promotion and the false-demotion directions. This is the right design.


Other areas reviewed (no findings)

Area Verdict
-F owner="${REPO%%/*}" / -F name="${REPO##*/}" — REPO passed as typed GraphQL variables, not interpolated into query body Safe
--stranded-days input: ^[0-9]+$ gate + $((10#$2)) octal guard Safe
--merged-json file path: require_file checks -f before cat Safe
truncated output: sourced from .number (integer field) → "#\(.)" — not an arbitrary string Safe
Severity regex (P[0-9]).[1:] | tonumber in collapsing step: capture is always one digit Safe
$endCursor GraphQL pagination variable: managed internally by gh --paginate, never shell-expanded Safe
sev: (map(.sev) | map(... | tonumber) | min ...) — maps .sev strings, not objects Safe (fix in e561a35)
jq ... 2>/dev/null error suppression + [[ -z "$merged" ]] guard Mostly safe; non-JSON non-empty path is the gap above

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Correction: the P0 in my earlier comment was a false promotion, by the bug review then caught

The severity figures in my comment above were produced by the pre-review severity logic, which
tested the whole comment body for P0|CRITICAL|SECURITY. Review found that exact defect
("Match structured severity markers instead of body substrings"), and it inflated my own numbers.

The #969 "P0" was a P1 whose prose mentioned a higher severity. There is no P0 in the window.

Re-run on e561a353, with severity read from the structured marker only:

Severity Before (buggy) Corrected
P0 1 0
P1 8 10
P2 34 34
Stranded PRs 43 44

The P1 count went up, not down: numeric ranking now stops a single unclassified thread from
collapsing a PR to [--] and hiding its real severity, which surfaced #1318 and reclassified
#969 into the P1 band rather than out of it. Net: no P0, ten P1s, one more affected PR than I
reported.

The ten P1s:

#969   feat(autonomy): deterministic lane-stop gate + operator STOP notification   (3 findings)
#1252  feat(context-guard): version-independent statusline shim                    (2 findings)
#1296  refactor(session-flow): restructure the handoff save-point taxonomy         (2 findings)
#1318  feat(claude-config): add audit-pass, one coordinated resumable pass         (2 findings)
#1320  docs: source-control the loop-lane launch prompts                           (2 findings)
#1322  docs(topics): absorb the context-engineering source                         (5 findings)
#1405  feat(source-control): explicit-autopilot merge-rung exception               (2 findings)
#1425  feat(discovery)!: dispatch explore and research to purpose-built subagents  (3 findings)
#1503  fix(guardrails): close git-alias-chain re-expansion bypass of both guards   (1 finding)
#1694  feat(autonomy): add C3 auto-merge cell with evidence predicate              (1 finding)

No truncation warning fired, so every affected PR is under the 100-thread page limit and this read
is complete.

What the review round changed

All three findings are fixed in e561a353, and fixing them exposed a fourth defect I introduced
while fixing them: capture(...).s and .captures[0] both index an object when the regex does not
match, which aborted the entire jq program and rendered every PR as clear — the exact fail-open
this section exists to catch, reintroduced by its own fix. Caught before merge by running the live
path rather than trusting the fixture suite, which was green throughout.

Tests: 63 → 71, 0 failures.

`comment-hygiene` flags `tracker-ref:pr-reference`: a comment citing the pull
request that produced it is a back-reference no future reader can resolve, and
version control already owns that history. The section header carried one.

The cases below it already state what they cover, so nothing is lost.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 26s —— View job


Review pass at 1dce962

  • Load tooling
  • Read changed files at HEAD
  • Verify open findings from prior rounds
  • Assess 1dce962 (chore: drop back-reference from test section header)
  • Post complete review

1dce962 — correct and benign

The commit drops (PR #1781 review) from a test section comment header, leaving:

-# --- Severity classification and ranking (PR #1781 review) -------------------
+# --- Severity classification and ranking -------------------------------------

The back-reference named the PR that produced the cases, not the code they test. Removing it is correct — version control owns that history, and the test descriptions below the header already state what each case covers.


Still open from prior rounds

The three correctness bugs caught in the first and second rounds (severity collapsing, structured-marker extraction, silent truncation) are all correctly fixed in e561a35 and unaffected by this commit. Four items from those rounds remain open.

[DOCUMENTATION] --stranded-days and --merged-json absent from --help

morning-brief.sh lines 17–31 — the usage() function prints this block verbatim. --stranded-days does not appear among the usage flags and --merged-json does not appear under the fixture flags. A user running --help cannot discover the only operator-tunable parameter or its companion fixture flag.

 #   morning-brief.sh --rec-maxlen N           truncate RECOMMENDED previews (default 240; 0 = full)
+#   morning-brief.sh --stranded-days N        post-merge review-findings window (default 3)
 #   morning-brief.sh --help
 #
 # Fixture flags (skip the network; used by the test suite and for reuse):
 #   --now ISO                 fixed clock for deterministic staleness
 ...
 #   --telemetry-json FILE     array of {body} (the telemetry issue's comments)
+#   --merged-json FILE        array of paged GraphQL results for the stranded section

Fix this →


[DOCUMENTATION] --stranded-days absent from argument-hint in SKILL.md

SKILL.md line 4:

argument-hint: "[--repo owner/name] [--telemetry-issue N] [--stale-hours N] — read-only; omit to view the current repo"

--stranded-days is the only operator-tunable parameter this PR adds. The table body in the skill (line 51) does name it, but an operator reading the hint — the first place they'd look when invoking the skill with arguments — will not discover it.

Fix this →


[TESTING] No validation tests for --stranded-days — minor

The existing --stale-hours and --rec-maxlen flags each have two validation tests: one for a non-numeric value and one for leading-zero octal normalization. --stranded-days shares identical validation and 10# coercion code (copied from the same pattern) but has neither test. Low-risk given the code is a direct copy, but the inconsistency is worth closing before merge.


[TESTING] BSD date fallback doesn't exercise the stranded section — minor

The BSD date stub run at line 398 passes --counts-json, --pr-json, --decisions-json, and --telemetry-json but no --merged-json. The stranded section takes the no repo resolved degrade path rather than the timestamp-comparison path. The shell arithmetic (NOW_EPOCH - STRANDED_DAYS * 86400) is never exercised under the BSD stub. Low risk — the math is trivial and covered on the GNU path.


What's correct at HEAD

Everything fixed in e561a35 is still correct and unaffected by this commit:

  • Severity collapsing maps to numeric rank before min, so "" → 99 and P0 → 0; an unclassified thread beside a genuine P0 renders [P0], not [--].
  • Structured-marker extraction uses [ match(...) ] (not capture(...)) so a non-matching pattern yields [] rather than aborting the jq program and silently rendering every PR as clear.
  • Truncation detection reads reviewThreads.pageInfo.hasNextPage and fires a named warning even when the truncated PR has no stranded findings — the important case, since that's when the truncation is invisible without the warning.
  • Fail-loud API error handling catches the error-document shape (data absent, errors present) and prints a NOT-an-all-clear message; the regression test (merged-apierror.json) makes it durable.
  • Negative test cases (pre-merge thread, resolved post-merge thread, out-of-window merge) each have dedicated exclusion tests. These are the cases that make the signal trustworthy.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 4s —— View job


Security Review

  • Load tooling
  • Read PR diff and changed files (ec9634d feature, e561a35 severity fix + truncation, 1dce962 chore)
  • Analyze for injection surfaces, trust-boundary issues, token/secret handling, logic flaws
  • Post findings

No new security vulnerabilities in the final commit (1dce962 — test comment edit only). Two SUGGESTION-level findings from the prior security reviews remain open at HEAD and are recapped below for completeness. No critical or important issues.


SUGGESTION — Non-JSON API response bypasses the fail-loud guard → silent false all-clear

Confidence: PLAUSIBLE — raised in the prior security review pass on e561a35; not yet addressed

morning-brief.sh L536–553

The guard against an empty response fires when $merged is empty:

if [[ -z "$merged" ]]; then
    echo "  (unable to read merged PRs)"
    ...
fi

But if gh writes non-empty, non-JSON content to stdout — an HTML error page from a network device, a captive portal, or an unexpected gh stdout path — $merged is non-empty and this guard does not fire. Both downstream jq calls specify 2>/dev/null and suppress their failure, returning empty output. The section then prints:

(none — every merged PR in the window is clear)

No NOT-an-all-clear qualifier. The section built specifically to prevent silent false all-clears produces one, for exactly the same shape of failure it was built to catch.

A targeted fix: validate JSON-parsability before entering the error-detection block:

if ! jq -e . >/dev/null 2>&1 <<<"$merged"; then
  echo "  (unable to read merged PRs — response is not valid JSON, this is NOT an all-clear)"
  echo
  return
fi

Fix this →


SUGGESTION — Unfiltered GitHub API data reaches the terminal

Confidence: PLAUSIBLE — raised in both prior security review passes; a systemic pattern throughout the script, not new to this PR

morning-brief.sh L634 and L550:

echo "$stranded"     # contains PR titles from GitHub API
echo "    $api_err"  # contains GraphQL error message text

jq -r outputs raw strings with no control-character stripping. A PR contributor with a crafted title could embed terminal escape sequences that render in the operator's terminal emulator. Practical exposure is low: gh uses TLS, GitHub normalizes usernames, and the operator controls which repository is briefed. Author logins (P[a-zA-Z0-9-]+) are safe; PR titles and error message text are not.

No remediation required to accept this PR. If hardening is desired, piping through cat -v or a sed ANSI-strip before echo would close the channel.


Areas reviewed — no findings

Area Verdict
GraphQL variables: -F owner="${REPO%%/*}" / -F name="${REPO##*/}" Safe — variables bound by -F, never interpolated into the query body
Query body single-quoted, SC2016 suppression declared per-line Safe
--stranded-days input: ^[0-9]+$ regex + $((10#$2)) octal guard Safe
--merged-json path: require_file validates -f before cat Safe
--argjson cutoff "$cutoff" — integer from shell arithmetic Safe
jq program is a static string — no API data interpolated into it Safe
"#\(.pr)" in truncation output — .pr is a GraphQL integer field Safe
`sev: (... .[1:]
`sort_by(.sev[1:] tonumber, .pr)— guarded byif .sev == "--"`
$((NOW_EPOCH - STRANDED_DAYS * 86400)) arithmetic Safe — validated integer, 64-bit bash range
Token / credential handling None in script — gh CLI handles auth internally
1dce962 (HEAD commit) — test file comment edit only No security surface

What was fixed in e561a35 (positive finding, confirmed)

The severity promotion via body-substring matching (test("P0|CRITICAL|SECURITY")) was a semantic integrity issue: any finding whose prose mentioned "CRITICAL infrastructure" would appear to the operator as a P0, eroding trust and masking real signals in noise. The fix commit replaced this with structured-marker-only extraction (![Px Badge], badge/Px URL, leading bracket), removing attacker-controlled prose from the severity signal path entirely. The test fixture ("![](x) Preserve P1 labels when CRITICAL SECURITY findings appear") confirms the promotion and demotion directions are both covered.

@kyle-sexton
kyle-sexton merged commit c7b0af7 into main Jul 30, 2026
31 checks passed
@kyle-sexton
kyle-sexton deleted the feat/1777-stranded-review-findings branch July 30, 2026 05:18
kyle-sexton added a commit that referenced this pull request Jul 31, 2026
…sent (#1851)

No linked issue

## Summary

Fixes two of the three defects re-verified in #1784 — the two stranded
#969 findings. Both were a
value that is *present but falsy* being read as *absent*.

**#1784 is deliberately NOT closed by this PR.** Its P1 (the lane-stop
gate reading its enable flag
off channel B) is untouched — see "Not addressed" below.

## Fix

**`plugins/autonomy/hooks/lane-stop-gate.sh` (0.11.7 → 0.11.8).** The
completion marker's one-shot
authorization was latched solely by deleting the file, and the marker
lives in the watched checkout —
a directory the hook is not guaranteed to be able to write. An `rm` the
OS refused left a file that
still satisfied `[[ -f "$MARKER" ]]` on the next run: exactly the
cross-run bypass that consuming the
marker exists to close. The surrounding comment asserted "the next run
must not rely on that stale
file" while nothing enforced it.

Consumption is now recorded in the plugin's own persistent data
directory — the marker path plus the
consumed file's identity (mtime and size) — and the deletion is the
tidy-up rather than the latch. A
marker recorded as consumed is not a signal however long it survives on
disk; a marker later
recreated has a new identity, so the stale record is dropped and the
fresh marker authorizes
normally.

Recreation recovery is **best-effort by design**, and the second commit
scopes the claim to what the
identity read actually resolves. Both portable `stat` dialects report
whole-second mtime, so a marker
recreated at the same size within the same second — an empty
`touch`-style marker being the realistic
case — is indistinguishable from the consumed one and stays latched
until the second turns over.
Sub-second (`%.9Y`) and inode spellings would narrow that window but are
GNU-only, and this identity
feeds a **gate**: the coarse read costs a stop delayed by under a
second, while a wrong "recreated"
verdict costs the unearned second authorization the ledger exists to
prevent. Delay is the correct
failure direction, so the portable spelling stands and the comment and
CHANGELOG say so rather than
implying recovery is guaranteed. A host where neither dialect reports an
identity holds the record
for the same reason.

The data directory is derived from the hook's **own install path** (the
`plugins/cache` anchor Claude
Code documents), falling back to `CLAUDE_PLUGIN_DATA` only for a
`--plugin-dir` install carrying no
such anchor: the script's own location is not something a watched
repository can redirect, whereas
`CLAUDE_PLUGIN_DATA` is an env value a repo `settings.json` `env` block
reaches. Where no data
directory can be written, the deletion remains the only latch — the
behavior that predates this
ledger.

**`plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh` (0.24.4 →
0.24.5).** Both field readers
used jq's `//` alternative operator, which fires on every FALSY value
rather than on absence. A lane
configured `"settings": false` yielded `empty`, reached bash as `""`,
and — because
`validate_launch_inputs` guards its "settings must be a JSON object"
check on `[[ -n "$settings" ]]` —
that type check never ran at all: the lane launched with `--settings`
silently omitted, no error,
nothing for the operator to see. `lane_json_field` now tests presence
with `has`, so `false` reaches
the type check and the lane is skipped with the error already written
for it. The scalar reader had
the same collapse for `name`/`model`/`effort`/`prompt` (a mistyped
`"effort": false` launched a lane
with no effort), so those fields are typed once at config time and a
non-string value is a config
error alongside the existing duplicate-name and path-traversal checks.
An explicit `null` stays the
JSON spelling of "no value" and remains equivalent to an absent field in
both readers.

## Test plan

Red-to-green proven for both defects by running the new cases against
the **pre-fix** sources checked
out from `origin/main` into a staged copy, then against the fixed
sources.

| Suite | Pre-fix (`origin/main` source) | Post-fix |
|---|---|---|
| `plugins/autonomy/hooks/lane-stop-gate.test.sh` | **1 fail** — `a
surviving consumed marker wrongly authorized a later run` | **0 fail**
(37 cases) |
| `plugins/claude-ops/skills/lanes/scripts/lane-launcher.test.sh` | **10
fail** — the 8 `boolean .name/.model/.effort/.prompt` cases plus
`settings:false reaches the type check`, `settings:false lane not
launched`, `settings:false surfaces a non-zero exit` | **0 fail** (144
cases) |

Case counts differ across the two columns for the gate suite because
cases 21b and 22–23 were added
*after* that red run; the red run is the marker regression alone.

Case 21b pins the same-second/same-size recreation boundary described
above, and reports which side
of the second it landed on rather than asserting a timing race — so a
future finer-grained identity
has to move that case deliberately.

**A defect the new coverage found.** The first draft of the marker
ledger tested only the
`CLAUDE_PLUGIN_DATA` fallback — never the install-path derivation the
tamper-resistance claim
actually rests on. Cases 22–23 stage the hook under a synthetic
`<root>/plugins/cache/<marketplace>/<name>/<version>/hooks/` tree with
an unrelated
`CLAUDE_PLUGIN_DATA` present, and they failed: `gate_data_dir` appended
`/plugins` twice, writing to
`<root>/plugins/plugins/data/<id>` instead of the documented
`<root>/plugins/data/<id>`. The layout
was checked against a real install on this machine
(`~/.claude/plugins/cache/melodic-software/autonomy/<ver>`
alongside `~/.claude/plugins/data/autonomy-melodic-software`) and the
path is corrected here.

Gates run locally against `origin/main` as base before pushing, all
green (and re-confirmed by the
full CI run on this PR):

- `scripts/check-changelog-parity.sh --check`, `--check-bump
origin/main`, `--check-order`
- `scripts/check-changed-skills.sh origin/main`
- `scripts/check-silent-skips.sh`
- `scripts/check-shell-portability.sh origin/main`
- `shellcheck --rcfile=.shellcheckrc` over all four changed shell files
- `markdownlint-cli2 --config .markdownlint-cli2.jsonc` over the two
CHANGELOGs and `config.md`
- Sibling suites unaffected and still green: `lane-notify`,
`machine-behavior`, `restart-consumer`,
  `telemetry-upsert`

## Not addressed

**#1784's P1 — the lane-stop gate reads its enable flag off channel B —
is not fixed here, and the
issue stays open for it.** `lane-stop-gate.sh` still reads
`CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ENABLED` from bare env, which
`docs/conventions/hook-config-delivery/README.md` rule 3 forbids for a
safety-critical
optional-with-default toggle (fact 4: an unconfigured key lets a repo's
own `settings.json` `env`
block populate it freely).

That fix is a channel-F reader, and the only shipped exemplar is Python
(`plugins/disk-hygiene/lib/killswitch_config.py`); a bash equivalent
needs the `${CLAUDE_PLUGIN_ROOT}`
anchor, the fixed-path managed-settings table plus `managed-settings.d/`
drop-ins, an explicit fail
direction, and an entry in the convention's Adopters table. That is its
own design and its own
security review, not a rider on this one. Acceptance criterion 1 of
#1784 therefore remains unmet;
criterion 5 is only partly reachable — two of the three #969 threads
become resolvable when this
lands, the channel-B thread does not. #1784 remains the tracker for
both.

## Related

Refs #1784
Refs #969
Refs #1777
Refs #1781

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Review findings that arrive after merge are stranded: the thread-resolution gate cannot see them

1 participant