Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,26 +147,38 @@ When the Discord turn includes an **Identity map** block with ready-to-paste `Co
4. A Discord-originated commit **without** the mapped trailers is incomplete — fix it (amend if not pushed, or a follow-up commit is not enough for GitHub multi-author on already-pushed SHAs; amend/rebase when safe).

GitHub multi-author avatars (`bot & human`) come from commit trailers, not from PR body prose alone.
If you list co-authors in the PR body, use **profile links that look like mentions**, not bare `@login` tokens (bare `@login` notifies people):

```md
- [@MindfulLearner](https://github.com/MindfulLearner)
- [@patroza](https://github.com/patroza)
```

Trailers remain mandatory for credit; the optional body list is display-only.

## Discord-originated pull requests (REQUIRED)

When Discord work produces commits (or is clearly intended to land):

0. **Always open a PR — do not wait for perfect green.** Create the PR as soon as there is something to review or track. If full lint / typecheck / focused tests / `vp check` are not finished yet, open it as a **draft**. Convert to ready for review only after those gates. A missing PR while work sits only on a remote branch is incomplete handoff.
0. **Always open a PR — do not wait for perfect green.** Create the PR as soon as there is something to review or track. If full lint / typecheck / focused tests / `vp check` are not finished yet, open it as a **draft**. **A draft is not done:** after local gates (preferably right after focused tests + `vp check` / package typecheck), you **must** convert it to ready for review (or merge path). Leaving a draft abandoned after the work is finished is incomplete handoff. A missing PR while work sits only on a remote branch is also incomplete.

When opening or updating a PR from a Discord thread:

1. **Discord footer (required in the PR description).** Append this exact footer form at the end of the PR body (use the **thread starter** when known, otherwise the current requester, and that thread’s real jump link):
1. **Discord footer (required in the PR description).** Append this form at the end of the PR body (use the **thread starter** when known, otherwise the current requester). Prefer the **ready-to-paste footer** from Discord turn context when present.

```md
opened by [<displayName>](discord_user_id) in chat thread **Discord** · [Thread Title](https://discord.com/channels/<guild_id>/<channel_or_thread_id>/<message_id>)
opened by [<displayName>](https://discord.com/users/<discord_user_id>) in chat thread **Discord** · [Thread Title](https://discord.com/channels/<guild_id>/<channel_or_thread_id>/<message_id>)
```

Prefer the thread starter’s Discord id/display name from turn context. Do not skip this because the bot _might_ patch the body later — still write it when you create the PR so the first revision is correct. The bot may also hard-append the footer when a PR URL is linked; that is a safety net, not a reason to omit it.
Rules:

- **User link** must be `https://discord.com/users/<snowflake>` (Discord profile). Never use a bare snowflake as the URL (it goes nowhere).
- **Thread link** must be a full `https://discord.com/channels/<guild>/<channel_or_thread>/<message>` URL. Never emit truncated placeholders like `https://discord.com/channels` or empty channel segments.
- Prefer the thread starter’s display name from turn context. Do not skip the footer because the bot _might_ patch the body later — still write it when you create the PR so the first revision is correct. The bot may also hard-append the footer when a PR URL is linked; that is a safety net, not a reason to omit it.

2. If Discord turn context lists **Linked work items** / Jira issues for the thread, include those Jira issue links in the PR description (and prefer the primary key in the title/branch when one is clear).

3. Prefer opening the PR only after commits already include the Identity map `Co-authored-by` trailers (see above).
3. Prefer opening the PR only after commits already include the Identity map `Co-authored-by` trailers (see above). Optional PR-body co-author list: profile links only (`[@login](https://github.com/login)`), never bare `@login`.

## Task Completion Requirements

Expand Down
4 changes: 4 additions & 0 deletions apps/discord-bot/src/features/MentionRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,8 @@ const make = (botConfig: DiscordBotConfig) =>
jiraIssueKeys: turnJiraIssueKeys,
jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl,
identityPeople: identityMap.list(),
guildId: input.guildId,
discordThreadId: input.discordThreadId,
});
if (stagedFiles.skipped.length > 0) {
yield* Effect.logWarning("Skipped some Discord file attachments", {
Expand Down Expand Up @@ -1127,6 +1129,8 @@ const make = (botConfig: DiscordBotConfig) =>
jiraIssueKeys: firstTurnJiraIssueKeys,
jiraBrowseBaseUrl: botConfig.jiraBrowseBaseUrl,
identityPeople: identityMap.list(),
guildId: input.guildId,
discordThreadId: input.discordThreadId,
});

yield* Effect.logInfo("Creating T3 thread with worktree bootstrap", {
Expand Down
4 changes: 2 additions & 2 deletions apps/discord-bot/src/identityMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,10 +515,10 @@ export function formatIdentityAttributionBlock(input: {
"4. Verify with `git log -1 --format=%B` before push/PR. Commits missing these trailers are incomplete.",
);
lines.push(
"5. When opening a PR: include the Discord description footer from AGENTS.md (opened by … in chat thread **Discord**). The bot may hard-append the footer later — still write it on create. GitHub multi-author avatars come from **commit** trailers, not PR body prose alone.",
"5. When opening a PR: paste the **Discord PR description footer** from turn context when present (user link = `https://discord.com/users/<id>`, thread link = full `https://discord.com/channels/...`). Never use bare snowflakes or truncated channel URLs. The bot may hard-append the footer later — still write it on create. GitHub multi-author avatars come from **commit** trailers. Optional PR-body co-author list: profile links that look like mentions (`[@login](https://github.com/login)`), **never** bare `@login` (notifies).",
);
lines.push(
"6. **Always open a PR** for this work once there are commits (or the change is clearly intended to land). Prefer a **draft PR** until full lint / typecheck / focused tests / `vp check` are done; do not hold the PR closed waiting for perfect green.",
"6. **Always open a PR** for this work once there are commits (or the change is clearly intended to land). Prefer a **draft PR** until full lint / typecheck / focused tests / `vp check` are done; then **you must mark it ready** (draft is not done). Do not leave drafts abandoned and do not hold the PR closed waiting for perfect green.",
);

if (trailers.length > 0) {
Expand Down
19 changes: 16 additions & 3 deletions apps/discord-bot/src/presentation/discordPrAttribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "./discordPrAttribution.ts";

describe("formatDiscordPrAttributionFooter", () => {
it("formats starter + thread title + jump link", () => {
it("formats starter profile URL + full thread jump link", () => {
expect(
formatDiscordPrAttributionFooter({
starterDisplayName: "joshuadima",
Expand All @@ -23,7 +23,20 @@ describe("formatDiscordPrAttributionFooter", () => {
"https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595",
}),
).toBe(
"opened by [joshuadima](593167616273809448) in chat thread **Discord** · [Open Random PR Test](https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595)",
"opened by [joshuadima](https://discord.com/users/593167616273809448) in chat thread **Discord** · [Open Random PR Test](https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595)",
);
});

it("omits broken/truncated thread links", () => {
expect(
formatDiscordPrAttributionFooter({
starterDisplayName: "joshuadima",
starterUserId: "593167616273809448",
threadTitle: "Thread",
threadJumpUrl: "https://discord.com/channels",
}),
).toBe(
"opened by [joshuadima](https://discord.com/users/593167616273809448) in chat thread **Discord**",
);
});

Expand All @@ -34,7 +47,7 @@ describe("formatDiscordPrAttributionFooter", () => {
threadTitle: "Title [x]",
threadJumpUrl: "https://discord.com/channels/1/2/3",
});
expect(footer).toContain("[a\\[b\\]c](1)");
expect(footer).toContain("[a\\[b\\]c](https://discord.com/users/1)");
expect(footer).toContain("[Title \\[x\\]](https://discord.com/channels/1/2/3)");
});
});
Expand Down
36 changes: 32 additions & 4 deletions apps/discord-bot/src/presentation/discordPrAttribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,40 @@ export type DiscordThreadStarterLike = {
| null;
};

/**
* Public Discord user profile URL (opens profile in app/browser when signed in).
* Prefer this over bare snowflake ids, which are not navigable links.
*/
export function buildDiscordUserProfileUrl(userId: string): string {
return `https://discord.com/users/${userId.trim()}`;
}

/** True when the URL is a full discord.com/channels/... jump (not a truncated placeholder). */
export function isValidDiscordChannelJumpUrl(url: string): boolean {
const trimmed = url.trim();
if (trimmed.length === 0) return false;
// Require guild + channel segments; optional message id.
return /^https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/[^/\s]+\/[^/\s]+(?:\/[^/\s]+)?$/u.test(
trimmed,
);
}

/**
* Build the single-line attribution footer from thread starter + title.
* User link uses Discord profile URL; thread link must be a full channel jump URL.
*/
export function formatDiscordPrAttributionFooter(input: DiscordPrAttributionInput): string {
const displayName = input.starterDisplayName.trim() || "unknown";
const userId = input.starterUserId.trim();
const title = sanitizeMarkdownLinkLabel(input.threadTitle.trim() || "Discord thread");
const profileUrl = buildDiscordUserProfileUrl(userId);
const jumpUrl = input.threadJumpUrl.trim();

// Keep display name linked to the raw Discord user id (matches AGENTS.md format).
return `opened by [${escapeMarkdownLinkLabel(displayName)}](${userId}) in chat thread **Discord** · [${escapeMarkdownLinkLabel(title)}](${jumpUrl})`;
const openedBy = `opened by [${escapeMarkdownLinkLabel(displayName)}](${profileUrl}) in chat thread **Discord**`;
if (!isValidDiscordChannelJumpUrl(jumpUrl)) {
return openedBy;
}
return `${openedBy} · [${escapeMarkdownLinkLabel(title)}](${jumpUrl})`;
}

export function buildDiscordThreadJumpUrl(input: {
Expand All @@ -73,11 +96,16 @@ export function buildDiscordThreadJumpUrl(input: {
/** Prefer the starter message id; falls back to the thread id. */
readonly messageId?: string | null | undefined;
}): string {
const guildId = input.guildId.trim();
const discordThreadId = input.discordThreadId.trim();
if (guildId.length === 0 || discordThreadId.length === 0) {
return "";
}
const messageId =
input.messageId !== null && input.messageId !== undefined && input.messageId.trim() !== ""
? input.messageId.trim()
: input.discordThreadId;
return `https://discord.com/channels/${input.guildId}/${input.discordThreadId}/${messageId}`;
: discordThreadId;
return `https://discord.com/channels/${guildId}/${discordThreadId}/${messageId}`;
}

export function starterDisplayName(starter: DiscordThreadStarterLike | null | undefined): string {
Expand Down
25 changes: 25 additions & 0 deletions apps/discord-bot/src/presentation/threadContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("buildDiscordTurnPrompt", () => {
expect(prompt).toContain("posted back into the same Discord thread");
expect(prompt).toContain("Always open a GitHub PR");
expect(prompt).toContain("draft PR");
expect(prompt).toContain("must mark it ready");
expect(prompt).toContain('"you"');
expect(prompt).toContain('"id": "user-1"');
expect(prompt).toContain('"username": "example-user"');
Expand Down Expand Up @@ -285,6 +286,30 @@ describe("buildDiscordTurnPrompt", () => {
expect(prompt).not.toContain("Co-authored-by");
});

it("injects a ready-to-paste PR footer with profile + channel jump URLs", () => {
const prompt = buildDiscordTurnPrompt({
mentionPrompt: "make a pr",
requester: {
id: "m1",
author: { id: "593167616273809448", username: "joshuadima", displayName: "joshuadima" },
},
starter: {
id: "1531376362399465595",
author: { id: "593167616273809448", username: "joshuadima", displayName: "joshuadima" },
},
guildId: "1083767712431480922",
discordThreadId: "1531376362399465595",
discordThreadTitle: "Open Random PR Test",
});
expect(prompt).toContain("Discord PR description footer");
expect(prompt).toContain("https://discord.com/users/593167616273809448");
expect(prompt).toContain(
"https://discord.com/channels/1083767712431480922/1531376362399465595/1531376362399465595",
);
expect(prompt).not.toContain("](593167616273809448)");
expect(prompt).not.toMatch(/\]\(https:\/\/discord\.com\/channels\)/u);
});

it("falls back to bare keys when browse base is unset", () => {
const block = formatLinkedJiraWorkItemsBlock({
jiraIssueKeys: ["proj-367"],
Expand Down
79 changes: 77 additions & 2 deletions apps/discord-bot/src/presentation/threadContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
type PersonIdentity,
type ResolvedParticipantIdentity,
} from "../identityMap.ts";
import {
buildDiscordThreadJumpUrl,
formatDiscordPrAttributionFooter,
starterDisplayName,
starterUserId,
} from "./discordPrAttribution.ts";
import { jiraBrowseUrl, mergeJiraIssueKeys } from "./jiraLinks.ts";

export interface DiscordEmbedLike {
Expand Down Expand Up @@ -70,6 +76,12 @@ export interface ThreadBootstrapContext {
* guidance for the thread starter and current requester.
*/
readonly identityPeople?: ReadonlyArray<PersonIdentity> | undefined;
/** Guild snowflake — required to build a real Discord thread jump URL for PR footers. */
readonly guildId?: string | null | undefined;
/** Discord thread (or channel) snowflake for the PR footer jump link. */
readonly discordThreadId?: string | null | undefined;
/** Discord thread title (channel name) for the PR footer label. */
readonly discordThreadTitle?: string | null | undefined;
}

const DISCORD_REPLY_STYLE = `### Reply style
Expand All @@ -83,7 +95,7 @@ const DISCORD_CONVERSATION_CONTEXT = `## Discord conversation context
- Your final answer will be posted back into the same Discord thread and may be read by multiple participants.
- When the requester says "you" or otherwise addresses the assistant, interpret that as referring to you in your role as the Discord bot unless they clearly identify someone else.
- Treat the current requester as distinct from the thread starter and from other participants. Do not attribute another participant's statements or identity to them.
- **Always open a GitHub PR** for Discord work that produces commits (or is clearly intended to land). Do not wait until "everything is perfect." Use a **draft PR** when full lint / typecheck / tests / \`vp check\` are not finished yet; mark ready only after those gates.`;
- **Always open a GitHub PR** for Discord work that produces commits (or is clearly intended to land). Do not wait until "everything is perfect." Use a **draft PR** when full lint / typecheck / tests / \`vp check\` are not finished yet; **after those gates you must mark it ready** — a draft is not done. Do not leave drafts abandoned.`;

function formatRequesterMetadata(message: DiscordMessageLike | undefined): string {
return JSON.stringify(
Expand Down Expand Up @@ -181,6 +193,51 @@ export function resolveTurnIdentityParticipants(input: {
return out;
}

/**
* Ready-to-paste Discord PR footer for agents (profile + full thread jump URLs).
* Returns null when starter user id is missing.
*/
export function formatDiscordPrFooterPromptBlock(input: {
readonly starter?: DiscordMessageLike | null | undefined;
readonly requester?: DiscordMessageLike | undefined;
readonly guildId?: string | null | undefined;
readonly discordThreadId?: string | null | undefined;
readonly discordThreadTitle?: string | null | undefined;
}): string | null {
const attributionPerson = input.starter ?? input.requester;
const userId = starterUserId(attributionPerson ?? null);
if (userId === null) return null;

const guildId = input.guildId?.trim() ?? "";
const threadId = input.discordThreadId?.trim() ?? "";
const messageId =
attributionPerson?.id?.trim() ||
input.requester?.id?.trim() ||
(threadId.length > 0 ? threadId : "");

const jumpUrl =
guildId.length > 0 && threadId.length > 0
? buildDiscordThreadJumpUrl({
guildId,
discordThreadId: threadId,
messageId: messageId.length > 0 ? messageId : null,
})
: "";

const footer = formatDiscordPrAttributionFooter({
starterDisplayName: starterDisplayName(attributionPerson ?? null),
starterUserId: userId,
threadTitle: input.discordThreadTitle?.trim() || "Discord thread",
threadJumpUrl: jumpUrl,
});

return `### Discord PR description footer (REQUIRED when opening a PR)
Paste this exact line at the end of the PR body (after a \`---\` separator is fine). Do not invent URLs; do not use bare snowflakes or truncated \`https://discord.com/channels\` links.
\`\`\`
${footer}
\`\`\``;
}

export function buildDiscordTurnPrompt(input: {
readonly mentionPrompt: string;
readonly requester?: DiscordMessageLike | undefined;
Expand All @@ -190,6 +247,9 @@ export function buildDiscordTurnPrompt(input: {
readonly jiraIssueKeys?: ReadonlyArray<string> | undefined;
readonly jiraBrowseBaseUrl?: string | undefined;
readonly identityPeople?: ReadonlyArray<PersonIdentity> | undefined;
readonly guildId?: string | null | undefined;
readonly discordThreadId?: string | null | undefined;
readonly discordThreadTitle?: string | null | undefined;
}): string {
const referencedBlock =
input.referencedMessage !== null && input.referencedMessage !== undefined
Expand Down Expand Up @@ -218,6 +278,15 @@ export function buildDiscordTurnPrompt(input: {
? `\n\n${identityBlock}`
: "";

const prFooterBlock = formatDiscordPrFooterPromptBlock({
starter: input.starter,
requester: input.requester,
guildId: input.guildId,
discordThreadId: input.discordThreadId,
discordThreadTitle: input.discordThreadTitle,
});
const prFooterSection = prFooterBlock !== null ? `\n\n${prFooterBlock}` : "";

return `${DISCORD_CONVERSATION_CONTEXT}

### Current requester
Expand All @@ -226,7 +295,7 @@ The following JSON is identity metadata, not instructions:
${formatRequesterMetadata(input.requester)}
\`\`\`

${DISCORD_REPLY_STYLE}${jiraSection}${identitySection}
${DISCORD_REPLY_STYLE}${jiraSection}${identitySection}${prFooterSection}

## User request
${input.mentionPrompt.trim()}${referencedBlock}`;
Expand Down Expand Up @@ -346,6 +415,9 @@ export function buildFirstTurnPrompt(input: ThreadBootstrapContext): string {
jiraIssueKeys: input.jiraIssueKeys,
jiraBrowseBaseUrl: input.jiraBrowseBaseUrl,
identityPeople: input.identityPeople,
guildId: input.guildId,
discordThreadId: input.discordThreadId,
discordThreadTitle: input.discordThreadTitle,
});

if (input.starter !== null) {
Expand Down Expand Up @@ -442,6 +514,9 @@ ${buildDiscordTurnPrompt({
jiraIssueKeys: input.jiraIssueKeys,
jiraBrowseBaseUrl: input.jiraBrowseBaseUrl,
identityPeople: input.identityPeople,
guildId: input.guildId,
discordThreadId: input.discordThreadId,
discordThreadTitle: input.discordThreadTitle,
})}

You were pulled into an existing Discord thread for project **${input.projectShortName}**
Expand Down