From 0f4ff232408bb53355a55bdec299e0d1a5e30648 Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 6 Oct 2026 19:17:14 +0200 Subject: [PATCH 1/3] fix: migrate core plugins to OpenCode V2 (#107) --- .opencode/modules/core/plugins/ccgs-hooks.ts | 251 +++++++---- .../core/plugins/changelog-generator.ts | 134 ++++-- .../modules/core/plugins/drift-detector.ts | 221 ++++++--- .opencode/plugins/README.md | 198 ++++---- .opencode/plugins/ccgs-hooks.ts | 251 +++++++---- .opencode/plugins/changelog-generator.ts | 138 ++++-- .opencode/plugins/drift-detector.ts | 221 ++++++--- .opencode/plugins/tests/test-plugin-v2.mjs | 424 ++++++++++++++++++ opencode.json | 6 +- 9 files changed, 1349 insertions(+), 495 deletions(-) create mode 100644 .opencode/plugins/tests/test-plugin-v2.mjs diff --git a/.opencode/modules/core/plugins/ccgs-hooks.ts b/.opencode/modules/core/plugins/ccgs-hooks.ts index 11e16aa..8caf3d7 100644 --- a/.opencode/modules/core/plugins/ccgs-hooks.ts +++ b/.opencode/modules/core/plugins/ccgs-hooks.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import { execSync } from "child_process" import * as fs from "fs" import * as path from "path" @@ -43,10 +43,6 @@ function normalizePath(p: string): string { return p.replace(/\\/g, "/") } -function getProjectRoot(directory: string | undefined, worktree: string | undefined): string { - return directory || worktree || process.cwd() -} - function logAudit(projectRoot: string, message: string) { try { const dir = path.join(projectRoot, "production", "session-logs") @@ -378,6 +374,7 @@ export function detectSkillChange(filePath: string): string | null { } export function validateAssetPath(projectRoot: string, filePath: string): { warnings: string[]; errors: string[] } { + filePath = normalizePath(filePath) const warnings: string[] = [] const errors: string[] = [] @@ -391,8 +388,9 @@ export function validateAssetPath(projectRoot: string, filePath: string): { warn warnings.push(`NAMING: ${filePath} must be lowercase with underscores (got: ${filename})`) } - if (/\/assets\/data\/.*\.json$/.test(filePath)) { - if (fs.existsSync(filePath) && !validateJson(filePath)) { + if (/(?:^|\/)assets\/data\/.*\.json$/.test(filePath)) { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath) + if (fs.existsSync(absolutePath) && !validateJson(absolutePath)) { errors.push(`FORMAT: ${filePath} is not valid JSON`) } } @@ -547,132 +545,209 @@ export function detectPushToProtected(cmd: string, currentBranch: string): strin return "" } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client.app.log({ body: { service, level, message, extra } }).catch(() => {}) +type PluginLogLevel = "debug" | "info" | "warn" | "error" + +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: PluginLogLevel, message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) + } + return { + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), } - return { debug: (m: string, x?: any) => log("debug", m, x), info: (m: string, x?: any) => log("info", m, x), warn: (m: string, x?: any) => log("warn", m, x), error: (m: string, x?: any) => log("error", m, x) } } -export const CCGSHooks: Plugin = async ({ project, client, $, directory, worktree }) => { - const projectRoot = getProjectRoot(directory, worktree) - const logger = createPluginLogger(client, "ccgs-hooks") - const gc = (s: string) => globalThis[s as any] - const log = client?.app?.log ? logger : { debug: gc, info: gc, warn: gc, error: gc } +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} - log.info("Plugin loaded", { projectRoot }) - logAudit(projectRoot, "Plugin loaded") +function getAgentType(args: Record): string { + const agentType = args.subagent_type || args.subagentType || args.agent_type || args.agentType + return typeof agentType === "string" ? agentType : "" +} - return { - event: async ({ event }) => { - try { - if (event.type === "session.created") { - const ctx = handleSessionCreated(projectRoot) - const gaps = handleDetectGaps(projectRoot) - log.info("Session context", { summary: ctx.split("\n")[0] || "no context" }) - if (gaps.length > 0) { - log.warn("Documentation gaps", { gaps: gaps.length, details: gaps }) - } - } else if (event.type === "session.idle" || event.type === "server.instance.disposed") { - handleSessionIdle(projectRoot) - log.info("Session idle — state archived") - } - } catch (err) { - log.error(`Error in event ${event.type}`, { error: String(err) }) - logAudit(projectRoot, `ERROR in event ${event.type}: ${err}`) - } - }, +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } + + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } + + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false + } +} + +/** OpenCode V2 plugin that wires CCGS session and tool behavior into the runtime. */ +export const CCGSHooks: OpenCodePlugin.Plugin = { + id: "ocgs.ccgs-hooks", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("ccgs-hooks") - "experimental.session.compacting": async (input, output) => { + logger.info("Plugin loaded", { projectRoot }) + logAudit(projectRoot, "Plugin loaded") + + await ctx.session.hook("compaction", (event) => { const context = buildCompactionContext(projectRoot) logCompactionEvent(projectRoot) - output.context.push(context) - }, - - "experimental.compaction.autocontinue": async (input, output) => { - const msg = handlePostCompact(projectRoot) - log.info(msg) - }, - - "tool.execute.before": async (input, output) => { - if (isGitRepo(projectRoot) && input.tool === "bash" && output.args?.command) { - const cmd = output.args.command as string - if (/^git\s+push/.test(cmd)) { - const matched = detectPushToProtected(cmd, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) + event.system.push({ type: "text", text: context }) + }) + + await ctx.tool.hook("execute.before", (event) => { + const args = getToolArgs(event.input) + const command = typeof args.command === "string" ? args.command : "" + + if (isGitRepo(projectRoot) && event.tool === "bash" && command) { + if (/^git\s+push/.test(command)) { + const matched = detectPushToProtected(command, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) if (matched) { - log.warn(`Push to protected branch '${matched}'`, { branch: matched }) + logger.warn(`Push to protected branch '${matched}'`, { branch: matched }) logAudit(projectRoot, `Push to protected branch '${matched}' detected.`) logAudit(projectRoot, "Reminder: Ensure build passes, unit tests pass, and no S1/S2 bugs exist.") } } - if (/^git\s+commit/.test(cmd)) { + if (/^git\s+commit/.test(command)) { const staged = git(projectRoot, "diff", "--cached", "--name-only") if (!staged) return const result = validateCommitFiles(projectRoot, staged.split("\n")) if (result.errors.length > 0) { - log.error("Commit blocked by validation errors", { errors: result.errors }) + logger.error("Commit blocked by validation errors", { errors: result.errors }) throw new Error(result.errors.join("\n")) } if (result.warnings.length > 0) { - log.warn("Commit warnings", { warnings: result.warnings }) - result.warnings.forEach((w) => logAudit(projectRoot, w)) + logger.warn("Commit warnings", { warnings: result.warnings }) + result.warnings.forEach((warning) => logAudit(projectRoot, warning)) } } } - if (input.tool === "task") { - const agentType = - (output.args?.subagent_type as string) || - (output.args?.subagentType as string) || - (output.args?.agent_type as string) || - (output.args?.agentType as string) || - "" + if (event.tool === "task") { + const agentType = getAgentType(args) handleLogAgent(projectRoot, agentType) - log.debug("Agent invoked", { agentType }) + logger.debug("Agent invoked", { agentType }) } - }, + }) - "tool.execute.after": async (input, output) => { + await ctx.tool.hook("execute.after", (event) => { + if (event.status !== "completed") return + + const args = getToolArgs(event.input) const filePath = normalizePath( - (input.args?.filePath as string) || - (input.args?.path as string) || - (output.args?.filePath as string) || - (output.args?.path as string) || - "" + (typeof args.filePath === "string" ? args.filePath : "") || + (typeof args.path === "string" ? args.path : "") ) if (!filePath) return const assetResult = validateAssetPath(projectRoot, filePath) if (assetResult.warnings.length > 0) { - log.warn("Asset warnings", { warnings: assetResult.warnings }) - assetResult.warnings.forEach((w) => logAudit(projectRoot, w)) + logger.warn("Asset warnings", { warnings: assetResult.warnings }) + assetResult.warnings.forEach((warning) => logAudit(projectRoot, warning)) logAudit(projectRoot, "(Warnings are advisory. Fix before final commit.)") } if (assetResult.errors.length > 0) { - log.error("Asset validation failed", { errors: assetResult.errors }) - assetResult.errors.forEach((e) => logAudit(projectRoot, e)) - throw new Error("Asset validation failed. Fix errors before proceeding.") + logger.error("Asset validation failed", { errors: assetResult.errors }) + assetResult.errors.forEach((error) => logAudit(projectRoot, error)) + + const existingContent = typeof event.result.content === "string" + ? (event.result.content ? [{ type: "text" as const, text: event.result.content }] : []) + : [...(event.result.content ?? [])] + event.result = { + ...event.result, + content: [ + ...existingContent, + { type: "text" as const, text: "Asset validation failed. Fix errors before proceeding." }, + ...assetResult.errors.map((error) => ({ type: "text" as const, text: error })), + ], + } } - if (input.tool === "task") { - const agentType = - (output.args?.subagent_type as string) || - (output.args?.subagentType as string) || - (output.args?.agent_type as string) || - (output.args?.agentType as string) || - "" + if (event.tool === "task") { + const agentType = getAgentType(args) handleLogAgentStop(projectRoot, agentType) - log.debug("Agent completed", { agentType }) + logger.debug("Agent completed", { agentType }) } const skillChange = detectSkillChange(filePath) if (skillChange) { - log.info(`Skill modified: ${skillChange}`, { skill: skillChange }) + logger.info(`Skill modified: ${skillChange}`, { skill: skillChange }) logAudit(projectRoot, `=== Skill Modified: ${skillChange} ===`) logAudit(projectRoot, `Run /skill-test static ${skillChange} to validate structural compliance.`) } - }, - } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.created" && event.type !== "session.idle" && event.type !== "session.compacted") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + try { + if (event.type === "session.created") { + const context = handleSessionCreated(projectRoot) + const gaps = handleDetectGaps(projectRoot) + logger.info("Session context", { summary: context.split("\n")[0] || "no context" }) + if (gaps.length > 0) { + logger.warn("Documentation gaps", { gaps: gaps.length, details: gaps }) + } + } else if (event.type === "session.idle") { + handleSessionIdle(projectRoot) + logger.info("Session idle — state archived") + } else if (event.type === "session.compacted") { + logger.info(handlePostCompact(projectRoot)) + } + } catch (error) { + logger.error(`Error in event ${event.type}`, { error: String(error) }) + logAudit(projectRoot, `ERROR in event ${event.type}: ${error}`) + } + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) + } + } + })() + + return () => controller.abort() + }, } + +export default CCGSHooks diff --git a/.opencode/modules/core/plugins/changelog-generator.ts b/.opencode/modules/core/plugins/changelog-generator.ts index ad5c419..6680c21 100644 --- a/.opencode/modules/core/plugins/changelog-generator.ts +++ b/.opencode/modules/core/plugins/changelog-generator.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import { execSync } from "child_process" import * as fs from "fs" import * as path from "path" @@ -178,16 +178,66 @@ function updateChangelogFile(projectRoot: string, version: string, content: stri fs.writeFileSync(filePath, updated) } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client?.app?.log({ body: { service, level, message, extra } }).catch(() => {}) +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: "debug" | "info" | "warn" | "error", message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) } return { - debug: (m: string, x?: any) => log("debug", m, x), - info: (m: string, x?: any) => log("info", m, x), - warn: (m: string, x?: any) => log("warn", m, x), - error: (m: string, x?: any) => log("error", m, x), + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), + } +} + +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} + +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } + + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } + + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false } } @@ -210,36 +260,50 @@ export function generateChangelogs(projectRoot: string, version?: string): { int } } -export const ChangelogGenerator: Plugin = async ({ project, client, directory, worktree }) => { - const projectRoot = directory || worktree || process.cwd() - const logger = createPluginLogger(client, "changelog-generator") +/** OpenCode V2 plugin that previews changelog updates and detects changelog commands. */ +export const ChangelogGenerator: OpenCodePlugin.Plugin = { + id: "ocgs.changelog-generator", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("changelog-generator") - logger.info("Changelog generator loaded", { projectRoot }) + logger.info("Changelog generator loaded", { projectRoot }) - return { - event: async ({ event }) => { - // Auto-generate changelog on session idle for uncommitted work - if (event.type === "session.idle" || event.type === "server.instance.disposed") { - try { - const { internal, player } = generateChangelogs(projectRoot, "unreleased") - if (!internal.includes("No changes")) { - logger.info("Changelog generated with unreleased changes — run changelog-generator to write CHANGELOG.md.") + await ctx.tool.hook("execute.before", (event) => { + if (event.tool !== "bash") return + + const args = getToolArgs(event.input) + const command = typeof args.command === "string" ? args.command : "" + if (command.includes("changelog") || command.includes("CHANGELOG")) { + logger.info("Changelog-related command detected — consider running the changelog generator") + } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.idle") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + + try { + const { internal } = generateChangelogs(projectRoot, "unreleased") + if (!internal.includes("No changes")) { + logger.info("Changelog generated with unreleased changes — run changelog-generator to write CHANGELOG.md.") + } + } catch (error) { + logger.error("Failed to generate changelog preview", { error: String(error) }) } - } catch (err) { - logger.error("Failed to generate changelog preview", { error: String(err) }) + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) } } - }, + })() - "tool.execute.before": async (input, output) => { - if (input.tool !== "bash") return - - const cmd = output.args?.command as string || "" - - // Detect changelog-related commands - if (cmd.includes("changelog") || cmd.includes("CHANGELOG")) { - logger.info("Changelog-related command detected — consider running the changelog generator") - } - }, - } + return () => controller.abort() + }, } + +export default ChangelogGenerator diff --git a/.opencode/modules/core/plugins/drift-detector.ts b/.opencode/modules/core/plugins/drift-detector.ts index bca63ab..37d1692 100644 --- a/.opencode/modules/core/plugins/drift-detector.ts +++ b/.opencode/modules/core/plugins/drift-detector.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import * as fs from "fs" import * as path from "path" @@ -262,89 +262,90 @@ function detectCommandDrift(projectRoot: string, filePath: string): DriftIssue[] return issues } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client?.app?.log({ body: { service, level, message, extra } }).catch(() => {}) +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: "debug" | "info" | "warn" | "error", message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) } return { - debug: (m: string, x?: any) => log("debug", m, x), - info: (m: string, x?: any) => log("info", m, x), - warn: (m: string, x?: any) => log("warn", m, x), - error: (m: string, x?: any) => log("error", m, x), + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), } } -export const DriftDetector: Plugin = async ({ project, client, directory, worktree }) => { - const projectRoot = directory || worktree || process.cwd() - const logger = createPluginLogger(client, "drift-detector") - - logger.info("Drift detector loaded", { projectRoot }) +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} - return { - event: async ({ event }) => { - if (event.type !== "session.created") return - - // Full scan on session start - logger.info("Running drift detection scan...") - const allIssues: DriftIssue[] = [] - - const agentsDir = path.join(projectRoot, ".agents", "agents") - if (fs.existsSync(agentsDir)) { - for (const file of fs.readdirSync(agentsDir)) { - if (!file.endsWith(".md")) continue - const relPath = `.agents/agents/${file}` - allIssues.push(...detectAgentDrift(projectRoot, relPath)) - } - } +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } - const skillsDir = path.join(projectRoot, ".agents", "skills") - if (fs.existsSync(skillsDir)) { - for (const dir of fs.readdirSync(skillsDir)) { - const skillPath = path.join(skillsDir, dir) - if (!fs.statSync(skillPath).isDirectory()) continue - const relPath = `.agents/skills/${dir}/SKILL.md` - if (fs.existsSync(path.join(projectRoot, relPath))) { - allIssues.push(...detectSkillDrift(projectRoot, relPath)) - } - } - } + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } - const commandsDir = path.join(projectRoot, ".agents", "commands") - if (fs.existsSync(commandsDir)) { - for (const file of fs.readdirSync(commandsDir)) { - if (!file.endsWith(".md") || file === "README.md") continue - const relPath = `.agents/commands/${file}` - allIssues.push(...detectCommandDrift(projectRoot, relPath)) - } - } + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false + } +} - const high = allIssues.filter((i) => i.severity === "HIGH") - const medium = allIssues.filter((i) => i.severity === "MEDIUM") - const low = allIssues.filter((i) => i.severity === "LOW") +/** OpenCode V2 plugin that reports agent, skill, and command drift. */ +export const DriftDetector: OpenCodePlugin.Plugin = { + id: "ocgs.drift-detector", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("drift-detector") - if (high.length > 0) { - logger.error(`Drift detected: ${high.length} HIGH severity issues`, { issues: high }) - } - if (medium.length > 0) { - logger.warn(`Drift detected: ${medium.length} MEDIUM severity issues`, { issues: medium }) - } - if (low.length > 0) { - logger.info(`Drift advisory: ${low.length} LOW severity suggestions`, { issues: low }) - } + logger.info("Drift detector loaded", { projectRoot }) - if (allIssues.length === 0) { - logger.info("Drift scan: CLEAN — all agent/skill/command files match templates") - } - }, + await ctx.tool.hook("execute.after", (event) => { + if (event.status !== "completed") return - "tool.execute.after": async (input, output) => { - const filePath = ((input.args?.filePath as string) || (output.args?.filePath as string) || "") - .replace(/\\/g, "/") + const args = getToolArgs(event.input) + const filePath = ( + (typeof args.filePath === "string" ? args.filePath : "") || + (typeof args.path === "string" ? args.path : "") + ).replace(/\\/g, "/") if (!filePath) return - // Quick single-file drift check on write/edit + // Quick single-file drift check on write/edit. let issues: DriftIssue[] = [] if (filePath.startsWith(".agents/agents/")) { @@ -356,16 +357,90 @@ export const DriftDetector: Plugin = async ({ project, client, directory, worktr } if (issues.length > 0) { - const high = issues.filter((i) => i.severity === "HIGH") + const high = issues.filter((issue) => issue.severity === "HIGH") if (high.length > 0) { logger.error(`Drift in ${filePath}: ${high.length} HIGH issues`, { issues: high }) } - const remaining = issues.filter((i) => i.severity !== "HIGH") + const remaining = issues.filter((issue) => issue.severity !== "HIGH") if (remaining.length > 0) { logger.info(`Drift in ${filePath}: ${remaining.length} advisory items`, { issues: remaining }) } } - }, - } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.created") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + + try { + // Full scan on session start. + logger.info("Running drift detection scan...") + const allIssues: DriftIssue[] = [] + + const agentsDir = path.join(projectRoot, ".agents", "agents") + if (fs.existsSync(agentsDir)) { + for (const file of fs.readdirSync(agentsDir)) { + if (!file.endsWith(".md")) continue + const relPath = `.agents/agents/${file}` + allIssues.push(...detectAgentDrift(projectRoot, relPath)) + } + } + + const skillsDir = path.join(projectRoot, ".agents", "skills") + if (fs.existsSync(skillsDir)) { + for (const dir of fs.readdirSync(skillsDir)) { + const skillPath = path.join(skillsDir, dir) + if (!fs.statSync(skillPath).isDirectory()) continue + const relPath = `.agents/skills/${dir}/SKILL.md` + if (fs.existsSync(path.join(projectRoot, relPath))) { + allIssues.push(...detectSkillDrift(projectRoot, relPath)) + } + } + } + + const commandsDir = path.join(projectRoot, ".agents", "commands") + if (fs.existsSync(commandsDir)) { + for (const file of fs.readdirSync(commandsDir)) { + if (!file.endsWith(".md") || file === "README.md") continue + const relPath = `.agents/commands/${file}` + allIssues.push(...detectCommandDrift(projectRoot, relPath)) + } + } + + const high = allIssues.filter((issue) => issue.severity === "HIGH") + const medium = allIssues.filter((issue) => issue.severity === "MEDIUM") + const low = allIssues.filter((issue) => issue.severity === "LOW") + + if (high.length > 0) { + logger.error(`Drift detected: ${high.length} HIGH severity issues`, { issues: high }) + } + if (medium.length > 0) { + logger.warn(`Drift detected: ${medium.length} MEDIUM severity issues`, { issues: medium }) + } + if (low.length > 0) { + logger.info(`Drift advisory: ${low.length} LOW severity suggestions`, { issues: low }) + } + + if (allIssues.length === 0) { + logger.info("Drift scan: CLEAN — all agent/skill/command files match templates") + } + } catch (error) { + logger.error("Drift detection scan failed", { error: String(error) }) + } + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) + } + } + })() + + return () => controller.abort() + }, } + +export default DriftDetector diff --git a/.opencode/plugins/README.md b/.opencode/plugins/README.md index 3f52105..cf9ffdd 100644 --- a/.opencode/plugins/README.md +++ b/.opencode/plugins/README.md @@ -1,145 +1,143 @@ # Plugin Architecture -OpenCode plugins are TypeScript modules that register lifecycle hooks with the -OpenCode runtime. Each plugin is a self-contained `.ts` file in `.opencode/plugins/`. +OpenCode V2 plugins are TypeScript modules with a stable `id` and a `setup(ctx)` +entrypoint. `setup` registers hooks through the plugin context; each hook is +removed when the plugin unloads. These project plugins are listed in +`opencode.json` under the plural `plugins` key. ## Available Plugins -| Plugin | Purpose | Hooks | -|--------|---------|-------| -| `ccgs-hooks.ts` | Session lifecycle, commit validation, asset checks, agent logging, gap detection | `session.created`, `session.idle`, `experimental.session.compacting`, `experimental.compaction.autocontinue`, `tool.execute.before`, `tool.execute.after` | -| `drift-detector.ts` | Detects agent/skill/command template drift | `session.created` (scan), `tool.execute.after` (single-file check) | -| `changelog-generator.ts` | Generates CHANGELOG.md from conventional commits | `session.idle` (preview), `tool.execute.before` (command detection) | +| Plugin | Purpose | V2 events and hooks | +|--------|---------|---------------------| +| `ccgs-hooks.ts` | Session lifecycle, commit validation, asset checks, agent logging, gap detection | Events: `session.created`, `session.idle`, `session.compacted`; `session.compaction`; `tool.execute.before`, `tool.execute.after` | +| `drift-detector.ts` | Detects agent/skill/command template drift | Event: `session.created`; `tool.execute.after` | +| `changelog-generator.ts` | Generates and previews CHANGELOG.md from conventional commits | Event: `session.idle`; `tool.execute.before` | ## Plugin Structure -Every plugin follows this pattern: +The V2 SDK exposes `Plugin.define`, which returns the plugin definition. The +definition is the default export: ```typescript -import type { Plugin } from "@opencode-ai/plugin" - -export const MyPlugin: Plugin = async ({ project, client, directory, worktree }) => { - const projectRoot = directory || worktree || process.cwd() - - return { - // Lifecycle hooks - event: async ({ event }) => { - if (event.type === "session.created") { /* ... */ } - }, - - // Tool hooks (before execution) - "tool.execute.before": async (input, output) => { - // Modify output.args to change tool behavior - // Throw to block the tool from executing - }, - - // Tool hooks (after execution) - "tool.execute.after": async (input, output) => { - // React to completed tool calls - }, - - // Compaction hooks - "experimental.session.compacting": async (input, output) => { - // Feed context into the compaction event - output.context.push("Additional context for the compacted session") - }, - - "experimental.compaction.autocontinue": async (input, output) => { - // Add instructions for the auto-continued session - }, - } -} +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "ocgs.example", + async setup(ctx) { + const controller = new AbortController() + + await ctx.tool.hook("execute.before", (event) => { + if (event.tool === "bash") { + const input = event.input as { command?: string } + console.info("[example] command", input.command) + } + }) + + await ctx.session.hook("compaction", (event) => { + event.system.push({ type: "text", text: "Recovery context for compaction." }) + }) + + void (async () => { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type === "session.idle") console.info("[example] session idle") + } + })() + + return () => controller.abort() + }, +}) ``` -## Hook Types +Use `ctx.location.directory` as the plugin's project directory. V2 tool hooks +receive one event object: `event.tool` identifies the tool and `event.input` +contains its arguments. `execute.after` also includes a `status` and either a +`result` or an `error`. + +## V2 Hook APIs + +### Events + +Subscribe to public server events with `ctx.event.subscribe()`. The returned +async iterable accepts an `AbortSignal`; abort it in the cleanup function +returned by `setup`. + +Use `event.location?.directory` when the envelope includes a location. +`session.created` also carries its location in `event.data.location`. For +locationless session events, resolve `event.data.sessionID` with +`ctx.session.get()` and ignore events whose location cannot be resolved. + +### `ctx.tool.hook("execute.before", handler)` + +Runs before a tool executes. Inspect or modify `event.input`. Throw an error +when the tool must be blocked. -### `event` -Fires on session lifecycle events: `session.created`, `session.idle`, `server.instance.disposed`. +### `ctx.tool.hook("execute.after", handler)` -### `tool.execute.before` -Fires before any tool executes. Use to: -- Validate input parameters -- Block dangerous operations (push to protected branches) -- Log agent invocations -- Modify tool arguments +Runs after tool completion or failure. Inspect `event.input` and +`event.status`; successful calls have `event.result`, failed calls have +`event.error`. This hook cannot undo an operation that has already completed. -Throw an Error to prevent the tool from executing. +### `ctx.session.hook("compaction", handler)` -### `tool.execute.after` -Fires after tool completion. Use to: -- Validate output/result files -- Detect file pattern changes (skill modifications) -- Log agent completions +Runs before a checkpoint summary is generated. Add recovery instructions to +`event.system` as text parts: -### `experimental.session.compacting` -Fires when context compression is about to occur. Use to inject recovery context: ```typescript -"experimental.session.compacting": async (input, output) => { - output.context.push("Current task: implementing player movement system...") -} +await ctx.session.hook("compaction", (event) => { + event.system.push({ type: "text", text: "Read the active session state after compaction." }) +}) ``` -### `experimental.compaction.autocontinue` -Fires after compaction when the session auto-continues. Use to guide the session to recover state. +OpenCode V2 has no direct equivalent of the V1 +`experimental.compaction.autocontinue` hook. Use the public +`session.compacted` event for post-compaction notifications. -## Logger Pattern +## Logging and Errors -All plugins should use a structured logger: +The V2 context does not expose the V1 `client.app.log` method. Use the +supported console methods for plugin diagnostics: ```typescript -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client?.app?.log({ body: { service, level, message, extra } }).catch(() => {}) - } - return { - debug: (m: string, x?: any) => log("debug", m, x), - info: (m: string, x?: any) => log("info", m, x), - warn: (m: string, x?: any) => log("warn", m, x), - error: (m: string, x?: any) => log("error", m, x), - } -} +console.info("[example] plugin loaded", { directory: ctx.location.directory }) ``` -## Adding a New Plugin - -1. Create `{plugin-name}.ts` in `.opencode/plugins/` -2. Export a `Plugin` instance (`export const MyPlugin: Plugin = ...`) -3. Register the plugin in `opencode.json` under the `plugins` array -4. Write tests in `.opencode/plugins/tests/` following the `test-*.mjs` naming convention -5. Document the plugin in this README +Catch and report failures inside background event subscriptions. Throw from +`execute.before` only when blocking a tool is intentional. Do not rely on an +`execute.after` error to roll back completed side effects. Use `logAudit()` in +CCGSHooks when a persistent audit record is required. -## Error Handling Guidelines +## Adding a New Plugin -- Always wrap hook handlers in try/catch -- Log errors via the plugin logger (don't throw from `event` handlers) -- Throw from `tool.execute.before` only to block a tool from executing -- Use `logAudit()` for persistent audit trail (ccgs-hooks utility) -- Never throw from `tool.execute.after` (tool already completed) +1. Create `{plugin-name}.ts` in `.opencode/plugins/`. +2. Default-export a V2 plugin definition with a stable ID and `setup(ctx)`. +3. Add its local path to the `plugins` array in `opencode.json`. +4. Add behavior tests in `.opencode/plugins/tests/` using the `test-*.mjs` naming convention. +5. Document the plugin in this README. ## Testing -Tests are Node.js ESM scripts in `.opencode/plugins/tests/`. Each test: -- Imports the plugin's exported functions -- Passes simulated project context -- Asserts expected output +Tests are Node.js ESM scripts in `.opencode/plugins/tests/`. They import +plugin helpers or default definitions, pass V2-shaped context values, and +assert behavior. + +Run the plugin tests with: -Run all plugin tests: ```bash -node .opencode/plugins/tests/test-*.mjs +npm run test:plugins ``` ## Plugin Configuration -Plugins are configured in `opencode.json`: +The three core plugins are explicitly configured in `opencode.json`: ```json { "plugins": [ - ".opencode/plugins/ccgs-hooks.ts", - ".opencode/plugins/drift-detector.ts", - ".opencode/plugins/changelog-generator.ts" + "./.opencode/plugins/ccgs-hooks.ts", + "./.opencode/plugins/drift-detector.ts", + "./.opencode/plugins/changelog-generator.ts" ] } ``` -Plugins load in order. The first plugin's hooks fire first. +Plugins load in configuration order. diff --git a/.opencode/plugins/ccgs-hooks.ts b/.opencode/plugins/ccgs-hooks.ts index 421f7e8..392e259 100644 --- a/.opencode/plugins/ccgs-hooks.ts +++ b/.opencode/plugins/ccgs-hooks.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import { execFileSync, execSync, spawnSync } from "child_process" import * as fs from "fs" import * as path from "path" @@ -40,10 +40,6 @@ function normalizePath(p: string): string { return p.replace(/\\/g, "/") } -function getProjectRoot(directory: string | undefined, worktree: string | undefined): string { - return directory || worktree || process.cwd() -} - function logAudit(projectRoot: string, message: string) { try { const dir = path.join(projectRoot, "production", "session-logs") @@ -389,8 +385,9 @@ export function validateAssetPath(projectRoot: string, filePath: string): { warn warnings.push(`NAMING: ${filePath} must be lowercase with underscores (got: ${filename})`) } - if (/\/assets\/data\/.*\.json$/.test(filePath)) { - if (fs.existsSync(filePath) && !validateJson(filePath)) { + if (/(?:^|\/)assets\/data\/.*\.json$/.test(filePath)) { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath) + if (fs.existsSync(absolutePath) && !validateJson(absolutePath)) { errors.push(`FORMAT: ${filePath} is not valid JSON`) } } @@ -559,132 +556,210 @@ export function detectPushToProtected(cmd: string, currentBranch: string): strin return "" } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client.app.log({ body: { service, level, message, extra } }).catch(() => { }) +type PluginLogLevel = "debug" | "info" | "warn" | "error" + +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: PluginLogLevel, message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) + } + return { + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), } - return { debug: (m: string, x?: any) => log("debug", m, x), info: (m: string, x?: any) => log("info", m, x), warn: (m: string, x?: any) => log("warn", m, x), error: (m: string, x?: any) => log("error", m, x) } } -export const CCGSHooks: Plugin = async ({ project, client, $, directory, worktree }) => { - const projectRoot = getProjectRoot(directory, worktree) - const logger = createPluginLogger(client, "ccgs-hooks") - const gc = (s: string) => globalThis[s as any] - const log = client?.app?.log ? logger : { debug: gc, info: gc, warn: gc, error: gc } +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} - log.info("Plugin loaded", { projectRoot }) - logAudit(projectRoot, "Plugin loaded") +function getAgentType(args: Record): string { + const agentType = args.subagent_type || args.subagentType || args.agent_type || args.agentType + return typeof agentType === "string" ? agentType : "" +} - return { - event: async ({ event }) => { - try { - if (event.type === "session.created") { - const ctx = handleSessionCreated(projectRoot) - const gaps = handleDetectGaps(projectRoot) - log.info("Session context", { summary: ctx.split("\n")[0] || "no context" }) - if (gaps.length > 0) { - log.warn("Documentation gaps", { gaps: gaps.length, details: gaps }) - } - } else if (event.type === "session.idle" || event.type === "server.instance.disposed") { - handleSessionIdle(projectRoot) - log.info("Session idle — state archived") - } - } catch (err) { - log.error(`Error in event ${event.type}`, { error: String(err) }) - logAudit(projectRoot, `ERROR in event ${event.type}: ${err}`) - } - }, +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } + + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } + + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false + } +} + +/** OpenCode V2 plugin that wires CCGS session and tool behavior into the runtime. */ +export const CCGSHooks: OpenCodePlugin.Plugin = { + id: "ocgs.ccgs-hooks", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("ccgs-hooks") - "experimental.session.compacting": async (input, output) => { + logger.info("Plugin loaded", { projectRoot }) + logAudit(projectRoot, "Plugin loaded") + + await ctx.session.hook("compaction", (event) => { const context = buildCompactionContext(projectRoot) logCompactionEvent(projectRoot) - output.context.push(context) - }, - - "experimental.compaction.autocontinue": async (input, output) => { - const msg = handlePostCompact(projectRoot) - log.info(msg) - }, - - "tool.execute.before": async (input, output) => { - if (isGitRepo(projectRoot) && input.tool === "bash" && output.args?.command) { - const cmd = output.args.command as string - if (/^git\s+push/.test(cmd)) { - const matched = detectPushToProtected(cmd, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) + event.system.push({ type: "text", text: context }) + }) + + await ctx.tool.hook("execute.before", (event) => { + const args = getToolArgs(event.input) + const command = typeof args.command === "string" ? args.command : "" + + if (isGitRepo(projectRoot) && event.tool === "bash" && command) { + if (/^git\s+push/.test(command)) { + const matched = detectPushToProtected(command, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) if (matched) { - log.warn(`Push to protected branch '${matched}'`, { branch: matched }) + logger.warn(`Push to protected branch '${matched}'`, { branch: matched }) logAudit(projectRoot, `Push to protected branch '${matched}' detected.`) logAudit(projectRoot, "Reminder: Ensure build passes, unit tests pass, and no S1/S2 bugs exist.") } } - if (/^git\s+commit/.test(cmd)) { + if (/^git\s+commit/.test(command)) { const staged = git(projectRoot, "diff", "--cached", "--name-only") if (!staged) return const result = validateCommitFiles(projectRoot, staged.split("\n")) if (result.errors.length > 0) { - log.error("Commit blocked by validation errors", { errors: result.errors }) + logger.error("Commit blocked by validation errors", { errors: result.errors }) throw new Error(result.errors.join("\n")) } if (result.warnings.length > 0) { - log.warn("Commit warnings", { warnings: result.warnings }) - result.warnings.forEach((w) => logAudit(projectRoot, w)) + logger.warn("Commit warnings", { warnings: result.warnings }) + result.warnings.forEach((warning) => logAudit(projectRoot, warning)) } } } - if (input.tool === "task") { - const agentType = - (output.args?.subagent_type as string) || - (output.args?.subagentType as string) || - (output.args?.agent_type as string) || - (output.args?.agentType as string) || - "" + if (event.tool === "task") { + const agentType = getAgentType(args) handleLogAgent(projectRoot, agentType) - log.debug("Agent invoked", { agentType }) + logger.debug("Agent invoked", { agentType }) } - }, + }) - "tool.execute.after": async (input, output) => { + await ctx.tool.hook("execute.after", (event) => { + if (event.status !== "completed") return + + const args = getToolArgs(event.input) const filePath = normalizePath( - (input.args?.filePath as string) || - (input.args?.path as string) || - (output.args?.filePath as string) || - (output.args?.path as string) || - "" + (typeof args.filePath === "string" ? args.filePath : "") || + (typeof args.path === "string" ? args.path : "") ) if (!filePath) return const assetResult = validateAssetPath(projectRoot, filePath) if (assetResult.warnings.length > 0) { - log.warn("Asset warnings", { warnings: assetResult.warnings }) - assetResult.warnings.forEach((w) => logAudit(projectRoot, w)) + logger.warn("Asset warnings", { warnings: assetResult.warnings }) + assetResult.warnings.forEach((warning) => logAudit(projectRoot, warning)) logAudit(projectRoot, "(Warnings are advisory. Fix before final commit.)") } if (assetResult.errors.length > 0) { - log.error("Asset validation failed", { errors: assetResult.errors }) - assetResult.errors.forEach((e) => logAudit(projectRoot, e)) - throw new Error("Asset validation failed. Fix errors before proceeding.") + logger.error("Asset validation failed", { errors: assetResult.errors }) + assetResult.errors.forEach((error) => logAudit(projectRoot, error)) + + const existingContent = typeof event.result.content === "string" + ? (event.result.content ? [{ type: "text" as const, text: event.result.content }] : []) + : [...(event.result.content ?? [])] + event.result = { + ...event.result, + content: [ + ...existingContent, + { type: "text" as const, text: "Asset validation failed. Fix errors before proceeding." }, + ...assetResult.errors.map((error) => ({ type: "text" as const, text: error })), + ], + } } - if (input.tool === "task") { - const agentType = - (output.args?.subagent_type as string) || - (output.args?.subagentType as string) || - (output.args?.agent_type as string) || - (output.args?.agentType as string) || - "" + if (event.tool === "task") { + const agentType = getAgentType(args) handleLogAgentStop(projectRoot, agentType) - log.debug("Agent completed", { agentType }) + logger.debug("Agent completed", { agentType }) } const skillChange = detectSkillChange(filePath) if (skillChange) { - log.info(`Skill modified: ${skillChange}`, { skill: skillChange }) + logger.info(`Skill modified: ${skillChange}`, { skill: skillChange }) logAudit(projectRoot, `=== Skill Modified: ${skillChange} ===`) logAudit(projectRoot, `Run /skill-test static ${skillChange} to validate structural compliance.`) } - }, - } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.created" && event.type !== "session.idle" && event.type !== "session.compacted") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + + try { + if (event.type === "session.created") { + const context = handleSessionCreated(projectRoot) + const gaps = handleDetectGaps(projectRoot) + logger.info("Session context", { summary: context.split("\n")[0] || "no context" }) + if (gaps.length > 0) { + logger.warn("Documentation gaps", { gaps: gaps.length, details: gaps }) + } + } else if (event.type === "session.idle") { + handleSessionIdle(projectRoot) + logger.info("Session idle — state archived") + } else if (event.type === "session.compacted") { + logger.info(handlePostCompact(projectRoot)) + } + } catch (error) { + logger.error(`Error in event ${event.type}`, { error: String(error) }) + logAudit(projectRoot, `ERROR in event ${event.type}: ${error}`) + } + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) + } + } + })() + + return () => controller.abort() + }, } + +export default CCGSHooks diff --git a/.opencode/plugins/changelog-generator.ts b/.opencode/plugins/changelog-generator.ts index f3f0b0f..56e3808 100644 --- a/.opencode/plugins/changelog-generator.ts +++ b/.opencode/plugins/changelog-generator.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import { execSync, spawnSync } from "child_process" import * as fs from "fs" import * as path from "path" @@ -187,16 +187,66 @@ export function updateChangelogFile(projectRoot: string, version: string, conten fs.writeFileSync(filePath, updated) } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client?.app?.log({ body: { service, level, message, extra } }).catch(() => { }) +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: "debug" | "info" | "warn" | "error", message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) } return { - debug: (m: string, x?: any) => log("debug", m, x), - info: (m: string, x?: any) => log("info", m, x), - warn: (m: string, x?: any) => log("warn", m, x), - error: (m: string, x?: any) => log("error", m, x), + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), + } +} + +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} + +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } + + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } + + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false } } @@ -219,38 +269,52 @@ export function generateChangelogs(projectRoot: string, version?: string): { int } } -export const ChangelogGenerator: Plugin = async ({ project, client, directory, worktree }) => { - const projectRoot = directory || worktree || process.cwd() - const logger = createPluginLogger(client, "changelog-generator") +/** OpenCode V2 plugin that previews changelog updates and detects changelog commands. */ +export const ChangelogGenerator: OpenCodePlugin.Plugin = { + id: "ocgs.changelog-generator", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("changelog-generator") - logger.info("Changelog generator loaded", { projectRoot }) + logger.info("Changelog generator loaded", { projectRoot }) - return { - event: async ({ event }) => { - // Auto-generate changelog on session idle for uncommitted work - if (event.type === "session.idle" || event.type === "server.instance.disposed") { - try { - const { internal, player } = generateChangelogs(projectRoot, "unreleased") - if (!internal.includes("No changes")) { - updateChangelogFile(projectRoot, "unreleased", internal, false) - updateChangelogFile(projectRoot, "unreleased", player, true) - logger.info("Changelog written to CHANGELOG.md and CHANGELOG_INTERNAL.md") + await ctx.tool.hook("execute.before", (event) => { + if (event.tool !== "bash") return + + const args = getToolArgs(event.input) + const command = typeof args.command === "string" ? args.command : "" + if (command.includes("changelog") || command.includes("CHANGELOG")) { + logger.info("Changelog-related command detected — consider running the changelog generator") + } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.idle") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + + try { + const { internal, player } = generateChangelogs(projectRoot, "unreleased") + if (!internal.includes("No changes")) { + updateChangelogFile(projectRoot, "unreleased", internal, false) + updateChangelogFile(projectRoot, "unreleased", player, true) + logger.info("Changelog written to CHANGELOG.md and CHANGELOG_INTERNAL.md") + } + } catch (error) { + logger.error("Failed to generate changelog", { error: String(error) }) } - } catch (err) { - logger.error("Failed to generate changelog", { error: String(err) }) + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) } } - }, + })() - "tool.execute.before": async (input, output) => { - if (input.tool !== "bash") return - - const cmd = output.args?.command as string || "" - - // Detect changelog-related commands - if (cmd.includes("changelog") || cmd.includes("CHANGELOG")) { - logger.info("Changelog-related command detected — consider running the changelog generator") - } - }, - } + return () => controller.abort() + }, } + +export default ChangelogGenerator diff --git a/.opencode/plugins/drift-detector.ts b/.opencode/plugins/drift-detector.ts index ea6f6a2..4eb2b2c 100644 --- a/.opencode/plugins/drift-detector.ts +++ b/.opencode/plugins/drift-detector.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" import * as fs from "fs" import * as path from "path" @@ -262,89 +262,90 @@ export function detectCommandDrift(projectRoot: string, filePath: string): Drift return issues } -type PluginLogger = ReturnType -function createPluginLogger(client: any, service: string) { - const log = (level: string, message: string, extra?: any) => { - client?.app?.log({ body: { service, level, message, extra } }).catch(() => {}) +interface PluginLogger { + debug(message: string, extra?: unknown): void + info(message: string, extra?: unknown): void + warn(message: string, extra?: unknown): void + error(message: string, extra?: unknown): void +} + +function createPluginLogger(service: string): PluginLogger { + const log = (level: "debug" | "info" | "warn" | "error", message: string, extra?: unknown) => { + const formatted = `[${service}] ${message}` + if (extra === undefined) console[level](formatted) + else console[level](formatted, extra) } return { - debug: (m: string, x?: any) => log("debug", m, x), - info: (m: string, x?: any) => log("info", m, x), - warn: (m: string, x?: any) => log("warn", m, x), - error: (m: string, x?: any) => log("error", m, x), + debug: (message: string, extra?: unknown) => log("debug", message, extra), + info: (message: string, extra?: unknown) => log("info", message, extra), + warn: (message: string, extra?: unknown) => log("warn", message, extra), + error: (message: string, extra?: unknown) => log("error", message, extra), } } -export const DriftDetector: Plugin = async ({ project, client, directory, worktree }) => { - const projectRoot = directory || worktree || process.cwd() - const logger = createPluginLogger(client, "drift-detector") - - logger.info("Drift detector loaded", { projectRoot }) +function getToolArgs(input: unknown): Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} - return { - event: async ({ event }) => { - if (event.type !== "session.created") return - - // Full scan on session start - logger.info("Running drift detection scan...") - const allIssues: DriftIssue[] = [] - - const agentsDir = path.join(projectRoot, ".agents", "agents") - if (fs.existsSync(agentsDir)) { - for (const file of fs.readdirSync(agentsDir)) { - if (!file.endsWith(".md")) continue - const relPath = `.agents/agents/${file}` - allIssues.push(...detectAgentDrift(projectRoot, relPath)) - } - } +async function isEventForProject( + ctx: OpenCodePlugin.Context, + event: { type: string; location?: { directory?: string }; data?: unknown }, + projectRoot: string, + logger: PluginLogger, +): Promise { + const data = event.data && typeof event.data === "object" + ? event.data as { location?: { directory?: string }; sessionID?: OpenCodeSession.ID } + : undefined + const directory = event.location?.directory ?? data?.location?.directory + const matchesProject = (candidate: string) => { + const eventDirectory = path.resolve(candidate) + const pluginDirectory = path.resolve(projectRoot) + return process.platform === "win32" + ? eventDirectory.toLowerCase() === pluginDirectory.toLowerCase() + : eventDirectory === pluginDirectory + } - const skillsDir = path.join(projectRoot, ".agents", "skills") - if (fs.existsSync(skillsDir)) { - for (const dir of fs.readdirSync(skillsDir)) { - const skillPath = path.join(skillsDir, dir) - if (!fs.statSync(skillPath).isDirectory()) continue - const relPath = `.agents/skills/${dir}/SKILL.md` - if (fs.existsSync(path.join(projectRoot, relPath))) { - allIssues.push(...detectSkillDrift(projectRoot, relPath)) - } - } - } + if (typeof directory === "string") return matchesProject(directory) + if (typeof data?.sessionID !== "string") { + logger.warn("Ignoring session event without a resolvable location", { type: event.type }) + return false + } - const commandsDir = path.join(projectRoot, ".agents", "commands") - if (fs.existsSync(commandsDir)) { - for (const file of fs.readdirSync(commandsDir)) { - if (!file.endsWith(".md") || file === "README.md") continue - const relPath = `.agents/commands/${file}` - allIssues.push(...detectCommandDrift(projectRoot, relPath)) - } - } + try { + const session = await ctx.session.get({ sessionID: data.sessionID }) + return matchesProject(session.location.directory) + } catch (error) { + logger.warn("Could not resolve session location; ignoring event", { + type: event.type, + sessionID: data.sessionID, + error: String(error), + }) + return false + } +} - const high = allIssues.filter((i) => i.severity === "HIGH") - const medium = allIssues.filter((i) => i.severity === "MEDIUM") - const low = allIssues.filter((i) => i.severity === "LOW") +/** OpenCode V2 plugin that reports agent, skill, and command drift. */ +export const DriftDetector: OpenCodePlugin.Plugin = { + id: "ocgs.drift-detector", + async setup(ctx) { + const projectRoot = ctx.location.directory || process.cwd() + const logger = createPluginLogger("drift-detector") - if (high.length > 0) { - logger.error(`Drift detected: ${high.length} HIGH severity issues`, { issues: high }) - } - if (medium.length > 0) { - logger.warn(`Drift detected: ${medium.length} MEDIUM severity issues`, { issues: medium }) - } - if (low.length > 0) { - logger.info(`Drift advisory: ${low.length} LOW severity suggestions`, { issues: low }) - } + logger.info("Drift detector loaded", { projectRoot }) - if (allIssues.length === 0) { - logger.info("Drift scan: CLEAN — all agent/skill/command files match templates") - } - }, + await ctx.tool.hook("execute.after", (event) => { + if (event.status !== "completed") return - "tool.execute.after": async (input, output) => { - const filePath = ((input.args?.filePath as string) || (output.args?.filePath as string) || "") - .replace(/\\/g, "/") + const args = getToolArgs(event.input) + const filePath = ( + (typeof args.filePath === "string" ? args.filePath : "") || + (typeof args.path === "string" ? args.path : "") + ).replace(/\\/g, "/") if (!filePath) return - // Quick single-file drift check on write/edit + // Quick single-file drift check on write/edit. let issues: DriftIssue[] = [] if (filePath.startsWith(".agents/agents/")) { @@ -356,16 +357,90 @@ export const DriftDetector: Plugin = async ({ project, client, directory, worktr } if (issues.length > 0) { - const high = issues.filter((i) => i.severity === "HIGH") + const high = issues.filter((issue) => issue.severity === "HIGH") if (high.length > 0) { logger.error(`Drift in ${filePath}: ${high.length} HIGH issues`, { issues: high }) } - const remaining = issues.filter((i) => i.severity !== "HIGH") + const remaining = issues.filter((issue) => issue.severity !== "HIGH") if (remaining.length > 0) { logger.info(`Drift in ${filePath}: ${remaining.length} advisory items`, { issues: remaining }) } } - }, - } + }) + + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.created") continue + if (!await isEventForProject(ctx, event, projectRoot, logger)) continue + + try { + // Full scan on session start. + logger.info("Running drift detection scan...") + const allIssues: DriftIssue[] = [] + + const agentsDir = path.join(projectRoot, ".agents", "agents") + if (fs.existsSync(agentsDir)) { + for (const file of fs.readdirSync(agentsDir)) { + if (!file.endsWith(".md")) continue + const relPath = `.agents/agents/${file}` + allIssues.push(...detectAgentDrift(projectRoot, relPath)) + } + } + + const skillsDir = path.join(projectRoot, ".agents", "skills") + if (fs.existsSync(skillsDir)) { + for (const dir of fs.readdirSync(skillsDir)) { + const skillPath = path.join(skillsDir, dir) + if (!fs.statSync(skillPath).isDirectory()) continue + const relPath = `.agents/skills/${dir}/SKILL.md` + if (fs.existsSync(path.join(projectRoot, relPath))) { + allIssues.push(...detectSkillDrift(projectRoot, relPath)) + } + } + } + + const commandsDir = path.join(projectRoot, ".agents", "commands") + if (fs.existsSync(commandsDir)) { + for (const file of fs.readdirSync(commandsDir)) { + if (!file.endsWith(".md") || file === "README.md") continue + const relPath = `.agents/commands/${file}` + allIssues.push(...detectCommandDrift(projectRoot, relPath)) + } + } + + const high = allIssues.filter((issue) => issue.severity === "HIGH") + const medium = allIssues.filter((issue) => issue.severity === "MEDIUM") + const low = allIssues.filter((issue) => issue.severity === "LOW") + + if (high.length > 0) { + logger.error(`Drift detected: ${high.length} HIGH severity issues`, { issues: high }) + } + if (medium.length > 0) { + logger.warn(`Drift detected: ${medium.length} MEDIUM severity issues`, { issues: medium }) + } + if (low.length > 0) { + logger.info(`Drift advisory: ${low.length} LOW severity suggestions`, { issues: low }) + } + + if (allIssues.length === 0) { + logger.info("Drift scan: CLEAN — all agent/skill/command files match templates") + } + } catch (error) { + logger.error("Drift detection scan failed", { error: String(error) }) + } + } + } catch (error) { + if (!controller.signal.aborted) { + logger.error("Event subscription failed", { error: String(error) }) + } + } + })() + + return () => controller.abort() + }, } + +export default DriftDetector diff --git a/.opencode/plugins/tests/test-plugin-v2.mjs b/.opencode/plugins/tests/test-plugin-v2.mjs new file mode 100644 index 0000000..4ddba9a --- /dev/null +++ b/.opencode/plugins/tests/test-plugin-v2.mjs @@ -0,0 +1,424 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { tmpdir } from "node:os" +import { strict as assert } from "node:assert" +import { describe, it } from "node:test" +import { execSync } from "node:child_process" + +const runtimePlugins = { + "ccgs-hooks": await import("../ccgs-hooks.ts"), + "drift-detector": await import("../drift-detector.ts"), + "changelog-generator": await import("../changelog-generator.ts"), +} + +const modulePlugins = { + "ccgs-hooks": await import("../../modules/core/plugins/ccgs-hooks.ts"), + "drift-detector": await import("../../modules/core/plugins/drift-detector.ts"), + "changelog-generator": await import("../../modules/core/plugins/changelog-generator.ts"), +} + +function definition(module) { + return module.default?.default ?? module.default +} + +function createContext(directory, events = [], sessionLocations = {}) { + const handlers = new Map() + let signal + let resolveEventsConsumed + const eventsConsumed = new Promise((resolve) => { + resolveEventsConsumed = resolve + }) + + return { + handlers, + eventsConsumed, + get signal() { + return signal + }, + context: { + app: { name: "OpenCode", version: "2.0.23", channel: "stable" }, + location: { directory, project: { directory, canonical: directory } }, + event: { + subscribe(options = {}) { + signal = options.signal + return (async function* () { + for (const event of events) yield { location: { directory }, ...event } + resolveEventsConsumed() + await new Promise((resolve) => { + if (signal?.aborted) return resolve() + signal?.addEventListener("abort", resolve, { once: true }) + }) + })() + }, + }, + session: { + async get({ sessionID }) { + const sessionDirectory = sessionLocations[sessionID] ?? directory + return { + id: sessionID, + projectID: "test-project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: new Date(0).toISOString(), updated: new Date(0).toISOString() }, + location: { directory: sessionDirectory }, + } + }, + async hook(name, handler) { + handlers.set(`session:${name}`, handler) + return { dispose: async () => {} } + }, + }, + tool: { + async hook(name, handler) { + handlers.set(`tool:${name}`, handler) + return { dispose: async () => {} } + }, + }, + }, + } +} + +function makeTempProject(prefix) { + return fs.mkdtempSync(path.join(tmpdir(), prefix)) +} + +async function captureConsole(callback) { + const levels = ["debug", "info", "log", "warn", "error"] + const originals = Object.fromEntries(levels.map((level) => [level, console[level]])) + const messages = [] + for (const level of levels) { + console[level] = (...values) => messages.push(`${level}: ${values.map(String).join(" ")}`) + } + try { + await callback() + } finally { + for (const level of levels) console[level] = originals[level] + } + return messages +} + +async function deliverEvent(pluginModule, projectRoot, event, sessionLocations = {}) { + const plugin = definition(pluginModule) + const fixture = createContext(projectRoot, [event], sessionLocations) + let dispose + try { + return await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + await fixture.eventsConsumed + }) + } finally { + await dispose?.() + } +} + +for (const [name, module] of Object.entries(runtimePlugins)) { + it(`exports ${name} with the OpenCode V2 id/setup contract`, () => { + const plugin = definition(module) + assert.equal(plugin?.id, `ocgs.${name}`) + assert.equal(typeof plugin?.setup, "function") + }) +} + +for (const [name, module] of Object.entries(modulePlugins)) { + it(`ships the V2 ${name} plugin from the core module`, () => { + const plugin = definition(module) + assert.equal(plugin?.id, `ocgs.${name}`) + assert.equal(typeof plugin?.setup, "function") + }) +} + +it("uses the plural V2 plugin key and registers all three local plugins", () => { + const config = JSON.parse(fs.readFileSync(new URL("../../../opencode.json", import.meta.url), "utf8")) + assert.deepEqual(config.plugins, [ + "./.opencode/plugins/ccgs-hooks.ts", + "./.opencode/plugins/drift-detector.ts", + "./.opencode/plugins/changelog-generator.ts", + ]) + assert.equal(Object.hasOwn(config, "plugin"), false) +}) + +describe("OpenCode V2 hook behavior", () => { + it("injects session recovery context through the V2 compaction hook", async () => { + const root = makeTempProject("ocgs-v2-compaction-") + const stateDir = path.join(root, "production", "session-state") + fs.mkdirSync(stateDir, { recursive: true }) + fs.writeFileSync(path.join(stateDir, "active.md"), "Recovery marker: keep this task in context.\n") + const plugin = definition(runtimePlugins["ccgs-hooks"]) + const fixture = createContext(root) + let dispose + + try { + assert.equal(typeof plugin?.setup, "function") + await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("session:compaction") + assert.equal(typeof hook, "function") + const event = { system: [] } + await hook(event) + assert.ok(event.system.some((part) => part.text.includes("Recovery marker: keep this task in context."))) + }) + assert.ok(fixture.signal instanceof AbortSignal) + assert.equal(fs.existsSync(path.join(root, "production", "session-logs", "compaction-log.txt")), true) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + assert.equal(fixture.signal.aborted, true) + }) + + it("ignores foreign session-idle events before archiving state in both plugin copies", async () => { + const root = makeTempProject("ocgs-v2-foreign-idle-") + const stateDir = path.join(root, "production", "session-state") + const otherProject = path.join(root, "other-project") + fs.mkdirSync(stateDir, { recursive: true }) + fs.writeFileSync(path.join(stateDir, "active.md"), "Keep this state in its own project.\\n") + const plugins = [runtimePlugins["ccgs-hooks"], modulePlugins["ccgs-hooks"]] + + try { + for (const plugin of plugins) { + await deliverEvent( + plugin, + root, + { type: "session.idle", location: undefined, data: { sessionID: "foreign-session" } }, + { "foreign-session": otherProject }, + ) + assert.equal(fs.existsSync(path.join(root, "production", "session-logs", "session-log.md")), false) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("resolves owner-routed session-idle events without envelope locations", async () => { + const root = makeTempProject("ocgs-v2-owner-idle-") + const stateDir = path.join(root, "production", "session-state") + const sessionLog = path.join(root, "production", "session-logs", "session-log.md") + fs.mkdirSync(stateDir, { recursive: true }) + fs.writeFileSync(path.join(stateDir, "active.md"), "This owner-routed state must be archived.\\n") + const plugins = [runtimePlugins["ccgs-hooks"], modulePlugins["ccgs-hooks"]] + + try { + for (const plugin of plugins) { + await deliverEvent(plugin, root, { + type: "session.idle", + location: undefined, + data: { sessionID: "owner-session" }, + }) + assert.equal(fs.existsSync(sessionLog), true) + fs.rmSync(sessionLog, { force: true }) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("ignores foreign session-created events in both drift-detector copies", async () => { + const root = makeTempProject("ocgs-v2-foreign-drift-") + const agentDir = path.join(root, ".agents", "agents") + fs.mkdirSync(agentDir, { recursive: true }) + fs.writeFileSync(path.join(agentDir, "broken.md"), "Missing required frontmatter\\n") + const plugins = [runtimePlugins["drift-detector"], modulePlugins["drift-detector"]] + + try { + for (const plugin of plugins) { + const messages = await deliverEvent(plugin, root, { + type: "session.created", + location: undefined, + data: { location: { directory: path.join(root, "other-project") } }, + }) + assert.equal(messages.some((message) => message.includes("Running drift detection scan")), false) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("ignores foreign session-idle events in both changelog-generator copies", async () => { + const root = makeTempProject("ocgs-v2-foreign-changelog-") + execSync("git init -q", { cwd: root, stdio: "ignore" }) + execSync("git config user.name Tester", { cwd: root, stdio: "ignore" }) + execSync("git config user.email tester@example.invalid", { cwd: root, stdio: "ignore" }) + fs.writeFileSync(path.join(root, "README.md"), "Project change.\\n") + execSync("git add README.md", { cwd: root, stdio: "ignore" }) + execSync('git commit -m "feat: add project change"', { cwd: root, stdio: "ignore" }) + const plugins = [runtimePlugins["changelog-generator"], modulePlugins["changelog-generator"]] + + try { + for (const plugin of plugins) { + const messages = await deliverEvent(plugin, root, { + type: "session.idle", + location: { directory: path.join(root, "other-project") }, + }) + assert.equal(messages.some((message) => message.includes("Changelog generated with unreleased changes")), false) + assert.equal(fs.existsSync(path.join(root, "CHANGELOG.md")), false) + assert.equal(fs.existsSync(path.join(root, "CHANGELOG_INTERNAL.md")), false) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("reports invalid V2 asset output without throwing from the after hook", async () => { + const root = makeTempProject("ocgs-v2-asset-") + const relativePath = `assets/data/${path.basename(root)}.json` + const absolutePath = path.join(root, ...relativePath.split("/")) + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) + fs.writeFileSync(absolutePath, "not valid JSON\\n") + const plugin = definition(runtimePlugins["ccgs-hooks"]) + const fixture = createContext(root) + let dispose + + try { + assert.equal(typeof plugin?.setup, "function") + await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.after") + assert.equal(typeof hook, "function") + const event = { + tool: "write", + status: "completed", + input: { filePath: relativePath }, + result: { content: "File written." }, + } + await hook(event) + assert.ok(Array.isArray(event.result.content)) + assert.ok(event.result.content.some((part) => part.type === "text" && part.text.includes("not valid JSON"))) + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("reads V2 bash input when checking protected-branch pushes", async () => { + const root = makeTempProject("ocgs-v2-push-") + execSync("git init -q", { cwd: root, stdio: "ignore" }) + execSync("git checkout -b feature/plugin-v2", { cwd: root, stdio: "ignore" }) + const plugin = definition(runtimePlugins["ccgs-hooks"]) + const fixture = createContext(root) + let dispose + let messages + + try { + assert.equal(typeof plugin?.setup, "function") + messages = await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + await hook({ tool: "bash", input: { command: "git push origin master" } }) + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + assert.ok(messages.some((message) => message.includes("Push to protected branch 'master'"))) + }) + + it("blocks invalid data-file commits from V2 bash input", async () => { + const root = makeTempProject("ocgs-v2-commit-") + const dataDir = path.join(root, "assets", "data") + fs.mkdirSync(dataDir, { recursive: true }) + fs.writeFileSync(path.join(dataDir, "broken.json"), "not valid JSON\\n") + execSync("git init -q", { cwd: root, stdio: "ignore" }) + execSync("git add assets/data/broken.json", { cwd: root, stdio: "ignore" }) + const plugin = definition(runtimePlugins["ccgs-hooks"]) + const fixture = createContext(root) + let dispose + + try { + assert.equal(typeof plugin?.setup, "function") + await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + assert.throws( + () => hook({ tool: "bash", input: { command: "git commit -m 'fix: invalid data'" } }), + /not valid JSON/, + ) + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + + it("runs the drift scan for a V2 session-created event", async () => { + const root = makeTempProject("ocgs-v2-event-") + const agentDir = path.join(root, ".agents", "agents") + fs.mkdirSync(agentDir, { recursive: true }) + fs.writeFileSync(path.join(agentDir, "broken.md"), "Missing required frontmatter\\n") + const plugin = definition(runtimePlugins["drift-detector"]) + const fixture = createContext(root, [{ + type: "session.created", + location: undefined, + data: { location: { directory: root } }, + }]) + let dispose + let messages + + try { + assert.equal(typeof plugin?.setup, "function") + messages = await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + await fixture.eventsConsumed + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + assert.ok(messages.some((message) => message.includes("Drift detected: 1 HIGH severity issues"))) + }) + + it("passes V2 tool input to drift detection after a successful edit", async () => { + const root = makeTempProject("ocgs-v2-drift-") + const agentDir = path.join(root, ".agents", "agents") + fs.mkdirSync(agentDir, { recursive: true }) + fs.writeFileSync(path.join(agentDir, "broken.md"), "Missing required frontmatter\n") + const plugin = definition(runtimePlugins["drift-detector"]) + const fixture = createContext(root) + let dispose + let messages + + try { + assert.equal(typeof plugin?.setup, "function") + messages = await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.after") + assert.equal(typeof hook, "function") + await hook({ + tool: "edit", + status: "completed", + input: { filePath: ".agents/agents/broken.md" }, + result: { output: "updated" }, + }) + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + assert.ok(messages.some((message) => message.includes("HIGH issues"))) + }) + + it("reads the V2 tool input when detecting changelog commands", async () => { + const root = makeTempProject("ocgs-v2-changelog-") + const plugin = definition(runtimePlugins["changelog-generator"]) + const fixture = createContext(root) + let dispose + let messages + + try { + assert.equal(typeof plugin?.setup, "function") + messages = await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + await hook({ tool: "bash", input: { command: "npm run changelog" } }) + }) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + assert.ok(messages.some((message) => message.includes("Changelog-related command detected"))) + }) +}) diff --git a/opencode.json b/opencode.json index a207550..9d5e671 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,10 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["./.opencode/plugins/ccgs-hooks.ts"], + "plugins": [ + "./.opencode/plugins/ccgs-hooks.ts", + "./.opencode/plugins/drift-detector.ts", + "./.opencode/plugins/changelog-generator.ts" + ], "command": { "hybrid-prototype": { "template": "Run the hybrid-prototype skill: load .agents/skills/hybrid-prototype/SKILL.md and follow the fast-lane prototype workflow. Concept: $ARGUMENTS", From 7a8e7bbbde4dcb48b6e4b53f490597e3bc2f5da6 Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 6 Oct 2026 20:57:19 +0200 Subject: [PATCH 2/3] fix: import session ID from OpenCode schema (#108) --- .opencode/modules/core/plugins/ccgs-hooks.ts | 3 ++- .opencode/modules/core/plugins/changelog-generator.ts | 3 ++- .opencode/modules/core/plugins/drift-detector.ts | 3 ++- .opencode/plugins/ccgs-hooks.ts | 3 ++- .opencode/plugins/changelog-generator.ts | 3 ++- .opencode/plugins/drift-detector.ts | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.opencode/modules/core/plugins/ccgs-hooks.ts b/.opencode/modules/core/plugins/ccgs-hooks.ts index 8caf3d7..e112b99 100644 --- a/.opencode/modules/core/plugins/ccgs-hooks.ts +++ b/.opencode/modules/core/plugins/ccgs-hooks.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import { execSync } from "child_process" import * as fs from "fs" import * as path from "path" diff --git a/.opencode/modules/core/plugins/changelog-generator.ts b/.opencode/modules/core/plugins/changelog-generator.ts index 6680c21..12ed1ac 100644 --- a/.opencode/modules/core/plugins/changelog-generator.ts +++ b/.opencode/modules/core/plugins/changelog-generator.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import { execSync } from "child_process" import * as fs from "fs" import * as path from "path" diff --git a/.opencode/modules/core/plugins/drift-detector.ts b/.opencode/modules/core/plugins/drift-detector.ts index 37d1692..8cbd20a 100644 --- a/.opencode/modules/core/plugins/drift-detector.ts +++ b/.opencode/modules/core/plugins/drift-detector.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import * as fs from "fs" import * as path from "path" diff --git a/.opencode/plugins/ccgs-hooks.ts b/.opencode/plugins/ccgs-hooks.ts index 392e259..dae2f32 100644 --- a/.opencode/plugins/ccgs-hooks.ts +++ b/.opencode/plugins/ccgs-hooks.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import { execFileSync, execSync, spawnSync } from "child_process" import * as fs from "fs" import * as path from "path" diff --git a/.opencode/plugins/changelog-generator.ts b/.opencode/plugins/changelog-generator.ts index 56e3808..1a0d2cc 100644 --- a/.opencode/plugins/changelog-generator.ts +++ b/.opencode/plugins/changelog-generator.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import { execSync, spawnSync } from "child_process" import * as fs from "fs" import * as path from "path" diff --git a/.opencode/plugins/drift-detector.ts b/.opencode/plugins/drift-detector.ts index 4eb2b2c..986279b 100644 --- a/.opencode/plugins/drift-detector.ts +++ b/.opencode/plugins/drift-detector.ts @@ -1,4 +1,5 @@ -import type { Plugin as OpenCodePlugin, Session as OpenCodeSession } from "@opencode/plugin" +import type { Plugin as OpenCodePlugin } from "@opencode/plugin" +import type { Session as OpenCodeSession } from "@opencode/schema/session" import * as fs from "fs" import * as path from "path" From c10a1dfe5471a4d756df810eec909f68bebf8b0c Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 6 Oct 2026 20:57:45 +0200 Subject: [PATCH 3/3] fix: handle V2 shell and subagent tools (#108) --- .opencode/modules/core/plugins/ccgs-hooks.ts | 17 +- .../core/plugins/changelog-generator.ts | 2 +- .opencode/plugins/ccgs-hooks.ts | 17 +- .opencode/plugins/changelog-generator.ts | 2 +- .opencode/plugins/tests/test-plugin-v2.mjs | 149 ++++++++++++------ 5 files changed, 117 insertions(+), 70 deletions(-) diff --git a/.opencode/modules/core/plugins/ccgs-hooks.ts b/.opencode/modules/core/plugins/ccgs-hooks.ts index e112b99..cd95295 100644 --- a/.opencode/modules/core/plugins/ccgs-hooks.ts +++ b/.opencode/modules/core/plugins/ccgs-hooks.ts @@ -575,7 +575,7 @@ function getToolArgs(input: unknown): Record { } function getAgentType(args: Record): string { - const agentType = args.subagent_type || args.subagentType || args.agent_type || args.agentType + const agentType = args.agent || args.subagent_type || args.subagentType || args.agent_type || args.agentType return typeof agentType === "string" ? agentType : "" } @@ -636,7 +636,7 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { const args = getToolArgs(event.input) const command = typeof args.command === "string" ? args.command : "" - if (isGitRepo(projectRoot) && event.tool === "bash" && command) { + if (isGitRepo(projectRoot) && event.tool === "shell" && command) { if (/^git\s+push/.test(command)) { const matched = detectPushToProtected(command, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) if (matched) { @@ -660,7 +660,7 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { } } - if (event.tool === "task") { + if (event.tool === "subagent") { const agentType = getAgentType(args) handleLogAgent(projectRoot, agentType) logger.debug("Agent invoked", { agentType }) @@ -671,6 +671,11 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { if (event.status !== "completed") return const args = getToolArgs(event.input) + if (event.tool === "subagent") { + const agentType = getAgentType(args) + handleLogAgentStop(projectRoot, agentType) + logger.debug("Agent completed", { agentType }) + } const filePath = normalizePath( (typeof args.filePath === "string" ? args.filePath : "") || (typeof args.path === "string" ? args.path : "") @@ -701,12 +706,6 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { } } - if (event.tool === "task") { - const agentType = getAgentType(args) - handleLogAgentStop(projectRoot, agentType) - logger.debug("Agent completed", { agentType }) - } - const skillChange = detectSkillChange(filePath) if (skillChange) { logger.info(`Skill modified: ${skillChange}`, { skill: skillChange }) diff --git a/.opencode/modules/core/plugins/changelog-generator.ts b/.opencode/modules/core/plugins/changelog-generator.ts index 12ed1ac..803b764 100644 --- a/.opencode/modules/core/plugins/changelog-generator.ts +++ b/.opencode/modules/core/plugins/changelog-generator.ts @@ -271,7 +271,7 @@ export const ChangelogGenerator: OpenCodePlugin.Plugin = { logger.info("Changelog generator loaded", { projectRoot }) await ctx.tool.hook("execute.before", (event) => { - if (event.tool !== "bash") return + if (event.tool !== "shell") return const args = getToolArgs(event.input) const command = typeof args.command === "string" ? args.command : "" diff --git a/.opencode/plugins/ccgs-hooks.ts b/.opencode/plugins/ccgs-hooks.ts index dae2f32..c571f68 100644 --- a/.opencode/plugins/ccgs-hooks.ts +++ b/.opencode/plugins/ccgs-hooks.ts @@ -586,7 +586,7 @@ function getToolArgs(input: unknown): Record { } function getAgentType(args: Record): string { - const agentType = args.subagent_type || args.subagentType || args.agent_type || args.agentType + const agentType = args.agent || args.subagent_type || args.subagentType || args.agent_type || args.agentType return typeof agentType === "string" ? agentType : "" } @@ -647,7 +647,7 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { const args = getToolArgs(event.input) const command = typeof args.command === "string" ? args.command : "" - if (isGitRepo(projectRoot) && event.tool === "bash" && command) { + if (isGitRepo(projectRoot) && event.tool === "shell" && command) { if (/^git\s+push/.test(command)) { const matched = detectPushToProtected(command, git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD")) if (matched) { @@ -671,7 +671,7 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { } } - if (event.tool === "task") { + if (event.tool === "subagent") { const agentType = getAgentType(args) handleLogAgent(projectRoot, agentType) logger.debug("Agent invoked", { agentType }) @@ -682,6 +682,11 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { if (event.status !== "completed") return const args = getToolArgs(event.input) + if (event.tool === "subagent") { + const agentType = getAgentType(args) + handleLogAgentStop(projectRoot, agentType) + logger.debug("Agent completed", { agentType }) + } const filePath = normalizePath( (typeof args.filePath === "string" ? args.filePath : "") || (typeof args.path === "string" ? args.path : "") @@ -712,12 +717,6 @@ export const CCGSHooks: OpenCodePlugin.Plugin = { } } - if (event.tool === "task") { - const agentType = getAgentType(args) - handleLogAgentStop(projectRoot, agentType) - logger.debug("Agent completed", { agentType }) - } - const skillChange = detectSkillChange(filePath) if (skillChange) { logger.info(`Skill modified: ${skillChange}`, { skill: skillChange }) diff --git a/.opencode/plugins/changelog-generator.ts b/.opencode/plugins/changelog-generator.ts index 1a0d2cc..4c8e28a 100644 --- a/.opencode/plugins/changelog-generator.ts +++ b/.opencode/plugins/changelog-generator.ts @@ -280,7 +280,7 @@ export const ChangelogGenerator: OpenCodePlugin.Plugin = { logger.info("Changelog generator loaded", { projectRoot }) await ctx.tool.hook("execute.before", (event) => { - if (event.tool !== "bash") return + if (event.tool !== "shell") return const args = getToolArgs(event.input) const command = typeof args.command === "string" ? args.command : "" diff --git a/.opencode/plugins/tests/test-plugin-v2.mjs b/.opencode/plugins/tests/test-plugin-v2.mjs index 4ddba9a..44ee8f8 100644 --- a/.opencode/plugins/tests/test-plugin-v2.mjs +++ b/.opencode/plugins/tests/test-plugin-v2.mjs @@ -290,58 +290,128 @@ describe("OpenCode V2 hook behavior", () => { } }) - it("reads V2 bash input when checking protected-branch pushes", async () => { + it("reads V2 shell input when checking protected-branch pushes in both CCGS copies", async () => { const root = makeTempProject("ocgs-v2-push-") execSync("git init -q", { cwd: root, stdio: "ignore" }) execSync("git checkout -b feature/plugin-v2", { cwd: root, stdio: "ignore" }) - const plugin = definition(runtimePlugins["ccgs-hooks"]) - const fixture = createContext(root) - let dispose - let messages + const plugins = [runtimePlugins["ccgs-hooks"], modulePlugins["ccgs-hooks"]] + const messages = [] try { - assert.equal(typeof plugin?.setup, "function") - messages = await captureConsole(async () => { - dispose = await plugin.setup(fixture.context) - const hook = fixture.handlers.get("tool:execute.before") - assert.equal(typeof hook, "function") - await hook({ tool: "bash", input: { command: "git push origin master" } }) - }) + for (const pluginModule of plugins) { + const plugin = definition(pluginModule) + const fixture = createContext(root) + let dispose + + try { + messages.push(...await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + await hook({ tool: "shell", input: { command: "git push origin master" } }) + })) + } finally { + await dispose?.() + } + } } finally { - await dispose?.() fs.rmSync(root, { recursive: true, force: true }) } - assert.ok(messages.some((message) => message.includes("Push to protected branch 'master'"))) + assert.equal(messages.filter((message) => message.includes("Push to protected branch 'master'")).length, 2) }) - it("blocks invalid data-file commits from V2 bash input", async () => { + it("blocks invalid data-file commits from V2 shell input in both CCGS copies", async () => { const root = makeTempProject("ocgs-v2-commit-") const dataDir = path.join(root, "assets", "data") fs.mkdirSync(dataDir, { recursive: true }) fs.writeFileSync(path.join(dataDir, "broken.json"), "not valid JSON\\n") execSync("git init -q", { cwd: root, stdio: "ignore" }) execSync("git add assets/data/broken.json", { cwd: root, stdio: "ignore" }) - const plugin = definition(runtimePlugins["ccgs-hooks"]) - const fixture = createContext(root) - let dispose + const plugins = [runtimePlugins["ccgs-hooks"], modulePlugins["ccgs-hooks"]] try { - assert.equal(typeof plugin?.setup, "function") - await captureConsole(async () => { - dispose = await plugin.setup(fixture.context) - const hook = fixture.handlers.get("tool:execute.before") - assert.equal(typeof hook, "function") - assert.throws( - () => hook({ tool: "bash", input: { command: "git commit -m 'fix: invalid data'" } }), - /not valid JSON/, - ) - }) + for (const pluginModule of plugins) { + const plugin = definition(pluginModule) + const fixture = createContext(root) + let dispose + + try { + await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + assert.throws( + () => hook({ tool: "shell", input: { command: "git commit -m 'fix: invalid data'" } }), + /not valid JSON/, + ) + }) + } finally { + await dispose?.() + } + } } finally { - await dispose?.() fs.rmSync(root, { recursive: true, force: true }) } }) + it("audits V2 subagent invocations and completions in both CCGS copies", async () => { + for (const pluginModule of [runtimePlugins["ccgs-hooks"], modulePlugins["ccgs-hooks"]]) { + const root = makeTempProject("ocgs-v2-subagent-") + const plugin = definition(pluginModule) + const fixture = createContext(root) + let dispose + + try { + await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const beforeHook = fixture.handlers.get("tool:execute.before") + const afterHook = fixture.handlers.get("tool:execute.after") + assert.equal(typeof beforeHook, "function") + assert.equal(typeof afterHook, "function") + await beforeHook({ tool: "subagent", input: { agent: "reviewer", task: "Review the changes" } }) + await afterHook({ + tool: "subagent", + status: "completed", + input: { agent: "reviewer", task: "Review the changes" }, + result: { content: "Review complete." }, + }) + }) + + const auditFile = path.join(root, "production", "session-logs", "agent-audit.log") + const audit = fs.readFileSync(auditFile, "utf8") + assert.match(audit, /Agent invoked: reviewer/) + assert.match(audit, /Agent completed: reviewer/) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + } + }) + + it("reads V2 shell input when detecting changelog commands in both copies", async () => { + const plugins = [runtimePlugins["changelog-generator"], modulePlugins["changelog-generator"]] + const messages = [] + + for (const pluginModule of plugins) { + const root = makeTempProject("ocgs-v2-changelog-") + const plugin = definition(pluginModule) + const fixture = createContext(root) + let dispose + + try { + messages.push(...await captureConsole(async () => { + dispose = await plugin.setup(fixture.context) + const hook = fixture.handlers.get("tool:execute.before") + assert.equal(typeof hook, "function") + await hook({ tool: "shell", input: { command: "npm run changelog" } }) + })) + } finally { + await dispose?.() + fs.rmSync(root, { recursive: true, force: true }) + } + } + assert.equal(messages.filter((message) => message.includes("Changelog-related command detected")).length, 2) + }) it("runs the drift scan for a V2 session-created event", async () => { const root = makeTempProject("ocgs-v2-event-") @@ -400,25 +470,4 @@ describe("OpenCode V2 hook behavior", () => { assert.ok(messages.some((message) => message.includes("HIGH issues"))) }) - it("reads the V2 tool input when detecting changelog commands", async () => { - const root = makeTempProject("ocgs-v2-changelog-") - const plugin = definition(runtimePlugins["changelog-generator"]) - const fixture = createContext(root) - let dispose - let messages - - try { - assert.equal(typeof plugin?.setup, "function") - messages = await captureConsole(async () => { - dispose = await plugin.setup(fixture.context) - const hook = fixture.handlers.get("tool:execute.before") - assert.equal(typeof hook, "function") - await hook({ tool: "bash", input: { command: "npm run changelog" } }) - }) - } finally { - await dispose?.() - fs.rmSync(root, { recursive: true, force: true }) - } - assert.ok(messages.some((message) => message.includes("Changelog-related command detected"))) - }) })