diff --git a/AGENTS.md b/AGENTS.md index 9b6ce05f5..4c0204fa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,35 @@ See [`shared/workflow/check-before-pushing.md`](shared/workflow/check-before-pus When printing a status recap or summary, include a timestamp in the user's local time zone (Pacific Time, `America/Los_Angeles` --- get it from `TZ=America/Los_Angeles date "+%Y-%m-%d %H:%M %Z"`). Each reading expires immediately: run the command fresh for every recap rather than extrapolating elapsed time from a prior reading. +## Every comment you post to a forge says an agent posted it + +See [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). + +An agent driving `gh`/`glab` under the account holder's credentials posts as **that person**: their login, their avatar, a `MEMBER` association, and `type: User`. +Nothing in the API distinguishes such a comment from one they typed, so a reader deciding how much weight to give a claim, a status note, or a review has no way to tell which they are reading. +The forge cannot say it; the body must. + +End every comment an agent posts with this line, on its own, after a blank line: + +``` +_Posted by Claude Code (AI agent) --- not written by a human._ +``` + +Substitute your own agent's name where you are not Claude Code, and keep the rest of the line verbatim so one query finds every disclosed comment. +Check the substituted **name** against `REVIEW_BODY_MARKERS` as well as a replacement marker: `code review` is one of its entries, so an agent named for code review would reintroduce through its own name the false-clean the emoji ban exists to prevent. + +The marker deliberately contains **no robot emoji**: [`scripts/check-pr-fully-clean.py`](scripts/check-pr-fully-clean.py) matches that emoji as a review-body marker, so a disclosed claim comment would be admitted into the fully-clean verdict scan as a finding-free review. +Check any replacement marker against that script's `REVIEW_BODY_MARKERS` and `REVIEW_AGENT_MARKERS` before adopting it. + +Scope: comment bodies, on every surface --- claims, releases, status notes, review replies, self-reviews, issue comments filed on the user's behalf. +Not commit messages, not titles, not issue bodies, not PR bodies, each of which has its own attribution convention. +Two exemptions. +A comment another machine parses as a command (`@dependabot rebase`), where the test is the audience rather than the length. +And a comment posted under a genuine bot token, where the forge already reports `type: Bot` and the marker adds nothing. + +- **Do:** append the marker to every agent-posted comment, including ones whose prose already identifies the session. +- **Don't:** use the robot emoji in the marker, and don't read "the account holder knows an agent is running" as making the disclosure unnecessary --- the reader is whoever finds the thread later. + ## File formatting & links - Use GitHub-style markdown for all responses and documentation. @@ -179,15 +208,10 @@ Each reading expires immediately: run the command fresh for every recap rather t ## Deliver completed implementation work -When asked to implement, edit, or write up a change on a feature branch, do -not stop at an uncommitted worktree. -Complete the delivery cycle: create the applicable tracking issue when -issue-first workflow applies, commit the scoped changes, run local -adversarial self-review to a clean verdict, push the branch, open or update -its Pull Request, request AI review after the final push, and drive CI and -review findings to a clean result. -This does not grant merge authority; the strict merge policy below still -applies. +When asked to implement, edit, or write up a change on a feature branch, do not stop at an uncommitted worktree. +Complete the delivery cycle: create the applicable tracking issue when issue-first workflow applies, commit the scoped changes, run local adversarial self-review to a clean verdict, push the branch, open or update its Pull Request, request AI review after the final push, and drive CI and review findings to a clean result. +This does not grant merge authority. +The strict merge policy below still applies. ## Every self-review is an adversarial review by a separate subagent diff --git a/CLAUDE.md b/CLAUDE.md index fa184d841..cfeee4586 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -567,10 +567,13 @@ A `CHANGES_REQUESTED` state is blocking regardless of whether an automated re-re When the user gives feedback, corrections, or guidance in the CLI or chat while working a PR, paraphrase it and post it as a PR comment: ``` -gh pr comment --body "..." +gh pr comment --body " + +_Posted by Claude Code (AI agent) --- not written by a human._" ``` One to three sentences is enough. +The trailing marker is required, per the section above: this comment paraphrases the user in the user's own voice under the user's own login, which is the shape most easily read as their own writing. Don't quote verbatim — paraphrase so it reads naturally in the PR thread. Skip trivial acknowledgments or conversational exchanges with nothing to act on. @@ -607,15 +610,28 @@ Re-derive it from a live query rather than trusting the earlier verdict. The `claim-pr` skill operationalizes this (the exact claim wording, when it applies, and the closing/unclaim comment). +## Every comment you post to a forge says an agent posted it + +[shared/workflow/disclose-agent-authorship.md](shared/workflow/disclose-agent-authorship.md) + +A comment posted through `gh`/`glab` under the account holder's credentials carries **their** login and reads as `type: User`, so nothing in the API distinguishes it from a comment they typed --- `memories/github.md` records auditors mistaking exactly that. +The forge cannot say it, so the body must: end every agent-posted comment with + +``` +_Posted by Claude Code (AI agent) --- not written by a human._ +``` + +The marker deliberately avoids the robot emoji, which `scripts/check-pr-fully-clean.py` matches as a `REVIEW_BODY_MARKERS` entry --- a disclosed claim comment would otherwise scan as a finding-free **review**. +The fragment carries the rest: the two exemptions, the comment-bodies-only scope, and the queries that verify a marker or a bot identity. + +- **Do:** append the marker to every claim, release, status, reply, and self-review comment, including ones whose prose already names the session. +- **Don't:** use the robot emoji in it, or put it in a commit message, a title, an issue body, or a PR body. + ## Read a repo's canonical contributor doc before starting work, not just before pushing [shared/workflow/read-canonical-doc-before-starting.md](shared/workflow/read-canonical-doc-before-starting.md) -When a short `CLAUDE.md` names a fuller document as the actual authority ---- `.github/copilot-instructions.md`, `CONTRIBUTING.md`, a linked style -guide --- read that document before the first edit, and front-load its -pre-PR requirements into the first commit rather than discovering them via -a red CI check. +When a short `CLAUDE.md` names a fuller document as the actual authority --- `.github/copilot-instructions.md`, `CONTRIBUTING.md`, a linked style guide --- read that document before the first edit, and front-load its pre-PR requirements into the first commit rather than discovering them via a red CI check. ## Open a PR immediately after claiming an issue diff --git a/README.md b/README.md index f71987dc0..81a9e6b21 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,7 @@ the rule is consulted when it is *read* and broken when a message is | `warn-status-read-after-pipe.py` | `PreToolUse` (Bash) | warns, never blocks, when an expandable `$?` sits in the segment immediately after a pipeline carrying no `pipefail`, so the status read belongs to the trailing formatter rather than to the command. Excludes single-quoted spans, heredoc bodies and `#` comments so the corpus's own documentation of the bug cannot trip it, which also means a `bash -c '...'` one-liner gets no warning | | `no-push-without-self-review.py` | `PreToolUse` (Bash) | blocks `git push` unless a separate `adversarial-reviewer` subagent returned a clean verdict as its own call result AND that report's `Reviewed-Commit:` fingerprint matches the commits the push would ship (refspec resolved), or the push itself is prefixed with `ALLOW_UNREVIEWED_PUSH=1`; a verdict quoted anywhere else --- in another file, or in this guard's own denial --- does not count | | `flag-uncited-rebuttal.py` | `PreToolUse` (Bash) | warns, never blocks, when a PR/issue comment about to be posted disputes a finding whose most recently fetched citation named an external URL that no earlier `WebFetch`/`WebSearch` in the transcript touched -- ai-config#2070's wrong rebuttal, retracted two rounds later once the URL was finally fetched | +| `require-agent-disclosure.py` | `PreToolUse` (Bash, mcp__github__.*) | warns, never blocks, on a `gh`/`glab` command or MCP call that posts a forge comment without the agent-disclosure marker -- such a comment carries the account holder's own login and reads as `type: User`, indistinguishable from one they typed. Three verdicts, not one: the marker is missing, the body is somewhere the check cannot read (`--body-file`, `--editor`, `$BODY`) so it says so rather than accusing, or the body discloses with the robot emoji, which `check-pr-fully-clean.py` matches as a review-body marker | For agent-independent monitoring across all projects and sessions, install the user service after the hook files are installed: diff --git a/commands/release-pr.md b/commands/release-pr.md index 67f9ab40d..6974ad2ab 100644 --- a/commands/release-pr.md +++ b/commands/release-pr.md @@ -6,7 +6,8 @@ allowed-tools: - mcp__github__pull_request_read --- -Counterpart to `/claim-pr`. Post a single, recognisable "paws off released" comment so other agents and sessions know the PR is free for the next person. +Counterpart to `/claim-pr`. +Post a single, recognisable "claim released" comment so other agents and sessions know the PR is free for the next person. ## Arguments @@ -31,7 +32,18 @@ If only one positional arg is given, treat it as `pr_number`. Call `mcp__github__pull_request_read(method = "get_comments", owner = , repo = , pullNumber = )`. Walk the last ~10 comments and confirm: - - the most recent claim/release exchange is an unmatched claim — a "paws off until I'm done" claim comment that hasn't yet been followed by a release. Treat **either** release phrasing as a release marker: this command's `… done — paws off released.` **or** the existing `claim-pr` skill's `Done with my local session — unclaiming.`, + - the most recent claim/release exchange is an unmatched claim that hasn't yet been followed by a release. + **Match the two-word invariant `hold off` (case-insensitively), never a full sentence** --- the PR claim reads `please hold off on pushing to this branch until I'm done` while the issue claim reads `please hold off until I'm done`, so neither sentence is a substring of the other and a matcher keyed on either one misses the other. + **Also match both retired invariants, `paws off` and `back off`** (the second was `ardi`'s, and is easy to miss because seven other skills agreed on the first): claims posted before 2026-08-24 are still live on open PRs, since a claim expires on activity rather than on age, and a matcher narrowed to the new wording returns nothing on them --- indistinguishable from no claim at all. + So the claim test is `test("hold off|paws off|back off"; "i")`. + Treat **any** of these as a release marker --- four the corpus posts today plus one retired form still sitting on open PRs, and enumerating only this command's own is what makes it post a stray release over somebody else's completed handover: + - this command's `… done --- claim released.` + - its pre-2026-08-24 form `… done --- paws off released.` + - `claim-pr`'s `Done with my local session --- unclaiming.` + - `ardi`'s on-clean unclaim, `Done --- PR is free.` + - `post-merge`'s conflict unclaim, `Conflict resolved --- branch is now mergeable. …` + + Derive that list rather than trusting this one, since a skill may add a sixth: `grep -rn "unclaim\|released\|PR is free\|now mergeable" skills/ commands/`. - and that claim's `lane` matches the lane we're releasing. If the most recent signal is already a release, or the claim was by a different lane, stop and tell the user — don't post a stray release that misrepresents who was holding the PR. @@ -39,15 +51,21 @@ If only one positional arg is given, treat it as `pr_number`. 3. Compose the comment body, exactly in this shape so other agents recognise it: ``` - done — paws off released. + done --- claim released. + + _Posted by Claude Code (AI agent) --- not written by a human._ ``` - If `summary` is provided, append it in parentheses on the same line: + If `summary` is provided, append it in parentheses on the first line: ``` - done — paws off released. () + done --- claim released. () + + _Posted by Claude Code (AI agent) --- not written by a human._ ``` + The trailing marker is required on every agent-posted comment, and is deliberately emoji-free --- see [`disclose-agent-authorship`](../shared/workflow/disclose-agent-authorship.md). + 4. Post the comment: `mcp__github__add_issue_comment(owner = , repo = , issue_number = , body = )`. diff --git a/hooks/hooks.json b/hooks/hooks.json index 0534f588b..febd5d0c8 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -349,6 +349,54 @@ "timeout": 15, "script": "flag-uncited-rebuttal.py", "why": "shared/workflow/address-every-comment.md's 'read the cited source' rule, from Morrison-Lab/ai-config#2070 (2026-08-24): a rebuttal disputing a finding that cited an external URL was posted using only local grep evidence, never fetching the URL the reviewer named -- and the rebuttal was wrong. Warns, never blocks, when a PR/issue comment about to be posted disputes a finding whose most recently fetched citation named an external URL that no earlier WebFetch/WebSearch in the transcript touched." + }, + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/require-agent-disclosure.py\"", + "timeout": 10, + "script": "require-agent-disclosure.py", + "why": "cai 2026-08-24 'all comments online posted by bots should say so', citing UCD-SERG/ucd-serg.github.io#108's agent-authored claim comment posted under d-morrison as type: User, author_association MEMBER -- indistinguishable from a human's. memories/github.md already recorded auditors making that mistake. shared/workflow/disclose-agent-authorship.md is the rule; this is the instrument, since the rule is read at read time and the omission happens at composition time. WARNS, never blocks: a missing marker is cheap to repair with a follow-up comment, while a blocked `gh pr comment` interrupts the one action that makes a claim visible to other sessions -- trading a labelling defect for a collision. Anchored at a command position over text whose heredoc BODIES have been stripped -- the opener tail is kept, so piping a heredoc into `--body-file -` still fires while a doc-writing heredoc quoting `gh pr comment` does not. This corpus quotes that command constantly, and matching such a quote is the near-miss require-gh-repo-flag.py shipped with. Fails open.", + "_note_marker_avoids_the_robot_emoji": [ + "The disclosure marker is prose, NOT the robot emoji, and the hook", + "warns separately when a body discloses with the emoji instead.", + "scripts/check-pr-fully-clean.py matches that emoji as a", + "REVIEW_BODY_MARKERS entry, so a disclosed claim comment would be", + "admitted into the fully-clean verdict scan as a review -- and a claim", + "carries no findings, so it would scan as a CLEAN one. That is the", + "false-clean failure shared/workflow/fully-clean.md already records for", + "a human-authored self-review, arriving through the very mechanism", + "added to make authorship legible." + ], + "_note_cannot_see_every_body": [ + "--body-file, --editor, `-F ` and an interpolated $BODY all put the", + "text where a literal scan cannot reach. The hook reports those as an", + "UNREADABLE body rather than as a missing marker, so its warning never", + "asserts more than it observed. That partial visibility is the second", + "reason it cannot deny: a block built on a check that sees a fraction of", + "its population would refuse compliant commands.", + "", + "skills/ard/SKILL.md's per-round summary is exactly that shape -- posted", + "via --body-file on every round of every PR -- so it states the marker", + "requirement in its own text rather than relying on this guard.", + "", + "REGISTERED TWICE, under Bash and under mcp__github__.*, because", + "hooks.json matches by TOOL NAME: the MCP tool list inside the script is", + "unreachable from a Bash-only entry however complete it is. A remote/web", + "session has no `gh` at all, so a Bash-only registration would be silent", + "in exactly the population the MCP branch was written for." + ], + "_note_segmentation_is_quote_aware": [ + "Segments are split on shell separators OUTSIDE quotes. A plain", + "re.split(r'[;&|\\n]+', ...) looked adequate and was not: the marker sits", + "on its own line at the END of a body, so splitting on every newline cuts", + "the marker off the very command it discloses, and every correctly", + "disclosed multi-line comment -- the normal shape -- warned. Caught by the", + "test suite before this hook was ever registered.", + "", + "Judging PER SEGMENT rather than per call is what stops one disclosed body", + "vouching for an undisclosed sibling in a batched round, which", + "shared/workflow/efficient-pr-babysitting.md actively encourages." + ] } ] }, @@ -433,6 +481,13 @@ "timeout": 10, "script": "warn-pr-create-without-dupe-check.py", "why": "Dual-registered with the Bash entry, following no-unauthorized-merge.py's precedent. tool-mappings.md maps CREATE_PR to mcp__github__create_pull_request, and CLAUDE.md mandates that substitute in remote sessions -- so registering only under Bash let a remote session discharge the guard via MCP reads while never being able to trip it via MCP creation." + }, + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/require-agent-disclosure.py\"", + "timeout": 10, + "script": "require-agent-disclosure.py", + "why": "The MCP half of the Bash entry above. A remote/web session has no `gh` on PATH, so mcp__github__add_issue_comment and its siblings are the ONLY way it posts a comment -- and hooks.json matches by tool name, so the Bash entry never sees them. Same script, same warn-only behaviour; it reads tool_input.body directly rather than parsing a command line, and stays silent when no string body is present (some pull_request_review_write methods submit without one)." } ] } diff --git a/hooks/require-agent-disclosure.py b/hooks/require-agent-disclosure.py new file mode 100755 index 000000000..f9daa9faa --- /dev/null +++ b/hooks/require-agent-disclosure.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""PreToolUse guard: a forge comment an agent posts must disclose that it did. + +A comment posted through `gh`/`glab` under the account holder's credentials +carries THEIR login, avatar and `MEMBER` association, and reads as +`type: User` -- so nothing in the API distinguishes it from a comment they +typed. `memories/github.md` records auditors making exactly that mistake. The +forge cannot say it; the body has to. + +See `shared/workflow/disclose-agent-authorship.md` for the rule and the marker. + +WARNS, never blocks. Two reasons, and the second is the load-bearing one: + + * A missing disclosure is cheap to repair with a follow-up comment, while a + blocked `gh pr comment` interrupts the one action that makes a claim + visible to other sessions -- which is the collision the claim convention + exists to prevent. Blocking would trade a labelling defect for a + correctness one. + * The body is not always visible. `--body-file`, `--editor`, `-F ` and + `$VAR` expansion all put the text somewhere a literal scan cannot read, so + the check decides the question only for the inline-literal case. A deny + built on a check that sees a fraction of its population would refuse + compliant commands, which is why this stays advisory. (The kindred + incident in `README.md`'s hook section is the opposite shape: an early + `require-gh-repo-flag.py` matched too MUCH, firing on a heredoc that merely + documented a gated command. Both directions end in a guard that refuses + correct work; they arrive by different routes.) + +MARKER, not emoji. The disclosure marker deliberately avoids the robot emoji: +`scripts/check-pr-fully-clean.py` matches that emoji as a `REVIEW_BODY_MARKERS` +entry, so a disclosed claim comment would be admitted into the fully-clean +verdict scan as a finding-free review. This hook therefore looks for the prose +marker and, separately, points out a body that discloses with the emoji. + +EXEMPT: a body whose WHOLE content is a command addressed to another bot +(`@dependabot rebase`, the review re-request `ardi` mandates). The test is the +audience -- a machine parses that body -- not the length, so the pattern is +anchored to the whole body rather than to its first token. + +The corpus declares a SECOND exemption this guard does not implement: a comment +posted under a genuine bot token, where the forge already reports `type: Bot` +and the marker adds nothing. Whether a token is an app's or a person's is not in +the command text, so no lexical check can decide it -- which is why the rule +carries it and the instrument does not. The visible consequence is that +`skills/claude-agent-workflow/SKILL.md`'s in-workflow reply draws a warning it +should not. Warn-only, so the cost is a note rather than a refusal. + +PER SEGMENT, not per call. A batched round posting several comments in one +Bash call is the encouraged shape (`shared/workflow/efficient-pr-babysitting.md`), +so one disclosed body must not vouch for an undisclosed sibling. Each +command-position segment is judged on its own text. + +Covers the Bash CLI forms and the `mcp__github__*` comment tools, since a +remote/web session has no `gh` at all and MCP is its only path there +(`CLAUDE.md`, "Skills that call gh/glab: fall back to tool-mappings.md"). +That second half needs a SECOND registration: `hooks.json` matches hooks by +tool name, so an entry under `Bash` alone never reaches an MCP call however +many MCP tool names this file lists. + +Fails OPEN: any parse problem returns 0 with no output. +""" +import json +import re +import sys + +# --- what counts as posting a comment --------------------------------------- +# +# ANCHORED at a command position -- start of string, after a separator, or after +# a shell keyword that introduces a command. Unanchored, this fires on every +# reply and every doc that merely QUOTES the command, and this corpus quotes it +# constantly (`shared/workflow/claim-pr.md` is nothing but such quotes). +_ANCHOR = ( + r"(?:^|[;&|\n({`]|\b(?:then|else|elif|do|if|while|until)\s|!\s*)\s*" + r"(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*" +) + +# The named CLI verbs, where the command word alone settles it. +_POST_CMDS = ( + r"gh\s+pr\s+comment", + r"gh\s+issue\s+comment", + # A review needs a BODY flag to be a comment. `gh pr review 12 --approve` + # posts no prose, so there is nothing to disclose and warning on it spends + # the guard's credibility on a command it cannot be about. + # NOT gated on a body flag by a lookahead here. The obvious spelling, + # `(?=[^\n;|&]*--body...)`, carries the exact bound this file's own + # `is_api_post` docstring diagnoses two blocks below: it cannot cross the + # backslash line-continuation that `skills/ard` uses for every long command, + # so a review whose body flag sits on a continuation line went SILENT. + # The body-flag test is applied per segment in `is_post_segment` instead, + # where the whole command is already in hand. + r"gh\s+pr\s+review\b", + # `glab ... comment` is a real alias of `... note`; both spellings ship. + r"glab\s+mr\s+(?:note|comment)", + r"glab\s+issue\s+(?:note|comment)", +) +POST_RE = re.compile(_ANCHOR + r"(?:" + "|".join(_POST_CMDS) + r")", re.MULTILINE) + +# The raw-API routes need a TWO-PART test rather than one regex spanning the +# gap between their parts. +# +# A single pattern was tried twice and failed twice. `[^\n;|&]*` cannot cross the +# backslash line-continuation every review-thread reply in `skills/ard` uses. +# Widening it to `[^;&]{0,400}?` put the COMMENT BODY inside the gap, so an +# ordinary semicolon in the prose, or a body over 400 characters, made the +# detector silent -- on exactly the longer, more human-looking replies the rule +# is for. Both times the fixture was short and punctuation-free and passed. +# +# So test the parts independently over the whole segment, which the quote-aware +# splitter has already bounded to one command. Order-independent by +# construction, which also fixes `gh api -f body=... `. +API_CMD_RE = re.compile(_ANCHOR + r"(?:gh|glab)\s+api\b", re.MULTILINE) +# A body-supplying field is what separates a POST from the review-READ that +# `CLAUDE.md` prescribes and every ARDI round runs. +# The optional quote is load-bearing: `tool-mappings`'s own canonical reply +# command is `-F "body=@"`, quote first, and this corpus writes the +# quoted-whole-argument spelling for sibling flags too +# (`request-pr-review`'s `-f "reviewers[]="`). Without it the registry line +# this change annotates was completely invisible to the guard. +API_BODY_FIELD_RE = re.compile(r"(?:-f|-F|--field|--raw-field)\s+[\"']?body=") +# The comment-bearing endpoints, and the GraphQL comment mutations. +# No `/replies` alternative. GitHub's reply route is +# `POST /repos/{o}/{r}/pulls/{n}/comments/{id}/replies`, so it always contains +# `/comments` -- the alternative could never fire alone, which is why no fixture +# could isolate it and why two attempts at one were masked by their own paths. +# An untestable alternative also implies a route that does not exist. +API_COMMENT_TARGET_RE = re.compile( + r"/comments|/notes|/discussions" + r"|addDiscussionComment|addComment", re.IGNORECASE) + + +def is_api_post(segment): + """True when this segment posts a comment through a raw forge API.""" + if not API_CMD_RE.search("\n" + segment): + return False + if not API_COMMENT_TARGET_RE.search(segment): + return False + # A GraphQL comment mutation may carry its body inside the query text or in + # an `--input` file rather than in a `body=` field, so the field test alone + # would miss it. The earlier version of this branch keyed on the mutation + # NAME alone, which classified `gh api graphql --input p.json # addDiscuss- + # ionComment payload` -- a comment about a payload -- as a post. + # `mutation` beside the name is what separates executing one from naming one. + if (re.search(r"\bmutation\b", segment) + and re.search(r"addDiscussionComment|addComment", segment, + re.IGNORECASE)): + return True + return bool(API_BODY_FIELD_RE.search(segment)) + + +# A review with no body flag posts no prose (`gh pr review 12 --approve`), so +# there is nothing to disclose. Tested over the whole segment rather than in a +# lookahead, so a continuation line cannot hide the flag. +REVIEW_ONLY_RE = re.compile(_ANCHOR + r"gh\s+pr\s+review\b", re.MULTILINE) +ANY_BODY_FLAG_RE = re.compile( + r"--body\b|--body=|--body-file|--message\b|--message=|-b\s|-m\s|-F\s" + r"|(?:-f|-F|--field|--raw-field)\s+[\"']?body=") + + +def is_post_segment(segment): + """True when this segment posts a forge comment by any route.""" + if POST_RE.search("\n" + segment): + # `gh pr review` is the one named verb that may carry no body at all. + if (REVIEW_ONLY_RE.search("\n" + segment) + and not ANY_BODY_FLAG_RE.search(segment)): + return False + return True + return is_api_post(segment) + + +# Segment boundaries, split QUOTE-AWARE. +# +# A plain `re.split(r"[;&|\n]+", ...)` looked adequate and was not: the marker +# sits on its own line at the END of a body, so splitting on every newline cuts +# the marker off the very command it discloses, and every correctly-disclosed +# multi-line comment -- the normal shape -- warns. Tracking quote state is the +# minimum needed to keep a body intact. It is still not a shell parser; an +# unbalanced quote degrades to treating the remainder as quoted, which merges +# segments and can only silence a warning, never invent one. +SEG_SEPARATORS = ";&|\n" + +# A heredoc body is prose when it is being WRITTEN and is the comment body when +# it is being PIPED (`--body-file -`, `$(cat <` is gh pr comment's own body-file shorthand. Matched as a token + # with NO `=` in it, rather than by a negative lookahead after an optional + # quote: the optional quote gave the engine a backtracking path where it + # skipped the quote, failed to find `key=` starting at `"`, and so satisfied + # the negation -- which made `-F "in_reply_to=5"` look like a file. + r"|(?"` so it could reuse the +# shell-shaped pattern below; a `"` inside the body then closed that synthetic +# argument early, and `@dependabot rebase" and a long note for the humans ...` +# took the exemption. Reconstructing syntax to reuse a matcher is what reopened +# a hole the Bash path had a fixture against. +BOT_BODY_RE = re.compile( + r"^\s*@(?:" + _BOT_HANDLES + r")\b[ \w-]{0,40}\s*$", re.IGNORECASE) + + +# MCP comment-posting tools. +# +# NOT derived from `tool-mappings.yml`, and the earlier claim that it named four +# of these was a miscount: that registry maps OPERATIONS, and `add_issue_comment` +# appears under two of them (COMMENT_PR and COMMENT_ISSUE), so counting rows +# double-counts one tool. It names two of the five --- `add_issue_comment` and +# `add_reply_to_pull_request_comment` --- plus COMMENT_DISCUSSION, whose row said +# no MCP tool existed until this change corrected it. `pull_request_review_write` +# and `add_comment_to_pending_review` have no operation of their own. +# +# So this list is wider than the registry by construction. Re-derive it against +# the server's tool list rather than against that file. +MCP_POST_TOOLS = ( + "mcp__github__add_issue_comment", + "mcp__github__add_comment_to_pending_review", + "mcp__github__add_reply_to_pull_request_comment", + "mcp__github__pull_request_review_write", + "mcp__github__discussion_comment_write", +) + +MARKER_TEXT = "_Posted by Claude Code (AI agent) --- not written by a human._" +SEE = "See shared/workflow/disclose-agent-authorship.md." + +MISSING = ( + "This posts a forge comment with no agent-disclosure marker. A comment " + "posted through `gh`/`glab` carries the account holder's own login and " + "reads as `type: User`, so nothing distinguishes it from one they typed. " + "End the body with:\n\n " + MARKER_TEXT + "\n\n" + "Exempt: a body that is wholly a command to another bot " + "(`@dependabot rebase`). " + SEE +) +UNREADABLE = ( + "This posts a forge comment whose body this check cannot read (it comes " + "from a file, an editor, or a variable). Confirm the body ends with the " + "agent-disclosure marker:\n\n " + MARKER_TEXT + "\n\n" + SEE +) +EMOJI = ( + "This forge comment discloses with the robot emoji. Use the prose marker " + "instead:\n\n " + MARKER_TEXT + "\n\n" + "scripts/check-pr-fully-clean.py matches the robot emoji as a " + "REVIEW_BODY_MARKERS entry, so a comment carrying it is admitted into the " + "fully-clean verdict scan as a review -- and a claim or status comment " + "carries no findings, so it scans as a CLEAN one. " + SEE +) + + +def split_segments(text): + """Split on shell separators that are OUTSIDE quotes.""" + segments, current = [], [] + quote = None + ansi_c = False + escaped = False + prev = "" + for ch in text: + prev_ch = prev + prev = ch + if escaped: + current.append(ch) + escaped = False + continue + # A plain '...' takes no escapes; ANSI-C $'...' does. Treating the + # latter as the former read `$'don\'t'` as closed and split the rest of + # the body on its newlines, cutting the marker off its own command. + if ch == "\\" and (quote != "'" or ansi_c): + current.append(ch) + escaped = True + continue + if quote: + current.append(ch) + if ch == quote: + quote = None + ansi_c = False + continue + if ch in "\"'": + quote = ch + ansi_c = ch == "'" and prev_ch == "$" + current.append(ch) + continue + if ch in SEG_SEPARATORS: + segments.append("".join(current)) + current = [] + continue + current.append(ch) + segments.append("".join(current)) + return segments + + +# Placeholder standing in for a heredoc body. Carries no shell separator, so it +# survives segmentation intact and keeps its body attached to the ONE segment +# that opened it. +SLOT = "\x00HEREDOC{}\x00" +SLOT_RE = re.compile(r"\x00HEREDOC(\d+)\x00") + + +def strip_heredocs(command): + """Remove heredoc BODIES, keeping the opener tail and a body placeholder. + + Only the body is prose. The opener line's tail is still shell and routinely + carries the very command being looked for -- piping a heredoc into + `--body-file -` is the idiomatic way to post a multi-line body. Same + reasoning, and the same lesson, as `warn-pr-create-without-dupe-check.py`. + + Returns (stripped_text, bodies). A plain strip was not enough: the body a + segment consumes has to stay attached to THAT segment, or a doc-writing + heredoc quoting the marker vouches for a bare comment elsewhere in the call. + Dropping `<<` along with the body also erased the only signal that a segment + had a heredoc at all, so the placeholder carries it. + """ + bodies = [] + + def take(m): + bodies.append(m.group(0)) + return m.group(2) + " " + SLOT.format(len(bodies) - 1) + + return HEREDOC_RE.sub(take, command), bodies + + +def bodies_for(segment, bodies): + """The heredoc bodies this segment actually opened.""" + return "\n".join(bodies[int(i)] for i in SLOT_RE.findall(segment) + if int(i) < len(bodies)) + + +def judge_segment(segment, extra): + """Return a warning for one command-position segment, or None. + + `extra` is supplied ONLY when this segment actually references a heredoc. + Passing every heredoc body to every segment let a doc-writing heredoc that + merely QUOTED the marker vouch for a bare comment posted later in the same + call -- which is exactly the per-segment property this function exists to + provide, defeated by the argument meant to support it. + """ + text = segment + "\n" + extra + if BOT_COMMAND_RE.search(segment): + return None + if MARKER_RE.search(text): + return None + if EMOJI_DISCLOSURE_RE.search(text): + return EMOJI + # A heredoc body we actually READ settles it: the body is in hand and + # carries no marker, so this is a missing marker rather than an unseen one. + # `--body-file -` is unreadable BY FLAG and readable in fact when its stdin + # is the heredoc, and reporting "cannot read" over a body just read is the + # same misdiagnosis the `-F ` case produced. + if extra: + return MISSING + if UNREADABLE_RE.search(segment) or not HAS_INLINE_BODY_RE.search(segment): + return UNREADABLE + return MISSING + + +def verdict_bash(command): + """Return a warning string for a Bash command, or None.""" + stripped, bodies = strip_heredocs(command) + if not any(is_post_segment(seg) for seg in split_segments(stripped)): + return None + warnings = [] + for segment in split_segments(stripped): + if not is_post_segment(segment): + continue + # A heredoc body IS this segment's comment body only when this segment + # opened it. Elsewhere it is somebody else's prose. + found = judge_segment(segment, bodies_for(segment, bodies)) + if found and found not in warnings: + warnings.append(found) + if not warnings: + return None + return "\n\n".join(warnings) + + +def verdict_mcp(tool_name, tool_input): + """Return a warning string for an MCP comment tool, or None.""" + if tool_name not in MCP_POST_TOOLS: + return None + body = tool_input.get("body") + if not isinstance(body, str): + # `pull_request_review_write` submits without a body on some methods, + # and a body we never saw is not a body we can judge. + return None + if BOT_BODY_RE.match(body): + return None + if MARKER_RE.search(body): + return None + if EMOJI_DISCLOSURE_RE.search(body): + return EMOJI + return MISSING + + +def verdict(command): + """Bash-only entry point, kept for the test suite and for callers.""" + return verdict_bash(command) + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except Exception: + return 0 + tool_name = payload.get("tool_name") + tool_input = payload.get("tool_input") or {} + if not isinstance(tool_input, dict): + return 0 + if tool_name == "Bash": + reason = verdict_bash(tool_input.get("command") or "") + else: + reason = verdict_mcp(tool_name, tool_input) + if not reason: + return 0 + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": reason, + }, + # Surfaced to the USER as well, not only to the model: whether a comment + # posted under their account discloses its authorship is their call to + # see being made, and a model-only warning leaves them unaware it fired. + "systemMessage": ( + "This forge comment may not disclose that an agent posted it. " + "Comments posted through `gh`/`glab` carry your own login and read " + "as `type: User`. " + SEE + ), + })) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/test-require-agent-disclosure.py b/hooks/test-require-agent-disclosure.py new file mode 100755 index 000000000..8716065f6 --- /dev/null +++ b/hooks/test-require-agent-disclosure.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +"""Tests for require-agent-disclosure.py. + +The cases that matter are the near-misses, per +`shared/workflow/algorithmatize-checks.md`: a matcher that fires on every +`gh pr comment` is useless, and one that fires on none of them is invisible. +""" +import importlib.util +import json +import pathlib +import subprocess +import sys + +_spec = importlib.util.spec_from_file_location( + "guard", pathlib.Path(__file__).with_name("require-agent-disclosure.py")) +guard = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(guard) + +MARKER = "_Posted by Claude Code (AI agent) --- not written by a human._" + + +def GQL(body): + """The corpus's verbatim addDiscussionComment command, with `body` in it.""" + return ("gh api graphql -f discussionId='' -f body='" + body + + "' -f query='\n" + " mutation($discussionId: ID!, $body: String!) {\n" + " addDiscussionComment(input: {discussionId: $discussionId, " + "body: $body}) {\n comment { id url }\n }\n }'") + +# (label, command, expect_warning) +CASES = [ + # --- must warn ----------------------------------------------------------- + ("bare pr comment", + 'gh pr comment 12 --body "Working on this."', True), + ("bare issue comment", + 'gh issue comment 12 --body "Working on this."', True), + ("glab mr note", + 'glab mr note create 12 --message "Working on this."', True), + ("glab issue note", + 'glab issue note 12 --message "Working on this."', True), + ("gh pr review", + 'gh pr review 12 --comment --body "Looks fine."', True), + ("prose self-id is not the marker", + 'gh pr comment 12 --body "Claude Code CLI (local session) is working on this."', + True), + + # --- must NOT warn ------------------------------------------------------- + ("marker present", + f'gh pr comment 12 --body "Working on this.\n\n{MARKER}"', False), + ("marker with another agent name", + 'gh pr comment 12 --body "Done.\n\n_Posted by Codex (AI agent) -- not a human._"', + False), + ("dependabot rebase is exempt", + 'gh pr comment 12 --repo o/r --body "@dependabot rebase"', False), + ("dependabot squash is exempt", + 'gh pr comment 12 --repo o/r --body "@dependabot squash and merge"', False), + ("renovate is exempt", + 'gh pr comment 12 --body "@renovate rebase"', False), + + # --- not a comment-posting command at all -------------------------------- + ("reading comments is not posting", + 'gh pr view 12 --json comments', False), + ("issue create is not a comment", + 'gh issue create --title x --body "y"', False), + ("git commit is not a comment", + 'git commit -m "Working on this."', False), + # --- the near-misses this corpus generates constantly --------------------- + ("prose merely discussing the rule", + 'echo "always end a gh pr comment with the marker"', False), + ("a doc-writing heredoc quoting the command", + 'cat > doc.md <<\'EOF\'\ngh pr comment --body "Working on this."\nEOF', + False), + ("grep for the command is not the command", + 'grep -rn "gh pr comment" skills/', False), + ("a chained real command still warns", + 'git push && gh pr comment 12 --body "Pushed."', True), + ("a variable elsewhere does not hide a visible marker", + f'gh pr comment "$N" --repo "$REPO" --body "Done.\n\n{MARKER}"', False), + + # --- forms the first version missed entirely (review findings 2, 12) ------ + ("gh api issues comments", + 'gh api repos/o/r/issues/12/comments -f body="Working on this."', True), + ("gh api review-thread reply", + 'gh api repos/o/r/pulls/12/comments/9/replies -f body="Addressed."', True), + ("gh api reply WITH marker", + f'gh api repos/o/r/pulls/12/comments/9/replies -f body="Addressed.\n\n{MARKER}"', + False), + ("glab mr comment alias", + 'glab mr comment 12 --message "Working on this."', True), + ("glab issue comment alias", + 'glab issue comment 12 --message "Working on this."', True), + ("command after then", + 'if true; then gh pr comment 12 --body "bare"; fi', True), + ("negated command", + '! gh pr comment 12 --body "bare"', True), + ("command inside a do-loop", + 'for n in 1 2; do gh pr comment $n --body "bare"; done', True), + + # --- one marker must not vouch for a sibling (review finding 4) ----------- + ("a disclosed comment does not vouch for an undisclosed sibling", + f'gh pr comment 1 --body "a\n\n{MARKER}" && gh pr comment 2 --body "b"', + True), + ("both disclosed is silent", + f'gh pr comment 1 --body "a\n\n{MARKER}" && gh pr comment 2 --body "b\n\n{MARKER}"', + False), + ("a grep for the marker does not vouch for a bare comment", + 'grep -rn "Posted by Claude Code (AI agent)" . ; gh pr comment 2 --body "bare"', + True), + + # --- heredocs: body when piped, prose when written (review finding 3) ----- + ("heredoc IS the body, and discloses", + 'gh pr comment 12 --body-file - <<\'EOF\'\nDone.\n\n' + MARKER + + '\nEOF', False), + # `--body-file -` is genuinely unreadable-by-flag, and the heredoc makes the + # body visible anyway -- so this must report MISSING, not "cannot read". + ("heredoc IS the body, and does not disclose", + 'gh pr comment 12 --body-file - <<\'EOF\'\nDone, undisclosed.\nEOF', "missing"), + ("a doc heredoc does not silence a real sibling command", + 'cat > d.md <<\'EOF\'\ngh pr comment --body "x"\nEOF\ngh pr comment 2 --body "bare"', + True), + # The fixture above proves only that the sibling is SEEN. This one proves + # the heredoc cannot vouch for it: the doc being written quotes the marker + # verbatim, which is the normal shape when editing this very corpus. + ("a heredoc quoting the marker does not vouch for a bare sibling", + 'cat > frag.md <<\'EOF\'\nEnd every body with:\n\n' + MARKER + + '\nEOF\ngh pr comment 2 --body "bare claim"', True), + + # --- the exemption is whole-body, not first-token (review finding 8) ------ + ("a bot handle followed by prose for humans is NOT exempt", + 'gh pr comment 12 --body "@dependabot rebase please, and a note for the ' + 'humans reading this thread: I will also rerun CI"', True), + ("the review re-request is exempt", + 'gh pr comment 12 --body "@' + 'claude review"', False), + + # --- a READ is not a post; round-2 review finding 3 ---------------------- + ("gh api GET of comments is a read, not a post", + 'gh api repos/o/r/issues/12/comments --paginate | jq -s \'.\'', False), + ("gh pr review --approve posts no prose", + 'gh pr review 12 --approve', False), + ("gh pr review WITH a body is a post", + 'gh pr review 12 --request-changes --body-file /tmp/r.md', None), + + # --- forge-API comment routes; round-2 review finding 8 ------------------ + ("glab api discussion note", + 'glab api -X POST "projects/:id/merge_requests/5/discussions/9/notes" ' + '-f body="Addressed."', True), + ("gh api graphql addDiscussionComment", + "gh api graphql -f body='Moved.' -f query='mutation { " + "addDiscussionComment(input:{}) { comment { url } } }'", True), + ("gh api graphql addDiscussionComment WITH marker", + "gh api graphql -f body='Moved.\n\n" + MARKER + "' -f query='mutation { " + "addDiscussionComment(input:{}) { comment { url } } }'", False), + + # --- the exemption must survive a trailing token; round-2 finding 5 ------ + ("chores site verbatim, with its trailing comment", + 'gh pr comment "$N" --repo "$REPO" --body "@dependabot rebase" ' + '# COMMENT_PR', False), + ("bot body followed by another flag", + 'gh pr comment 12 --body "@dependabot rebase" --repo o/r', False), + + # --- VERBATIM corpus command lines; round-3 review findings 2 and 7 ------ + # + # The earlier fixtures for these were single-line inventions, and both + # passed while the detector matched nothing the corpus actually writes -- + # `fixtures-are-not-evidence` exactly. These are copied from the skills. + ("ard's GitHub review-thread reply, with its line continuation", + 'gh api "repos/{owner}/{repo}/pulls//comments" \\\n' + ' -F in_reply_to="" -F body="@/tmp/reply-.md"', + None), + ("ard's GitLab discussion note, with its line continuation", + 'glab api -X POST "projects/:id/merge_requests//discussions//notes" \\\n' + ' -F body="@/tmp/reply-.md"', None), + ("discussions' multi-line addDiscussionComment, no marker", + "gh api graphql -f discussionId='' -f body='' -f query='\n" + " mutation($discussionId: ID!, $body: String!) {\n" + " addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {\n" + " comment { id url }\n }\n }'", "missing"), + ("discussions' multi-line addDiscussionComment, WITH marker", + "gh api graphql -f discussionId='' -f body='\n\n" + MARKER + + "' -f query='\n mutation($discussionId: ID!, $body: String!) {\n" + " addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {\n" + " comment { id url }\n }\n }'", False), + + # --- precision on ordinary corpus reads; round-3 findings 3, 4, 11 ------- + ("the review-verdict read CLAUDE.md prescribes", + "gh api repos/o/r/issues/12/comments --paginate | jq -s '.'", False), + ("an incidental robot emoji is a missing marker, not a wrong one", + 'gh pr comment 5 --body "\U0001f916 CI regenerated the snapshots."', + "missing"), + ("-b with an expanded variable is unreadable, like --body", + 'gh pr comment 5 -b "$BODY"', None), + # A MID-STRING `$VAR` is the case the whole-value fixture above cannot see: + # HAS_INLINE_BODY_RE rejects only a value BEGINNING with `$`, so a round-6 + # edit deleted the short-flag clause as "dead" and this shape started + # reporting a missing marker over a body the check never read. + ("-b with a mid-string expansion is still unreadable", + 'gh pr comment 5 -b "Addressed in $SHA."', None), + ("-f body= with a mid-string expansion is unreadable", + 'gh pr comment 5 -f body="Addressed in $SHA."', None), + ("-F \"body=...\" with a mid-string expansion is unreadable", + 'gh pr comment 5 -F "body=Addressed in $SHA."', None), + ("--body with a mid-string expansion is unreadable", + 'gh pr comment 5 --body "Addressed in $SHA."', None), + # A single-quoted `$` does not expand in bash, so this body IS readable -- + # and it is reported unreadable anyway, deliberately. The `--body`/`--message` + # clause has always behaved this way, so the short forms match it rather than + # diverging, and the error is toward the weaker note rather than toward an + # assertion about text never read. Pinned so the choice is visible as one. + ("a single-quoted $ is reported unreadable, matching --body's behaviour", + "gh pr comment 5 -b 'costs $9'", None), + ("--raw-field body= is inline and readable", + 'gh api repos/o/r/issues/12/comments --raw-field body="hi"', "missing"), + ("-F body=@file is a file reference, so unreadable", + 'gh api repos/o/r/issues/12/comments -F body="@/tmp/b.md"', None), + + # --- round-4: the body sits INSIDE what used to be the gap --------------- + # + # The round-3 GraphQL fixture used the 12-character placeholder + # ``, which has no `;`, no `&`, and fits any length bound -- so + # it passed on the one input that concealed the bug. These vary exactly the + # properties the old gap regex was sensitive to. + ("GraphQL body containing a semicolon", + GQL("Addressed; pushed."), "missing"), + ("GraphQL body containing an ampersand", + GQL("Fixed A & B."), "missing"), + ("GraphQL body longer than the old 400-char bound", + GQL("x" * 320), "missing"), + ("GraphQL long body WITH marker", + GQL("x" * 320 + "\n\n" + MARKER), False), + + # --- round-4: a typed -F field is not a body-file ------------------------ + ("a typed -F field does not hide a visible body", + 'gh api repos/o/r/pulls/1/comments -F in_reply_to=5 ' + '-F body="Addressed, undisclosed."', "missing"), + + # --- round-4: argument order must not decide it -------------------------- + ("gh api with the body flag before the path", + 'gh api -X POST -f body="Working on this." repos/o/r/issues/12/comments', + "missing"), + + # --- round-5: properties a mutation could delete with the suite green ---- + # + # Each of these was implemented and unpinned: removing the behaviour passed + # 77/77. A property no test discriminates is one a later edit deletes for + # free, which is the whole reason to pin it rather than to trust the code. + ("an env-var prefix does not hide a command position", + 'GH_TOKEN=x gh pr comment 12 --body "bare"', True), + ("ANSI-C $'...' keeps its escaped quote, so the body stays whole", + "gh pr comment 12 --body $'Done, don\\'t worry.\n\n" + MARKER + "'", + False), + ("a bare /notes path is a comment target", + 'glab api -X POST "projects/:id/merge_requests/5/notes" -f body="bare"', + True), + # GitLab's create-a-new-thread route: `/discussions` with no `/notes` and + # no `/comments`, so it is the only fixture that isolates that alternative. + # The first attempt used `repos/o/r/discussions/5/comments`, which still + # matched via `/comments` and masked the very thing it was pinning. + ("a bare /discussions path is a comment target", + 'glab api -X POST "projects/:id/merge_requests/5/discussions" ' + '-f body="bare"', True), + + # --- round-5: `gh pr review`'s body flag may sit on a continuation line --- + ("review with the body flag on a continuation line", + 'gh pr review 12 --request-changes \\\n --body "Findings: one thing."', + "missing"), + ("review with body-file on a continuation line", + 'gh pr review 12 --comment \\\n --body-file /tmp/r.md', None), + + # --- round-5: naming a mutation is not posting --------------------------- + ("a command that merely NAMES the mutation posts nothing", + 'gh api graphql --input payload.json # addDiscussionComment payload', + False), + + # --- round-5: the `--body=` equals form is inline, not unreadable -------- + ("--body= equals form is a visible body", + 'gh pr comment 12 --body="Working on this."', "missing"), + ("--message= equals form is a visible body", + 'glab mr note 12 --message="bare"', "missing"), + ("--body= equals form WITH marker", + 'gh pr comment 12 --body="Done.\n\n' + MARKER + '"', False), + ("--body= with an expanded variable is still unreadable", + 'gh pr comment 12 --body="$BODY"', None), + + # --- round-6: the QUOTED whole-argument field form ---------------------- + # + # `tool-mappings`'s own canonical reply command writes `-F "body=@"`, + # quote first, and the field pattern required `body=` to follow whitespace + # directly -- so the registry line this change annotates was invisible. + ("gh api with a quoted body= argument", + 'gh api repos/o/r/issues/1/comments -f "body=Working on this."', "missing"), + ("gh api with a quoted body= argument, WITH marker", + 'gh api repos/o/r/issues/1/comments -f "body=Done.\n\n' + MARKER + '"', + False), + ("the registry's own quoted body=@file reply command", + 'gh api -X POST "repos/o/r/pulls/1/comments/9/replies" -F "body=@/tmp/r.md"', + None), + ("a quoted typed field does not look like a body-file", + 'gh api repos/o/r/pulls/1/comments -F "in_reply_to=5" -f body="Addressed."', + "missing"), + ("an unquoted -F file is still a body-file", + 'gh pr comment 12 -F "/tmp/body.md"', None), + + # --- round-6: executing a GraphQL mutation vs naming one ----------------- + ("a GraphQL mutation whose body is not in a body= field", + "gh api graphql --input p.json -f query='mutation { addDiscussionComment(x) }'", + None), + ("a comment mentioning the mutation posts nothing", + 'gh api graphql --input p.json # addDiscussionComment payload', False), + + # --- round-6: properties that survived mutation with the suite green ----- + ("command substitution is a command position", + 'URL=$(gh pr comment 12 --body "bare")', True), + ("a brace group is a command position", + '{ gh pr comment 12 --body "bare"; }', True), + ("the marker needs its attribution prefix, not just the parenthetical", + 'gh pr comment 12 --body "Our (AI agent) policy is documented."', "missing"), + ("a heredoc piped into --body-file - keeps its opener tail", + "cat <<'EOF' | gh pr comment 12 --body-file -\nDone, undisclosed.\nEOF", + "missing"), + # GitHub's reply route always contains `/comments`, so this is caught by + # that alternative -- there is no separate `/replies` one to pin. + ("the review-thread reply route is a comment target", + 'gh api "repos/o/r/pulls/1/comments/9/replies" -f body="bare"', True), + + # --- unreadable vs missing must not be confused (review finding 9) ------- + ("gh pr comment -F is a body-file, reported unreadable", + 'gh pr comment 12 -F /tmp/body.md', None), + ("--editor is unreadable", + 'gh pr comment 12 --editor', None), +] + +# --- the emoji branch -------------------------------------------------------- +ROBOT_CASE = ( + 'gh pr comment 12 --body "Done.\n\n\U0001f916 Posted by Claude Code."') + +# --- the unreadable-body branch --------------------------------------------- +INDIRECT_CASES = [ + ("body-file", 'gh pr comment 12 --body-file /tmp/b.md'), + ("api body file", 'gh pr comment 12 -F body=@/tmp/b.md'), + ("variable body", 'gh pr comment 12 --body "$BODY"'), +] + + +def run(): + failed = 0 + for label, command, expect in CASES: + reason = guard.verdict(command) + if expect == "missing": + ok = reason is not None and "no agent-disclosure marker" in reason + print(f"{'PASS' if ok else 'FAIL'}: {label} " + f"(reported missing={ok})") + elif expect is None: + # Must warn, and specifically about a body it could not read -- + # accusing a command of omitting a marker never seen is the + # misdiagnosis review finding 9 named. + ok = reason is not None and "cannot read" in reason + print(f"{'PASS' if ok else 'FAIL'}: {label} " + f"(reported unreadable={ok})") + else: + got = reason is not None + ok = got == expect + print(f"{'PASS' if ok else 'FAIL'}: {label} " + f"(warned={got}, expected={expect})") + failed += not ok + + reason = guard.verdict(ROBOT_CASE) + ok = reason is not None and "robot emoji" in reason + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: a robot-emoji disclosure is named as " + f"the wrong marker") + + # Review finding 14: a body merely MENTIONING the emoji discloses nothing, + # so the emoji advice would be inapplicable and would displace the real one. + mention = 'gh pr comment 12 --body "The \U0001f916 badge broke; rerunning."' + reason = guard.verdict(mention) + ok = reason is not None and "robot emoji" not in reason + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: merely mentioning the emoji is not " + f"treated as disclosing with it") + + for label, command in INDIRECT_CASES: + reason = guard.verdict(command) + ok = reason is not None and "cannot read" in reason + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: {label} reports an unreadable body " + f"rather than a missing marker") + + # The hook must never block. Its only output shape is additionalContext. + src = pathlib.Path(__file__).with_name( + "require-agent-disclosure.py").read_text(encoding="utf-8") + body = src.split('"""', 2)[-1] + ok = "permissionDecision" not in body + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: the hook warns and never denies") + + # End-to-end through stdin, because `verdict()` returning a string proves + # only that the text was COMPUTED. Whether the harness ever surfaces it is a + # fact about the emitted JSON, and a test asserting bool(verdict) cannot + # tell a surfaced warning from discarded output. + for label, payload, expect_warning in ( + ("a bare comment emits additionalContext", + {"tool_name": "Bash", + "tool_input": {"command": 'gh pr comment 12 --body "Working on this."'}}, + True), + ("a disclosed comment emits nothing", + {"tool_name": "Bash", + "tool_input": {"command": f'gh pr comment 12 --body "Done.\n\n{MARKER}"'}}, + False), + ("a non-Bash tool emits nothing", + {"tool_name": "Edit", "tool_input": {"command": "gh pr comment 1 --body x"}}, + False), + ("malformed stdin fails open", "not json at all", False), + ): + stdin = payload if isinstance(payload, str) else json.dumps(payload) + proc = subprocess.run( + [sys.executable, + str(pathlib.Path(__file__).with_name("require-agent-disclosure.py"))], + input=stdin, capture_output=True, text=True) + out = proc.stdout.strip() + if expect_warning: + try: + emitted = json.loads(out)["hookSpecificOutput"] + except Exception: + emitted = {} + try: + whole = json.loads(out) + except Exception: + whole = {} + ok = (proc.returncode == 0 + and emitted.get("hookEventName") == "PreToolUse" + and "additionalContext" in emitted + and "permissionDecision" not in emitted + and "disclosure marker" in emitted.get("additionalContext", "") + # The user-facing half. Warning only the model leaves the + # account holder unaware a comment posted under their login + # was flagged, and check-hook-output-shape.py's systemMessage + # rule fires on Stop hooks only, so nothing else pins this. + and isinstance(whole.get("systemMessage"), str) + and "agent" in whole.get("systemMessage", "")) + else: + ok = proc.returncode == 0 and out == "" + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: {label}") + + # Review finding 10: a remote/web session has no `gh`, so MCP is its only + # path -- a Bash-only guard is silent exactly where the CLI is absent. + for label, tool, body, expect in ( + ("MCP add_issue_comment bare", "mcp__github__add_issue_comment", + "Working on this.", True), + ("MCP add_issue_comment disclosed", "mcp__github__add_issue_comment", + "Working on this.\n\n" + MARKER, False), + ("MCP review reply bare", + "mcp__github__add_reply_to_pull_request_comment", "Addressed.", True), + ("MCP bot-command body is exempt", "mcp__github__add_issue_comment", + "@dependabot rebase", False), + # Round-4: verdict_mcp used to synthesize `--body ""` to reuse the + # shell-shaped pattern, so a quote INSIDE the body closed that synthetic + # argument early and faked the exemption. + ("MCP body with an embedded quote does not fake the exemption", + "mcp__github__add_issue_comment", + '@dependabot rebase" and a long note for the humans reading this', + True), + ("a non-comment MCP tool is out of scope", + "mcp__github__create_pull_request", "Closes #1", False), + # Round-5: three of the five MCP tools were unpinned -- removing any of + # them from MCP_POST_TOOLS passed the whole suite. + ("MCP pending-review comment", "mcp__github__add_comment_to_pending_review", + "Bare finding.", True), + ("MCP review write", "mcp__github__pull_request_review_write", + "Bare review body.", True), + ("MCP discussion comment", "mcp__github__discussion_comment_write", + "Bare discussion reply.", True), + ("MCP discussion comment WITH marker", + "mcp__github__discussion_comment_write", + "Reply.\n\n" + MARKER, False), + # Round-6: the isinstance guard exists for review methods that submit no + # body at all (resolve_thread, delete_pending); nothing pinned it. + ("an MCP call with no body is not judged", + "mcp__github__pull_request_review_write", None, False), + ): + payload = {} if body is None else {"body": body} + got = guard.verdict_mcp(tool, payload) is not None + ok = got == expect + failed += not ok + print(f"{'PASS' if ok else 'FAIL'}: {label} " + f"(warned={got}, expected={expect})") + + total = len(CASES) + 2 + len(INDIRECT_CASES) + 1 + 4 + 11 + print(f"\n{total - failed} passed, {failed} failed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/memories/github-mcp-tools.md b/memories/github-mcp-tools.md index 7cc76e631..0b8e4f00b 100644 --- a/memories/github-mcp-tools.md +++ b/memories/github-mcp-tools.md @@ -225,15 +225,13 @@ See ai-config#694 for the precedent. or edit one, so it is not sufficient here. Say so when handing off, rather than leaving someone to wonder why the new labels look unstyled. -- **Comments/replies you post via the GitHub MCP tools echo back into the - session's `` events under the human account's - identity, not a bot identity.** `add_reply_to_pull_request_comment` and - `add_issue_comment` authenticate as the human who owns the session (e.g. - `d-morrison`), so a webhook event for your own just-posted reply shows - `Author: d-morrison` (or whichever human), never a recognizable bot name - like `claude[bot]`. Don't use the author field to decide "is this my own - echo, skip it." This is easy to get wrong at a glance since a same-author - event looks exactly like a genuine human reply demanding a response. +- **Comments/replies you post via the GitHub MCP tools echo back into the session's `` events under the human account's identity, not a bot identity.** + `add_reply_to_pull_request_comment` and `add_issue_comment` authenticate as the human who owns the session (e.g. `d-morrison`), so a webhook event for your own just-posted reply shows `Author: d-morrison` (or whichever human), never a recognizable bot name like `claude[bot]`. + Don't use the author field to decide "is this my own echo, skip it." + This is easy to get wrong at a glance since a same-author event looks exactly like a genuine human reply demanding a response. + **Since 2026-08-24 every agent-posted comment also ends with `_Posted by Claude Code (AI agent) --- not written by a human._`**, which is the corpus-wide marker the `mcp__github__*` comment tools carry --- see [`disclose-agent-authorship`](../shared/workflow/disclose-agent-authorship.md). + It is a more reliable self-echo test than the author field, and unlike the footer below it is required by a rule rather than by the harness's attribution setting. + Comments posted before that date carry no marker, so the warning above still governs when auditing older threads. **Check for the Claude Code attribution footer instead of fuzzy-matching body text/timing** --- every comment posted from these sessions ends with `_Generated by [Claude Code](https://claude.ai/code)_` per the system diff --git a/memories/github.md b/memories/github.md index a31e43421..e78390373 100644 --- a/memories/github.md +++ b/memories/github.md @@ -192,9 +192,15 @@ in [`github-repo-transfers.md`](github-repo-transfers.md). `jq -e .` does separate empty from valid input, but then rejects the legitimate `null`/`false` bodies `gh api` returns routinely. Also note `gh api ... --jq ''` returns raw unquoted strings for string scalar expressions (such as `.head.sha`), which are not valid JSON on their own and fail `jq empty`. Fetch the endpoint JSON first and parse with `jq -r` instead. (Learned on gha#518.) -- **A reply posted via `gh pr comment`/`gh api` from within a session shows up under the *human user's own* GitHub account, not a bot identity — don't mistake it for an independent human review when auditing a PR's review state.** `gh` authenticates as whatever account is logged in locally (often the user's own, e.g. seen as `dem-extra1` on `Lacaedemon/sparta`), so when an agent (or a dispatched subagent) replies to an inline review comment on the user's behalf, `gh api repos///pulls//reviews` lists it as a `COMMENTED` review authored by the user — indistinguishable at a glance from the user genuinely opening the PR in a browser and typing a reply themselves. +- **A reply posted via `gh pr comment`/`gh api` from within a session shows up under the *human user's own* GitHub account, not a bot identity — don't mistake it for an independent human review when auditing a PR's review state.** + `gh` authenticates as whatever account is logged in locally (often the user's own, e.g. seen as `dem-extra1` on `Lacaedemon/sparta`), so when an agent (or a dispatched subagent) replies to an inline review comment on the user's behalf, `gh api repos///pulls//reviews` lists it as a `COMMENTED` review authored by the user — indistinguishable at a glance from the user genuinely opening the PR in a browser and typing a reply themselves. + **Since 2026-08-24 the fix is on the posting side:** every comment an agent posts carries a trailing `_Posted by Claude Code (AI agent) --- not written by a human._` marker, so the body says what the author field cannot. + See [`disclose-agent-authorship`](../shared/workflow/disclose-agent-authorship.md). + That makes agent-authored comments identifiable **going forward**. + Comments posted before that date carry no marker, so this warning still governs when auditing older threads. Before treating an unexpected review entry as a signal that the human intervened, check whether its body/inline-comment content reads like the agent's own scripted reply (referencing a specific commit SHA, restating verification numbers) rather than free-form human commentary — if so, it's the session's own tooling, not new human input. - **The same ambiguity runs the other way, and there it arrives as a positive claim rather than an inference you might draw.** An automated reviewer reading the PR's own history sees that same bot-account commit and can describe it *in its review body* as the work of a human, e.g. "that finding was confirmed and fixed by a human reviewer (`dem-extra1`) in commit ``", stating as fact something no API field asserts. + **The same ambiguity runs the other way, and there it arrives as a positive claim rather than an inference you might draw.** + An automated reviewer reading the PR's own history sees that same bot-account commit and can describe it *in its review body* as the work of a human, e.g. "that finding was confirmed and fixed by a human reviewer (`dem-extra1`) in commit ``", stating as fact something no API field asserts. That is worse than the inference case above, because the claim is now published prose a later reader inherits, and "a human already verified this" is precisely the sentence that stops the next person checking. Correct it in the thread when you see it, naming which account is actually a session identity; don't let it stand just because the surrounding verdict was clean. (`ucdavis/bcs#532`, 2026-07-31: a `claude-review` pass reported a fix as human-confirmed when `dem-extra1` was the Claude session that made it, and no human had touched the PR at that point.) - **`gh pr view --json` does not accept `merged` as a field.** Use `state` (returns `"MERGED"`) and `mergedAt` (ISO timestamp, null if not merged) to check merge status. Example: `gh pr view --json state,mergedAt`. diff --git a/memories/preferences.md b/memories/preferences.md index c7d11a12a..ade24fb94 100644 --- a/memories/preferences.md +++ b/memories/preferences.md @@ -786,7 +786,7 @@ Skill-builder encodes steps that are easy to skip when done ad hoc: the extend-first check, running the four local validation scripts (`validate-skills.py`, `check-links.py`, `check-vendored-drift.py`, `markdownlint-cli2`) before pushing, registering any cited MCP tool in `tool-mappings.yml`, updating `skills.qmd`'s count from the actual `skills/` directory count (not a manual +1), cross-linking related skills, and explicitly requesting a human reviewer after AI review passes. (Learned on ai-config#338 --- the `prompt-me`/`pm` skill was built and shipped without invoking `skill-builder`, so none of those steps ran; CI happened to catch what the scripts would have. Reinforced on ai-config#347 --- `resolve-pr-threads` was hand-authored and needed a review round to catch a `tool-mappings.yml` gap `skill-builder` already documented from a near-identical miss in `push-memory` #311.) - Claim a PR before pushing iterative commits to it, even when you opened the PR yourself in the same session --- this repo's `@claude` review workflow can fire and interleave with an in-flight push. - Post the "paws off" comment from `claim-pr` right after opening the PR, not just for PRs you're joining mid-flight. (Missed on ai-config#338: several commits were pushed across an ARDI-style review loop with no claim comment posted.) + Post the claim comment from `claim-pr` right after opening the PR, not just for PRs you're joining mid-flight. (Missed on ai-config#338: several commits were pushed across an ARDI-style review loop with no claim comment posted.) - Default dispatched subagents (`Agent({...})`, or `agent(prompt, {...})` inside a `Workflow` script) to a mid-tier model like Sonnet, not whatever model the conductor itself is running as. Reserve the top tier for genuinely judgment-heavy work (cross-cutting design docs, remediation on a disputed finding) and escalate only on explicit user request --- most fleet work (implement a spec, drive an ARDI loop, checklist-verify a "fully clean" claim against fresh queries) doesn't need the most capable tier. The concrete cost of skipping this: a session that dispatched ~30 unscoped subagents nearly exhausted a model-specific weekly quota (85-86% used, reset days out) while the all-models weekly pool sat at ~56-57% --- a wide gap between a model-specific usage bar and the all-models bar is the diagnostic signature of this exact mistake. diff --git a/scripts/orchestrator/pr_claim_manager.py b/scripts/orchestrator/pr_claim_manager.py index f5065988a..a261e3e18 100644 --- a/scripts/orchestrator/pr_claim_manager.py +++ b/scripts/orchestrator/pr_claim_manager.py @@ -147,7 +147,16 @@ def claim_issue_and_open_draft_pr( logger.info("Successfully opened draft PR for issue #%d: %s", issue_number, result["pr_url"]) # 7. Post claim comment on issue - claim_body = f"Orchestrator worker is working on this via draft PR #{result.get('pr_number')} -- paws off until done." + # The trailing marker discloses agent authorship on every comment we + # post, and is deliberately emoji-free: check-pr-fully-clean.py + # matches the robot emoji as a review-body marker, so a claim + # carrying it would scan as a finding-free review. + # See shared/workflow/disclose-agent-authorship.md. + claim_body = ( + f"Orchestrator worker is working on this via draft PR " + f"#{result.get('pr_number')} -- please hold off until done." + "\n\n_Posted by Claude Code (AI agent) --- not written by a human._" + ) comment_cmd = ["gh", "issue", "comment", str(issue_number), "--body", claim_body] if effective_repo: comment_cmd.extend(["-R", effective_repo]) diff --git a/scripts/sync-codex-skill-wrappers.py b/scripts/sync-codex-skill-wrappers.py index b8b85eead..1afa7f0d9 100755 --- a/scripts/sync-codex-skill-wrappers.py +++ b/scripts/sync-codex-skill-wrappers.py @@ -162,6 +162,19 @@ def reference_doc(mappings: dict) -> str: concrete tools — mostly `gh`/`git` commands. This registry maps each canonical operation to the equivalent GitHub MCP tool so any model can run a skill. +> [!IMPORTANT] +> **Every comment-posting operation below carries the agent-disclosure marker in +> its body**, on its own line after a blank line: +> `_Posted by Claude Code (AI agent) --- not written by a human._` +> +> This registry is the substitution point for remote/web sessions, which have no +> `gh` at all --- so a marker-free template here is a marker-free comment there, in +> exactly the population that cannot fall back to the CLI examples in the skills. +> See [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). +> Two exemptions: a body another machine parses as a command (`@dependabot +> rebase`), and a comment posted under a genuine bot token, where the forge +> already reports `type: Bot`. + ## How each model resolves an operation {chr(10).join(model_lines)} diff --git a/shared/workflow/claim-pr.md b/shared/workflow/claim-pr.md index 68a57f4fa..96d6ab879 100644 --- a/shared/workflow/claim-pr.md +++ b/shared/workflow/claim-pr.md @@ -6,13 +6,21 @@ to start a conflicting parallel session. Use: ``` -gh pr comment --body "Working on this --- paws off until I'm done." -gh issue comment --body "Working on this --- paws off until I'm done." +gh pr comment --body "Working on this --- please hold off on pushing to this branch until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" +gh issue comment --body "Working on this --- please hold off until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" ``` -Then proceed with the work. After the session ends (PR merged, issue closed, or -work otherwise paused), follow up with a closing comment so the PR/issue is -unclaimed for the next person. +Both halves of that body are load-bearing, and they answer different questions. +The first line says the thread is claimed. +The trailing line says **who claimed it**, and it is required on every comment an agent posts to a forge --- not only on a claim. +See [`disclose-agent-authorship`](disclose-agent-authorship.md), which carries the rule, the exact marker, and why the marker deliberately avoids the robot emoji. + +Then proceed with the work. +After the session ends (PR merged, issue closed, or work otherwise paused), follow up with a closing comment so the PR/issue is unclaimed for the next person. Skip the claim step if the most recent comment already says you are working on it **and that claim is still live under the expiration rule below**. @@ -33,12 +41,8 @@ PR is a stronger "in-flight" signal than a comment alone. **A claim expires 2 hours after the most recent push or comment on the PR/issue --- reassert it rather than resuming under a stale one.** -A claim comment with no expiry binds the thread indefinitely: a crashed or -abandoned session leaves its "paws off" standing forever, and a second session -has no rule for when the claim stops blocking. -So the convention is time-boxed and keyed to observable activity: a claim is -**live for 2 hours from the most recent push or comment** on the PR/issue, and -**expired** past that. +A claim comment with no expiry binds the thread indefinitely: a crashed or abandoned session leaves its "hold off" standing forever, and a second session has no rule for when the claim stops blocking. +So the convention is time-boxed and keyed to observable activity: a claim is **live for 2 hours from the most recent push or comment** on the PR/issue, and **expired** past that. The rule cuts both ways. @@ -87,39 +91,73 @@ while under-respecting a live one costs a collision. issue claims last 2 hours from the most recent push or comment; if it's been longer than that, reassert your claim.") -**Verify a mid-task "already done" claim against real PR state before trusting -or redoing it.** A PR you claimed and are actively driving can still gain -commits from a **second, independently-running session** under the same -account --- a `` review-comment-reply event can -describe work ("Addressed... Pushed in ``") that this session never did. -Don't assume it's fabricated or injected, and don't reflexively redo the same -fix: cross-check the PR's actual commit list (`gh pr view --json commits` / -`pull_request_read` `get_commits`) and review threads before either (a) -trusting the claim, or (b) starting the same fix yourself. If a commit with -that SHA genuinely exists, authored close to when the event arrived, treat it -as confirmation a live parallel session owns this PR right now --- stop -pushing further speculative fixes yourself, and, if genuinely in doubt, ask -whether to keep driving or step back, rather than racing the other session's -pushes. This gap is distinct from the initial claim check above: it's not -about claiming a PR before starting, but about **re-verifying you're still -the sole active driver** once work has been under way for a while --- -especially when you picked up the PR mid-session (e.g. by answering a -diagnostic question about it) rather than through the normal claim-then-branch -flow, so no fresh "paws off" check ever ran right before you started pushing. -(`d-morrison/gha#286`, 2026-07-24: a webhook event delivered a review-comment -reply attributed to `d-morrison` reading exactly like a Claude-authored -reply, claiming a fix "Addressed... Pushed in 3fb8c5b" that this session -hadn't made; verified real via `get_commits` before proceeding --- a second -live session, not injection.) - -**The git-level variant of that check: a rejected push whose remote commit is -byte-for-byte what you were about to push.** +**Every detector of a claim matches the OLD wording as well as the new one, and dropping the old alternation is the one edit that fails silently.** +There were **two** retired invariants, not one, and enumerating them from the file in front of you is how the second was missed for a whole review round. +Most emitters carried the `paws off` invariant --- `claim-pr`, `gi`, `st`, `gip`, `pr-on-claim`, `post-merge`, `handoff` (as "still claimed, paws off.") and the orchestrator (as "paws off until done"). +[`ardi`](ardi.md) did not. +It said "back off until done", and had done since 2026-06-17 --- and it is the corpus's highest-traffic claim emitter, run on every PR in every repo. +Both now read "please hold off ...". + +Derive that set from history rather than from the current tree, which no longer contains any of them. +Match the **wordings**, not the `--body` flag. +Keying on the flag looks tighter and reaches neither `gip`, which states its claim as quoted prose inside a worker brief, nor the orchestrator, which builds the body as a Python f-string on a different line from the flag --- the two emitters least like the others, and so the two a flag-shaped search is least able to find. +Widen the pathspec past `skills/` for the same reason. + +```bash +git log -p --all -- 'skills/**' 'shared/**' 'commands/**' 'scripts/**' \ + | grep -oiE '^\+.*(paws off|back off|hold off)[^"]*' | sort -u +``` + +That returns the matcher and prose lines too, so it wants a skim rather than a count --- the deliberate trade for a search that cannot miss an emitter because of how it happened to spell the call. + +Claims posted before that are still sitting on open PRs and issues, and a claim stays live on activity rather than on age --- so a thread claimed under the old wording and pushed to this morning is live right now. + +A detector narrowed to the new phrase alone still returns cleanly on such a thread. +It returns **nothing**, which is indistinguishable from an unclaimed thread, and that reading licenses exactly the parallel session this whole convention exists to prevent. +Nothing in the output announces the miss: a claim search that finds no claim looks the same whether the thread is free or the matcher went blind. + +**Match the two-word invariant, never a whole sentence.** +The claim body varies by target --- a PR claim says "please hold off on pushing to this branch until I'm done" and an issue claim says "please hold off until I'm done" --- so neither sentence contains the other, and a detector keyed on either one is blind to half the claims. +Under the old single-string wording that distinction did not exist, which is exactly why it is easy to carry a whole-sentence matcher across the rename without noticing it has narrowed. +`hold off` is the invariant. +`paws off` and `back off` are its two predecessors, and a matcher naming only the first is the failure this very section describes, committed by the section itself. + +So match the alternation, case-insensitively, everywhere a claim is read: + +```bash +gh pr view --json comments \ + -q '.comments[] | select(.body | test("hold off|paws off|back off"; "i"))' # READ_PR_COMMENTS +``` + +Keep both old alternatives until no claim under a retired wording can plausibly still be live --- which, given the 2-hour rule keys on activity and not on the comment's own age, means until every PR and issue open on 2026-08-24 has closed. +Removing it is a deliberate later edit, not tidying to do in passing. + +**Then check the same comment for a release term, because one release marker contains a claim invariant.** +The retired release wording is `... done --- paws off released.`, which matches `paws off` --- so the invariant that fixes the whole-sentence bug introduces a second one, and this one fails the quiet way round: a *released* PR reads as claimed, the reader backs off, and nothing reports why. +The sentence matcher this replaced did not collide, so the collision arrived with the fix. +Treat a comment as a release rather than a claim when it also matches `unclaim|released|PR is free|now mergeable`, and derive that list rather than copying it: `grep -rn "unclaim\|released\|PR is free\|now mergeable" skills/ commands/`. + +- **Do:** match `hold off|paws off|back off` case-insensitively wherever a claim is read, then exclude the comment if it also carries a release term. +- **Do:** treat both old alternatives as load-bearing until the threads carrying them have closed. +- **Don't:** read an empty claim search as an unclaimed thread without first confirming the matcher covers both wordings --- the two results are identical. +- **Don't:** drop a back-compat alternative as part of an unrelated change. +- **Don't:** enumerate the retired wordings from the files you happen to be editing --- `back off` was invisible to exactly that method for a full review round, because the one file that posted it was not one of the seven that agreed with each other. + +**Verify a mid-task "already done" claim against real PR state before trusting or redoing it.** +A PR you claimed and are actively driving can still gain commits from a **second, independently-running session** under the same account. +A `` review-comment-reply event can describe work this session never did, in the form "Addressed, pushed in ``". +Don't assume it's fabricated or injected, and don't reflexively redo the same fix: cross-check the PR's actual commit list (`gh pr view --json commits` / `pull_request_read` `get_commits`) and review threads before either (a) trusting the claim, or (b) starting the same fix yourself. +If a commit with that SHA genuinely exists, authored close to when the event arrived, treat it as confirmation a live parallel session owns this PR right now --- stop pushing further speculative fixes yourself, and, if genuinely in doubt, ask whether to keep driving or step back, rather than racing the other session's pushes. +This gap is distinct from the initial claim check above: it's not about claiming a PR before starting, but about **re-verifying you're still the sole active driver** once work has been under way for a while --- especially when you picked up the PR mid-session (e.g. by answering a diagnostic question about it) rather than through the normal claim-then-branch flow, so no fresh claim check ever ran right before you started pushing. + +(`d-morrison/gha#286`, 2026-07-24: a webhook event delivered a review-comment reply attributed to `d-morrison`, reading exactly like a Claude-authored reply and claiming a fix this session hadn't made, worded "Addressed, pushed in 3fb8c5b". +It was verified real via `get_commits` before proceeding --- a second live session, not injection.) + +**The git-level variant of that check: a rejected push whose remote commit is byte-for-byte what you were about to push.** The section above covers a *comment* claiming work was done. Here the parallel session makes no claim at all. -Your `git push` is simply rejected because it pushed first, and what it -pushed is the same merge you just made. -The reflex on a rejected push is to merge again, which would stack a -redundant merge commit on top of an identical one. +Your `git push` is simply rejected because it pushed first, and what it pushed is the same merge you just made. +The reflex on a rejected push is to merge again, which would stack a redundant merge commit on top of an identical one. Four reads settle it before you touch anything: @@ -130,19 +168,14 @@ git show -s --format=%P HEAD # your merge's parents git show -s --format=%P origin/ # its parents ``` -An identical tree plus identical parents means the two merges are the same -merge, so the right action is `git reset --hard origin/`. +An identical tree plus identical parents means the two merges are the same merge, so the right action is `git reset --hard origin/`. -- **Do:** compare trees and parents before deciding what a rejected push - means. -- **Do:** discard your local merge with `git reset --hard origin/` - once both match. -- **Don't:** re-merge reflexively on a rejected push --- that is what - produces the redundant merge commit. +- **Do:** compare trees and parents before deciding what a rejected push means. +- **Do:** discard your local merge with `git reset --hard origin/` once both match. +- **Don't:** re-merge reflexively on a rejected push --- that is what produces the redundant merge commit. - **Don't:** force-push over the other session's commit. -(`Morrison-Lab/ai-config#965`, 2026-07-31: `main` moved one commit, a local -`git merge origin/main` was made, and the push was rejected. +(`Morrison-Lab/ai-config#965`, 2026-07-31: `main` moved one commit, a local `git merge origin/main` was made, and the push was rejected. The remote carried `b8d2273`, a merge of the same two parents, with tree `1bda1bc`, identical to the local merge's.) diff --git a/shared/workflow/disclose-agent-authorship.md b/shared/workflow/disclose-agent-authorship.md new file mode 100644 index 000000000..f77774ad0 --- /dev/null +++ b/shared/workflow/disclose-agent-authorship.md @@ -0,0 +1,113 @@ +Every comment an agent posts to a forge --- GitHub, GitLab, or any other --- says, in the body, that an agent posted it. + +The reason is that the forge cannot say it for you. +An agent driving `gh` under the account holder's credentials posts as **that person**: the comment carries their avatar, their login, and a `MEMBER` or `OWNER` association, and nothing in the API response distinguishes it from a comment they typed. +`type` reads `User`, not `Bot`, because the token belongs to a user. +So a reader deciding how much weight to give a claim, a status report, or a review has no way to tell which of the two they are reading, and the default reading is the wrong one. + +The marker is one line, on its own, at the end of the body: + +``` +_Posted by Claude Code (AI agent) --- not written by a human._ +``` + +**It deliberately does not use the robot emoji.** +That looks like the obvious choice, and it is the one thing the marker must avoid: `scripts/check-pr-fully-clean.py` matches the bare emoji as a `REVIEW_BODY_MARKERS` entry, so any comment carrying it is admitted into the verdict scan as a review item. +A disclosure footer on every agent comment would therefore turn every claim, every status note, and every deferral into something the fully-clean checker reads as a review --- and a claim comment carries no findings, so it would scan as a **clean** one. +That is the false-clean failure [`fully-clean`](fully-clean.md) already describes for a human-authored self-review, arriving through the very mechanism added to make authorship legible. + +The marker above collides with none of the checker's `REVIEW_BODY_MARKERS` (the robot emoji, `### ` plus that emoji, `code review`, `**claude finished`, `### verdict`, `verdict:`) nor with any `REVIEW_AGENT_MARKERS` entry, verified against `scripts/check-pr-fully-clean.py` on 2026-08-24. +Check a replacement marker against both tuples before changing it. + +**The scope is every comment, not every review.** +Review comments are the case that already discloses, since a review body announces its own agent. +The comments that need this are the ones that read most like a person: a claim, a release, a status update, a reply on a review thread, a comment filed on an issue on the user's behalf, a paraphrase of the user's own in-chat feedback. +An issue's own **body** is not a comment and stays unattributed, per [`defer-issue`](../../skills/defer-issue/SKILL.md), which is why this item says *comment* rather than *issue*. +Each of those is short, conversational, and posted under a human login, which is exactly the shape that gets mistaken for a human. + +**A comment posted under a genuine BOT identity needs no marker.** +The whole argument above is that the forge cannot report authorship when the token belongs to a person. +Where it belongs to an app --- a workflow posting as `github-actions[bot]`, so the API returns `type: Bot` and the UI shows a bot badge --- the forge already says it, and a marker adds nothing. +`skills/claude-agent-workflow/SKILL.md`'s in-workflow reply is that case. + +The test is the **token**, not the author: an agent driving `gh` with the account holder's PAT posts as a `User` however automated the surrounding workflow is. +Check `.user.type` on a posted comment if unsure, rather than reasoning from how the comment was produced --- and pick the endpoint by comment KIND, since an issue-or-PR comment and a review-thread comment have separate id spaces and separate routes: + +```bash +gh api repos///issues/comments/ --jq .user.type # PR or issue comment +gh api repos///pulls/comments/ --jq .user.type # review-thread reply +``` + +Asking the first about a review-thread comment returns `404 Not Found` --- which reads as a missing comment rather than as the wrong route, and is the reason to pick the route by kind rather than to try one and see. + +**A prose self-identification is not a substitute for the marker.** +"Claude Code CLI (local session) is working on this" already discloses, so appending the footer to it looks redundant. +Keep both. +A convention worth anything has to be checkable by one query rather than by reading each body and judging whether its prose happened to disclose --- and a uniform trailing marker is what makes a sweep, or a hook, possible at all. + +**Search for the marker as a substring, never as a whole line.** +A body composed inside an indented code fence carries that fence's indentation into the posted comment, so the marker can arrive with leading spaces. +Dedenting the source to column 0 is not the fix: a column-0 line ends the enclosing list item, which closes the fence and turns the marker into prose, so the comment stops being shown as a command at all. +Whether markdownlint notices depends on the file --- measured 2026-08-24 with `markdownlint-cli2@0.22.1` in a repo-wide run under this repo's config, the same dedent raises `MD049` (emphasis-style) in `skills/post-merge/SKILL.md`, `MD046` (code-block-style) in `commands/release-pr.md`, and **nothing at all** in `skills/st/SKILL.md`. +Read the per-file lines, not the run's whole output. +A first pass at this measurement dedented one file at a time and grepped the rule codes out of the entire run, which reported `MD049` for all three --- because a *different* file was broken at the time and contributed its own `MD049` to every run. +The cause was never the invocation. +`markdownlint-cli2` unions a command-line path with the config's globs, so `markdownlint-cli2 commands/release-pr.md` lints all 573 files under the same config as a bare run --- which is why filtering the output by filename is what the loop needed. +Single-file scope does exist, and `--help` names it: `--no-globs` ignores the config's `globs` and lints the one path (573 files versus 1, measured 2026-08-24). +Reach for that rather than for a filter when isolating a file. +The mistake was attributing an error to whichever file the loop happened to be testing, which is [`metacognitive-monitoring`](metacognitive-monitoring.md)'s cause check going unasked. +`MD049` defaults to `consistent`, so it fires only where the document already established asterisk emphasis --- which makes the linter a partial detector here rather than the check. + +**That `consistent` default cuts the other way too, and it is the sharper hazard.** +The marker is underscore-emphasised, so a marker that escapes its fence into prose becomes the document's first emphasis and sets the expectation to underscore --- whereupon every pre-existing `*asterisk*` in that file starts failing, at line numbers far from the edit. +`skills/migrate-discussion/SKILL.md` has exactly that shape, with two asterisk-emphasised lines around 165. + +Do not rely on the linter to catch it, though. +A re-measurement on 2026-08-24 could not reproduce an `MD049` there by the same method that reproduces the other three files' results exactly, so the structural hazard is real and the detector's coverage of it is not established. +Treat marker indentation as something to get right rather than something a check will report. + +**Where the marker must NOT go: content that is not a comment.** +A commit message, a title of any kind, an issue **body**, or a PR body has its own attribution conventions and its own consumers, and a trailing italic line in a commit message corrupts a changelog. +PR bodies already carry the harness's own generated-with footer. +This rule governs comment bodies. + +**The first of two exemptions, and it is narrow: a comment another MACHINE parses as a command.** +`@dependabot rebase`, `@dependabot squash and merge`, and their equivalents are not addressed to a reader at all --- they are an API call wearing a comment, and the receiving bot parses the body. +Appending prose to one risks changing what it parses, for no reader's benefit, since nobody mistakes `@dependabot rebase` for a human's considered opinion. + +The exemption is about the **audience**, not about brevity. +A one-line status comment is short and still has a human reader, so it carries the marker. + +As of 2026-08-24 the exemption covers three sites: `skills/chores/SKILL.md`'s two Dependabot commands, and the review re-request `skills/ardi/SKILL.md` mandates, whose whole body is the reviewer's own `@`-mention. +That third one is worth naming because the first draft of this rule missed it and asserted the other two were the only instances --- an enumeration of a population nobody had queried, which is [`metacognitive-monitoring`](metacognitive-monitoring.md)'s scope-claim failure. + +**No single query derives that set, so run two and say why the first is short.** +The literal `--body "@...` grep finds the two Dependabot sites and misses the third for a mundane reason: `ardi` states its review re-request in prose rather than in a `--body` command, so a command-shaped search cannot reach it. + +An earlier draft of this passage gave a different and wrong reason --- that the handle is never spelled contiguously in a source file, because a diff view would summon the bot. +That is false twice over. +The handle appears 248 times across this corpus's markdown, counting every file but this one --- the command below is itself an occurrence, so a figure that included this file would move each time the file was edited, and both earlier drafts of this sentence were wrong for exactly that reason. +And [`memories/mention-triggers.md`](../../memories/mention-triggers.md) states the gate as `contains(github.event.comment.body, '@claude')`, over comment, review and issue bodies --- file contents are not among them. +The practice of not spelling it applies to text that becomes a comment, which is what that file scopes it to. + +```bash +# Option flags go BEFORE `--`; after it they are read as file operands, and the +# search silently runs unfiltered. The first draft of this block got that wrong +# and returned its own test fixtures as apparent sites. +grep -rn --include='*.md' --include='*.sh' -- '--body "@' . # command-shaped sites +grep -rn '@claude' skills/ # prose sites +git grep -noi '@claude' -- '*.md' ':!shared/workflow/disclose-agent-authorship.md' | wc -l # the figure above +``` + +- **Do:** omit the marker on a comment whose whole body is a command addressed to another bot. +- **Don't:** widen that to any comment that happens to be short, or to any comment posted by automation --- the test is whether a machine parses the body, not whether a machine wrote it. + +- **Do:** end every agent-posted forge comment with the marker line, on its own, after a blank line. +- **Do:** keep the marker on comments whose prose already identifies the session, so one query finds all of them. +- **Do:** check a proposed replacement marker against `check-pr-fully-clean.py`'s `REVIEW_BODY_MARKERS` and `REVIEW_AGENT_MARKERS` before adopting it. +- **Don't:** use the robot emoji in the marker --- it is a review-body marker, and it converts every disclosed comment into a finding-free review item. +- **Don't:** treat a comment posted under a human login as self-evidently agent-authored because the account holder knows an agent is running. + The reader is whoever finds the thread later. +- **Don't:** put the marker in a commit message, a title, an issue body, or a PR body. + +(Directive from the user, 2026-08-24: "all comments online posted by bots should say so", citing --- an agent-authored claim comment posted under `d-morrison`, `type: User`, `author_association: MEMBER`, reading exactly like a human's.) diff --git a/shared/workflow/learn-from-review-findings.md b/shared/workflow/learn-from-review-findings.md index e8ed6023d..a4e73a33f 100644 --- a/shared/workflow/learn-from-review-findings.md +++ b/shared/workflow/learn-from-review-findings.md @@ -82,7 +82,7 @@ Both remedies the round-3 review suggested were a fourth narrowing, and each is A manual action by someone else, on a PR you claimed, is the same trigger with nothing labelled a finding. The trigger above still needs a reviewer to state a defect. This is the version where nobody states anything. -You claimed the PR --- posted the "paws off" comment, per [`claim-pr`](claim-pr.md) --- and while you are driving it, another actor performs an action that was already yours to have taken. +You claimed the PR --- posted the claim comment, per [`claim-pr`](claim-pr.md) --- and while you are driving it, another actor performs an action that was already yours to have taken. A human requests the review you should have requested the moment the PR opened or went ready, per [`pr-on-claim`](pr-on-claim.md)'s "Request the external reviewer in the same stride". A maintainer merges `main` in to clear a conflict [`sync-with-main`](sync-with-main.md) says you should have been watching for. A bot resolves a thread [`address-every-comment`](address-every-comment.md) says you should have resolved on Address. diff --git a/skills.qmd b/skills.qmd index cf4dc7486..caf8b8293 100644 --- a/skills.qmd +++ b/skills.qmd @@ -32,7 +32,7 @@ field (e.g. "grab an issue", "drive to clean", "update memories and skills"). | [`ardi`](skills/ardi/SKILL.md) | `dc`, `drive`, `clean`, `iterate` | Read the latest review, address/rebut/defer every finding, push, re-request review — loop until clean | | [`ard`](skills/ard/SKILL.md) | `adr` | Single ARD pass: address, rebut, defer, or acknowledge — without the iterate loop | | [`resolve-pr-threads`](skills/resolve-pr-threads/SKILL.md) | | Resolve inline review threads that are already settled, without a full ARD pass | -| [`claim-pr`](skills/claim-pr/SKILL.md) | | Post a "paws off" comment before a work session; unclaim when done | +| [`claim-pr`](skills/claim-pr/SKILL.md) | | Post a claim comment before a work session; unclaim when done | | [`request-pr-review`](skills/request-pr-review/SKILL.md) | | Add a human reviewer and @-mention them with context | | [`pr-status`](skills/pr-status/SKILL.md) | | Report the current PR's CI and review state | | [`pr-status-all`](skills/pr-status-all/SKILL.md) | | Report status across all open PRs | diff --git a/skills/ard/SKILL.md b/skills/ard/SKILL.md index 8cc26437f..890fed912 100644 --- a/skills/ard/SKILL.md +++ b/skills/ard/SKILL.md @@ -112,6 +112,17 @@ gh pr comment --body-file ard-summary.md # COMMENT_PR — GitHub glab mr note -F ard-summary.md # GitLab ``` +**End `ard-summary.md` with the agent-disclosure marker**, on its own line after a blank line: + +``` +_Posted by Claude Code (AI agent) --- not written by a human._ +``` + +This comment is posted under the account holder's own login on every round of every PR, so it is among the likeliest in the corpus to be mistaken for their own writing --- see [`disclose-agent-authorship`](../../shared/workflow/disclose-agent-authorship.md). +The body arrives via `--body-file`, which the disclosure guard cannot read. +It still warns --- but only that the body is unreadable, never that the marker is absent, and that weaker note reads as a formality rather than a finding. +So this step states the requirement itself. + **Keep the bot's trigger phrase out of the summary body.** The `issue_comment` trigger fires on the bare bot `@`-mention **anywhere** in a comment — even in a sentence saying you're *not* re-requesting a review. Refer to it obliquely @@ -162,6 +173,8 @@ resolve via GraphQL (`RESOLVE_REVIEW_THREAD`): # /tmp/reply-.md silently writes to the wrong file) cat > "/tmp/reply-.md" <<'EOF' ✅ Addressed in ``. + +_Posted by Claude Code (AI agent) --- not written by a human._ EOF gh api "repos/{owner}/{repo}/pulls//comments" \ -F in_reply_to="" -F body="@/tmp/reply-.md" # REPLY_REVIEW_COMMENT @@ -182,6 +195,8 @@ In a remote/web session without `gh`, resolve `RESOLVE_REVIEW_THREAD` via ```bash cat > "/tmp/reply-.md" <<'EOF' Addressed in ``. + +_Posted by Claude Code (AI agent) --- not written by a human._ EOF glab api -X POST "projects/:id/merge_requests//discussions//notes" \ -F body="@/tmp/reply-.md" diff --git a/skills/ardi/SKILL.md b/skills/ardi/SKILL.md index 5b7623dcd..de693ed23 100644 --- a/skills/ardi/SKILL.md +++ b/skills/ardi/SKILL.md @@ -19,7 +19,14 @@ finding → push → post summary → re-request review → repeat until clean. 1. **Identify and claim the PR/MR.** Use the current branch's open MR, or the one the user specified. -Post a brief claim comment (`COMMENT_PR`) so a parallel `@claude` CI run or another person doesn't start a colliding session: `gh pr comment --body "Driving this PR to clean --- back off until done."` +Post a brief claim comment (`COMMENT_PR`) so a parallel `@claude` CI run or another person doesn't start a colliding session. +The body carries a real blank line before the disclosure marker --- `\n` inside a bash double-quoted string is the two characters, not a newline: + +```bash +gh pr comment --body "Driving this PR to clean --- please hold off until done. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR +``` Skip if your most recent comment already says so and is still live --- claims expire 2 hours after the most recent push or comment, and an expired one needs reasserting, per [`claim-pr`](../../shared/workflow/claim-pr.md). (`COMMENT_PR` and the other bracketed tokens below are abstract operation tokens --- resolve to your model's tool via [`tool-mappings.md`](../../tool-mappings.md).) @@ -383,8 +390,13 @@ The loop stopped to ask twice under the old guard; both times the answer was to ## On clean -Post an unclaim comment (`COMMENT_PR` --- `gh pr comment --body "Done --- PR is free."`) to -unblock any parallel sessions that backed off in step 1. +Post an unclaim comment (`COMMENT_PR`) to unblock any parallel sessions that backed off in step 1: + +```bash +gh pr comment --body "Done --- PR is free. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR +``` **Then run `ums`, before reporting ready.** The clean verdict is the proactive-UMS checkpoint for this PR, not the merge; diff --git a/skills/ardia/SKILL.md b/skills/ardia/SKILL.md index a4a109f13..db8ddda26 100644 --- a/skills/ardia/SKILL.md +++ b/skills/ardia/SKILL.md @@ -36,7 +36,7 @@ mutates a PR stays serial. - **Skip a driven draft.** Any of these marks one: the head commit is still the `start:` scaffold (the implementer is mid-flight), - a still-live "paws off" claim comment stands (claims expire after 2 hours with no push or comment --- [`claim-pr`](../../shared/workflow/claim-pr.md)), + a still-live claim comment stands (claims expire after 2 hours with no push or comment --- [`claim-pr`](../../shared/workflow/claim-pr.md)), another actor pushed recently, or the draft is deliberately held as a merge-order gate (`CLAUDE.md`'s "Surface merge-order constraints", surface 3). - **Include a parked draft.** diff --git a/skills/claim-pr/SKILL.md b/skills/claim-pr/SKILL.md index 39df7e017..2e834999b 100644 --- a/skills/claim-pr/SKILL.md +++ b/skills/claim-pr/SKILL.md @@ -34,8 +34,12 @@ Past 2 hours the claim has expired; re-post it before resuming. ### GitHub ```bash -gh pr comment --body "Claude Code CLI (local session) is working on this — paws off until I'm done." # COMMENT_PR -gh issue comment --body "Claude Code CLI (local session) is working on this — paws off until I'm done." # COMMENT_ISSUE +gh pr comment --body "Claude Code CLI (local session) is working on this — please hold off on pushing to this branch until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR +gh issue comment --body "Claude Code CLI (local session) is working on this — please hold off until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_ISSUE ``` (`COMMENT_PR` / `COMMENT_ISSUE` are abstract operation tokens — resolve to your @@ -47,7 +51,9 @@ On GitLab, post the claim as a **resolvable discussion** (not a plain note) so it can be resolved later: ```bash -glab mr note create --message "Claude Code CLI (local session) is working on this — paws off until I'm done." +glab mr note create --message "Claude Code CLI (local session) is working on this — please hold off on pushing to this branch until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" ``` > GitLab MR notes are resolvable discussions by default. @@ -102,7 +108,16 @@ DISCUSSION_ID=$(glab api "projects//merge_requests//discussi import json, sys for d in json.load(sys.stdin): for n in d.get('notes', []): - if 'paws off' in n.get('body', '') and not n.get('resolved'): + body = n.get('body', '').lower() + # Both wordings: claims posted before 2026-08-24 say 'paws off'. + # A RELEASE is excluded first -- the retired release note + # '... done --- paws off released.' contains 'paws off', so a claim-only + # test resolves the release's thread instead of the claim's. + is_release = any(t in body for t in + ('unclaim', 'released', 'pr is free', 'now mergeable')) + is_claim = (any(t in body for t in ('hold off', 'paws off', 'back off')) + and not is_release) + if is_claim and not n.get('resolved'): print(d['id']); break else: continue break @@ -117,8 +132,12 @@ glab api --method PUT \ ### GitHub — post a closing comment ```bash -gh pr comment --body "Done with my local session — unclaiming." # COMMENT_PR -gh issue comment --body "Done with my local session — unclaiming." # COMMENT_ISSUE +gh pr comment --body "Done with my local session — unclaiming. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR +gh issue comment --body "Done with my local session — unclaiming. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_ISSUE ``` ## Notes diff --git a/skills/config-ai/SKILL.md b/skills/config-ai/SKILL.md index 89dc64bcb..4a0970694 100644 --- a/skills/config-ai/SKILL.md +++ b/skills/config-ai/SKILL.md @@ -147,7 +147,7 @@ last two. the implementation form chosen in Step 1 and why, the target file(s)/path(s), and enough detail that a future `gi`/`grab-issue` session -- which does have push access -- can build it unattended. - Don't claim it (`claim-pr`'s "paws off" comment is for a session about to implement); + Don't claim it (`claim-pr`'s claim comment is for a session about to implement); leave it open for whoever picks it up. 3. **No access to the target repo at all** (not in the session's GitHub scope, no network path, issue creation itself fails) → file the issue in the **current** repo instead -- whatever repo the session is actually working in. Write it standalone, diff --git a/skills/defer-issue/SKILL.md b/skills/defer-issue/SKILL.md index d8646424c..0df07e95d 100644 --- a/skills/defer-issue/SKILL.md +++ b/skills/defer-issue/SKILL.md @@ -130,9 +130,10 @@ EOF - Check `gh label list` for an existing `followup`, `deferred`, or `tech-debt` label and add it with `--label`. **Don't fabricate labels that don't exist** — `gh` will fail and you'll have to retry. -- Don't add `🤖 Generated with Claude Code` attribution to the issue body - unless the user asks. Issue attribution isn't covered by the global - `attribution` setting. +- Don't add `🤖 Generated with Claude Code` attribution to the issue **body** unless the user asks. + Issue attribution isn't covered by the global `attribution` setting. + This is not an exemption from the agent-disclosure rule, which governs **comments** rather than issue bodies and uses a different, deliberately emoji-free marker --- see [`disclose-agent-authorship`](../../shared/workflow/disclose-agent-authorship.md). + So a comment this skill posts on the deferring PR carries the marker, while the filed issue's own body stays unattributed. **GitLab:** diff --git a/skills/discussions/SKILL.md b/skills/discussions/SKILL.md index 914421293..e56d2950e 100644 --- a/skills/discussions/SKILL.md +++ b/skills/discussions/SKILL.md @@ -181,7 +181,9 @@ Top-level comment on the discussion (uses the discussion `id` from step 3, `COMMENT_DISCUSSION`): ```bash -gh api graphql -f discussionId='' -f body='' -f query=' +gh api graphql -f discussionId='' -f body=' + +_Posted by Claude Code (AI agent) --- not written by a human._' -f query=' mutation($discussionId: ID!, $body: String!) { addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { comment { id url } @@ -193,7 +195,9 @@ Threaded reply to a specific comment — add `replyToId` (the comment `id`, also `COMMENT_DISCUSSION`): ```bash -gh api graphql -f discussionId='' -f replyToId='' -f body='' -f query=' +gh api graphql -f discussionId='' -f replyToId='' -f body=' + +_Posted by Claude Code (AI agent) --- not written by a human._' -f query=' mutation($discussionId: ID!, $replyToId: ID!, $body: String!) { addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) { comment { id url } diff --git a/skills/gi/SKILL.md b/skills/gi/SKILL.md index 959ac4080..f1bc47cb7 100644 --- a/skills/gi/SKILL.md +++ b/skills/gi/SKILL.md @@ -87,12 +87,10 @@ check both explicitly here). gh issue view --json comments --jq '.comments | last | .body' | cat # READ_ISSUE_COMMENTS ``` -If it contains "Working on this" / "paws off" (or an equivalent claim), skip -the issue --- unless the claim has expired: no push or comment on the issue in -over 2 hours, per [`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration -rule. -An expired claim is taken over by posting your own claim comment, never -silently. +Match the two-word invariant `hold off`, or either retired wording `paws off` / `back off`, case-insensitively --- then **exclude the comment if it also carries a release term** (`unclaim|released|PR is free|now mergeable`), because the retired release wording `... done --- paws off released.` contains `paws off` and would otherwise read as a live claim. +See [`claim-pr`](../../shared/workflow/claim-pr.md)'s "Match the two-word invariant". +If a live claim stands, skip the issue --- unless the claim has expired: no push or comment on the issue in over 2 hours, per [`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration rule. +An expired claim is taken over by posting your own claim comment, never silently. **(2) No open PR already references the issue:** @@ -124,10 +122,14 @@ MRs/PRs that touched the same area. Don't undo past progress. ```bash # GitHub -gh issue comment --body "Claude Code CLI (local session) is working on this — paws off until I'm done." # COMMENT_ISSUE +gh issue comment --body "Claude Code CLI (local session) is working on this — please hold off until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_ISSUE # GitLab -glab issue note --message "Claude Code CLI (local session) is working on this — paws off until I'm done." +glab issue note --message "Claude Code CLI (local session) is working on this — please hold off until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" ``` ### 7. Create a branch diff --git a/skills/gip/SKILL.md b/skills/gip/SKILL.md index c739a3054..0bdd56bee 100644 --- a/skills/gip/SKILL.md +++ b/skills/gip/SKILL.md @@ -142,8 +142,9 @@ and the default branch for each issue: > stay entirely within this worktree and touch only files relevant to this issue. > > 1. **Claim it** so no one else double-works it: -> post a brief "Working on this --- paws off until I'm done." comment on the issue +> post a brief "Working on this --- please hold off until I'm done." comment on the issue > (`gh issue comment --body "..."`, or the MCP `mcp__github__add_issue_comment` equivalent in a remote session). +> End the body with the agent-disclosure marker every agent-posted comment carries: `_Posted by Claude Code (AI agent) --- not written by a human._` > 2. **Check history** — before writing code, > scan merged/closed PRs that touched the same area so you don't undo past work or reintroduce a fixed bug (`gh pr list --state all --search ""`). > If a past PR already solved this, diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index 78ddffdc6..80f2cd61f 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -79,11 +79,13 @@ short note so the `@claude` bot and other sessions don't push conflicting changes — especially when you have unpushed local commits or running jobs. ```bash -gh pr comment --body "⏸️ **Local session paused** () — still claimed, paws off. +gh pr comment --body "⏸️ **Local session paused** () — still claimed. <2-4 bullets: in-flight jobs + IDs, unpushed local commits and why held, what runs next> -Please don't push to this branch in the meantime." # COMMENT_PR +Please hold off on pushing to this branch in the meantime. + +_Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR ``` If the work is genuinely *finished* (merged/closed, nothing outstanding), post a diff --git a/skills/migrate-discussion/SKILL.md b/skills/migrate-discussion/SKILL.md index 96f5173d5..b312816aa 100644 --- a/skills/migrate-discussion/SKILL.md +++ b/skills/migrate-discussion/SKILL.md @@ -87,7 +87,9 @@ this is outward-facing and hard to reverse. 3. Comment on the discussion pointing at the new issue, then close it: ```bash - gh api graphql -f discussionId='' -f body='Moved to to track the actionable work.' -f query=' + gh api graphql -f discussionId='' -f body='Moved to to track the actionable work. + + _Posted by Claude Code (AI agent) --- not written by a human._' -f query=' mutation($discussionId: ID!, $body: String!) { addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { comment { url } @@ -139,7 +141,9 @@ this is outward-facing and hard to reverse. not planned: ```bash - gh issue comment --body 'Moved to — this is better suited to Discussions.' # COMMENT_ISSUE + gh issue comment --body 'Moved to — this is better suited to Discussions. + + _Posted by Claude Code (AI agent) --- not written by a human._' # COMMENT_ISSUE gh issue close --reason 'not planned' # CLOSE_ISSUE ``` diff --git a/skills/post-merge/SKILL.md b/skills/post-merge/SKILL.md index 4618a2558..b287bcedd 100644 --- a/skills/post-merge/SKILL.md +++ b/skills/post-merge/SKILL.md @@ -313,18 +313,25 @@ conflicting PR can sit in `UNKNOWN` and get missed if you filter for and "A stacked PR is the one conflict that intersection cannot attribute". 3. **Check claim status.** Read the most recent comment. - If it says "Working on this --- paws off" (or equivalent) and the claim is - still live --- a push or comment within the last 2 hours --- - skip it --- another session owns it. - An expired claim (over 2 idle hours) no longer blocks; take over with a - fresh claim comment of your own, per - [`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration rule. + Match the two-word invariant, `hold off` or either retired wording `paws off` / `back off`, case-insensitively --- never a whole sentence. + The PR and issue claims differ after those two words, and the dash between them is an em-dash in this file's own claim emitter (step 4), so a quoted prefix misses claims this very skill posts. + See [`claim-pr`](../../shared/workflow/claim-pr.md)'s "Match the two-word invariant". + + **Then check the same comment for a release term, because one release marker contains a claim invariant.** + The retired release wording is `... done --- paws off released.`, which matches `paws off` --- so the invariant that fixes one bug introduces another, and this one fails the safe way round: a released PR reads as claimed, the sweep skips it, and the conflict is never resolved with nothing reporting why. + The sentence matcher this replaced did not collide, so the collision arrived with the fix. + Treat the comment as a release, not a claim, if it also matches `unclaim|released|PR is free|now mergeable`. + If a live claim stands --- a push or comment within the last 2 hours --- skip the PR. + Another session owns it. + An expired claim (over 2 idle hours) no longer blocks. + Take over with a fresh claim comment of your own, per [`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration rule. 4. **Claim it.** ```bash - gh pr comment --body "Working on this — paws off until I'm done." # COMMENT_PR + gh pr comment --body "Working on this — please hold off on pushing to this branch until I'm done. + + _Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR ``` -5. **Create an isolated worktree**, fetch the latest `main` (the squash-merge - commit that caused the conflict), and merge: +5. **Create an isolated worktree**, fetch the latest `main` (the squash-merge commit that caused the conflict), and merge: ```bash git fetch origin main # FETCH — fetch both: we need the new main tip git worktree add .claude/worktrees/pr- origin/ @@ -354,14 +361,14 @@ conflicting PR can sit in `UNKNOWN` and get missed if you filter for ``` 8. **Unclaim** with a brief resolution summary: ```bash - gh pr comment --body "Conflict resolved — branch is now mergeable. " # COMMENT_PR + gh pr comment --body "Conflict resolved — branch is now mergeable. + + _Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_PR ``` -Resolve PRs one at a time — not because worktrees race each other (each -worktree is an independent checkout), but because the same human or bot may be -actively working a PR between your claim and your push. One-at-a-time keeps -the blast radius small. Skip any PR whose conflict is in a file you can't -understand without more context — comment asking for clarification instead. +Resolve PRs one at a time — not because worktrees race each other (each worktree is an independent checkout), but because the same human or bot may be actively working a PR between your claim and your push. +One-at-a-time keeps the blast radius small. +Skip any PR whose conflict is in a file you can't understand without more context — comment asking for clarification instead. **Match the response to standing, not only to cause.** Step 2 says whether a conflict is yours; it does not say the branch is. diff --git a/skills/pr-on-claim/SKILL.md b/skills/pr-on-claim/SKILL.md index 6d61acf32..1c7e9d94d 100644 --- a/skills/pr-on-claim/SKILL.md +++ b/skills/pr-on-claim/SKILL.md @@ -28,7 +28,8 @@ Operationalizes the strong form of the claim workflow: branch → empty commit 4. Open a **draft PR** with: - Title: issue title (or override) - Body: `"Closes #\n\nWIP — opened up front to claim the issue; implementing now."` -5. Post a claim comment on the issue: `"Claude Code CLI (local session) is working on this — paws off until I'm done."` +5. Post a claim comment on the issue, whose body is the claim line, a blank line, and the agent-disclosure marker every agent-posted comment carries (see [`disclose-agent-authorship`](../../shared/workflow/disclose-agent-authorship.md)). + `\n` here is notation for a real newline, as in step 4 --- inside a bash double-quoted string those two characters stay two characters, so [`pr-on-claim.sh`](pr-on-claim.sh) writes the body with actual line breaks: `"Claude Code CLI (local session) is working on this — please hold off until I'm done.\n\n_Posted by Claude Code (AI agent) --- not written by a human._"` ## Why draft? diff --git a/skills/pr-on-claim/pr-on-claim.sh b/skills/pr-on-claim/pr-on-claim.sh index a3fc2b83c..046945927 100644 --- a/skills/pr-on-claim/pr-on-claim.sh +++ b/skills/pr-on-claim/pr-on-claim.sh @@ -41,7 +41,9 @@ WIP — opened up front to claim the issue; implementing now." \ --draft echo "Posting claim comment on issue..." -gh issue comment "$ISSUE_NUM" --body "Claude Code CLI (local session) is working on this — paws off until I'm done." +gh issue comment "$ISSUE_NUM" --body "Claude Code CLI (local session) is working on this — please hold off until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" echo "✓ PR opened and issue claimed." git log --oneline -1 diff --git a/skills/push/SKILL.md b/skills/push/SKILL.md index 5b7d73727..c80123175 100644 --- a/skills/push/SKILL.md +++ b/skills/push/SKILL.md @@ -89,7 +89,7 @@ git log --oneline HEAD.."origin/$BRANCH" 2>/dev/null An object you cannot resolve locally is the **stronger** signal, not the milder one: the remote moved after your last fetch and you cannot see what is there. -### 3. "Paws off" claim by someone else +### 3. Claim comment by someone else Look at the open PR for this branch for a claim comment posted by **another** session or person. (Your own most-recent "I'm working on this" comment is fine — @@ -98,17 +98,20 @@ that's your claim.) ```bash PR=$(gh pr view --json number,headRefName -q .number 2>/dev/null) # VIEW_PR gh pr view "$PR" --json comments \ - -q '.comments[] | select(.body | test("paws off"; "i")) | "\(.author.login): \(.body)"' # READ_PR_COMMENTS + -q '.comments[] | select(.body | test("hold off|paws off|back off|unclaim|released|PR is free|now mergeable"; "i")) | "\(.author.login): \(.body)"' # READ_PR_COMMENTS ``` -If the latest "paws off" comment is from someone **other than you**, hasn't -been unclaimed, and is still live --- the PR shows a push or comment within -the last 2 hours, per -[`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration rule --- -**do not push.** Ask the user. -An expired claim (over 2 idle hours) no longer blocks on its own, but take it -over with a fresh claim comment and run this skill's other checks (branch-head -advance, `@claude` run in flight) before pushing. +The alternation is deliberate, and it covers RELEASES as well as claims. +Claims posted before 2026-08-24 say "paws off", and a claim stays live on activity rather than on age, so an old-wording claim can be live right now. +The release terms matter because the old wording made them free: `paws off released` contains `paws off`, so one grep surfaced both sides of the exchange. +`claim released` contains neither claim term, so a claim-only query returns the claim and not its release --- and this check asks whether the claim "hasn't been unclaimed", which a claim-only output cannot answer. +A released PR would read as live-claimed, and this skill would refuse a legitimate push. +Derive the release terms rather than copying this list, which is a snapshot of what the corpus posts today: `grep -rn "unclaim\|released\|PR is free\|now mergeable" skills/ commands/`. +A matcher narrowed to the new phrase returns nothing on such a thread, which reads exactly like an unclaimed one --- see [`claim-pr`](../../shared/workflow/claim-pr.md). + +If the latest claim comment is from someone **other than you**, hasn't been unclaimed, and is still live --- the PR shows a push or comment within the last 2 hours, per [`claim-pr`](../../shared/workflow/claim-pr.md)'s expiration rule --- **do not push.** +Ask the user. +An expired claim (over 2 idle hours) no longer blocks on its own, but take it over with a fresh claim comment and run this skill's other checks (branch-head advance, `@claude` run in flight) before pushing. ### 4. Hold / block labels @@ -190,14 +193,12 @@ review, not a draft). ## Relationship to other skills -- **`claim-pr`** — posts/removes the "paws off" claim comment this skill reads - in check #3. `push` is the read side; `claim-pr` is the write side. -- **`session-lock` / `deconflict-sessions`** — the local-checkout counterpart: - it keeps parallel sessions on one machine from sharing a working tree. `push` - guards the *remote* branch; `session-lock` guards the *local* tree. -- **`sync-pr-branch` / `merge-main`** — when check #2 fires because `main` (not - the branch) moved ahead, sync the branch first, then push. `sync-pr-branch` - ends in a push and should itself honor these checks. +- **`claim-pr`** — posts/removes the claim comment this skill reads in check #3. + `push` is the read side; `claim-pr` is the write side. +- **`session-lock` / `deconflict-sessions`** — the local-checkout counterpart: it keeps parallel sessions on one machine from sharing a working tree. + `push` guards the *remote* branch; `session-lock` guards the *local* tree. +- **`sync-pr-branch` / `merge-main`** — when check #2 fires because `main` (not the branch) moved ahead, sync the branch first, then push. + `sync-pr-branch` ends in a push and should itself honor these checks. - **`ardi`** — its push step should run these checks; the "detect an active parallel session before pushing" note in `claim-pr` is the same guard. - **[`check-before-pushing`](../../shared/workflow/check-before-pushing.md)** --- the standing rule these checks implement, and the home of the immediacy argument and the `--force-if-includes` mechanism. @@ -208,7 +209,7 @@ review, not a draft). - ❌ Force-pushing over commits another session added (check #2) - ❌ Bare `git push --force` instead of `--force-with-lease --force-if-includes` (check #6) - ❌ Reusing an earlier fetch as the check --- the reading has to be taken immediately before the push (check #2) -- ❌ Pushing past a fresh "paws off" claim from someone else (check #3) +- ❌ Pushing past a fresh claim comment from someone else (check #3) - ❌ Pushing onto a `do-not-merge` / `hold` PR without asking (check #4) - ❌ Pushing while a `@claude` run is mid-session on the branch (check #5) - ❌ Pushing directly to `main` / the default branch (check #1) diff --git a/skills/st/SKILL.md b/skills/st/SKILL.md index 3f8d57128..dd6124437 100644 --- a/skills/st/SKILL.md +++ b/skills/st/SKILL.md @@ -86,7 +86,9 @@ glab issue create --title "" --description " - Then **claim it** (`claim-pr` pattern) so a parallel session / the `@claude` bot doesn't collide: ```bash - gh issue comment --body "Claude Code CLI (local session) is working on this — paws off until I'm done." # COMMENT_ISSUE + gh issue comment --body "Claude Code CLI (local session) is working on this — please hold off until I'm done. + + _Posted by Claude Code (AI agent) --- not written by a human._" # COMMENT_ISSUE ``` ### 4. Check history diff --git a/tool-mappings.md b/tool-mappings.md index c9923bd69..d64ab8f78 100644 --- a/tool-mappings.md +++ b/tool-mappings.md @@ -10,6 +10,19 @@ The canonical skills under `skills/` are written for Claude Code and name concrete tools — mostly `gh`/`git` commands. This registry maps each canonical operation to the equivalent GitHub MCP tool so any model can run a skill. +> [!IMPORTANT] +> **Every comment-posting operation below carries the agent-disclosure marker in +> its body**, on its own line after a blank line: +> `_Posted by Claude Code (AI agent) --- not written by a human._` +> +> This registry is the substitution point for remote/web sessions, which have no +> `gh` at all --- so a marker-free template here is a marker-free comment there, in +> exactly the population that cannot fall back to the CLI examples in the skills. +> See [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). +> Two exemptions: a body another machine parses as a command (`@dependabot +> rebase`), and a comment posted under a genuine bot token, where the forge +> already reports `type: Bot`. + ## How each model resolves an operation - **Claude Code** — GitHub MCP tool in remote/web sessions; the CLI command locally. @@ -36,8 +49,8 @@ operation to the equivalent GitHub MCP tool so any model can run a skill. | `MERGE_PR` | Merge a pull request. | `gh pr merge ""` | `mcp__github__merge_pull_request` | | `MARK_PR_READY` | Flip a draft pull request to ready for review. | `gh pr ready ""` | `mcp__github__update_pull_request (draft=false)` | | `REOPEN_PR` | Reopen a closed pull request. | `gh pr reopen ""` | `mcp__github__update_pull_request (state=open)` | -| `COMMENT_PR` | Post a top-level comment on a pull request. | `gh pr comment "" --body "..."` | `mcp__github__add_issue_comment` | -| `REPLY_REVIEW_COMMENT` | Reply to an inline pull-request review comment. The path carries the PR number; the id-only route (`PATCH .../pulls/comments/`) EDITS that comment instead. | `gh api -X POST "repos///pulls//comments//replies" -F "body=@"` | `mcp__github__add_reply_to_pull_request_comment` | +| `COMMENT_PR` | Post a top-level comment on a pull request. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). | `gh pr comment "" --body "..."` | `mcp__github__add_issue_comment` | +| `REPLY_REVIEW_COMMENT` | Reply to an inline pull-request review comment. The path carries the PR number; the id-only route (`PATCH .../pulls/comments/`) EDITS that comment instead. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). | `gh api -X POST "repos///pulls//comments//replies" -F "body=@"` | `mcp__github__add_reply_to_pull_request_comment` | | `RESOLVE_REVIEW_THREAD` | Mark an inline pull-request review thread as resolved. | `gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:""}) { thread { isResolved } } }'` | `mcp__github__resolve_review_thread` | | `WATCH_PR` | Subscribe to / unsubscribe from a pull request's activity. | (no CLI equivalent) | `mcp__github__subscribe_pr_activity / mcp__github__unsubscribe_pr_activity` | | `VIEW_ISSUE` | Read an issue's details. | `gh issue view ""` | `mcp__github__issue_read` | @@ -46,15 +59,15 @@ operation to the equivalent GitHub MCP tool so any model can run a skill. | `READ_ISSUE_COMMENTS` | Read an issue's comments. | `gh issue view "" --comments` | `mcp__github__issue_read (method=get_comments)` | | `ISSUE_LINKED_PRS` | List the pull requests cross-referenced from an issue's timeline (i.e. PRs that link or close it). | `gh api --paginate "repos///issues//timeline"` | (no GitHub MCP tool; approximate with SEARCH_PRS) | | `CREATE_ISSUE` | Open a new issue. | `gh issue create` | `mcp__github__issue_write (method=create)` | -| `COMMENT_ISSUE` | Post a comment on an issue. | `gh issue comment "" --body "..."` | `mcp__github__add_issue_comment` | +| `COMMENT_ISSUE` | Post a comment on an issue. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). | `gh issue comment "" --body "..."` | `mcp__github__add_issue_comment` | | `CLOSE_ISSUE` | Close an issue with a reason. | `gh issue close "" --reason "..."` | `mcp__github__issue_write (method=update, state=closed, state_reason=...)` | | `REOPEN_ISSUE` | Reopen a closed issue. | `gh issue reopen "" --comment "..."` | `mcp__github__issue_write (method=update, state=open)` | | `LABEL_ISSUE` | Set an issue's labels. The two behave differently and are not interchangeable: `--add-label` ADDS to the existing set, while the MCP path REPLACES the whole set, so pass the union of existing and new labels there. The MCP path also silently creates an unknown label name instead of rejecting it. | `gh issue edit "" --add-label "..."` | `mcp__github__issue_write (method=update, labels=[...])` | | `GET_LABEL` | Read a single label's name, color, and description. There is no MCP tool to create or update a label; use gh label create/edit, or gh api from a workflow. | `gh api "repos///labels/"` | `mcp__github__get_label` | -| `LIST_DISCUSSIONS` | List a repository's discussions. Readable over REST; writes are GraphQL-only. | `gh api repos/{owner}/{repo}/discussions` | (no GitHub MCP tool; use gh api REST or graphql) | -| `VIEW_DISCUSSION` | Read a discussion topic and its comment thread. Readable over REST. | `gh api repos/{owner}/{repo}/discussions/{number}[/comments]` | (no GitHub MCP tool; use gh api REST or graphql) | -| `COMMENT_DISCUSSION` | Post a reply on a discussion (top-level or threaded). | `gh api graphql (addDiscussionComment)` | (no GitHub MCP tool; use gh api graphql) | -| `ANSWER_DISCUSSION` | Mark a comment as the accepted answer on a Q&A discussion. | `gh api graphql (markDiscussionCommentAsAnswer)` | (no GitHub MCP tool; use gh api graphql) | +| `LIST_DISCUSSIONS` | List a repository's discussions. Readable over REST; writes are GraphQL-only. | `gh api repos/{owner}/{repo}/discussions` | `mcp__github__list_discussions` | +| `VIEW_DISCUSSION` | Read a discussion topic and its comment thread. Readable over REST. | `gh api repos/{owner}/{repo}/discussions/{number}[/comments]` | `mcp__github__get_discussion / mcp__github__get_discussion_comments` | +| `COMMENT_DISCUSSION` | Post a reply on a discussion (top-level or threaded). **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). | `gh api graphql (addDiscussionComment)` | `mcp__github__discussion_comment_write (method=add\|reply)` | +| `ANSWER_DISCUSSION` | Mark a comment as the accepted answer on a Q&A discussion. | `gh api graphql (markDiscussionCommentAsAnswer)` | `mcp__github__discussion_comment_write (method=mark_answer)` | | `CREATE_DISCUSSION` | Open a new discussion in a category. | `gh api graphql (createDiscussion)` | (no GitHub MCP tool; use gh api graphql) | | `CLOSE_DISCUSSION` | Close a discussion with a reason (RESOLVED, OUTDATED, DUPLICATE). | `gh api graphql (closeDiscussion)` | (no GitHub MCP tool; use gh api graphql) | | `PUSH` | Push commits to a branch. | `git push -u origin ""` | (use git; no GitHub MCP equivalent) | diff --git a/tool-mappings.yml b/tool-mappings.yml index 1297fd819..12a1758f8 100644 --- a/tool-mappings.yml +++ b/tool-mappings.yml @@ -107,11 +107,11 @@ operations: cli: gh pr reopen "" github_mcp: mcp__github__update_pull_request (state=open) - id: COMMENT_PR - description: Post a top-level comment on a pull request. + description: Post a top-level comment on a pull request. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). cli: gh pr comment "" --body "..." github_mcp: mcp__github__add_issue_comment - id: REPLY_REVIEW_COMMENT - description: Reply to an inline pull-request review comment. The path carries the PR number; the id-only route (`PATCH .../pulls/comments/`) EDITS that comment instead. + description: Reply to an inline pull-request review comment. The path carries the PR number; the id-only route (`PATCH .../pulls/comments/`) EDITS that comment instead. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). cli: gh api -X POST "repos///pulls//comments//replies" -F "body=@" github_mcp: mcp__github__add_reply_to_pull_request_comment - id: RESOLVE_REVIEW_THREAD @@ -147,7 +147,7 @@ operations: cli: gh issue create github_mcp: mcp__github__issue_write (method=create) - id: COMMENT_ISSUE - description: Post a comment on an issue. + description: Post a comment on an issue. **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). cli: gh issue comment "" --body "..." github_mcp: mcp__github__add_issue_comment - id: CLOSE_ISSUE @@ -177,19 +177,19 @@ operations: - id: LIST_DISCUSSIONS description: List a repository's discussions. Readable over REST; writes are GraphQL-only. cli: gh api repos/{owner}/{repo}/discussions - github_mcp: (no GitHub MCP tool; use gh api REST or graphql) + github_mcp: mcp__github__list_discussions - id: VIEW_DISCUSSION description: Read a discussion topic and its comment thread. Readable over REST. cli: gh api repos/{owner}/{repo}/discussions/{number}[/comments] - github_mcp: (no GitHub MCP tool; use gh api REST or graphql) + github_mcp: mcp__github__get_discussion / mcp__github__get_discussion_comments - id: COMMENT_DISCUSSION - description: Post a reply on a discussion (top-level or threaded). + description: Post a reply on a discussion (top-level or threaded). **The body ends with the agent-disclosure marker** --- see [`disclose-agent-authorship`](shared/workflow/disclose-agent-authorship.md). cli: gh api graphql (addDiscussionComment) - github_mcp: (no GitHub MCP tool; use gh api graphql) + github_mcp: mcp__github__discussion_comment_write (method=add|reply) - id: ANSWER_DISCUSSION description: Mark a comment as the accepted answer on a Q&A discussion. cli: gh api graphql (markDiscussionCommentAsAnswer) - github_mcp: (no GitHub MCP tool; use gh api graphql) + github_mcp: mcp__github__discussion_comment_write (method=mark_answer) - id: CREATE_DISCUSSION description: Open a new discussion in a category. cli: gh api graphql (createDiscussion) diff --git a/workflow.qmd b/workflow.qmd index 0bbcbc2b1..38f9117e7 100644 --- a/workflow.qmd +++ b/workflow.qmd @@ -27,7 +27,9 @@ Before fetching a branch, editing files, or triggering a review loop, post a brief comment so other people and bots know not to start a conflicting session: ```sh -gh pr comment --body "Working on this --- paws off until I'm done." +gh pr comment --body "Working on this --- please hold off on pushing to this branch until I'm done. + +_Posted by Claude Code (AI agent) --- not written by a human._" ``` After the session ends (PR merged, paused, or closed), follow up with a