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/.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/.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]); + } +} 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/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 | 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); + }); +}); 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); });