From 2c27298dc1183a5c45597f29e9628235e729bcdf Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 16:19:19 +0100 Subject: [PATCH] fix(council-archive): simplify finalize output and archive reads --- src/agents/athena/agent.ts | 14 ++- .../council-flow.integration.test.ts | 99 +++++++++++++++---- .../create-council-finalize.test.ts | 68 ++++++++++--- .../create-council-finalize.ts | 76 +++++++++----- .../create-council-read.test.ts | 40 +++++--- .../council-archive/create-council-read.ts | 29 ++++-- src/tools/council-archive/types.ts | 4 +- 7 files changed, 250 insertions(+), 80 deletions(-) diff --git a/src/agents/athena/agent.ts b/src/agents/athena/agent.ts index b32ec998e..61c5eb3e2 100644 --- a/src/agents/athena/agent.ts +++ b/src/agents/athena/agent.ts @@ -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 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 , 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=) 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=), 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=, run_in_background=true, write_output_to_file=true, prompt="Your analysis is incomplete. Please finish and wrap your final analysis in ... 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 diff --git a/src/tools/council-archive/council-flow.integration.test.ts b/src/tools/council-archive/council-flow.integration.test.ts index a60b69041..375082036 100644 --- a/src/tools/council-archive/council-flow.integration.test.ts +++ b/src/tools/council-archive/council-flow.integration.test.ts @@ -1,7 +1,7 @@ /// 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 () => { diff --git a/src/tools/council-archive/create-council-finalize.test.ts b/src/tools/council-archive/create-council-finalize.test.ts index 281f7d217..8f6f8f14f 100644 --- a/src/tools/council-archive/create-council-finalize.test.ts +++ b/src/tools/council-archive/create-council-finalize.test.ts @@ -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") }) }) }) diff --git a/src/tools/council-archive/create-council-finalize.ts b/src/tools/council-archive/create-council-finalize.ts index 8fee91f07..7de8f78c8 100644 --- a/src/tools/council-archive/create-council-finalize.ts +++ b/src/tools/council-archive/create-council-finalize.ts @@ -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, } diff --git a/src/tools/council-archive/create-council-read.test.ts b/src/tools/council-archive/create-council-read.test.ts index 0de597f2e..1a91f3127 100644 --- a/src/tools/council-archive/create-council-read.test.ts +++ b/src/tools/council-archive/create-council-read.test.ts @@ -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\nFull analysis here") + 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, "Partial analysis still writing...") + await writeFile(archivePath, "do not parse this") 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("do not parse this") }) }) - 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") + }) + }) }) diff --git a/src/tools/council-archive/create-council-read.ts b/src/tools/council-archive/create-council-read.ts index dfa5f318e..9a1ce3623 100644 --- a/src/tools/council-archive/create-council-read.ts +++ b/src/tools/council-archive/create-council-read.ts @@ -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) } }, }) diff --git a/src/tools/council-archive/types.ts b/src/tools/council-archive/types.ts index 3472863a0..db1e707dd 100644 --- a/src/tools/council-archive/types.ts +++ b/src/tools/council-archive/types.ts @@ -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 }