/* global process */ /** * Trellis Workflow State Injection Plugin * * Per-turn UserPromptSubmit equivalent for OpenCode. * * On every chat.message, if a Trellis task is active, inject a short * breadcrumb reminding the main AI what task is * active and its expected flow. Breadcrumb text is pulled exclusively * from the project's workflow.md [workflow-state:STATUS] tag blocks — * workflow.md is the single source of truth. There are no fallback * tables in this plugin: when workflow.md is missing or a tag is * absent, the breadcrumb degrades to a generic * "Refer to workflow.md for current step." line so users see (and fix) * the broken state instead of the plugin silently masking it. * * Unlike session-start, this plugin does NOT dedupe — the breadcrumb * should surface on every turn so long conversations don't drift. * * Silently skips when: * - No .trellis/ directory * - No active task in the session runtime context * - task.json malformed or missing status */ import { existsSync, readFileSync } from "fs" import { join } from "path" import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js" // Supports STATUS values with letters, digits, underscores, hyphens // (so "in-review" / "blocked-by-team" work alongside "in_progress"). const TAG_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g /** * Parse workflow.md for [workflow-state:STATUS] blocks. * * Returns {status: body}. workflow.md is the single source of truth — * there are no fallback tables here. Missing tags (or a missing / * unreadable workflow.md) fall back to a generic line in * buildBreadcrumb so users see the broken state and fix workflow.md * rather than the plugin silently masking it. */ function loadBreadcrumbs(directory) { const workflowPath = join(directory, ".trellis", "workflow.md") if (!existsSync(workflowPath)) return {} let content try { content = readFileSync(workflowPath, "utf-8") } catch { return {} } const result = {} for (const match of content.matchAll(TAG_RE)) { const status = match[1] const body = match[2].trim() if (body) result[status] = body } return result } /** * Get (taskId, status) from active task, or null if no active task. */ function getActiveTask(ctx, platformInput = null) { const active = ctx.getActiveTask(platformInput) const taskRef = active.taskPath if (!taskRef) return null const taskDir = ctx.resolveTaskDir(taskRef) if (active.stale || !taskDir || !existsSync(taskDir)) { return { id: taskRef.split("/").pop(), status: "stale", source: active.source } } const taskJsonPath = join(taskDir, "task.json") if (!existsSync(taskJsonPath)) return null try { const data = JSON.parse(readFileSync(taskJsonPath, "utf-8")) const status = typeof data.status === "string" ? data.status : "" if (!status) return null const id = data.id || taskRef.split("/").pop() return { id, status, source: active.source } } catch { return null } } /** * Build the ... block. * - Known status (tag present in workflow.md) → detailed body * - Unknown status (no tag, or workflow.md missing) → generic * "Refer to workflow.md for current step." line * - no_task pseudo-status (id === null) → header omits task info */ function buildBreadcrumb(id, status, templates, source = null) { let body = templates[status] if (body === undefined) { body = "Refer to workflow.md for current step." } let header = id === null ? `Status: ${status}` : `Task: ${id} (${status})` if (source) { header = `${header}\nSource: ${source}` } return `\n${header}\n${body}\n` } // OpenCode 1.2.x expects plugins to be factory functions (see inject-subagent-context.js comment). export default async ({ directory }) => { const ctx = new TrellisContext(directory) debugLog("workflow-state", "Plugin loaded, directory:", directory) return { // chat.message fires on every user message. Inject breadcrumb in-place // so it persists in conversation history. "chat.message": async (input, output) => { try { // Skip Trellis sub-agent turns — the per-turn breadcrumb is for the // main session only; sub-agent context comes from the parent's // tool.execute.before injection. if (isTrellisSubagent(input)) { debugLog("workflow-state", "Skipping trellis subagent turn:", input?.agent) return } if (process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1") { return } if (process.env.OPENCODE_NON_INTERACTIVE === "1") { return } if (!ctx.isTrellisProject()) { return } const templates = loadBreadcrumbs(directory) const task = getActiveTask(ctx, input) const breadcrumb = task ? buildBreadcrumb(task.id, task.status, templates, task.source) : buildBreadcrumb(null, "no_task", templates) const parts = output?.parts || [] const textPartIndex = parts.findIndex( p => p.type === "text" && p.text !== undefined, ) if (textPartIndex !== -1) { const originalText = parts[textPartIndex].text || "" parts[textPartIndex].text = `${breadcrumb}\n\n${originalText}` } else { parts.unshift({ type: "text", text: breadcrumb }) } debugLog( "workflow-state", "Injected breadcrumb for task", task ? task.id : "none", "status", task ? task.status : "no_task", ) } catch (error) { debugLog( "workflow-state", "Error in chat.message:", error instanceof Error ? error.message : String(error), ) } }, } }