feat: enhance collaboration guidelines in documentation and refactor mention handling for improved clarity and functionality

This commit is contained in:
Abhimanyu Saharan
2026-02-06 22:52:18 +05:30
parent 5611f8eb67
commit c238ae9876
9 changed files with 363 additions and 256 deletions

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import json
import re
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from uuid import UUID
@@ -26,11 +25,10 @@ from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"])
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def _parse_since(value: str | None) -> datetime | None:
if not value:
@@ -52,23 +50,6 @@ def _serialize_memory(memory: BoardMemory) -> dict[str, object]:
return BoardMemoryRead.model_validate(memory, from_attributes=True).model_dump(mode="json")
def _extract_mentions(message: str) -> set[str]:
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
if not mentions:
return False
name = (agent.name or "").strip()
if not name:
return False
normalized = name.lower()
if normalized in mentions:
return True
first = normalized.split()[0]
return first in mentions
async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientConfig | None:
if board.gateway_id is None:
return None
@@ -123,14 +104,14 @@ async def _notify_chat_targets(
config = await _gateway_config(session, board)
if config is None:
return
mentions = _extract_mentions(memory.content)
mentions = extract_mentions(memory.content)
statement = select(Agent).where(col(Agent.board_id) == board.id)
targets: dict[str, Agent] = {}
for agent in await session.exec(statement):
if agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and _matches_mention(agent, mentions):
if mentions and matches_agent_mention(agent, mentions):
targets[str(agent.id)] = agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(str(actor.agent.id), None)
@@ -148,7 +129,7 @@ async def _notify_chat_targets(
for agent in targets.values():
if not agent.openclaw_session_id:
continue
mentioned = _matches_mention(agent, mentions)
mentioned = matches_agent_mention(agent, mentions)
header = "BOARD CHAT MENTION" if mentioned else "BOARD CHAT"
message = (
f"{header}\n"

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import json
import re
from collections import deque
from collections.abc import AsyncIterator
from datetime import datetime, timezone
@@ -40,6 +39,7 @@ from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
@@ -51,7 +51,6 @@ TASK_EVENT_TYPES = {
"task.comment",
}
SSE_SEEN_MAX = 2000
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def _comment_validation_error() -> HTTPException:
@@ -99,23 +98,6 @@ def _parse_since(value: str | None) -> datetime | None:
return parsed
def _extract_mentions(message: str) -> set[str]:
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
if not mentions:
return False
name = (agent.name or "").strip()
if not name:
return False
normalized = name.lower()
if normalized in mentions:
return True
first = normalized.split()[0]
return first in mentions
async def _lead_was_mentioned(
session: AsyncSession,
task: Task,
@@ -130,8 +112,8 @@ async def _lead_was_mentioned(
for message in await session.exec(statement):
if not message:
continue
mentions = _extract_mentions(message)
if _matches_mention(lead, mentions):
mentions = extract_mentions(message)
if matches_agent_mention(lead, mentions):
return True
return False
@@ -718,12 +700,12 @@ async def create_task_comment(
session.add(event)
await session.commit()
await session.refresh(event)
mention_names = _extract_mentions(payload.message)
mention_names = extract_mentions(payload.message)
targets: dict[UUID, Agent] = {}
if mention_names and task.board_id:
statement = select(Agent).where(col(Agent.board_id) == task.board_id)
for agent in await session.exec(statement):
if _matches_mention(agent, mention_names):
if matches_agent_mention(agent, mention_names):
targets[agent.id] = agent
if not mention_names and task.assigned_agent_id:
assigned_agent = await session.get(Agent, task.assigned_agent_id)
@@ -742,7 +724,7 @@ async def create_task_comment(
for agent in targets.values():
if not agent.openclaw_session_id:
continue
mentioned = _matches_mention(agent, mention_names)
mentioned = matches_agent_mention(agent, mention_names)
header = "TASK MENTION" if mentioned else "NEW TASK COMMENT"
action_line = (
"You were mentioned in this comment."

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import re
from app.models.agents import Agent
# Mention tokens are single, space-free words (e.g. "@alex", "@lead").
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def extract_mentions(message: str) -> set[str]:
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
def matches_agent_mention(agent: Agent, mentions: set[str]) -> bool:
if not mentions:
return False
# "@lead" is a reserved shortcut that always targets the board lead.
if "lead" in mentions and agent.is_board_lead:
return True
mentions = mentions - {"lead"}
name = (agent.name or "").strip()
if not name:
return False
normalized = name.lower()
if normalized in mentions:
return True
# Mentions are single tokens; match on first name for display names with spaces.
first = normalized.split()[0]
return first in mentions

View File

@@ -0,0 +1,20 @@
from app.models.agents import Agent
from app.services.mentions import extract_mentions, matches_agent_mention
def test_extract_mentions_parses_tokens():
assert extract_mentions("hi @Alex and @bob-2") == {"alex", "bob-2"}
def test_matches_agent_mention_matches_first_name():
agent = Agent(name="Alice Cooper")
assert matches_agent_mention(agent, {"alice"}) is True
assert matches_agent_mention(agent, {"cooper"}) is False
def test_matches_agent_mention_supports_reserved_lead_shortcut():
lead = Agent(name="Riya", is_board_lead=True)
other = Agent(name="Lead", is_board_lead=False)
assert matches_agent_mention(lead, {"lead"}) is True
assert matches_agent_mention(other, {"lead"}) is False

View File

@@ -67,7 +67,6 @@ export default function EditBoardPage() {
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const onboardingParam = searchParams.get("onboarding");
const searchParamsString = searchParams.toString();
@@ -77,12 +76,12 @@ export default function EditBoardPage() {
onboardingParam !== "0" &&
onboardingParam.toLowerCase() !== "false";
const [isOnboardingOpen, setIsOnboardingOpen] = useState(shouldAutoOpenOnboarding);
useEffect(() => {
if (!boardId) return;
if (!shouldAutoOpenOnboarding) return;
setIsOnboardingOpen(true);
// Remove the flag from the URL so refreshes don't constantly reopen it.
const nextParams = new URLSearchParams(searchParamsString);
nextParams.delete("onboarding");

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
@@ -168,6 +168,178 @@ const MARKDOWN_TABLE_COMPONENTS: Components = {
),
};
const MARKDOWN_COMPONENTS_BASIC: Components = {
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-2 last:mb-0", className)} {...props} />
),
ul: ({ node: _node, className, ...props }) => (
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
),
ol: ({ node: _node, className, ...props }) => (
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
),
li: ({ node: _node, className, ...props }) => (
<li className={cn("mb-1", className)} {...props} />
),
strong: ({ node: _node, className, ...props }) => (
<strong className={cn("font-semibold", className)} {...props} />
),
};
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
...MARKDOWN_COMPONENTS_BASIC,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-3 last:mb-0", className)} {...props} />
),
h1: ({ node: _node, className, ...props }) => (
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
),
h2: ({ node: _node, className, ...props }) => (
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
h3: ({ node: _node, className, ...props }) => (
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
code: ({ node: _node, className, ...props }) => (
<code
className={cn("rounded bg-slate-100 px-1 py-0.5 text-xs", className)}
{...props}
/>
),
pre: ({ node: _node, className, ...props }) => (
<pre
className={cn(
"overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100",
className,
)}
{...props}
/>
),
};
const MARKDOWN_REMARK_PLUGINS_BASIC = [remarkGfm];
const MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
type MarkdownVariant = "basic" | "comment" | "description";
const Markdown = memo(function Markdown({
content,
variant,
}: {
content: string;
variant: MarkdownVariant;
}) {
const trimmed = content.trim();
const remarkPlugins =
variant === "comment"
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
: MARKDOWN_REMARK_PLUGINS_BASIC;
const components =
variant === "description" ? MARKDOWN_COMPONENTS_DESCRIPTION : MARKDOWN_COMPONENTS_BASIC;
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{trimmed}
</ReactMarkdown>
);
});
Markdown.displayName = "Markdown";
const formatShortTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const TaskCommentCard = memo(function TaskCommentCard({
comment,
authorLabel,
}: {
comment: TaskComment;
authorLabel: string;
}) {
const message = (comment.message ?? "").trim();
return (
<div className="rounded-xl border border-slate-200 bg-white p-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{authorLabel}</span>
<span>{formatShortTimestamp(comment.created_at)}</span>
</div>
{message ? (
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message} variant="comment" />
</div>
) : (
<p className="mt-2 text-sm text-slate-900"></p>
)}
</div>
);
});
TaskCommentCard.displayName = "TaskCommentCard";
const ChatMessageCard = memo(function ChatMessageCard({
message,
}: {
message: BoardChatMessage;
}) {
return (
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">{message.source ?? "User"}</p>
<span className="text-xs text-slate-400">
{formatShortTimestamp(message.created_at)}
</span>
</div>
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message.content} variant="basic" />
</div>
</div>
);
});
ChatMessageCard.displayName = "ChatMessageCard";
const LiveFeedCard = memo(function LiveFeedCard({
comment,
taskTitle,
authorLabel,
}: {
comment: TaskComment;
taskTitle: string;
authorLabel: string;
}) {
const message = (comment.message ?? "").trim();
return (
<div className="rounded-xl border border-slate-200 bg-white p-3">
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-700">{taskTitle}</p>
<p className="mt-1 text-[11px] text-slate-400">{authorLabel}</p>
</div>
<span className="text-[11px] text-slate-400">
{formatShortTimestamp(comment.created_at)}
</span>
</div>
{message ? (
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900 break-words">
<Markdown content={message} variant="basic" />
</div>
) : (
<p className="mt-2 text-xs text-slate-500"></p>
)}
</div>
);
});
LiveFeedCard.displayName = "LiveFeedCard";
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
@@ -181,6 +353,7 @@ export default function BoardDetailPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const selectedTaskIdRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
@@ -352,6 +525,10 @@ export default function BoardDetailPage() {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
selectedTaskIdRef.current = selectedTask?.id ?? null;
}, [selectedTask?.id]);
useEffect(() => {
chatMessagesRef.current = chatMessages;
}, [chatMessages]);
@@ -691,14 +868,30 @@ export default function BoardDetailPage() {
if (payload.comment?.task_id && payload.type === "task.comment") {
pushLiveFeed(payload.comment);
setComments((prev) => {
if (selectedTask?.id !== payload.comment?.task_id) {
if (selectedTaskIdRef.current !== payload.comment?.task_id) {
return prev;
}
const exists = prev.some((item) => item.id === payload.comment?.id);
if (exists) {
return prev;
}
const createdAt = payload.comment?.created_at;
const createdMs = createdAt ? new Date(createdAt).getTime() : NaN;
if (prev.length === 0 || Number.isNaN(createdMs)) {
return [...prev, payload.comment as TaskComment];
}
const last = prev[prev.length - 1];
const lastMs = last?.created_at ? new Date(last.created_at).getTime() : NaN;
if (!Number.isNaN(lastMs) && createdMs >= lastMs) {
return [...prev, payload.comment as TaskComment];
}
const next = [...prev, payload.comment as TaskComment];
next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
return next;
});
} else if (payload.task) {
setTasks((prev) => {
@@ -766,7 +959,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout);
}
};
}, [board, boardId, isSignedIn, selectedTask?.id, pushLiveFeed]);
}, [board, boardId, isSignedIn, pushLiveFeed]);
useEffect(() => {
if (!isSignedIn || !boardId) return;
@@ -1007,14 +1200,6 @@ export default function BoardDetailPage() {
selectedTask,
]);
const orderedComments = useMemo(() => {
return [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return bTime - aTime;
});
}, [comments]);
const pendingApprovals = useMemo(
() => approvals.filter((approval) => approval.status === "pending"),
[approvals],
@@ -1061,7 +1246,13 @@ export default function BoardDetailPage() {
taskId,
);
if (result.status !== 200) throw new Error("Unable to load comments.");
setComments(result.data.items ?? []);
const items = [...(result.data.items ?? [])];
items.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
setComments(items);
} catch (err) {
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -1074,6 +1265,7 @@ export default function BoardDetailPage() {
setIsLiveFeedOpen(false);
const fullTask = tasksRef.current.find((item) => item.id === task.id);
if (!fullTask) return;
selectedTaskIdRef.current = fullTask.id;
setSelectedTask(fullTask);
setIsDetailOpen(true);
void loadComments(task.id);
@@ -1081,6 +1273,7 @@ export default function BoardDetailPage() {
const closeComments = () => {
setIsDetailOpen(false);
selectedTaskIdRef.current = null;
setSelectedTask(null);
setComments([]);
setCommentsError(null);
@@ -1313,17 +1506,6 @@ export default function BoardDetailPage() {
return "Agent";
};
const formatCommentTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatTaskTimestamp = (value?: string | null) => {
if (!value) return "—";
const date = new Date(value);
@@ -1792,44 +1974,7 @@ export default function BoardDetailPage() {
</p>
{selectedTask?.description ? (
<div className="prose prose-sm max-w-none text-slate-700">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, ...props }) => (
<p className="mb-3 last:mb-0" {...props} />
),
ul: ({ node: _node, ...props }) => (
<ul className="mb-3 list-disc pl-5" {...props} />
),
ol: ({ node: _node, ...props }) => (
<ol className="mb-3 list-decimal pl-5" {...props} />
),
li: ({ node: _node, ...props }) => (
<li className="mb-1" {...props} />
),
strong: ({ node: _node, ...props }) => (
<strong className="font-semibold" {...props} />
),
h1: ({ node: _node, ...props }) => (
<h1 className="mb-2 text-base font-semibold" {...props} />
),
h2: ({ node: _node, ...props }) => (
<h2 className="mb-2 text-sm font-semibold" {...props} />
),
h3: ({ node: _node, ...props }) => (
<h3 className="mb-2 text-sm font-semibold" {...props} />
),
code: ({ node: _node, ...props }) => (
<code className="rounded bg-slate-100 px-1 py-0.5 text-xs" {...props} />
),
pre: ({ node: _node, ...props }) => (
<pre className="overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100" {...props} />
),
}}
>
{selectedTask.description}
</ReactMarkdown>
<Markdown content={selectedTask.description} variant="description" />
</div>
) : (
<p className="text-sm text-slate-500">No description provided.</p>
@@ -1963,60 +2108,16 @@ export default function BoardDetailPage() {
<p className="text-sm text-slate-500">No comments yet.</p>
) : (
<div className="space-y-3">
{orderedComments.map((comment) => (
<div
{comments.map((comment) => (
<TaskCommentCard
key={comment.id}
className="rounded-xl border border-slate-200 bg-white p-3 select-none"
>
<>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>
{comment.agent_id
comment={comment}
authorLabel={
comment.agent_id
? assigneeById.get(comment.agent_id) ?? "Agent"
: "Admin"}
</span>
<span>{formatCommentTimestamp(comment.created_at)}</span>
</div>
{comment.message?.trim() ? (
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, ...props }) => (
<p
className="text-sm text-slate-900 break-words"
{...props}
: "Admin"
}
/>
),
ul: ({ node: _node, ...props }) => (
<ul
className="list-disc pl-5 text-sm text-slate-900 break-words"
{...props}
/>
),
li: ({ node: _node, ...props }) => (
<li
className="mb-1 text-sm text-slate-900 break-words"
{...props}
/>
),
strong: ({ node: _node, ...props }) => (
<strong
className="font-semibold text-slate-900"
{...props}
/>
),
}}
>
{comment.message}
</ReactMarkdown>
</div>
) : (
<p className="mt-2 text-sm text-slate-900"></p>
)}
</>
</div>
))}
</div>
)}
@@ -2063,41 +2164,7 @@ export default function BoardDetailPage() {
</p>
) : (
chatMessages.map((message) => (
<div
key={message.id}
className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4 select-none"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">
{message.source ?? "User"}
</p>
<span className="text-xs text-slate-400">
{formatTaskTimestamp(message.created_at)}
</span>
</div>
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, ...props }) => (
<p className="mb-2 last:mb-0" {...props} />
),
ul: ({ node: _node, ...props }) => (
<ul className="mb-2 list-disc pl-5" {...props} />
),
ol: ({ node: _node, ...props }) => (
<ol className="mb-2 list-decimal pl-5" {...props} />
),
strong: ({ node: _node, ...props }) => (
<strong className="font-semibold" {...props} />
),
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
<ChatMessageCard key={message.id} message={message} />
))
)}
<div ref={chatEndRef} />
@@ -2140,57 +2207,20 @@ export default function BoardDetailPage() {
) : (
<div className="space-y-3">
{orderedLiveFeed.map((comment) => (
<div
<LiveFeedCard
key={comment.id}
className="rounded-xl border border-slate-200 bg-white p-3 select-none"
>
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-700">
{comment.task_id
comment={comment}
taskTitle={
comment.task_id
? taskTitleById.get(comment.task_id) ?? "Task"
: "Task"}
</p>
<p className="mt-1 text-[11px] text-slate-400">
{comment.agent_id
: "Task"
}
authorLabel={
comment.agent_id
? assigneeById.get(comment.agent_id) ?? "Agent"
: "Admin"}
</p>
</div>
<span className="text-[11px] text-slate-400">
{formatCommentTimestamp(comment.created_at)}
</span>
</div>
{comment.message?.trim() ? (
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, ...props }) => (
<p className="mb-2 last:mb-0" {...props} />
),
ul: ({ node: _node, ...props }) => (
<ul className="mb-2 list-disc pl-5" {...props} />
),
ol: ({ node: _node, ...props }) => (
<ol className="mb-2 list-decimal pl-5" {...props} />
),
li: ({ node: _node, ...props }) => (
<li className="mb-1" {...props} />
),
strong: ({ node: _node, ...props }) => (
<strong className="font-semibold" {...props} />
),
}}
>
{comment.message}
</ReactMarkdown>
</div>
) : (
<p className="mt-2 text-xs text-slate-500"></p>
)}
</div>
: "Admin"
}
/>
))}
</div>
)}

View File

@@ -31,6 +31,17 @@ Write things down. Do not rely on short-term context.
- HEARTBEAT.md defines what to do on each heartbeat.
- Follow it exactly.
## Collaboration (mandatory)
- You are one of multiple agents on a board. Act like a team, not a silo.
- The assigned agent is the DRI for a task. Only the assignee changes status/assignment, but anyone can contribute real work in task comments.
- Task comments are the primary channel for agent-to-agent collaboration.
- Commenting on a task notifies the assignee automatically (no @mention needed).
- Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work).
- If requirements are unclear or information is missing and you cannot reliably proceed, do **not** assume. Ask the board lead for clarity by tagging them.
- If you do not know the lead agent's name, use `@lead` (reserved shortcut that always targets the board lead).
- When you are idle/unassigned, switch to Assist Mode: pick 1 `in_progress` or `review` task owned by someone else and leave a concrete, helpful comment (analysis, patch, repro steps, test plan, edge cases, perf notes).
- Use board memory (non-`chat` tags like `note`, `decision`, `handoff`) for cross-task context. Do not put task status updates there.
## Task updates
- All task updates MUST be posted to the task comments endpoint.
- Do not post task updates in chat/web channels under any circumstance.

View File

@@ -24,6 +24,8 @@ If any required input is missing, stop and request a provisioning update.
- Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress.
- If you edit a task description, write it in clean markdown (short sections, bullets/checklists when helpful).
- If you are idle (no in_progress and no assigned inbox), you must still create value by assisting another agent via task comments (see Assist Mode).
- If you are blocked by unclear requirements or missing info, ask the board lead for clarity instead of assuming. Tag them as `@FirstName` or use `@lead` if you don't know the name.
## Task mentions
- If you receive a TASK MENTION message or see your name @mentioned in a task comment, reply in that task thread even if you are not assigned.
@@ -64,6 +66,12 @@ curl -s "$BASE_URL/api/v1/agent/boards" \
-H "X-Agent-Token: {{ auth_token }}"
```
2b) List agents on the board (so you know who to collaborate with and who is lead):
```bash
curl -s "$BASE_URL/api/v1/agent/agents?board_id=$BOARD_ID" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
@@ -82,7 +90,7 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
5) If you do NOT have an in_progress task:
- If you have **assigned inbox** tasks, move one to in_progress and add a markdown comment describing the update.
- If there are **unassigned inbox** tasks, do **not** claim them. Wait for the board lead to assign work.
- If you have **no assigned inbox** tasks, do **not** claim unassigned work. Run Assist Mode (below).
6) Work the task:
- Post progress comments as you go.
@@ -112,6 +120,38 @@ curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-d '{"status": "review"}'
```
## Assist Mode (when idle)
If you have no in_progress task and no assigned inbox tasks, you still must contribute on every heartbeat by helping another agent.
1) List tasks to assist (pick 1 in_progress or review task you can add value to):
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=review&limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
2) Read the task comments:
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments?limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) Leave a concrete, helpful comment in the task thread (this notifies the assignee automatically):
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"message":"### Assist\n- What I found\n- Suggested fix\n- Edge cases/tests\n\n### Next\n- Recommended next step"}'
```
Constraints:
- Do not change task status or assignment (you are not the DRI).
- Do not spam. Default to 1 assist comment per heartbeat.
- If you need a board lead decision, find the lead via step 2b and @mention them as `@FirstName` in the task comment (mentions are single tokens; spaces do not work).
## Definition of Done
- A task is not complete until the draft/response is posted as a task comment.
- Comments must be markdown.

View File

@@ -25,18 +25,22 @@ If any required input is missing, stop and request a provisioning update.
- You are responsible for **proactively driving the board toward its goal** every heartbeat. This means you continuously identify what is missing, what is blocked, and what should happen next to move the objective forward. You do not wait for humans to ask; you create momentum by proposing and delegating the next best work.
- **Never idle.** If there are no pending tasks (no inbox / in_progress / review items), you must create a concrete plan and populate the board with the next best tasks to achieve the goal.
- You are responsible for **increasing collaboration among other agents**. Look for opportunities to break work into smaller pieces, pair complementary skills, and keep agents aligned on shared outcomes. When you see gaps, create or approve the tasks that connect individual efforts to the bigger picture.
- Prefer "Assist" tasks over reassigning. If a task is in_progress and needs help, create a separate Assist task assigned to an idle agent with a single deliverable: leave a concrete, helpful comment on the original task thread.
- Ensure every high-priority task has a second set of eyes: a buddy agent for review, validation, or edge-case testing (again via Assist tasks).
- When you leave review feedback, format it as clean markdown. Use headings/bullets/tables when helpful, but only when it improves clarity.
- If your feedback is longer than 2 sentences, do **not** write a single paragraph. Use a short heading + bullets so each idea is on its own line.
## Task mentions
- If you are @mentioned in a task comment, you may reply **regardless of task status**.
- Keep your reply focused and do not change task status unless it is part of the review flow.
- `@lead` is a reserved shortcut mention that always refers to you (the board lead). Treat it as high priority.
## Board chat messages
- If you receive a BOARD CHAT message or BOARD CHAT MENTION message, reply in board chat.
- Use: POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
Body: {"content":"...","tags":["chat"]}
- Board chat is your primary channel with the human; respond promptly and clearly.
- If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them.
## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP.
@@ -61,6 +65,7 @@ If any required input is missing, stop and request a provisioning update.
2) Review recent tasks/comments and board memory:
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?limit=50
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory?limit=50
- GET $BASE_URL/api/v1/agent/agents?board_id={BOARD_ID}
- For any task in **review**, fetch its comments:
GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
@@ -86,6 +91,11 @@ If any required input is missing, stop and request a provisioning update.
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"assigned_agent_id":"AGENT_ID"}
5b) Build collaboration pairs:
- For each high/medium priority task in_progress, ensure there is at least one helper agent.
- If a task needs help, create a new Assist task assigned to an idle agent with a clear deliverable: "leave a helpful comment on TASK_ID with analysis/patch/tests".
- If you notice duplication between tasks, create a coordination task to split scope cleanly and assign it to one agent.
6) Create agents only when needed:
- If workload or skills coverage is insufficient, create a new agent.
- Rule: you may autocreate agents only when confidence >= 70 and the action is not risky/external.
@@ -95,7 +105,7 @@ If any required input is missing, stop and request a provisioning update.
POST $BASE_URL/api/v1/agent/agents
Body example:
{
"name": "Researcher Alpha",
"name": "Riya",
"board_id": "{BOARD_ID}",
"identity_profile": {
"role": "Research",
@@ -219,5 +229,5 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
- Claiming or working tasks as the lead.
- Posting task comments outside review, @mentions, or tasks you created.
- Assigning a task to yourself.
- Marking tasks review/done (lead cannot).
- Moving tasks to in_progress/review (lead cannot).
- Using nonagent endpoints or Authorization header.