diff --git a/.sisyphus/evidence/team-mode/task-2-category-prompt-required.txt b/.sisyphus/evidence/team-mode/task-2-category-prompt-required.txt new file mode 100644 index 000000000..d3f55481c --- /dev/null +++ b/.sisyphus/evidence/team-mode/task-2-category-prompt-required.txt @@ -0,0 +1,7 @@ +bun test v1.3.12 (700fc117) + + 1 pass + 3 filtered out + 0 fail + 1 expect() calls +Ran 1 test across 1 file. [64.00ms] diff --git a/.sisyphus/evidence/team-mode/task-2-disc-both.txt b/.sisyphus/evidence/team-mode/task-2-disc-both.txt new file mode 100644 index 000000000..1174095e7 --- /dev/null +++ b/.sisyphus/evidence/team-mode/task-2-disc-both.txt @@ -0,0 +1,7 @@ +bun test v1.3.12 (700fc117) + + 1 pass + 3 filtered out + 0 fail + 1 expect() calls +Ran 1 test across 1 file. [65.00ms] diff --git a/.sisyphus/evidence/team-mode/task-2-disc-category.txt b/.sisyphus/evidence/team-mode/task-2-disc-category.txt new file mode 100644 index 000000000..7aed175f2 --- /dev/null +++ b/.sisyphus/evidence/team-mode/task-2-disc-category.txt @@ -0,0 +1,7 @@ +bun test v1.3.12 (700fc117) + + 1 pass + 3 filtered out + 0 fail + 3 expect() calls +Ran 1 test across 1 file. [69.00ms] diff --git a/.sisyphus/evidence/team-mode/task-2-eligibility-registry.txt b/.sisyphus/evidence/team-mode/task-2-eligibility-registry.txt new file mode 100644 index 000000000..749f3d548 --- /dev/null +++ b/.sisyphus/evidence/team-mode/task-2-eligibility-registry.txt @@ -0,0 +1,7 @@ +bun test v1.3.12 (700fc117) + + 1 pass + 3 filtered out + 0 fail + 12 expect() calls +Ran 1 test across 1 file. [61.00ms] diff --git a/.sisyphus/notepads/team-mode/learnings.md b/.sisyphus/notepads/team-mode/learnings.md new file mode 100644 index 000000000..5349aca4e --- /dev/null +++ b/.sisyphus/notepads/team-mode/learnings.md @@ -0,0 +1,5 @@ +## 2026-04-18 Task 2: types module + +- `MemberSchema` needs `.strict()` on the base shape so the discriminatedUnion rejects members that mix `category` and `subagent_type`. +- `backendType` and `isActive` defaults are part of the schema contract, so tests should use `toMatchObject` instead of exact object equality. +- The eligibility registry must preserve the plan strings verbatim, especially the hard-reject messages for Momus verification. diff --git a/src/features/team-mode/index.ts b/src/features/team-mode/index.ts new file mode 100644 index 000000000..51f739d01 --- /dev/null +++ b/src/features/team-mode/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/src/features/team-mode/types.test.ts b/src/features/team-mode/types.test.ts new file mode 100644 index 000000000..042f220e5 --- /dev/null +++ b/src/features/team-mode/types.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { + AGENT_ELIGIBILITY_REGISTRY, + CategoryMemberSchema, + MemberSchema, + SubagentMemberSchema, +} from "./types" + +describe("team-mode types", () => { + test("member category branch parses and narrows", () => { + // given + const member = { kind: "category", name: "m1", category: "deep", prompt: "impl X" } + + // when + const result = MemberSchema.safeParse(member) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toMatchObject(member) + expect(result.data).toMatchObject({ kind: "category", category: "deep" }) + } + }) + + test("both kinds rejected", () => { + // given + const member = { + kind: "category", + name: "m1", + category: "deep", + subagent_type: "sisyphus", + prompt: "impl X", + } + + // when + const result = MemberSchema.safeParse(member) + + // then + expect(result.success).toBe(false) + }) + + test("category requires prompt", () => { + // given + const member = { kind: "category", name: "m1", category: "deep" } + + // when + const result = CategoryMemberSchema.safeParse(member) + + // then + expect(result.success).toBe(false) + }) + + test("eligibility registry shape", () => { + // given + const entries = Object.entries(AGENT_ELIGIBILITY_REGISTRY) + + // when + const verdictCounts = entries.reduce( + (counts, [, value]) => { + counts[value.verdict] += 1 + return counts + }, + { eligible: 0, conditional: 0, "hard-reject": 0 }, + ) + + // then + expect(entries).toHaveLength(11) + expect(verdictCounts).toEqual({ eligible: 3, conditional: 1, "hard-reject": 7 }) + expect(AGENT_ELIGIBILITY_REGISTRY.hephaestus.rejectionMessage).toBe( + "Agent 'hephaestus' lacks teammate permission. Either apply D-36 (add teammate: \"allow\" in tool-config-handler.ts) or use subagent_type: \"sisyphus\" instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.oracle.rejectionMessage).toBe( + "Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.librarian.rejectionMessage).toBe( + "Agent 'librarian' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for research queries instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.explore.rejectionMessage).toBe( + "Agent 'explore' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for codebase exploration instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY["multimodal-looker"].rejectionMessage).toBe( + "Agent 'multimodal-looker' has read-only tool access (only 'read' allowed). Cannot write to mailbox as team member.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.metis.rejectionMessage).toBe( + "Agent 'metis' is read-only (pre-planning consultant). Cannot write to mailbox as team member. Use delegate-task for pre-planning analysis instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.momus.rejectionMessage).toBe( + "Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.", + ) + expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe( + "Agent 'prometheus' is plan-mode-only; can only write to .sisyphus/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.", + ) + expect(CategoryMemberSchema).toBeDefined() + expect(SubagentMemberSchema).toBeDefined() + }) +}) diff --git a/src/features/team-mode/types.ts b/src/features/team-mode/types.ts new file mode 100644 index 000000000..2b2da3f2a --- /dev/null +++ b/src/features/team-mode/types.ts @@ -0,0 +1,190 @@ +import { z } from "zod" + +export const MESSAGE_KINDS = [ + "message", + "shutdown_request", + "shutdown_approved", + "shutdown_rejected", + "announcement", +] as const + +export const MEMBER_KINDS = ["category", "subagent_type"] as const + +export const TASK_STATUSES = ["pending", "claimed", "in_progress", "completed", "deleted"] as const + +export const RUNTIME_STATUSES = [ + "creating", + "active", + "shutdown_requested", + "deleting", + "deleted", + "failed", + "orphaned", +] as const + +const MemberBaseSchema = z.object({ + name: z.string().min(1).regex(/^[a-z0-9-]+$/), + cwd: z.string().optional(), + worktreePath: z.string().optional(), + subscriptions: z.array(z.string()).optional(), + backendType: z.enum(["in-process", "tmux"]).default("in-process"), + color: z.string().optional(), + isActive: z.boolean().default(true), +}).strict() + +export const CategoryMemberSchema = MemberBaseSchema.extend({ + kind: z.literal("category"), + category: z.string().min(1), + prompt: z.string().min(1), +}) + +export const SubagentMemberSchema = MemberBaseSchema.extend({ + kind: z.literal("subagent_type"), + subagent_type: z.string().min(1), + prompt: z.string().optional(), +}) + +export const MemberSchema = z.discriminatedUnion("kind", [CategoryMemberSchema, SubagentMemberSchema]) + +const TeamReferenceSchema = z.object({ + path: z.string(), + description: z.string().optional(), +}).strict() + +export const TeamSpecSchema = z.object({ + version: z.literal(1), + name: z.string().min(1).regex(/^[a-z0-9-]+$/), + description: z.string().optional(), + createdAt: z.number().int().positive(), + leadAgentId: z.string(), + teamAllowedPaths: z.array(z.string()).optional(), + sessionPermission: z.string().optional(), + members: z.array(MemberSchema).min(1).max(8), +}) + +export const MessageSchema = z.object({ + version: z.literal(1), + messageId: z.string().uuid(), + from: z.string(), + to: z.string(), + kind: z.enum(MESSAGE_KINDS), + body: z.string().max(32 * 1024), + summary: z.string().optional(), + references: z.array(TeamReferenceSchema).optional(), + timestamp: z.number().int().positive(), + correlationId: z.string().uuid().optional(), + color: z.string().optional(), +}) + +export const TaskSchema = z.object({ + version: z.literal(1), + id: z.string(), + subject: z.string(), + description: z.string(), + activeForm: z.string().optional(), + status: z.enum(TASK_STATUSES), + owner: z.string().optional(), + blocks: z.array(z.string()).default([]), + blockedBy: z.array(z.string()).default([]), + metadata: z.record(z.string(), z.unknown()).optional(), + createdAt: z.number().int().positive(), + updatedAt: z.number().int().positive(), + claimedAt: z.number().int().positive().optional(), +}) + +const RuntimeStateMemberSchema = z.object({ + name: z.string(), + sessionId: z.string().optional(), + tmuxPaneId: z.string().optional(), + agentType: z.enum(["leader", "general-purpose"]), + status: z.enum(["pending", "running", "idle", "errored", "completed", "shutdown_approved"]), + color: z.string().optional(), + worktreePath: z.string().optional(), + lastInjectedTurnMarker: z.string().optional(), + pendingInjectedMessageIds: z.array(z.string()).default([]), +}).strict() + +const RuntimeBoundsSchema = z.object({ + maxMembers: z.number().int().default(8), + maxParallelMembers: z.number().int().default(4), + maxMessagesPerRun: z.number().int().default(10000), + maxWallClockMinutes: z.number().int().default(120), + maxMemberTurns: z.number().int().default(500), +}).strict() + +const ShutdownRequestSchema = z.object({ + memberId: z.string(), + requestedAt: z.number().int().positive(), + approvedAt: z.number().int().positive().optional(), + rejectedReason: z.string().optional(), +}).strict() + +export const RuntimeStateSchema = z.object({ + version: z.literal(1), + teamRunId: z.string().uuid(), + teamName: z.string(), + specSource: z.enum(["project", "user"]), + createdAt: z.number().int().positive(), + status: z.enum(RUNTIME_STATUSES), + leadSessionId: z.string().optional(), + members: z.array(RuntimeStateMemberSchema), + shutdownRequests: z.array(ShutdownRequestSchema).default([]), + bounds: RuntimeBoundsSchema, +}) + +export const AGENT_ELIGIBILITY_REGISTRY: Readonly> = { + sisyphus: { verdict: "eligible" }, + hephaestus: { + verdict: "conditional", + rejectionMessage: + "Agent 'hephaestus' lacks teammate permission. Either apply D-36 (add teammate: \"allow\" in tool-config-handler.ts) or use subagent_type: \"sisyphus\" instead.", + }, + oracle: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead.", + }, + librarian: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'librarian' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for research queries instead.", + }, + explore: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'explore' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for codebase exploration instead.", + }, + "multimodal-looker": { + verdict: "hard-reject", + rejectionMessage: + "Agent 'multimodal-looker' has read-only tool access (only 'read' allowed). Cannot write to mailbox as team member.", + }, + metis: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'metis' is read-only (pre-planning consultant). Cannot write to mailbox as team member. Use delegate-task for pre-planning analysis instead.", + }, + momus: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.", + }, + atlas: { verdict: "eligible" }, + prometheus: { + verdict: "hard-reject", + rejectionMessage: + "Agent 'prometheus' is plan-mode-only; can only write to .sisyphus/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.", + }, + "sisyphus-junior": { verdict: "eligible" }, +} as const + +export type TeamSpec = z.infer +export type Member = z.infer +export type CategoryMember = z.infer +export type SubagentMember = z.infer +export type Message = z.infer +export type Task = z.infer +export type RuntimeState = z.infer