diff --git a/src/agents/athena/athena-config-injection.test.ts b/src/agents/athena/athena-config-injection.test.ts index cc2155b22..c11afc379 100644 --- a/src/agents/athena/athena-config-injection.test.ts +++ b/src/agents/athena/athena-config-injection.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, it } from "bun:test" import { createAthenaAgent } from "./agent" @@ -40,9 +42,9 @@ describe("Athena prompt config injection placeholders", () => { it("#then uses sequential workflow step numbering", () => { expect(athenaConfig.prompt).toContain("Step 12: Synthesize") - expect(athenaConfig.prompt).toContain("Step 13: Determine the follow-up path") - expect(athenaConfig.prompt).toContain("Step 14: ACTIONABLE findings") - expect(athenaConfig.prompt).toContain("Step 15: INFORMATIONAL findings") + expect(athenaConfig.prompt).toContain("Step 13: Determine follow-up path from council_finalize runtime guidance") + expect(athenaConfig.prompt).toContain("Step 14: Execute the runtime guidance action flow") + expect(athenaConfig.prompt).toContain("Step 15: Fallback behavior if runtime guidance is missing") }) it("#then omits legacy mixed step labels", () => { @@ -71,11 +73,10 @@ describe("Athena prompt config injection placeholders", () => { expect(step2Index).toBeGreaterThan(step1Index) }) - it("#then keeps edge-case disambiguation inside step 3 classification", () => { + it("#then uses primary-objective wording for step 3 classification", () => { const prompt = athenaConfig.prompt ?? "" - expect(prompt).toContain("Step 3: Classify the question intent.") - expect(prompt).toContain("Classification disambiguation rule:") - expect(prompt).not.toContain("Before selecting a route, apply this interpretation rule") + expect(prompt).toContain("Step 3: Classify the question intent by primary objective.") + expect(prompt).not.toContain("Classification disambiguation rule:") }) it("#then requires question tool routing in self-answerable path", () => { @@ -93,7 +94,7 @@ describe("Athena prompt config injection placeholders", () => { }) it("#then keeps intent classification anchored to step 3 wording", () => { - expect(athenaConfig.prompt).toContain("Step 3: Classify the question intent.") + expect(athenaConfig.prompt).toContain("Step 3: Classify the question intent by primary objective.") expect(athenaConfig.prompt).toContain("Then proceed to Step 2.") expect(athenaConfig.prompt).not.toContain("Then classify intent and proceed to Step 2.") expect(athenaConfig.prompt).not.toContain("Classify intent immediately and proceed to Step 2.") @@ -102,6 +103,12 @@ describe("Athena prompt config injection placeholders", () => { it("#then excludes non-interactive mode branch from runtime prompt", () => { expect(athenaConfig.prompt).not.toContain("Non-interactive mode (Question tool unavailable)") }) + + it("#then requires passing intent to council_finalize and honoring injected runtime guidance", () => { + expect(athenaConfig.prompt).toContain("intent=\"{intent from Step 3}\"") + expect(athenaConfig.prompt).toContain("runtime guidance message injected by council_finalize") + expect(athenaConfig.prompt).toContain("") + }) }) }) }) diff --git a/src/agents/athena/council-runtime-guidance.ts b/src/agents/athena/council-runtime-guidance.ts new file mode 100644 index 000000000..4a7ff3557 --- /dev/null +++ b/src/agents/athena/council-runtime-guidance.ts @@ -0,0 +1,562 @@ +const VALID_INTENTS = [ + "DIAGNOSE", + "AUDIT", + "PLAN", + "EVALUATE", + "EXPLAIN", + "CREATE", + "PERSPECTIVES", + "FREEFORM", +] as const + +export type CouncilIntent = (typeof VALID_INTENTS)[number] + +const RUNTIME_GUIDANCE_BY_INTENT: Record = { + DIAGNOSE: ` + +Use DIAGNOSE synthesis. +- Build: Symptom -> hypotheses -> root cause -> contributing factors -> recommended fix. +- Anchor confidence to agreement level and evidence quality. + + + +Path type: ACTIONABLE. + +1) Treat DIAGNOSE as a single-incident flow. +- Identify the primary root-cause fix candidate as one scoped item. + +2) If no actionable fix candidate is identified, ask: +Question({ + questions: [{ + question: "No actionable findings were identified. What should we do next?", + header: "Next Step", + options: [ + { label: "Ask follow-up", description: "Ask a clarifying question and run another council pass" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +3) If an actionable fix candidate exists, ask action directly (no findings multi-select): +Question({ + questions: [{ + question: "How should we execute this diagnosis fix?", + header: "Action", + options: [ + { label: "Implement (Hephaestus)", description: "Hand off to Hephaestus for direct implementation" }, + { label: "Implement (Sisyphus)", description: "Hand off to Sisyphus for implementation" }, + { label: "Implement (Sisyphus ultrawork)", description: "Hand off to Sisyphus with ultrawork mode" }, + { label: "No action", description: "Review only - no delegation" } + ], + multiple: false + }] +}) + +4) Execute selected action: +- Implement (Hephaestus) -> switch_agent(agent="hephaestus") +- Implement (Sisyphus) -> switch_agent(agent="sisyphus") +- Implement (Sisyphus ultrawork) -> switch_agent(agent="sisyphus") and prefix the handoff context with "ultrawork " +- No action -> acknowledge and end +`, + + AUDIT: ` + +Use AUDIT synthesis. +- Output numbered findings grouped by confidence: unanimous, majority, minority, solo. +- For each finding: issue, impact, evidence, fix direction. + + + +Path type: ACTIONABLE. + +1) Build actionable findings list from audit synthesis. + +2) Ask how the user wants to process findings: +Question({ + questions: [{ + question: "How would you like to process the findings?", + header: "Processing Mode", + options: [ + { label: "One by one", description: "Review findings individually with per-finding decisions" }, + { label: "By severity/urgency", description: "Select findings by criticality first" }, + { label: "By quorum", description: "Select findings by agreement level" } + ], + multiple: false + }] +}) + +3) Branch by processing mode. + +Mode: One by one +- Process findings in batches using ONE Question call per batch. +- Default batch size: 3 findings per batch. +- Hard cap: 5 findings only when findings are short and options are limited. +- For each finding in the batch, provide dynamic options from synthesis (for example A/B/C fix options) plus: + - Skip + - Defer + - Stop review +- Example Question tool call (batch of 3 findings): +Question({ + questions: [ + { + question: "Finding #10: choose how to proceed.", + header: "#10 Action", + options: [ + { label: "Option A", description: "Apply fix option A from synthesis" }, + { label: "Option B", description: "Apply fix option B from synthesis" }, + { label: "Skip", description: "Do not act on this finding now" }, + { label: "Defer", description: "Keep for later" }, + { label: "Stop review", description: "End one-by-one processing" } + ], + multiple: false + }, + { + question: "Finding #11: choose how to proceed.", + header: "#11 Action", + options: [ + { label: "Option A", description: "Apply fix option A from synthesis" }, + { label: "Option C", description: "Apply fix option C from synthesis" }, + { label: "Skip", description: "Do not act on this finding now" }, + { label: "Defer", description: "Keep for later" }, + { label: "Stop review", description: "End one-by-one processing" } + ], + multiple: false + }, + { + question: "Finding #12: choose how to proceed.", + header: "#12 Action", + options: [ + { label: "Option B", description: "Apply fix option B from synthesis" }, + { label: "Skip", description: "Do not act on this finding now" }, + { label: "Defer", description: "Keep for later" }, + { label: "Stop review", description: "End one-by-one processing" } + ], + multiple: false + } + ] +}) +- Keep free-form answers enabled (user may type custom choices like "#10:A, #11:skip"). Parse them. +- Continue batch-by-batch until user stops or all findings are processed. + +Mode: By severity/urgency +- Ask multi-select severity buckets using only non-empty groups. +- Typical groups: Critical, High, Medium, Low. +- Example Question tool call: +Question({ + questions: [{ + question: "Which findings should we act on by severity? You can also type specific finding numbers (e.g. #1, #3, #7).", + header: "Select Findings", + options: [ + // Include ONLY severities that actually have findings. Skip empty ones. + // Replace N with the actual count for each category. + { label: "All Critical (N)", description: "Highest urgency findings" }, + { label: "All High (N)", description: "High-priority findings" }, + { label: "All Medium (N)", description: "Medium-priority findings" }, + { label: "All Low (N)", description: "Lower-priority findings" }, + ], + multiple: true + }] +}) +- Resolve selected buckets into concrete finding IDs. + +Mode: By quorum +- Ask multi-select quorum buckets using only non-empty groups. +- Typical groups: Unanimous, Majority, Minority, Solo. +- Example Question tool call: +Question({ + questions: [{ + question: "Which findings should we act on? You can also type specific finding numbers (e.g. #1, #3, #7).", + header: "Select Findings", + options: [ + // Include ONLY categories that actually have findings. Skip empty ones. + // Replace N with the actual count for each category. + { label: "All Unanimous (N)", description: "Findings agreed on by all members" }, + { label: "All Majority (N)", description: "Findings agreed on by most members" }, + { label: "All Minority (N)", description: "Findings from 2+ members - higher false-positive risk" }, + { label: "All Solo (N)", description: "Single-member findings - potential false positives" }, + ], + multiple: true + }] +}) +- Resolve selected buckets into concrete finding IDs. + +4) If actionable findings count is 0 OR user selected no findings, do NOT ask execution action. Ask: +Question({ + questions: [{ + question: "No findings were selected for action. What should we do next?", + header: "Next Step", + options: [ + { label: "Ask follow-up", description: "Ask a clarifying question and run another council pass" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +5) If selected findings exist, ask what action to take on the selected findings: +Question({ + questions: [{ + question: "How should we handle the selected findings?", + header: "Action", + options: [ + { label: "Fix now (Atlas)", description: "Hand off to Atlas for direct implementation" }, + { label: "Create plan (Prometheus)", description: "Hand off to Prometheus for planning and phased execution" }, + { label: "No action", description: "Review only - no delegation" } + ], + multiple: false + }] +}) + +6) Execute selected action: +- Fix now (Atlas) -> switch_agent(agent="atlas") with ONLY selected findings +- Create plan (Prometheus) -> switch_agent(agent="prometheus") with ONLY selected findings +- No action -> acknowledge and end +`, + + PLAN: ` + +Use PLAN synthesis. +- Consolidate into one execution-ready phased plan. +- For each phase include: goal, tasks, dependencies, risks, effort estimate, and exit criteria. +- If members disagree on sequencing or strategy, preserve alternatives and pick a default recommendation with rationale. +- End with critical path and immediate first step. + + + +Path type: PLAN_EXECUTION. + +1) Ask what to do with this plan: +Question({ + questions: [{ + question: "What should we do with this plan?", + header: "Plan Next Step", + options: [ + { label: "Execute full plan (Prometheus)", description: "Hand off all phases to Prometheus for execution" }, + { label: "Execute selected phase (Prometheus)", description: "Choose one phase and execute only that phase first" }, + { label: "Write to document", description: "Write the plan under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another planning question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +2) If user chooses Execute selected phase (Prometheus), ask: +Question({ + questions: [{ + question: "Which phase should we execute first?", + header: "Select Phase", + options: [ + // Build from synthesized plan phases (for example: Phase 1, Phase 2, Phase 3). + // Include concise phase goal in each description. + ], + multiple: false + }] +}) + +3) Execute selected action: +- Execute full plan (Prometheus) -> switch_agent(agent="prometheus") with full synthesized plan. +- Execute selected phase (Prometheus) -> switch_agent(agent="prometheus") with only the selected phase plus dependencies. +- Write to document -> write the document directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path to the user. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, + + EVALUATE: ` + +Use EVALUATE synthesis. +- Compare options against explicit criteria. +- Surface tradeoffs and finish with a primary recommendation plus fallback conditions. +- State confidence and the key uncertainty that could change the recommendation. + + + +Path type: INFORMATIONAL. + +1) Ask what to do with the evaluation: +Question({ + questions: [{ + question: "What should we do with this evaluation?", + header: "Evaluation Next Step", + options: [ + { label: "Adopt option -> create plan (Prometheus)", description: "Turn a selected option into an execution plan" }, + { label: "Adopt option -> implement now", description: "Implement a selected option immediately" }, + { label: "Write to document", description: "Write under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another comparison question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +2) If user chooses either adopt-option path, ask: +Question({ + questions: [{ + question: "Which option should we adopt?", + header: "Select Option", + options: [ + // Build from synthesized options list (e.g., Option A, Option B, Option C). + ], + multiple: false + }] +}) + +3) If user chooses "Adopt option -> implement now", ask execution agent: +Question({ + questions: [{ + question: "Which execution agent should implement the selected option?", + header: "Execution Agent", + options: [ + { label: "Hephaestus", description: "Direct implementation with Hephaestus" }, + { label: "Sisyphus", description: "Implementation with Sisyphus" }, + { label: "Sisyphus ultrawork", description: "Implementation with Sisyphus using ultrawork mode" } + ], + multiple: false + }] +}) + +4) Execute selected action: +- Adopt option -> create plan (Prometheus) -> switch_agent(agent="prometheus") with selected option and rationale. +- Adopt option -> implement now + Hephaestus -> switch_agent(agent="hephaestus") with selected option. +- Adopt option -> implement now + Sisyphus -> switch_agent(agent="sisyphus") with selected option. +- Adopt option -> implement now + Sisyphus ultrawork -> switch_agent(agent="sisyphus") and prefix handoff context with "ultrawork ". +- Write to document -> write directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, + + EXPLAIN: ` + +Use EXPLAIN synthesis. +- Start with thesis. +- Then mechanisms/interactions. +- Then uncertainties and confidence. +- Include a concise "why this matters" section tied to the user question. + + + +Path type: INFORMATIONAL. + +1) Ask what to do with the explanation: +Question({ + questions: [{ + question: "What should we do with this explanation?", + header: "Explanation Next Step", + options: [ + { label: "Convert to action plan (Prometheus)", description: "Turn insights into a phased plan" }, + { label: "Write to document", description: "Write under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another explanatory question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +2) Execute selected action: +- Convert to action plan (Prometheus) -> switch_agent(agent="prometheus") with synthesized explanation and target outcome. +- Write to document -> write directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, + + CREATE: ` + +Use CREATE synthesis. +- Preserve creations side-by-side as a gallery. +- Do not collapse into a single merged artifact unless the user asks. +- Assign stable IDs to creations (e.g., C1, C2, C3) so user selections are unambiguous. + + + +Path type: INFORMATIONAL. + +1) Ask which creations to carry forward: +Question({ + questions: [{ + question: "Which creations should we carry forward? You can also type IDs (e.g. C1, C3).", + header: "Select Creations", + options: [ + // Build from synthesized gallery IDs and titles. + ], + multiple: true + }] +}) + +2) If no creations are selected, ask: +Question({ + questions: [{ + question: "No creations selected. What should we do next?", + header: "Next Step", + options: [ + { label: "Ask follow-up", description: "Ask for clearer selection criteria" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +3) If creations are selected, ask: +Question({ + questions: [{ + question: "How should we proceed with the selected creations?", + header: "Creation Next Step", + options: [ + { label: "Implement selected creation (Hephaestus)", description: "Direct implementation with Hephaestus" }, + { label: "Implement selected creation (Sisyphus)", description: "Implementation with Sisyphus" }, + { label: "Implement selected creation (Sisyphus ultrawork)", description: "Implementation with Sisyphus using ultrawork mode" }, + { label: "Write selected creation to document", description: "Write under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another creation-focused question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +4) Execute selected action: +- Implement selected creation (Hephaestus) -> switch_agent(agent="hephaestus") with only selected creation(s). +- Implement selected creation (Sisyphus) -> switch_agent(agent="sisyphus") with only selected creation(s). +- Implement selected creation (Sisyphus ultrawork) -> switch_agent(agent="sisyphus") and prefix handoff context with "ultrawork ", including only selected creation(s). +- Write selected creation to document -> write directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, + + PERSPECTIVES: ` + +Use PERSPECTIVES synthesis. +- Map positions. +- Identify tensions. +- Evaluate evidence strength. +- Take a final stance with conditions. +- Name strongest counter-position and what evidence could overturn the final stance. + + + +Path type: INFORMATIONAL. + +1) Ask what to do with these perspectives: +Question({ + questions: [{ + question: "What should we do with this perspectives analysis?", + header: "Perspectives Next Step", + options: [ + { label: "Commit to stance -> create plan (Prometheus)", description: "Turn a chosen stance into a phased plan" }, + { label: "Commit to stance -> implement now", description: "Implement based on a chosen stance immediately" }, + { label: "Write to document", description: "Write under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another perspective question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +2) If user chooses a commit-to-stance path, ask: +Question({ + questions: [{ + question: "Which stance should we commit to?", + header: "Select Stance", + options: [ + // Build from synthesized perspective labels and final stance. + ], + multiple: false + }] +}) + +3) If user chooses "Commit to stance -> implement now", ask execution agent: +Question({ + questions: [{ + question: "Which execution agent should implement this stance?", + header: "Execution Agent", + options: [ + { label: "Hephaestus", description: "Direct implementation with Hephaestus" }, + { label: "Sisyphus", description: "Implementation with Sisyphus" }, + { label: "Sisyphus ultrawork", description: "Implementation with Sisyphus using ultrawork mode" } + ], + multiple: false + }] +}) + +4) Execute selected action: +- Commit to stance -> create plan (Prometheus) -> switch_agent(agent="prometheus") with selected stance and rationale. +- Commit to stance -> implement now + Hephaestus -> switch_agent(agent="hephaestus") with selected stance. +- Commit to stance -> implement now + Sisyphus -> switch_agent(agent="sisyphus") with selected stance. +- Commit to stance -> implement now + Sisyphus ultrawork -> switch_agent(agent="sisyphus") and prefix handoff context with "ultrawork ". +- Write to document -> write directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, + + FREEFORM: ` + +Use FREEFORM synthesis. +- Preserve meaningful diversity across member responses. +- Avoid forcing rigid structure. +- Produce a clear bottom-line answer plus notable alternatives. + + + +Path type: INFORMATIONAL. + +1) Ask what to do next: +Question({ + questions: [{ + question: "What should we do with this result?", + header: "Next Step", + options: [ + { label: "Create plan (Prometheus)", description: "Turn the result into a phased execution plan" }, + { label: "Implement now", description: "Implement directly from this result" }, + { label: "Write to document", description: "Write under .sisyphus/athena/notes/{council-session-name}" }, + { label: "Ask follow-up", description: "Ask another question" }, + { label: "Done", description: "No further action needed" } + ], + multiple: false + }] +}) + +2) If user chooses Implement now, ask execution agent: +Question({ + questions: [{ + question: "Which execution agent should implement this?", + header: "Execution Agent", + options: [ + { label: "Hephaestus", description: "Direct implementation with Hephaestus" }, + { label: "Sisyphus", description: "Implementation with Sisyphus" }, + { label: "Sisyphus ultrawork", description: "Implementation with Sisyphus using ultrawork mode" } + ], + multiple: false + }] +}) + +3) Execute selected action: +- Create plan (Prometheus) -> switch_agent(agent="prometheus") with synthesized result. +- Implement now + Hephaestus -> switch_agent(agent="hephaestus") with synthesized result. +- Implement now + Sisyphus -> switch_agent(agent="sisyphus") with synthesized result. +- Implement now + Sisyphus ultrawork -> switch_agent(agent="sisyphus") and prefix handoff context with "ultrawork ". +- Write to document -> write directly to ".sisyphus/athena/notes/{council-session-name}" and then report the exact path. +- Ask follow-up -> ask user then restart at Step 3. +- Done -> acknowledge and end. +`, +} + +export function getValidCouncilIntents(): readonly CouncilIntent[] { + return VALID_INTENTS +} + +export function resolveCouncilIntent(intent?: string): CouncilIntent | null { + if (!intent) return null + const normalized = intent.toUpperCase() + return (VALID_INTENTS as readonly string[]).includes(normalized) + ? (normalized as CouncilIntent) + : null +} + +export function buildAthenaRuntimeGuidance(intent: CouncilIntent): string { + return [ + "", + "source: council_finalize", + `intent: ${intent}`, + RUNTIME_GUIDANCE_BY_INTENT[intent].trim(), + "", + ].join("\n\n") +} diff --git a/src/agents/athena/index.ts b/src/agents/athena/index.ts index 6d812c3b5..88adce0c2 100644 --- a/src/agents/athena/index.ts +++ b/src/agents/athena/index.ts @@ -1,3 +1,9 @@ export { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./agent" export { createCouncilMemberAgent, COUNCIL_MEMBER_PROMPT, COUNCIL_SOLO_ADDENDUM, COUNCIL_DELEGATION_ADDENDUM } from "./council-member-agent" export { COUNCIL_INTENT_ADDENDUMS } from "./council-intent-addendums" +export { + buildAthenaRuntimeGuidance, + getValidCouncilIntents, + resolveCouncilIntent, +} from "./council-runtime-guidance" +export type { CouncilIntent } from "./council-runtime-guidance" diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index aa75c22db..f66cdc0a4 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -32,6 +32,7 @@ import { createPrepareCouncilPromptTool, } from "../tools" import { createCouncilFinalize, createCouncilRead } from "../tools/council-archive" +import { contextCollector } from "../features/context-injector" import { getMainSessionID } from "../features/claude-code-session-state" import { filterDisabledTools } from "../shared/disabled-tools" import { isTaskSystemEnabled, log } from "../shared" @@ -281,7 +282,7 @@ export function createToolRegistry(args: { ...taskToolsRecord, ...hashlineToolsRecord, prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory), - council_finalize: createCouncilFinalize(ctx.directory), + council_finalize: createCouncilFinalize(ctx.directory, { contextCollector }), council_read: createCouncilRead(ctx.directory), } diff --git a/src/tools/council-archive/create-council-finalize.test.ts b/src/tools/council-archive/create-council-finalize.test.ts index 8f6f8f14f..7bcd3522b 100644 --- a/src/tools/council-archive/create-council-finalize.test.ts +++ b/src/tools/council-archive/create-council-finalize.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, it, beforeEach } from "bun:test" import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises" import { join } from "node:path" @@ -232,4 +234,165 @@ describe("createCouncilFinalize", () => { expect(secondContent).toContain("Second response") }) }) + + describe("#given intent-based runtime guidance injection", () => { + it("#then registers critical custom context for Athena runtime guidance", async () => { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_intent.md"), + mockTaskOutput("Council: GPT-5", "Plan proposal"), + "utf-8", + ) + + const calls: Array<{ sessionID: string; content: string; priority?: string; source: string; id: string }> = [] + const collector = { + register: (sessionID: string, options: { id: string; source: string; content: string; priority?: string }) => { + calls.push({ + sessionID, + id: options.id, + source: options.source, + content: options.content, + priority: options.priority, + }) + }, + } + + const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const result = await toolDef.execute( + { task_ids: ["bg_intent"], name: "intent", intent: "PLAN" }, + mockCtx, + ) + + expect(() => JSON.parse(result)).not.toThrow() + expect(calls).toHaveLength(1) + expect(calls[0].sessionID).toBe(mockCtx.sessionID) + expect(calls[0].id).toBe("athena-runtime-guidance") + expect(calls[0].source).toBe("custom") + expect(calls[0].priority).toBe("critical") + expect(calls[0].content).toContain("") + expect(calls[0].content).toContain("intent: PLAN") + expect(calls[0].content).toContain("Execute full plan (Prometheus)") + expect(calls[0].content).toContain("Execute selected phase (Prometheus)") + expect(calls[0].content).toContain(".sisyphus/athena/notes/{council-session-name}") + expect(calls[0].content).not.toContain("Hand off to Atlas to save the plan as .md") + }) + + it("#then emits diagnose action options for hephaestus and sisyphus", async () => { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_diagnose.md"), + mockTaskOutput("Council: Claude", "Root cause found"), + "utf-8", + ) + + const calls: Array<{ content: string }> = [] + const collector = { + register: (_sessionID: string, options: { content: string }) => { + calls.push({ content: options.content }) + }, + } + + const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const result = await toolDef.execute( + { task_ids: ["bg_diagnose"], name: "diagnose", intent: "DIAGNOSE" }, + mockCtx, + ) + + expect(() => JSON.parse(result)).not.toThrow() + expect(calls).toHaveLength(1) + expect(calls[0].content).toContain("Implement (Hephaestus)") + expect(calls[0].content).toContain("Implement (Sisyphus)") + expect(calls[0].content).toContain("Implement (Sisyphus ultrawork)") + expect(calls[0].content).toContain("switch_agent(agent=\"hephaestus\")") + expect(calls[0].content).toContain("switch_agent(agent=\"sisyphus\")") + expect(calls[0].content).toContain("prefix the handoff context with \"ultrawork \"") + expect(calls[0].content).not.toContain("Fix now (Atlas)") + expect(calls[0].content).not.toContain("Create plan (Prometheus)") + }) + + it("#then emits audit processing mode and batching guidance", async () => { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_audit.md"), + mockTaskOutput("Council: Claude", "Audit findings"), + "utf-8", + ) + + const calls: Array<{ content: string }> = [] + const collector = { + register: (_sessionID: string, options: { content: string }) => { + calls.push({ content: options.content }) + }, + } + + const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const result = await toolDef.execute( + { task_ids: ["bg_audit"], name: "audit", intent: "AUDIT" }, + mockCtx, + ) + + expect(() => JSON.parse(result)).not.toThrow() + expect(calls).toHaveLength(1) + expect(calls[0].content).toContain("How would you like to process the findings?") + expect(calls[0].content).toContain("One by one") + expect(calls[0].content).toContain("By severity/urgency") + expect(calls[0].content).toContain("By quorum") + expect(calls[0].content).toContain("Default batch size: 3 findings per batch") + expect(calls[0].content).toContain("Hard cap: 5 findings") + expect(calls[0].content).toContain("Example Question tool call (batch of 3 findings)") + expect(calls[0].content).toContain("Finding #10: choose how to proceed.") + expect(calls[0].content).toContain("#10 Action") + expect(calls[0].content).toContain("Stop review") + expect(calls[0].content).toContain("#10:A, #11:skip") + expect(calls[0].content).toContain("Which findings should we act on by severity?") + expect(calls[0].content).toContain("All Critical (N)") + expect(calls[0].content).toContain("All High (N)") + expect(calls[0].content).toContain("All Medium (N)") + expect(calls[0].content).toContain("All Low (N)") + expect(calls[0].content).toContain("Which findings should we act on? You can also type specific finding numbers") + expect(calls[0].content).toContain("All Unanimous (N)") + expect(calls[0].content).toContain("All Majority (N)") + expect(calls[0].content).toContain("All Minority (N)") + expect(calls[0].content).toContain("All Solo (N)") + expect(calls[0].content).toContain("Fix now (Atlas)") + expect(calls[0].content).toContain("Create plan (Prometheus)") + }) + + it("#then emits informational write-to-document path without atlas delegation", async () => { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_eval.md"), + mockTaskOutput("Council: Claude", "Option comparison"), + "utf-8", + ) + + const calls: Array<{ content: string }> = [] + const collector = { + register: (_sessionID: string, options: { content: string }) => { + calls.push({ content: options.content }) + }, + } + + const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const result = await toolDef.execute( + { task_ids: ["bg_eval"], name: "eval", intent: "EVALUATE" }, + mockCtx, + ) + + expect(() => JSON.parse(result)).not.toThrow() + expect(calls).toHaveLength(1) + expect(calls[0].content).toContain("What should we do with this evaluation?") + expect(calls[0].content).toContain("Adopt option -> create plan (Prometheus)") + expect(calls[0].content).toContain("Adopt option -> implement now") + expect(calls[0].content).toContain(".sisyphus/athena/notes/{council-session-name}") + expect(calls[0].content).not.toContain("Write to document (Atlas)") + }) + + it("#then rejects invalid intent values", async () => { + const toolDef = createCouncilFinalize(tmpDir) + const result = await toolDef.execute( + { task_ids: ["bg_none"], name: "invalid-intent", intent: "NOT_A_REAL_INTENT" }, + mockCtx, + ) + + expect(result).toContain("Invalid intent") + expect(result).toContain("NOT_A_REAL_INTENT") + }) + }) }) diff --git a/src/tools/council-archive/create-council-finalize.ts b/src/tools/council-archive/create-council-finalize.ts index 7de8f78c8..edcd337ab 100644 --- a/src/tools/council-archive/create-council-finalize.ts +++ b/src/tools/council-archive/create-council-finalize.ts @@ -3,7 +3,13 @@ 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 { + buildAthenaRuntimeGuidance, + getValidCouncilIntents, + resolveCouncilIntent, +} from "../../agents/athena" import { log } from "../../shared/logger" +import type { ContextCollector } from "../../features/context-injector" import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types" interface MetaMember { @@ -16,6 +22,12 @@ interface MetaMember { response_complete: boolean } +type RegisterContext = Pick + +type CouncilFinalizeToolContext = { + sessionID?: string +} + function slugify(text: string): string { return text .toLowerCase() @@ -66,19 +78,46 @@ function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMem return lines.join("\n") + "\n" } -export function createCouncilFinalize(basePath?: string): ToolDefinition { +export function createCouncilFinalize( + basePath?: string, + options?: { contextCollector?: RegisterContext } +): ToolDefinition { + const collector = options?.contextCollector + 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.", + "Finalize council task outputs: extract COUNCIL_MEMBER_RESPONSE content from raw task output files, write per-member archive files, inject intent-specific Athena runtime guidance, 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"), + intent: tool.schema + .string() + .optional() + .describe(`Classified question intent used for runtime Athena guidance injection. Valid intents: ${getValidCouncilIntents().join(", ")}`), 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) { + async execute(args: CouncilFinalizeArgs, toolContext: CouncilFinalizeToolContext) { + const resolvedIntent = resolveCouncilIntent(args.intent) + if (args.intent && !resolvedIntent) { + return `Invalid intent: "${args.intent}". Valid intents: ${getValidCouncilIntents().join(", ")}.` + } + + if (collector && resolvedIntent && toolContext.sessionID) { + collector.register(toolContext.sessionID, { + id: "athena-runtime-guidance", + source: "custom", + priority: "critical", + content: buildAthenaRuntimeGuidance(resolvedIntent), + metadata: { + intent: resolvedIntent, + source: "council_finalize", + }, + }) + } + const base = basePath ?? process.cwd() const hexId = randomBytes(2).toString("hex") const archiveName = `council-${args.name}-${hexId}` diff --git a/src/tools/council-archive/types.ts b/src/tools/council-archive/types.ts index db1e707dd..ae6e3d598 100644 --- a/src/tools/council-archive/types.ts +++ b/src/tools/council-archive/types.ts @@ -1,6 +1,7 @@ export interface CouncilFinalizeArgs { task_ids: string[] name: string + intent?: string question?: string prompt_file?: string } diff --git a/src/tools/switch-agent/tools.test.ts b/src/tools/switch-agent/tools.test.ts index 2a91780aa..248e43262 100644 --- a/src/tools/switch-agent/tools.test.ts +++ b/src/tools/switch-agent/tools.test.ts @@ -86,6 +86,40 @@ describe("switch_agent tool", () => { expect(entry?.agent).toBe("prometheus") }) + //#given valid hephaestus switch args + //#when execute is called + //#then it stores pending switch for hephaestus + test("should queue switch to hephaestus", async () => { + const tool = createToolWithMockClient() + const result = await tool.execute( + { agent: "Hephaestus", context: "Implement the selected diagnosis fix" }, + toolContext + ) + + expect(result).toContain("hephaestus") + expect(result).toContain("switch") + + const entry = consumePendingSwitch(sessionID) + expect(entry?.agent).toBe("hephaestus") + }) + + //#given valid sisyphus switch args + //#when execute is called + //#then it stores pending switch for sisyphus + test("should queue switch to sisyphus", async () => { + const tool = createToolWithMockClient() + const result = await tool.execute( + { agent: "Sisyphus", context: "Implement the selected diagnosis fix" }, + toolContext + ) + + expect(result).toContain("sisyphus") + expect(result).toContain("switch") + + const entry = consumePendingSwitch(sessionID) + expect(entry?.agent).toBe("sisyphus") + }) + //#given an invalid agent name //#when execute is called //#then it returns an error