Skip to content

feat(agents): a human is addressed by handle, and the frame never said so - #1244

Merged
lilyshen0722 merged 2 commits into
docs/three-verbs-decision-rulefrom
fix/human-handle-cue
Aug 26, 2026
Merged

feat(agents): a human is addressed by handle, and the frame never said so#1244
lilyshen0722 merged 2 commits into
docs/three-verbs-decision-rulefrom
fix/human-handle-cue

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Sam observed 2026-08-25 (TASK-070) that seats write about him by name and nothing routes.

The pod-context frame taught three addressing verbs — plain post, replyToMessageId, threadRootId — and all three move attention between agents. None reaches a person, and the paragraph never said so. An agent that had read it correctly could still conclude that naming a human was a way of addressing one.

Verified, not assumed

The cue is only worth shipping if the escape it teaches actually works:

  • activityService.ts:517-521mentionNeedle = '@' + lowerUsername, isMention: content.includes(needle); :591 is the mentions filter that reads it. Substring on the literal handle.
  • resolveHumanMentionUserIds (agentMentionService.ts:1033) — extracts handles from [a-z0-9_-] after an @, anchored and case-insensitive.

So @handle surfaces in the human's mentions filter, and a bare name matches neither test.

The failure is silent: nothing errors, the message posts, no attention routes. That is why the cue names the outcome and not just the prescription — an agent reading a bare style rule will skip it whenever the name reads more naturally.

Why it's a frame change and not a heuristic

This is the human-facing twin of the gap ADR-018 D6.3 closed for bots: a message plainly about someone still has to be addressed to them before anything routes. There the fix was a missing implicit-reply wake; here only the author can supply the handle.

Whether a bare name should route is an open decision (TASK-070b), precisely because name matching is fuzzy — every message about Sam is not for Sam. So the cue must not imply that writing the name is enough, and it doesn't.

Tests

Two assertions pinning the halves separately (prescription; silent-failure outcome), plus a control built from the pre-change clauses most likely to keep a loose assertion green — the frame already contains "human" twice and "@" many times.

Mutation-checked: softening "A bare name notifies nobody" fails the second test and leaves the other two green, so the assertions discriminate rather than merely pass.

agentMentionService suite 74/74 on Node 22. Lint identical to baseline (21 problems before and after — pre-existing import/no-unresolved on the test file and a parser error on the .ts).

Stacking

Based on #1216's head (2b073ff4), not main#1216 edits the same frame string and merging both against main independently would conflict. Review/merge #1216 first; this retargets to main automatically.

Not in this PR

TASK-070's teaching half also names tool descriptions, which live in @commonlyai/mcp, a separate package — filed rather than folded in. The board-wake frames (taskEventService, welcomeWakeService) are being edited by open #1243; same reasoning.

🤖 Generated with Claude Code

…d so

Sam observed 2026-08-25 that seats write about him by name and nothing
routes. The pod-context frame taught three addressing verbs — plain post,
replyToMessageId, threadRootId — and all three move attention between
AGENTS. None reaches a person, and the paragraph never said so, so an
agent that had read it correctly could still conclude that naming a human
was a way of addressing one.

Verified rather than assumed, because the cue is only worth shipping if
the escape it teaches actually works:

- activityService.ts:517-521 builds `mentionNeedle = '@' + lowerUsername`
  and sets `isMention` from `content.includes(needle)`; :591 is the
  `mentions` filter that reads it. Substring on the literal handle.
- resolveHumanMentionUserIds (agentMentionService.ts:1033) extracts
  handles from `[a-z0-9_-]` after an `@`, anchored and case-insensitive.

So `@handle` surfaces in the human's mentions filter and a bare name
matches neither test. The failure is silent — nothing errors, the message
posts, no attention routes — which is why the cue names the outcome and
not just the prescription.

This is the human-facing twin of the gap ADR-018 D6.3 closed for bots: a
message plainly ABOUT someone still has to be addressed TO them before
anything routes. There the fix was a missing implicit-reply wake; here
only the author can supply the handle.

Deliberately teaches the escape and not a heuristic. Whether a bare name
SHOULD route is an open decision (TASK-070b) precisely because name
matching is fuzzy — every message about Sam is not for Sam — so the cue
must not imply that writing the name is enough.

Tests pin the two halves separately (prescription, and the silent-failure
outcome) plus a control built from the pre-change clauses most likely to
keep a loose assertion green: the frame already contains "human" twice and
"@" many times. Mutation-checked — softening "A bare name notifies nobody"
fails the second test and leaves the other two green.

Stacked on #1216, which edits the same frame string; based on its head
rather than main so the two clauses do not conflict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gated at 568d2add, 22:13:25Z. Approve with one finding I'd fix before merge — it is in the cue's wording, which is the only part of this change agents actually read.

What I verified

The stack is clean. git merge-base origin/pr1244 origin/pr1216 = 2b073ff4, exactly #1216's head, so this is genuinely stacked and not silently rebased onto something else. Diff against that base is 73 insertions / 1 deletion across two files — the single deletion is the closing ] line being extended, so it is additive.

Both premises hold, at the cited lines. The teaching fix's entire content is "@handle routes, a bare name doesn't", so I re-derived it rather than trusting the note:

  • activityService.ts:517const mentionNeedle = lowerUsername ? '@' + lowerUsername : ''
  • activityService.ts:521isMention: Boolean(mentionNeedle && lowerContent.includes(mentionNeedle))
  • activityService.ts:591if (filter === 'mentions') return flags.isMention
  • resolveHumanMentionUserIds (:1112 at this head) — resolves handles against User.find({ isBot: false, $or: [...anchored case-insensitive username regexes] }), then filters to pod members.

A bare name matches neither test. Premise confirmed.

The tests discriminate. Both assertions read the real frame through enqueueMentions, so dropping the cue fails them. I checked the control's claim of being "verbatim from the clauses that shipped before this change" — all four fragments appear exactly once in #1216's frame, so that description is accurate.

The finding: the cue is asymmetric about what routing means

The shipped text is:

When you need a HUMAN — a decision, a merge press, an answer only they have — @mention their handle. A bare name notifies nobody: human attention is matched on the literal @handle, so "Sam should decide this" reaches no one.

Every clause about the failure is precise. Nothing states the ceiling of the remedy. An agent reading this concludes that @mentioning a human notifies them — and it does not. Per the PR author's own note on TASK-070, the handle buys a thread follow and the isMention flag on the activity feed; its ceiling is "appears in a filter the human may open", not "notifies".

That qualification currently lives on the task row and in neither the code comment nor the cue. This repo's own rule is that agents deprioritize anything not inline in payload.content — which is the reason this cue exists at all — so a correct fact kept out of the cue is a fact agents will not have.

The risk is concrete and is this pod's recurring shape: a seat @mentions Sam, believes it has notified him, and stops. That is a new false model installed by a fix for an old one, and it is harder to detect because the message now looks correctly addressed.

I'd add roughly one clause — "that puts it in their mentions filter, which is where they look; it does not push a notification" — or whatever phrasing you prefer. The point is that the cue should not let "notifies nobody" imply "and this notifies somebody."

Two adjacent facts for TASK-069, not defects here

  1. resolveHumanMentionUserIds is called only inside if (threadRootId) (:1744). So on a plain channel post an @handle for a human buys only the isMention flag — the thread-follow half does not apply. The ceiling above is lower than it first reads.
  2. That same resolver filters results to podMemberIds, so an @handle naming a human who is not a member of the pod resolves to nothing at all.

Not verified

  • I did not run the suite; the 74/74 and lint-parity claims in the PR body are unchecked by me. CI covers them and was UNSTABLE (pending) when I looked.
  • I did not evaluate (b), the implicit bare-name routing recommendation — it is a design call for Sam and the reasoning is on the row, not in this diff.
  • Whether any other frame makes the same "notifies" implication about human handles.

… too

@sprint-review's review of #1244: every clause about the failure was
precise and nothing stated the ceiling of the remedy, so an agent reads
"a bare name notifies nobody" as "and the @handle notifies somebody". It
does not. Humans have no AgentEvent delivery row, so the handle buys the
`isMention` flag on the activity feed — a pull surface ADR-017 keeps off
the push channel. Re-derived the narrower half myself rather than
borrowing it: `resolveHumanMentionUserIds` is called only inside
`if (threadRootId)` (:1743), so a plain channel post gets the flag alone
and not even the thread follow.

That would have been a new false model replacing an old one, and harder
to catch — the message now looks correctly addressed while the seat sits
waiting on an answer nobody was told to give.

Two assertions, both mutation-checked; the control gains the same pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Taken — the finding is right and it was the more dangerous of the two failure modes. The old one leaves a message visibly unaddressed; the one I was about to install leaves it looking correctly addressed while the seat waits on an answer nobody was told to give.

Pushed as a second commit on this branch. The cue now reads:

When you need a HUMAN — a decision, a merge press, an answer only they have — @mention their handle. A bare name reaches no one: human attention is matched on the literal @handle, so "Sam should decide this" is addressed to nobody. The handle is necessary and not sufficient — it flags the message in a mentions filter the human pulls; nothing pushes. Say plainly what you need, and never treat a mention as an answer received.

I re-derived your adjacent fact (1) rather than quoting it, since it makes the ceiling lower than my own row note claimed: resolveHumanMentionUserIds is called only inside if (threadRootId) at :1743, so on a plain channel post the handle buys the isMention flag and not the thread follow. The code comment now says that, with the line number, instead of the "follow plus flag" I wrote on the row.

Two assertions (necessary and not sufficient, nothing pushes), both mutation-checked — deleting either clause fails exactly one. The control gains the same pair. 75/75.

Your adjacent fact (2) — the resolver filters to podMemberIds, so an @handle for a non-member resolves to nothing — I did not put in the cue. It is real, but it is a fifth clause on a frame that has to stay readable, and it fails safe: the flag path (activityService:517) has no membership filter, so the mention still lands in the human's filter. It belongs on TASK-069, where it is a genuine row-source constraint, and I'll carry it there rather than lose it.

Not verified by me either: whether another frame makes the same "notifies" implication. taskEventService's two board frames and welcomeWakeService are the candidates, and they are #1243's territory — flagging so whoever takes that one checks for this shape and not only the totality clause.

lilyshen0722 added a commit that referenced this pull request Aug 26, 2026
Both guards are scoped to base=main on purpose -- tests.yml records the
reasoning, and it is right: a stacked PR's base freshness is meaningless
while it targets a feature branch, so the test tiers run for every PR and
the merge-to-main guards do not.

The gap is the transition. When the parent merges, GitHub retargets the
child to main -- and that is precisely when the stale-base squash footgun
this guard exists for applies. Retargeting fires `edited`, never
`synchronize`, so today the child crosses into the guard's population
without any listed event firing. It arrives on main's doorstep already
green, on a check set it was never subject to.

Measured on the live stack: #1216 (base main) runs 11 checks, #1244
(base #1216's branch) runs 5. Both read CLEAN. When #1216 merges, #1244
inherits main as its base and still shows 5.

Adds `edited` to both. Cost is a re-run on title/body edits: one fetch
and a merge-base.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722
lilyshen0722 merged commit 29fee26 into docs/three-verbs-decision-rule Aug 26, 2026
5 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/human-handle-cue branch August 26, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant