feat(council-archive): add council_finalize and council_read tools, refactor background_wait to metadata-only
- council_finalize: batch extract, archive, and return council results - council_read: secure file reader for archived council responses - background_wait: stripped to metadata-only, returns completed_tasks array - Removed all council-specific logic from background_wait - 28/28 tests passing across all changed files
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it, beforeEach } from "bun:test"
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { createCouncilFinalize } from "./create-council-finalize"
|
||||
import type { CouncilFinalizeResult } from "./types"
|
||||
|
||||
function mockTaskOutput(agent: string, responseBody: string, complete = true): string {
|
||||
const closing = complete ? "\n</COUNCIL_MEMBER_RESPONSE>" : ""
|
||||
return [
|
||||
"---",
|
||||
`task_id: bg_test`,
|
||||
`agent: ${agent}`,
|
||||
`session_id: ses_test`,
|
||||
`parent_session_id: ses_parent`,
|
||||
`status: completed`,
|
||||
`completed_at: 2026-02-27T14:00:00.000Z`,
|
||||
"---",
|
||||
"",
|
||||
"[assistant] 14:00:00",
|
||||
`<COUNCIL_MEMBER_RESPONSE>`,
|
||||
responseBody,
|
||||
closing,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "test-session",
|
||||
messageID: "test-message",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("createCouncilFinalize", () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "council-finalize-"))
|
||||
await mkdir(join(tmpDir, ".sisyphus", "task-outputs"), { recursive: true })
|
||||
})
|
||||
|
||||
describe("#given 3 members with valid output files", () => {
|
||||
it("#then creates full archive with all has_response true", async () => {
|
||||
const agents = [
|
||||
{ id: "bg_001", agent: "Council: Claude Opus", response: "Opus analysis" },
|
||||
{ id: "bg_002", agent: "Council: GPT-5", response: "GPT analysis" },
|
||||
{ id: "bg_003", agent: "Council: Gemini", response: "Gemini analysis" },
|
||||
]
|
||||
|
||||
for (const a of agents) {
|
||||
await writeFile(
|
||||
join(tmpDir, ".sisyphus", "task-outputs", `${a.id}.md`),
|
||||
mockTaskOutput(a.agent, a.response),
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
const toolDef = createCouncilFinalize(tmpDir)
|
||||
const resultStr = await toolDef.execute(
|
||||
{ task_ids: agents.map((a) => a.id), name: "test" },
|
||||
mockCtx,
|
||||
)
|
||||
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||
|
||||
expect(result.archive_dir).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{4}$/)
|
||||
expect(result.meta_file).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{4}\/meta\.yaml$/)
|
||||
expect(result.members).toHaveLength(3)
|
||||
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
const member = result.members[i]
|
||||
expect(member.task_id).toBe(agents[i].id)
|
||||
expect(member.has_response).toBe(true)
|
||||
expect(member.response_complete).toBe(true)
|
||||
expect(member.result).toBe(agents[i].response)
|
||||
expect(member.result_truncated).toBeUndefined()
|
||||
expect(member.error).toBeUndefined()
|
||||
expect(member.archive_file).toBeDefined()
|
||||
}
|
||||
|
||||
const opusArchive = await readFile(join(tmpDir, result.members[0].archive_file!), "utf-8")
|
||||
expect(opusArchive).toBe("Opus analysis")
|
||||
|
||||
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
|
||||
expect(metaContent).toContain("archive_name: council-test-")
|
||||
expect(metaContent).toContain("created_at:")
|
||||
expect(metaContent).toContain('member: "Council: Claude Opus"')
|
||||
expect(metaContent).toContain("member_slug: council-claude-opus")
|
||||
expect(metaContent).toContain("has_response: true")
|
||||
expect(metaContent).toContain("response_complete: true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given 1 of 3 output files missing", () => {
|
||||
it("#then returns partial success with error for missing member", async () => {
|
||||
await writeFile(
|
||||
join(tmpDir, ".sisyphus", "task-outputs", "bg_001.md"),
|
||||
mockTaskOutput("Council: Claude Opus", "Opus findings"),
|
||||
"utf-8",
|
||||
)
|
||||
await writeFile(
|
||||
join(tmpDir, ".sisyphus", "task-outputs", "bg_003.md"),
|
||||
mockTaskOutput("Council: Gemini", "Gemini findings"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const toolDef = createCouncilFinalize(tmpDir)
|
||||
const resultStr = await toolDef.execute(
|
||||
{ task_ids: ["bg_001", "bg_002", "bg_003"], name: "partial" },
|
||||
mockCtx,
|
||||
)
|
||||
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||
|
||||
expect(result.members).toHaveLength(3)
|
||||
|
||||
expect(result.members[0].has_response).toBe(true)
|
||||
expect(result.members[0].result).toBe("Opus findings")
|
||||
|
||||
expect(result.members[1].has_response).toBe(false)
|
||||
expect(result.members[1].error).toBe("Task output file not found")
|
||||
expect(result.members[1].member).toBe("unknown")
|
||||
|
||||
expect(result.members[2].has_response).toBe(true)
|
||||
expect(result.members[2].result).toBe("Gemini findings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given large response exceeding 8000 chars", () => {
|
||||
it("#then truncates result and sets result_truncated flag", async () => {
|
||||
const largeResponse = "x".repeat(9000)
|
||||
await writeFile(
|
||||
join(tmpDir, ".sisyphus", "task-outputs", "bg_large.md"),
|
||||
mockTaskOutput("Council: Claude Opus", largeResponse),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const toolDef = createCouncilFinalize(tmpDir)
|
||||
const resultStr = await toolDef.execute(
|
||||
{ task_ids: ["bg_large"], name: "large" },
|
||||
mockCtx,
|
||||
)
|
||||
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||
|
||||
const member = result.members[0]
|
||||
expect(member.has_response).toBe(true)
|
||||
expect(member.result_truncated).toBe(true)
|
||||
expect(member.result).toHaveLength(500)
|
||||
expect(member.result).toBe("x".repeat(500))
|
||||
|
||||
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
||||
expect(fullContent).toHaveLength(9000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given empty response between tags", () => {
|
||||
it("#then returns has_response true with empty string result", async () => {
|
||||
const emptyOutput = [
|
||||
"---",
|
||||
"task_id: bg_empty",
|
||||
"agent: Council: Empty Agent",
|
||||
"session_id: ses_test",
|
||||
"parent_session_id: ses_parent",
|
||||
"status: completed",
|
||||
"completed_at: 2026-02-27T14:00:00.000Z",
|
||||
"---",
|
||||
"",
|
||||
"[assistant] 14:00:00",
|
||||
"<COUNCIL_MEMBER_RESPONSE></COUNCIL_MEMBER_RESPONSE>",
|
||||
].join("\n")
|
||||
|
||||
await writeFile(
|
||||
join(tmpDir, ".sisyphus", "task-outputs", "bg_empty.md"),
|
||||
emptyOutput,
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const toolDef = createCouncilFinalize(tmpDir)
|
||||
const resultStr = await toolDef.execute(
|
||||
{ task_ids: ["bg_empty"], name: "empty" },
|
||||
mockCtx,
|
||||
)
|
||||
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||
|
||||
const member = result.members[0]
|
||||
expect(member.has_response).toBe(true)
|
||||
expect(member.response_complete).toBe(true)
|
||||
expect(member.result).toBe("")
|
||||
expect(member.result_truncated).toBeUndefined()
|
||||
expect(member.archive_file).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { extractCouncilResponse } from "./council-response-extractor"
|
||||
import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types"
|
||||
|
||||
const RESULT_SIZE_LIMIT = 8000
|
||||
const PREVIEW_SIZE = 500
|
||||
|
||||
interface MetaMember {
|
||||
task_id: string
|
||||
member: string
|
||||
member_slug: string
|
||||
task_output_path: string
|
||||
archive_file: string
|
||||
has_response: boolean
|
||||
response_complete: boolean
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function extractAgentFromFrontmatter(content: string): string | null {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!fmMatch) return null
|
||||
const agentLine = fmMatch[1].match(/^agent:\s*(.+)$/m)
|
||||
return agentLine ? agentLine[1].trim() : null
|
||||
}
|
||||
|
||||
function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[]): string {
|
||||
const lines: string[] = [
|
||||
`archive_name: ${archiveName}`,
|
||||
`created_at: ${createdAt}`,
|
||||
"members:",
|
||||
]
|
||||
|
||||
for (const m of members) {
|
||||
lines.push(` - task_id: ${m.task_id}`)
|
||||
lines.push(` member: "${m.member}"`)
|
||||
lines.push(` member_slug: ${m.member_slug}`)
|
||||
lines.push(` task_output_path: ${m.task_output_path}`)
|
||||
lines.push(` archive_file: ${m.archive_file}`)
|
||||
lines.push(` has_response: ${m.has_response}`)
|
||||
lines.push(` response_complete: ${m.response_complete}`)
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Finalize council task outputs: extract COUNCIL_MEMBER_RESPONSE content from raw task output files, write per-member archive files, and create meta.yaml.",
|
||||
args: {
|
||||
task_ids: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.describe("Array of background task IDs whose output files should be processed"),
|
||||
name: tool.schema.string().describe("Council name used in the archive directory name"),
|
||||
},
|
||||
async execute(args: CouncilFinalizeArgs) {
|
||||
const base = basePath ?? process.cwd()
|
||||
const hexId = randomBytes(2).toString("hex")
|
||||
const archiveName = `council-${args.name}-${hexId}`
|
||||
const relArchiveDir = join(".sisyphus", "athena", archiveName)
|
||||
const absArchiveDir = join(base, relArchiveDir)
|
||||
|
||||
await mkdir(absArchiveDir, { recursive: true })
|
||||
|
||||
const members: CouncilMemberResult[] = []
|
||||
const metaMembers: MetaMember[] = []
|
||||
|
||||
for (const taskId of args.task_ids) {
|
||||
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
|
||||
const absTaskOutput = join(base, relTaskOutput)
|
||||
|
||||
let fileContent: string
|
||||
try {
|
||||
fileContent = await readFile(absTaskOutput, "utf-8")
|
||||
} catch {
|
||||
members.push({
|
||||
task_id: taskId,
|
||||
member: "unknown",
|
||||
has_response: false,
|
||||
error: "Task output file not found",
|
||||
})
|
||||
metaMembers.push({
|
||||
task_id: taskId,
|
||||
member: "unknown",
|
||||
member_slug: "unknown",
|
||||
task_output_path: relTaskOutput,
|
||||
archive_file: "",
|
||||
has_response: false,
|
||||
response_complete: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
|
||||
const memberSlug = slugify(agentName)
|
||||
const extraction = extractCouncilResponse(fileContent)
|
||||
|
||||
const relArchiveFile = join(relArchiveDir, `${memberSlug}.md`)
|
||||
const absArchiveFile = join(base, relArchiveFile)
|
||||
|
||||
const memberResult: CouncilMemberResult = {
|
||||
task_id: taskId,
|
||||
member: agentName,
|
||||
has_response: extraction.has_response,
|
||||
}
|
||||
|
||||
if (extraction.has_response) {
|
||||
memberResult.response_complete = extraction.response_complete
|
||||
}
|
||||
|
||||
if (extraction.result !== null) {
|
||||
await writeFile(absArchiveFile, extraction.result, "utf-8")
|
||||
memberResult.archive_file = relArchiveFile
|
||||
|
||||
if (extraction.result.length > RESULT_SIZE_LIMIT) {
|
||||
memberResult.result = extraction.result.slice(0, PREVIEW_SIZE)
|
||||
memberResult.result_truncated = true
|
||||
} else {
|
||||
memberResult.result = extraction.result
|
||||
}
|
||||
}
|
||||
|
||||
members.push(memberResult)
|
||||
metaMembers.push({
|
||||
task_id: taskId,
|
||||
member: agentName,
|
||||
member_slug: memberSlug,
|
||||
task_output_path: relTaskOutput,
|
||||
archive_file: extraction.result !== null ? relArchiveFile : "",
|
||||
has_response: extraction.has_response,
|
||||
response_complete: extraction.response_complete,
|
||||
})
|
||||
}
|
||||
|
||||
const relMetaFile = join(relArchiveDir, "meta.yaml")
|
||||
const absMetaFile = join(base, relMetaFile)
|
||||
const createdAt = new Date().toISOString()
|
||||
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers), "utf-8")
|
||||
|
||||
const result: CouncilFinalizeResult = {
|
||||
archive_dir: relArchiveDir,
|
||||
meta_file: relMetaFile,
|
||||
members,
|
||||
}
|
||||
|
||||
return JSON.stringify(result, null, 2)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createCouncilRead } from "./create-council-read"
|
||||
|
||||
let tempDir: string
|
||||
let sisyphusDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "council-read-test-"))
|
||||
sisyphusDir = join(tempDir, ".sisyphus")
|
||||
await mkdir(sisyphusDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "test-message",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("createCouncilRead", () => {
|
||||
describe("#given an archive file with complete COUNCIL_MEMBER_RESPONSE tags", () => {
|
||||
it("#then returns has_response true, response_complete true, and the content", async () => {
|
||||
const archivePath = join(sisyphusDir, "member-1.txt")
|
||||
await writeFile(archivePath, "Some preamble\n<COUNCIL_MEMBER_RESPONSE>Full analysis here</COUNCIL_MEMBER_RESPONSE>")
|
||||
|
||||
const tool = createCouncilRead()
|
||||
const relativePath = `.sisyphus/member-1.txt`
|
||||
|
||||
process.chdir(tempDir)
|
||||
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.has_response).toBe(true)
|
||||
expect(parsed.response_complete).toBe(true)
|
||||
expect(parsed.result).toBe("Full analysis here")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an archive file with incomplete COUNCIL_MEMBER_RESPONSE tags", () => {
|
||||
it("#then returns has_response true, response_complete false", async () => {
|
||||
const archivePath = join(sisyphusDir, "member-2.txt")
|
||||
await writeFile(archivePath, "<COUNCIL_MEMBER_RESPONSE>Partial analysis still writing...")
|
||||
|
||||
const tool = createCouncilRead()
|
||||
const relativePath = `.sisyphus/member-2.txt`
|
||||
|
||||
process.chdir(tempDir)
|
||||
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.has_response).toBe(true)
|
||||
expect(parsed.response_complete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an archive file with no COUNCIL_MEMBER_RESPONSE tags", () => {
|
||||
it("#then returns has_response false", async () => {
|
||||
const archivePath = join(sisyphusDir, "member-3.txt")
|
||||
await writeFile(archivePath, "Just some plain text without any tags.")
|
||||
|
||||
const tool = createCouncilRead()
|
||||
const relativePath = `.sisyphus/member-3.txt`
|
||||
|
||||
process.chdir(tempDir)
|
||||
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.has_response).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a path outside .sisyphus/", () => {
|
||||
it("#then returns Access denied error", async () => {
|
||||
const tool = createCouncilRead()
|
||||
const result = await tool.execute({ file_path: "/etc/passwd" }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.error).toBe("Access denied: path must be within .sisyphus/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a missing file within .sisyphus/", () => {
|
||||
it("#then returns has_response false with File not found error", async () => {
|
||||
const tool = createCouncilRead()
|
||||
const relativePath = `.sisyphus/nonexistent-file.txt`
|
||||
|
||||
process.chdir(tempDir)
|
||||
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.has_response).toBe(false)
|
||||
expect(parsed.error).toContain("File not found")
|
||||
expect(parsed.error).toContain(relativePath)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a path traversal attempt", () => {
|
||||
it("#then returns Access denied error", async () => {
|
||||
const tool = createCouncilRead()
|
||||
const result = await tool.execute({ file_path: "../../../etc/passwd" }, toolContext)
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
expect(parsed.error).toBe("Access denied: path must be within .sisyphus/")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { extractCouncilResponse } from "./council-response-extractor"
|
||||
|
||||
export function createCouncilRead(): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Read a council archive file and extract the council member response. Use this to access full results for truncated members or for follow-up/cross-check analysis.",
|
||||
args: {
|
||||
file_path: tool.schema.string().describe("Path to the archive file (must be within .sisyphus/)"),
|
||||
},
|
||||
async execute(args: { file_path: string }) {
|
||||
if (!args.file_path.startsWith(".sisyphus/")) {
|
||||
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(args.file_path, "utf-8")
|
||||
const extraction = extractCouncilResponse(content)
|
||||
return JSON.stringify(extraction, null, 2)
|
||||
} catch {
|
||||
return JSON.stringify({ has_response: false, error: `File not found: ${args.file_path}` }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface CouncilFinalizeArgs {
|
||||
task_ids: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CouncilMemberResult {
|
||||
task_id: string
|
||||
member: string
|
||||
has_response: boolean
|
||||
response_complete?: boolean
|
||||
result?: string
|
||||
result_truncated?: boolean
|
||||
archive_file?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CouncilFinalizeResult {
|
||||
archive_dir: string
|
||||
meta_file: string
|
||||
members: CouncilMemberResult[]
|
||||
}
|
||||
Reference in New Issue
Block a user