fix(council-archive): simplify finalize output and archive reads

This commit is contained in:
ismeth
2026-02-28 16:19:19 +01:00
committed by YeonGyu-Kim
parent f0aef077c8
commit 2c27298dc1
7 changed files with 250 additions and 80 deletions
+9 -5
View File
@@ -127,12 +127,14 @@ Step 4: Track progress with background_wait (metadata only):
Step 4.1: Collect results with council_finalize (after ALL members complete):
- Once all members have reached terminal state, call:
council_finalize(task_ids=[...all task IDs...], name="{topic-slug}")
council_finalize(task_ids=[...all task IDs...], name="{topic-slug}", question="{original user question}", prompt_file="{path from prepare_council_prompt}")
where {topic-slug} is a short descriptive slug of the council topic (e.g., "check-bg-wait-issues", "auth-review").
- council_finalize reads raw output files, extracts <COUNCIL_MEMBER_RESPONSE> content, writes per-member archive files, and returns structured JSON.
Pass "question" with the original user question that triggered this council.
Pass "prompt_file" with the temp file path returned by prepare_council_prompt (it will be moved into the archive).
- council_finalize reads raw output files, extracts clean response content from <COUNCIL_MEMBER_RESPONSE>, writes per-member archive files, and returns structured JSON.
- The returned JSON has: archive_dir, meta_file, and members array.
- Each member entry has: task_id, member, has_response, response_complete, result (or result_truncated if >8000 chars), archive_file.
- For members with result_truncated: true, use council_read(file_path=<archive_file>) to get the full content.
- Each member entry has: task_id, member, has_response, response_complete, and archive_file.
- council_finalize does NOT return member content inline. Read member content from archive_file via council_read(file_path=<archive_file>), which returns raw archive content directly (no tag extraction step).
Step 4.5: Detect failed or stuck members.
For each member in the background_wait JSON response, check:
@@ -145,7 +147,7 @@ Step 4.6: Verify completed members have valid responses.
For each member in the council_finalize result, check:
- has_response: true AND response_complete: true → ✅ Use this result for synthesis.
- has_response: true AND response_complete: false → Member started but didn't finish. Nudge: call task(session_id=<member_session_id>, run_in_background=true, write_output_to_file=true, prompt="Your analysis is incomplete. Please finish and wrap your final analysis in <COUNCIL_MEMBER_RESPONSE>...</COUNCIL_MEMBER_RESPONSE> tags."). After nudge completes, call council_finalize again with the nudged task IDs.
- has_response: false AND status: "completed" → Member completed but didn't use tags. Nudge similarly.
- has_response: false and background_wait status for the same task_id is "completed" → Member completed but didn't use tags. Nudge similarly.
- has_response: false AND error → Member failed to produce output. Apply retry logic (Step 4.7).
Step 4.7: Retry failed members (if configured).
@@ -216,6 +218,8 @@ If the question has both AUDIT and other aspects, use AUDIT format with ACTIONAB
Step 6: Synthesize the collected council member outputs using the format selected in Step 5.
Before synthesis, for every member with has_response=true and archive_file present, read the member output from archive_file using council_read and use that content as the source for synthesis.
**Universal requirements (ALL formats):**
- Track which members agree and disagree on each point — agreement level is your confidence signal
- When only 1 member raises a point, flag it as lower confidence
@@ -1,7 +1,7 @@
/// <reference types="bun-types" />
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createCouncilFinalize } from "./create-council-finalize"
@@ -88,7 +88,7 @@ afterEach(async () => {
describe("council archive integration flow", () => {
describe("#given 3 council members with valid output files", () => {
describe("#when finalize is called and then each archive is read", () => {
it("#then creates archive with correct structure and council_read extracts content from task outputs", async () => {
it("#then creates archive with correct structure and council_read extracts content from archives", async () => {
const agents = [
{ id: "bg_opus", agent: "Council: Claude Opus", response: "Opus deep analysis of architecture" },
{ id: "bg_gpt", agent: "Council: GPT-5", response: "GPT pragmatic code review" },
@@ -119,8 +119,6 @@ describe("council archive integration flow", () => {
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()
}
@@ -142,8 +140,7 @@ describe("council archive integration flow", () => {
const readTool = createCouncilRead(tmpDir)
for (let i = 0; i < agents.length; i++) {
const taskOutputPath = join(".sisyphus", "task-outputs", `${agents[i].id}.md`)
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
const readResult = await readTool.execute({ file_path: result.members[i].archive_file! }, toolContext)
const parsed = JSON.parse(readResult)
expect(parsed.has_response).toBe(true)
@@ -156,7 +153,7 @@ describe("council archive integration flow", () => {
describe("#given a member with incomplete tags (no closing tag)", () => {
describe("#when finalize is called and archive is read", () => {
it("#then has_response is true and response_complete is false in both finalize and read", async () => {
it("#then finalize marks incomplete but council_read still returns raw archived content", async () => {
const taskId = "bg_partial"
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
@@ -180,12 +177,11 @@ describe("council archive integration flow", () => {
expect(archiveContent).toBe("Analysis still in progress...")
const readTool = createCouncilRead(tmpDir)
const taskOutputPath = join(".sisyphus", "task-outputs", `${taskId}.md`)
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
const readResult = await readTool.execute({ file_path: member.archive_file! }, toolContext)
const parsed = JSON.parse(readResult)
expect(parsed.has_response).toBe(true)
expect(parsed.response_complete).toBe(false)
expect(parsed.response_complete).toBe(true)
expect(parsed.result).toBe("Analysis still in progress...")
})
})
@@ -210,7 +206,6 @@ describe("council archive integration flow", () => {
const member = result.members[0]
expect(member.has_response).toBe(false)
expect(member.archive_file).toBeUndefined()
expect(member.result).toBeUndefined()
})
})
})
@@ -239,21 +234,21 @@ describe("council archive integration flow", () => {
expect(result.members).toHaveLength(3)
expect(result.members[0].has_response).toBe(true)
expect(result.members[0].result).toBe("First analysis")
expect(result.members[0].archive_file).toBeDefined()
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("Third analysis")
expect(result.members[2].archive_file).toBeDefined()
})
})
})
describe("#given a very large council response exceeding 8000 chars", () => {
describe("#when finalize is called and then archive is read", () => {
it("#then finalize truncates to 500 char preview but full content is available via council_read", async () => {
it("#then finalize stores full output and council_read returns full response", async () => {
const largeResponse = "A".repeat(9000)
const taskId = "bg_large"
await writeFile(
@@ -271,17 +266,13 @@ describe("council archive integration flow", () => {
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("A".repeat(500))
expect(member.archive_file).toBeDefined()
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
expect(fullContent).toHaveLength(9000)
const readTool = createCouncilRead(tmpDir)
const taskOutputPath = join(".sisyphus", "task-outputs", `${taskId}.md`)
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
const readResult = await readTool.execute({ file_path: member.archive_file! }, toolContext)
const parsed = JSON.parse(readResult)
expect(parsed.has_response).toBe(true)
@@ -291,6 +282,76 @@ describe("council archive integration flow", () => {
})
})
describe("#given question and prompt_file params", () => {
describe("#when finalize is called with question and prompt_file", () => {
it("#then meta.yaml includes question and prompt_file, and prompt file is moved to archive", async () => {
const taskId = "bg_with_meta"
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
mockTaskOutput("Council: Opus", "Analysis result"),
"utf-8",
)
const tmpPromptDir = join(tmpDir, ".sisyphus", "tmp")
await mkdir(tmpPromptDir, { recursive: true })
const promptFile = join(".sisyphus", "tmp", "athena-council-test.md")
await writeFile(join(tmpDir, promptFile), "Council prompt content here", "utf-8")
const finalizeTool = createCouncilFinalize(tmpDir)
const resultStr = await finalizeTool.execute(
{
task_ids: [taskId],
name: "meta-test",
question: "What is the best architecture for this app?",
prompt_file: promptFile,
},
toolContext,
)
const result: CouncilFinalizeResult = JSON.parse(resultStr)
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
expect(metaContent).toContain("question: |")
expect(metaContent).toContain(" What is the best architecture for this app?")
expect(metaContent).toContain("prompt_file:")
expect(metaContent).toContain("council-prompt.md")
const promptDest = join(tmpDir, result.archive_dir, "council-prompt.md")
const promptContent = await readFile(promptDest, "utf-8")
expect(promptContent).toBe("Council prompt content here")
const originalExists = await stat(join(tmpDir, promptFile)).then(() => true).catch(() => false)
expect(originalExists).toBe(false)
})
})
describe("#when finalize is called with question only (no prompt_file)", () => {
it("#then meta.yaml includes question but no prompt_file", async () => {
const taskId = "bg_question_only"
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
mockTaskOutput("Council: GPT", "GPT analysis"),
"utf-8",
)
const finalizeTool = createCouncilFinalize(tmpDir)
const resultStr = await finalizeTool.execute(
{
task_ids: [taskId],
name: "question-only",
question: "How should we handle auth?",
},
toolContext,
)
const result: CouncilFinalizeResult = JSON.parse(resultStr)
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
expect(metaContent).toContain("question: |")
expect(metaContent).toContain(" How should we handle auth?")
expect(metaContent).not.toContain("prompt_file:")
})
})
})
describe("#given a completed council task in background_wait", () => {
describe("#when background_wait returns for that task", () => {
it("#then completed_tasks entry has no result payload", async () => {
@@ -3,6 +3,7 @@ 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 { createCouncilRead } from "./create-council-read"
import type { CouncilFinalizeResult } from "./types"
function mockTaskOutput(agent: string, responseBody: string, complete = true): string {
@@ -71,8 +72,8 @@ describe("createCouncilFinalize", () => {
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).not.toHaveProperty("result")
expect(member).not.toHaveProperty("result_truncated")
expect(member.error).toBeUndefined()
expect(member.archive_file).toBeDefined()
}
@@ -113,19 +114,23 @@ describe("createCouncilFinalize", () => {
expect(result.members).toHaveLength(3)
expect(result.members[0].has_response).toBe(true)
expect(result.members[0].result).toBe("Opus findings")
expect(result.members[0].archive_file).toBeDefined()
expect(result.members[0]).not.toHaveProperty("result")
expect(result.members[0]).not.toHaveProperty("result_truncated")
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")
expect(result.members[2].archive_file).toBeDefined()
expect(result.members[2]).not.toHaveProperty("result")
expect(result.members[2]).not.toHaveProperty("result_truncated")
})
})
describe("#given large response exceeding 8000 chars", () => {
it("#then truncates result and sets result_truncated flag", async () => {
it("#then keeps full content in archive and council_read returns full response", async () => {
const largeResponse = "x".repeat(9000)
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", "bg_large.md"),
@@ -142,12 +147,18 @@ describe("createCouncilFinalize", () => {
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))
expect(member.archive_file).toBeDefined()
expect(member).not.toHaveProperty("result")
expect(member).not.toHaveProperty("result_truncated")
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
expect(fullContent).toHaveLength(9000)
const readTool = createCouncilRead(tmpDir)
const readResult = await readTool.execute({ file_path: member.archive_file! }, mockCtx)
const parsed = JSON.parse(readResult)
expect(parsed.has_response).toBe(true)
expect(parsed.response_complete).toBe(true)
expect(parsed.result).toHaveLength(9000)
expect(parsed.result).toBe(largeResponse)
})
})
@@ -183,9 +194,42 @@ describe("createCouncilFinalize", () => {
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()
expect(member).not.toHaveProperty("result")
expect(member).not.toHaveProperty("result_truncated")
})
})
describe("#given two members that slugify to same member slug", () => {
it("#then each member gets a unique archive_file keyed by task id", async () => {
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", "bg_alpha.md"),
mockTaskOutput("Agent A+B", "First response"),
"utf-8",
)
await writeFile(
join(tmpDir, ".sisyphus", "task-outputs", "bg_beta.md"),
mockTaskOutput("Agent A B", "Second response"),
"utf-8",
)
const toolDef = createCouncilFinalize(tmpDir)
const resultStr = await toolDef.execute(
{ task_ids: ["bg_alpha", "bg_beta"], name: "collision" },
mockCtx,
)
const result: CouncilFinalizeResult = JSON.parse(resultStr)
const firstArchive = result.members[0].archive_file
const secondArchive = result.members[1].archive_file
expect(firstArchive).toBeDefined()
expect(secondArchive).toBeDefined()
expect(firstArchive).not.toBe(secondArchive)
const firstContent = await readFile(join(tmpDir, firstArchive!), "utf-8")
const secondContent = await readFile(join(tmpDir, secondArchive!), "utf-8")
expect(firstContent).toContain("First response")
expect(secondContent).toContain("Second response")
})
})
})
@@ -1,13 +1,11 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { readFile, writeFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { readFile, writeFile, mkdir, rename } from "node:fs/promises"
import { join, isAbsolute, resolve } from "node:path"
import { randomBytes } from "node:crypto"
import { extractCouncilResponse } from "./council-response-extractor"
import { log } from "../../shared/logger"
import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types"
const RESULT_SIZE_LIMIT = 8000
const PREVIEW_SIZE = 500
interface MetaMember {
task_id: string
member: string
@@ -25,6 +23,10 @@ function slugify(text: string): string {
.replace(/^-+|-+$/g, "")
}
function toPosixPath(pathValue: string): string {
return pathValue.replace(/\\/g, "/")
}
function extractAgentFromFrontmatter(content: string): string | null {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (!fmMatch) return null
@@ -32,13 +34,25 @@ function extractAgentFromFrontmatter(content: string): string | null {
return agentLine ? agentLine[1].trim() : null
}
function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[]): string {
function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[], question?: string, promptFile?: string): string {
const lines: string[] = [
`archive_name: ${archiveName}`,
`created_at: ${createdAt}`,
"members:",
]
if (question) {
lines.push(`question: |`)
for (const qLine of question.split("\n")) {
lines.push(` ${qLine}`)
}
}
if (promptFile) {
lines.push(`prompt_file: ${promptFile}`)
}
lines.push("members:")
for (const m of members) {
lines.push(` - task_id: ${m.task_id}`)
lines.push(` member: "${m.member}"`)
@@ -61,12 +75,15 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
.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"),
question: tool.schema.string().optional().describe("Original user question that triggered the council"),
prompt_file: tool.schema.string().optional().describe("Path to the council prompt temp file (will be moved into the archive)"),
},
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 relArchiveDirForOutput = toPosixPath(relArchiveDir)
const absArchiveDir = join(base, relArchiveDir)
await mkdir(absArchiveDir, { recursive: true })
@@ -76,6 +93,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
for (const taskId of args.task_ids) {
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
const relTaskOutputForOutput = toPosixPath(relTaskOutput)
const absTaskOutput = join(base, relTaskOutput)
let fileContent: string
@@ -92,7 +110,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
task_id: taskId,
member: "unknown",
member_slug: "unknown",
task_output_path: relTaskOutput,
task_output_path: relTaskOutputForOutput,
archive_file: "",
has_response: false,
response_complete: false,
@@ -101,10 +119,12 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
}
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
const memberSlug = slugify(agentName)
const memberSlug = slugify(agentName) || "unknown"
const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-")
const extraction = extractCouncilResponse(fileContent)
const relArchiveFile = join(relArchiveDir, `${memberSlug}.md`)
const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`)
const relArchiveFileForOutput = toPosixPath(relArchiveFile)
const absArchiveFile = join(base, relArchiveFile)
const memberResult: CouncilMemberResult = {
@@ -119,14 +139,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
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
}
memberResult.archive_file = relArchiveFileForOutput
}
members.push(memberResult)
@@ -134,21 +147,38 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
task_id: taskId,
member: agentName,
member_slug: memberSlug,
task_output_path: relTaskOutput,
archive_file: extraction.result !== null ? relArchiveFile : "",
task_output_path: relTaskOutputForOutput,
archive_file: extraction.result !== null ? relArchiveFileForOutput : "",
has_response: extraction.has_response,
response_complete: extraction.response_complete,
})
}
let relPromptFile: string | undefined
if (args.prompt_file) {
try {
const promptFilename = "council-prompt.md"
const absPromptSrc = isAbsolute(args.prompt_file) ? args.prompt_file : resolve(base, args.prompt_file)
const absPromptDest = join(absArchiveDir, promptFilename)
await rename(absPromptSrc, absPromptDest).catch(async () => {
const content = await readFile(absPromptSrc, "utf-8")
await writeFile(absPromptDest, content, "utf-8")
})
relPromptFile = toPosixPath(join(relArchiveDir, promptFilename))
} catch (err) {
log("[council-finalize] Failed to move prompt file", { promptFile: args.prompt_file, error: String(err) })
}
}
const relMetaFile = join(relArchiveDir, "meta.yaml")
const relMetaFileForOutput = toPosixPath(relMetaFile)
const absMetaFile = join(base, relMetaFile)
const createdAt = new Date().toISOString()
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers), "utf-8")
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8")
const result: CouncilFinalizeResult = {
archive_dir: relArchiveDir,
meta_file: relMetaFile,
archive_dir: relArchiveDirForOutput,
meta_file: relMetaFileForOutput,
members,
}
@@ -27,10 +27,10 @@ const toolContext = {
}
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 () => {
describe("#given an archive file with clean extracted content", () => {
it("#then returns has_response true, response_complete true, and raw file content", async () => {
const archivePath = join(sisyphusDir, "member-1.txt")
await writeFile(archivePath, "Some preamble\n<COUNCIL_MEMBER_RESPONSE>Full analysis here</COUNCIL_MEMBER_RESPONSE>")
await writeFile(archivePath, "Full analysis here")
const tool = createCouncilRead(tempDir)
const relativePath = `.sisyphus/member-1.txt`
@@ -44,10 +44,10 @@ describe("createCouncilRead", () => {
})
})
describe("#given an archive file with incomplete COUNCIL_MEMBER_RESPONSE tags", () => {
it("#then returns has_response true, response_complete false", async () => {
describe("#given an archive file containing tag-like text", () => {
it("#then returns raw content without re-parsing tags", async () => {
const archivePath = join(sisyphusDir, "member-2.txt")
await writeFile(archivePath, "<COUNCIL_MEMBER_RESPONSE>Partial analysis still writing...")
await writeFile(archivePath, "<COUNCIL_MEMBER_RESPONSE>do not parse this</COUNCIL_MEMBER_RESPONSE>")
const tool = createCouncilRead(tempDir)
const relativePath = `.sisyphus/member-2.txt`
@@ -56,14 +56,15 @@ describe("createCouncilRead", () => {
const parsed = JSON.parse(result)
expect(parsed.has_response).toBe(true)
expect(parsed.response_complete).toBe(false)
expect(parsed.response_complete).toBe(true)
expect(parsed.result).toBe("<COUNCIL_MEMBER_RESPONSE>do not parse this</COUNCIL_MEMBER_RESPONSE>")
})
})
describe("#given an archive file with no COUNCIL_MEMBER_RESPONSE tags", () => {
it("#then returns has_response false", async () => {
describe("#given an archive file with empty content", () => {
it("#then returns empty result content", async () => {
const archivePath = join(sisyphusDir, "member-3.txt")
await writeFile(archivePath, "Just some plain text without any tags.")
await writeFile(archivePath, "")
const tool = createCouncilRead(tempDir)
const relativePath = `.sisyphus/member-3.txt`
@@ -71,7 +72,9 @@ describe("createCouncilRead", () => {
const result = await tool.execute({ file_path: relativePath }, toolContext)
const parsed = JSON.parse(result)
expect(parsed.has_response).toBe(false)
expect(parsed.has_response).toBe(true)
expect(parsed.response_complete).toBe(true)
expect(parsed.result).toBe("")
})
})
@@ -108,4 +111,19 @@ describe("createCouncilRead", () => {
expect(parsed.error).toBe("Access denied: path must be within .sisyphus/")
})
})
describe("#given a Windows-style path under .sisyphus", () => {
it("#then normalizes separators and reads the file successfully", async () => {
const archivePath = join(sisyphusDir, "windows-path.txt")
await writeFile(archivePath, "Windows path response")
const tool = createCouncilRead(tempDir)
const result = await tool.execute({ file_path: ".sisyphus\\windows-path.txt" }, toolContext)
const parsed = JSON.parse(result)
expect(parsed.has_response).toBe(true)
expect(parsed.response_complete).toBe(true)
expect(parsed.result).toBe("Windows path response")
})
})
})
@@ -1,28 +1,41 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { extractCouncilResponse } from "./council-response-extractor"
import { resolve, sep } from "node:path"
function normalizeInputPath(pathValue: string): string {
return pathValue.replace(/\\/g, "/")
}
function isPathWithinDirectory(pathToCheck: string, directory: string): boolean {
const normalizedDir = directory.endsWith(sep) ? directory : `${directory}${sep}`
return pathToCheck === directory || pathToCheck.startsWith(normalizedDir)
}
export function createCouncilRead(basePath?: string): 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.",
"Read a council archive file and return its raw member response content.",
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/")) {
const normalizedInputPath = normalizeInputPath(args.file_path)
if (!normalizedInputPath.startsWith(".sisyphus/")) {
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
}
try {
const base = basePath ?? process.cwd()
const absPath = join(base, args.file_path)
const absPath = resolve(base, normalizedInputPath)
const absSisyphusRoot = resolve(base, ".sisyphus")
if (!isPathWithinDirectory(absPath, absSisyphusRoot)) {
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
}
const content = await readFile(absPath, "utf-8")
const extraction = extractCouncilResponse(content)
return JSON.stringify(extraction, null, 2)
return JSON.stringify({ has_response: true, response_complete: true, result: content }, null, 2)
} catch {
return JSON.stringify({ has_response: false, error: `File not found: ${args.file_path}` }, null, 2)
return JSON.stringify({ has_response: false, error: `File not found: ${normalizedInputPath}` }, null, 2)
}
},
})
+2 -2
View File
@@ -1,6 +1,8 @@
export interface CouncilFinalizeArgs {
task_ids: string[]
name: string
question?: string
prompt_file?: string
}
export interface CouncilMemberResult {
@@ -8,8 +10,6 @@ export interface CouncilMemberResult {
member: string
has_response: boolean
response_complete?: boolean
result?: string
result_truncated?: boolean
archive_file?: string
error?: string
}