diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json
index 3ab34f714..0be00dbfb 100644
--- a/assets/oh-my-opencode.schema.json
+++ b/assets/oh-my-opencode.schema.json
@@ -1211,6 +1211,289 @@
},
"additionalProperties": false
},
+ "athena": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "fallback_models": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "variant": {
+ "type": "string"
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "enum": [
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ },
+ "temperature": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 2
+ },
+ "top_p": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "maxTokens": {
+ "type": "number"
+ },
+ "thinking": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "enabled",
+ "disabled"
+ ]
+ },
+ "budgetTokens": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "variant": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string"
+ },
+ "skills": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "temperature": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 2
+ },
+ "top_p": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "prompt_append": {
+ "type": "string"
+ },
+ "tools": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ },
+ "disable": {
+ "type": "boolean"
+ },
+ "description": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string",
+ "enum": [
+ "subagent",
+ "primary",
+ "all"
+ ]
+ },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{6}$"
+ },
+ "permission": {
+ "type": "object",
+ "properties": {
+ "edit": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ },
+ "bash": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ },
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ }
+ }
+ ]
+ },
+ "webfetch": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ },
+ "task": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ },
+ "doom_loop": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ },
+ "external_directory": {
+ "type": "string",
+ "enum": [
+ "ask",
+ "allow",
+ "deny"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "maxTokens": {
+ "type": "number"
+ },
+ "thinking": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "enabled",
+ "disabled"
+ ]
+ },
+ "budgetTokens": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false
+ },
+ "reasoningEffort": {
+ "type": "string",
+ "enum": [
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ },
+ "textVerbosity": {
+ "type": "string",
+ "enum": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "providerOptions": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ },
+ "ultrawork": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "variant": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "compaction": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "variant": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
"sisyphus-junior": {
"type": "object",
"properties": {
@@ -4044,6 +4327,41 @@
},
"additionalProperties": false
},
+ "athena": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "type": "string",
+ "pattern": "^[^/\\s]+\\/[^/\\s]+$"
+ },
+ "members": {
+ "minItems": 1,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "model": {
+ "type": "string",
+ "pattern": "^[^/\\s]+\\/[^/\\s]+$"
+ }
+ },
+ "required": [
+ "name",
+ "model"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "members"
+ ],
+ "additionalProperties": false
+ },
"categories": {
"type": "object",
"propertyNames": {
diff --git a/src/agents/athena.ts b/src/agents/athena.ts
new file mode 100644
index 000000000..2a8955192
--- /dev/null
+++ b/src/agents/athena.ts
@@ -0,0 +1,16 @@
+import type { AgentConfig } from "@opencode-ai/sdk"
+import type { AgentMode } from "./types"
+import { buildAthenaPrompt, type AthenaPromptOptions } from "./athena/prompt"
+
+const MODE: AgentMode = "primary"
+
+export function createAthenaAgent(model: string, options?: AthenaPromptOptions): AgentConfig {
+ return {
+ description: "Primary council orchestrator for Athena workflows. (Athena - OhMyOpenCode)",
+ mode: MODE,
+ model,
+ temperature: 0.1,
+ prompt: buildAthenaPrompt(options),
+ }
+}
+createAthenaAgent.mode = MODE
diff --git a/src/agents/athena/council-contract.ts b/src/agents/athena/council-contract.ts
new file mode 100644
index 000000000..c9d79505e
--- /dev/null
+++ b/src/agents/athena/council-contract.ts
@@ -0,0 +1,36 @@
+export const COUNCIL_MEMBER_RESPONSE_TAG = "COUNCIL_MEMBER_RESPONSE"
+
+export type CouncilVerdict = "support" | "oppose" | "mixed" | "abstain"
+
+export interface CouncilEvidenceItem {
+ source: string
+ detail: string
+}
+
+export interface CouncilMemberResponse {
+ member: string
+ verdict: CouncilVerdict
+ confidence: number
+ rationale: string
+ risks: string[]
+ evidence: CouncilEvidenceItem[]
+ proposed_actions: string[]
+ missing_information: string[]
+}
+
+export interface AthenaCouncilMember {
+ name: string
+ model: string
+}
+
+export interface ParsedCouncilMemberResponse {
+ ok: true
+ value: CouncilMemberResponse
+ source: "raw_json" | "tagged_json"
+}
+
+export interface CouncilResponseParseFailure {
+ ok: false
+ error: string
+ source: "raw_json" | "tagged_json" | "none"
+}
diff --git a/src/agents/athena/council-members.ts b/src/agents/athena/council-members.ts
new file mode 100644
index 000000000..a43a6033b
--- /dev/null
+++ b/src/agents/athena/council-members.ts
@@ -0,0 +1,24 @@
+import type { AthenaCouncilMember } from "./council-contract"
+
+function slugify(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+}
+
+export function toCouncilMemberAgentName(memberName: string): string {
+ const slug = slugify(memberName)
+ return `council-member-${slug || "member"}`
+}
+
+export function buildCouncilRosterSection(members: AthenaCouncilMember[]): string {
+ if (members.length === 0) {
+ return "- No configured council roster. Use default subagent_type=\"council-member\"."
+ }
+
+ return members
+ .map((member) => `- ${member.name} | model=${member.model} | subagent_type=${toCouncilMemberAgentName(member.name)}`)
+ .join("\n")
+}
diff --git a/src/agents/athena/council-quorum.test.ts b/src/agents/athena/council-quorum.test.ts
new file mode 100644
index 000000000..38a5f38a4
--- /dev/null
+++ b/src/agents/athena/council-quorum.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test"
+import { evaluateCouncilQuorum } from "./council-quorum"
+
+describe("evaluateCouncilQuorum", () => {
+ test("#given partial failures with enough successful members #when evaluating #then quorum reached with graceful degradation", () => {
+ // given
+ const input = {
+ totalMembers: 5,
+ successfulMembers: 3,
+ failedMembers: 2,
+ }
+
+ // when
+ const result = evaluateCouncilQuorum(input)
+
+ // then
+ expect(result.required).toBe(3)
+ expect(result.reached).toBe(true)
+ expect(result.gracefulDegradation).toBe(true)
+ })
+
+ test("#given too many failures #when evaluating #then quorum is unreachable", () => {
+ // given
+ const input = {
+ totalMembers: 4,
+ successfulMembers: 1,
+ failedMembers: 3,
+ }
+
+ // when
+ const result = evaluateCouncilQuorum(input)
+
+ // then
+ expect(result.required).toBe(2)
+ expect(result.reached).toBe(false)
+ expect(result.canStillReach).toBe(false)
+ })
+})
diff --git a/src/agents/athena/council-quorum.ts b/src/agents/athena/council-quorum.ts
new file mode 100644
index 000000000..923505dc9
--- /dev/null
+++ b/src/agents/athena/council-quorum.ts
@@ -0,0 +1,36 @@
+export interface CouncilQuorumInput {
+ totalMembers: number
+ successfulMembers: number
+ failedMembers: number
+ requestedQuorum?: number
+}
+
+export interface CouncilQuorumResult {
+ required: number
+ reached: boolean
+ canStillReach: boolean
+ gracefulDegradation: boolean
+}
+
+function clampMinimumQuorum(totalMembers: number, requestedQuorum?: number): number {
+ if (requestedQuorum && requestedQuorum > 0) {
+ return Math.min(totalMembers, requestedQuorum)
+ }
+
+ return Math.max(1, Math.ceil(totalMembers / 2))
+}
+
+export function evaluateCouncilQuorum(input: CouncilQuorumInput): CouncilQuorumResult {
+ const required = clampMinimumQuorum(input.totalMembers, input.requestedQuorum)
+ const reached = input.successfulMembers >= required
+ const remainingPossible = input.totalMembers - input.failedMembers
+ const canStillReach = remainingPossible >= required
+ const gracefulDegradation = reached && input.failedMembers > 0
+
+ return {
+ required,
+ reached,
+ canStillReach,
+ gracefulDegradation,
+ }
+}
diff --git a/src/agents/athena/council-response-parser.test.ts b/src/agents/athena/council-response-parser.test.ts
new file mode 100644
index 000000000..4cff836c8
--- /dev/null
+++ b/src/agents/athena/council-response-parser.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from "bun:test"
+import { parseCouncilMemberResponse } from "./council-response-parser"
+
+describe("parseCouncilMemberResponse", () => {
+ test("#given valid raw json #when parsing #then returns parsed council payload", () => {
+ // given
+ const raw = JSON.stringify({
+ member: "architect",
+ verdict: "support",
+ confidence: 0.9,
+ rationale: "Matches existing module boundaries",
+ risks: ["Regression in edge-case parser"],
+ evidence: [{ source: "src/agents/athena.ts", detail: "Current prompt is too generic" }],
+ proposed_actions: ["Add strict orchestration workflow"],
+ missing_information: ["Need runtime timeout budget"],
+ })
+
+ // when
+ const result = parseCouncilMemberResponse(raw)
+
+ // then
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.source).toBe("raw_json")
+ expect(result.value.member).toBe("architect")
+ expect(result.value.verdict).toBe("support")
+ })
+
+ test("#given tagged json payload #when parsing #then extracts from COUNCIL_MEMBER_RESPONSE tag", () => {
+ // given
+ const raw = [
+ "analysis intro",
+ "",
+ JSON.stringify({
+ member: "skeptic",
+ verdict: "mixed",
+ confidence: 0.62,
+ rationale: "Quorum logic exists but retry handling is weak",
+ risks: ["Timeout blind spot"],
+ evidence: [{ source: "src/tools/background-task/create-background-wait.ts", detail: "No nudge semantics" }],
+ proposed_actions: ["Add stuck detection policy"],
+ missing_information: [],
+ }),
+ "",
+ ].join("\n")
+
+ // when
+ const result = parseCouncilMemberResponse(raw)
+
+ // then
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.source).toBe("tagged_json")
+ expect(result.value.member).toBe("skeptic")
+ expect(result.value.proposed_actions).toEqual(["Add stuck detection policy"])
+ })
+
+ test("#given malformed payload #when parsing #then returns structured parse failure", () => {
+ // given
+ const raw = "Council says: maybe this works"
+
+ // when
+ const result = parseCouncilMemberResponse(raw)
+
+ // then
+ expect(result.ok).toBe(false)
+ if (result.ok) return
+ expect(result.source).toBe("none")
+ expect(result.error.length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/agents/athena/council-response-parser.ts b/src/agents/athena/council-response-parser.ts
new file mode 100644
index 000000000..14213c813
--- /dev/null
+++ b/src/agents/athena/council-response-parser.ts
@@ -0,0 +1,159 @@
+import {
+ COUNCIL_MEMBER_RESPONSE_TAG,
+ type CouncilMemberResponse,
+ type CouncilResponseParseFailure,
+ type ParsedCouncilMemberResponse,
+} from "./council-contract"
+
+type ParseResult = ParsedCouncilMemberResponse | CouncilResponseParseFailure
+
+function normalizeJsonPayload(input: string): string {
+ const trimmed = input.trim()
+ if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) {
+ return trimmed
+ }
+
+ const firstNewLine = trimmed.indexOf("\n")
+ if (firstNewLine < 0) {
+ return trimmed
+ }
+
+ return trimmed.slice(firstNewLine + 1, -3).trim()
+}
+
+function tryParseJsonObject(input: string): unknown {
+ const normalized = normalizeJsonPayload(input)
+ if (!normalized.startsWith("{")) {
+ return null
+ }
+
+ try {
+ return JSON.parse(normalized)
+ } catch {
+ return null
+ }
+}
+
+function extractTaggedPayload(raw: string): string | null {
+ const xmlLike = new RegExp(
+ `<${COUNCIL_MEMBER_RESPONSE_TAG}>([\\s\\S]*?)<\\/${COUNCIL_MEMBER_RESPONSE_TAG}>`,
+ "i",
+ )
+ const xmlMatch = raw.match(xmlLike)
+ if (xmlMatch?.[1]) {
+ return xmlMatch[1].trim()
+ }
+
+ const prefixed = new RegExp(`${COUNCIL_MEMBER_RESPONSE_TAG}\\s*:\\s*`, "i")
+ const prefixMatch = raw.match(prefixed)
+ if (!prefixMatch) {
+ return null
+ }
+
+ const matchIndex = prefixMatch.index
+ if (matchIndex === undefined) {
+ return null
+ }
+
+ const rest = raw.slice(matchIndex + prefixMatch[0].length)
+ const firstBrace = rest.indexOf("{")
+ if (firstBrace < 0) {
+ return null
+ }
+
+ return rest.slice(firstBrace).trim()
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
+}
+
+function isEvidenceArray(value: unknown): value is CouncilMemberResponse["evidence"] {
+ return Array.isArray(value)
+ && value.every(
+ (item) =>
+ typeof item === "object"
+ && item !== null
+ && typeof (item as { source?: unknown }).source === "string"
+ && typeof (item as { detail?: unknown }).detail === "string",
+ )
+}
+
+function validateCouncilMemberResponse(payload: unknown): CouncilMemberResponse | null {
+ if (typeof payload !== "object" || payload === null) {
+ return null
+ }
+
+ const candidate = payload as Record
+ const verdict = candidate.verdict
+ const confidence = candidate.confidence
+
+ if (
+ typeof candidate.member !== "string"
+ || (verdict !== "support" && verdict !== "oppose" && verdict !== "mixed" && verdict !== "abstain")
+ || typeof confidence !== "number"
+ || confidence < 0
+ || confidence > 1
+ || typeof candidate.rationale !== "string"
+ || !isStringArray(candidate.risks)
+ || !isEvidenceArray(candidate.evidence)
+ || !isStringArray(candidate.proposed_actions)
+ || !isStringArray(candidate.missing_information)
+ ) {
+ return null
+ }
+
+ return {
+ member: candidate.member,
+ verdict,
+ confidence,
+ rationale: candidate.rationale,
+ risks: candidate.risks,
+ evidence: candidate.evidence,
+ proposed_actions: candidate.proposed_actions,
+ missing_information: candidate.missing_information,
+ }
+}
+
+function parseValidated(payload: unknown, source: ParsedCouncilMemberResponse["source"]): ParseResult {
+ const validated = validateCouncilMemberResponse(payload)
+ if (!validated) {
+ return {
+ ok: false,
+ error: "Council member response does not match required contract",
+ source,
+ }
+ }
+
+ return {
+ ok: true,
+ value: validated,
+ source,
+ }
+}
+
+export function parseCouncilMemberResponse(raw: string): ParseResult {
+ const directJson = tryParseJsonObject(raw)
+ if (directJson) {
+ return parseValidated(directJson, "raw_json")
+ }
+
+ const taggedPayload = extractTaggedPayload(raw)
+ if (taggedPayload) {
+ const taggedJson = tryParseJsonObject(taggedPayload)
+ if (taggedJson) {
+ return parseValidated(taggedJson, "tagged_json")
+ }
+ return {
+ ok: false,
+ error: "Tagged council response found, but JSON payload is invalid",
+ source: "tagged_json",
+ }
+ }
+
+ return {
+ ok: false,
+ error: "No parseable council response payload found",
+ source: "none",
+ }
+}
diff --git a/src/agents/athena/council-retry.test.ts b/src/agents/athena/council-retry.test.ts
new file mode 100644
index 000000000..35916634c
--- /dev/null
+++ b/src/agents/athena/council-retry.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test"
+import { decideCouncilRecoveryAction } from "./council-retry"
+
+describe("decideCouncilRecoveryAction", () => {
+ test("#given running member with stale progress and nudge budget #when deciding #then nudge", () => {
+ // given
+ const now = 10_000
+ const decision = decideCouncilRecoveryAction(
+ {
+ status: "running",
+ attempts: 1,
+ nudges: 0,
+ startedAt: 1_000,
+ lastProgressAt: 1_000,
+ },
+ {
+ maxAttempts: 2,
+ maxNudges: 1,
+ stuckAfterMs: 2_000,
+ },
+ now,
+ )
+
+ // then
+ expect(decision.action).toBe("nudge")
+ })
+
+ test("#given stuck member after nudge with retry budget #when deciding #then retry", () => {
+ // given
+ const now = 20_000
+ const decision = decideCouncilRecoveryAction(
+ {
+ status: "running",
+ attempts: 1,
+ nudges: 1,
+ startedAt: 1_000,
+ lastProgressAt: 1_000,
+ },
+ {
+ maxAttempts: 3,
+ maxNudges: 1,
+ stuckAfterMs: 5_000,
+ },
+ now,
+ )
+
+ // then
+ expect(decision.action).toBe("retry")
+ })
+})
diff --git a/src/agents/athena/council-retry.ts b/src/agents/athena/council-retry.ts
new file mode 100644
index 000000000..2a370bc82
--- /dev/null
+++ b/src/agents/athena/council-retry.ts
@@ -0,0 +1,68 @@
+export type CouncilMemberTaskStatus =
+ | "pending"
+ | "running"
+ | "completed"
+ | "failed"
+ | "cancelled"
+ | "timed_out"
+
+export interface CouncilMemberTaskState {
+ status: CouncilMemberTaskStatus
+ attempts: number
+ nudges: number
+ startedAt: number
+ lastProgressAt: number
+}
+
+export interface CouncilRetryPolicy {
+ maxAttempts: number
+ maxNudges: number
+ stuckAfterMs: number
+}
+
+export type CouncilRecoveryAction = "wait" | "nudge" | "retry" | "give_up"
+
+export interface CouncilRecoveryDecision {
+ action: CouncilRecoveryAction
+ reason: string
+}
+
+export function isCouncilMemberStuck(
+ now: number,
+ lastProgressAt: number,
+ stuckAfterMs: number,
+): boolean {
+ return now - lastProgressAt >= stuckAfterMs
+}
+
+export function decideCouncilRecoveryAction(
+ state: CouncilMemberTaskState,
+ policy: CouncilRetryPolicy,
+ now: number,
+): CouncilRecoveryDecision {
+ if (state.status === "completed" || state.status === "cancelled") {
+ return { action: "give_up", reason: "Task already reached terminal status" }
+ }
+
+ if (state.status === "failed" || state.status === "timed_out") {
+ if (state.attempts < policy.maxAttempts) {
+ return { action: "retry", reason: "Terminal failure with retries remaining" }
+ }
+ return { action: "give_up", reason: "Terminal failure and retry budget exhausted" }
+ }
+
+ const stuck = isCouncilMemberStuck(now, state.lastProgressAt, policy.stuckAfterMs)
+ if (!stuck) {
+ return { action: "wait", reason: "Task is still making progress" }
+ }
+
+ if (state.nudges < policy.maxNudges) {
+ return { action: "nudge", reason: "Task appears stuck and nudge budget remains" }
+ }
+
+ if (state.attempts < policy.maxAttempts) {
+ return { action: "retry", reason: "Task stuck after nudges, retrying with fresh run" }
+ }
+
+ return { action: "give_up", reason: "Task stuck and all recovery budgets exhausted" }
+}
diff --git a/src/agents/athena/council-synthesis.test.ts b/src/agents/athena/council-synthesis.test.ts
new file mode 100644
index 000000000..6407de75a
--- /dev/null
+++ b/src/agents/athena/council-synthesis.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { synthesizeCouncilOutcome } from "./council-synthesis"
+import type { CouncilMemberResponse } from "./council-contract"
+
+function response(overrides: Partial): CouncilMemberResponse {
+ return {
+ member: "member-a",
+ verdict: "support",
+ confidence: 0.8,
+ rationale: "default rationale",
+ risks: [],
+ evidence: [{ source: "file.ts", detail: "detail" }],
+ proposed_actions: ["Ship with tests"],
+ missing_information: [],
+ ...overrides,
+ }
+}
+
+describe("synthesizeCouncilOutcome", () => {
+ test("#given majority support with one failure #when synthesizing #then reports agreement and graceful degradation", () => {
+ // given
+ const responses = [
+ response({ member: "architect", verdict: "support", proposed_actions: ["Ship with tests"] }),
+ response({ member: "skeptic", verdict: "support", proposed_actions: ["Ship with tests"] }),
+ response({ member: "critic", verdict: "oppose", risks: ["Parser drift"] }),
+ ]
+
+ // when
+ const result = synthesizeCouncilOutcome({
+ responses,
+ failedMembers: ["perf"],
+ quorumReached: true,
+ })
+
+ // then
+ expect(result.majorityVerdict).toBe("support")
+ expect(result.agreementMembers).toEqual(["architect", "skeptic"])
+ expect(result.disagreementMembers).toContain("critic")
+ expect(result.disagreementMembers).toContain("perf")
+ expect(result.commonActions).toEqual(["Ship with tests"])
+ expect(result.gracefulDegradation).toBe(true)
+ })
+})
diff --git a/src/agents/athena/council-synthesis.ts b/src/agents/athena/council-synthesis.ts
new file mode 100644
index 000000000..c321ca1db
--- /dev/null
+++ b/src/agents/athena/council-synthesis.ts
@@ -0,0 +1,141 @@
+import type { CouncilMemberResponse, CouncilVerdict } from "./council-contract"
+
+export interface CouncilSynthesisInput {
+ responses: CouncilMemberResponse[]
+ failedMembers: string[]
+ quorumReached: boolean
+}
+
+export interface CouncilSynthesisResult {
+ majorityVerdict: CouncilVerdict
+ consensusLevel: "unanimous" | "strong" | "split" | "fragmented"
+ agreementMembers: string[]
+ disagreementMembers: string[]
+ commonActions: string[]
+ contestedRisks: string[]
+ unresolvedQuestions: string[]
+ gracefulDegradation: boolean
+}
+
+function normalizeKey(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+function getMajorityVerdict(responses: CouncilMemberResponse[]): CouncilVerdict {
+ const counts = new Map()
+ for (const response of responses) {
+ counts.set(response.verdict, (counts.get(response.verdict) ?? 0) + 1)
+ }
+
+ const orderedVerdicts: CouncilVerdict[] = ["support", "mixed", "oppose", "abstain"]
+ let winner: CouncilVerdict = "abstain"
+ let winnerCount = -1
+
+ for (const verdict of orderedVerdicts) {
+ const count = counts.get(verdict) ?? 0
+ if (count > winnerCount) {
+ winner = verdict
+ winnerCount = count
+ }
+ }
+
+ return winner
+}
+
+function deriveConsensusLevel(agreementCount: number, totalCount: number): CouncilSynthesisResult["consensusLevel"] {
+ if (totalCount === 0) {
+ return "fragmented"
+ }
+
+ if (agreementCount === totalCount) {
+ return "unanimous"
+ }
+
+ const ratio = agreementCount / totalCount
+ if (ratio >= 0.75) {
+ return "strong"
+ }
+ if (ratio >= 0.5) {
+ return "split"
+ }
+ return "fragmented"
+}
+
+function collectCommonActions(responses: CouncilMemberResponse[]): string[] {
+ const counts = new Map()
+ for (const response of responses) {
+ for (const action of response.proposed_actions) {
+ const key = normalizeKey(action)
+ const existing = counts.get(key)
+ if (!existing) {
+ counts.set(key, { text: action, count: 1 })
+ continue
+ }
+ existing.count += 1
+ }
+ }
+
+ const threshold = Math.max(2, Math.ceil(responses.length / 2))
+ return [...counts.values()]
+ .filter((item) => item.count >= threshold)
+ .map((item) => item.text)
+}
+
+function collectContestedRisks(responses: CouncilMemberResponse[]): string[] {
+ const counts = new Map()
+ for (const response of responses) {
+ for (const risk of response.risks) {
+ const key = normalizeKey(risk)
+ const existing = counts.get(key)
+ if (!existing) {
+ counts.set(key, { text: risk, count: 1 })
+ continue
+ }
+ existing.count += 1
+ }
+ }
+
+ return [...counts.values()]
+ .filter((item) => item.count === 1)
+ .map((item) => item.text)
+}
+
+function collectUnresolvedQuestions(responses: CouncilMemberResponse[]): string[] {
+ const seen = new Set()
+ const questions: string[] = []
+
+ for (const response of responses) {
+ for (const question of response.missing_information) {
+ const key = normalizeKey(question)
+ if (seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ questions.push(question)
+ }
+ }
+
+ return questions
+}
+
+export function synthesizeCouncilOutcome(input: CouncilSynthesisInput): CouncilSynthesisResult {
+ const majorityVerdict = getMajorityVerdict(input.responses)
+ const agreementMembers = input.responses
+ .filter((response) => response.verdict === majorityVerdict)
+ .map((response) => response.member)
+ const disagreementMembers = input.responses
+ .filter((response) => response.verdict !== majorityVerdict)
+ .map((response) => response.member)
+ .concat(input.failedMembers)
+
+ return {
+ majorityVerdict,
+ consensusLevel: deriveConsensusLevel(agreementMembers.length, input.responses.length),
+ agreementMembers,
+ disagreementMembers,
+ commonActions: collectCommonActions(input.responses),
+ contestedRisks: collectContestedRisks(input.responses),
+ unresolvedQuestions: collectUnresolvedQuestions(input.responses),
+ gracefulDegradation: input.quorumReached && input.failedMembers.length > 0,
+ }
+}
diff --git a/src/agents/athena/prompt.ts b/src/agents/athena/prompt.ts
new file mode 100644
index 000000000..3f67a0a20
--- /dev/null
+++ b/src/agents/athena/prompt.ts
@@ -0,0 +1,68 @@
+import type { AthenaCouncilMember } from "./council-contract"
+import { COUNCIL_MEMBER_RESPONSE_TAG } from "./council-contract"
+import { buildCouncilRosterSection } from "./council-members"
+
+export interface AthenaPromptOptions {
+ members?: AthenaCouncilMember[]
+}
+
+export function buildAthenaPrompt(options: AthenaPromptOptions = {}): string {
+ const roster = buildCouncilRosterSection(options.members ?? [])
+
+ return `You are Athena, a primary council orchestrator agent.
+
+Operate as a strict multi-model council coordinator.
+
+Core workflow:
+1) Receive user request and define a concise decision question for the council.
+2) Fan out council-member tasks in parallel with task(..., run_in_background=true).
+3) Collect with background_wait first, then background_output for completed IDs.
+4) Parse each member output as strict JSON contract; fallback to ${COUNCIL_MEMBER_RESPONSE_TAG} tag extraction.
+5) Apply quorum, retries, and graceful degradation.
+6) Synthesize agreement vs disagreement explicitly, then provide final recommendation.
+
+Council roster:
+${roster}
+
+Execution protocol:
+- Always run council fan-out in parallel. Never sequentially wait on one member before launching others.
+- Use subagent_type="council-member" if no named roster is configured.
+- For named roster entries, use that exact subagent_type so each member runs on its assigned model.
+- Keep prompts evidence-oriented and read-only. Members must inspect code, tests, logs, and config references.
+- Never ask members to edit files, delegate, or switch agents.
+
+Member response contract (required):
+- Preferred: raw JSON only.
+- Fallback allowed: wrap JSON in <${COUNCIL_MEMBER_RESPONSE_TAG}>...${COUNCIL_MEMBER_RESPONSE_TAG}>.
+- Required JSON keys:
+ {
+ "member": string,
+ "verdict": "support" | "oppose" | "mixed" | "abstain",
+ "confidence": number (0..1),
+ "rationale": string,
+ "risks": string[],
+ "evidence": [{ "source": string, "detail": string }],
+ "proposed_actions": string[],
+ "missing_information": string[]
+ }
+
+Failure and stuck handling:
+- Track per-member attempts, nudges, and progress timestamps.
+- Detect stuck tasks when no progress appears within expected interval.
+- First recovery action for stuck: nudge through continuation prompt.
+- If still stuck or failed: retry with a fresh background task, bounded by retry limit.
+- If a member remains failed after retry budget, mark as failed and continue.
+
+Quorum and degradation:
+- Default quorum: ceil(total_members / 2), minimum 1.
+- If quorum reached, continue synthesis even when some members failed.
+- If quorum cannot be reached after retries, report partial findings and explicit uncertainty.
+
+Synthesis output requirements:
+- Separate "agreement" and "disagreement" sections.
+- Name which members support the majority view and which dissent or failed.
+- Call out unresolved questions and evidence gaps.
+- End with one executable recommendation and a confidence statement.
+
+Do not expose internal operational noise. Report concise structured findings.`
+}
diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts
index 350d69e54..410046b01 100644
--- a/src/agents/builtin-agents.ts
+++ b/src/agents/builtin-agents.ts
@@ -12,6 +12,8 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
+import { createAthenaAgent } from "./athena"
+import { createCouncilMemberAgent } from "./council-member"
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
@@ -33,6 +35,7 @@ type AgentSource = AgentFactory | AgentConfig
const agentSources: Record = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
+ athena: createAthenaAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
@@ -43,6 +46,7 @@ const agentSources: Record = {
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
+ "council-member": createCouncilMemberAgent,
}
/**
diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts
index f5fd1d920..d38aa14ca 100644
--- a/src/agents/builtin-agents/general-agents.ts
+++ b/src/agents/builtin-agents/general-agents.ts
@@ -39,7 +39,6 @@ export function collectPendingBuiltinAgents(input: {
browserProvider,
uiSelectedModel,
availableModels,
- isFirstRunNoCache,
disabledSkills,
disableOmoEnv = false,
} = input
@@ -56,8 +55,9 @@ export function collectPendingBuiltinAgents(input: {
if (agentName === "sisyphus-junior") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
- const override = agentOverrides[agentName]
- ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
+ const override = Object.entries(agentOverrides).find(
+ ([key]) => key.toLowerCase() === agentName.toLowerCase(),
+ )?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
diff --git a/src/agents/council-member.ts b/src/agents/council-member.ts
new file mode 100644
index 000000000..7ce7f81e6
--- /dev/null
+++ b/src/agents/council-member.ts
@@ -0,0 +1,51 @@
+import type { AgentConfig } from "@opencode-ai/sdk"
+import type { AgentMode } from "./types"
+import { createAgentToolRestrictions } from "../shared/permission-compat"
+import { COUNCIL_MEMBER_RESPONSE_TAG } from "./athena/council-contract"
+
+const MODE: AgentMode = "subagent"
+
+const councilMemberRestrictions = createAgentToolRestrictions([
+ "write",
+ "edit",
+ "apply_patch",
+ "task",
+ "task_*",
+ "teammate",
+ "call_omo_agent",
+ "switch_agent",
+])
+
+export function createCouncilMemberAgent(model: string): AgentConfig {
+ return {
+ description: "Internal hidden council member used by Athena. Read-only analysis only.",
+ mode: MODE,
+ model,
+ temperature: 0.1,
+ hidden: true,
+ ...councilMemberRestrictions,
+ prompt: `You are an internal council-member for Athena.
+
+You are strictly read-only and evidence-oriented.
+You must not modify files, delegate, or switch agents.
+You must cite concrete evidence from files, tests, logs, or tool output.
+
+Output contract:
+- Preferred output: raw JSON only.
+- Fallback output: wrap JSON with <${COUNCIL_MEMBER_RESPONSE_TAG}>...${COUNCIL_MEMBER_RESPONSE_TAG}>.
+- Required JSON schema:
+ {
+ "member": string,
+ "verdict": "support" | "oppose" | "mixed" | "abstain",
+ "confidence": number (0..1),
+ "rationale": string,
+ "risks": string[],
+ "evidence": [{ "source": string, "detail": string }],
+ "proposed_actions": string[],
+ "missing_information": string[]
+ }
+
+Do not include markdown explanations outside the contract unless Athena asks for it explicitly.`,
+ }
+}
+createCouncilMemberAgent.mode = MODE
diff --git a/src/agents/types.ts b/src/agents/types.ts
index 5f5fa6bfe..f9f4f2d0c 100644
--- a/src/agents/types.ts
+++ b/src/agents/types.ts
@@ -112,6 +112,7 @@ export function isGeminiModel(model: string): boolean {
export type BuiltinAgentName =
| "sisyphus"
| "hephaestus"
+ | "athena"
| "oracle"
| "librarian"
| "explore"
@@ -119,9 +120,10 @@ export type BuiltinAgentName =
| "metis"
| "momus"
| "atlas"
- | "sisyphus-junior";
+ | "sisyphus-junior"
+ | "council-member";
-export type OverridableAgentName = "build" | BuiltinAgentName;
+export type OverridableAgentName = "build" | Exclude;
export type AgentName = BuiltinAgentName;
diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts
index b0280f52e..cbdd58447 100644
--- a/src/agents/utils.test.ts
+++ b/src/agents/utils.test.ts
@@ -11,6 +11,32 @@ import * as shared from "../shared"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
describe("createBuiltinAgents with model overrides", () => {
+ test("registers athena as builtin primary agent", async () => {
+ // #given
+
+ // #when
+ const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
+
+ // #then
+ expect(agents.athena).toBeDefined()
+ expect(agents.athena.mode).toBe("primary")
+ })
+
+ test("registers council-member as hidden internal subagent", async () => {
+ // #given
+
+ // #when
+ const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
+
+ // #then
+ expect(agents["council-member"]).toBeDefined()
+ expect(agents["council-member"].mode).toBe("subagent")
+ expect((agents["council-member"] as AgentConfig & { hidden?: boolean }).hidden).toBe(true)
+ expect(agents.sisyphus.prompt).not.toContain("council-member")
+ expect(agents.hephaestus.prompt).not.toContain("council-member")
+ expect(agents.atlas.prompt).not.toContain("council-member")
+ })
+
test("Sisyphus with default model has thinking config when all models available", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap
index 29eef9db0..8569dfdef 100644
--- a/src/cli/__snapshots__/model-fallback.test.ts.snap
+++ b/src/cli/__snapshots__/model-fallback.test.ts.snap
@@ -4,9 +4,15 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/gpt-5-nano",
+ },
"atlas": {
"model": "opencode/gpt-5-nano",
},
+ "council-member": {
+ "model": "opencode/gpt-5-nano",
+ },
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -68,9 +74,15 @@ exports[`generateModelConfig single native provider uses Claude models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -130,9 +142,15 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -193,10 +211,18 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "openai/gpt-5.4",
"variant": "medium",
@@ -278,10 +304,18 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "openai/gpt-5.4",
"variant": "medium",
@@ -363,9 +397,15 @@ exports[`generateModelConfig single native provider uses Gemini models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/gpt-5-nano",
+ },
"atlas": {
"model": "opencode/gpt-5-nano",
},
+ "council-member": {
+ "model": "opencode/gpt-5-nano",
+ },
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -423,9 +463,15 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/gpt-5-nano",
+ },
"atlas": {
"model": "opencode/gpt-5-nano",
},
+ "council-member": {
+ "model": "opencode/gpt-5-nano",
+ },
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -483,9 +529,16 @@ exports[`generateModelConfig all native providers uses preferred models from fal
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -558,9 +611,16 @@ exports[`generateModelConfig all native providers uses preferred models with isM
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -634,9 +694,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/claude-sonnet-4-6",
+ },
"atlas": {
"model": "opencode/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "opencode/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -709,9 +776,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/claude-sonnet-4-6",
+ },
"atlas": {
"model": "opencode/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "opencode/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -785,9 +859,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "github-copilot/claude-sonnet-4.6",
+ },
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
+ "council-member": {
+ "model": "github-copilot/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -855,9 +936,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "github-copilot/claude-sonnet-4.6",
+ },
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
+ "council-member": {
+ "model": "github-copilot/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -926,9 +1014,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/gpt-5-nano",
+ },
"atlas": {
"model": "opencode/gpt-5-nano",
},
+ "council-member": {
+ "model": "opencode/gpt-5-nano",
+ },
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -984,9 +1078,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "opencode/gpt-5-nano",
+ },
"atlas": {
"model": "opencode/gpt-5-nano",
},
+ "council-member": {
+ "model": "opencode/gpt-5-nano",
+ },
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -1042,9 +1142,16 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "opencode/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1117,9 +1224,16 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "github-copilot/claude-sonnet-4.6",
+ },
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -1192,9 +1306,15 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1256,9 +1376,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1322,9 +1448,16 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "github-copilot/claude-sonnet-4.6",
+ },
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
+ "council-member": {
+ "model": "github-copilot/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -1400,9 +1533,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1478,9 +1618,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
+ "athena": {
+ "model": "anthropic/claude-sonnet-4-6",
+ },
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
+ "council-member": {
+ "model": "openai/gpt-5.4",
+ "variant": "medium",
+ },
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
diff --git a/src/config/index.ts b/src/config/index.ts
index 57a347d3a..d25b8fcf1 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -4,6 +4,7 @@ export {
export type {
OhMyOpenCodeConfig,
+ AthenaConfig,
AgentOverrideConfig,
AgentOverrides,
McpName,
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 04dd0b15b..a379d24e4 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -1,5 +1,6 @@
export * from "./schema/agent-names"
export * from "./schema/agent-overrides"
+export * from "./schema/athena-config"
export * from "./schema/babysitting"
export * from "./schema/background-task"
export * from "./schema/browser-automation"
diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts
index 73e4f1f80..08f323f3f 100644
--- a/src/config/schema/agent-names.ts
+++ b/src/config/schema/agent-names.ts
@@ -3,6 +3,7 @@ import { z } from "zod"
export const BuiltinAgentNameSchema = z.enum([
"sisyphus",
"hephaestus",
+ "athena",
"prometheus",
"oracle",
"librarian",
@@ -12,6 +13,7 @@ export const BuiltinAgentNameSchema = z.enum([
"momus",
"atlas",
"sisyphus-junior",
+ "council-member",
])
export const BuiltinSkillNameSchema = z.enum([
@@ -27,6 +29,7 @@ export const OverridableAgentNameSchema = z.enum([
"plan",
"sisyphus",
"hephaestus",
+ "athena",
"sisyphus-junior",
"OpenCode-Builder",
"prometheus",
diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts
index ac560cbd5..5e3cb240c 100644
--- a/src/config/schema/agent-overrides.ts
+++ b/src/config/schema/agent-overrides.ts
@@ -62,6 +62,7 @@ export const AgentOverridesSchema = z.object({
hephaestus: AgentOverrideConfigSchema.extend({
allow_non_gpt_model: z.boolean().optional(),
}).optional(),
+ athena: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),
diff --git a/src/config/schema/athena-config.test.ts b/src/config/schema/athena-config.test.ts
new file mode 100644
index 000000000..276d205b7
--- /dev/null
+++ b/src/config/schema/athena-config.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test"
+import { AthenaConfigSchema } from "./athena-config"
+import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
+
+describe("AthenaConfigSchema", () => {
+ test("accepts athena config with required members", () => {
+ // given
+ const config = {
+ model: "openai/gpt-5.4",
+ members: [
+ { name: "Socrates", model: "openai/gpt-5.4" },
+ { name: "Plato", model: "anthropic/claude-sonnet-4-6" },
+ ],
+ }
+
+ // when
+ const result = AthenaConfigSchema.safeParse(config)
+
+ // then
+ expect(result.success).toBe(true)
+ })
+
+ test("rejects athena config when members are missing", () => {
+ // given
+ const config = {
+ model: "openai/gpt-5.4",
+ }
+
+ // when
+ const result = AthenaConfigSchema.safeParse(config)
+
+ // then
+ expect(result.success).toBe(false)
+ })
+
+ test("rejects case-insensitive duplicate member names", () => {
+ // given
+ const config = {
+ members: [
+ { name: "Socrates", model: "openai/gpt-5.4" },
+ { name: "socrates", model: "anthropic/claude-sonnet-4-6" },
+ ],
+ }
+
+ // when
+ const result = AthenaConfigSchema.safeParse(config)
+
+ // then
+ expect(result.success).toBe(false)
+ })
+
+ test("rejects member model without provider prefix", () => {
+ // given
+ const config = {
+ members: [{ name: "Socrates", model: "gpt-5.4" }],
+ }
+
+ // when
+ const result = AthenaConfigSchema.safeParse(config)
+
+ // then
+ expect(result.success).toBe(false)
+ })
+})
+
+describe("OhMyOpenCodeConfigSchema athena field", () => {
+ test("accepts athena config at root", () => {
+ // given
+ const config = {
+ athena: {
+ model: "openai/gpt-5.4",
+ members: [{ name: "Socrates", model: "openai/gpt-5.4" }],
+ },
+ }
+
+ // when
+ const result = OhMyOpenCodeConfigSchema.safeParse(config)
+
+ // then
+ expect(result.success).toBe(true)
+ })
+})
diff --git a/src/config/schema/athena-config.ts b/src/config/schema/athena-config.ts
new file mode 100644
index 000000000..6aaee8f69
--- /dev/null
+++ b/src/config/schema/athena-config.ts
@@ -0,0 +1,39 @@
+import { z } from "zod"
+
+const PROVIDER_MODEL_PATTERN = /^[^/\s]+\/[^/\s]+$/
+
+const ProviderModelSchema = z
+ .string()
+ .regex(PROVIDER_MODEL_PATTERN, "Model must use provider/model format")
+
+const AthenaCouncilMemberSchema = z.object({
+ name: z.string().trim().min(1),
+ model: ProviderModelSchema,
+})
+
+export const AthenaConfigSchema = z
+ .object({
+ model: ProviderModelSchema.optional(),
+ members: z.array(AthenaCouncilMemberSchema).min(1),
+ })
+ .superRefine((value, ctx) => {
+ const seen = new Map()
+
+ for (const [index, member] of value.members.entries()) {
+ const normalizedName = member.name.trim().toLowerCase()
+ const existingIndex = seen.get(normalizedName)
+
+ if (existingIndex !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["members", index, "name"],
+ message: `Duplicate member name '${member.name}' (case-insensitive). First seen at members[${existingIndex}]`,
+ })
+ continue
+ }
+
+ seen.set(normalizedName, index)
+ }
+ })
+
+export type AthenaConfig = z.infer
diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts
index 434bfe7ee..8e7269733 100644
--- a/src/config/schema/oh-my-opencode-config.ts
+++ b/src/config/schema/oh-my-opencode-config.ts
@@ -2,6 +2,7 @@ import { z } from "zod"
import { AnyMcpNameSchema } from "../../mcp/types"
import { BuiltinSkillNameSchema } from "./agent-names"
import { AgentOverridesSchema } from "./agent-overrides"
+import { AthenaConfigSchema } from "./athena-config"
import { BabysittingConfigSchema } from "./babysitting"
import { BackgroundTaskConfigSchema } from "./background-task"
import { BrowserAutomationConfigSchema } from "./browser-automation"
@@ -41,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(),
+ athena: AthenaConfigSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts
index 33f15f233..1ebb4662e 100644
--- a/src/plugin-handlers/agent-config-handler.ts
+++ b/src/plugin-handlers/agent-config-handler.ts
@@ -21,6 +21,7 @@ import {
} from "./agent-override-protection";
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
+import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring"
type AgentConfigRecord = Record | undefined> & {
build?: Record;
@@ -273,6 +274,11 @@ export async function applyAgentConfig(params: {
}
if (params.config.agent) {
+ applyAthenaCouncilAgentWiring(
+ params.config.agent as Record,
+ params.pluginConfig.athena,
+ )
+
params.config.agent = remapAgentKeysToDisplayNames(
params.config.agent as Record,
);
diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts
index c315ad76a..023d24af5 100644
--- a/src/plugin-handlers/agent-priority-order.ts
+++ b/src/plugin-handlers/agent-priority-order.ts
@@ -3,8 +3,9 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [
{ displayName: getAgentDisplayName("sisyphus"), order: 1 },
{ displayName: getAgentDisplayName("hephaestus"), order: 2 },
- { displayName: getAgentDisplayName("prometheus"), order: 3 },
- { displayName: getAgentDisplayName("atlas"), order: 4 },
+ { displayName: getAgentDisplayName("athena"), order: 3 },
+ { displayName: getAgentDisplayName("prometheus"), order: 4 },
+ { displayName: getAgentDisplayName("atlas"), order: 5 },
];
function injectOrderField(
diff --git a/src/plugin-handlers/athena-council-agent-wiring.test.ts b/src/plugin-handlers/athena-council-agent-wiring.test.ts
new file mode 100644
index 000000000..d67d900c3
--- /dev/null
+++ b/src/plugin-handlers/athena-council-agent-wiring.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring"
+
+describe("applyAthenaCouncilAgentWiring", () => {
+ test("#given athena config with roster #when wiring agents #then injects dynamic council member agents", () => {
+ // given
+ const agentConfig: Record = {
+ athena: {
+ model: "openai/gpt-5.4",
+ prompt: "placeholder",
+ },
+ }
+
+ // when
+ applyAthenaCouncilAgentWiring(agentConfig, {
+ model: "anthropic/claude-opus-4-6",
+ members: [
+ { name: "Architect", model: "openai/gpt-5.4" },
+ { name: "Skeptic", model: "anthropic/claude-sonnet-4-6" },
+ ],
+ })
+
+ // then
+ const athena = agentConfig.athena as Record
+ expect(athena.model).toBe("anthropic/claude-opus-4-6")
+ expect(typeof athena.prompt).toBe("string")
+ expect((athena.prompt as string).includes("council-member-architect")).toBe(true)
+
+ const architect = agentConfig["council-member-architect"] as Record
+ const skeptic = agentConfig["council-member-skeptic"] as Record
+ expect(architect).toBeDefined()
+ expect(skeptic).toBeDefined()
+ expect(architect.model).toBe("openai/gpt-5.4")
+ expect(skeptic.model).toBe("anthropic/claude-sonnet-4-6")
+ })
+})
diff --git a/src/plugin-handlers/athena-council-agent-wiring.ts b/src/plugin-handlers/athena-council-agent-wiring.ts
new file mode 100644
index 000000000..cf49af50f
--- /dev/null
+++ b/src/plugin-handlers/athena-council-agent-wiring.ts
@@ -0,0 +1,42 @@
+import type { AthenaConfig } from "../config"
+import { createCouncilMemberAgent } from "../agents/council-member"
+import { buildAthenaPrompt } from "../agents/athena/prompt"
+import { toCouncilMemberAgentName } from "../agents/athena/council-members"
+
+export function applyAthenaCouncilAgentWiring(
+ agentConfig: Record,
+ athenaConfig?: AthenaConfig,
+): void {
+ const members = athenaConfig?.members ?? []
+ const athena = agentConfig.athena as Record | undefined
+
+ if (athenaConfig?.model) {
+ if (athena) {
+ athena.model = athenaConfig.model
+ }
+ }
+
+ if (!athena) {
+ return
+ }
+
+ if (members.length > 0) {
+ athena.prompt = buildAthenaPrompt({
+ members: members.map((member) => ({ name: member.name, model: member.model })),
+ })
+ }
+
+ for (const member of members) {
+ const dynamicAgentName = toCouncilMemberAgentName(member.name)
+ if (agentConfig[dynamicAgentName]) {
+ continue
+ }
+
+ const memberAgent = createCouncilMemberAgent(member.model)
+ agentConfig[dynamicAgentName] = {
+ ...memberAgent,
+ description: `Athena council member (${member.name}) using ${member.model}.`,
+ hidden: true,
+ }
+ }
+}
diff --git a/src/plugin-handlers/tool-config-handler.test.ts b/src/plugin-handlers/tool-config-handler.test.ts
index 3eaa8afe3..046e5a3ee 100644
--- a/src/plugin-handlers/tool-config-handler.test.ts
+++ b/src/plugin-handlers/tool-config-handler.test.ts
@@ -67,6 +67,7 @@ describe("applyToolConfig", () => {
it.each([
"atlas",
+ "athena",
"sisyphus",
"hephaestus",
"prometheus",
@@ -195,6 +196,7 @@ describe("applyToolConfig", () => {
describe("#when applying tool config", () => {
it.each([
"atlas",
+ "athena",
"sisyphus",
"hephaestus",
"prometheus",
@@ -216,6 +218,50 @@ describe("applyToolConfig", () => {
})
})
+ describe("#given council-member agent exists", () => {
+ describe("#when applying tool config", () => {
+ it("#then should enforce read-only and non-delegating permissions", () => {
+ const params = createParams({ agents: ["council-member"] })
+
+ applyToolConfig(params)
+
+ const agent = params.agentResult["council-member"] as {
+ permission: Record
+ }
+ expect(agent.permission.write).toBe("deny")
+ expect(agent.permission.edit).toBe("deny")
+ expect(agent.permission.apply_patch).toBe("deny")
+ expect(agent.permission.task).toBe("deny")
+ expect(agent.permission["task_*"]).toBe("deny")
+ expect(agent.permission.call_omo_agent).toBe("deny")
+ expect(agent.permission.switch_agent).toBe("deny")
+ expect(agent.permission.teammate).toBe("deny")
+ })
+ })
+ })
+
+ describe("#given dynamic council-member agent exists", () => {
+ describe("#when applying tool config", () => {
+ it("#then should enforce read-only and non-delegating permissions", () => {
+ const params = createParams({ agents: ["council-member-architect"] })
+
+ applyToolConfig(params)
+
+ const agent = params.agentResult["council-member-architect"] as {
+ permission: Record
+ }
+ expect(agent.permission.write).toBe("deny")
+ expect(agent.permission.edit).toBe("deny")
+ expect(agent.permission.apply_patch).toBe("deny")
+ expect(agent.permission.task).toBe("deny")
+ expect(agent.permission["task_*"]).toBe("deny")
+ expect(agent.permission.call_omo_agent).toBe("deny")
+ expect(agent.permission.switch_agent).toBe("deny")
+ expect(agent.permission.teammate).toBe("deny")
+ })
+ })
+ })
+
describe("#given disabled_tools includes 'question'", () => {
let originalConfigContent: string | undefined
let originalCliRunMode: string | undefined
diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts
index 4be1f2937..ac49ff88e 100644
--- a/src/plugin-handlers/tool-config-handler.ts
+++ b/src/plugin-handlers/tool-config-handler.ts
@@ -3,6 +3,22 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
type AgentWithPermission = { permission?: Record };
+const COUNCIL_MEMBER_AGENT_PREFIX = "council-member-"
+
+function applyCouncilMemberRestrictions(agent: AgentWithPermission): void {
+ agent.permission = {
+ ...agent.permission,
+ write: "deny",
+ edit: "deny",
+ apply_patch: "deny",
+ task: "deny",
+ "task_*": "deny",
+ teammate: "deny",
+ call_omo_agent: "deny",
+ switch_agent: "deny",
+ }
+}
+
function getConfigQuestionPermission(): string | null {
const configContent = process.env.OPENCODE_CONFIG_CONTENT;
if (!configContent) return null;
@@ -114,6 +130,30 @@ export function applyToolConfig(params: {
...denyTodoTools,
};
}
+ const athena = agentByKey(params.agentResult, "athena")
+ if (athena) {
+ athena.permission = {
+ ...athena.permission,
+ call_omo_agent: "deny",
+ task: "allow",
+ question: questionPermission,
+ "task_*": "allow",
+ teammate: "allow",
+ ...denyTodoTools,
+ }
+ }
+ const councilMember = agentByKey(params.agentResult, "council-member");
+ if (councilMember) {
+ applyCouncilMemberRestrictions(councilMember)
+ }
+
+ for (const [agentName, agentConfig] of Object.entries(params.agentResult)) {
+ if (!agentName.toLowerCase().startsWith(COUNCIL_MEMBER_AGENT_PREFIX)) {
+ continue
+ }
+
+ applyCouncilMemberRestrictions(agentConfig as AgentWithPermission)
+ }
params.config.permission = {
webfetch: "allow",
diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts
index 3d7276a1b..18ac3a49f 100644
--- a/src/shared/agent-display-names.test.ts
+++ b/src/shared/agent-display-names.test.ts
@@ -180,10 +180,12 @@ describe("AGENT_DISPLAY_NAMES", () => {
it("contains all expected agent mappings", () => {
// given expected mappings
const expectedMappings = {
+ athena: "Athena",
sisyphus: "Sisyphus (Ultraworker)",
hephaestus: "Hephaestus (Deep Agent)",
prometheus: "Prometheus (Plan Builder)",
atlas: "Atlas (Plan Executor)",
+ "council-member": "council-member",
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
momus: "Momus (Plan Critic)",
@@ -197,4 +199,4 @@ describe("AGENT_DISPLAY_NAMES", () => {
// then contains all expected mappings
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
})
-})
\ No newline at end of file
+})
diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts
index a0bda2241..2c3c9a005 100644
--- a/src/shared/agent-display-names.ts
+++ b/src/shared/agent-display-names.ts
@@ -6,6 +6,7 @@
export const AGENT_DISPLAY_NAMES: Record = {
sisyphus: "Sisyphus (Ultraworker)",
hephaestus: "Hephaestus (Deep Agent)",
+ athena: "Athena",
prometheus: "Prometheus (Plan Builder)",
atlas: "Atlas (Plan Executor)",
"sisyphus-junior": "Sisyphus-Junior",
@@ -15,6 +16,7 @@ export const AGENT_DISPLAY_NAMES: Record = {
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
+ "council-member": "council-member",
}
/**
@@ -51,4 +53,4 @@ export function getAgentConfigKey(agentName: string): string {
if (reversed !== undefined) return reversed
if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
return lower
-}
\ No newline at end of file
+}
diff --git a/src/shared/agent-tool-restrictions.test.ts b/src/shared/agent-tool-restrictions.test.ts
new file mode 100644
index 000000000..751720f80
--- /dev/null
+++ b/src/shared/agent-tool-restrictions.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { getAgentToolRestrictions, hasAgentToolRestrictions } from "./agent-tool-restrictions"
+
+describe("agent-tool-restrictions council-member", () => {
+ test("returns council-member restrictions as read-only and non-delegating", () => {
+ // given
+
+ // when
+ const restrictions = getAgentToolRestrictions("council-member")
+
+ // then
+ expect(restrictions.write).toBe(false)
+ expect(restrictions.edit).toBe(false)
+ expect(restrictions.apply_patch).toBe(false)
+ expect(restrictions.task).toBe(false)
+ expect(restrictions["task_*"]).toBe(false)
+ expect(restrictions.call_omo_agent).toBe(false)
+ expect(restrictions.switch_agent).toBe(false)
+ expect(restrictions.teammate).toBe(false)
+ })
+
+ test("matches council-member case-insensitively", () => {
+ // given
+
+ // when
+ const restrictions = getAgentToolRestrictions("Council-Member")
+
+ // then
+ expect(restrictions.write).toBe(false)
+ expect(hasAgentToolRestrictions("Council-Member")).toBe(true)
+ })
+
+ test("matches dynamic council-member names by prefix", () => {
+ // given
+
+ // when
+ const restrictions = getAgentToolRestrictions("council-member-architect")
+
+ // then
+ expect(restrictions.write).toBe(false)
+ expect(restrictions.task).toBe(false)
+ expect(hasAgentToolRestrictions("council-member-architect")).toBe(true)
+ })
+})
diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts
index 865251d6f..fe89c6f64 100644
--- a/src/shared/agent-tool-restrictions.ts
+++ b/src/shared/agent-tool-restrictions.ts
@@ -42,15 +42,34 @@ const AGENT_RESTRICTIONS: Record> = {
"sisyphus-junior": {
task: false,
},
+
+ "council-member": {
+ write: false,
+ edit: false,
+ apply_patch: false,
+ task: false,
+ "task_*": false,
+ teammate: false,
+ call_omo_agent: false,
+ switch_agent: false,
+ },
}
export function getAgentToolRestrictions(agentName: string): Record {
+ if (agentName.toLowerCase().startsWith("council-member-")) {
+ return AGENT_RESTRICTIONS["council-member"]
+ }
+
return AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
+ if (agentName.toLowerCase().startsWith("council-member-")) {
+ return true
+ }
+
const restrictions = AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
return restrictions !== undefined && Object.keys(restrictions).length > 0
diff --git a/src/shared/migration/agent-names.ts b/src/shared/migration/agent-names.ts
index 3321b0b84..82f252fe1 100644
--- a/src/shared/migration/agent-names.ts
+++ b/src/shared/migration/agent-names.ts
@@ -5,6 +5,9 @@ export const AGENT_NAME_MAP: Record = {
Sisyphus: "sisyphus",
sisyphus: "sisyphus",
+ Athena: "athena",
+ athena: "athena",
+
// Prometheus variants → "prometheus"
"OmO-Plan": "prometheus",
"omo-plan": "prometheus",
@@ -37,10 +40,12 @@ export const AGENT_NAME_MAP: Record = {
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
+ "council-member": "council-member",
}
export const BUILTIN_AGENT_NAMES = new Set([
"sisyphus", // was "Sisyphus"
+ "athena",
"oracle",
"librarian",
"explore",
@@ -49,6 +54,7 @@ export const BUILTIN_AGENT_NAMES = new Set([
"momus", // was "Momus (Plan Reviewer)"
"prometheus", // was "Prometheus (Planner)"
"atlas", // was "Atlas"
+ "council-member",
"build",
])
diff --git a/src/shared/model-requirements.test.ts b/src/shared/model-requirements.test.ts
index d09a4530a..e6c67192f 100644
--- a/src/shared/model-requirements.test.ts
+++ b/src/shared/model-requirements.test.ts
@@ -265,9 +265,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(hephaestus.requiresModel).toBeUndefined()
})
- test("all 11 builtin agents have valid fallbackChain arrays", () => {
- // #given - list of 11 agent names
+ test("all builtin and internal agents have valid fallbackChain arrays", () => {
const expectedAgents = [
+ "athena",
"sisyphus",
"hephaestus",
"oracle",
@@ -278,6 +278,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
"metis",
"momus",
"atlas",
+ "council-member",
"sisyphus-junior",
]
@@ -285,7 +286,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #then - all agents present with valid fallbackChain
- expect(definedAgents).toHaveLength(11)
+ expect(definedAgents).toHaveLength(expectedAgents.length)
for (const agent of expectedAgents) {
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
expect(requirement).toBeDefined()
diff --git a/src/shared/model-requirements.ts b/src/shared/model-requirements.ts
index 541c26d00..4be398a96 100644
--- a/src/shared/model-requirements.ts
+++ b/src/shared/model-requirements.ts
@@ -55,6 +55,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record = {
],
requiresProvider: ["openai", "github-copilot", "venice", "opencode"],
},
+ athena: {
+ fallbackChain: [
+ {
+ providers: ["anthropic", "github-copilot", "opencode"],
+ model: "claude-sonnet-4-6",
+ },
+ {
+ providers: ["openai", "github-copilot", "opencode"],
+ model: "gpt-5.4",
+ variant: "medium",
+ },
+ ],
+ },
oracle: {
fallbackChain: [
{
@@ -180,6 +193,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record = {
{ providers: ["opencode"], model: "big-pickle" },
],
},
+ "council-member": {
+ fallbackChain: [
+ {
+ providers: ["openai", "github-copilot", "opencode"],
+ model: "gpt-5.4",
+ variant: "medium",
+ },
+ {
+ providers: ["anthropic", "github-copilot", "opencode"],
+ model: "claude-sonnet-4-6",
+ },
+ ],
+ },
};
export const CATEGORY_MODEL_REQUIREMENTS: Record = {