fix(council-archive): simplify finalize output and archive reads
This commit is contained in:
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user