Skip to content

Non-Blocking Ticket-Hygiene Reinforcement Hooks

ADR-0007: Non-Blocking Ticket-Hygiene Reinforcement Hooks

Section titled “ADR-0007: Non-Blocking Ticket-Hygiene Reinforcement Hooks”

Accepted

This workspace’s own global instructions already mandate strict lifecycle discipline for tracked work: a Projects v2 Status field that progresses Backlog/Todo through In Progress, In Review, and Done; a comment posted at every meaningful transition; and native sub-issue linkage for Epic/Story/Task hierarchies. That policy exists in prose today. Nothing reinforces it at the point an agent actually performs a touching action, and prose reminders are known to be forgotten mid-session, exactly the failure mode ADR-0003’s own investigation surfaced for board Status specifically.

The github-sdlc-plugins family (github-sdlc-planning, github-pull-requests, github-bug-capture, and siblings) already exposes MCP tools that create, update, and transition GitHub issues and pull requests. Those tools, the generic github MCP server, and raw gh CLI invocations via Bash are three structurally different surfaces by which an agent can touch the same GitHub issue or PR, and none of them was watched for hygiene drift after the fact before this ADR.

Relationship to ADR-0003 (read this before touching Status logic here)

Section titled “Relationship to ADR-0003 (read this before touching Status logic here)”

ADR-0003 already decided, and shipped, the following for board Status specifically:

  • The org project’s eleven built-in Projects v2 workflows own Todo-on-add and Done-on-close/merge. No tooling in this suite re-implements or races those transitions.
  • The one gap native automation leaves, In Progress before a PR exists, is closed by github-sdlc-planning’s hooks/lib/in-progress.mjs, a PostToolUse hook that mutates set_field_value via gh api graphql.
  • ADR-0003 explicitly could not confirm, via the public API, which Status value (if any) the built-in Pull request linked to issue workflow applies (ProjectV2Workflow exposes name/enabled only, not its configured target field value).

This ADR’s hook is advisory-only and never mutates Status, in contrast to ADR-0003’s hook, which does mutate (In Progress). Its status-progression check exists precisely because ADR-0003 left the Pull request linked to issue mapping unconfirmed: rather than assume it sets In Review, this hook observes the issue’s actual Status after a PR closing it is opened and surfaces a suggestion, never a mutation, when Status is not already In Review, Done, or Blocked. It never suggests Todo, In Progress, or Done — those remain exactly ADR-0003’s and native automation’s territory. Where ADR-0003 closed a gap by acting, this ADR closes a different gap (comment hygiene, sub-issue linkage, and an unconfirmed Status transition) by only ever suggesting.

  • Non-blocking is a hard constraint, not a preference. The originating goal (reports/github-ticket-hygiene-hooks/ in the research-harness workspace) states it directly: a hook that cannot mechanically verify a hygiene rule must stay silent, never guess, and never block the tool call it observed.
  • A hook process runs outside the MCP JSON-RPC session and cannot call set_field_value/list_sub_issues/other MCP tools directly; every read this hook performs goes through gh api graphql, the same fail-open pattern in-progress.mjs already established.
  • Hook scripts in this plugin family run dependency-free: no node_modules at hook-execution time, unlike the MCP servers, which share an npm workspace package.
  • PreToolUse/PostToolUse are the only events with access to a specific tool call’s tool_name/tool_input; Stop/SubagentStop carry only the common envelope (session_id, transcript_path, cwd).

A targeted prior-art search found no existing GitHub App, bot, or Claude Code plugin combining “fires on the agent’s own GitHub-touching tool call” with “nudges Projects v2 Status/sub-issue hygiene specifically.” The closest analogs, probot/stale and zeke/semantic-pull-requests, are comment-based and advisory like this design, but react to GitHub-side webhooks after the fact and so cannot distinguish which client (an MCP tool call vs. a raw gh CLI invocation) performed the touch the way an agent-side hook can.

  1. Never block. The originating goal’s hard constraint: this capability must reinforce discipline through suggestion only, never through a hard gate.
  2. Never duplicate ADR-0003. Whatever this hook checks about Status must be additive to, not a re-implementation or a race against, native Projects v2 automation and in-progress.mjs.
  3. Recognize the touch regardless of surface. A plugin’s own MCP tools, the generic github MCP server, and raw gh CLI calls are three observably different tool-call shapes; missing any one of them leaves a real gap in coverage 2026 agentic-development practice increasingly relies on (an agent freely choosing whichever surface is convenient).
  1. Fail open, independently, per check. One unresolvable check (a rate-limited GraphQL call) must not suppress a different, resolvable finding in the same hook invocation.
  2. Minimize reminder spam. A single turn touching the same issue five times should not produce five identical reminders.
  3. Minimize new dependency surface. Reuse in-progress.mjs’s injected- runGraphQL testing pattern and dependency-free hook style rather than inventing a new one.

Option 1: Non-blocking advisory hooks (chosen)

Section titled “Option 1: Non-blocking advisory hooks (chosen)”

Description: A PostToolUse hook registered under three matcher groups (a plugin’s own MCP tools, the generic github MCP server, raw gh CLI via Bash) runs three independent, best-effort checks and surfaces findings via hookSpecificOutput.additionalContext on a plain exit 0. A companion Stop/SubagentStop hook consolidates a turn’s findings into one reminder.

Technical Characteristics: No new blocking levers used (decision: "block", exit code 2 are both permanently off the table). Every check degrades to a silent no-op on any ambiguity.

Advantages: Satisfies every primary driver directly; matches 2026 AI-code-review practice converging on an “advisory-first, promote-to- gate-later” rollout norm, since false-positive fatigue erodes trust faster than a missed issue does (a pattern this session’s own research confirmed against DORA’s 2025/2026 State of AI-assisted Software Development data, which independently finds agentic-AI governance still industry-immature, making a hard gate the riskier default in this environment specifically).

Disadvantages: Cannot be the enforcement mechanism of last resort for a genuinely non-negotiable rule; a hard requirement still needs a human-owned or platform-native control (e.g. a required GitHub status check) layered on top, which this ADR does not attempt to provide.

Risk Assessment:

  • Technical Risk: Low. Reuses in-progress.mjs’s proven fail-open gh api graphql pattern; every check is independently try/caught.
  • Schedule Risk: Low.
  • Ecosystem Risk: Low. Purely additive; a disabled or misbehaving hook degrades to silence, never to a broken tool call.

Option 2: Hard-blocking hook (decision: "block" on a detected gap)

Section titled “Option 2: Hard-blocking hook (decision: "block" on a detected gap)”

Description: The same detection logic, but a detected gap blocks the triggering tool call (PostToolUse decision: "block", or a PreToolUse equivalent) until the agent resolves it.

Technical Characteristics: Requires the check to be certain, not best-effort, since a false positive now blocks real work, not just annoys.

Advantages: A confirmed gap could never silently go unaddressed, unlike an advisory reminder an agent is free to ignore.

Disadvantages: Directly violates the originating goal’s non-negotiable constraint. Every check here is inherently best-effort (a gh api graphql round trip that can rate-limit, a heuristic transcript scan for a lifecycle comment): blocking on a heuristic that can be wrong trades a missed reminder for a broken workflow, the exact false-positive-fatigue risk the 2026 advisory-first research consensus warns against.

Disqualifying Factor: violates the hard non-blocking constraint by definition; not compatible with this ADR’s mandate regardless of technical merit.

Risk Assessment:

  • Technical Risk: High. A heuristic false positive breaks a real tool call, not just an informational message.
  • Schedule Risk: Low.
  • Ecosystem Risk: High. Erodes trust in the hook system broadly, the documented 2026-practice risk this ADR’s research explicitly names.

Option 3: Do nothing; rely on the existing prose instructions

Section titled “Option 3: Do nothing; rely on the existing prose instructions”

Description: Leave the lifecycle-discipline policy as prose in CLAUDE.md-equivalent instructions, with no mechanical reinforcement at all.

Technical Characteristics: No new code.

Advantages: Zero implementation cost.

Disadvantages: This is the status quo the originating goal identifies as insufficient: prose reminders are known to be forgotten mid-session, the same failure mode ADR-0003’s own investigation already documented for Status specifically (before that ADR’s hook existed).

Disqualifying Factor: fails the problem statement by definition; has already been shown insufficient for the narrower Status-only case ADR-0003 addressed.

Risk Assessment:

  • Technical Risk: Low (no new code).
  • Schedule Risk: Low.
  • Ecosystem Risk: High. The discipline gap this ADR exists to close remains open indefinitely.

We adopt Option 1. Six constituent design decisions shape the implementation:

AD-1: Non-blocking, advisory-only output contract

Section titled “AD-1: Non-blocking, advisory-only output contract”

The hook may only ever speak through additionalContext/systemMessage on a plain exit 0; decision: "block" and exit code 2 are permanently off the table for this capability. Consequence: a genuinely non-negotiable hygiene rule still needs a human-owned or platform-native control layered on top; this hook is never that control.

AD-2: Tri-matcher registration instead of one broad regex

Section titled “AD-2: Tri-matcher registration instead of one broad regex”

Three separate matcher groups run the same script: a plugin’s own MCP tools (mcp__<plugin>__.*), the generic github MCP server (mcp__github__.*), and raw gh CLI calls via a Bash matcher with in-script command parsing. Consequence: a fourth tool surface in the future means adding a fourth matcher, not relying on an existing one to generalize.

AD-3: Stop/SubagentStop aggregator as a backstop, not the primary detector

Section titled “AD-3: Stop/SubagentStop aggregator as a backstop, not the primary detector”

Per-touch detection stays on PostToolUse, which writes a per-call signal to a session-scoped scratch file (hooks/lib/hygiene-scratch.mjs); Stop/SubagentStop only reads that file back and consolidates. Amended 2026-07-13 (issue #278): the aggregator now also re-runs one specific, already-existing detection function (scanTranscriptForComment) against the turn’s final transcript state, to drop a lifecycle-comment finding a later same-turn action already resolved – narrower than “the aggregator detects,” since it revalidates a signal PostToolUse already produced rather than deriving a new one, and touches no other finding kind. Consequence: the two hooks are still not redundant (removing PostToolUse loses the initial detection entirely; removing Stop/SubagentStop loses both turn-level deduplication and this revalidation), but the aggregator is no longer purely a passive scratch-file reader for this one finding kind.

AD-4: Copy-and-register distribution across sibling plugins

Section titled “AD-4: Copy-and-register distribution across sibling plugins”

github-sdlc-planning ships the canonical hooks/lib/hygiene-check.mjs, hooks/lib/hygiene-scratch.mjs, hooks/lib/hygiene-aggregate.mjs, hooks/hygiene-check.mjs, and hooks/hygiene-aggregate.mjs. github-pull-requests and github-bug-capture each ship a byte-identical copy under their own hooks/, registered in their own hooks.json scoped to their own plugin-tool-name matcher. A CI job (.github/workflows/ci.yml’s hygiene-hook-drift-check) diffs every sibling copy against the canonical version on every PR and fails on any drift. This is a deliberate departure from the wider industry norm of publish-once/import-by-reference (ESLint shareable configs, Danger.js plugins, GitHub Actions reusable workflows all converge on that pattern); it is not available here because hook scripts run with no node_modules at execution time, unlike the MCP servers, which already share an npm workspace package, and there is no single shared hooks.json a “core” capability automatically propagates from. Consequence: the drift-check CI job is load-bearing, not optional; without it this decision degrades silently into exactly the drift risk the shared-package pattern exists to prevent.

AD-5: Independent, best-effort degradation per check

Section titled “AD-5: Independent, best-effort degradation per check”

Status-progression, lifecycle-comment, and sub-issue-linkage each resolve or skip independently within one hook invocation (runHygieneChecks/Promise.allSettled in hooks/lib/hygiene-check.mjs). Consequence: the reminder text assembled per call is whichever checks actually resolved that call; a turn can receive a partial reminder, never an all-or-nothing one.

AD-6: Project-level registration as a non-exclusive alternative attachment point

Section titled “AD-6: Project-level registration as a non-exclusive alternative attachment point”

A consuming project’s own .claude/settings.json can register the identical matcher set against the identical script without touching any plugin manifest at all, since hook sources merge additively across policy, project, user, local, and every enabled plugin’s own hooks/hooks.json. Documented as a first-class option, not a fallback, in docs/how-to/register-hygiene-hook-at-project-level.md. Consequence: a consumer’s choice between plugin-shipped and project-registered depends on whether they want the reminder tied to the plugin’s release lifecycle or their own project’s; both attachment points can be active simultaneously without conflict.

  1. Closes the discipline gap ADR-0003 left outside its own scope. ADR-0003 only ever addressed board Status; lifecycle comments and sub-issue linkage had no mechanical reinforcement at all before this ADR.
  2. Surface-agnostic. An agent using a plugin’s own tools, the generic github MCP server, or raw gh CLI all get the same reminder, closing the gap AD-2 identifies in the pre-existing hooks.json (which matched only github-sdlc-planning’s own tool names).
  3. No new blocking failure mode. Every check fails open; a hook bug, a gh auth failure, or a rate limit degrades to silence, never to a broken tool call, by construction (Promise.allSettled inside runHygieneChecks, plus a top-level error handler in each entrypoint – hygiene-check.mjs’s main().catch(() => emitEmpty()) and hygiene-aggregate.mjs’s try { main(); } catch { emitEmpty(); }, the latter wrapping a plain synchronous main rather than an async one).
  1. Best-effort, not exhaustive. The lifecycle-comment check is deliberately over-inclusive about which actions count as “a transition” (any set_field_value/update_issue/create_issue call, since a bare fieldId does not identify which field changed without a second round trip this hook does not make). This is an accepted heuristic imprecision, not a violation of the “never guess” constraint, which governs the resolved/unresolved distinction between checks, not this heuristic’s own recall/precision trade-off.
  2. Copy-and-register duplication (AD-4) is a real maintenance cost, mitigated but not eliminated by the drift-check CI job: three copies of the same logic exist in the repository, and the drift check only fires on a PR, not continuously.
  1. This ADR does not change ADR-0003’s decision in any way. Native Projects v2 automation still owns Todo-on-add and Done-on-close/merge; in-progress.mjs still owns the In Progress mutation. This ADR adds a read-only, advisory layer alongside those, never a replacement.

The decision achieves its objective, non-blocking reinforcement across all three tool-agnostic surfaces, measured by: hooks/hygiene-check.mjs and hooks/hygiene-aggregate.mjs (and their lib/ modules) contain no process.exit call and no decision: "block" output anywhere; the hygiene-hook-drift-check CI job fails a PR that lets a sibling copy diverge from the canonical version; and mcp-server/test/unit/hygiene-check-hook.test.ts, hygiene-scratch-aggregate.test.ts, and hygiene-entrypoints.test.ts (the last spawning the entrypoint scripts directly, the same contract Claude Code itself uses) cover every check’s resolved, no-gap, and fail-open paths, including the entrypoints’ own crash-safety on malformed stdin – see the 2026-07-09 Audit entries below for the two local-review rounds that found and closed the gaps this coverage now guards.

  • Epic #156 — this ADR’s tracking epic.
  • reports/github-ticket-hygiene-hooks/github-ticket-hygiene-hooks.ai-architecture-doc.md (zircote/research-harness) — the originating architecture document this ADR’s AD-1 through AD-6 are drawn from.
  • Claude Code Docs: Hooks reference — event catalog, input schema, matcher syntax this hook’s registration relies on.
  • probot/stale, zeke/semantic-pull-requests — the closest prior-art analogs; both comment-based and advisory, neither agent-side.
  • Date: 2026-07-09
  • Source: Epic #156; the originating architecture document in zircote/research-harness.
  • Related ADRs: ADR-0003, ADR-0004, ADR-0005, ADR-0006.

Status: Compliant

Findings:

Finding Files Lines Assessment
Status-progression check never targets Todo/In Progress/Done, only In Review, reconciled explicitly against ADR-0003 plugins/github-sdlc-planning/hooks/lib/hygiene-check.mjs - compliant
Neither entrypoint calls process.exit or emits decision: “block” plugins/github-sdlc-planning/hooks/hygiene-check.mjs, hygiene-aggregate.mjs - compliant
Sibling copies verified byte-identical to canonical at authoring time; drift-check CI job added plugins/github-pull-requests/hooks/, plugins/github-bug-capture/hooks/, .github/workflows/ci.yml - compliant

Summary: Drafted and accepted in the same session the implementing Epic (#156) and its Stories/Tasks (#157-171) were filed and executed; no open objections to the advisory-only contract, the tri-matcher design, or the copy-and-register distribution model.

Action Required: None for this ADR.

Status: Compliant (with one filed follow-up)

Findings:

Finding Files Lines Assessment
Round 1: hygiene-aggregate.mjs‘s bare main() call could crash with a non-zero exit on a null-shaped stdin payload or a malformed scratch entry, contradicting this ADR’s own AD-1 claim; the gh CLI surface only recognized gh pr create, leaving checkLifecycleComment/checkSubIssueLinkage unreachable from gh issue create/edit/close, contradicting the surface-agnostic decision driver; a digit in a title/body could be mis-captured as the target issue number; checkLifecycleComment was called eagerly rather than deferred, risking silently discarding the other two checks’ findings on a synchronous throw plugins//hooks/hygiene-aggregate.mjs, plugins//hooks/lib/hygiene-check.mjs - fixed
Round 2: checkSubIssueLinkage fired on a close, contradicting its own documented “skips a close” behavior; the MCP branch only handled a flat-object tool_output, missing the MCP content-array wrapper shape a sibling hook (validate-mif.mjs) already handles for the same tool family; mcp__github__issue_write was miscategorized as a comment action, which is not this tool’s actual semantics (method: 'create'|'update') plugins/*/hooks/lib/hygiene-check.mjs - fixed
Round 3: scanTranscriptForComment read the entire session transcript unbounded on every qualifying touch, rather than a bounded tail window the way diagnostic-capture.mjs already does for the same class of file; this ADR’s own Audit/Decision-Outcome text had not been updated after rounds 1-2 landed plugins/*/hooks/lib/hygiene-check.mjs, this file - fixed
Round 3: checkLifecycleComment cannot resolve an issue/PR’s identity for a set_field_value touch, since that tool’s own input/output only ever carries itemId/fieldId, never owner/repo/number – the check is structurally unreachable for the single most direct way an agent changes a board Status field plugins/github-sdlc-planning/hooks/lib/hygiene-check.mjs - filed as a follow-up issue (requires a design decision – resolving itemId to issue coordinates needs an async GraphQL round trip inside what is currently a synchronous, dependency-free extractTouch), not fixed in this PR

Summary: Three independent local-review rounds ran against the implementing branch before the PR opened, per this workspace’s mandatory pre-PR review convention. Every finding that was a mechanical, in-scope fix landed in the same branch (regression-tested, including one test verified to fail against the pre-fix code and pass against the fix). The one finding requiring a real architecture decision (set_field_value touches carrying no issue identity) is filed as a tracked follow-up issue rather than decided unilaterally.

Action Required: Track and resolve the filed follow-up issue for the set_field_value identity gap.

Status: Compliant

Findings:

Finding Files Lines Assessment
Issue #172 (the set_field_value identity gap from the 2026-07-09 audit) resolved: checkLifecycleComment is now async and resolves a set_field_value touch’s itemId to owner/repo/number via a new resolveItemIdentity GraphQL round trip (node(id: itemId) { ... on ProjectV2Item { content { ... on Issue/PullRequest { number repository { owner { login } name } } } } }) before scanning for a lifecycle comment, failing open (no finding) on any ambiguity – a Draft Issue item with no linked content, a malformed response, or a GraphQL error – the same as every other unresolvable case in this file. extractTouch carries the bare itemId through as a passthrough field for set_field_value touches (still synchronous and dependency-free itself); only checkLifecycleComment performs the resolution, and only when it actually needs to. plugins/*/hooks/lib/hygiene-check.mjs - compliant

Summary: Of the three design options issue #172 weighed (an async GraphQL resolution inside checkLifecycleComment; a fragile scratch-file itemId lookup; permanently documenting the gap as out of scope), the GraphQL resolution was chosen and implemented, closing #172. Verified end-to-end (extraction through resolution through finding) and covered by new unit tests for resolveItemIdentity and for checkLifecycleComment’s set_field_value path (resolved-with-finding, resolved-no-finding, ambiguous-response fail-open, GraphQL-error fail-open, and no-itemId short-circuit).

Action Required: None; issue #172 is resolved.

Status: Compliant

Findings:

Finding Files Lines Assessment
Issue #278 resolved: buildConsolidatedContext replayed every PostToolUse-time scratch finding verbatim, even after a later same-turn action already resolved it – a lifecycle-comment finding is only ever true as of the instant its own checkLifecycleComment ran. Fixed by re-running scanTranscriptForComment against the turn’s final transcript state before reporting, dropping any lifecycle-comment finding that scan now resolves as found; every other finding kind is unaffected. Required amending AD-3 (above) since the aggregator is no longer a pure scratch-file reader for this one finding kind. plugins/*/hooks/lib/hygiene-aggregate.mjs - compliant
Round-2 review finding on the same PR: reusing scanTranscriptForComment at Stop time across a whole turn (rather than narrowly at PostToolUse, its original use) exposed a pre-existing gap in its Bash-comment match – it matched a `gh issue pr comment command by issue number alone, with no owner/repo check, so a same-turn comment against a same-numbered issue in a *different* repo could wrongly suppress a genuine reminder. Fixed by reusingextractTouch's existing parseGhRepoFlaghelper (exported for this purpose) to validate an explicit-R/–repo` flag against the finding’s own identity when one is present; a command with no such flag keeps the prior number-only match, since this scan has no cwd context to resolve an unflagged command’s real target repo and that case was never the one at issue. plugins/*/hooks/lib/hygiene-check.mjs -

Summary: Both findings trace to the same root cause: reusing an existing detection function in a new, broader context (turn-wide Stop-time revalidation) exposed an edge case that was safe in its original, narrower context (single-touch PostToolUse) but not in the new one. Both are fixed in the same PR, with regression tests proven to fail without each fix and a manual end-to-end reproduction against the real hook entrypoint. Propagated identically to github-pull-requests/github-bug-capture’s byte-identical copies per AD-4, verified against the exact diff logic hygiene-hook-drift-check runs.

Action Required: None; issue #278 is resolved.

Status: Compliant

Findings:

Finding Files Lines Assessment
Issue #289 resolved: scanTranscriptForComment’s line parser only ever read a bare entry.tool_name/entry.message.tool_name, a shape no real Claude Code session transcript line actually has – a real line’s tool calls live in message.content[] as one or more tool_use blocks keyed name/input. The bug report’s own framing (MCP comment tools “never match”) undersold the defect: verified against a real session transcript, the scan matched nothing at all, including a literal gh issue comment <N> Bash call, whenever it shared a line with another tool call in the same assistant turn – the dominant shape a paired comment-then-transition batch actually takes. Fixed by extracting every tool_use block from message.content[] (falling back to the old flat shape for the existing test fixtures, which model it), checking all of a line’s tool calls instead of assuming one. Variable-based gh invocations (e.g. gh issue comment $2 in a loop) remain a documented, statically-unresolvable limitation per the issue’s own suggested fix – not attempted here. plugins/*/hooks/lib/hygiene-check.mjs - fixed

Summary: Root-caused by replaying the exact false positive this hook produced against this session’s own live transcript, then confirming the scan returns found: false for it via the real (unmodified) function – proving the defect empirically rather than from the bug report’s description alone. Regression tests use a new real-shape transcript fixture (tmpRealTranscriptWith) distinct from the pre-existing flat-shape one, covering an MCP comment tool and a literal gh comment Bash call each sharing a transcript line with a sibling set_field_value tool_use block, plus a real-shape negative case. Propagated identically to github-pull-requests/github-bug-capture’s byte-identical copies per AD-4.

Action Required: None; issue #289 is resolved.

Status: Compliant

Findings:

Finding Files Lines Assessment
Issue #324 resolved (follow-up to #320/#323): scanTranscriptForComment is structurally blind to two cases no amount of parent-transcript scanning can fix – (1) a lifecycle comment posted by a background workflow subagent, whose tool calls execute and are logged in its OWN transcript, never the parent session’s; and (2) a same-turn parallel tool-call dispatch, where a comment call’s own tool_result may not yet be flushed to the transcript file at the instant a sibling set_field_value call’s PostToolUse hook fires and reads it, even though the underlying GitHub API call already completed. Fixed by adding checkRecentCommentViaGraphQL, a live GraphQL fallback checkLifecycleComment tries only when the transcript scan resolves but finds nothing: it queries the issue’s own recent comments directly from GitHub (bypassing the transcript file entirely) and treats any comment created within a 5-minute window of “now” as satisfying the transition. Wired into both checkLifecycleComment (the PostToolUse-time check) and isLifecycleFindingNowResolved (the Stop-time aggregator’s own re-validation, hygiene-aggregate.mjs’s lib) – the aggregator needed the same fallback in its own right, since a subagent-posted comment can never resolve via a parent-transcript re-scan no matter how long the turn runs. Deliberately over-inclusive (any recent comment, not just the acting user’s) per this hook’s existing false-negative-over-false-positive tolerance for an advisory nudge; fails open (no live confirmation, the pre-existing transcript-only finding stands) on a missing runGraphQL, a malformed response, or any thrown error. plugins//hooks/lib/hygiene-check.mjs, plugins//hooks/lib/hygiene-aggregate.mjs, plugins/*/hooks/hygiene-aggregate.mjs - fixed

Summary: Both gaps trace to the same structural limitation – scanTranscriptForComment can only ever see what’s written to ONE transcript file, and neither a subagent’s own transcript nor a not-yet-flushed same-turn write is guaranteed to be in it at the instant a check runs. Rather than attempting to read a subagent’s transcript directly (no stable, discoverable path to it exists from the parent’s own hook context) or forcing sequential tool-call dispatch (outside a hook’s control), both gaps are closed with the one signal that doesn’t depend on transcript timing at all: asking GitHub directly. hygiene-aggregate.mjs’s entrypoint gained the same runGraphQL wrapper hygiene-check.mjs’s entrypoint already had, and buildConsolidatedContext/ isLifecycleFindingNowResolved became async to accommodate it – the same kind of sync-to-async migration issue #172 already made to checkLifecycleComment itself. Regression tests cover: a comment invisible to the transcript scan but confirmed via a live GraphQL check (both the PostToolUse-time check and the Stop-time aggregator); no live comment found (finding still fires, unchanged from pre-#324 behavior); and the live check failing open (a thrown GraphQL error, a missing runGraphQL) without ever suppressing a genuine finding. Propagated identically to github-pull-requests/github-bug-capture’s byte-identical copies per AD-4.

Action Required: None; issue #324 is resolved.

2026-07-18 (PR #325 Copilot review follow-up)

Section titled “2026-07-18 (PR #325 Copilot review follow-up)”

Status: Compliant

Findings:

Finding Files Lines Assessment
checkRecentCommentViaGraphQL’s query only fetched repository.issue(number:). A Projects v2 tracked item’s content can be a PullRequest (resolveItemIdentity already handles both), so a PR-backed item’s number would resolve to a null issue field and the fallback could never live-confirm it – reintroducing, for PR-backed items specifically, the exact blind spot this PR exists to close. Fixed by adding a sibling pullRequest(number:) field to the same query (GraphQL resolves whichever type the number actually is; the other side comes back null) and merging both issue/pullRequest comments.nodes arrays before the recency check. plugins/*/hooks/lib/hygiene-check.mjs - fixed

Summary: Copilot’s review of PR #325 caught that the live-fallback query addressed only Issue-backed tracked items, not PR-backed ones, even though resolveItemIdentity (the function that produces the identity this fallback consumes) already treats both as first-class. Both issue and pullRequest are now queried by number in the same request; response parsing merges whichever side actually returned comment nodes (the other is null by construction, never both at once). Three new regression tests cover: a recent comment surfacing only via pullRequest.comments.nodes; a too-old comment on the pullRequest side; and the fail-open path when both issue and pullRequest resolve to null (item not found by either type). Propagated identically to github-pull-requests/ github-bug-capture’s byte-identical copies per AD-4.

Action Required: None; the gap Copilot flagged on PR #325 is resolved.