Skip to content

feat(bin): wake firstmate when the captain declines a PR by comment and close - #6

Merged
morris2spears merged 4 commits into
mainfrom
fm/firstmate-pr-decline-feedback
Jul 30, 2026
Merged

morris2spears merged 4 commits into
mainfrom
fm/firstmate-pr-decline-feedback

Conversation

@morris2spears

Copy link
Copy Markdown
Owner

Intent

The captain's real way of rejecting work on a pull request is to leave a plain GitHub comment and then close the PR unmerged - not GitHub's formal "Request changes" review. Nothing in firstmate read that signal, so his instruction only surfaced when somebody thought to look manually. He explicitly wants it picked up WITHOUT opening a firstmate session. Goal: make comment+close wake firstmate automatically, carrying the instruction, and have firstmate relay it to the same task worker and restart validation with no further approval prompt.

Deliberate decisions a reviewer reading only the diff would not know:

  1. Reuse, not a new mechanism. The task explicitly required extending the existing per-task PR poll (bin/fm-pr-check.sh, bin/fm-pr-lib.sh, bin/fm-pr-poll.sh) and its byte-static template + validated sidecar + registration contract, rather than inventing a separate unregistered check path. Changing bin/fm-pr-poll.sh changes its template hash, which invalidates polls armed by the previous release; that is intentional and safe because bin/fm-pr-check-migrate.sh already rebuilds canonical polls from recorded metadata at the next bootstrap. I verified that upgrade path empirically against a poll armed with the pre-change template bytes: it was rebuilt and re-armed with metadata preserved.

  2. Captain identity is the authenticated forge account (gh api user -q .login), deliberately NOT a new config file and not a hardcoded login. The task said to reuse an existing identity source; bootstrap already requires and validates GitHub auth and every forge action runs as that account. It is resolved fresh inside the poll rather than stored in the sidecar, specifically so the offline migration can still rebuild a poll from metadata alone and so there is no new tamperable field. gh 2.89 returns null for author.is_bot on pr view --json comments, so bot exclusion is by exact login match, not by that field - a bot such as github-actions can never equal the captain login. This is checked by test.

  3. The poll stays a pure read-only program. It has no durable state and reports the newest captain comment on EVERY cycle; the watcher owns the durable dedup record (state/.pr-decline-seen). This was chosen over letting the poll write, because the poll runs under a kill/timeout wrapper where a partial write is possible.

  4. Ordering is deliberately at-least-once, not at-most-once: the wake is durably queued FIRST and the seen record is written after. An interruption in between costs one repeated relay; the reverse order could silently drop the captain's instruction. A damaged, truncated, or wrong-mode seen record likewise reports "not delivered" and re-relays. Do not flag this as a dedup bug - losing the captain's word is the failure we are avoiding, and a repeat is loud and harmless. The record is bound to the PR URL as well as the comment id so re-arming a task on a different PR is never silenced by the old record.

  5. The wake payload carries identifiers only ("declined "), never the comment body, matching the existing tiny check-output convention (merged, x-mention ) and the X-mode precedent of stashing payload out of the wake line. Firstmate fetches the body from the forge when it handles the wake. The comment id is charset- and length-validated in three places because it reaches a wake reason line.

  6. A decline is deliberately NOT terminal: nothing retires, the poll stays armed, and it still retires normally on the merge that follows the revisions. Only an exact merged result retires, unchanged.

  7. Decline detection is GitHub-only on purpose, matching bin/fm-pr-merge.sh which also serves GitHub alone. Reading a comment author needs identity output plain glab does not expose, and firstmate deliberately does not require a JSON processor. Documented in docs/gitlab-merge-watch.md rather than left implicit.

  8. Relaying the comment is AUTONOMOUS - no captain approval, no yolo posture needed. That is the entire point of the request: the captain commenting and closing IS the decision. This deliberately does not touch the ask-user authority contract, which still owns every decision the worker's own pipeline discovers mid-run; the new skill states that boundary explicitly, and also carves out comment content that is out of scope or destructive/irreversible/security-sensitive as still needing the captain.

  9. Documentation placement follows the repo's own firstmate-coding-guidelines skill (loaded before starting, as the task required): AGENTS.md gets only the load trigger and one word in the check-wake line, because a decline is a check: result and not a new wake kind; the full handling procedure lives in the new agent-only skill .agents/skills/pr-decline-feedback/SKILL.md, registered in docs/documentation-audiences.json. docs/supervision-protocols/ was deliberately left unchanged - those files document per-harness arm/wait mechanism, not per-wake-kind handling.

Verification already done locally: bin/fm-lint.sh clean with pinned ShellCheck 0.11.0; bin/fm-doc-audience-check.sh ok; 47 changed-selected test scripts pass with 0 failures, including 3 new tests covering closed-not-merged vs closed-merged, bot/pipeline-authored comments, comment-seen-before dedup, a newer comment re-waking, identity-bound and malformed delivery records, and hostile comment identifiers; plus teardown cleanup of the new sidecar folded into the existing teardown test.

What Changed

  • bin/fm-pr-poll.sh now also detects a decline: on a PR that is closed without being merged, it resolves the captain as the authenticated forge account (gh api user -q .login) and reports declined <number> <comment-id> for the newest captain comment written within six hours of the close, so an incidental older comment cannot be relayed as the instruction. The poll stays read-only and non-terminal - only an exact merged result still retires it - and detection is GitHub-only, documented in docs/gitlab-merge-watch.md.
  • bin/fm-watch.sh owns the durable dedup: helpers added in bin/fm-pr-lib.sh parse the decline output and read/write state/<id>.pr-decline-seen, bound to both the PR URL and the comment id. The wake is queued first and the record written after, so an interruption costs one repeated relay rather than a lost instruction; a damaged or wrong-mode record re-relays. bin/fm-teardown.sh cleans up the new sidecar alongside the other poll artifacts.
  • Added the agent-only skill .agents/skills/pr-decline-feedback/SKILL.md (registered in docs/documentation-audiences.json) describing the autonomous relay to the same task worker, with the load trigger wired into AGENTS.md and docs/architecture.md; tests/fm-pr-check-security.test.sh gains coverage for closed-not-merged vs merged, bot-authored comments, once-only delivery, identity-bound and malformed records, and teardown cleanup.

Risk Assessment

✅ Low: The follow-up commit applies both accepted fixes exactly as directed - closing the only substantive gap from round 1 - without weakening any existing validation or the dedup contract, leaving only an informational test-fidelity note.

Testing

I ran the changed-selected PR/watcher suites (fm-pr-check-security plus teardown, watch-triage, wake-queue, documentation-audiences and pr-merge) and all passed with no failures. Because the suite's fake gh reimplements the six-hour comment window in bash rather than running the poll's real --json/-q filter, I additionally executed that filter through real jq against a GitHub-shaped payload and confirmed it drops a week-old comment, keeps in-window ones, picks the newest captain comment, and errors out (silent poll) on an unparseable closedAt. I then captured a full CLI transcript of the end-user flow with a fake gh serving real JSON through real jq: an open PR stays silent, the captain's comment plus an unmerged close produces declined 42 IC_kwDOreview1, one watcher cycle queues that wake durably and writes the private 0600 delivery record, the next cycle stays silent while a control path still runs, a newer captain comment wakes again, the poll remains armed, and a later merge retires it normally. Finally I verified the upgrade path the diff alone cannot show: a poll armed with the previous release's template bytes is correctly invalidated by the new template hash and is rebuilt, re-armed and decline-capable after bin/fm-pr-check-migrate.sh, with sidecar metadata preserved. This change has no rendered UI surface - it is shell tooling whose user-visible output is the poll line and the watcher wake line, so CLI transcripts are the end-user artifact rather than screenshots. The one thing not machine-verifiable is that firstmate relays the fetched comment to the worker without an approval prompt, since that is agent-instruction behavior in the new skill file; its registration is covered by the doc-audience test.

Evidence: End-to-end decline transcript (open -> comment+close -> wake -> dedup -> newer comment -> merge retires)

PR #42 is OPEN. Captain has commented, but has not closed it. poll output: [] <- silence: an open PR is not a decline Captain leaves the instruction and CLOSES the PR unmerged (github-actions also commented). poll output: [declined 42 IC_kwDOreview1] <- bot comment ignored, week-old comment ignored Watcher cycle -> firstmate is woken with the instruction, no session opened, no approval asked. stdout | check: .../state/task-a.check.sh: declined 42 IC_kwDOreview1 durable wake queue: 1785439805 1 check .../state/task-a.check.sh check: .../state/task-a.check.sh: declined 42 IC_kwDOreview1 delivery record task-a.pr-decline-seen (mode 600): fm-pr-decline-seen-v1 https://github.com/acme/widget/pull/42&#10;IC_kwDOreview1&#10;&#10;Next watcher cycle, same comment still on the closed PR -> no repeat wake. poll still says: [declined 42 IC_kwDOreview1] wake queue lines: 0 <- deduped by the delivery record Captain adds a NEWER comment -> that is a fresh instruction and wakes again. stdout | check: .../state/task-a.check.sh: declined 42 IC_kwDOreview2 durable wake queue: 1785439816 2 check .../state/task-a.check.sh check: .../state/task-a.check.sh: declined 42 IC_kwDOreview2 A decline is not terminal: the poll is still armed. poll artifacts still valid and armed: yes Crew reworks, captain merges -> the same poll retires normally. stdout | check: .../state/task-a.check.sh: merged poll artifacts after merge: removed

could not determine current branch: failed to run git: not on any branch

�[1mPR #42 is OPEN. Captain has commented, but has not closed it.�[0m
  poll output: []   <- silence: an open PR is not a decline

�[1mCaptain leaves the instruction and CLOSES the PR unmerged (github-actions also commented).�[0m
  poll output: [declined 42 IC_kwDOreview1]   <- bot comment ignored, week-old comment ignored

�[1mWatcher cycle -> firstmate is woken with the instruction, no session opened, no approval asked.�[0m
  stdout | check: /var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh: declined 42 IC_kwDOreview1
  durable wake queue:
    1785439805	1	check	/var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh	check: /var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh: declined 42 IC_kwDOreview1
  delivery record task-a.pr-decline-seen (mode 600):
    fm-pr-decline-seen-v1
    https://github.com/acme/widget/pull/42
    IC_kwDOreview1

�[1mNext watcher cycle, same comment still on the closed PR -> no repeat wake.�[0m
  poll still says: [declined 42 IC_kwDOreview1]
  wake queue lines: 0   <- deduped by the delivery record

�[1mCaptain adds a NEWER comment -> that is a fresh instruction and wakes again.�[0m
  stdout | check: /var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh: declined 42 IC_kwDOreview2
  durable wake queue:
    1785439816	2	check	/var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh	check: /var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh: declined 42 IC_kwDOreview2

�[1mA decline is not terminal: the poll is still armed.�[0m
  poll artifacts still valid and armed: yes

�[1mCrew reworks, captain merges -> the same poll retires normally.�[0m
  stdout | check: /var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T//fm-decline-e2e.ru8OOn/home/state/task-a.check.sh: merged
  poll artifacts after merge: removed
Evidence: Upgrade path: pre-change poll invalidated then rebuilt by fm-pr-check-migrate.sh

A poll armed by the previous release (old template bytes): old template sha : e87aae384ec62ae28f78a0779020f52f7b891ba0cafea2551b5a2c4e967870cf new template sha : 7f10147dae9bf123736d08e0bce7835fa441217e41d4bb25a99bea1a88a097ee armed check sha : e87aae384ec62ae28f78a0779020f52f7b891ba0cafea2551b5a2c4e967870cf validates against the NEW template: NO - the release invalidated it, as intended Next bootstrap runs bin/fm-pr-check-migrate.sh ... PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home After migration: rebuilt check sha: 7f10147dae9bf123736d08e0bce7835fa441217e41d4bb25a99bea1a88a097ee re-armed and valid against the new template: yes metadata preserved in the sidecar: github https://github.com/acme/widget/pull/42&#10;github.com&#10;acme/widget&#10;42&#10;&#10;The rebuilt poll detects the captain's decline: poll output: [declined 42 IC_kwDOreview1]


�[1mA poll armed by the previous release (old template bytes):�[0m
  old template sha : e87aae384ec62ae28f78a0779020f52f7b891ba0cafea2551b5a2c4e967870cf
  new template sha : 7f10147dae9bf123736d08e0bce7835fa441217e41d4bb25a99bea1a88a097ee
  armed check sha  : e87aae384ec62ae28f78a0779020f52f7b891ba0cafea2551b5a2c4e967870cf
  validates against the NEW template: NO - the release invalidated it, as intended

�[1mNext bootstrap runs bin/fm-pr-check-migrate.sh ...�[0m
  PR_CHECK_MIGRATION: canonical polls rebuilt and armed; resume supervision for this home

�[1mAfter migration:�[0m
  rebuilt check sha: 7f10147dae9bf123736d08e0bce7835fa441217e41d4bb25a99bea1a88a097ee
  re-armed and valid against the new template: yes
  metadata preserved in the sidecar:
    github
    https://github.com/acme/widget/pull/42
    github.com
    acme/widget
    42
  quarantined: task-a.check.RYcisY
  quarantined: task-a.data.qICWdt
  quarantined: task-a.diagnostic.canonical
  quarantined: task-a.registration.pM1xRu

�[1mThe rebuilt poll detects the captain's decline:�[0m
  poll output: [declined 42 IC_kwDOreview1]
Evidence: Poll's real jq filter run against a gh-shaped payload (real jq 1.8.1)
== real jq applied to gh 'pr view --json closedAt,comments' payload ==
github-actions IC_kwDOBot
capn IC_kwDOCaptain1
capn IC_kwDOCaptain2
github-actions IC_kwDOBot2
(the 10-day-old capn comment IC_kwDOAncient was dropped by the six-hour close window)

== poll's captain filter (captain=capn), newest wins ==
selected comment id: IC_kwDOCaptain2

== null closedAt (unparseable) -> query fails -> poll stays silent ==
jq: error (at <stdin>:1): strptime/1 requires string inputs and arguments
jq exit=5
Evidence: Decline test results from tests/fm-pr-check-security.test.sh
ok - closed-without-merge plus a captain comment is the only decline signal
ok - each captain comment on a declined pull request is delivered exactly once
ok - the delivery record and decline line refuse every malformed or foreign input

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-pr-poll.sh:98 - The decline signal is "CLOSED-not-merged AND any captain-authored comment exists" - there is no binding between the comment and the close. The poll selects the newest captain comment regardless of when it was written, and nothing compares it to the PR's closedAt. Concrete false positive: the captain leaves an incidental comment early on PR feat(spawn): add cb harness adapter for account-B crewmates kunchenguid/firstmate#40 ("looks good, merge once CI is green"), the work is later superseded and feat(spawn): add cb harness adapter for account-B crewmates kunchenguid/firstmate#40 is closed unmerged, the poll emits declined 40 &lt;old-comment-id&gt;, and per .agents/skills/pr-decline-feedback/SKILL.md the relay is explicitly autonomous - firstmate steers the worker to redo the delivery path off a stale, unrelated comment with no approval prompt. The only guard is prose (skill step 2, "sanity-check the author"), which does not catch a genuine but stale captain comment. gh pr view --json closedAt,comments already exposes .comments[].createdAt, so requiring the selected comment to be at or after the close would cost no extra API call. Flagging rather than fixing because the commit message defines the signal this way deliberately.
  • ℹ️ bin/fm-pr-poll.sh:98 - gh pr view --json comments fetches only the first 100 issue comments (gh's GraphQL query uses comments(first: 100)), oldest first. On a PR with more than 100 comments the captain's actual decline comment is outside the returned window, so the poll either stays silent or - worse, combined with the recency gap above - selects an old captain comment from the first 100 and relays it as the instruction. Worth a note in the poll's comment or a follow-up switch to a paginated/--json reversed query.
  • ℹ️ bin/fm-pr-lib.sh:903 - fm_pr_poll_retirement_recover_one removes the check, sidecar, registration, and receipt on merge, but not &lt;id&gt;.pr-decline-seen; only bin/fm-teardown.sh removes it. After a decline followed by the merge that retires the poll, the record lingers in state/ until teardown. Harmless for correctness (it is URL+comment bound so it can never silence a different PR or a different comment), but it does mean a retired task leaves a private artifact behind, and it makes validate_pr_poll_cleanup take the strict-validation path for an id whose poll artifacts are all gone.
  • ℹ️ bin/fm-pr-poll.sh:90 - A decline is deliberately non-terminal, so a PR that stays closed keeps the poll armed forever and each cycle now issues two extra GitHub API calls (gh api user plus the comments query) per closed task, indefinitely and even after the comment has already been delivered. Bounded and intentional given the non-terminal design, but on a home with several long-closed PR tasks and a short CHECK_INTERVAL this is steady, permanent API traffic that previously did not exist.

🔧 Fix: bind relayed decline comment to the PR close
1 info still open:

  • ℹ️ tests/fm-pr-check-security.test.sh:83 - The fake gh implements the six-hour window itself ([ &#34;${offset:-0}&#34; -ge -21600 ]) and prints the already-filtered &lt;login&gt;\t&lt;id&gt; lines, so the suite exercises the shell-side selection but never the real jq filter in bin/fm-pr-poll.sh:121. A typo in that expression - a wrong field name, a misplaced paren, fromdate vs fromdateiso8601 - would leave every test green while gh exits non-zero in production, and because the poll is fail-silent by design the whole decline feature would simply stop firing with no error anywhere. Inherent to a fake-forge harness and the author states the query was verified manually against gh 2.89, so noting it rather than asking for a change; a cheap guard if it ever matters is asserting the exact --json/-q argument string recorded in FM_TEST_GH_LOG.
✅ **Test** - passed

✅ No issues found.

  • bash bin/fm-test-run.sh tests/fm-pr-check-security.test.sh - full PR-poll/migration/teardown suite, exit 0, including the 3 new decline tests (test_declined_pull_request_detection, test_declined_comment_delivers_exactly_once, test_decline_delivery_record_is_identity_bound) and the extended test_teardown_removes_poll_artifacts
  • bash bin/fm-test-run.sh tests/fm-teardown.test.sh tests/fm-watch-triage.test.sh tests/fm-wake-queue.test.sh tests/fm-documentation-audiences.test.sh tests/fm-pr-merge.test.sh - adjacent suites for the watcher, wake queue, teardown and doc-audience registration, exit 0
  • Ran the poll's real query filter (.closedAt|fromdateiso8601) as $c | .comments[] | select((.createdAt|fromdateiso8601) &gt;= ($c - 21600)) | [.author.login, .id] | @tsv through real jq 1.8.1 against a gh pr view --json closedAt,comments-shaped payload (the suite's fake gh emulates this window in bash and never executes the filter): stale comment dropped, in-window comments kept, newest captain comment selected, null closedAt exits non-zero
  • Manual end-to-end transcript bash decline-e2e.sh - armed a canonical poll via fm_pr_poll_prepare/fm_pr_poll_publish_prepared with a fake gh backed by real JSON + real jq, then drove OPEN -> captain comment + CLOSED unmerged -> bin/fm-watch.sh cycle -> repeat cycle -> newer comment -> merge, inspecting .wake-queue, task-a.pr-decline-seen (mode 600) and poll artifact state at each step
  • Manual upgrade-path check bash upgrade-migration.sh - armed a poll with the base-commit bin/fm-pr-poll.sh bytes, confirmed it fails fm_pr_poll_artifacts_valid against the new template, ran bin/fm-pr-check-migrate.sh, confirmed the rebuilt poll matches the new template sha with sidecar metadata preserved and then detects a decline
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)

🔧 Fix: suppress SC2016 for jq program in fm-pr-poll.sh
1 warning still open:

  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

…nt and close

The captain declines work by leaving a comment on the pull request and
closing it unmerged, not by filing a formal "request changes" review.
Nothing read that signal, so the instruction only surfaced when someone
thought to look.

The existing per-task merge poll now also reports a GitHub pull request
that is closed without merging and carries a comment authored by the
account the home is authenticated to the forge as. That authenticated
account is the identity source, so a review bot, a CI bot, or a pipeline
comment can never be read as the captain speaking.

The poll stays a pure read-only program: it reports the newest captain
comment on every cycle, and the watcher's private <id>.pr-decline-seen
record turns those repeats into one relay. The record is bound to the
pull request as well as the comment, and is written only after the wake
is durably queued, so an interruption costs a repeated relay rather than
a dropped instruction. A damaged record fails the same direction.

A decline is not terminal: nothing retires, the watch stays armed, and it
still retires on the merge that follows the revisions.

The new pr-decline-feedback skill owns the handling procedure and the
authority boundary. Relaying the comment is autonomous because the
comment plus the close is the decision; the ask-user contract for
decisions the pipeline discovers itself is untouched.

Decline detection is GitHub-only, matching fm-pr-merge.sh: reading a
comment author needs identity output plain glab does not expose.

Changing the poll template changes its hash, so polls armed by the
previous release fail validation and the existing non-executing migration
rebuilds them from recorded metadata at the next bootstrap.
@morris2spears
morris2spears merged commit e645d1e into main Jul 30, 2026
10 checks passed
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.

1 participant