From 3b904cbbd9b2cd638aaa4d4fb5fcf3ec620e0f80 Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Wed, 4 Nov 2026 11:05:57 +0200 Subject: [PATCH 1/4] feat(hooks): add harness-neutral CCGS hook logic for Pi and omp Ports the hook logic from .opencode/plugins/ccgs-hooks.ts into a pure Node module with no Pi or omp imports. The wiring is in a separate extension (next commit), so this logic runs under node --test directly. Git calls use argv instead of a shell. Notifications spawn detached, so a missing notifier binary cannot block the agent. --- .pi/extensions/ocgs-hooks/core.ts | 528 ++++++++++++++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 .pi/extensions/ocgs-hooks/core.ts diff --git a/.pi/extensions/ocgs-hooks/core.ts b/.pi/extensions/ocgs-hooks/core.ts new file mode 100644 index 0000000..13181ab --- /dev/null +++ b/.pi/extensions/ocgs-hooks/core.ts @@ -0,0 +1,528 @@ +/** + * Harness-neutral CCGS hook logic. The Pi and omp wiring lives in ./index.ts. + * Ported from .opencode/plugins/ccgs-hooks.ts. Pure Node: no Pi or omp imports, + * so the functions run under `node --test` without a harness. + */ +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const PROTECTED_BRANCHES = ["main", "master", "develop"]; +const SOURCE_EXTENSIONS = [".gd", ".cs", ".cpp", ".c", ".h", ".hpp", ".rs", ".py", ".js", ".ts"]; +const ASSETS_PATH_RE = /(?:^|\/)assets\//; +const ASSET_JSON_RE = /^assets\/data\/.*\.json$/; +const SKILL_CHANGE_RE = /\.(?:opencode|agents)\/(?:skills|commands)\/([^/]+)/; +// omp's edit tool names each edited file in a [PATH#TAG] header line. +const HASHLINE_HEADER_RE = /^\[([^\]\n#]+)#[0-9A-Fa-f]{4}\]/gm; + +export const DESIGN_SECTIONS = [ + "Overview", + "Player Fantasy", + "Detailed", + "Formulas", + "Edge Cases", + "Dependencies", + "Tuning Knobs", + "Acceptance Criteria", +]; + +export interface Findings { + warnings: string[]; + errors: string[]; +} + +/** Runs git with argv (no shell). Returns trimmed stdout, or "" on any failure. */ +export function git(cwd: string, ...args: string[]): string { + const result = spawnSync("git", args, { + encoding: "utf8", + cwd, + stdio: ["pipe", "pipe", "ignore"], + timeout: 15000, + windowsHide: true, + }); + if (result.error || result.status !== 0) return ""; + return (result.stdout ?? "").trim(); +} + +export function isGitRepo(cwd: string): boolean { + return git(cwd, "rev-parse", "--git-dir") !== ""; +} + +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/"); +} + +export function isBashTool(toolName: string): boolean { + return toolName === "bash" || toolName === "powershell"; +} + +/** Pi's delegation tool is `Task`; omp's built-in is `task`. */ +export function isTaskTool(toolName: string): boolean { + return toolName.toLowerCase() === "task"; +} + +function sessionTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +} + +export function logAudit(projectRoot: string, message: string): void { + const dir = path.join(projectRoot, "production", "session-logs"); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, "agent-audit.log"), `${sessionTimestamp()} | ${message}\n`); +} + +function validateJson(filePath: string): boolean { + try { + JSON.parse(fs.readFileSync(filePath, "utf8")); + return true; + } catch { + return false; + } +} + +function findFilesRecursive(root: string, predicate: (name: string, filePath: string) => boolean): string[] { + const results: string[] = []; + if (!fs.existsSync(root)) return results; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + results.push(...findFilesRecursive(fullPath, predicate)); + } else if (entry.isFile() && predicate(entry.name, fullPath)) { + results.push(fullPath); + } + } + return results; +} + +function getFilesByMtime(dir: string, filter: (name: string) => boolean): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(filter) + .map((f) => path.join(dir, f)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); +} + +export function handleSessionCreated(projectRoot: string): string { + const lines: string[] = []; + logAudit(projectRoot, "session.created fired"); + + if (isGitRepo(projectRoot)) { + const branch = git(projectRoot, "rev-parse", "--abbrev-ref", "HEAD"); + if (branch) { + lines.push(`Branch: ${branch}`); + lines.push(""); + lines.push("Recent commits:"); + const commits = git(projectRoot, "log", "--oneline", "-5"); + if (commits) commits.split("\n").forEach((line) => lines.push(` ${line}`)); + } + } + + const sprintFiles = getFilesByMtime(path.join(projectRoot, "production", "sprints"), (f) => + /^sprint-.*\.md$/.test(f), + ); + if (sprintFiles.length > 0) { + lines.push(""); + lines.push(`Active sprint: ${path.basename(sprintFiles[0], ".md")}`); + } + + const milestoneFiles = getFilesByMtime(path.join(projectRoot, "production", "milestones"), (f) => + f.endsWith(".md"), + ); + if (milestoneFiles.length > 0) { + lines.push(`Active milestone: ${path.basename(milestoneFiles[0], ".md")}`); + } + + let bugCount = 0; + for (const dir of [path.join(projectRoot, "tests", "playtest"), path.join(projectRoot, "production")]) { + bugCount += findFilesRecursive(dir, (name) => /^BUG-.*\.md$/.test(name)).length; + } + if (bugCount > 0) lines.push(`Open bugs: ${bugCount}`); + + const srcDir = path.join(projectRoot, "src"); + if (fs.existsSync(srcDir)) { + let todoCount = 0; + let fixmeCount = 0; + for (const fp of findFilesRecursive(srcDir, () => true)) { + const content = fs.readFileSync(fp, "utf8"); + todoCount += (content.match(/TODO/g) || []).length; + fixmeCount += (content.match(/FIXME/g) || []).length; + } + if (todoCount > 0 || fixmeCount > 0) { + lines.push(""); + lines.push(`Code health: ${todoCount} TODOs, ${fixmeCount} FIXMEs in src/`); + } + } + + return lines.join("\n"); +} + +/** Archives active.md and the session's commits and uncommitted files to session-log.md. */ +export function handleSessionIdle(projectRoot: string): void { + logAudit(projectRoot, "Session idle/shutdown"); + + const timestamp = sessionTimestamp(); + const logDir = path.join(projectRoot, "production", "session-logs"); + fs.mkdirSync(logDir, { recursive: true }); + + const stateFile = path.join(projectRoot, "production", "session-state", "active.md"); + if (fs.existsSync(stateFile)) { + const block = `## Archived Session State: ${timestamp}\n${fs.readFileSync(stateFile, "utf8")}\n---\n`; + fs.appendFileSync(path.join(logDir, "session-log.md"), block); + } + + if (isGitRepo(projectRoot)) { + const recentCommits = git(projectRoot, "log", "--oneline", "--since=8 hours ago"); + const modifiedFiles = git(projectRoot, "diff", "--name-only"); + if (recentCommits || modifiedFiles) { + let entry = `## Session End: ${timestamp}\n`; + if (recentCommits) entry += `### Commits\n${recentCommits}\n`; + if (modifiedFiles) entry += `### Uncommitted Changes\n${modifiedFiles}\n`; + entry += "---\n"; + fs.appendFileSync(path.join(logDir, "session-log.md"), entry); + } + } +} + +function isEngineConfigured(projectRoot: string): boolean { + const agentsMd = path.join(projectRoot, "AGENTS.md"); + if (!fs.existsSync(agentsMd)) return false; + const engineLine = fs + .readFileSync(agentsMd, "utf8") + .split("\n") + .find((l) => /^\s*-\s*\*\*Engine\*\*:/.test(l)); + return !engineLine?.includes("[CHOOSE:"); +} + +function getSubdirNames(root: string): string[] { + if (!fs.existsSync(root)) return []; + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +export function handleLogAgent(projectRoot: string, agentType: string): void { + const dir = path.join(projectRoot, "production", "session-logs"); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, "agent-audit.log"), + `${sessionTimestamp()} | Agent invoked: ${agentType || "unknown"}\n`, + ); +} + +export function handleLogAgentStop(projectRoot: string, agentType: string): void { + const dir = path.join(projectRoot, "production", "session-logs"); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, "agent-audit.log"), + `${sessionTimestamp()} | Agent completed: ${agentType || "unknown"}\n`, + ); +} + +export function handleDetectGaps(projectRoot: string): string[] { + const gaps: string[] = []; + + const engineConfigured = isEngineConfigured(projectRoot); + const hasGameConcept = fs.existsSync(path.join(projectRoot, "design", "gdd", "game-concept.md")); + const srcCount = findFilesRecursive(path.join(projectRoot, "src"), (name) => + SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)), + ).length; + const isFresh = !engineConfigured && !hasGameConcept && srcCount === 0; + + if (isFresh) { + gaps.push("NEW PROJECT: No engine configured, no game concept, no source code. Run: /start"); + gaps.push("For comprehensive analysis, run: /project-stage-detect"); + return gaps; + } + + const designCount = findFilesRecursive(path.join(projectRoot, "design", "gdd"), (name) => + name.endsWith(".md"), + ).length; + if (srcCount > 50 && designCount < 5) { + gaps.push( + `GAP: ${srcCount} source files but only ${designCount} design docs. Run: /reverse-document design src/[system]`, + ); + } + + const protoDir = path.join(projectRoot, "prototypes"); + if (fs.existsSync(protoDir)) { + const undocumented = getSubdirNames(protoDir).filter( + (dir) => + !fs.existsSync(path.join(protoDir, dir, "README.md")) && + !fs.existsSync(path.join(protoDir, dir, "CONCEPT.md")), + ); + if (undocumented.length > 0) { + gaps.push( + `GAP: ${undocumented.length} undocumented prototypes (${undocumented.join(", ")}). Run: /reverse-document concept [name]`, + ); + } + } + + const coreDir = path.join(projectRoot, "src", "core"); + const engineDir = path.join(projectRoot, "src", "engine"); + const archDir = path.join(projectRoot, "docs", "architecture"); + if (fs.existsSync(coreDir) || fs.existsSync(engineDir)) { + if (!fs.existsSync(archDir)) { + gaps.push("GAP: Core systems exist but no docs/architecture/. Run: /architecture-decision"); + } else { + const adrCount = findFilesRecursive(archDir, (name) => name.endsWith(".md")).length; + if (adrCount < 3) { + gaps.push( + `GAP: Only ${adrCount} ADRs for core systems. Run: /reverse-document architecture src/core/[system]`, + ); + } + } + } + + const gameplayDir = path.join(projectRoot, "src", "gameplay"); + for (const system of getSubdirNames(gameplayDir)) { + const fileCount = findFilesRecursive(path.join(gameplayDir, system), () => true).length; + if (fileCount >= 5) { + const doc1 = path.join(projectRoot, "design", "gdd", `${system}-system.md`); + const doc2 = path.join(projectRoot, "design", "gdd", `${system}.md`); + if (!fs.existsSync(doc1) && !fs.existsSync(doc2)) { + gaps.push( + `GAP: src/gameplay/${system}/ (${fileCount} files) has no design doc. Run: /reverse-document design src/gameplay/${system}`, + ); + } + } + } + + if ( + srcCount > 100 && + !fs.existsSync(path.join(projectRoot, "production", "sprints")) && + !fs.existsSync(path.join(projectRoot, "production", "milestones")) + ) { + gaps.push(`GAP: ${srcCount} files but no production planning. Run: /sprint-plan`); + } + + return gaps; +} + +/** Checks staged files before `git commit`. Errors block the commit; warnings are advisory. */ +export function validateCommitFiles(projectRoot: string, stagedFiles: string[]): Findings { + const warnings: string[] = []; + const errors: string[] = []; + + for (const file of stagedFiles) { + const fp = path.join(projectRoot, file); + if (!fs.existsSync(fp)) continue; + + if (file.startsWith("design/gdd/") && file.endsWith(".md")) { + const content = fs.readFileSync(fp, "utf8"); + for (const section of DESIGN_SECTIONS) { + if (!content.toLowerCase().includes(section.toLowerCase())) { + warnings.push(`DESIGN: ${file} missing required section: ${section}`); + } + } + } + + if (ASSET_JSON_RE.test(file) && !validateJson(fp)) { + errors.push(`BLOCKED: ${file} is not valid JSON. Fix before committing.`); + } + + if (file.startsWith("src/gameplay/")) { + const content = fs.readFileSync(fp, "utf8"); + if (/(damage|health|speed|rate|chance|cost|duration)\s*[:=]\s*\d+/.test(content)) { + warnings.push(`CODE: ${file} may contain hardcoded gameplay values. Use data files.`); + } + } + + if (file.startsWith("src/")) { + const content = fs.readFileSync(fp, "utf8"); + if (content.split("\n").some((l) => /(TODO|FIXME|HACK)[^(]/.test(l))) { + warnings.push(`STYLE: ${file} has TODO/FIXME without owner tag. Use TODO(name) format.`); + } + } + } + + return { warnings, errors }; +} + +/** Returns the skill or command name when a path under .agents/ (or .opencode/) is a skill or command. */ +export function detectSkillChange(filePath: string): string | null { + const match = filePath.match(SKILL_CHANGE_RE); + return match ? match[1] : null; +} + +export function validateAssetPath(projectRoot: string, filePath: string): Findings { + const normalized = normalizePath(filePath); + const warnings: string[] = []; + const errors: string[] = []; + + if (!ASSETS_PATH_RE.test(normalized)) return { warnings, errors }; + + const filename = path.basename(normalized); + if (/[A-Z\s-]/.test(filename)) { + warnings.push(`NAMING: ${normalized} must be lowercase with underscores (got: ${filename})`); + } + + if (/(?:^|\/)assets\/data\/.*\.json$/.test(normalized)) { + const absolutePath = path.isAbsolute(normalized) ? normalized : path.join(projectRoot, normalized); + if (fs.existsSync(absolutePath) && !validateJson(absolutePath)) { + errors.push(`FORMAT: ${normalized} is not valid JSON`); + } + } + + return { warnings, errors }; +} + +/** + * Paths a write or edit tool call touched. Pi's edit passes `path`; omp's edit passes + * hashline text in `input` with one [PATH#TAG] header per file, and no `path`. + */ +export function editedPaths(toolName: string, input: Record): string[] { + if (toolName !== "write" && toolName !== "edit") return []; + const paths: string[] = []; + if (typeof input.path === "string" && input.path) paths.push(input.path); + if (toolName === "edit" && typeof input.input === "string") { + for (const match of input.input.matchAll(HASHLINE_HEADER_RE)) paths.push(match[1]); + } + return [...new Set(paths)]; +} + +export function buildCompactionContext(projectRoot: string): string { + const lines: string[] = []; + + lines.push("=== SESSION STATE BEFORE COMPACTION ==="); + lines.push(`Timestamp: ${new Date().toISOString()}`); + + const activeState = path.join(projectRoot, "production", "session-state", "active.md"); + if (fs.existsSync(activeState)) { + const contentLines = fs.readFileSync(activeState, "utf8").split("\n"); + lines.push(""); + lines.push("## Active Session State (from production/session-state/active.md)"); + if (contentLines.length > 100) { + lines.push(contentLines.slice(0, 100).join("\n")); + lines.push(`... (truncated — ${contentLines.length} total lines, showing first 100)`); + } else { + lines.push(contentLines.join("\n")); + } + } else { + lines.push(""); + lines.push("## No active session state file found"); + lines.push("Consider maintaining production/session-state/active.md for better recovery."); + } + + lines.push(""); + lines.push("## Files Modified (git working tree)"); + if (isGitRepo(projectRoot)) { + const changed = git(projectRoot, "diff", "--name-only"); + const staged = git(projectRoot, "diff", "--staged", "--name-only"); + const untracked = git(projectRoot, "ls-files", "--others", "--exclude-standard"); + + let anyChanges = false; + if (changed) { + anyChanges = true; + lines.push("Unstaged changes:"); + changed.split("\n").forEach((f) => lines.push(` - ${f}`)); + } + if (staged) { + anyChanges = true; + lines.push("Staged changes:"); + staged.split("\n").forEach((f) => lines.push(` - ${f}`)); + } + if (untracked) { + anyChanges = true; + lines.push("New untracked files:"); + untracked.split("\n").forEach((f) => lines.push(` - ${f}`)); + } + if (!anyChanges) lines.push(" (no uncommitted changes)"); + } else { + lines.push(" (not a git repository)"); + } + + lines.push(""); + lines.push("## Design Docs — Work In Progress"); + const gddDir = path.join(projectRoot, "design", "gdd"); + let wipFound = false; + for (const fp of findFilesRecursive(gddDir, (name) => name.endsWith(".md"))) { + const wipLines = fs + .readFileSync(fp, "utf8") + .split("\n") + .filter((l) => /TODO|WIP|PLACEHOLDER|\[TO BE|\[TBD\]/.test(l)); + if (wipLines.length > 0) { + wipFound = true; + lines.push(` ${normalizePath(path.relative(projectRoot, fp))}:`); + wipLines.forEach((l) => lines.push(` ${l}`)); + } + } + if (!wipFound) lines.push(" (no WIP markers found in design docs)"); + + lines.push(""); + lines.push("## Recovery Instructions"); + lines.push("After compaction, read production/session-state/active.md to recover full working context."); + lines.push("Then read any files listed above that are being actively worked on."); + lines.push("=== END SESSION STATE ==="); + + return lines.join("\n"); +} + +export function logCompactionEvent(projectRoot: string): void { + const logDir = path.join(projectRoot, "production", "session-logs"); + fs.mkdirSync(logDir, { recursive: true }); + fs.appendFileSync( + path.join(logDir, "compaction-log.txt"), + `Context compaction occurred at ${new Date().toISOString()}.\n`, + ); +} + +export function handlePostCompact(projectRoot: string): string { + const active = path.join(projectRoot, "production", "session-state", "active.md"); + if (fs.existsSync(active)) { + const size = fs.readFileSync(active, "utf8").split("\n").length; + return `Session state restored: production/session-state/active.md (${size} lines) — read this file to continue working.`; + } + return "No session state file found. Check production/session-logs/ for the last session audit."; +} + +export function detectPushToProtected(cmd: string, currentBranch: string): string { + for (const b of PROTECTED_BRANCHES) { + if (currentBranch === b || new RegExp(`\\s${b}(\\s|$)`).test(cmd)) return b; + } + return ""; +} + +export function getAgentType(args: Record): string { + const agentType = args.agent || args.subagent_type || args.subagentType || args.agent_type || args.agentType; + return typeof agentType === "string" ? agentType : ""; +} + +/** + * Best-effort OS notification. Spawns detached so it never blocks the agent + * (the OpenCode port used execSync, which froze the event loop for the sleep). + */ +export function showNotification(message: string): void { + const text = (message || "Agent needs your attention").slice(0, 200); + const run = (command: string, args: string[]) => { + const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }); + child.on("error", () => { + /* notification is best-effort; a missing notifier binary is not an error */ + }); + child.unref(); + }; + + if (process.platform === "win32") { + const escaped = text.replace(/'/g, "''"); + run("powershell.exe", [ + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + "Add-Type -AssemblyName System.Windows.Forms; " + + "$n = New-Object System.Windows.Forms.NotifyIcon; " + + "$n.Icon = [System.Drawing.SystemIcons]::Information; " + + "$n.BalloonTipTitle = 'OCGS'; " + + `$n.BalloonTipText = '${escaped}'; ` + + "$n.Visible = $true; " + + "$n.ShowBalloonTip(5000); " + + "Start-Sleep -Seconds 6; " + + "$n.Dispose()", + ]); + } else if (process.platform === "darwin") { + run("osascript", ["-e", `display notification ${JSON.stringify(text)} with title "OCGS"`]); + } else if (process.platform === "linux") { + run("notify-send", ["OCGS", text]); + } +} From 222299f073b1744a831289bd13cae3d46a30575d Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Wed, 4 Nov 2026 11:06:26 +0200 Subject: [PATCH 2/4] feat(hooks): register the CCGS hooks extension for Pi and omp Maps the 12 hooks listed under "Hooks Mapping" in README.md to Pi and omp events. Pi auto-discovers .pi/extensions/ocgs-hooks. omp loads it through .omp/config.yml (next commit). Each harness emits only some of the events. A handler for an event the running harness never emits never fires. The shared session-stop handler skips subagent sessions (ctx.agent.kind === "sub"), so a child session cannot archive the parent's active.md. Pi's ExtensionContext has no agent field, so this guard is inert on Pi. Tests cover the commit and push guards, asset and skill checks, agent audit, compaction context, and the subagent stop regression. --- .pi/extensions/ocgs-hooks/index.ts | 259 +++++++++++++ tests/extensions/test-ocgs-hooks.test.mjs | 450 ++++++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 .pi/extensions/ocgs-hooks/index.ts create mode 100644 tests/extensions/test-ocgs-hooks.test.mjs diff --git a/.pi/extensions/ocgs-hooks/index.ts b/.pi/extensions/ocgs-hooks/index.ts new file mode 100644 index 0000000..b1a5f79 --- /dev/null +++ b/.pi/extensions/ocgs-hooks/index.ts @@ -0,0 +1,259 @@ +/** + * OCGS hooks for Pi and oh-my-pi: the 12 CCGS hooks listed in README.md ("Hooks Mapping"). + * Logic lives in ./core. Each harness emits only some of the events below; a handler for + * an event the running harness never emits simply never fires. + * Pi: agent_settled, session_compact (context fallback), ui_prompt_start + * omp: session_stop, session.compacting (context into the summary), tool_approval_requested + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + buildCompactionContext, + detectPushToProtected, + detectSkillChange, + editedPaths, + getAgentType, + git, + handleDetectGaps, + handleLogAgent, + handleLogAgentStop, + handlePostCompact, + handleSessionCreated, + handleSessionIdle, + isBashTool, + isGitRepo, + isTaskTool, + logAudit, + logCompactionEvent, + normalizePath, + showNotification, + validateAssetPath, + validateCommitFiles, +} from "./core"; + +interface HookContext { + cwd?: string; + hasUI?: boolean; + ui?: { notify(message: string, type?: "info" | "warning" | "error"): void }; + agent?: { kind?: string }; +} + +interface ContentBlock { + type: "text"; + text: string; +} + +interface ToolCallEvent { + toolName: string; + input?: Record; +} + +interface ToolResultEvent extends ToolCallEvent { + content: ContentBlock[] | string; + isError: boolean; +} + +/** The subset of the Pi/omp ExtensionAPI this extension uses. omp's event map is not in Pi's typings. */ +interface HookRegistrar { + on(event: string, handler: (event: E, ctx: HookContext) => unknown): void; + sendMessage(message: { customType: string; content: string; display: boolean }): void; +} + +/** Every handler resolves the project from the event's cwd. The fallback is shared, so it stays a named function. */ +function rootOf(ctx: HookContext): string { + return ctx.cwd || process.cwd(); +} + +/** Hook failures are recorded, never thrown: omp fails closed on tool_call errors. */ +function reportError(root: string, label: string, error: unknown): void { + logAudit(root, `ERROR in ${label}: ${String(error)}`); +} + +/** Returns the reason to block a bash command, or undefined to allow it. */ +function checkBashCommand(root: string, command: string, ctx: HookContext): string | undefined { + if (!isGitRepo(root)) return undefined; + + if (/^git\s+push/.test(command)) { + const matched = detectPushToProtected(command, git(root, "rev-parse", "--abbrev-ref", "HEAD")); + if (matched) { + const message = `Push to protected branch '${matched}' detected.`; + logAudit(root, message); + logAudit(root, "Reminder: Ensure build passes, unit tests pass, and no S1/S2 bugs exist."); + if (ctx.hasUI) ctx.ui?.notify(`${message} Ensure build and tests pass first.`, "warning"); + } + return undefined; + } + + if (!/^git\s+commit/.test(command)) return undefined; + + const staged = git(root, "diff", "--cached", "--name-only"); + if (!staged) return undefined; + + const result = validateCommitFiles(root, staged.split("\n")); + for (const warning of result.warnings) logAudit(root, warning); + if (result.errors.length > 0) { + for (const error of result.errors) logAudit(root, `Commit blocked: ${error}`); + return result.errors.join("\n"); + } + if (result.warnings.length > 0 && ctx.hasUI) ctx.ui?.notify(result.warnings.join("\n"), "warning"); + return undefined; +} + +/** Asset and skill checks for a completed write or edit. Errors are appended to the tool result. */ +function checkEditedFiles(root: string, event: ToolResultEvent): { content: ContentBlock[] } | undefined { + const errors: string[] = []; + for (const filePath of editedPaths(event.toolName, event.input ?? {})) { + const asset = validateAssetPath(root, filePath); + for (const warning of asset.warnings) logAudit(root, warning); + if (asset.warnings.length > 0) logAudit(root, "(Warnings are advisory. Fix before final commit.)"); + for (const error of asset.errors) { + logAudit(root, error); + errors.push(error); + } + + const skill = detectSkillChange(normalizePath(filePath)); + if (skill) { + logAudit(root, `=== Skill Modified: ${skill} ===`); + logAudit(root, `Run /skill-test static ${skill} to validate structural compliance.`); + } + } + + if (errors.length === 0) return undefined; + const existing: ContentBlock[] = Array.isArray(event.content) + ? event.content + : [{ type: "text", text: String(event.content) }]; + return { + content: [ + ...existing, + { type: "text", text: "Asset validation failed. Fix errors before proceeding." }, + ...errors.map((text): ContentBlock => ({ type: "text", text })), + ], + }; +} + +export default function ocgsHooks(pi: ExtensionAPI): void { + const api = pi as unknown as HookRegistrar; + // Set when omp's session.compacting has already placed the context in the summary, so + // session_compact does not send it a second time. Pi never emits session.compacting. + let contextInjectedBeforeCompaction = false; + logAudit(process.cwd(), "Plugin loaded"); + + // 1. session-start.sh + detect-gaps.sh + api.on("session_start", (_event: unknown, ctx: HookContext) => { + if (ctx.agent?.kind === "sub") return undefined; + const root = rootOf(ctx); + try { + const summary = handleSessionCreated(root); + const gaps = handleDetectGaps(root); + for (const gap of gaps) logAudit(root, gap); + if (ctx.hasUI) { + if (summary) ctx.ui?.notify(summary, "info"); + if (gaps.length > 0) ctx.ui?.notify(gaps.join("\n"), "warning"); + } + } catch (error) { + reportError(root, "session_start", error); + } + return undefined; + }); + + // 2. session-stop.sh: Pi emits agent_settled after each run; omp emits session_stop. + const archiveSessionState = (_event: unknown, ctx: HookContext) => { + if (ctx.agent?.kind === "sub") return undefined; + const root = rootOf(ctx); + try { + handleSessionIdle(root); + } catch (error) { + reportError(root, "session_idle", error); + } + return undefined; + }; + api.on("agent_settled", archiveSessionState); + api.on("session_stop", archiveSessionState); + + // 10. pre-compact.sh: audit every compaction, before it runs (both harnesses). + api.on("session_before_compact", (_event: unknown, ctx: HookContext) => { + const root = rootOf(ctx); + try { + logCompactionEvent(root); + } catch (error) { + reportError(root, "session_before_compact", error); + } + return undefined; + }); + + // 10. pre-compact.sh on omp: the context enters the compaction summary itself. + api.on("session.compacting", (_event: unknown, ctx: HookContext) => { + if (ctx.agent?.kind === "sub") return undefined; + const root = rootOf(ctx); + try { + const context = buildCompactionContext(root); + contextInjectedBeforeCompaction = true; + return { context: [context] }; + } catch (error) { + reportError(root, "session.compacting", error); + return undefined; + } + }); + + // 10 + 11. pre-compact.sh context (Pi fallback) and post-compact.sh restore notice. + api.on("session_compact", (_event: unknown, ctx: HookContext) => { + if (ctx.agent?.kind === "sub") return undefined; + const root = rootOf(ctx); + const alreadyInjected = contextInjectedBeforeCompaction; + contextInjectedBeforeCompaction = false; + try { + if (!alreadyInjected) { + api.sendMessage({ + customType: "ocgs-compaction-context", + content: buildCompactionContext(root), + display: false, + }); + } + const message = handlePostCompact(root); + logAudit(root, `Post-compact: ${message}`); + if (ctx.hasUI) ctx.ui?.notify(message, "info"); + } catch (error) { + reportError(root, "session_compact", error); + } + return undefined; + }); + + // 4, 7, 8. log-agent.sh (task start), validate-commit.sh, validate-push.sh (bash) + api.on("tool_call", (event: ToolCallEvent, ctx: HookContext) => { + const root = rootOf(ctx); + try { + const input = event.input ?? {}; + if (isBashTool(event.toolName) && typeof input.command === "string") { + const reason = checkBashCommand(root, input.command, ctx); + if (reason) return { block: true, reason }; + } + if (isTaskTool(event.toolName)) handleLogAgent(root, getAgentType(input)); + } catch (error) { + reportError(root, "tool_call", error); + } + return undefined; + }); + + // 5, 6, 9. log-agent-stop.sh (task end), validate-assets.sh, validate-skill-change.sh (write/edit) + api.on("tool_result", (event: ToolResultEvent, ctx: HookContext) => { + if (event.isError) return undefined; + const root = rootOf(ctx); + try { + if (isTaskTool(event.toolName)) { + handleLogAgentStop(root, getAgentType(event.input ?? {})); + return undefined; + } + return checkEditedFiles(root, event); + } catch (error) { + reportError(root, "tool_result", error); + return undefined; + } + }); + + // 12. notify.sh: Pi raises ui_prompt_start, omp raises tool_approval_requested, when the agent waits on the user. + const awaitingInput = (): undefined => { + showNotification("OCGS: the agent is waiting for your input"); + return undefined; + }; + api.on("ui_prompt_start", awaitingInput); + api.on("tool_approval_requested", awaitingInput); +} diff --git a/tests/extensions/test-ocgs-hooks.test.mjs b/tests/extensions/test-ocgs-hooks.test.mjs new file mode 100644 index 0000000..32f5811 --- /dev/null +++ b/tests/extensions/test-ocgs-hooks.test.mjs @@ -0,0 +1,450 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { createFakeExtensionApi } from "./support/fake-extension-api.mjs"; + +const repoRoot = process.cwd(); +const hooksDir = path.join(repoRoot, ".pi", "extensions", "ocgs-hooks"); +const core = await import(pathToFileURL(path.join(hooksDir, "core.ts")).href); +const extension = (await import(pathToFileURL(path.join(hooksDir, "index.ts")).href)).default; + +const created = []; + +function newProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ocgs-hooks-")); + created.push(root); + const init = spawnSync("git", ["init", "-q"], { cwd: root, encoding: "utf8" }); + assert.strictEqual(init.status, 0, `git init failed: ${init.stderr}`); + return root; +} + +function writeFile(root, rel, content) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); +} + +function gitIn(root, ...args) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + assert.strictEqual(result.status, 0, `git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout; +} + +function readAudit(root) { + const file = path.join(root, "production", "session-logs", "agent-audit.log"); + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; +} + +/** Loads the extension with cwd set to the project (the factory logs to process.cwd()). */ +function loadExtension(root) { + process.chdir(root); + const api = createFakeExtensionApi(); + api.sent = []; + api.sendMessage = (message) => api.sent.push(message); + extension(api); + return api; +} + +after(() => { + process.chdir(repoRoot); + // Windows can hold fixtures briefly after the cases finish; retry rather than fail teardown. + for (const root of created) { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + } +}); + +describe("core: commit validation", () => { + it("errors on a staged asset JSON file that does not parse", () => { + const root = newProject(); + writeFile(root, "assets/data/bad.json", "{ not json"); + const result = core.validateCommitFiles(root, ["assets/data/bad.json"]); + assert.deepStrictEqual(result.errors, [ + "BLOCKED: assets/data/bad.json is not valid JSON. Fix before committing.", + ]); + }); + + it("passes a staged asset JSON file that parses", () => { + const root = newProject(); + writeFile(root, "assets/data/good.json", '{"damage": 3}'); + const result = core.validateCommitFiles(root, ["assets/data/good.json"]); + assert.deepStrictEqual(result, { warnings: [], errors: [] }); + }); + + it("warns on a design doc that lacks a required section", () => { + const root = newProject(); + writeFile(root, "design/gdd/combat.md", "## Overview\n\nOnly one section here.\n"); + const result = core.validateCommitFiles(root, ["design/gdd/combat.md"]); + assert.ok(result.warnings.includes("DESIGN: design/gdd/combat.md missing required section: Formulas")); + assert.ok(!result.warnings.includes("DESIGN: design/gdd/combat.md missing required section: Overview")); + }); + + it("warns on a TODO without an owner tag in src/ but not a tagged one", () => { + const root = newProject(); + writeFile(root, "src/main.gd", "# TODO fix this\n"); + writeFile(root, "src/owned.gd", "# TODO(ana) fix this\n"); + const bare = core.validateCommitFiles(root, ["src/main.gd"]); + const owned = core.validateCommitFiles(root, ["src/owned.gd"]); + assert.strictEqual(bare.warnings.length, 1); + assert.match(bare.warnings[0], /STYLE: src\/main\.gd has TODO\/FIXME without owner tag/); + assert.deepStrictEqual(owned.warnings, []); + }); + + it("ignores staged paths that no longer exist (deletions)", () => { + const root = newProject(); + const result = core.validateCommitFiles(root, ["assets/data/removed.json"]); + assert.deepStrictEqual(result, { warnings: [], errors: [] }); + }); +}); + +describe("core: push, skill, and asset checks", () => { + it("flags a push to a protected branch named in the command or the current branch", () => { + assert.strictEqual(core.detectPushToProtected("git push origin main", "feature/x"), "main"); + assert.strictEqual(core.detectPushToProtected("git push", "develop"), "develop"); + assert.strictEqual(core.detectPushToProtected("git push origin feature/x", "feature/x"), ""); + }); + + it("reports the skill or command name for .agents and .opencode skill paths only", () => { + assert.strictEqual(core.detectSkillChange(".agents/skills/demo/SKILL.md"), "demo"); + assert.strictEqual(core.detectSkillChange(".opencode/commands/demo.md"), "demo.md"); + assert.strictEqual(core.detectSkillChange(".agents/rules/ai-code.md"), null); + }); + + it("warns on an asset filename that is not lowercase with underscores", () => { + const root = newProject(); + const result = core.validateAssetPath(root, "assets/Bad Name.png"); + assert.strictEqual(result.warnings.length, 1); + assert.match(result.warnings[0], /NAMING: assets\/Bad Name\.png must be lowercase/); + assert.deepStrictEqual(result.errors, []); + }); + + it("ignores paths outside assets/", () => { + const root = newProject(); + assert.deepStrictEqual(core.validateAssetPath(root, "src/Thing.gd"), { warnings: [], errors: [] }); + }); +}); + +describe("core: edited paths (Pi write/edit and omp hashline edit)", () => { + it("reads the path argument of write and edit", () => { + assert.deepStrictEqual(core.editedPaths("write", { path: "a.md" }), ["a.md"]); + assert.deepStrictEqual(core.editedPaths("edit", { path: "b.md" }), ["b.md"]); + }); + + it("reads every [PATH#TAG] header from an omp edit and dedupes", () => { + const input = { input: "[assets/x.json#A1B2]\n+1\n[assets/y.json#C3D4]\n+2\n[assets/x.json#A1B2]\n" }; + assert.deepStrictEqual(core.editedPaths("edit", input), ["assets/x.json", "assets/y.json"]); + }); + + it("returns nothing for tools that do not edit files", () => { + assert.deepStrictEqual(core.editedPaths("read", { path: "a.md" }), []); + }); +}); + +describe("core: session context and state archive", () => { + it("reports a fresh project as NEW PROJECT with no engine, concept, or source", () => { + const root = newProject(); + const gaps = core.handleDetectGaps(root); + assert.match(gaps[0], /^NEW PROJECT: No engine configured/); + }); + + it("truncates a long active.md to its first 100 lines in the compaction context", () => { + const root = newProject(); + const longState = Array.from({ length: 150 }, (_, i) => `line ${i + 1}`).join("\n"); + writeFile(root, "production/session-state/active.md", longState); + const context = core.buildCompactionContext(root); + assert.match(context, /\(truncated — 150 total lines, showing first 100\)/); + assert.ok(context.includes("line 100")); + assert.ok(!context.includes("line 101\n")); + }); + + it("archives active.md into session-log.md when the session settles", () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: combat tuning\n"); + core.handleSessionIdle(root); + const log = fs.readFileSync(path.join(root, "production", "session-logs", "session-log.md"), "utf8"); + assert.match(log, /## Archived Session State: /); + assert.ok(log.includes("Focus: combat tuning")); + }); +}); + +describe("extension wiring: commit and push guard (tool_call)", () => { + it("blocks a commit that stages invalid asset JSON", async () => { + const root = newProject(); + writeFile(root, "assets/data/bad.json", "{ not json"); + gitIn(root, "add", "assets/data/bad.json"); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_call", + { toolName: "bash", toolCallId: "t1", input: { command: 'git commit -m "wip"' } }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result.block, true); + assert.match(result.reason, /assets\/data\/bad\.json is not valid JSON/); + assert.match(readAudit(root), /Commit blocked: BLOCKED: assets\/data\/bad\.json/); + }); + + it("allows a commit whose staged files pass", async () => { + const root = newProject(); + writeFile(root, "assets/data/good.json", '{"damage": 3}'); + gitIn(root, "add", "assets/data/good.json"); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_call", + { toolName: "bash", toolCallId: "t2", input: { command: 'git commit -m "ok"' } }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result, undefined); + }); + + it("warns on a push to a protected branch without blocking it", async () => { + const root = newProject(); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_call", + { toolName: "bash", toolCallId: "t3", input: { command: "git push origin main" } }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result, undefined); + assert.match(readAudit(root), /Push to protected branch 'main' detected\./); + }); + + it("leaves non-git bash commands and non-bash tools alone", async () => { + const root = newProject(); + const api = loadExtension(root); + const [bash] = await api.emit( + "tool_call", + { toolName: "bash", toolCallId: "t4", input: { command: "ls -la" } }, + { cwd: root, hasUI: false }, + ); + const [read] = await api.emit( + "tool_call", + { toolName: "read", toolCallId: "t5", input: { path: "a.md" } }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(bash, undefined); + assert.strictEqual(read, undefined); + }); +}); + +describe("extension wiring: asset and skill checks (tool_result)", () => { + it("appends the asset error to a Pi write result", async () => { + const root = newProject(); + writeFile(root, "assets/data/broken.json", "{"); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_result", + { + toolName: "write", + toolCallId: "r1", + input: { path: "assets/data/broken.json" }, + content: [{ type: "text", text: "Wrote file" }], + isError: false, + }, + { cwd: root, hasUI: false }, + ); + const texts = result.content.map((block) => block.text); + assert.strictEqual(texts[0], "Wrote file"); + assert.ok(texts.includes("Asset validation failed. Fix errors before proceeding.")); + assert.ok(texts.includes("FORMAT: assets/data/broken.json is not valid JSON")); + }); + + it("appends the asset error to an omp hashline edit result", async () => { + const root = newProject(); + writeFile(root, "assets/data/broken.json", "{"); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_result", + { + toolName: "edit", + toolCallId: "r2", + input: { input: "[assets/data/broken.json#AB12]\n+x" }, + content: "Edited", + isError: false, + }, + { cwd: root, hasUI: false }, + ); + const texts = result.content.map((block) => block.text); + assert.strictEqual(texts[0], "Edited"); + assert.ok(texts.includes("FORMAT: assets/data/broken.json is not valid JSON")); + }); + + it("skips checks when the tool errored", async () => { + const root = newProject(); + writeFile(root, "assets/data/broken.json", "{"); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_result", + { + toolName: "write", + toolCallId: "r3", + input: { path: "assets/data/broken.json" }, + content: [{ type: "text", text: "failed" }], + isError: true, + }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result, undefined); + }); + + it("audits a modified skill and advises /skill-test", async () => { + const root = newProject(); + const api = loadExtension(root); + const [result] = await api.emit( + "tool_result", + { + toolName: "write", + toolCallId: "r4", + input: { path: ".agents/skills/demo/SKILL.md" }, + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result, undefined); + const audit = readAudit(root); + assert.match(audit, /=== Skill Modified: demo ===/); + assert.match(audit, /Run \/skill-test static demo/); + }); +}); + +describe("extension wiring: subagents and session lifecycle", () => { + it("logs a task invocation on start and completion", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit( + "tool_call", + { toolName: "task", toolCallId: "s1", input: { agent: "explore" } }, + { cwd: root, hasUI: false }, + ); + await api.emit( + "tool_result", + { toolName: "task", toolCallId: "s1", input: { agent: "explore" }, content: [], isError: false }, + { cwd: root, hasUI: false }, + ); + const audit = readAudit(root); + assert.match(audit, /Agent invoked: explore/); + assert.match(audit, /Agent completed: explore/); + }); + + it("runs the session-start checks for the main session", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit("session_start", { type: "session_start", reason: "startup" }, { cwd: root, hasUI: false }); + const audit = readAudit(root); + assert.match(audit, /session\.created fired/); + assert.match(audit, /NEW PROJECT/); + }); + + it("skips the session-start checks inside a subagent session", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit( + "session_start", + { type: "session_start" }, + { cwd: root, hasUI: false, agent: { kind: "sub" } }, + ); + assert.doesNotMatch(readAudit(root), /session\.created fired/); + }); + + it("archives session state when the session stops (omp session_stop)", async () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: economy pass\n"); + const api = loadExtension(root); + await api.emit("session_stop", {}, { cwd: root, hasUI: false }); + const log = fs.readFileSync(path.join(root, "production", "session-logs", "session-log.md"), "utf8"); + assert.ok(log.includes("Focus: economy pass")); + }); + + it("logs the compaction event before compaction", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit("session_before_compact", { type: "session_before_compact" }, { cwd: root, hasUI: false }); + const log = fs.readFileSync(path.join(root, "production", "session-logs", "compaction-log.txt"), "utf8"); + assert.match(log, /Context compaction occurred at /); + }); + + it("injects the session context once compaction completes", async () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: boss fight\n"); + const api = loadExtension(root); + await api.emit("session_compact", { type: "session_compact" }, { cwd: root, hasUI: false }); + assert.strictEqual(api.sent.length, 1); + assert.strictEqual(api.sent[0].customType, "ocgs-compaction-context"); + assert.strictEqual(api.sent[0].display, false); + assert.ok(api.sent[0].content.includes("SESSION STATE BEFORE COMPACTION")); + assert.ok(api.sent[0].content.includes("Focus: boss fight")); + }); + + it("does not inject compaction context into a subagent session", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit("session_compact", { type: "session_compact" }, { cwd: root, hasUI: false, agent: { kind: "sub" } }); + assert.strictEqual(api.sent.length, 0); + }); +}); + +describe("extension wiring: compaction context on omp and Pi", () => { + it("returns the session context to omp's session.compacting so it enters the summary", async () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: raid balance\n"); + const api = loadExtension(root); + const [result] = await api.emit( + "session.compacting", + { sessionId: "s", messages: [] }, + { cwd: root, hasUI: false }, + ); + assert.strictEqual(result.context.length, 1); + assert.ok(result.context[0].includes("SESSION STATE BEFORE COMPACTION")); + assert.ok(result.context[0].includes("Focus: raid balance")); + }); + + it("does not send the context a second time when omp already injected it", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit("session.compacting", { sessionId: "s", messages: [] }, { cwd: root, hasUI: false }); + await api.emit("session_compact", { type: "session_compact" }, { cwd: root, hasUI: false }); + assert.strictEqual(api.sent.length, 0); + }); + + it("resets the guard so a later Pi-style compaction still sends the context", async () => { + const root = newProject(); + const api = loadExtension(root); + await api.emit("session.compacting", { sessionId: "s", messages: [] }, { cwd: root, hasUI: false }); + await api.emit("session_compact", { type: "session_compact" }, { cwd: root, hasUI: false }); + await api.emit("session_compact", { type: "session_compact" }, { cwd: root, hasUI: false }); + assert.strictEqual(api.sent.length, 1); + assert.strictEqual(api.sent[0].customType, "ocgs-compaction-context"); + }); + + it("does not return compaction context to a subagent's session.compacting", async () => { + const root = newProject(); + const api = loadExtension(root); + const [result] = await api.emit( + "session.compacting", + { sessionId: "s", messages: [] }, + { cwd: root, hasUI: false, agent: { kind: "sub" } }, + ); + assert.strictEqual(result, undefined); + }); +}); + +describe("regression: a subagent stop must not archive the parent's session state", () => { + it("does not write session-log.md when a subagent session stops (omp session_stop)", async () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: parent session\n"); + const api = loadExtension(root); + await api.emit("session_stop", {}, { cwd: root, hasUI: false, agent: { kind: "sub" } }); + assert.strictEqual(fs.existsSync(path.join(root, "production", "session-logs", "session-log.md")), false); + }); + + it("does not write session-log.md when a subagent session settles (Pi agent_settled)", async () => { + const root = newProject(); + writeFile(root, "production/session-state/active.md", "Focus: parent session\n"); + const api = loadExtension(root); + await api.emit("agent_settled", {}, { cwd: root, hasUI: false, agent: { kind: "sub" } }); + assert.strictEqual(fs.existsSync(path.join(root, "production", "session-logs", "session-log.md")), false); + }); +}); From 53975d5318fa3a53be08b008aa198a5c62886fea Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Wed, 4 Nov 2026 11:06:28 +0200 Subject: [PATCH 3/4] feat(omp): load ocgs-hooks through .omp/config.yml Adds the extension to the project config and to the expected extension list in tests/omp/test-config.test.mjs, which is now seven entries. --- .omp/config.yml | 1 + tests/omp/test-config.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.omp/config.yml b/.omp/config.yml index 0403cd6..2d73d3b 100644 --- a/.omp/config.yml +++ b/.omp/config.yml @@ -2,6 +2,7 @@ extensions: - ./.pi/extensions/ocgs-audit - ./.pi/extensions/ocgs-changelog - ./.pi/extensions/ocgs-drift-detector + - ./.pi/extensions/ocgs-hooks - ./.pi/extensions/ocgs-path-guard - ./.pi/extensions/ocgs-question - ./.pi/extensions/ocgs-validate diff --git a/tests/omp/test-config.test.mjs b/tests/omp/test-config.test.mjs index ffb5c9c..48c12ec 100644 --- a/tests/omp/test-config.test.mjs +++ b/tests/omp/test-config.test.mjs @@ -12,6 +12,7 @@ const EXPECTED_EXTENSIONS = [ "./.pi/extensions/ocgs-audit", "./.pi/extensions/ocgs-changelog", "./.pi/extensions/ocgs-drift-detector", + "./.pi/extensions/ocgs-hooks", "./.pi/extensions/ocgs-path-guard", "./.pi/extensions/ocgs-question", "./.pi/extensions/ocgs-validate", @@ -28,7 +29,7 @@ function ompJson(args) { } describe("omp project config wiring", { skip: ompAvailable ? false : "omp is not installed" }, () => { - it("effective extensions are exactly the six Pi extensions (no core, no delegation)", () => { + it("effective extensions are exactly the seven Pi extensions (no core, no delegation)", () => { const effective = ompJson(["config", "get", "extensions"]); assert.deepStrictEqual(effective.value, EXPECTED_EXTENSIONS); }); From 67ab290bfecf8428f66883e038270cf5bd97b1c4 Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Wed, 4 Nov 2026 11:06:29 +0200 Subject: [PATCH 4/4] docs: document ocgs-hooks for Pi and omp Adds the extension to the Pi extension table and reference, the omp wiring table, the root hooks section, and the Pi workflow comparison. The reference lists where Pi and omp differ from the bash originals: no pre-summary compaction hook on Pi, one session-log entry per run, and subagent detection only on omp. --- .pi/README.md | 1 + README.md | 2 ++ docs/omp-compatibility.md | 5 ++++- docs/pi-extensions.md | 34 ++++++++++++++++++++++++++++++++++ docs/pi-workflow.md | 1 + 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.pi/README.md b/.pi/README.md index 38af0e6..b2a59cc 100644 --- a/.pi/README.md +++ b/.pi/README.md @@ -18,6 +18,7 @@ Pi discovers `.agents/` content through the `ocgs-core` extension's `resources_d | `ocgs-drift-detector` | `resources_discover` + `tool_result` | Detects structural drift in `.agents/` files after writes | | `ocgs-changelog` | `registerCommand` | `/changelog` command with TUI for conventional-commit changelogs | | `ocgs-validate` | `resources_discover` + `tool_result` | Validates `.agents/` content for harness-neutral conformance | +| `ocgs-hooks` | `session_start` + `agent_settled` + `session_compact` + `tool_call` + `tool_result` + `ui_prompt_start` | The 12 CCGS hooks: session context, commit and push guards, asset and skill checks, agent audit, compaction context, notifications. See `docs/pi-extensions.md` | ## How Extensions Load diff --git a/README.md b/README.md index 9907f43..d67873a 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,8 @@ All 12 bash hooks from CCGS ported to `ccgs-hooks.ts`: | 11 | `post-compact.sh` | `experimental.compaction.autocontinue` | **5** | | 12 | `notify.sh` | Utility (`showNotification`) | — | +Pi and oh-my-pi run the same 12 hooks from `.pi/extensions/ocgs-hooks/`. See [docs/pi-extensions.md](docs/pi-extensions.md#ocgs-hooks). + > 🧪 Run plugin test suite: `npm run test:plugins` (needs the `tsx` ESM loader — see CONTRIBUTING.md) > 🧪 Run workflow integrity suite: `node tests/workflow/run-all.mjs` > 🧪 Run e2e parity suite: `npm run test:parity` (requires both the `opencode` and `pi` CLIs installed) diff --git a/docs/omp-compatibility.md b/docs/omp-compatibility.md index 12bc234..23e55d7 100644 --- a/docs/omp-compatibility.md +++ b/docs/omp-compatibility.md @@ -29,6 +29,7 @@ extensions: - ./.pi/extensions/ocgs-audit - ./.pi/extensions/ocgs-changelog - ./.pi/extensions/ocgs-drift-detector + - ./.pi/extensions/ocgs-hooks - ./.pi/extensions/ocgs-path-guard - ./.pi/extensions/ocgs-question - ./.pi/extensions/ocgs-validate @@ -41,6 +42,7 @@ skills: | `ocgs-audit` | Appends session and tool events to `production/session-logs/agent-audit.log` | `session_start` and `tool_call` lines (rows 6, startup) | | `ocgs-changelog` | Shows the `ocgs-changelog` widget on `agent_end` when there are unreleased commits and no `CHANGELOG.md` | Row 9 | | `ocgs-drift-detector` | Checks `.agents/` files for structural drift on startup and after writes | Row 8, row 7 | +| `ocgs-hooks` | Runs the 12 CCGS hooks: session context, commit and push guards, asset and skill checks, agent audit, compaction context (omp `session.compacting`), notifications | Load verified headlessly (see Verification). Not in the acceptance matrix | | `ocgs-path-guard` | Injects path-scoped rules into the system prompt when a read, write, or edit matches a rule's `paths:` | Rows 4, 5 | | `ocgs-question` | Registers the `question` tool (TUI picker, inactive by default on omp). Also logs omp `ask` answers to `agent-decisions.jsonl` through a `tool_result` hook | Row 10 (decision append, `rpc-ui`) | | `ocgs-validate` | Validates `.agents/` content on startup and after writes | Rows 7, 8 | @@ -131,8 +133,9 @@ These were found while running the Pi baseline. They are Pi 0.85.1 behavior, not ## Verification -- `npm run test:extensions` covers the extension modules and the ask decision mapping: 25 tests pass (19 existing, 6 for ask decision logging). +- `npm run test:extensions` covers the extension modules, the ask decision mapping, and the `ocgs-hooks` port: 61 tests pass (36 for `ocgs-hooks`, including the subagent stop regression). - `npm run test:omp` covers the omp config wiring: 3 tests pass. +- `ocgs-hooks` loads on omp: `omp --mode rpc` with no prompt exits 0 with empty stderr, the factory's `Plugin loaded` audit line appears, and the `session_start` summary is notified. Model-driven behavior (compaction injection, notification UI) was not run. - `npm run test:framework` passes. - The acceptance runs use `--max-cost 0.10` on every omp model call. The `ocgs-verify` profile is not used; see [Deviations](#deviations-from-the-plan). diff --git a/docs/pi-extensions.md b/docs/pi-extensions.md index 1319a23..8c815dc 100644 --- a/docs/pi-extensions.md +++ b/docs/pi-extensions.md @@ -249,6 +249,40 @@ After every `write` or `edit` to a `.agents/` path, validates the modified file --- +## ocgs-hooks + +**File:** `.pi/extensions/ocgs-hooks/index.ts` (wiring), `core.ts` (logic, no harness imports) + +The 12 hooks from the Claude Code original (listed under "Hooks Mapping" in the root README), wired for Pi and oh-my-pi. Each harness emits only some of the events below. A handler for an event the running harness never emits never fires. + +### Events + +| Original hook | Pi event | omp event | Behavior | +|---|---|---|---| +| `session-start.sh`, `detect-gaps.sh` | `session_start` | `session_start` | Branch, recent commits, active sprint and milestone, open bugs, TODO counts, documentation gaps | +| `session-stop.sh` | `agent_settled` | `session_stop` | Archives `active.md` and the session's commits to `session-log.md` | +| `pre-compact.sh` | `session_before_compact` (audit), `session_compact` (context message) | `session.compacting` (context added to the summary) | Logs each compaction; restores `active.md`, git state, and WIP markers | +| `post-compact.sh` | `session_compact` | `session_compact` | Restore notice | +| `validate-commit.sh` | `tool_call` on `bash` `git commit` | `tool_call` | Blocks invalid asset JSON; warns on missing design sections, hardcoded gameplay values, untagged TODOs | +| `validate-push.sh` | `tool_call` on `bash` `git push` | `tool_call` | Warns on pushes to `main`, `master`, `develop` (not blocked) | +| `validate-assets.sh` | `tool_result` (`write`, `edit`) | `tool_result` | Asset naming and JSON validity; errors are appended to the tool result | +| `validate-skill-change.sh` | `tool_result` (`write`, `edit`) | `tool_result` | Audit line advising `/skill-test` | +| `log-agent.sh`, `log-agent-stop.sh` | `tool_call` / `tool_result` on `Task` | `tool_call` / `tool_result` on `task` | Agent start and completion in `agent-audit.log` | +| `notify.sh` | `ui_prompt_start` | `tool_approval_requested` | OS notification when the agent waits for input | + +### Differences from the bash originals + +- Pi has no hook that runs before the summary is written. On Pi the context is sent as a hidden message after compaction. On omp it goes into the summary through `session.compacting`, and `session_compact` skips the message when omp already injected it. +- `agent_settled` fires after every agent run, so `session-log.md` gets one entry per run. +- Session-start, session-stop, and compaction-context handlers skip subagent sessions when the harness sets `ctx.agent.kind === "sub"`. omp does. Pi 0.85.1's `ExtensionContext` has no `agent` field, so on Pi subagent sessions are not detected. +- Not ported: the upstream hooks outside the 12 listed (`validate-read`, `validate-write`, `validate-registry`, `validate-agent-artifact`, `update-check`, `log-instructions`). + +### Logs + +Writes to `production/session-logs/agent-audit.log`, `session-log.md`, and `compaction-log.txt`. + +--- + ## Glossary | Term | Definition | diff --git a/docs/pi-workflow.md b/docs/pi-workflow.md index 736e59f..975b1fa 100644 --- a/docs/pi-workflow.md +++ b/docs/pi-workflow.md @@ -13,6 +13,7 @@ This guide covers the day-to-day workflow differences between using OCGS with Op | **Decision capture** | OCGS plugin (`question` tool) | `ocgs-question` extension with TUI | | **Path-scoped rules** | OpenCode rule loading | `ocgs-path-guard` extension | | **Audit log** | `ccgs-hooks.ts` plugin | `ocgs-audit` extension (byte-identical) | +| **Session and tool hooks** | `ccgs-hooks.ts` plugin (12 hooks) | `ocgs-hooks` extension (same 12 hooks; Pi and omp events, see `pi-extensions.md`) | | **Drift detection** | `drift-detector.ts` plugin | `ocgs-drift-detector` extension (richer — inline tool_result mutation) | | **Changelog** | `changelog-generator.ts` plugin | `ocgs-changelog` extension (TUI modal) | | **Content validation** | `validate.mjs` CI script | `ocgs-validate` extension + CI script |