Skip to content
Merged
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,27 @@ calling it a regression, and treat CI as authoritative.**
sequencing in "Release channel tags & the mutable-ref exception" →
["Caller-stub input forwarding across channel pins"](#caller-stub-input-forwarding-across-channel-pins):
never forward an input the pinned channel does not yet declare.
- **dev-lead ingests the issue's human comments, not just its title/body (#1566).**
`dev-lead-fix-issue.sh` fetches `issues/<n>/comments` and renders them into the
`ISSUE_COMMENTS` prompt variable via the pure renderer `scripts/lib/issue-comments.sh`
(tests: `tests/dev-lead/unit/test_issue_comments.bats`). The prompt template treats
comments as **refinements that may supersede the body**, read in chronological order,
with later **human** comments outranking the body and earlier comments. This is how a
maintainer steers an in-flight issue: post a comment answering an open question or
correcting a wrong assumption, and it reaches the engine. **Only comments from users with
repository write access are surfaced** — the renderer keeps `author_association` of
`OWNER`, `MEMBER`, or `COLLABORATOR` and drops everyone else (`CONTRIBUTOR`, `NONE`, …),
so a drive-by outside commenter cannot redirect an implementation. **Also deliberately
excluded from the rendered block:** `Bot`-type authors (CI bots, third-party reviewers)
and dev-lead's own automation comments — any comment whose body *begins* with an
`<!-- dev-lead…` HTML marker or a `## Dev-Lead …` plan/progress/completion heading is
treated as automation and dropped (the match is anchored to the start of the body,
regardless of author), so automation chatter never crowds out human steering. The block is
**size-bounded** (`ISSUE_COMMENTS_MAX`, `ISSUE_COMMENTS_CHAR_BUDGET`) applied newest-first
so the freshest steering always survives; the character budget is a **hard bound** — if
the single newest comment alone exceeds it, that comment is **truncated** to the budget
rather than emitted in full. Any comments dropped, and any truncation, are **counted and
stated** in the block, never silently discarded.
- All other workflow changes must use templates from
[`standards/workflows/`](https://github.com/petry-projects/.github/tree/main/standards/workflows) verbatim.
- **Note:** `.github/workflows/auto-rebase.yml` pins the reusable workflow at the `@auto-rebase/v2-next`
Expand Down
19 changes: 18 additions & 1 deletion prompts/dev-lead/fix-issue.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- VARIABLES: ISSUE_NUMBER, ISSUE_URL, REPO, ISSUE_TITLE, ISSUE_BODY, ORG_STANDARDS_HINT, LINT_SCRIPT -->
<!-- VARIABLES: ISSUE_NUMBER, ISSUE_URL, REPO, ISSUE_TITLE, ISSUE_BODY, ISSUE_COMMENTS, ORG_STANDARDS_HINT, LINT_SCRIPT -->
# Dev-Lead Agent: Implement Issue

You are the dev-lead agent for the `${REPO}` repository. You have been assigned to implement a GitHub issue.
Expand All @@ -15,6 +15,23 @@ You are the dev-lead agent for the `${REPO}` repository. You have been assigned
${ISSUE_BODY}
```

## Issue Comments

> **Comments are refinements that may supersede the body above.** Read them in
> chronological order and treat later **human** comments as outranking the body
> and any earlier comment when they conflict — a comment can answer an open
> question, correct a wrong assumption in the body, narrow the scope, or change
> the intended approach. Research, clarifications, and answers posted here are
> authoritative: **do not conclude that a question is unanswerable if a comment
> answers it.** Only comments from users with **write access** to the repository
> (owners, members, collaborators) are shown here — comments from outside or
> drive-by accounts are filtered out upstream, so every comment in this block
> comes from a trusted maintainer. Bot comments and dev-lead's own status comments
> are excluded below; if the block notes that older comments were omitted for
> length, read the full thread on the issue before deciding a detail is missing.

${ISSUE_COMMENTS}

## Execution Phases

Work through each phase in order. **Do not skip phases.**
Expand Down
11 changes: 10 additions & 1 deletion scripts/dev-lead-fix-issue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ source "$(dirname "$0")/lib/premature-closure-detect.sh"
# required description sections so the PR is not escalated by triage for a
# description gap.
source "$(dirname "$0")/lib/dev-lead-pr-body.sh"
# Pure comment renderer (#1566): render_issue_comments filters + size-bounds the
# issue's comments into the ISSUE_COMMENTS prompt variable.
source "$(dirname "$0")/lib/issue-comments.sh"
# net_diff_is_empty / net_diff_summary (#1786): refuse to open/claim a PR whose
# three-dot net diff against base is empty.
source "$(dirname "$0")/lib/net-diff-guard.sh"
Expand Down Expand Up @@ -561,8 +564,14 @@ main() {
export REPO
ISSUE_TITLE=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq '.title' 2>/dev/null || echo "Unknown")
ISSUE_BODY=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq '.body // ""' 2>/dev/null || echo "")
# Issue comments (#1566): human clarifications/answers posted after filing must
# reach the prompt. render_issue_comments filters bot/dev-lead noise, keeps the
# rest chronological, and size-bounds them. `|| ISSUE_COMMENTS=""` keeps a gh or
# jq failure from aborting under set -e/pipefail — a missing comment block just
# degrades to the body-only prompt.
ISSUE_COMMENTS=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUMBER}/comments?per_page=100" 2>/dev/null | render_issue_comments) || ISSUE_COMMENTS=""
ORG_STANDARDS_HINT="See AGENTS.md and docs/ for coding standards."
export ISSUE_TITLE ISSUE_BODY ORG_STANDARDS_HINT
export ISSUE_TITLE ISSUE_BODY ISSUE_COMMENTS ORG_STANDARDS_HINT
# Export lint script path so it is substituted into the rendered prompt.
# dirname "$0" resolves correctly in both direct (scripts/) and reusable (.dev-lead/scripts/) runs.
export LINT_SCRIPT="${LINT_SCRIPT:-"$(dirname "$0")/dev-lead-lint.sh"}"
Expand Down
152 changes: 152 additions & 0 deletions scripts/lib/issue-comments.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# issue-comments.sh — render a work-issue's comments for the dev-lead
# implementation prompt (#1566).
#
# Before #1566, dev-lead-fix-issue.sh exported only the issue TITLE and BODY into
# the rendered prompt; comments never reached the engine, so any clarification,
# correction, or answer posted after filing was silently discarded (see #1566 for
# the observed #637 failure). These PURE functions take the (possibly paginated)
# `issues/<n>/comments` JSON on stdin and emit a filtered, chronological,
# size-bounded markdown block the caller exports as ISSUE_COMMENTS. Fetching the
# comments is the caller's job (dev-lead-fix-issue.sh).
#
# It is a set of PURE functions with NO network I/O and NO top-level side effects
# beyond default-value assignment, so it can be sourced by both the driver and its
# unit tests (tests/dev-lead/unit/test_issue_comments.bats). It does NOT call
# `set -euo pipefail` — sourced-library exception: adding it would change the
# caller's shell options (mirroring the approved convention in comment-noise.sh /
# persona-runner.sh; see AGENTS.md Bash guideline note).

# Size bounds (overridable via env for tuning/tests). A long thread must never
# blow the context window (#1566 AC #4), and whatever is dropped is STATED in the
# rendered block rather than silently discarded.
: "${ISSUE_COMMENTS_MAX:=20}" # keep at most the most-recent N human comments
: "${ISSUE_COMMENTS_CHAR_BUDGET:=16000}" # total character budget across kept comment bodies

# ic_noise_pattern — ERE identifying a comment that is dev-lead's own automation
# chatter rather than human steering (#1566 AC #3). Two shapes, anchored to the
# start of the body so a human quoting them mid-comment is not misclassified:
# ^\s*<!-- *dev-lead — every dev-lead HTML marker family (issue status,
# dedup, defer, lint-failed, retraction, completion).
# ^\s*## *Dev-Lead — the plan / progress / completion headings dev-lead
# posts (these carry no HTML marker but always lead with
# this heading), so they never crowd out human comments.
ic_noise_pattern() {
printf '%s' '^[[:space:]]*(<!-- *dev-lead|## *Dev-Lead)'
}

# _ic_filter_human: read the paginated comments JSON on stdin; emit one
# base64-encoded {login,created_at,body} object per SURVIVING comment, in
# chronological (API) order. Drops Bot-type authors, comments from users WITHOUT
# repository write access (#1800: only author_association OWNER/MEMBER/COLLABORATOR
# is trusted — a drive-by CONTRIBUTOR/NONE commenter must not redirect the
# implementation), dev-lead automation comments (ic_noise_pattern), and empty
# bodies. author_association comes from the GitHub API (not attacker-controlled)
# and is always present on real comments; it is absent only in synthetic test
# input, where it defaults to OWNER so the body-only degradation path is
# unaffected. jq's `-s` slurps every page into one array; `[.[] | .[]?]` flattens
# both the single-array and paginated (array-per-page) shapes, mirroring
# count_prior_attempts in the driver.
_ic_filter_human() {
jq -s -r --arg noise "$(ic_noise_pattern)" '
[ .[] | .[]? ]
| map(select(type == "object"))
| map(select((.body // "") != ""))
| map(select(((.user.type?) // "") != "Bot"))
| map(select(
(((.author_association?) // "OWNER") | ascii_upcase) as $aa
| ($aa == "OWNER" or $aa == "MEMBER" or $aa == "COLLABORATOR")))
| map(select((.body // "") | test($noise) | not))
| .[]
| { login: (.user.login? // "unknown"),
created_at: (.created_at // ""),
body: (.body // "") }
| @base64
' 2>/dev/null || true
}

# render_issue_comments: read the paginated comments JSON on stdin; print the
# rendered markdown block (or an explicit "no human comments" note — never
# nothing). Honors ISSUE_COMMENTS_MAX (most-recent-N) then ISSUE_COMMENTS_CHAR_BUDGET
# (applied newest-first so the freshest steering always survives). Any comment
# dropped by either bound is COUNTED and the omission is stated (#1566 AC #4).
render_issue_comments() {
local -a rows=()
local line
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] && rows+=( "$line" )
done < <(_ic_filter_human)

local total=${#rows[@]}
if [ "$total" -eq 0 ]; then
printf '_No human comments on this issue — only the title and body above are available. (Bot and dev-lead status comments are excluded.)_\n'
return 0
fi

# 1) Most-recent-N window. Comments are chronological, so keep the tail.
local start=0
if [ "$total" -gt "$ISSUE_COMMENTS_MAX" ]; then
start=$(( total - ISSUE_COMMENTS_MAX ))
fi

# 2) Character budget over that window, applied newest→oldest so the latest
# (highest-priority) steering is never the comment that gets dropped. The
# budget is a HARD bound (#1800): if the single newest comment alone exceeds
# it, that comment's body is TRUNCATED to the budget (and the truncation is
# stated) rather than emitted in full, so the rendered block can never blow
# the documented prompt-size limit.
local -a keep_idx=()
local used=0 i obj body blen
local newest_trunc=0 # chars the newest comment's body was truncated to (0 = untruncated)
for (( i = total - 1; i >= start; i-- )); do
obj=$(printf '%s' "${rows[$i]}" | base64 -d 2>/dev/null) || continue
body=$(printf '%s' "$obj" | jq -r '.body' 2>/dev/null) || continue
blen=${#body}
if [ "${#keep_idx[@]}" -eq 0 ]; then
# Newest comment always survives, but is truncated to the budget if it
# alone is oversized — otherwise the budget would not be a hard bound.
if [ "$blen" -gt "$ISSUE_COMMENTS_CHAR_BUDGET" ]; then
newest_trunc=$ISSUE_COMMENTS_CHAR_BUDGET
used=$ISSUE_COMMENTS_CHAR_BUDGET
else
used=$blen
fi
keep_idx+=( "$i" )
continue
fi
if [ $(( used + blen )) -gt "$ISSUE_COMMENTS_CHAR_BUDGET" ]; then
break
fi
used=$(( used + blen ))
keep_idx+=( "$i" )
done

local kept=${#keep_idx[@]}
local omitted=$(( total - kept ))
local newest_idx=${keep_idx[0]}

# Header +, when anything was dropped or truncated, an explicit note.
printf '### Issue comments (%d shown, chronological — later human comments take precedence)\n\n' "$kept"
if [ "$omitted" -gt 0 ]; then
printf '> _Note: this issue has %d human comment(s); showing only the most recent %d to fit the prompt budget. %d earlier human comment(s) were omitted — read them on the issue if needed. Bot and dev-lead status comments are excluded from this list._\n\n' \
"$total" "$kept" "$omitted"
fi
if [ "$newest_trunc" -gt 0 ]; then
printf '> _Note: the most recent comment exceeded the %d-character budget and was truncated to fit — read the full comment on the issue._\n\n' \
"$ISSUE_COMMENTS_CHAR_BUDGET"
fi

# Emit kept comments in chronological order (keep_idx is newest-first).
local login created
for (( i = kept - 1; i >= 0; i-- )); do
local idx=${keep_idx[$i]}
obj=$(printf '%s' "${rows[$idx]}" | base64 -d 2>/dev/null) || continue
login=$(printf '%s' "$obj" | jq -r '.login' 2>/dev/null) || login="unknown"
created=$(printf '%s' "$obj" | jq -r '.created_at' 2>/dev/null) || created=""
body=$(printf '%s' "$obj" | jq -r '.body' 2>/dev/null) || body=""
if [ "$newest_trunc" -gt 0 ] && [ "$idx" -eq "$newest_idx" ]; then
body="${body:0:$newest_trunc}"$'\n\n_[Comment truncated to fit the prompt budget — read the full comment on the issue.]_'
fi
printf '#### @%s — %s\n\n%s\n\n' "$login" "$created" "$body"
done
}
69 changes: 69 additions & 0 deletions tests/dev-lead/unit/test_fix_issue.bats
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"

# Pin the prompt template to the in-repo source so an ambient PROMPTS_DIR
# (e.g. a vendored .dev-lead/ runtime copy) cannot shadow it (#1566).
export PROMPTS_DIR="$SCRIPT_DIR/prompts/dev-lead"

# Default env
export ISSUE_NUMBER="100"
export REPO="petry-projects/.github-private"
Expand Down Expand Up @@ -1134,6 +1138,71 @@ GHEOF
[ ! -f "$RETRACT_FILE" ]
}

# ── issue comments reach the prompt (#1566) ───────────────────────────────────

@test "fix-issue: a comment answering the body question reaches the rendered prompt (#1566 AC1/AC5)" {
# The engine receives the rendered prompt on stdin; capture it to a file.
PROMPT_CAPTURE="$STUB_BIN_DIR/prompt_capture"
cat > "$STUB_BIN_DIR/claude" <<CLAUDEEOF
#!/usr/bin/env bash
cat > "$PROMPT_CAPTURE"
echo "stub engine response"
exit 0
CLAUDEEOF
chmod +x "$STUB_BIN_DIR/claude"

cat > "$STUB_BIN_DIR/dev-lead-lint.sh" <<'LINTEOF'
#!/usr/bin/env bash
exit 0
LINTEOF
chmod +x "$STUB_BIN_DIR/dev-lead-lint.sh"

cat > "$STUB_BIN_DIR/git" <<'GITEOF'
#!/usr/bin/env bash
case "$*" in
"status --porcelain") echo "M scripts/foo.sh" ;;
"rev-parse HEAD") echo "deadbeef1234" ;;
*) exit 0 ;;
esac
GITEOF
chmod +x "$STUB_BIN_DIR/git"

# gh stub: issue body poses a question; a human comment answers it.
cat > "$STUB_BIN_DIR/gh" <<'GHEOF'
#!/usr/bin/env bash
cmd="$1"; shift || true
case "$cmd" in
pr) case "$*" in create*) echo "https://github.com/petry-projects/.github-private/pull/42" ;; *) exit 0 ;; esac ;;
label) exit 0 ;;
api)
case "$*" in
*"pulls?state=open"*) echo "0" ;;
*comments*) echo '[{"user":{"login":"alice","type":"User"},"created_at":"2026-08-21T01:14:00Z","body":"ANSWER: the telemetry source is the oauth/usage endpoint."}]' ;;
*"users/"*) echo '{"id":12345}' ;;
*"issues/"*) echo '{"title":"Test","body":"QUESTION: what is the telemetry source?"}' ;;
*) echo "{}" ;;
esac ;;
issue) exit 0 ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"

export DEV_LEAD_DRY_RUN="false"
export LINT_SCRIPT="$STUB_BIN_DIR/dev-lead-lint.sh"
export GITHUB_RUN_ID="99"

run bash "$FIX_ISSUE_SCRIPT"

[ "$status" -eq 0 ]
[ -f "$PROMPT_CAPTURE" ]
local prompt; prompt=$(cat "$PROMPT_CAPTURE")
# The comment's answer must be present in the prompt the engine received.
[[ "$prompt" == *"ANSWER: the telemetry source is the oauth/usage endpoint."* ]]
# And it must be framed as a refinement that can supersede the body.
[[ "$prompt" == *"supersede"* ]]
}

@test "fix-issue: opened PR is labeled auto-rebase:ready (breaks #711 review-ready deadlock)" {
# Lint passes
cat > "$STUB_BIN_DIR/dev-lead-lint.sh" <<'LINTEOF'
Expand Down
Loading
Loading