chore: merge dev into main
This commit is contained in:
commit
679f05999f
369 changed files with 43997 additions and 6596 deletions
8
.github/CLA.md
vendored
8
.github/CLA.md
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# Contributor License Agreement (CLA)
|
||||
|
||||
**Project:** Claude Agent Teams UI (claude-agent-teams-ui)
|
||||
**Project:** Agent Teams (agent-teams-ai)
|
||||
**License:** GNU Affero General Public License v3.0 (AGPL-3.0)
|
||||
|
||||
---
|
||||
|
|
@ -15,10 +15,10 @@ You represent that you have the right to grant the above license and that your c
|
|||
|
||||
## Signed contributors
|
||||
|
||||
| Name | Email | Date |
|
||||
|------|-------|------|
|
||||
| Name | Email | Date |
|
||||
| ---------------- | ---------------------- | ---------- |
|
||||
| Илия (777genius) | quantjumppro@gmail.com | 2026-02-22 |
|
||||
|
||||
---
|
||||
|
||||
*To add yourself: submit a pull request that adds your name, email, and the current date to the table above. By merging the PR you confirm your agreement with this CLA.*
|
||||
_To add yourself: submit a pull request that adds your name, email, and the current date to the table above. By merging the PR you confirm your agreement with this CLA._
|
||||
|
|
|
|||
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for contributing to Claude Agent Teams UI!
|
||||
Thanks for contributing to Agent Teams!
|
||||
|
||||
## Before You Start
|
||||
|
||||
|
|
|
|||
8
.github/SECURITY.md
vendored
8
.github/SECURITY.md
vendored
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Network Activity
|
||||
|
||||
Claude Agent Teams UI makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
|
||||
Agent Teams makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
|
||||
|
||||
| Network activity | When | Mode | User-initiated |
|
||||
|---|---|---|---|
|
||||
|
|
@ -18,7 +18,7 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
|
|||
|
||||
- All session data is read **locally** from `~/.claude/` — it never leaves your machine.
|
||||
- The app does not write to session files. Volume mounts in Docker use `:ro` (read-only) by default.
|
||||
- Configuration is stored at `~/.claude/claude-devtools-config.json` on the local filesystem.
|
||||
- Configuration is stored at `~/.claude/agent-teams-config.json` on the local filesystem.
|
||||
- No data is sent to Anthropic, GitHub (other than the auto-updater in Electron mode), or any other third party.
|
||||
|
||||
## Docker Network Isolation
|
||||
|
|
@ -26,8 +26,8 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
|
|||
For maximum trust, run the Docker container with `--network none`:
|
||||
|
||||
```bash
|
||||
docker build -t claude-agent-teams-ui -f docker/Dockerfile .
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
docker build -t agent-teams-ai -f docker/Dockerfile .
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
|
||||
```
|
||||
|
||||
Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compose.yml`.
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -71,7 +71,7 @@ jobs:
|
|||
run: npx eslint src/ --fix --no-cache || true
|
||||
|
||||
- name: Validate workspace truth gate
|
||||
run: pnpm check
|
||||
run: pnpm check:ci
|
||||
|
||||
test:
|
||||
strategy:
|
||||
|
|
@ -97,7 +97,7 @@ jobs:
|
|||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test:workspace
|
||||
run: pnpm test:workspace:ci
|
||||
|
||||
task-change-ledger-windows:
|
||||
name: Task change ledger Windows smoke
|
||||
|
|
|
|||
2
.github/workflows/landing.yml
vendored
2
.github/workflows/landing.yml
vendored
|
|
@ -33,6 +33,8 @@ jobs:
|
|||
working-directory: landing
|
||||
env:
|
||||
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
|
||||
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
|
||||
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
|
||||
run: npx nuxt generate
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -254,7 +254,7 @@ jobs:
|
|||
run: ${{ matrix.dist_command }} --publish never
|
||||
|
||||
- name: Validate packaged bundle (macOS ${{ matrix.arch }})
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Claude Agent Teams UI.app" darwin ${{ matrix.arch }}
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }}
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -498,7 +498,7 @@ jobs:
|
|||
|
||||
declare -A FILES=(
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
|
||||
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage"
|
||||
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Claude Agent Teams UI
|
||||
# Agent Teams
|
||||
|
||||
A new approach to task management with AI agent teams. Assemble agent teams with different roles that work autonomously in parallel, communicate with each other, create and manage their own tasks, review code, and collaborate across teams. You manage everything through a kanban board — like a CTO with an AI engineering team.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>
|
||||
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>
|
||||
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Agent Teams" width="80" />
|
||||
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>
|
||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams UI</a></h1>
|
||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1>
|
||||
|
||||
<p align="center">
|
||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
||||
|
|
@ -169,7 +169,7 @@ For feature architecture and implementation guidance:
|
|||
|
||||
## Comparison
|
||||
|
||||
| Feature | Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI |
|
||||
| Feature | Agent Teams | Vibe Kanban | Aperant | Cursor | Claude Code CLI |
|
||||
|---|---|---|---|---|---|
|
||||
| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ |
|
||||
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) |
|
||||
|
|
@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] Run terminal commands
|
||||
- [ ] Monitor agents processes/stats
|
||||
- [ ] Reusable agents with SOUL.md
|
||||
- [ ] Сommunicate via messenger
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
const kanbanStore = require('./kanbanStore.js');
|
||||
const taskStore = require('./taskStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
const { withTeamBoardLock } = require('./boardLock.js');
|
||||
|
||||
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||
const REVIEW_COLUMNS = new Set(['review', 'approved']);
|
||||
const INVENTORY_KANBAN_COLUMNS = new Set(['review', 'approved']);
|
||||
const MAX_MEMBER_ACTIONABLE_ITEMS = 50;
|
||||
const MAX_MEMBER_AWARENESS_ITEMS = 30;
|
||||
const MAX_LEAD_SECTION_ITEMS = 50;
|
||||
const MAX_EXPANDED_CONTEXT_ITEMS = 8;
|
||||
const MAX_DESCRIPTION_CHARS = 1200;
|
||||
const MAX_COMMENT_CHARS = 500;
|
||||
const MAX_SUBJECT_CHARS = 240;
|
||||
const MAX_ANOMALY_ITEMS = 25;
|
||||
const MAX_ANOMALY_DETAIL_CHARS = 500;
|
||||
|
||||
function normalizeName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
|
|
@ -15,28 +23,17 @@ function normalizeKey(value) {
|
|||
return normalizeName(value).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeReviewState(value) {
|
||||
const normalized = normalizeName(value);
|
||||
return REVIEW_STATES.has(normalized) ? normalized : 'none';
|
||||
}
|
||||
|
||||
function formatTaskLabel(task) {
|
||||
return `#${task.displayId || task.id}`;
|
||||
}
|
||||
|
||||
function isLeadCandidate(member) {
|
||||
if (!member || typeof member !== 'object') return false;
|
||||
if (typeof member.agentType === 'string' && member.agentType.trim() === 'team-lead') {
|
||||
return true;
|
||||
}
|
||||
if (typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) {
|
||||
return true;
|
||||
}
|
||||
return normalizeKey(member.name) === 'team-lead';
|
||||
return runtimeHelpers.isCanonicalLeadMember(member);
|
||||
}
|
||||
|
||||
function buildQueueRoster(paths) {
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(paths);
|
||||
const explicit = runtimeHelpers.collectExplicitTeamMembers(paths);
|
||||
const membersByKey = new Map();
|
||||
|
||||
for (const member of resolved.members || []) {
|
||||
|
|
@ -61,6 +58,7 @@ function buildQueueRoster(paths) {
|
|||
|
||||
return {
|
||||
membersByKey,
|
||||
explicitMemberKeys: new Set(explicit.membersByKey.keys()),
|
||||
removedNames: resolved.removedNames || new Set(),
|
||||
leadAliases,
|
||||
leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean),
|
||||
|
|
@ -69,6 +67,52 @@ function buildQueueRoster(paths) {
|
|||
};
|
||||
}
|
||||
|
||||
function collectExplicitMemberKeys(paths) {
|
||||
return new Set(runtimeHelpers.collectExplicitTeamMembers(paths).membersByKey.keys());
|
||||
}
|
||||
|
||||
function isCurrentRuntimeMember(teamName, memberName) {
|
||||
const requestedKey = normalizeKey(memberName);
|
||||
if (!requestedKey) return false;
|
||||
|
||||
const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity();
|
||||
if (!runtimeIdentity) return false;
|
||||
|
||||
const runtimeAgentName = normalizeKey(runtimeIdentity.agentName);
|
||||
const runtimeAgentId = normalizeKey(runtimeIdentity.agentId);
|
||||
const runtimeTeamName = normalizeKey(runtimeIdentity.teamName);
|
||||
const requestedAgentId = `${requestedKey}@${normalizeKey(teamName)}`;
|
||||
return (
|
||||
(runtimeAgentName === requestedKey || runtimeAgentId === requestedAgentId) &&
|
||||
(!runtimeTeamName || runtimeTeamName === normalizeKey(teamName))
|
||||
);
|
||||
}
|
||||
|
||||
function validateBriefingMember(paths, teamName, memberName) {
|
||||
const normalized = normalizeName(memberName);
|
||||
const key = normalizeKey(normalized);
|
||||
if (!key) {
|
||||
throw new Error('Missing member name');
|
||||
}
|
||||
|
||||
const roster = buildQueueRoster(paths);
|
||||
if (roster.removedNames.has(key)) {
|
||||
throw new Error(`Member is removed from the team: ${normalized}`);
|
||||
}
|
||||
const explicitMemberKeys = collectExplicitMemberKeys(paths);
|
||||
if (explicitMemberKeys.has(key) || isCurrentRuntimeMember(teamName, normalized)) {
|
||||
return { warnings: [] };
|
||||
}
|
||||
if (roster.membersByKey.has(key)) {
|
||||
return {
|
||||
warnings: [
|
||||
`Member identity warning: ${normalized} is known only from inbox state, not team config/member metadata. Verify the member name before acting.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Member not found in team metadata or inboxes: ${normalized}`);
|
||||
}
|
||||
|
||||
function resolveQueueActor(value, roster) {
|
||||
const normalized = normalizeName(value);
|
||||
if (!normalized) return null;
|
||||
|
|
@ -84,6 +128,7 @@ function resolveQueueActor(value, roster) {
|
|||
|
||||
const member = roster.membersByKey.get(key);
|
||||
if (!member) return null;
|
||||
if (!roster.explicitMemberKeys || !roster.explicitMemberKeys.has(key)) return null;
|
||||
|
||||
if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) {
|
||||
return { kind: 'lead', memberName: roster.canonicalLeadName };
|
||||
|
|
@ -98,56 +143,8 @@ function areSameActors(left, right) {
|
|||
return normalizeKey(left.memberName) === normalizeKey(right.memberName);
|
||||
}
|
||||
|
||||
function resolveReviewStateFromHistory(task) {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const event = events[i];
|
||||
if (
|
||||
event.type === 'review_requested' ||
|
||||
event.type === 'review_changes_requested' ||
|
||||
event.type === 'review_approved' ||
|
||||
event.type === 'review_started'
|
||||
) {
|
||||
return {
|
||||
state: normalizeReviewState(event.to),
|
||||
source: `history_${event.type}`,
|
||||
};
|
||||
}
|
||||
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||
return {
|
||||
state: 'none',
|
||||
source: 'history_status_reset',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEffectiveReviewState(task, kanbanEntry) {
|
||||
const historyState = resolveReviewStateFromHistory(task);
|
||||
if (historyState) {
|
||||
return historyState;
|
||||
}
|
||||
|
||||
const persisted = normalizeReviewState(task.reviewState);
|
||||
if (persisted !== 'none') {
|
||||
return {
|
||||
state: persisted,
|
||||
source: 'task_review_state',
|
||||
};
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
return {
|
||||
state: normalizeReviewState(kanbanEntry.column),
|
||||
source: 'kanban_column',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'none',
|
||||
source: 'none',
|
||||
};
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry);
|
||||
}
|
||||
|
||||
function resolveLegacyKanbanReviewer(task, roster, options = {}) {
|
||||
|
|
@ -208,7 +205,10 @@ function resolveCurrentCycleReviewer(task, roster, options = {}) {
|
|||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||
if (
|
||||
event.type === 'status_changed' &&
|
||||
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -247,17 +247,51 @@ function buildBoardState(paths, teamName) {
|
|||
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
|
||||
const roster = buildQueueRoster(paths);
|
||||
const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task]));
|
||||
const anomalies = [];
|
||||
|
||||
if (kanbanState.tasks && typeof kanbanState.tasks === 'object') {
|
||||
for (const [taskId, entry] of Object.entries(kanbanState.tasks)) {
|
||||
if (!tasksById.has(taskId)) {
|
||||
anomalies.push({
|
||||
code: 'stale_kanban_task',
|
||||
taskId,
|
||||
detail: `Kanban ${entry && entry.column ? entry.column : 'entry'} references a missing or deleted task row.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kanbanState.columnOrder && typeof kanbanState.columnOrder === 'object') {
|
||||
for (const [columnId, orderedTaskIds] of Object.entries(kanbanState.columnOrder)) {
|
||||
if (!Array.isArray(orderedTaskIds)) continue;
|
||||
for (const taskId of orderedTaskIds) {
|
||||
const id = String(taskId);
|
||||
const entry = kanbanState.tasks ? kanbanState.tasks[id] : undefined;
|
||||
if (!tasksById.has(id) || !entry || entry.column !== columnId) {
|
||||
anomalies.push({
|
||||
code: 'stale_kanban_order',
|
||||
taskId: id,
|
||||
detail: `Kanban columnOrder.${columnId} references a task that is not in that column overlay.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const anomaly of taskRows.anomalies) {
|
||||
anomalies.push({
|
||||
code: anomaly.code,
|
||||
detail: anomaly.detail,
|
||||
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: [...taskRows.tasks].sort(compareTasksByFreshness),
|
||||
tasksById,
|
||||
kanbanState,
|
||||
roster,
|
||||
anomalies: taskRows.anomalies.map((anomaly) => ({
|
||||
code: anomaly.code,
|
||||
detail: anomaly.detail,
|
||||
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
|
||||
})),
|
||||
anomalies,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +332,14 @@ function getLastMeaningfulEventAt(task) {
|
|||
return timestamps[0] || undefined;
|
||||
}
|
||||
|
||||
function truncateText(value, maxChars) {
|
||||
const text = normalizeName(value);
|
||||
if (!text || text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, maxChars)}... [truncated]`;
|
||||
}
|
||||
|
||||
function buildAgendaItem(task, boardState) {
|
||||
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
|
||||
const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry);
|
||||
|
|
@ -427,6 +469,8 @@ function buildAgendaItem(task, boardState) {
|
|||
|
||||
const watchers = buildWatchers(ownerActor, reviewActor, actionOwner);
|
||||
|
||||
const lastMeaningfulEventAt = getLastMeaningfulEventAt(task);
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
displayId: task.displayId,
|
||||
|
|
@ -447,7 +491,7 @@ function buildAgendaItem(task, boardState) {
|
|||
...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}),
|
||||
...(watchers.length > 0 ? { watchers } : {}),
|
||||
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||
...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}),
|
||||
...(lastMeaningfulEventAt ? { lastMeaningfulEventAt } : {}),
|
||||
derivedFrom,
|
||||
_fullTask: task,
|
||||
};
|
||||
|
|
@ -539,14 +583,17 @@ function buildAgendaSnapshot(paths, teamName, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildInventoryRow(task, reviewState) {
|
||||
function buildInventoryRow(task, reviewState, kanbanEntry) {
|
||||
return {
|
||||
id: task.id,
|
||||
displayId: task.displayId,
|
||||
subject: task.subject,
|
||||
subject: truncateText(task.subject, MAX_SUBJECT_CHARS),
|
||||
status: task.status,
|
||||
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
|
||||
reviewState,
|
||||
...(kanbanEntry && INVENTORY_KANBAN_COLUMNS.has(kanbanEntry.column)
|
||||
? { kanbanColumn: kanbanEntry.column }
|
||||
: {}),
|
||||
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||
...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
|
||||
...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}),
|
||||
|
|
@ -569,7 +616,7 @@ function matchesInventoryFilters(row, filters) {
|
|||
}
|
||||
if (normalizeName(filters.kanbanColumn)) {
|
||||
const kanbanColumn = filters.kanbanColumn;
|
||||
if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.reviewState !== kanbanColumn) {
|
||||
if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.kanbanColumn !== kanbanColumn) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -590,7 +637,8 @@ function matchesInventoryFilters(row, filters) {
|
|||
|
||||
function listTaskInventory(paths, teamName, filters = {}) {
|
||||
return withTeamBoardLock(paths, () => {
|
||||
const boardState = buildBoardState(paths, teamName);
|
||||
const taskRows = taskStore.listTaskRows(paths);
|
||||
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
|
||||
const resolvedRelatedTo = normalizeName(filters.relatedTo)
|
||||
? taskStore.resolveTaskRef(paths, filters.relatedTo)
|
||||
: '';
|
||||
|
|
@ -602,21 +650,42 @@ function listTaskInventory(paths, teamName, filters = {}) {
|
|||
? Math.max(1, Math.floor(filters.limit))
|
||||
: null;
|
||||
|
||||
const rows = boardState.tasks
|
||||
.map((task) => {
|
||||
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
|
||||
const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state;
|
||||
return buildInventoryRow(task, reviewState);
|
||||
})
|
||||
.filter((row) =>
|
||||
matchesInventoryFilters(row, {
|
||||
...filters,
|
||||
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
|
||||
...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}),
|
||||
})
|
||||
);
|
||||
const resolvedFilters = {
|
||||
...filters,
|
||||
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
|
||||
...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}),
|
||||
};
|
||||
const candidates = [];
|
||||
|
||||
return limit == null ? rows : rows.slice(0, limit);
|
||||
const addCandidate = (candidate) => {
|
||||
if (limit == null || candidates.length < limit) {
|
||||
candidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
let oldestIndex = 0;
|
||||
for (let index = 1; index < candidates.length; index += 1) {
|
||||
if (compareTasksByFreshness(candidates[index].task, candidates[oldestIndex].task) > 0) {
|
||||
oldestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (compareTasksByFreshness(candidate.task, candidates[oldestIndex].task) < 0) {
|
||||
candidates[oldestIndex] = candidate;
|
||||
}
|
||||
};
|
||||
|
||||
for (const task of taskRows.tasks) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state;
|
||||
const row = buildInventoryRow(task, reviewState, kanbanEntry);
|
||||
if (!matchesInventoryFilters(row, resolvedFilters)) {
|
||||
continue;
|
||||
}
|
||||
addCandidate({ task, row });
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => compareTasksByFreshness(left.task, right.task)).map((entry) => entry.row);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -641,7 +710,7 @@ function formatAgendaLine(item) {
|
|||
if (item.needsClarification) {
|
||||
meta.push(`clarification=${item.needsClarification}`);
|
||||
}
|
||||
return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${item.subject} (${meta.join(', ')})`;
|
||||
return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${truncateText(item.subject, MAX_SUBJECT_CHARS)} (${meta.join(', ')})`;
|
||||
}
|
||||
|
||||
function appendExpandedTaskContext(lines, item) {
|
||||
|
|
@ -649,7 +718,7 @@ function appendExpandedTaskContext(lines, item) {
|
|||
if (!task || typeof task !== 'object') return;
|
||||
|
||||
if (normalizeName(task.description)) {
|
||||
lines.push(` Description: ${task.description}`);
|
||||
lines.push(` Description: ${truncateText(task.description, MAX_DESCRIPTION_CHARS)}`);
|
||||
}
|
||||
|
||||
const comments = Array.isArray(task.comments) ? task.comments : [];
|
||||
|
|
@ -658,17 +727,37 @@ function appendExpandedTaskContext(lines, item) {
|
|||
lines.push(' Comments:');
|
||||
for (const comment of comments.slice(-5)) {
|
||||
const author = normalizeName(comment && comment.author) || 'unknown';
|
||||
const text = normalizeName(comment && comment.text) || '(empty comment)';
|
||||
const text = truncateText(comment && comment.text, MAX_COMMENT_CHARS) || '(empty comment)';
|
||||
lines.push(` - ${author}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function appendOmittedLine(lines, sectionLabel, shownCount, totalCount) {
|
||||
if (totalCount <= shownCount) return;
|
||||
lines.push(
|
||||
`... ${totalCount - shownCount} more ${sectionLabel} item(s) omitted. Use task_list filters and task_get for drill-down.`
|
||||
);
|
||||
}
|
||||
|
||||
function formatAnomalyLine(anomaly) {
|
||||
const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : '';
|
||||
return `- ${anomaly.code}${ref}: ${anomaly.detail}`;
|
||||
return `- ${anomaly.code}${ref}: ${truncateText(anomaly.detail, MAX_ANOMALY_DETAIL_CHARS)}`;
|
||||
}
|
||||
|
||||
function appendAnomalies(lines, anomalies) {
|
||||
const shown = anomalies.slice(0, MAX_ANOMALY_ITEMS);
|
||||
for (const anomaly of shown) {
|
||||
lines.push(formatAnomalyLine(anomaly));
|
||||
}
|
||||
if (anomalies.length > shown.length) {
|
||||
lines.push(
|
||||
`... ${anomalies.length - shown.length} more board anomaly item(s) omitted. Run maintenance/reconcile or inspect board files for full details.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskBriefing(paths, teamName, memberName) {
|
||||
const memberValidation = validateBriefingMember(paths, teamName, memberName);
|
||||
const snapshot = buildAgendaSnapshot(paths, teamName, {
|
||||
kind: 'member',
|
||||
memberName: normalizeName(memberName),
|
||||
|
|
@ -679,11 +768,12 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
`Use task_list only to search/browse inventory rows, not as your working queue.`,
|
||||
];
|
||||
|
||||
if (snapshot.anomalies.length > 0) {
|
||||
if (memberValidation.warnings.length > 0 || snapshot.anomalies.length > 0) {
|
||||
lines.push('', 'Board warnings:');
|
||||
for (const anomaly of snapshot.anomalies) {
|
||||
lines.push(formatAnomalyLine(anomaly));
|
||||
for (const warning of memberValidation.warnings) {
|
||||
lines.push(`- ${warning}`);
|
||||
}
|
||||
appendAnomalies(lines, snapshot.anomalies);
|
||||
}
|
||||
|
||||
if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) {
|
||||
|
|
@ -693,19 +783,29 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
|
||||
if (snapshot.actionable.length > 0) {
|
||||
lines.push('', 'Actionable:');
|
||||
for (const item of snapshot.actionable) {
|
||||
let expandedCount = 0;
|
||||
const actionableItems = snapshot.actionable.slice(0, MAX_MEMBER_ACTIONABLE_ITEMS);
|
||||
for (const item of actionableItems) {
|
||||
lines.push(formatAgendaLine(item));
|
||||
if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') {
|
||||
appendExpandedTaskContext(lines, item);
|
||||
if (expandedCount < MAX_EXPANDED_CONTEXT_ITEMS) {
|
||||
appendExpandedTaskContext(lines, item);
|
||||
expandedCount += 1;
|
||||
} else {
|
||||
lines.push(' Context omitted: use task_get for full task details.');
|
||||
}
|
||||
}
|
||||
}
|
||||
appendOmittedLine(lines, 'Actionable', actionableItems.length, snapshot.actionable.length);
|
||||
}
|
||||
|
||||
if (snapshot.awareness.length > 0) {
|
||||
lines.push('', 'Awareness:');
|
||||
for (const item of snapshot.awareness) {
|
||||
const awarenessItems = snapshot.awareness.slice(0, MAX_MEMBER_AWARENESS_ITEMS);
|
||||
for (const item of awarenessItems) {
|
||||
lines.push(formatAgendaLine(item));
|
||||
}
|
||||
appendOmittedLine(lines, 'Awareness', awarenessItems.length, snapshot.awareness.length);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
|
|
@ -761,9 +861,7 @@ function formatLeadBriefing(paths, teamName) {
|
|||
|
||||
if (snapshot.anomalies.length > 0) {
|
||||
lines.push('', 'Board anomalies:');
|
||||
for (const anomaly of snapshot.anomalies) {
|
||||
lines.push(formatAnomalyLine(anomaly));
|
||||
}
|
||||
appendAnomalies(lines, snapshot.anomalies);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
|
|
@ -787,9 +885,11 @@ function formatLeadBriefing(paths, teamName) {
|
|||
if (!items || items.length === 0) continue;
|
||||
renderedAnySection = true;
|
||||
lines.push('', title);
|
||||
for (const item of items) {
|
||||
const sectionItems = items.slice(0, MAX_LEAD_SECTION_ITEMS);
|
||||
for (const item of sectionItems) {
|
||||
lines.push(formatAgendaLine(item));
|
||||
}
|
||||
appendOmittedLine(lines, title.replace(/:$/, ''), sectionItems.length, items.length);
|
||||
}
|
||||
|
||||
if (!renderedAnySection && snapshot.anomalies.length === 0) {
|
||||
|
|
|
|||
|
|
@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
|
|||
}
|
||||
|
||||
function resolveTargetLead(paths, config) {
|
||||
// 1. config.members — agentType check
|
||||
// 1. config.members - canonical lead detection shared with queue routing
|
||||
if (config && config.members && config.members.length) {
|
||||
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
|
||||
const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (lead && lead.name) return String(lead.name).trim();
|
||||
|
||||
// 2. config.members — name check
|
||||
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
|
||||
if (namedLead && namedLead.name) return String(namedLead.name).trim();
|
||||
}
|
||||
|
||||
// 3. members.meta.json — WITH normalization (trim + dedup)
|
||||
// 2. members.meta.json - WITH normalization (trim + dedup)
|
||||
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
const members = normalizeMetaMembers(raw && raw.members);
|
||||
if (members.length > 0) {
|
||||
const metaLead = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (metaLead && metaLead.name) return metaLead.name;
|
||||
return members[0].name;
|
||||
}
|
||||
|
|
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
|
|||
/* ENOENT or parse error */
|
||||
}
|
||||
|
||||
// 4. role-based (legacy compat)
|
||||
// 3. First configured member
|
||||
if (config && config.members && config.members.length) {
|
||||
const roleLead = config.members.find(
|
||||
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
|
||||
);
|
||||
if (roleLead && roleLead.name) return String(roleLead.name).trim();
|
||||
// 5. First member
|
||||
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
||||
}
|
||||
|
||||
|
|
@ -101,16 +90,6 @@ function normalizeForDedupe(value) {
|
|||
.toLowerCase();
|
||||
}
|
||||
|
||||
function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) {
|
||||
return [
|
||||
normalizeForDedupe(fromTeam),
|
||||
normalizeForDedupe(fromMember),
|
||||
normalizeForDedupe(toTeam),
|
||||
normalizeForDedupe(summary),
|
||||
normalizeForDedupe(text),
|
||||
].join('||');
|
||||
}
|
||||
|
||||
function getCrossTeamMessageDedupeKey(message) {
|
||||
if (!message || typeof message !== 'object') return '';
|
||||
return buildCrossTeamDedupeKey(
|
||||
|
|
@ -118,10 +97,44 @@ function getCrossTeamMessageDedupeKey(message) {
|
|||
message.fromMember,
|
||||
message.toTeam,
|
||||
message.text,
|
||||
message.summary
|
||||
message.summary,
|
||||
message.taskRefs
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTaskRefs(taskRefs) {
|
||||
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = taskRefs
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
taskId: String(item.taskId || '').trim(),
|
||||
displayId: String(item.displayId || '').trim(),
|
||||
teamName: String(item.teamName || '').trim(),
|
||||
}))
|
||||
.filter((item) => item.taskId && item.displayId && item.teamName);
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeTaskRefsForDedupe(taskRefs) {
|
||||
const normalized = normalizeTaskRefs(taskRefs);
|
||||
return normalized ? JSON.stringify(normalized) : '';
|
||||
}
|
||||
|
||||
function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs) {
|
||||
return [
|
||||
normalizeForDedupe(fromTeam),
|
||||
normalizeForDedupe(fromMember),
|
||||
normalizeForDedupe(toTeam),
|
||||
normalizeForDedupe(summary),
|
||||
normalizeForDedupe(text),
|
||||
normalizeTaskRefsForDedupe(taskRefs),
|
||||
].join('||');
|
||||
}
|
||||
|
||||
function findRecentDuplicate(outboxList, dedupeKey) {
|
||||
if (!Array.isArray(outboxList) || !dedupeKey) return null;
|
||||
const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS;
|
||||
|
|
@ -141,7 +154,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
|
|||
function sendCrossTeamMessage(context, flags) {
|
||||
const fromTeam = context.teamName;
|
||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
||||
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
|
||||
const replyToConversationId =
|
||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||
const conversationId =
|
||||
|
|
@ -151,11 +164,16 @@ function sendCrossTeamMessage(context, flags) {
|
|||
const text = typeof flags.text === 'string' ? flags.text : '';
|
||||
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
|
||||
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
|
||||
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
||||
|
||||
// Validate
|
||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||
}
|
||||
const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
|
||||
if (!sourceConfig || sourceConfig.deletedAt) {
|
||||
throw new Error(`Source team not found: ${fromTeam}`);
|
||||
}
|
||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||
}
|
||||
|
|
@ -165,6 +183,11 @@ function sendCrossTeamMessage(context, flags) {
|
|||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Message text is required');
|
||||
}
|
||||
const fromMember = rawFromMember
|
||||
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
|
||||
allowLeadAliases: true,
|
||||
})
|
||||
: runtimeHelpers.inferLeadName(context.paths);
|
||||
|
||||
// Target context + config
|
||||
const targetContext = createTargetContext(context, toTeam);
|
||||
|
|
@ -186,7 +209,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
});
|
||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs);
|
||||
|
||||
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
||||
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
|
||||
|
|
@ -219,6 +242,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
...(taskRefs ? { taskRefs } : {}),
|
||||
});
|
||||
writeJson(inboxPath, list);
|
||||
});
|
||||
|
|
@ -240,6 +264,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
source: CROSS_TEAM_SENT_SOURCE,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
...(taskRefs ? { taskRefs } : {}),
|
||||
});
|
||||
|
||||
outList.push({
|
||||
|
|
@ -250,6 +275,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
text,
|
||||
...(taskRefs ? { taskRefs } : {}),
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,62 @@
|
|||
const kanbanStore = require('./kanbanStore.js');
|
||||
const tasks = require('./tasks.js');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const { withTeamBoardLock } = require('./boardLock.js');
|
||||
|
||||
function getEffectiveReviewState(context, task) {
|
||||
const state = getKanbanState(context);
|
||||
const entry = state.tasks ? state.tasks[task.id] : undefined;
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, entry).state;
|
||||
}
|
||||
|
||||
function assertKanbanColumnAllowed(context, task, column, options = {}) {
|
||||
const transition = typeof options.transition === 'string' ? options.transition : 'direct';
|
||||
const label = `#${task.displayId || task.id}`;
|
||||
|
||||
if (task.status === 'deleted') {
|
||||
throw new Error(`Task ${label} is deleted`);
|
||||
}
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task ${label} must be completed before moving to ${String(column).toUpperCase()} column`);
|
||||
}
|
||||
|
||||
const reviewState = getEffectiveReviewState(context, task);
|
||||
if (column === 'review') {
|
||||
if (transition === 'request_review') {
|
||||
if (reviewState === 'approved') {
|
||||
throw new Error(`Task ${label} is already approved; reopen work before requesting another review`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (reviewState !== 'review') {
|
||||
throw new Error(`Task ${label} must be in review before moving to REVIEW column; use review_request first`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (column === 'approved') {
|
||||
if (transition === 'approve_review') {
|
||||
if (reviewState !== 'review' && reviewState !== 'approved') {
|
||||
throw new Error(`Task ${label} must be in review before approval`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (reviewState !== 'approved') {
|
||||
throw new Error(`Task ${label} must already be approved before repairing APPROVED column; use review_approve first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getKanbanState(context) {
|
||||
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
}
|
||||
|
||||
function setKanbanColumn(context, taskId, column) {
|
||||
function setKanbanColumn(context, taskId, column, options = {}) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
const task = tasks.getTask(context, canonicalTaskId);
|
||||
assertKanbanColumnAllowed(context, task, String(column), options);
|
||||
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
|
||||
return getKanbanState(context);
|
||||
});
|
||||
|
|
@ -17,6 +65,31 @@ function setKanbanColumn(context, taskId, column) {
|
|||
function clearKanban(context, taskId, options) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
const task = tasks.getTask(context, canonicalTaskId);
|
||||
const state = getKanbanState(context);
|
||||
const hasTaskEntry = Boolean(state.tasks && state.tasks[canonicalTaskId]);
|
||||
const hasColumnOrderRef =
|
||||
state.columnOrder &&
|
||||
typeof state.columnOrder === 'object' &&
|
||||
Object.values(state.columnOrder).some(
|
||||
(orderedTaskIds) =>
|
||||
Array.isArray(orderedTaskIds) &&
|
||||
orderedTaskIds.some((entry) => String(entry) === String(canonicalTaskId))
|
||||
);
|
||||
if (!hasTaskEntry && !hasColumnOrderRef) {
|
||||
return state;
|
||||
}
|
||||
const transition = options && typeof options.transition === 'string' ? options.transition : 'direct';
|
||||
const allowedInternalTransitions = new Set(['request_changes', 'rollback', 'status_reset', 'delete', 'restore']);
|
||||
const reviewState = getEffectiveReviewState(context, task);
|
||||
if (transition === 'direct' && reviewState !== 'none') {
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} is in reviewState=${reviewState}; use review tools or task status transitions instead of kanban_clear`
|
||||
);
|
||||
}
|
||||
if (transition !== 'direct' && !allowedInternalTransitions.has(transition)) {
|
||||
throw new Error(`Invalid kanban clear transition: ${transition}`);
|
||||
}
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
|
||||
return getKanbanState(context);
|
||||
});
|
||||
|
|
@ -28,9 +101,12 @@ function listReviewers(context) {
|
|||
|
||||
function addReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
next.add(String(resolvedReviewer));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: [...next],
|
||||
|
|
@ -42,7 +118,13 @@ function addReviewer(context, reviewer) {
|
|||
function removeReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const state = getKanbanState(context);
|
||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||
const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const reviewerNames = new Set(
|
||||
[reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
);
|
||||
const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: next,
|
||||
|
|
|
|||
|
|
@ -70,16 +70,56 @@ function writeKanbanState(paths, teamName, state) {
|
|||
writeJson(paths.kanbanPath, sanitizeState(teamName, state));
|
||||
}
|
||||
|
||||
function removeTaskFromColumnOrder(state, taskId) {
|
||||
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cleaned = {};
|
||||
let removed = 0;
|
||||
for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) {
|
||||
if (!Array.isArray(orderedTaskIds)) continue;
|
||||
const nextIds = orderedTaskIds.filter((entry) => String(entry) !== String(taskId));
|
||||
removed += orderedTaskIds.length - nextIds.length;
|
||||
if (nextIds.length > 0) {
|
||||
cleaned[columnId] = nextIds.map((entry) => String(entry));
|
||||
}
|
||||
}
|
||||
|
||||
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||
return removed;
|
||||
}
|
||||
|
||||
function appendTaskToColumnOrder(state, column, taskId) {
|
||||
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextColumnOrder = { ...state.columnOrder };
|
||||
const existing = Array.isArray(nextColumnOrder[column]) ? nextColumnOrder[column] : [];
|
||||
nextColumnOrder[column] = existing
|
||||
.map((entry) => String(entry))
|
||||
.filter((entry) => entry !== String(taskId))
|
||||
.concat([String(taskId)]);
|
||||
state.columnOrder = nextColumnOrder;
|
||||
}
|
||||
|
||||
function setKanbanColumn(paths, teamName, taskId, column) {
|
||||
if (column !== 'review' && column !== 'approved') {
|
||||
throw new Error(`Invalid kanban column: ${String(column)}`);
|
||||
}
|
||||
|
||||
const state = readKanbanState(paths, teamName);
|
||||
const hadColumnOrder = Boolean(state.columnOrder && Object.keys(state.columnOrder).length > 0);
|
||||
removeTaskFromColumnOrder(state, taskId);
|
||||
if (hadColumnOrder && !state.columnOrder) {
|
||||
state.columnOrder = {};
|
||||
}
|
||||
state.tasks[String(taskId)] =
|
||||
column === 'review'
|
||||
? { column: 'review', reviewer: null, movedAt: nowIso() }
|
||||
: { column: 'approved', movedAt: nowIso() };
|
||||
appendTaskToColumnOrder(state, column, taskId);
|
||||
writeKanbanState(paths, teamName, state);
|
||||
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||
...task,
|
||||
|
|
@ -91,6 +131,7 @@ function setKanbanColumn(paths, teamName, taskId, column) {
|
|||
function clearKanban(paths, teamName, taskId, options = {}) {
|
||||
const state = readKanbanState(paths, teamName);
|
||||
delete state.tasks[String(taskId)];
|
||||
removeTaskFromColumnOrder(state, taskId);
|
||||
writeKanbanState(paths, teamName, state);
|
||||
const nextReviewState =
|
||||
typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none';
|
||||
|
|
@ -133,7 +174,10 @@ function garbageCollect(paths, teamName, validTaskIds) {
|
|||
continue;
|
||||
}
|
||||
|
||||
const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId)));
|
||||
const validIds = orderedTaskIds.filter((taskId) => {
|
||||
const id = String(taskId);
|
||||
return validTaskIds.has(id) && state.tasks[id] && state.tasks[id].column === columnId;
|
||||
});
|
||||
staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length;
|
||||
if (validIds.length > 0) {
|
||||
cleaned[columnId] = validIds;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
function normalizeRuntimeProvider(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||
}
|
||||
|
||||
function createMemberMessagingProtocol(runtimeProvider) {
|
||||
const provider = normalizeRuntimeProvider(runtimeProvider);
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return {
|
||||
runtimeProvider: 'opencode',
|
||||
sendToolName: 'agent-teams_message_send',
|
||||
sendToolAliases: [
|
||||
'agent-teams_message_send',
|
||||
'agent_teams_message_send',
|
||||
'mcp__agent-teams__message_send',
|
||||
'mcp__agent_teams__message_send',
|
||||
'message_send',
|
||||
],
|
||||
sendLeadPhrase: 'MCP tool agent-teams_message_send',
|
||||
crossTeamPhrase: 'call MCP tool agent-teams_cross_team_send',
|
||||
buildLeadMessageExample({ teamName, leadName, fromName, text, summary }) {
|
||||
return `agent-teams_message_send { teamName: "${teamName}", to: "${leadName}", from: "${fromName}", text: "${text}", summary: "${summary}" }`;
|
||||
},
|
||||
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
|
||||
return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeProvider: 'native',
|
||||
sendToolName: 'SendMessage',
|
||||
sendToolAliases: ['SendMessage'],
|
||||
sendLeadPhrase: 'SendMessage',
|
||||
crossTeamPhrase: 'use the cross-team MCP tool cross_team_send',
|
||||
buildLeadMessageExample({ leadName, text, summary }) {
|
||||
return `SendMessage { to: "${leadName}", summary: "${summary}", message: "${text}" }`;
|
||||
},
|
||||
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
|
||||
return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenCodeMember(member) {
|
||||
const provider = String((member && (member.providerId || member.provider)) || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (provider) return provider === 'opencode';
|
||||
const model = String((member && member.model) || '').trim().toLowerCase();
|
||||
return model.startsWith('opencode/');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMemberMessagingProtocol,
|
||||
isOpenCodeMember,
|
||||
normalizeRuntimeProvider,
|
||||
};
|
||||
|
|
@ -71,6 +71,48 @@ function normalizeTaskRefs(taskRefs) {
|
|||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeMessageKind(messageKind) {
|
||||
return messageKind === 'default' ||
|
||||
messageKind === 'slash_command' ||
|
||||
messageKind === 'slash_command_result' ||
|
||||
messageKind === 'task_comment_notification'
|
||||
? messageKind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeSlashCommand(slashCommand) {
|
||||
if (!slashCommand || typeof slashCommand !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const name = String(slashCommand.name || '').trim();
|
||||
const command = String(slashCommand.command || '').trim();
|
||||
if (!name || !command) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
...(typeof slashCommand.args === 'string' ? { args: slashCommand.args } : {}),
|
||||
...(typeof slashCommand.knownDescription === 'string'
|
||||
? { knownDescription: slashCommand.knownDescription }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCommandOutput(commandOutput) {
|
||||
if (!commandOutput || typeof commandOutput !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const stream = commandOutput.stream === 'stdout' || commandOutput.stream === 'stderr'
|
||||
? commandOutput.stream
|
||||
: undefined;
|
||||
const commandLabel = String(commandOutput.commandLabel || '').trim();
|
||||
if (!stream || !commandLabel) {
|
||||
return undefined;
|
||||
}
|
||||
return { stream, commandLabel };
|
||||
}
|
||||
|
||||
function buildMessage(flags, defaults) {
|
||||
const timestamp =
|
||||
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
|
||||
|
|
@ -80,6 +122,9 @@ function buildMessage(flags, defaults) {
|
|||
: crypto.randomUUID();
|
||||
const attachments = normalizeAttachments(flags.attachments);
|
||||
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
||||
const messageKind = normalizeMessageKind(flags.messageKind);
|
||||
const slashCommand = normalizeSlashCommand(flags.slashCommand);
|
||||
const commandOutput = normalizeCommandOutput(flags.commandOutput);
|
||||
|
||||
return {
|
||||
from:
|
||||
|
|
@ -91,9 +136,15 @@ function buildMessage(flags, defaults) {
|
|||
timestamp,
|
||||
read: defaults.read,
|
||||
...(taskRefs ? { taskRefs } : {}),
|
||||
...(flags.actionMode === 'do' || flags.actionMode === 'ask' || flags.actionMode === 'delegate'
|
||||
? { actionMode: flags.actionMode }
|
||||
: {}),
|
||||
...(typeof flags.summary === 'string' && flags.summary.trim()
|
||||
? { summary: flags.summary.trim() }
|
||||
: {}),
|
||||
...(typeof flags.commentId === 'string' && flags.commentId.trim()
|
||||
? { commentId: flags.commentId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()
|
||||
? { relayOfMessageId: flags.relayOfMessageId.trim() }
|
||||
: {}),
|
||||
|
|
@ -121,6 +172,9 @@ function buildMessage(flags, defaults) {
|
|||
})),
|
||||
}
|
||||
: {}),
|
||||
...(messageKind ? { messageKind } : {}),
|
||||
...(slashCommand ? { slashCommand } : {}),
|
||||
...(commandOutput ? { commandOutput } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
messageId,
|
||||
};
|
||||
|
|
@ -235,4 +289,3 @@ module.exports = {
|
|||
lookupMessage,
|
||||
sendInboxMessage,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,90 @@
|
|||
const messageStore = require('./messageStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
|
||||
const PLACEHOLDER_TASK_REF_PREFIX = /^\s*#0{8}\b\s*(?:[:.-]\s*)?/i;
|
||||
|
||||
function stripPlaceholderTaskRefPrefix(value) {
|
||||
if (typeof value !== 'string' || !PLACEHOLDER_TASK_REF_PREFIX.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return value.replace(PLACEHOLDER_TASK_REF_PREFIX, '').trimStart();
|
||||
}
|
||||
|
||||
function normalizePlaceholderTaskRefPrefixes(flags) {
|
||||
const next = { ...(flags || {}) };
|
||||
if (typeof next.text === 'string') {
|
||||
const strippedText = stripPlaceholderTaskRefPrefix(next.text);
|
||||
next.text = strippedText.trim() ? strippedText : next.text;
|
||||
}
|
||||
if (typeof next.summary === 'string') {
|
||||
next.summary = stripPlaceholderTaskRefPrefix(next.summary);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeMessageSendFlags(context, flags) {
|
||||
const next = { ...(flags || {}) };
|
||||
const rawTo =
|
||||
(typeof next.member === 'string' && next.member.trim()) ||
|
||||
(typeof next.to === 'string' && next.to.trim()) ||
|
||||
'';
|
||||
|
||||
if (!rawTo) {
|
||||
throw new Error('message_send requires to');
|
||||
}
|
||||
|
||||
if (rawTo.toLowerCase() === 'user') {
|
||||
next.to = 'user';
|
||||
delete next.member;
|
||||
} else {
|
||||
const resolvedTo = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, rawTo, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient(rawTo)) {
|
||||
throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
|
||||
}
|
||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamRecipient(rawTo)) {
|
||||
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||
}
|
||||
if (!resolvedTo) {
|
||||
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
||||
}
|
||||
next.to = resolvedTo;
|
||||
next.member = resolvedTo;
|
||||
}
|
||||
|
||||
if (typeof next.from === 'string' && next.from.trim()) {
|
||||
const rawFrom = next.from.trim();
|
||||
if (rawFrom.toLowerCase() !== 'user') {
|
||||
next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
} else {
|
||||
next.from = 'user';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function assertUserDirectedMessageHasSender(context, flags) {
|
||||
const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : '';
|
||||
if (to !== 'user') return;
|
||||
|
||||
const from = typeof flags.from === 'string' ? flags.from.trim() : '';
|
||||
if (!from || from.toLowerCase() === 'user') {
|
||||
throw new Error('message_send to user requires from to be the responding team member name');
|
||||
}
|
||||
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, from, 'from', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
}
|
||||
|
||||
function sendMessage(context, flags) {
|
||||
return messageStore.sendInboxMessage(context.paths, flags);
|
||||
const normalized = normalizeMessageSendFlags(context, normalizePlaceholderTaskRefPrefixes(flags));
|
||||
assertUserDirectedMessageHasSender(context, normalized);
|
||||
return messageStore.sendInboxMessage(context.paths, normalized);
|
||||
}
|
||||
|
||||
function appendSentMessage(context, flags) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const kanban = require('./kanban.js');
|
||||
const messages = require('./messages.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
const tasks = require('./tasks.js');
|
||||
const { withTeamBoardLock } = require('./boardLock.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
|
@ -26,18 +27,163 @@ function resolveLeadSessionId(context, flags) {
|
|||
return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
|
||||
}
|
||||
|
||||
function getReviewStateFromHistory(task) {
|
||||
const result = reviewStateHelpers.getReviewStateFromHistory(task);
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
function getCurrentReviewState(task) {
|
||||
return getReviewStateFromHistory(task) || 'none';
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(context, task) {
|
||||
const state = kanban.getKanbanState(context);
|
||||
const kanbanEntry = state.tasks ? state.tasks[task.id] : undefined;
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
|
||||
}
|
||||
|
||||
function getLatestReviewRequestedReviewer(task) {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') {
|
||||
return e.to;
|
||||
if (e.type === 'review_requested') {
|
||||
return typeof e.reviewer === 'string' && e.reviewer.trim() ? e.reviewer.trim() : null;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
return 'none';
|
||||
if (
|
||||
e.type === 'review_changes_requested' ||
|
||||
e.type === 'review_approved' ||
|
||||
(e.type === 'status_changed' &&
|
||||
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')) ||
|
||||
e.type === 'task_created'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeActorKey(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function resolveKnownActorName(context, value, label) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return null;
|
||||
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
}
|
||||
|
||||
function tryResolveKnownActorName(context, value, label) {
|
||||
try {
|
||||
return resolveKnownActorName(context, value, label);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActorIdentityKey(context, value) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return '';
|
||||
const resolved = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, actor, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
return normalizeActorKey(resolved || actor);
|
||||
}
|
||||
|
||||
function isLeadActor(context, value) {
|
||||
const key = normalizeActorKey(value);
|
||||
const resolvedKey = resolveActorIdentityKey(context, value);
|
||||
const leadKey = normalizeActorKey(runtimeHelpers.inferLeadName(context.paths));
|
||||
return key === 'lead' || key === 'team-lead' || (leadKey && resolvedKey === leadKey);
|
||||
}
|
||||
|
||||
function assertMatchesAssignedReviewer(context, task, actor, actionName) {
|
||||
const assignedReviewer = getLatestReviewRequestedReviewer(task);
|
||||
if (!assignedReviewer || isLeadActor(context, actor)) {
|
||||
return;
|
||||
}
|
||||
const assignedKey = resolveActorIdentityKey(context, assignedReviewer);
|
||||
const actorKey = resolveActorIdentityKey(context, actor);
|
||||
if (assignedKey && actorKey && assignedKey !== actorKey) {
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} is assigned to reviewer ${assignedReviewer}; ${actor} cannot ${actionName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewStartActor(context, task, flags) {
|
||||
if (typeof flags.from === 'string' && flags.from.trim()) {
|
||||
const actor = resolveKnownActorName(context, flags.from, 'review actor');
|
||||
assertMatchesAssignedReviewer(context, task, actor, 'start review');
|
||||
return actor;
|
||||
}
|
||||
|
||||
const requestedReviewer = getLatestReviewRequestedReviewer(task);
|
||||
if (requestedReviewer) {
|
||||
return resolveKnownActorName(context, requestedReviewer, 'reviewer');
|
||||
}
|
||||
|
||||
const state = kanban.getKanbanState(context);
|
||||
const kanbanEntry = state.tasks ? state.tasks[task.id] : undefined;
|
||||
if (kanbanEntry && typeof kanbanEntry.reviewer === 'string' && kanbanEntry.reviewer.trim()) {
|
||||
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
|
||||
}
|
||||
|
||||
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`);
|
||||
}
|
||||
|
||||
function getLatestReviewStartedActor(task) {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_started') {
|
||||
return typeof e.actor === 'string' && e.actor.trim() ? e.actor.trim() : null;
|
||||
}
|
||||
if (
|
||||
e.type === 'review_changes_requested' ||
|
||||
e.type === 'review_approved' ||
|
||||
(e.type === 'status_changed' &&
|
||||
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')) ||
|
||||
e.type === 'task_created'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getReviewDecisionActor(context, task, flags, actionName) {
|
||||
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
|
||||
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor');
|
||||
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer');
|
||||
const inferredActor =
|
||||
startedActor &&
|
||||
(!assignedReviewer ||
|
||||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
|
||||
? startedActor
|
||||
: assignedReviewer;
|
||||
const actor =
|
||||
explicit ||
|
||||
inferredActor ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||
assertMatchesAssignedReviewer(context, task, actor, actionName);
|
||||
return actor;
|
||||
}
|
||||
|
||||
function assertReviewTransitionAllowed(context, task, transitionName) {
|
||||
if (task.status === 'deleted') {
|
||||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||
}
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`);
|
||||
}
|
||||
|
||||
const reviewState = getEffectiveReviewState(context, task);
|
||||
if (reviewState !== 'review') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
||||
function getLatestReviewLifecycleEvent(task) {
|
||||
|
|
@ -52,7 +198,10 @@ function getLatestReviewLifecycleEvent(task) {
|
|||
) {
|
||||
return e;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
if (
|
||||
e.type === 'status_changed' &&
|
||||
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
if (e.type === 'task_created') {
|
||||
|
|
@ -69,17 +218,58 @@ function startReview(context, taskId, flags = {}) {
|
|||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||
}
|
||||
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
|
||||
const latestReviewEvent = getLatestReviewLifecycleEvent(task);
|
||||
const prevReviewState = getCurrentReviewState(task);
|
||||
const prevReviewState = getEffectiveReviewState(context, task);
|
||||
|
||||
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||
const existingActorValid = existingActor
|
||||
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
|
||||
: false;
|
||||
const assignedReviewer = tryResolveKnownActorName(
|
||||
context,
|
||||
getLatestReviewRequestedReviewer(task),
|
||||
'reviewer'
|
||||
);
|
||||
const existingMatchesAssigned =
|
||||
!assignedReviewer ||
|
||||
(existingActorValid &&
|
||||
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
|
||||
const requestedActor =
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? getReviewStartActor(context, task, flags)
|
||||
: null;
|
||||
if (
|
||||
existingActorValid &&
|
||||
existingMatchesAssigned &&
|
||||
requestedActor &&
|
||||
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor)
|
||||
) {
|
||||
throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`);
|
||||
}
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
if (!existingActorValid || !existingMatchesAssigned) {
|
||||
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: repairedActor,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const from = getReviewStartActor(context, task, flags);
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review');
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
|
|
@ -93,7 +283,7 @@ function startReview(context, taskId, flags = {}) {
|
|||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, task.id);
|
||||
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
||||
}
|
||||
|
|
@ -110,12 +300,17 @@ function requestReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextReviewer = getReviewer(context, flags);
|
||||
const prevReviewState = getCurrentReviewState(currentTask);
|
||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
if (prevReviewState === 'approved') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||
}
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'review');
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'review', { transition: 'request_review' });
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_requested',
|
||||
|
|
@ -129,7 +324,7 @@ function requestReview(context, taskId, flags = {}) {
|
|||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, currentTask.id);
|
||||
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
|
||||
}
|
||||
|
|
@ -158,9 +353,9 @@ function requestReview(context, taskId, flags = {}) {
|
|||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", note?: "<optional note>", notifyOwner: true }\n\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", note?: "<optional note>", notifyOwner: true }\n\n` +
|
||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", comment: "..." }`
|
||||
),
|
||||
summary: `Review request for #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
|
|
@ -176,14 +371,21 @@ function requestReview(context, taskId, flags = {}) {
|
|||
function approveReview(context, taskId, flags = {}) {
|
||||
const result = withTeamBoardLock(context.paths, () => {
|
||||
const currentTask = tasks.getTask(context, taskId);
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'approve review');
|
||||
const nextNote =
|
||||
typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||
const suppressTaskComment = flags.suppressTaskComment === true;
|
||||
const prevReviewState = getCurrentReviewState(currentTask);
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
|
||||
if (currentTask.status === 'deleted') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is deleted`);
|
||||
}
|
||||
|
||||
if (prevReviewState === 'approved') {
|
||||
if (currentTask.status !== 'completed') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`);
|
||||
}
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
return {
|
||||
alreadyApproved: true,
|
||||
payload: {
|
||||
|
|
@ -196,7 +398,9 @@ function approveReview(context, taskId, flags = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved');
|
||||
assertReviewTransitionAllowed(context, currentTask, 'approval');
|
||||
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_approved',
|
||||
|
|
@ -263,13 +467,12 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
throw new Error(`No owner found for task ${String(taskId)}`);
|
||||
}
|
||||
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'request changes');
|
||||
const nextComment =
|
||||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
const prevReviewState = getCurrentReviewState(currentTask);
|
||||
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
|
||||
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
|
|
@ -283,7 +486,10 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
return t;
|
||||
});
|
||||
|
||||
kanban.clearKanban(context, currentTask.id, { nextReviewState: 'needsFix' });
|
||||
kanban.clearKanban(context, currentTask.id, {
|
||||
nextReviewState: 'needsFix',
|
||||
transition: 'request_changes',
|
||||
});
|
||||
tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom);
|
||||
tasks.addTaskComment(context, currentTask.id, {
|
||||
text: nextComment,
|
||||
|
|
|
|||
139
agent-teams-controller/src/internal/reviewState.js
Normal file
139
agent-teams-controller/src/internal/reviewState.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||
const REVIEW_COLUMNS = new Set(['review', 'approved']);
|
||||
const REVIEW_LIFECYCLE_EVENTS = new Set([
|
||||
'review_requested',
|
||||
'review_changes_requested',
|
||||
'review_approved',
|
||||
'review_started',
|
||||
]);
|
||||
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
||||
|
||||
function normalizeReviewState(value) {
|
||||
const normalized = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
return REVIEW_STATES.has(normalized) ? normalized : 'none';
|
||||
}
|
||||
|
||||
function eventReviewState(event) {
|
||||
if (!event || typeof event !== 'object' || !REVIEW_LIFECYCLE_EVENTS.has(event.type)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeReviewState(event.to);
|
||||
}
|
||||
|
||||
function derivePendingReviewState(events, startIndex) {
|
||||
for (let index = startIndex - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (!event || typeof event !== 'object') continue;
|
||||
|
||||
const reviewState = eventReviewState(event);
|
||||
if (reviewState) {
|
||||
return reviewState === 'needsFix'
|
||||
? { state: 'needsFix', source: 'history_pending_needs_fix' }
|
||||
: { state: 'none', source: 'history_pending_reset' };
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'task_created' ||
|
||||
(event.type === 'status_changed' &&
|
||||
(REVIEW_RESET_STATUSES.has(event.to) || event.to === 'pending'))
|
||||
) {
|
||||
return { state: 'none', source: 'history_pending_reset' };
|
||||
}
|
||||
}
|
||||
|
||||
return { state: 'none', source: 'history_pending_reset' };
|
||||
}
|
||||
|
||||
function getReviewStateFromHistory(task) {
|
||||
const events = Array.isArray(task && task.historyEvents) ? task.historyEvents : [];
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (!event || typeof event !== 'object') continue;
|
||||
|
||||
const reviewState = eventReviewState(event);
|
||||
if (reviewState) {
|
||||
return {
|
||||
state: reviewState,
|
||||
source: `history_${event.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'status_changed') {
|
||||
if (REVIEW_RESET_STATUSES.has(event.to)) {
|
||||
return {
|
||||
state: 'none',
|
||||
source: 'history_status_reset',
|
||||
};
|
||||
}
|
||||
if (event.to === 'pending') {
|
||||
return derivePendingReviewState(events, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(task, kanbanEntry) {
|
||||
const historyState = getReviewStateFromHistory(task);
|
||||
if (historyState) {
|
||||
return historyState;
|
||||
}
|
||||
|
||||
const status = typeof task?.status === 'string' ? task.status.trim() : '';
|
||||
const normalizeFallback = (state, source) => {
|
||||
const normalized = normalizeReviewState(state);
|
||||
if (normalized === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'in_progress' || status === 'deleted') {
|
||||
return {
|
||||
state: 'none',
|
||||
source: `${source}_status_reset`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return normalized === 'needsFix'
|
||||
? { state: 'needsFix', source: `${source}_pending_needs_fix` }
|
||||
: { state: 'none', source: `${source}_pending_reset` };
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return normalized === 'review' || normalized === 'approved'
|
||||
? { state: normalized, source }
|
||||
: { state: 'none', source: `${source}_completed_reset` };
|
||||
}
|
||||
|
||||
return { state: normalized, source };
|
||||
};
|
||||
|
||||
const persisted = normalizeReviewState(task && task.reviewState);
|
||||
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||
if (persistedFallback) {
|
||||
return persistedFallback;
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
|
||||
if (kanbanFallback) {
|
||||
return kanbanFallback;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'none',
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REVIEW_COLUMNS,
|
||||
REVIEW_LIFECYCLE_EVENTS,
|
||||
REVIEW_RESET_STATUSES,
|
||||
REVIEW_STATES,
|
||||
getEffectiveReviewState,
|
||||
getReviewStateFromHistory,
|
||||
normalizeReviewState,
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
|
|
@ -85,6 +86,10 @@ function looksLikeCrossTeamToolRecipient(name) {
|
|||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(String(name || '').trim());
|
||||
}
|
||||
|
||||
function looksLikeCrossTeamRecipient(name) {
|
||||
return looksLikeQualifiedExternalRecipient(name) || looksLikeCrossTeamPseudoRecipient(name);
|
||||
}
|
||||
|
||||
function getHomeDir() {
|
||||
if (process.env.HOME) return process.env.HOME;
|
||||
if (process.env.USERPROFILE) return process.env.USERPROFILE;
|
||||
|
|
@ -124,15 +129,68 @@ function getPaths(flags, teamName) {
|
|||
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
||||
}
|
||||
|
||||
function isCanonicalLeadMember(member) {
|
||||
if (!member || typeof member !== 'object') return false;
|
||||
const agentType = typeof member.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||
return (
|
||||
LEAD_AGENT_TYPES.has(agentType) ||
|
||||
name === 'team-lead' ||
|
||||
role === 'team-lead' ||
|
||||
role === 'team lead' ||
|
||||
role === 'lead'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMemberKey(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function collectExplicitTeamMembers(paths) {
|
||||
const config = readTeamConfig(paths) || {};
|
||||
const configMembers = Array.isArray(config.members) ? config.members : [];
|
||||
const metaMembers = readMembersMeta(paths);
|
||||
const membersByKey = new Map();
|
||||
const removedNames = new Set();
|
||||
|
||||
for (const rawMember of configMembers) {
|
||||
const normalized = normalizeMemberRecord(rawMember);
|
||||
if (!normalized) continue;
|
||||
membersByKey.set(normalizeMemberKey(normalized.name), normalized);
|
||||
}
|
||||
|
||||
for (const rawMember of metaMembers) {
|
||||
const normalized = normalizeMemberRecord(rawMember);
|
||||
if (!normalized) continue;
|
||||
const key = normalizeMemberKey(normalized.name);
|
||||
if (normalized.removedAt != null) {
|
||||
membersByKey.delete(key);
|
||||
removedNames.add(key);
|
||||
continue;
|
||||
}
|
||||
removedNames.delete(key);
|
||||
membersByKey.set(key, mergeResolvedMember(membersByKey.get(key) || { name: normalized.name }, normalized));
|
||||
}
|
||||
|
||||
return { membersByKey, removedNames };
|
||||
}
|
||||
|
||||
function inferLeadName(paths) {
|
||||
const resolved = resolveTeamMembers(paths);
|
||||
const lead = resolved.members.find(
|
||||
(member) =>
|
||||
member &&
|
||||
((typeof member.agentType === 'string' && member.agentType === 'team-lead') ||
|
||||
(typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) ||
|
||||
member.name === 'team-lead')
|
||||
);
|
||||
const members = resolved.members || [];
|
||||
const lead =
|
||||
members.find((member) => {
|
||||
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||
return LEAD_AGENT_TYPES.has(agentType);
|
||||
}) ||
|
||||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
||||
members.find(
|
||||
(member) => {
|
||||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||
return role === 'team-lead' || role === 'team lead' || role === 'lead';
|
||||
}
|
||||
);
|
||||
if (lead) {
|
||||
return String(lead.name);
|
||||
}
|
||||
|
|
@ -143,6 +201,39 @@ function inferLeadName(paths) {
|
|||
return 'team-lead';
|
||||
}
|
||||
|
||||
function resolveExplicitTeamMemberName(paths, candidate, options = {}) {
|
||||
const normalized = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : '';
|
||||
const key = normalizeMemberKey(normalized);
|
||||
if (!key) return null;
|
||||
|
||||
const explicit = collectExplicitTeamMembers(paths);
|
||||
if (explicit.removedNames.has(key)) return null;
|
||||
const directMember = explicit.membersByKey.get(key);
|
||||
if (directMember) {
|
||||
return directMember.name;
|
||||
}
|
||||
|
||||
if (options.allowLeadAliases !== false) {
|
||||
const leadName = inferLeadName(paths);
|
||||
const leadKey = normalizeMemberKey(leadName);
|
||||
if (key === 'lead' || key === 'team-lead' || (leadKey && key === leadKey)) {
|
||||
const leadMember = leadKey ? explicit.membersByKey.get(leadKey) : null;
|
||||
return leadMember ? leadMember.name : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertExplicitTeamMemberName(paths, candidate, label = 'member', options = {}) {
|
||||
const resolved = resolveExplicitTeamMemberName(paths, candidate, options);
|
||||
if (!resolved) {
|
||||
const value = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : String(candidate || '');
|
||||
throw new Error(`Unknown ${label}: ${value}. Use a configured team member name.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readTeamConfig(paths) {
|
||||
return readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||
}
|
||||
|
|
@ -182,6 +273,10 @@ function normalizeMemberRecord(member) {
|
|||
if (!member || typeof member !== 'object') return null;
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name) return null;
|
||||
const copyTrimmedString = (key) =>
|
||||
typeof member[key] === 'string' && member[key].trim()
|
||||
? { [key]: member[key].trim() }
|
||||
: {};
|
||||
return {
|
||||
name,
|
||||
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
||||
|
|
@ -193,6 +288,12 @@ function normalizeMemberRecord(member) {
|
|||
: {}),
|
||||
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}),
|
||||
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||
...copyTrimmedString('providerId'),
|
||||
...copyTrimmedString('providerBackendId'),
|
||||
...copyTrimmedString('provider'),
|
||||
...copyTrimmedString('model'),
|
||||
...copyTrimmedString('effort'),
|
||||
...copyTrimmedString('fastMode'),
|
||||
...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -207,6 +308,12 @@ function mergeResolvedMember(target, source) {
|
|||
...(source.agentType ? { agentType: source.agentType } : {}),
|
||||
...(source.color ? { color: source.color } : {}),
|
||||
...(source.cwd ? { cwd: source.cwd } : {}),
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
...(source.providerBackendId ? { providerBackendId: source.providerBackendId } : {}),
|
||||
...(source.provider ? { provider: source.provider } : {}),
|
||||
...(source.model ? { model: source.model } : {}),
|
||||
...(source.effort ? { effort: source.effort } : {}),
|
||||
...(source.fastMode ? { fastMode: source.fastMode } : {}),
|
||||
...(source.removedAt != null ? { removedAt: source.removedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -507,12 +614,18 @@ function saveTaskAttachmentFile(paths, taskId, flags) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
assertExplicitTeamMemberName,
|
||||
collectExplicitTeamMembers,
|
||||
getPaths,
|
||||
inferLeadName,
|
||||
isCanonicalLeadMember,
|
||||
looksLikeCrossTeamRecipient,
|
||||
looksLikeCrossTeamToolRecipient,
|
||||
isProcessAlive,
|
||||
listInboxMemberNames,
|
||||
readMembersMeta,
|
||||
readTeamConfig,
|
||||
resolveExplicitTeamMemberName,
|
||||
resolveTeamMembers,
|
||||
getCurrentRuntimeMemberIdentity,
|
||||
resolveCanonicalLeadSessionId,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
|
||||
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
||||
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||
const UUID_TASK_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
|
|
@ -65,11 +65,16 @@ function normalizeTask(rawTask, filePath) {
|
|||
reviewState: normalizeTaskReviewState(rawTask.reviewState),
|
||||
};
|
||||
|
||||
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
||||
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`);
|
||||
}
|
||||
task.status = String(task.status).trim();
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
function normalizeTaskReviewState(value) {
|
||||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||
return reviewStateHelpers.normalizeReviewState(value);
|
||||
}
|
||||
|
||||
function listTaskRows(paths, options = {}) {
|
||||
|
|
@ -82,7 +87,18 @@ function listTaskRows(paths, options = {}) {
|
|||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||
const filePath = path.join(paths.tasksDir, fileName);
|
||||
const rawTask = readJson(filePath, null);
|
||||
let rawTask;
|
||||
try {
|
||||
rawTask = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
anomalies.push({
|
||||
code: 'unreadable_task',
|
||||
taskId: path.basename(fileName, '.json'),
|
||||
filePath,
|
||||
detail: error instanceof Error ? error.message : 'Unreadable task row',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!rawTask) continue;
|
||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||
try {
|
||||
|
|
@ -410,7 +426,14 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
}
|
||||
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (task.status === status) return task;
|
||||
if (task.status === status) {
|
||||
if (status === 'deleted' || status === 'in_progress') {
|
||||
task.reviewState = 'none';
|
||||
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
||||
task.reviewState = 'none';
|
||||
}
|
||||
return task;
|
||||
}
|
||||
const timestamp = nowIso();
|
||||
const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
||||
|
|
@ -437,10 +460,17 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
|
||||
if (status === 'deleted') {
|
||||
task.deletedAt = timestamp;
|
||||
task.reviewState = 'none';
|
||||
} else if (task.deletedAt) {
|
||||
delete task.deletedAt;
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
task.reviewState = 'none';
|
||||
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
||||
task.reviewState = 'none';
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
|
@ -672,22 +702,7 @@ function compareTasksByFreshness(a, b) {
|
|||
}
|
||||
|
||||
function getEffectiveReviewState(kanbanEntry, task) {
|
||||
// Derive from historyEvents if available
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') {
|
||||
return e.to;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
// Fallback to persisted reviewState or kanban
|
||||
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
|
||||
return normalizeTaskReviewState(task.reviewState);
|
||||
}
|
||||
return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
|
||||
}
|
||||
|
||||
function formatBriefTaskLine(task, reviewState) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,31 @@ const kanbanStore = require('./kanbanStore.js');
|
|||
const agenda = require('./agenda.js');
|
||||
const { withTeamBoardLock } = require('./boardLock.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
const {
|
||||
createMemberMessagingProtocol,
|
||||
isOpenCodeMember,
|
||||
} = require('./memberMessagingProtocol.js');
|
||||
|
||||
function normalizeActorName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isClearOwnerValue(value) {
|
||||
return value == null || value === 'clear' || value === 'none';
|
||||
}
|
||||
|
||||
function assertKnownTaskActor(context, value, label) {
|
||||
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, value, label, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
}
|
||||
|
||||
function assertTaskNotDeleted(task, action) {
|
||||
if (task && task.status === 'deleted') {
|
||||
throw new Error(`Task #${task.displayId || task.id} is deleted; use task_restore before ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSameMember(left, right) {
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
}
|
||||
|
|
@ -53,6 +73,7 @@ function warnNonCritical(message, error) {
|
|||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native');
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim() ?
|
||||
options.description.trim() :
|
||||
|
|
@ -76,6 +97,18 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
lines.push(``, `Instructions:`, prompt);
|
||||
}
|
||||
|
||||
const notifyLeadExample = messagingProtocol.buildLeadMessageExample({
|
||||
teamName: context.teamName,
|
||||
leadName: '<lead-name>',
|
||||
fromName: '<your-name>',
|
||||
text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment <short-commentId-from-step-4>. Moving to next task.`,
|
||||
summary: `#${task.displayId || task.id} done`,
|
||||
});
|
||||
const openCodeVisibleMessageRule =
|
||||
messagingProtocol.runtimeProvider === 'opencode'
|
||||
? '\n For normal visible replies, use agent-teams_message_send. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
|
||||
: '';
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
|
|
@ -89,8 +122,8 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
task_add_comment { teamName: "${context.teamName}", taskId: "${task.id}", text: "<full results>", from: "<your-name>" }
|
||||
The response contains comment.id (UUID). Take its first 8 characters as the short commentId.
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
5. After task_complete, notify your lead via SendMessage with a brief summary and a pointer to the full comment (use the short commentId from step 4).
|
||||
Example: "#${task.displayId || task.id} done. <2-4 sentence summary>. For full details: task_get_comment { taskId: \\"${task.displayId || task.id}\\", commentId: \\"<short-commentId-from-step-4>\\" }. Moving to next task."`)
|
||||
5. After task_complete, notify your lead via ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4).
|
||||
Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`)
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -121,12 +154,23 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
||||
const ownerMember = (resolved.members || []).find(
|
||||
(member) => isSameMember(member && member.name, owner)
|
||||
);
|
||||
const messagingProtocol = createMemberMessagingProtocol(
|
||||
isOpenCodeMember(ownerMember) ? 'opencode' : 'native'
|
||||
);
|
||||
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
try {
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
text: buildAssignmentMessage(context, task, {
|
||||
...options,
|
||||
messagingProtocol,
|
||||
}),
|
||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
|
|
@ -171,16 +215,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
|
||||
if (input && input.notifyOwner !== false) {
|
||||
let taskInput = input;
|
||||
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
||||
taskInput = {
|
||||
...input,
|
||||
owner: assertKnownTaskActor(context, input.owner, 'task owner'),
|
||||
};
|
||||
}
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
|
||||
if (taskInput && taskInput.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
description: taskInput.description,
|
||||
prompt: taskInput.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
|
||||
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
from: taskInput.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
|
|
@ -233,18 +284,46 @@ function resolveTaskId(context, taskRef) {
|
|||
}
|
||||
|
||||
function setTaskStatus(context, taskId, status, actor) {
|
||||
return withTeamBoardLock(context.paths, () =>
|
||||
taskStore.setTaskStatus(context.paths, taskId, status, actor)
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const normalizedStatus = String(status || '').trim();
|
||||
if (before.status === 'deleted' && normalizedStatus !== 'deleted') {
|
||||
throw new Error(`Task #${before.displayId || before.id} is deleted; use task_restore before changing status`);
|
||||
}
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
||||
if (normalizedStatus === 'deleted' || normalizedStatus === 'in_progress' || normalizedStatus === 'pending') {
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
|
||||
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
|
||||
}
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function hasKanbanReference(state, taskId) {
|
||||
if (state.tasks && state.tasks[taskId]) {
|
||||
return true;
|
||||
}
|
||||
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return Object.values(state.columnOrder).some(
|
||||
(orderedTaskIds) =>
|
||||
Array.isArray(orderedTaskIds) && orderedTaskIds.some((entry) => String(entry) === String(taskId))
|
||||
);
|
||||
}
|
||||
|
||||
function startTask(context, taskId, actor) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor);
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
assertTaskNotDeleted(before, 'starting work');
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor);
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (state.tasks[task.id]) {
|
||||
delete state.tasks[task.id];
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
|
||||
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
|
||||
}
|
||||
return task;
|
||||
});
|
||||
|
|
@ -322,17 +401,46 @@ function completeTask(context, taskId, actor) {
|
|||
}
|
||||
|
||||
function softDeleteTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'deleted', actor);
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
|
||||
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'pending', actor || 'user');
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
if (before.status !== 'deleted') {
|
||||
throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
|
||||
}
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
|
||||
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
|
||||
}
|
||||
if (task.reviewState !== 'none') {
|
||||
task = taskStore.updateTask(context.paths, task.id, (current) => {
|
||||
current.reviewState = 'none';
|
||||
return current;
|
||||
});
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
const nextOwner = isClearOwnerValue(owner)
|
||||
? owner
|
||||
: assertKnownTaskActor(context, owner, 'task owner');
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||
return {
|
||||
previousTask: before,
|
||||
updatedTask: after,
|
||||
|
|
@ -520,7 +628,18 @@ function buildMemberActionModeProtocol() {
|
|||
return buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION);
|
||||
}
|
||||
|
||||
function buildMemberTaskProtocol(teamName) {
|
||||
function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessagingProtocol('native')) {
|
||||
const notifyLeadExample = messagingProtocol.buildLeadMessageExample({
|
||||
teamName,
|
||||
leadName: '<lead-name>',
|
||||
fromName: '<your-name>',
|
||||
text: '#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. Full details in task comment e5f6a7b8. Moving to #efgh5678 next.',
|
||||
summary: '#abcd1234 done',
|
||||
});
|
||||
const openCodeVisibleMessageRule =
|
||||
messagingProtocol.runtimeProvider === 'opencode'
|
||||
? '\n - For normal visible replies, use agent-teams_message_send. Always include teamName, to, from, text, and summary. Always set from to your teammate name. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
|
||||
: '';
|
||||
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
0. IMPORTANT ID RULE:
|
||||
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
|
||||
|
|
@ -537,13 +656,13 @@ function buildMemberTaskProtocol(teamName) {
|
|||
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
|
||||
3. Use MCP tool task_complete BEFORE sending your final reply:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>" }
|
||||
- CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
|
||||
- CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.
|
||||
- If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work.
|
||||
- After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified.
|
||||
- After that, run task_complete again before your reply.
|
||||
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
|
||||
- After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
|
||||
Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next."
|
||||
- After task_complete, send a notification to your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
|
||||
Example: ${notifyLeadExample}${openCodeVisibleMessageRule}
|
||||
- After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known.
|
||||
Example:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
|
||||
|
|
@ -553,10 +672,10 @@ function buildMemberTaskProtocol(teamName) {
|
|||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
|
||||
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
||||
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", note?: "<optional note>", notifyOwner: true }
|
||||
CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column.
|
||||
5. If review fails and changes are needed, use MCP tool review_request_changes:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" }
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", comment: "<what to fix>" }
|
||||
6. NEVER skip status updates. A task is NOT done until completed status is written.
|
||||
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
|
||||
7. To reply to a comment on a task, use MCP tool task_add_comment:
|
||||
|
|
@ -564,7 +683,9 @@ function buildMemberTaskProtocol(teamName) {
|
|||
8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
|
||||
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
|
||||
9. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability.
|
||||
9. When sending a message about a specific task, include its short display label like #<displayId> in your ${messagingProtocol.sendToolName} summary field for traceability.
|
||||
- If the message is NOT about a real board task, do NOT include any # task label.
|
||||
- Never invent placeholder task refs such as #00000000 or #<displayId>.
|
||||
10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234").
|
||||
11. Review workflow clarity (IMPORTANT):
|
||||
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
|
||||
|
|
@ -581,7 +702,7 @@ function buildMemberTaskProtocol(teamName) {
|
|||
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
|
||||
b) STEP 2 — THEN, add a task comment describing exactly what you need:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
|
||||
c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly.
|
||||
c) STEP 3 — THEN, send a message to your team lead via ${messagingProtocol.sendLeadPhrase} so they notice it promptly.
|
||||
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
|
||||
d) The clarification flag is durable until it is cleared explicitly.
|
||||
When the blocker is truly resolved, clear the flag yourself with:
|
||||
|
|
@ -646,7 +767,7 @@ function normalizeMemberName(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
async function memberBriefing(context, memberName) {
|
||||
async function memberBriefing(context, memberName, options = {}) {
|
||||
const requestedMemberName = String(memberName).trim();
|
||||
const requestedMemberKey = normalizeMemberName(requestedMemberName);
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
||||
|
|
@ -701,6 +822,9 @@ async function memberBriefing(context, memberName) {
|
|||
}
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const effectiveMember = member;
|
||||
const messagingProtocol = createMemberMessagingProtocol(
|
||||
options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native')
|
||||
);
|
||||
|
||||
const role =
|
||||
typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ?
|
||||
|
|
@ -729,12 +853,25 @@ async function memberBriefing(context, memberName) {
|
|||
);
|
||||
|
||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||
const completionNotifyExample = messagingProtocol.buildLeadMessageExample({
|
||||
teamName: context.teamName,
|
||||
leadName,
|
||||
fromName: requestedMemberName,
|
||||
text: '#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678.',
|
||||
summary: '#abcd1234 done',
|
||||
});
|
||||
const lines = [
|
||||
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
|
||||
`Role: ${role}.`,
|
||||
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
|
||||
`CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.`,
|
||||
`After task_complete, notify your team lead via SendMessage. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: "#abcd1234 done. Found 3 competitors, two lack kanban. For full details: task_get_comment { taskId: \\"abcd1234\\", commentId: \\"e5f6a7b8\\" }. Moving to #efgh5678."`,
|
||||
`CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.`,
|
||||
`After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: ${completionNotifyExample}`,
|
||||
...(messagingProtocol.runtimeProvider === 'opencode'
|
||||
? [
|
||||
'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.',
|
||||
'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.',
|
||||
]
|
||||
: []),
|
||||
`CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`,
|
||||
`Team lead: ${leadName}.`,
|
||||
buildMemberLanguageInstruction(config),
|
||||
|
|
@ -766,7 +903,7 @@ async function memberBriefing(context, memberName) {
|
|||
'',
|
||||
buildMemberFormattingProtocol(),
|
||||
'',
|
||||
buildMemberTaskProtocol(context.teamName),
|
||||
buildMemberTaskProtocol(context.teamName, messagingProtocol),
|
||||
'',
|
||||
buildMemberProcessProtocol(context.teamName)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
|||
'task_get_comment',
|
||||
'task_link',
|
||||
'task_list',
|
||||
'task_restore',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_set_status',
|
||||
|
|
|
|||
|
|
@ -151,6 +151,86 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
);
|
||||
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
|
||||
expect(briefing).toContain('Full details in task comment e5f6a7b8');
|
||||
expect(briefing).not.toContain('task_get_comment {');
|
||||
});
|
||||
|
||||
it('uses OpenCode-native visible-message wording for OpenCode member briefing', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'openrouter/test-model' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const briefing = await controller.tasks.memberBriefing('bob');
|
||||
|
||||
expect(briefing).toContain(
|
||||
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
|
||||
);
|
||||
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
||||
expect(briefing).toContain(
|
||||
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
||||
);
|
||||
expect(briefing).toContain('Full details in task comment e5f6a7b8');
|
||||
expect(briefing).toContain('Never invent placeholder task refs such as #00000000');
|
||||
expect(briefing).not.toContain('task_get_comment {');
|
||||
expect(briefing).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: '#00000000 bootstrap check-in and briefing retrieved. No actionable tasks.',
|
||||
summary: '#00000000 ready',
|
||||
});
|
||||
|
||||
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
|
||||
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
|
||||
expect(rows[0].text).toBe('bootstrap check-in and briefing retrieved. No actionable tasks.');
|
||||
expect(rows[0].summary).toBe('ready');
|
||||
});
|
||||
|
||||
it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', model: 'openai/gpt-5.4-mini' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const briefing = await controller.tasks.memberBriefing('bob');
|
||||
|
||||
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
|
||||
expect(briefing).not.toContain('agent-teams_message_send');
|
||||
});
|
||||
|
||||
it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'opencode/minimax-m2.5-free' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const briefing = await controller.tasks.memberBriefing('bob');
|
||||
|
||||
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
|
||||
expect(briefing).not.toContain('agent-teams_message_send');
|
||||
});
|
||||
|
||||
it('resolves member briefing from members.meta.json when config members are missing', async () => {
|
||||
|
|
@ -385,6 +465,31 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||
});
|
||||
|
||||
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const staleTask = controller.tasks.createTask({
|
||||
subject: 'Legacy stale approved task',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
reviewState: 'approved',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
|
||||
expect(staleLine).toContain('[status=pending]');
|
||||
expect(staleLine).not.toContain('review=');
|
||||
expect(staleLine).toContain('reason=owner_ready');
|
||||
|
||||
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
|
||||
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -559,12 +664,14 @@ describe('agent-teams-controller API', () => {
|
|||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('starts review idempotently without requiring completed status', () => {
|
||||
it('starts review idempotently after review_request', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
|
||||
|
||||
// startReview does not require completed status
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
||||
const result = controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
|
|
@ -582,7 +689,7 @@ describe('agent-teams-controller API', () => {
|
|||
// Verify history event
|
||||
const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started');
|
||||
expect(reviewEvent).toBeDefined();
|
||||
expect(reviewEvent.from).toBe('none');
|
||||
expect(reviewEvent.from).toBe('review');
|
||||
expect(reviewEvent.to).toBe('review');
|
||||
expect(reviewEvent.actor).toBe('alice');
|
||||
|
||||
|
|
@ -619,6 +726,148 @@ describe('agent-teams-controller API', () => {
|
|||
expect(reviewerBriefing).toContain('reviewer=alice');
|
||||
});
|
||||
|
||||
it('uses the assigned reviewer when review_start omits from', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(task.id);
|
||||
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
const startedEvent = reloaded.historyEvents.find((event) => event.type === 'review_started');
|
||||
expect(startedEvent.actor).toBe('alice');
|
||||
|
||||
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
|
||||
expect(reviewerBriefing).toContain(`#${task.displayId}`);
|
||||
expect(reviewerBriefing).toContain('reason=review_in_progress');
|
||||
expect(reviewerBriefing).toContain('reviewer=alice');
|
||||
});
|
||||
|
||||
it('rejects review terminal transitions outside active completed review tasks', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Pending task', owner: 'bob' });
|
||||
expect(() => controller.review.approveReview(pendingTask.id, { from: 'alice' })).toThrow(
|
||||
'must be completed before approval'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' });
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() =>
|
||||
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('must be in review before requesting changes');
|
||||
|
||||
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' });
|
||||
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
|
||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted');
|
||||
expect(() =>
|
||||
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('is deleted');
|
||||
expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('rejects review_start outside active review and keeps owner routing intact', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' });
|
||||
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
|
||||
'must be completed before starting review'
|
||||
);
|
||||
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' });
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
|
||||
'must be in review before starting review'
|
||||
);
|
||||
|
||||
const bobBriefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(bobBriefing).toContain(`#${pendingTask.displayId}`);
|
||||
expect(bobBriefing).toContain('actionOwner=@bob');
|
||||
expect(bobBriefing).not.toContain('reason=review_in_progress');
|
||||
});
|
||||
|
||||
it('rejects direct kanban lifecycle bypasses while allowing repair of matching review state', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' });
|
||||
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
|
||||
'must be completed before moving to APPROVED column'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' });
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
|
||||
'must be in review before moving to REVIEW column'
|
||||
);
|
||||
|
||||
controller.review.requestReview(completedTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
||||
delete state.tasks[completedTask.id];
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
||||
|
||||
controller.kanban.setKanbanColumn(completedTask.id, 'review');
|
||||
expect(controller.kanban.getKanbanState().tasks[completedTask.id].column).toBe('review');
|
||||
});
|
||||
|
||||
it('rejects review_request for already approved tasks until work is reopened', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved terminal task', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow(
|
||||
'is already approved'
|
||||
);
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
});
|
||||
|
||||
it('repairs kanban on idempotent review transitions without duplicate history', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Repair review column', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
const reviewState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
||||
delete reviewState.tasks[task.id];
|
||||
reviewState.columnOrder = { review: [] };
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(reviewState, null, 2));
|
||||
|
||||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started')
|
||||
).toHaveLength(1);
|
||||
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
const approvedState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
||||
delete approvedState.tasks[task.id];
|
||||
approvedState.columnOrder = { approved: [] };
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(approvedState, null, 2));
|
||||
|
||||
const approvedAgain = controller.review.approveReview(task.id, { from: 'alice' });
|
||||
expect(approvedAgain.alreadyApproved).toBe(true);
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when starting review on a deleted task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -628,6 +877,26 @@ describe('agent-teams-controller API', () => {
|
|||
expect(() => controller.review.startReview(task.id, { from: 'alice' })).toThrow('is deleted');
|
||||
});
|
||||
|
||||
it('clears stale needsFix reviewState when owner restarts work', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Needs fix restart', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Please fix.' });
|
||||
const started = controller.tasks.startTask(task.id, 'bob');
|
||||
|
||||
expect(started.status).toBe('in_progress');
|
||||
expect(started.reviewState).toBe('none');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
|
||||
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(briefing).toContain('reason=owner_executing');
|
||||
expect(briefing).not.toContain('reason=needs_fix');
|
||||
});
|
||||
|
||||
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -637,8 +906,10 @@ describe('agent-teams-controller API', () => {
|
|||
from: 'team-lead',
|
||||
text: 'Need your review',
|
||||
summary: 'Review request',
|
||||
commentId: 'comment-123',
|
||||
relayOfMessageId: 'm-original-1',
|
||||
source: 'system_notification',
|
||||
messageKind: 'task_comment_notification',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||
});
|
||||
|
|
@ -650,11 +921,113 @@ describe('agent-teams-controller API', () => {
|
|||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].messageKind).toBe('task_comment_notification');
|
||||
expect(rows[0].commentId).toBe('comment-123');
|
||||
expect(rows[0].relayOfMessageId).toBe('m-original-1');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('persists slash command metadata through controller messages.appendSentMessage', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.messages.appendSentMessage({
|
||||
from: 'user',
|
||||
to: 'alice',
|
||||
text: '/compact keep only kanban context',
|
||||
messageKind: 'slash_command',
|
||||
slashCommand: {
|
||||
name: 'compact',
|
||||
command: '/compact',
|
||||
args: 'keep only kanban context',
|
||||
knownDescription: 'Compact the active context',
|
||||
},
|
||||
});
|
||||
|
||||
controller.messages.appendSentMessage({
|
||||
from: 'alice',
|
||||
to: 'user',
|
||||
text: 'Compacted context.',
|
||||
messageKind: 'slash_command_result',
|
||||
commandOutput: {
|
||||
stream: 'stdout',
|
||||
commandLabel: '/compact',
|
||||
},
|
||||
});
|
||||
|
||||
const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
|
||||
const rows = JSON.parse(fs.readFileSync(sentPath, 'utf8'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].messageKind).toBe('slash_command');
|
||||
expect(rows[0].slashCommand).toMatchObject({
|
||||
name: 'compact',
|
||||
command: '/compact',
|
||||
args: 'keep only kanban context',
|
||||
});
|
||||
expect(rows[1].messageKind).toBe('slash_command_result');
|
||||
expect(rows[1].commandOutput).toEqual({
|
||||
stream: 'stdout',
|
||||
commandLabel: '/compact',
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes local message recipients and guards user-directed sender identity', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
from: 'bob',
|
||||
text: 'Need lead input',
|
||||
summary: 'Lead input',
|
||||
actionMode: 'ask',
|
||||
});
|
||||
|
||||
const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||
const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8'));
|
||||
expect(leadRows).toHaveLength(1);
|
||||
expect(leadRows[0].to).toBe('alice');
|
||||
expect(leadRows[0].from).toBe('bob');
|
||||
expect(leadRows[0].actionMode).toBe('ask');
|
||||
|
||||
controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'lead',
|
||||
text: 'Visible user reply',
|
||||
summary: 'Reply',
|
||||
});
|
||||
|
||||
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
|
||||
const userRows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
|
||||
expect(userRows).toHaveLength(1);
|
||||
expect(userRows[0].to).toBe('user');
|
||||
expect(userRows[0].from).toBe('alice');
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
text: 'Missing sender',
|
||||
})
|
||||
).toThrow('message_send to user requires from to be the responding team member name');
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'other-team.alice',
|
||||
from: 'bob',
|
||||
text: 'Wrong transport',
|
||||
})
|
||||
).toThrow('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'cross_team_send',
|
||||
from: 'bob',
|
||||
text: 'Wrong transport',
|
||||
})
|
||||
).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
|
||||
});
|
||||
|
||||
it('wakes task owner on regular comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -723,10 +1096,11 @@ describe('agent-teams-controller API', () => {
|
|||
text: 'Need your decision here.',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].from).toBe('bob');
|
||||
expect(rows[0].to).toBe('alice');
|
||||
expect(rows[0].text).toContain('Need your decision here.');
|
||||
});
|
||||
|
||||
|
|
@ -919,6 +1293,449 @@ describe('agent-teams-controller API', () => {
|
|||
expect(leadBriefing).not.toContain('review_reviewer_missing');
|
||||
});
|
||||
|
||||
it('does not treat role names containing lead as canonical team lead', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
|
||||
expect(aliceBriefing).toContain('Actionable:');
|
||||
expect(aliceBriefing).toContain(`#${task.displayId}`);
|
||||
expect(aliceBriefing).toContain('actionOwner=@alice');
|
||||
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
||||
});
|
||||
|
||||
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'alice', role: 'developer' },
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'opsbot', agentType: 'orchestrator' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
|
||||
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
|
||||
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
|
||||
expect(aliceBriefing).toContain('actionOwner=@alice');
|
||||
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
|
||||
});
|
||||
|
||||
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
members: [
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
||||
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||
|
||||
controller.kanban.addReviewer('lead');
|
||||
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
|
||||
|
||||
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
|
||||
controller.tasks.completeTask(reviewTask.id, 'bob');
|
||||
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
|
||||
|
||||
const requested = controller.tasks
|
||||
.getTask(reviewTask.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||
.at(-1);
|
||||
expect(requested.reviewer).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects task_briefing for unknown members', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
await expect(controller.tasks.taskBriefing('bbo')).rejects.toThrow(
|
||||
'Member not found in team metadata or inboxes: bbo'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when task_briefing member exists only because of inbox state', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inboxDir, 'bbo.json'), '[]', 'utf8');
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bbo');
|
||||
|
||||
expect(briefing).toContain('Board warnings:');
|
||||
expect(briefing).toContain(
|
||||
'Member identity warning: bbo is known only from inbox state, not team config/member metadata. Verify the member name before acting.'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears kanban tasks and column order when review tasks leave review', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Column cleanup', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.kanban.updateColumnOrder('review', [task.id]);
|
||||
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Needs work.' });
|
||||
|
||||
let kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.tasks[task.id]).toBeUndefined();
|
||||
expect(kanbanState.columnOrder).toBeUndefined();
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.kanban.updateColumnOrder('review', [task.id]);
|
||||
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.reviewState).toBe('none');
|
||||
kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.tasks[task.id]).toBeUndefined();
|
||||
expect(kanbanState.columnOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.kanban.updateColumnOrder('review', [task.id]);
|
||||
const deleted = controller.tasks.setTaskStatus(task.id, 'deleted', 'bob');
|
||||
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.reviewState).toBe('none');
|
||||
const kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.tasks[task.id]).toBeUndefined();
|
||||
expect(kanbanState.columnOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces unreadable task rows as board anomalies', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', 'broken.json'), '{ bad json', 'utf8');
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
expect(leadBriefing).toContain('Board anomalies:');
|
||||
expect(leadBriefing).toContain('unreadable_task (broken)');
|
||||
expect(leadBriefing).toContain('anomalies=1');
|
||||
});
|
||||
|
||||
it('caps large member briefings and points agents to drill-down tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
controller.tasks.createTask({
|
||||
subject: `Large queue task ${i}`,
|
||||
description: 'x'.repeat(3000),
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
comments: Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `comment-${i}-${index}`,
|
||||
author: 'bob',
|
||||
text: 'y'.repeat(1000),
|
||||
createdAt: new Date(Date.UTC(2026, 0, 1, 0, i, index)).toISOString(),
|
||||
})),
|
||||
notifyOwner: false,
|
||||
});
|
||||
}
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
const renderedTaskLines = briefing.split('\n').filter((line) => line.startsWith('- #'));
|
||||
expect(renderedTaskLines.length).toBe(50);
|
||||
expect(briefing).toContain('10 more Actionable item(s) omitted');
|
||||
expect(briefing).toContain('Use task_list filters and task_get for drill-down.');
|
||||
expect(briefing.length).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it('resets approved review state when work is reopened to pending', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved then reopened', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
const reopened = controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
|
||||
|
||||
expect(reopened.status).toBe('pending');
|
||||
expect(reopened.reviewState).toBe('none');
|
||||
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' })).toHaveLength(0);
|
||||
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
|
||||
|
||||
const bobBriefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(bobBriefing).toContain(`#${task.displayId}`);
|
||||
expect(bobBriefing).toContain('reason=owner_ready');
|
||||
expect(bobBriefing).toContain('actionOwner=@bob');
|
||||
});
|
||||
|
||||
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.kanban.clearKanban(task.id)).toThrow('reviewState=approved');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
|
||||
controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
|
||||
const noOpState = controller.kanban.clearKanban(task.id);
|
||||
expect(noOpState.tasks[task.id]).toBeUndefined();
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('does not let inbox-only names become real owners or reviewers', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inboxDir, 'boob.json'), '[]', 'utf8');
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
|
||||
|
||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob');
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
|
||||
'Unknown reviewer: boob'
|
||||
);
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
rawTask.owner = 'boob';
|
||||
rawTask.status = 'pending';
|
||||
rawTask.reviewState = 'none';
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
expect(leadBriefing).toContain(`#${task.displayId}`);
|
||||
expect(leadBriefing).toContain('reason=owner_invalid');
|
||||
expect(leadBriefing).toContain('Needs owner assignment:');
|
||||
});
|
||||
|
||||
it('prevents deleted tasks from being resurrected by normal work tools', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Deleted work guard', owner: 'bob' });
|
||||
|
||||
controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
|
||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work');
|
||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status');
|
||||
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
|
||||
'use task_restore before changing status'
|
||||
);
|
||||
|
||||
const restored = controller.tasks.restoreTask(task.id, 'alice');
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects task_restore for non-deleted tasks', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
|
||||
'task_restore only restores deleted tasks'
|
||||
);
|
||||
expect(controller.tasks.getTask(task.id).status).toBe('completed');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
});
|
||||
|
||||
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved without overlay', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
||||
delete state.tasks[task.id];
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
||||
|
||||
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id);
|
||||
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('repairs an invalid review_started actor without losing the assigned reviewer', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Repair reviewer actor', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
rawTask.historyEvents.push({
|
||||
id: 'bad-review-start',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'alicce',
|
||||
});
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
const startedEvents = controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_started');
|
||||
expect(startedEvents.at(-1).actor).toBe('alice');
|
||||
|
||||
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
|
||||
expect(reviewerBriefing).toContain(`#${task.displayId}`);
|
||||
expect(reviewerBriefing).toContain('reviewer=alice');
|
||||
expect(reviewerBriefing).not.toContain('review_reviewer_missing');
|
||||
});
|
||||
|
||||
it('repairs a valid but mismatched review_started actor back to the assigned reviewer', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members.push({ name: 'carol', role: 'reviewer' });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
rawTask.historyEvents.push({
|
||||
id: 'wrong-review-start',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'carol',
|
||||
});
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
controller.review.startReview(task.id);
|
||||
const startedEvents = controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_started');
|
||||
expect(startedEvents.at(-1).actor).toBe('alice');
|
||||
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const carolBriefing = await controller.tasks.taskBriefing('carol');
|
||||
expect(aliceBriefing).toContain(`#${task.displayId}`);
|
||||
expect(aliceBriefing).toContain('reviewer=alice');
|
||||
expect(carolBriefing).not.toContain('reason=review_in_progress');
|
||||
});
|
||||
|
||||
it('bounds anomaly and subject rendering on primary queue surfaces', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const longSubject = `Long subject ${'x'.repeat(5000)}`;
|
||||
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false });
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
teamName: 'my-team',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
missing: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
|
||||
},
|
||||
columnOrder: { review: ['missing', task.id] },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'tasks', 'my-team', 'bad-status.json'),
|
||||
JSON.stringify({ id: 'bad-status', subject: 'Bad status', status: 'inprogress' }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8');
|
||||
}
|
||||
|
||||
const briefing = await controller.tasks.leadBriefing();
|
||||
expect(briefing).toContain('Board anomalies:');
|
||||
expect(briefing).toContain('Invalid task status "inprogress"');
|
||||
expect(briefing).toContain('stale_kanban_task (missing)');
|
||||
expect(briefing).toContain('more board anomaly item(s) omitted');
|
||||
expect(briefing).not.toContain('x'.repeat(1000));
|
||||
|
||||
const inventoryRow = controller.tasks.listTaskInventory({ owner: 'bob' })[0];
|
||||
expect(inventoryRow.subject).toContain('[truncated]');
|
||||
expect(inventoryRow.subject.length).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('marks stale processes stopped during listing and supports unregister', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ describe('crossTeam module', () => {
|
|||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
|
||||
expect(inbox[0].from).toBe('team-a.team-lead');
|
||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
|
||||
expect(inbox[0].conversationId).toBeTruthy();
|
||||
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
||||
});
|
||||
|
|
@ -98,6 +98,60 @@ describe('crossTeam module', () => {
|
|||
expect(sentMessages[0].messageId).toBe(outbox[0].messageId);
|
||||
});
|
||||
|
||||
it('preserves taskRefs in target inbox, sender copy and outbox', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
const taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }];
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Please review the linked task',
|
||||
taskRefs,
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox[0].taskRefs).toEqual(taskRefs);
|
||||
|
||||
const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
|
||||
const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
|
||||
expect(sentMessages[0].taskRefs).toEqual(taskRefs);
|
||||
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox[0].taskRefs).toEqual(taskRefs);
|
||||
});
|
||||
|
||||
it('rejects unknown source fromMember', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'ghost',
|
||||
text: 'Hello from nowhere',
|
||||
})
|
||||
).toThrow('Unknown cross-team sender');
|
||||
});
|
||||
|
||||
it('preserves reply conversation metadata for explicit replies', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
|
|
@ -314,6 +368,108 @@ describe('crossTeam module', () => {
|
|||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves supported lead agent types before tech-lead role text', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'olivia', agentType: 'lead' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves orchestrator lead from members.meta.json', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'orla', agentType: 'orchestrator' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects phantom source teams before delivery or outbox writes', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello from nowhere',
|
||||
})
|
||||
).toThrow('Source team not found: team-a');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown cross-team senders', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'alicce',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Unknown cross-team sender: alicce');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves lead by name fallback', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
|
|
|
|||
4
bun.lock
4
bun.lock
|
|
@ -156,7 +156,7 @@
|
|||
"version": "1.0.0",
|
||||
},
|
||||
"landing": {
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
|
|
@ -1780,7 +1780,7 @@
|
|||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"claude-agent-teams-landing": ["claude-agent-teams-landing@workspace:landing"],
|
||||
"agent-teams-landing": ["agent-teams-landing@workspace:landing"],
|
||||
|
||||
"clean-regexp": ["clean-regexp@1.0.0", "", { "dependencies": { "escape-string-regexp": "1.0.5" } }, "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# =============================================================================
|
||||
# Claude Agent Teams UI standalone Docker image
|
||||
# Agent Teams standalone Docker image
|
||||
#
|
||||
# Runs the HTTP server without Electron, serving the full UI over HTTP.
|
||||
# Mount your ~/.claude directory to make session data available.
|
||||
#
|
||||
# Build: docker build -t claude-agent-teams-ui .
|
||||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
# Build: docker build -t agent-teams-ai .
|
||||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# =============================================================================
|
||||
# Claude Agent Teams UI — Docker Compose
|
||||
# Agent Teams - Docker Compose
|
||||
#
|
||||
# Quick start:
|
||||
# docker compose -f docker/docker-compose.yml up
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
# =============================================================================
|
||||
|
||||
services:
|
||||
claude-agent-teams-ui:
|
||||
agent-teams-ai:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ Minor release: React 19 + Electron 40 migration, start-task-by-user, auth troubl
|
|||
|
||||
## Published: v1.0.0 (2026-03-19)
|
||||
|
||||
Initial release: Claude Agent Teams UI with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md).
|
||||
Initial release: Agent Teams with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
After CI uploads artifacts, optional notes update:
|
||||
|
||||
```bash
|
||||
gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
|
||||
## Claude Agent Teams UI v1.0.0
|
||||
## Agent Teams v1.0.0
|
||||
|
||||
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ EOF
|
|||
## Release Notes Template
|
||||
|
||||
```markdown
|
||||
## Claude Agent Teams UI v<VERSION>
|
||||
## Agent Teams v<VERSION>
|
||||
|
||||
<1-2 sentence summary of the release>
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ EOF
|
|||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.dmg">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -197,9 +197,9 @@ electron-builder generates these artifacts per platform:
|
|||
| Platform | Versioned Name | Stable Name (for /latest/download) |
|
||||
|------------------|--------------------------------------------------|--------------------------------------------|
|
||||
| macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | — |
|
||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-mac.zip` | — |
|
||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
|
||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - |
|
||||
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
||||
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
||||
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Team Management Feature
|
||||
|
||||
Интерфейс для управления командами тиммейтов Claude Code внутри Claude Agent Teams UI (Electron).
|
||||
Интерфейс для управления командами тиммейтов Claude Code внутри Agent Teams (Electron).
|
||||
|
||||
## Что делает
|
||||
|
||||
|
|
|
|||
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
File diff suppressed because it is too large
Load diff
3471
docs/team-management/opencode-native-semantic-messaging-plan.md
Normal file
3471
docs/team-management/opencode-native-semantic-messaging-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -33,7 +33,7 @@ claude # Новая независимая сессия
|
|||
|
||||
---
|
||||
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в Agent Teams
|
||||
|
||||
### Уже реализовано (можно переиспользовать)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
|||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: { name: `claude-agent-teams-ui@${pkg.version}` },
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Claude Agent Teams Landing
|
||||
# Agent Teams Landing
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
<NuxtLink to="/" class="app-logo">
|
||||
<img
|
||||
:src="`${baseURL}logo-192.png`"
|
||||
alt="Claude Agent Teams"
|
||||
alt="Agent Teams"
|
||||
class="app-logo__img"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
<span class="app-logo__text">Claude Agent Teams</span>
|
||||
<span class="app-logo__text">Agent Teams</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<v-container class="app-footer__inner">
|
||||
<span class="app-footer__copy">{{ t("footer.copyright", { year }) }} · {{ t("footer.tagline") }}</span>
|
||||
<span class="app-footer__copy"
|
||||
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
|
||||
>
|
||||
<div class="app-footer__links">
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" href="https://github.com/777genius/claude_agent_teams_ui" target="_blank">GitHub</a>
|
||||
<a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
|
||||
</div>
|
||||
</v-container>
|
||||
</footer>
|
||||
|
|
@ -31,7 +34,7 @@ const year = new Date().getFullYear();
|
|||
.app-footer__copy {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
|
|
@ -46,7 +49,7 @@ const year = new Date().getFullYear();
|
|||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const navItems = computed(() => [
|
||||
|
|
@ -28,7 +29,7 @@ const navItems = computed(() => [
|
|||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
:prepend-icon="mdiGithub"
|
||||
|
|
@ -60,7 +61,7 @@ const navItems = computed(() => [
|
|||
{{ item.label }}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
])
|
||||
|
||||
const competitors = [
|
||||
{ key: 'us', name: 'Claude Agent Teams', highlight: true },
|
||||
{ key: 'us', name: 'Agent Teams', highlight: true },
|
||||
{ key: 'vibeKanban', name: 'Vibe Kanban' },
|
||||
{ key: 'aperant', name: 'Aperant' },
|
||||
{ key: 'cursor', name: 'Cursor' },
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import { downloadAssets } from "~/data/downloads";
|
||||
import type { DownloadOs, DownloadArch } from "~/data/downloads";
|
||||
import { downloadAssets } from '~/data/downloads';
|
||||
import type { DownloadOs, DownloadArch } from '~/data/downloads';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
const downloadStore = useDownloadStore();
|
||||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
const { releaseDownloadUrl } = useGithubRepo();
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
|
|
@ -18,18 +19,18 @@ const platformIcons: Record<string, string> = {
|
|||
};
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
macos: "#00f0ff",
|
||||
windows: "#39ff14",
|
||||
linux: "#ffd700",
|
||||
macos: '#00f0ff',
|
||||
windows: '#39ff14',
|
||||
linux: '#ffd700',
|
||||
};
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
const enriched = downloadAssets.map((asset) => {
|
||||
if (asset.os !== "macos") return { ...asset };
|
||||
if (asset.os !== 'macos') return { ...asset };
|
||||
if (!downloadStore.isMacOs) return { ...asset };
|
||||
return {
|
||||
...asset,
|
||||
archLabel: downloadStore.macArch === "arm64" ? "Apple Silicon" : "Intel",
|
||||
archLabel: downloadStore.macArch === 'arm64' ? 'Apple Silicon' : 'Intel',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -43,13 +44,13 @@ const visibleAssets = computed(() => {
|
|||
return [first, detected, ...rest];
|
||||
});
|
||||
|
||||
const getDownloadUrl = (asset: { os: string; arch: string; url: string }) => {
|
||||
const arch = (asset.os === "macos" ? downloadStore.macArch : asset.arch) as DownloadArch;
|
||||
return resolve(asset.os as DownloadOs, arch)?.url || asset.url;
|
||||
const getDownloadUrl = (asset: { os: string; arch: string; fileName: string }) => {
|
||||
const arch = (asset.os === 'macos' ? downloadStore.macArch : asset.arch) as DownloadArch;
|
||||
return resolve(asset.os as DownloadOs, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
};
|
||||
|
||||
const getDownloadArch = (asset: { os: string; arch: string }) => {
|
||||
return asset.os === "macos" ? downloadStore.macArch : asset.arch;
|
||||
return asset.os === 'macos' ? downloadStore.macArch : asset.arch;
|
||||
};
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
|
|
@ -90,7 +91,11 @@ const releaseDate = computed(() => {
|
|||
|
||||
<!-- Platform icon -->
|
||||
<div class="download-section__card-icon-wrap">
|
||||
<v-icon size="28" class="download-section__card-icon" :icon="platformIcons[asset.os] || mdiDownload" />
|
||||
<v-icon
|
||||
size="28"
|
||||
class="download-section__card-icon"
|
||||
:icon="platformIcons[asset.os] || mdiDownload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Platform info -->
|
||||
|
|
@ -103,10 +108,18 @@ const releaseDate = computed(() => {
|
|||
<a
|
||||
class="download-section__btn"
|
||||
:href="getDownloadUrl(asset)"
|
||||
@click.stop="trackDownloadClick({ os: asset.os, arch: getDownloadArch(asset), version: releaseVersion, source: 'download_section' }); downloadStore.setSelected(asset.id)"
|
||||
@click.stop="
|
||||
trackDownloadClick({
|
||||
os: asset.os,
|
||||
arch: getDownloadArch(asset),
|
||||
version: releaseVersion,
|
||||
source: 'download_section',
|
||||
});
|
||||
downloadStore.setSelected(asset.id);
|
||||
"
|
||||
>
|
||||
<v-icon size="18" class="download-section__btn-icon" :icon="mdiDownload" />
|
||||
<span>{{ t("download.title") }}</span>
|
||||
<span>{{ t('download.title') }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Active indicator -->
|
||||
|
|
@ -115,7 +128,7 @@ const releaseDate = computed(() => {
|
|||
class="download-section__card-indicator"
|
||||
>
|
||||
<v-icon size="16" :icon="mdiCheckCircle" />
|
||||
<span>{{ t("download.detected") }}</span>
|
||||
<span>{{ t('download.detected') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -245,11 +258,7 @@ const releaseDate = computed(() => {
|
|||
|
||||
.download-section__card--active .download-section__card-glow {
|
||||
opacity: 0.7;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
rgba(57, 255, 20, 0.1),
|
||||
transparent 70%
|
||||
);
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(57, 255, 20, 0.1), transparent 70%);
|
||||
}
|
||||
|
||||
/* Icon wrap */
|
||||
|
|
@ -267,7 +276,9 @@ const releaseDate = computed(() => {
|
|||
);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
margin-bottom: 14px;
|
||||
transition: transform 0.35s ease, box-shadow 0.35s ease;
|
||||
transition:
|
||||
transform 0.35s ease,
|
||||
box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.download-section__card:hover .download-section__card-icon-wrap {
|
||||
|
|
@ -290,7 +301,7 @@ const releaseDate = computed(() => {
|
|||
margin-bottom: 3px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #e0e6ff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.download-section__card-arch {
|
||||
|
|
@ -319,7 +330,7 @@ const releaseDate = computed(() => {
|
|||
box-shadow 0.25s ease,
|
||||
filter 0.25s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.download-section__btn:hover {
|
||||
|
|
@ -346,7 +357,7 @@ const releaseDate = computed(() => {
|
|||
font-weight: 600;
|
||||
color: #39ff14;
|
||||
opacity: 0.9;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Release info */
|
||||
|
|
@ -360,7 +371,7 @@ const releaseDate = computed(() => {
|
|||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
@keyframes downloadFadeUp {
|
||||
|
|
|
|||
|
|
@ -7,21 +7,26 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
|
||||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
const raw = releaseData.value?.pubDate;
|
||||
if (!raw) return null;
|
||||
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return new Date(raw).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
if (!asset) return 'https://github.com/777genius/claude_agent_teams_ui/releases/latest';
|
||||
if (!asset) return latestReleaseUrl.value;
|
||||
const arch = asset.os === 'macos' ? downloadStore.macArch : asset.arch;
|
||||
return resolve(asset.os, arch)?.url || asset.url;
|
||||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -75,17 +80,17 @@ const heroDownloadUrl = computed(() => {
|
|||
<div class="hero-section__trust">
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
|
||||
<span>{{ t("hero.trust.agentTeams") }}</span>
|
||||
<span>{{ t('hero.trust.agentTeams') }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
|
||||
<span>{{ t("hero.trust.kanban") }}</span>
|
||||
<span>{{ t('hero.trust.kanban') }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
|
||||
<span>{{ t("hero.trust.openSource") }}</span>
|
||||
<span>{{ t('hero.trust.openSource') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
|
@ -108,7 +113,6 @@ const heroDownloadUrl = computed(() => {
|
|||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -262,7 +266,12 @@ const heroDownloadUrl = computed(() => {
|
|||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(255, 0, 255, 0.2), rgba(57, 255, 20, 0.1));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 240, 255, 0.2),
|
||||
rgba(255, 0, 255, 0.2),
|
||||
rgba(57, 255, 20, 0.1)
|
||||
);
|
||||
filter: blur(20px);
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
|
|
@ -270,8 +279,15 @@ const heroDownloadUrl = computed(() => {
|
|||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.02); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── SSR Fallback ─── */
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { testimonials } from '~/data/testimonials'
|
||||
import { useLandingContent } from '~/composables/useLandingContent'
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { testimonials } from '~/data/testimonials';
|
||||
import { useLandingContent } from '~/composables/useLandingContent';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
const { issuesUrl } = useGithubRepo();
|
||||
const { smAndUp } = useDisplay();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
|
@ -16,7 +17,7 @@ const items = computed(() =>
|
|||
if (!contentItem) return null;
|
||||
return { ...contentItem, avatar: entry.avatar };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null),
|
||||
);
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
|
|
@ -24,9 +25,7 @@ const visibleItems = computed(() => {
|
|||
return items.value.slice(0, smAndUp.value ? 4 : 2);
|
||||
});
|
||||
|
||||
const hasMore = computed(() =>
|
||||
!expanded.value && items.value.length > (smAndUp.value ? 4 : 2)
|
||||
);
|
||||
const hasMore = computed(() => !expanded.value && items.value.length > (smAndUp.value ? 4 : 2));
|
||||
|
||||
const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
||||
</script>
|
||||
|
|
@ -36,32 +35,21 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
<v-container>
|
||||
<div class="testimonials-section__header">
|
||||
<h2 class="testimonials-section__title">
|
||||
{{ t("testimonials.sectionTitle") }}
|
||||
{{ t('testimonials.sectionTitle') }}
|
||||
</h2>
|
||||
<p class="testimonials-section__subtitle">
|
||||
{{ t("testimonials.sectionSubtitle") }}
|
||||
{{ t('testimonials.sectionSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-row justify="center">
|
||||
<v-col
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<div
|
||||
class="testimonials-section__card-wrap"
|
||||
:style="{ '--delay': `${index * 0.08}s` }"
|
||||
>
|
||||
<v-col v-for="(item, index) in visibleItems" :key="item.id" cols="12" sm="6">
|
||||
<div class="testimonials-section__card-wrap" :style="{ '--delay': `${index * 0.08}s` }">
|
||||
<div class="testimonial-card">
|
||||
<div class="testimonial-card__quote">"</div>
|
||||
<p class="testimonial-card__text">{{ item.text }}</p>
|
||||
<div class="testimonial-card__author">
|
||||
<div
|
||||
class="testimonial-card__avatar"
|
||||
:style="{ background: item.avatar }"
|
||||
>
|
||||
<div class="testimonial-card__avatar" :style="{ background: item.avatar }">
|
||||
{{ getInitial(item.name) }}
|
||||
</div>
|
||||
<div class="testimonial-card__info">
|
||||
|
|
@ -75,17 +63,14 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
</v-row>
|
||||
|
||||
<div v-if="hasMore || expanded" class="testimonials-section__toggle">
|
||||
<button
|
||||
class="testimonials-section__toggle-btn"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<button class="testimonials-section__toggle-btn" @click="expanded = !expanded">
|
||||
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="testimonials-section__feedback-cta">
|
||||
{{ t('testimonials.feedbackCta') }}
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/issues" target="_blank" class="testimonials-section__email">GitHub</a>
|
||||
<a :href="issuesUrl" target="_blank" class="testimonials-section__email">GitHub</a>
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
|
|
@ -139,7 +124,9 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.testimonial-card:hover {
|
||||
|
|
|
|||
23
landing/composables/useGithubRepo.ts
Normal file
23
landing/composables/useGithubRepo.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const useGithubRepo = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const githubRepo = computed(
|
||||
() => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui',
|
||||
);
|
||||
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
|
||||
const releasesUrl = computed(
|
||||
() => (config.public.githubReleasesUrl as string) || `${repoUrl.value}/releases`,
|
||||
);
|
||||
const latestReleaseUrl = computed(() => `${releasesUrl.value}/latest`);
|
||||
const issuesUrl = computed(() => `${repoUrl.value}/issues`);
|
||||
const releaseDownloadUrl = (assetName: string) =>
|
||||
`${latestReleaseUrl.value}/download/${assetName}`;
|
||||
|
||||
return {
|
||||
githubRepo,
|
||||
repoUrl,
|
||||
releasesUrl,
|
||||
latestReleaseUrl,
|
||||
issuesUrl,
|
||||
releaseDownloadUrl,
|
||||
};
|
||||
};
|
||||
|
|
@ -22,7 +22,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = config.public.siteUrl || "https://example.com";
|
||||
const siteName = (config as any)?.site?.name || "Claude Agent Teams";
|
||||
const siteName = (config as any)?.site?.name || "Agent Teams";
|
||||
const switchLocale = useSwitchLocalePath();
|
||||
|
||||
const title = computed(() => t(titleKey));
|
||||
|
|
@ -149,7 +149,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
htmlAttrs: { lang: locale.value || "en" },
|
||||
link: links,
|
||||
meta: [
|
||||
{ name: "author", content: "Claude Agent Teams" },
|
||||
{ name: "author", content: "Agent Teams" },
|
||||
{ name: "application-name", content: siteName },
|
||||
{ name: "apple-mobile-web-app-title", content: siteName },
|
||||
{ name: "format-detection", content: "telephone=no" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "أنت المدير التقني، والوكلاء هم فريقك. يتولون المهام بأنفسهم، يتواصلون مع بعضهم، يراجعون كود بعضهم البعض. وأنت فقط تنظر للوحة كانبان وتشرب قهوتك."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "ما هو Claude Agent Teams؟",
|
||||
"question": "ما هو Agent Teams؟",
|
||||
"answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Sie sind der CTO, Agenten sind Ihr Team. Sie erledigen Aufgaben, kommunizieren untereinander, reviewen Code. Sie schauen aufs Kanban-Board und trinken Kaffee."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Was ist Claude Agent Teams?",
|
||||
"question": "Was ist Agent Teams?",
|
||||
"answer": "Eine Desktop-App zur Orchestrierung von KI-Agententeams mit unserer eigenen lokalen Koordinationsschicht. Agenten haben Rollen, arbeiten autonom, kollaborieren über ein Kanban-Board und können mit Anthropic oder Codex laufen."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "What is Claude Agent Teams?",
|
||||
"question": "What is Agent Teams?",
|
||||
"answer": "A desktop app for orchestrating AI agent teams with our own local coordination layer. Agents have roles, work autonomously, collaborate through a kanban board, and can run on Anthropic or Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Tú eres el CTO, los agentes son tu equipo. Ellos manejan las tareas solos, se comunican entre sí, revisan el código del otro. Tú solo miras el tablero kanban y tomas café."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "¿Qué es Claude Agent Teams?",
|
||||
"question": "¿Qué es Agent Teams?",
|
||||
"answer": "Una app de escritorio para orquestar equipos de agentes IA con nuestra propia capa local de coordinación. Cada agente tiene un rol, trabaja de forma autónoma, colabora a través de un tablero kanban y puede ejecutarse con Anthropic o Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Vous êtes le CTO, les agents sont votre équipe. Ils gèrent les tâches, communiquent entre eux, révisent le code. Vous regardez le kanban et buvez votre café."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Qu'est-ce que Claude Agent Teams ?",
|
||||
"question": "Qu'est-ce que Agent Teams ?",
|
||||
"answer": "Une application de bureau pour orchestrer des équipes d'agents IA avec notre propre couche de coordination locale. Les agents ont des rôles, travaillent de façon autonome, collaborent sur un tableau kanban et peuvent s'exécuter avec Anthropic ou Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "आप CTO हैं, एजेंट आपकी टीम हैं। वे खुद टास्क संभालते हैं, आपस में बात करते हैं, एक-दूसरे का कोड रिव्यू करते हैं। आप बस कानबन बोर्ड देखें और कॉफी पिएँ।"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Claude Agent Teams क्या है?",
|
||||
"question": "Agent Teams क्या है?",
|
||||
"answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "あなたはCTO、エージェントはあなたのチーム。タスクを自分で処理し、互いにメッセージを送り、コードをレビューする。あなたはカンバンボードを見ながらコーヒーを飲むだけ。"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Claude Agent Teamsとは?",
|
||||
"question": "Agent Teamsとは?",
|
||||
"answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Você é o CTO, os agentes são sua equipe. Eles cuidam das tarefas sozinhos, se comunicam entre si, revisam o código uns dos outros. Você só olha o quadro kanban e toma café."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "O que é Claude Agent Teams?",
|
||||
"question": "O que é Agent Teams?",
|
||||
"answer": "Um app desktop para orquestrar equipes de agentes IA com nossa própria camada local de coordenação. Os agentes têm papéis, trabalham de forma autônoma, colaboram em um quadro kanban e podem rodar com Anthropic ou Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Что такое Claude Agent Teams?",
|
||||
"question": "Что такое Agent Teams?",
|
||||
"answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "你是 CTO,智能体是你的团队。它们自己处理任务、互相沟通、审查彼此的代码。你只需看着看板喝咖啡。"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "什么是 Claude Agent Teams?",
|
||||
"question": "什么是 Agent Teams?",
|
||||
"answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
export type DownloadOs = "macos" | "windows" | "linux";
|
||||
export type DownloadArch = "arm64" | "x64" | "universal";
|
||||
export type DownloadOs = 'macos' | 'windows' | 'linux';
|
||||
export type DownloadArch = 'arm64' | 'x64' | 'universal';
|
||||
|
||||
export const downloadAssets = [
|
||||
{
|
||||
id: "macos",
|
||||
os: "macos",
|
||||
arch: "universal",
|
||||
label: "macOS",
|
||||
archLabel: "Apple Silicon / Intel",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg"
|
||||
id: 'macos',
|
||||
os: 'macos',
|
||||
arch: 'universal',
|
||||
label: 'macOS',
|
||||
archLabel: 'Apple Silicon / Intel',
|
||||
fileName: 'Claude-Agent-Teams-UI-arm64.dmg',
|
||||
},
|
||||
{
|
||||
id: "windows-x64",
|
||||
os: "windows",
|
||||
arch: "x64",
|
||||
label: "Windows",
|
||||
archLabel: "64-bit",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe"
|
||||
id: 'windows-x64',
|
||||
os: 'windows',
|
||||
arch: 'x64',
|
||||
label: 'Windows',
|
||||
archLabel: '64-bit',
|
||||
fileName: 'Claude-Agent-Teams-UI-Setup.exe',
|
||||
},
|
||||
{
|
||||
id: "linux-appimage",
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
label: "Linux",
|
||||
archLabel: "64-bit",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage"
|
||||
}
|
||||
id: 'linux-appimage',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
label: 'Linux',
|
||||
archLabel: '64-bit',
|
||||
fileName: 'Claude-Agent-Teams-UI.AppImage',
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "حمّل الآن",
|
||||
"ctaPrimary": "تحميل لـ {platform}",
|
||||
"ctaSecondary": "مقارنة",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "لديك أسئلة؟ لدينا إجابات",
|
||||
"subtitle": "كل ما تحتاج معرفته عن Claude Agent Teams"
|
||||
"subtitle": "كل ما تحتاج معرفته عن Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "كيف نقارن",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "اعرف المزيد"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Herunterladen",
|
||||
"ctaPrimary": "Für {platform} herunterladen",
|
||||
"ctaSecondary": "Vergleichen",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Fragen? Wir haben Antworten",
|
||||
"subtitle": "Alles, was Sie über Claude Agent Teams wissen müssen"
|
||||
"subtitle": "Alles, was Sie über Agent Teams wissen müssen"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Wie wir im Vergleich abschneiden",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Mehr erfahren"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "KI-Agenten-Orchestrierung für Entwickler",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — KI-Agenten-Orchestrierung für Entwickler",
|
||||
"homeTitle": "Agent Teams - KI-Agenten-Orchestrierung für Entwickler",
|
||||
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Download Now",
|
||||
"ctaPrimary": "Download for {platform}",
|
||||
"ctaSecondary": "Compare",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Got questions? We've got answers",
|
||||
"subtitle": "Everything you need to know about Claude Agent Teams"
|
||||
"subtitle": "Everything you need to know about Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "How we compare",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Learn more"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "AI agent orchestration for developers",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — AI Agent Orchestration for Developers",
|
||||
"homeTitle": "Agent Teams - AI Agent Orchestration for Developers",
|
||||
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Descargar ahora",
|
||||
"ctaPrimary": "Descargar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "¿Tienes preguntas? Tenemos respuestas",
|
||||
"subtitle": "Todo lo que necesitas saber sobre Claude Agent Teams"
|
||||
"subtitle": "Todo lo que necesitas saber sobre Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Cómo nos comparamos",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Más información"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestación de agentes IA para desarrolladores",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — Orquestación de agentes IA para desarrolladores",
|
||||
"homeTitle": "Agent Teams - Orquestación de agentes IA para desarrolladores",
|
||||
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Télécharger",
|
||||
"ctaPrimary": "Télécharger pour {platform}",
|
||||
"ctaSecondary": "Comparer",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Des questions ? Nous avons les réponses",
|
||||
"subtitle": "Tout ce qu'il faut savoir sur Claude Agent Teams"
|
||||
"subtitle": "Tout ce qu'il faut savoir sur Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Comment nous nous comparons",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orchestration d'agents IA pour développeurs",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — Orchestration d'agents IA pour développeurs",
|
||||
"homeTitle": "Agent Teams - Orchestration d'agents IA pour développeurs",
|
||||
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "अभी डाउनलोड करें",
|
||||
"ctaPrimary": "{platform} के लिए डाउनलोड करें",
|
||||
"ctaSecondary": "तुलना करें",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "सवाल हैं? हमारे पास जवाब हैं",
|
||||
"subtitle": "Claude Agent Teams के बारे में सब कुछ"
|
||||
"subtitle": "Agent Teams के बारे में सब कुछ"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "तुलना करें",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "और जानें"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "ダウンロード",
|
||||
"ctaPrimary": "{platform}版をダウンロード",
|
||||
"ctaSecondary": "比較する",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "よくある質問",
|
||||
"subtitle": "Claude Agent Teamsについて知っておくべきこと"
|
||||
"subtitle": "Agent Teamsについて知っておくべきこと"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "他ツールとの比較",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "詳細"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "開発者向けAIエージェントオーケストレーション",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — 開発者向けAIエージェントオーケストレーション",
|
||||
"homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション",
|
||||
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Baixar agora",
|
||||
"ctaPrimary": "Baixar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Tem perguntas? Temos respostas",
|
||||
"subtitle": "Tudo sobre o Claude Agent Teams"
|
||||
"subtitle": "Tudo sobre o Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Como nos comparamos",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Saiba mais"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestração de agentes IA para desenvolvedores",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — Orquestração de agentes IA para desenvolvedores",
|
||||
"homeTitle": "Agent Teams - Orquestração de agentes IA para desenvolvedores",
|
||||
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Скачать",
|
||||
"ctaPrimary": "Скачать для {platform}",
|
||||
"ctaSecondary": "Сравнить",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Есть вопросы? У нас есть ответы",
|
||||
"subtitle": "Всё, что нужно знать о Claude Agent Teams"
|
||||
"subtitle": "Всё, что нужно знать о Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Сравнение с конкурентами",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Подробнее"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Оркестрация ИИ-агентов для разработчиков",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — Оркестрация ИИ-агентов для разработчиков",
|
||||
"homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков",
|
||||
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "立即下载",
|
||||
"ctaPrimary": "下载 {platform} 版",
|
||||
"ctaSecondary": "对比",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "有问题?我们有答案",
|
||||
"subtitle": "关于 Claude Agent Teams 的一切"
|
||||
"subtitle": "关于 Agent Teams 的一切"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "功能对比",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "了解更多"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "面向开发者的 AI 智能体编排",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — 面向开发者的 AI 智能体编排",
|
||||
"homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排",
|
||||
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const process: any;
|
||||
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev";
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
|
||||
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
|
|
@ -88,7 +88,7 @@ export default defineNuxtConfig({
|
|||
// @ts-expect-error - field provided by nuxt modules
|
||||
site: {
|
||||
url: siteUrl,
|
||||
name: "Claude Agent Teams"
|
||||
name: "Agent Teams"
|
||||
},
|
||||
runtimeConfig: {
|
||||
github: {
|
||||
|
|
|
|||
4
landing/package-lock.json
generated
4
landing/package-lock.json
generated
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://claude-agent-teams.dev";
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
|
||||
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
|
|||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://claude-agent-teams.dev";
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
|
||||
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "agent-teams-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for managing Claude Agent Teams through the agent-teams-controller API",
|
||||
"description": "MCP server for managing Agent Teams through the agent-teams-controller API",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
|
|
|||
7
mcp-server/src/agent-teams-controller.d.ts
vendored
7
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -27,7 +27,10 @@ declare module 'agent-teams-controller' {
|
|||
setNeedsClarification(taskId: string, value: string | null): unknown;
|
||||
linkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(memberName: string): Promise<string>;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
}
|
||||
|
|
@ -52,7 +55,7 @@ declare module 'agent-teams-controller' {
|
|||
export interface ControllerMessageApi {
|
||||
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||
sendMessage(flags: Record<string, unknown>): unknown;
|
||||
lookupMessage(messageId: string): { message: Record<string, unknown> };
|
||||
lookupMessage(messageId: string): { message: Record<string, unknown>; store: string };
|
||||
}
|
||||
|
||||
export interface ControllerProcessApi {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,20 @@ const controllerModule =
|
|||
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
|
||||
const { createController } = controllerModule;
|
||||
|
||||
const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||
|
||||
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
|
||||
export const agentBlocks = controllerModule.agentBlocks;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
|
||||
let resolvedClaudeDir = claudeDir;
|
||||
if (forcedClaudeDir) {
|
||||
resolvedClaudeDir = forcedClaudeDir;
|
||||
}
|
||||
|
||||
return createController({
|
||||
teamName,
|
||||
...(claudeDir ? { claudeDir } : {}),
|
||||
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,19 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
const taskRefSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
displayId: z.string().min(1),
|
||||
teamName: z.string().min(1),
|
||||
});
|
||||
|
||||
export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'cross_team_send',
|
||||
|
|
@ -22,6 +29,7 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
summary: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
replyToConversationId: z.string().optional(),
|
||||
taskRefs: z.array(taskRefSchema).optional(),
|
||||
chainDepth: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
|
|
@ -33,9 +41,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
summary,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
taskRefs,
|
||||
chainDepth,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
toTeam,
|
||||
|
|
@ -44,10 +54,12 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(summary ? { summary } : {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
...(replyToConversationId ? { replyToConversationId } : {}),
|
||||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -57,14 +69,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
excludeTeam: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||
...(excludeTeam ? { excludeTeam } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -73,9 +87,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -16,33 +17,41 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_set_column',
|
||||
description: 'Move task to review or approved column',
|
||||
description:
|
||||
'Repair the kanban overlay for a task that is already in review or approved. Use review_request/review_approve for lifecycle transitions.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
column: z.enum(['review', 'approved']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_clear',
|
||||
description: 'Remove task from kanban board',
|
||||
description:
|
||||
'Repair-clear a stale non-review kanban overlay. Use review_request_changes, review_approve, task_start, or task_set_status for lifecycle transitions.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -51,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -62,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -73,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
|
|
@ -12,7 +13,8 @@ const toolContextSchema = {
|
|||
export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'message_send',
|
||||
description: 'Send a message into team inbox',
|
||||
description:
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
to: z.string().min(1),
|
||||
|
|
@ -31,6 +33,15 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
})
|
||||
)
|
||||
.optional(),
|
||||
taskRefs: z
|
||||
.array(
|
||||
z.object({
|
||||
taskId: z.string().min(1),
|
||||
displayId: z.string().min(1),
|
||||
teamName: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
|
|
@ -42,19 +53,23 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
source,
|
||||
leadSessionId,
|
||||
attachments,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
taskRefs,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
port,
|
||||
url,
|
||||
claudeProcessId,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
pid,
|
||||
|
|
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent, slimTask } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
|
|
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -63,19 +68,21 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
notifyOwner: z.boolean().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
...(notifyOwner === true ? { 'notify-owner': true } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
comment: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
|
|
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
worktree,
|
||||
extraCliArgs,
|
||||
waitForReady,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||
cwd,
|
||||
...(prompt ? { prompt } : {}),
|
||||
|
|
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForReady !== undefined ? { waitForReady } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
waitForStop: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
|
||||
jsonTextContent(
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.stopTeam({
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForStop !== undefined ? { waitForStop } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
diagnostics,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -124,12 +130,14 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'runtime_deliver_message',
|
||||
description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination',
|
||||
description:
|
||||
'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
idempotencyKey: z.string().min(1),
|
||||
|
|
@ -156,8 +164,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
taskRefs,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -171,7 +180,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -203,8 +213,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -218,7 +229,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -241,8 +253,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
status,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -253,6 +266,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||
|
||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||
|
|
@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
commentId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
|
||||
)
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
relatedTo,
|
||||
blockedBy,
|
||||
limit,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||
...(owner ? { owner } : {}),
|
||||
|
|
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
limit: normalizeTaskListLimit(limit),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_restore',
|
||||
description: 'Restore a deleted task back to pending work state',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
value: z.enum(['lead', 'user', 'clear']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.linkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -564,15 +620,21 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -582,13 +644,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
40
mcp-server/src/utils/teamConfig.ts
Normal file
40
mcp-server/src/utils/teamConfig.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getController } from '../controller';
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => {
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const leadBriefing = (await client.callTool(
|
||||
'lead_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
40
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(leadBriefing.result?.isError).toBe(true);
|
||||
expect(leadBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskBriefing = (await client.callTool(
|
||||
'task_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
memberName: 'alice',
|
||||
},
|
||||
41
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskBriefing.result?.isError).toBe(true);
|
||||
expect(taskBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskList = (await client.callTool(
|
||||
'task_list',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
42
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskList.result?.isError).toBe(true);
|
||||
expect(taskList.result?.content?.[0]?.text).toContain(expected);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
||||
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
||||
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
||||
|
|
@ -996,7 +1042,6 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
claudeDir,
|
||||
teamName: 'review-roundtrip-team',
|
||||
taskId: roundtripTask.id,
|
||||
from: 'bob',
|
||||
},
|
||||
39
|
||||
);
|
||||
|
|
@ -1563,7 +1608,6 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
claudeDir,
|
||||
teamName: 'terminal-routing-team',
|
||||
taskId: approvedTask.id,
|
||||
from: 'bob',
|
||||
},
|
||||
87
|
||||
);
|
||||
|
|
@ -1779,4 +1823,172 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('guards review lifecycle bypasses and deleted resurrection over stdio', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-hardening-team');
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const createResult = await client.callTool(
|
||||
'task_create',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
subject: 'Lifecycle guard task',
|
||||
owner: 'alice',
|
||||
},
|
||||
101
|
||||
);
|
||||
const task = parseJsonToolResult((createResult as { result: unknown }).result);
|
||||
|
||||
await client.callTool(
|
||||
'task_complete',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'alice',
|
||||
},
|
||||
102
|
||||
);
|
||||
await client.callTool(
|
||||
'review_request',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
from: 'team-lead',
|
||||
reviewer: 'bob',
|
||||
},
|
||||
103
|
||||
);
|
||||
await client.callTool(
|
||||
'review_approve',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
from: 'bob',
|
||||
},
|
||||
104
|
||||
);
|
||||
|
||||
const clearResult = await client.callTool(
|
||||
'kanban_clear',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
},
|
||||
105
|
||||
);
|
||||
const clearResponse = clearResult as {
|
||||
error?: { message?: string };
|
||||
result?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const clearErrorText =
|
||||
clearResponse.error?.message ?? (clearResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(clearErrorText).toContain('reviewState=approved');
|
||||
|
||||
const reopenedResult = await client.callTool(
|
||||
'task_set_status',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
status: 'pending',
|
||||
actor: 'team-lead',
|
||||
},
|
||||
106
|
||||
);
|
||||
const reopened = parseJsonToolResult((reopenedResult as { result: unknown }).result);
|
||||
expect(reopened.status).toBe('pending');
|
||||
expect(reopened.reviewState).toBe('none');
|
||||
|
||||
const inventoryResult = await client.callTool(
|
||||
'task_list',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
owner: 'alice',
|
||||
},
|
||||
107
|
||||
);
|
||||
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
|
||||
expect(inventoryRows[0]).toMatchObject({
|
||||
id: task.id,
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
});
|
||||
|
||||
const deleteResult = await client.callTool(
|
||||
'task_set_status',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
status: 'deleted',
|
||||
actor: 'team-lead',
|
||||
},
|
||||
108
|
||||
);
|
||||
const deleted = parseJsonToolResult((deleteResult as { result: unknown }).result);
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.reviewState).toBe('none');
|
||||
|
||||
const startDeletedResult = await client.callTool(
|
||||
'task_start',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'alice',
|
||||
},
|
||||
109
|
||||
);
|
||||
const startDeletedResponse = startDeletedResult as {
|
||||
error?: { message?: string };
|
||||
result?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const startDeletedErrorText =
|
||||
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
||||
|
||||
const restoreResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
110
|
||||
);
|
||||
const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
const restoreAgainResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
111
|
||||
);
|
||||
const restoreAgainResponse = restoreAgainResult as {
|
||||
error?: { message?: string };
|
||||
result?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const restoreAgainErrorText =
|
||||
restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
res.end(JSON.stringify(result.body));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
||||
res.end(
|
||||
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -119,12 +121,17 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'Reply',
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'alpha' }],
|
||||
});
|
||||
|
||||
expect(parsed?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('launches and stops teams through the runtime MCP tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -171,6 +178,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
try {
|
||||
const launched = parseJsonToolResult(
|
||||
await getTool('team_launch').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
cwd: '/tmp/project',
|
||||
controlUrl: server.baseUrl,
|
||||
|
|
@ -182,6 +190,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
const stopped = parseJsonToolResult(
|
||||
await getTool('team_stop').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
})
|
||||
|
|
@ -216,6 +225,13 @@ describe('agent-teams-mcp tools', () => {
|
|||
});
|
||||
|
||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -224,6 +240,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
try {
|
||||
await getTool('runtime_bootstrap_checkin').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -231,6 +248,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
runtimeSessionId: 'ses-1',
|
||||
});
|
||||
await getTool('runtime_deliver_message').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-1',
|
||||
|
|
@ -241,6 +259,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'hello',
|
||||
});
|
||||
await getTool('runtime_task_event').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-task-1',
|
||||
|
|
@ -251,6 +270,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
event: 'started',
|
||||
});
|
||||
await getTool('runtime_heartbeat').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -280,6 +300,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
it('discovers the control endpoint from the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||
|
||||
const server = await startControlServer(async ({ method, url }) => {
|
||||
|
|
@ -555,6 +578,25 @@ describe('agent-teams-mcp tools', () => {
|
|||
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||
);
|
||||
expect(memberBriefingText).toContain('Review MCP adapter');
|
||||
expect(memberBriefingText).toContain('Full details in task comment e5f6a7b8');
|
||||
expect(memberBriefingText).not.toContain('task_get_comment {');
|
||||
|
||||
const openCodeMemberBriefing = await getTool('member_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
runtimeProvider: 'opencode',
|
||||
});
|
||||
const openCodeMemberBriefingText = (
|
||||
openCodeMemberBriefing as { content: Array<{ text: string }> }
|
||||
).content[0]?.text;
|
||||
expect(openCodeMemberBriefingText).toContain('agent-teams_message_send');
|
||||
expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8');
|
||||
expect(openCodeMemberBriefingText).toContain(
|
||||
'Never invent placeholder task refs such as #00000000'
|
||||
);
|
||||
expect(openCodeMemberBriefingText).not.toContain('task_get_comment {');
|
||||
expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
|
||||
|
|
@ -648,12 +690,16 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||
);
|
||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[3].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||
|
||||
const briefing = (await getTool('task_briefing').execute({
|
||||
|
|
@ -695,14 +741,22 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(memberBriefingText).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
);
|
||||
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
||||
expect(memberBriefingText).toContain(
|
||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
|
||||
);
|
||||
expect(memberBriefingText).toContain('Task briefing for alice:');
|
||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||
|
||||
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
|
||||
'[]'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
|
||||
'[]'
|
||||
);
|
||||
|
||||
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
||||
claudeDir,
|
||||
|
|
@ -710,7 +764,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
memberName: 'carol',
|
||||
})) as { content: Array<{ text: string }> };
|
||||
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
||||
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
|
||||
expect(inboxResolvedBriefingText).toContain(
|
||||
'Member briefing for carol on team "gamma" (gamma).'
|
||||
);
|
||||
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
||||
|
||||
await expect(
|
||||
|
|
@ -897,9 +953,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
||||
'needsFix'
|
||||
);
|
||||
expect(
|
||||
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
|
||||
).toBe('needsFix');
|
||||
|
||||
const kanbanCleared = parseJsonToolResult(
|
||||
await getTool('kanban_clear').execute({
|
||||
|
|
@ -910,7 +966,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
|
||||
|
||||
// review_start: moves task to review without requiring completed status
|
||||
// review_start: cannot move pending/non-review work into review by itself
|
||||
const pendingTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
|
|
@ -919,17 +975,14 @@ describe('agent-teams-mcp tools', () => {
|
|||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
const reviewStarted = parseJsonToolResult(
|
||||
await getTool('review_start').execute({
|
||||
await expect(
|
||||
getTool('review_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: pendingTask.id,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(reviewStarted.ok).toBe(true);
|
||||
expect(reviewStarted.column).toBe('review');
|
||||
expect(reviewStarted.taskId).toBe(pendingTask.id);
|
||||
).rejects.toThrow('must be completed before starting review');
|
||||
|
||||
const pid = process.pid;
|
||||
|
||||
|
|
@ -979,9 +1032,188 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(unregistered).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects public lifecycle bypasses through kanban_set_column and task_set_status', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'public-bypass-guards';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const pendingTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Cannot approve directly',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
getTool('kanban_set_column').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: pendingTask.id,
|
||||
column: 'approved',
|
||||
})
|
||||
).rejects.toThrow('must be completed before moving to APPROVED column');
|
||||
|
||||
const reviewTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Delete cleanup through generic status',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'bob',
|
||||
});
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
});
|
||||
const deleted = parseJsonToolResult(
|
||||
await getTool('task_set_status').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
status: 'deleted',
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.reviewState).toBe('none');
|
||||
const kanbanState = parseJsonToolResult(
|
||||
await getTool('kanban_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
||||
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
||||
|
||||
const restored = parseJsonToolResult(
|
||||
await getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
await expect(
|
||||
getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
).rejects.toThrow('task_restore only restores deleted tasks');
|
||||
});
|
||||
|
||||
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'review-approval-notify';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
|
||||
const readOwnerInbox = () =>
|
||||
fs.existsSync(ownerInboxPath) ? JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')) : [];
|
||||
|
||||
const quietTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Quiet approval',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: quietTask.id,
|
||||
actor: 'bob',
|
||||
});
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: quietTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
});
|
||||
const beforeQuietApprove = readOwnerInbox().length;
|
||||
parseJsonToolResult(
|
||||
await getTool('review_approve').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: quietTask.id,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(readOwnerInbox()).toHaveLength(beforeQuietApprove);
|
||||
|
||||
const notifyingTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Notifying approval',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: notifyingTask.id,
|
||||
actor: 'bob',
|
||||
});
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: notifyingTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
});
|
||||
const beforeNotifyingApprove = readOwnerInbox().length;
|
||||
parseJsonToolResult(
|
||||
await getTool('review_approve').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: notifyingTask.id,
|
||||
from: 'alice',
|
||||
notifyOwner: true,
|
||||
})
|
||||
);
|
||||
const afterNotifyingApprove = readOwnerInbox();
|
||||
expect(afterNotifyingApprove).toHaveLength(beforeNotifyingApprove + 1);
|
||||
expect(afterNotifyingApprove.at(-1).text).toContain('approved');
|
||||
});
|
||||
|
||||
it('persists full message metadata through message_send', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'gamma';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const sent = parseJsonToolResult(
|
||||
await getTool('message_send').execute({
|
||||
|
|
@ -994,6 +1226,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
source: 'system_notification',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }],
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -1003,6 +1236,84 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]);
|
||||
});
|
||||
|
||||
it('uses forced app claude dir over model-supplied claudeDir when configured', async () => {
|
||||
const forcedClaudeDir = makeClaudeDir();
|
||||
const wrongClaudeDir = makeClaudeDir();
|
||||
const teamName = 'forced-root';
|
||||
writeTeamConfig(forcedClaudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const previousForcedDir = process.env.AGENT_TEAMS_MCP_CLAUDE_DIR;
|
||||
process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = forcedClaudeDir;
|
||||
try {
|
||||
const sent = parseJsonToolResult(
|
||||
await getTool('message_send').execute({
|
||||
claudeDir: wrongClaudeDir,
|
||||
teamName,
|
||||
to: 'user',
|
||||
text: 'Forced root reply',
|
||||
from: 'bob',
|
||||
})
|
||||
);
|
||||
|
||||
expect(sent.deliveredToInbox).toBe(true);
|
||||
const forcedInboxPath = path.join(forcedClaudeDir, 'teams', teamName, 'inboxes', 'user.json');
|
||||
const wrongInboxPath = path.join(wrongClaudeDir, 'teams', teamName, 'inboxes', 'user.json');
|
||||
expect(JSON.parse(fs.readFileSync(forcedInboxPath, 'utf8'))[0]).toMatchObject({
|
||||
from: 'bob',
|
||||
to: 'user',
|
||||
text: 'Forced root reply',
|
||||
});
|
||||
expect(fs.existsSync(wrongInboxPath)).toBe(false);
|
||||
} finally {
|
||||
if (previousForcedDir === undefined) {
|
||||
delete process.env.AGENT_TEAMS_MCP_CLAUDE_DIR;
|
||||
} else {
|
||||
process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = previousForcedDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-configured teams before MCP side-effect writes', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'real-team', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('message_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
to: 'alice',
|
||||
text: 'Should not create inbox',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('process_register').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
pid: process.pid,
|
||||
label: 'watcher',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('cross_team_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
toTeam: 'real-team',
|
||||
text: 'Should not deliver',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
|
|
@ -1153,9 +1464,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(completed.comments).toBeUndefined();
|
||||
|
||||
// task_list: explicit inventory shape only
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('task_list').execute({ claudeDir, teamName })
|
||||
);
|
||||
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
|
||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||
expect(listedTask).toBeDefined();
|
||||
expect(listedTask).toEqual({
|
||||
|
|
@ -1195,9 +1504,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
const existing = fs.existsSync(sentPath)
|
||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
||||
: [];
|
||||
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
|
||||
existing.push(message);
|
||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||
}
|
||||
|
|
@ -1543,9 +1850,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'Roundtrip test message',
|
||||
timestamp: '2026-03-15T16:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
attachments: [
|
||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
||||
],
|
||||
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
|
|
@ -1663,4 +1968,20 @@ describe('agent-teams-mcp tools', () => {
|
|||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const params = { claudeDir, teamName: 'team-lead' };
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
|
||||
await expect(
|
||||
getTool('task_briefing').execute({
|
||||
...params,
|
||||
memberName: 'alice',
|
||||
})
|
||||
).rejects.toThrow(expected);
|
||||
await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
17
package.json
17
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "claude-agent-teams-ui",
|
||||
"type": "module",
|
||||
"version": "1.3.0",
|
||||
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Илия (777genius)",
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||
|
|
@ -43,8 +42,11 @@
|
|||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
|
||||
"test:workspace:ci": "pnpm test:ci && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
|
||||
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
|
||||
"check:workspace:ci": "pnpm typecheck:workspace && pnpm test:workspace:ci && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
|
||||
"check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
|
||||
"check:ci": "pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
"test:chunks": "tsx test/test-chunk-building.ts",
|
||||
|
|
@ -52,6 +54,7 @@
|
|||
"test:noise": "tsx test/test-noise-filtering.ts",
|
||||
"test:task-filtering": "tsx test/test-task-filtering.ts",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run --maxWorkers 1 --minWorkers 1",
|
||||
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
|
@ -259,6 +262,7 @@
|
|||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
"mac": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"target": [
|
||||
|
|
@ -273,7 +277,8 @@
|
|||
"icon": "resources/icons/mac/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
"sign": false,
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
|
|
@ -291,10 +296,14 @@
|
|||
"icon": "resources/icons/png",
|
||||
"category": "Development"
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
|
|
@ -302,6 +311,8 @@
|
|||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "777genius",
|
||||
"repo": "claude_agent_teams_ui",
|
||||
"releaseType": "release"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -279,7 +279,13 @@ function drawLaunchStage(
|
|||
for (let index = 0; index < 3; index += 1) {
|
||||
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
|
||||
ctx.arc(
|
||||
x + Math.cos(angle) * dotOrbit,
|
||||
y + Math.sin(angle) * dotOrbit,
|
||||
1.7,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
|
||||
ctx.fill();
|
||||
}
|
||||
|
|
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
|
|||
return hexWithAlpha('#f59e0b', 0.92);
|
||||
case 'runtime_pending':
|
||||
return hexWithAlpha('#67e8f9', 0.9);
|
||||
case 'shell_only':
|
||||
case 'runtime_candidate':
|
||||
return hexWithAlpha('#f97316', 0.9);
|
||||
case 'registered_only':
|
||||
return hexWithAlpha('#a1a1aa', 0.82);
|
||||
case 'stale_runtime':
|
||||
return hexWithAlpha('#ef4444', 0.82);
|
||||
case 'settling':
|
||||
return hexWithAlpha('#22c55e', 0.9);
|
||||
case 'error':
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ export type GraphLaunchVisualState =
|
|||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'shell_only'
|
||||
| 'runtime_candidate'
|
||||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'skipped';
|
||||
|
||||
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -82,7 +87,7 @@ export interface GraphNode {
|
|||
/** Avatar image URL (e.g., robohash) */
|
||||
avatarUrl?: string;
|
||||
/** Spawn lifecycle status */
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
|
||||
/** Shared launch-stage visual derived by the host app */
|
||||
launchVisualState?: GraphLaunchVisualState;
|
||||
/** Shared launch-stage text shown beside the node during launch only */
|
||||
|
|
|
|||
|
|
@ -312,7 +312,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"global.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -340,7 +341,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"us.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -368,7 +370,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"eu.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -396,7 +399,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"au.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -424,7 +428,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -452,7 +457,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-mythos-preview": {
|
||||
"input_cost_per_token": 0,
|
||||
|
|
@ -494,7 +501,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"us.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -522,7 +531,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"eu.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -550,7 +561,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"au.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -578,7 +591,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -605,7 +620,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -632,7 +648,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -659,7 +676,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"eu.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -686,7 +704,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -713,7 +732,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -1014,7 +1034,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"azure_ai/claude-opus-4-7": {
|
||||
"input_cost_per_token": 0.000005,
|
||||
|
|
@ -1042,7 +1063,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 159
|
||||
"tool_use_system_prompt_tokens": 159,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"azure_ai/claude-opus-4-1": {
|
||||
"cache_creation_input_token_cost": 0.00001875,
|
||||
|
|
@ -1106,7 +1129,8 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"bedrock/ap-northeast-1/anthropic.claude-instant-v1": {
|
||||
"input_cost_per_token": 0.00000223,
|
||||
|
|
@ -1682,7 +1706,8 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-sonnet-4-5-20250929-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -1876,7 +1901,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-6-20260205": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1908,7 +1934,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1940,7 +1967,9 @@
|
|||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-7-20260416": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1972,7 +2001,9 @@
|
|||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
|
|
@ -3233,7 +3264,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159
|
||||
"tool_use_system_prompt_tokens": 159,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"openrouter/anthropic/claude-opus-4.5": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -3271,7 +3303,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.5": {
|
||||
"input_cost_per_image": 0.0048,
|
||||
|
|
@ -4051,7 +4084,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vercel_ai_gateway/anthropic/claude-sonnet-4": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4425,7 +4459,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-opus-4-6@default": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4452,7 +4487,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4479,7 +4515,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-opus-4-7@default": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4506,7 +4544,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-sonnet-4-5": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4558,7 +4598,8 @@
|
|||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
}
|
||||
},
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-sonnet-4-5@20250929": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4697,7 +4738,8 @@
|
|||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
}
|
||||
},
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.0000015,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.6",
|
||||
"sourceRef": "v0.0.6",
|
||||
"version": "0.0.8",
|
||||
"sourceRef": "v0.0.8",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.6.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.8.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.6.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.8.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.6.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.8.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.6.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.8.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
|||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
function shouldUseWindowsShell(cmd) {
|
||||
|
|
@ -108,9 +109,10 @@ function getPlatformAssetKey() {
|
|||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
const releaseTag =
|
||||
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
|
|||
|
||||
function buildProgressBar(progressRatio, width) {
|
||||
const safeWidth = Math.max(10, width);
|
||||
const clampedRatio = Number.isFinite(progressRatio)
|
||||
? Math.min(1, Math.max(0, progressRatio))
|
||||
: 0;
|
||||
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
|
||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||
}
|
||||
|
|
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
|
|||
}
|
||||
|
||||
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
|
||||
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const columns =
|
||||
process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
|
||||
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||
const bytesText = hasTotal
|
||||
|
|
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
|
|||
return runAndCapture(binaryPath, ['--version']);
|
||||
}
|
||||
|
||||
function formatRuntimeVersionForDisplay(versionText) {
|
||||
const trimmed = versionText.trim();
|
||||
if (!trimmed) {
|
||||
return runtimeDisplayName;
|
||||
}
|
||||
|
||||
const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
|
||||
return `${versionOnly} (${runtimeDisplayName})`;
|
||||
}
|
||||
|
||||
function isExecutable(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
|
|
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
|
|||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
|
||||
} else if (
|
||||
(hasTotal && lastLoggedPercent < 100) ||
|
||||
(!hasTotal && writtenBytes !== lastLoggedBytes)
|
||||
) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +525,9 @@ async function main() {
|
|||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
|
||||
}
|
||||
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
|
||||
process.stdout.write(
|
||||
`Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
|
||||
);
|
||||
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const defaultEvidencePath = path.join(
|
||||
resolveAppDataDir(),
|
||||
'claude-agent-teams-ui',
|
||||
'opencode-bridge',
|
||||
'production-e2e-evidence.json'
|
||||
);
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_E2E_WRITE_APP_EVIDENCE: '1',
|
||||
OPENCODE_E2E_WRITE_EVIDENCE_PATH:
|
||||
process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() ||
|
||||
process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() ||
|
||||
defaultEvidencePath,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode production proof');
|
||||
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||
console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode production proof: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function resolveAppDataDir() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
}
|
||||
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
||||
export type {
|
||||
AnthropicFastModeResolution,
|
||||
AnthropicRuntimeProfileSource,
|
||||
AnthropicRuntimeReconciliation,
|
||||
AnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
||||
export type {
|
||||
AnthropicFastModeResolution,
|
||||
AnthropicRuntimeProfileSource,
|
||||
AnthropicRuntimeReconciliation,
|
||||
AnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
|
||||
import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder';
|
||||
import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback';
|
||||
import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel';
|
||||
|
|
@ -15,6 +13,8 @@ import {
|
|||
import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient';
|
||||
import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
|
||||
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
|
||||
import type { Logger } from '@shared/utils/logger';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
|
||||
describe('CodexModelCatalogAppServerClient', () => {
|
||||
it('reads config and paginated model/list in one app-server session', async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const requests: { method: string; params: unknown }[] = [];
|
||||
let sessionCount = 0;
|
||||
const session: CodexAppServerSession = {
|
||||
initializeResponse: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
export {
|
||||
buildCodexFastModeArgs,
|
||||
CODEX_FAST_CREDIT_COST_MULTIPLIER,
|
||||
|
|
@ -7,11 +14,3 @@ export {
|
|||
resolveCodexFastMode,
|
||||
resolveCodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
||||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
export {
|
||||
buildCodexFastModeArgs,
|
||||
CODEX_FAST_CREDIT_COST_MULTIPLIER,
|
||||
|
|
@ -7,11 +14,3 @@ export {
|
|||
resolveCodexFastMode,
|
||||
resolveCodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
||||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
} from '../ports/RecentProjectsSourcePort';
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 10_000;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface SourceLoadResult {
|
||||
candidates: RecentProjectCandidate[];
|
||||
|
|
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
|||
}
|
||||
|
||||
const viewModel = this.deps.output.present(response);
|
||||
const cacheTtlMs = hasDegradedSources
|
||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||
: this.#cacheTtlMs;
|
||||
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
|
||||
|
||||
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||
this.deps.logger.info('recent-projects loaded', {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,26 @@ import type {
|
|||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
const CODEX_THREAD_LIMIT = 40;
|
||||
const CODEX_THREAD_LIMIT = 20;
|
||||
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
CODEX_INITIALIZE_TIMEOUT_MS +
|
||||
CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS +
|
||||
CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
|
||||
const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
interface StaleCodexCandidatesSnapshot {
|
||||
candidates: RecentProjectCandidate[];
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
|
|
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
|||
return Boolean(result.live.error || result.archived.error);
|
||||
}
|
||||
|
||||
function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
|
||||
if (!result.live.error || !result.archived.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.live.error === result.archived.error) {
|
||||
return result.live.error;
|
||||
}
|
||||
|
||||
return `live: ${result.live.error}; archived: ${result.archived.error}`;
|
||||
}
|
||||
|
||||
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||
readonly sourceId = 'codex';
|
||||
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
|
||||
#fullFailureCooldownUntil = 0;
|
||||
#fullFailureCooldownReason: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
|
|
@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
const cooldown = this.#getActiveCooldown();
|
||||
if (cooldown) {
|
||||
this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
|
||||
return {
|
||||
candidates: this.#getFreshStaleCandidates() ?? [],
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||
const degraded = isDegradedThreadResult(threadSegments);
|
||||
const fullFailureReason = getFullFailureReason(threadSegments);
|
||||
this.#updateFullFailureCooldown(fullFailureReason);
|
||||
this.#logSegmentFailure(threadSegments, 'live');
|
||||
this.#logSegmentFailure(threadSegments, 'archived');
|
||||
const liveThreads = threadSegments.live.threads;
|
||||
|
|
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||
|
||||
if (!degraded) {
|
||||
this.#rememberHealthyCandidates(candidates);
|
||||
}
|
||||
|
||||
if (degraded && candidates.length === 0) {
|
||||
const staleCandidates = this.#getFreshStaleCandidates();
|
||||
if (staleCandidates) {
|
||||
this.deps.logger.info('codex recent-projects served stale candidates', {
|
||||
count: staleCandidates.length,
|
||||
reason: fullFailureReason ?? 'degraded-empty-result',
|
||||
});
|
||||
|
||||
return {
|
||||
candidates: staleCandidates,
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.deps.logger.info('codex recent-projects source loaded', {
|
||||
count: candidates.length,
|
||||
degraded,
|
||||
|
|
@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
#getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
|
||||
const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
|
||||
if (retryAfterMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
retryAfterMs,
|
||||
reason: this.#fullFailureCooldownReason,
|
||||
};
|
||||
}
|
||||
|
||||
#updateFullFailureCooldown(reason: string | null): void {
|
||||
if (!reason) {
|
||||
this.#fullFailureCooldownUntil = 0;
|
||||
this.#fullFailureCooldownReason = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
|
||||
this.#fullFailureCooldownReason = reason;
|
||||
}
|
||||
|
||||
#rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
|
||||
this.#staleCandidatesSnapshot =
|
||||
candidates.length > 0
|
||||
? {
|
||||
candidates,
|
||||
capturedAt: Date.now(),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
#getFreshStaleCandidates(): RecentProjectCandidate[] | null {
|
||||
const snapshot = this.#staleCandidatesSnapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
|
||||
this.#staleCandidatesSnapshot = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...snapshot.candidates];
|
||||
}
|
||||
|
||||
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||
limit: CODEX_THREAD_LIMIT,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import {
|
|||
type DashboardRecentProjectsPayload,
|
||||
normalizeDashboardRecentProjectsPayload,
|
||||
} from '@features/recent-projects/contracts';
|
||||
import {
|
||||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
|
||||
import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter';
|
||||
|
|
@ -13,10 +17,6 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent
|
|||
|
||||
import type { ClockPort } from '../../core/application/ports/ClockPort';
|
||||
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||
import {
|
||||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
export interface RecentProjectsFeatureFacade {
|
||||
|
|
|
|||
|
|
@ -74,10 +74,7 @@ export class CodexAppServerClient {
|
|||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
requestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
|
|
@ -122,13 +119,13 @@ export class CodexAppServerClient {
|
|||
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
sessionRequestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
initializeTimeoutMs +
|
||||
liveRequestTimeoutMs +
|
||||
archivedRequestTimeoutMs +
|
||||
MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
return this.#withThreadListSession(
|
||||
|
|
@ -140,50 +137,55 @@ export class CodexAppServerClient {
|
|||
label: 'codex app-server thread/list',
|
||||
},
|
||||
async (session) => {
|
||||
const [live, archived] = await Promise.allSettled([
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
liveRequestTimeoutMs
|
||||
),
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
archivedRequestTimeoutMs
|
||||
),
|
||||
]);
|
||||
const live = await this.#requestThreadListSegment(session, {
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
timeoutMs: liveRequestTimeoutMs,
|
||||
});
|
||||
const archived = await this.#requestThreadListSegment(session, {
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
timeoutMs: archivedRequestTimeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
live:
|
||||
live.status === 'fulfilled'
|
||||
? { threads: live.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
||||
},
|
||||
archived:
|
||||
archived.status === 'fulfilled'
|
||||
? { threads: archived.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error:
|
||||
archived.reason instanceof Error
|
||||
? archived.reason.message
|
||||
: String(archived.reason),
|
||||
},
|
||||
live,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #requestThreadListSegment(
|
||||
session: JsonRpcSession,
|
||||
options: {
|
||||
archived: boolean;
|
||||
limit: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
try {
|
||||
const response = await session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: options.archived,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
options.timeoutMs
|
||||
);
|
||||
|
||||
return {
|
||||
threads: response.data ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
threads: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async #withThreadListSession<T>(
|
||||
options: ThreadListSessionOptions,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
|
|
@ -201,7 +203,7 @@ export class CodexAppServerClient {
|
|||
'initialize',
|
||||
{
|
||||
clientInfo: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
name: 'agent-teams-ai',
|
||||
title: 'Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
|
|||
|
||||
const INITIAL_RECENT_PROJECTS = 11;
|
||||
const LOAD_MORE_STEP = 8;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||
|
||||
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue