diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..3b11ea0 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "gradata", + "version": "0.1.0", + "description": "AI that learns your judgment - auto-captures corrections and injects graduated rules", + "author": { + "name": "Gradata", + "email": "oliver@gradata.ai" + }, + "hooks": "./hooks/hooks.json", + "skills": "./skills" +} diff --git a/README.md b/README.md index 8dc557d..748a184 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,10 @@ node ~/.gradata/plugin/setup/doctor.js - **Claude Code** — installer also creates `~/.claude/plugins/gradata` symlinking the checkout, so `/gradata` slash-commands work out of the box. -- **Codex / OpenCode / Hermes** — pick up the Gradata block from `AGENTS.md` +- **Codex** — installer adds a managed Gradata hook block to + `~/.codex/config.toml` so session lifecycle events fire graduation and + AGENTS.md maintenance hooks. +- **OpenCode / Hermes** — pick up the Gradata block from `AGENTS.md` automatically. The `gradata-quickstart` skill provides the full reference; the doctor command is the universal health check: `node ~/.gradata/plugin/setup/doctor.js`. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..7449778 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,63 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/session-start.js" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/user-prompt.js" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/post-edit.js" + }, + { + "type": "command", + "command": "node hooks/post-tool-extended.js" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/pre-compact.js" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/session-stop.js" + } + ] + } + ] + } +} diff --git a/setup/install.js b/setup/install.js index 4aeed4a..95f4065 100644 --- a/setup/install.js +++ b/setup/install.js @@ -22,6 +22,12 @@ function flagValue(name) { const AUTO = hasFlag('--auto'); const PATCH_AGENTS_MD_EXPLICIT = hasFlag('--patch-agents-md'); +const CODEX_CONFIG_DIR = path.join(HOME, '.codex'); +const CODEX_CONFIG_PATH = path.join(CODEX_CONFIG_DIR, 'config.toml'); +const CLAUDE_CONFIG_DIR = path.join(HOME, '.claude'); +const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_CONFIG_DIR, 'settings.json'); +const CURSOR_CONFIG_PATH = path.join(HOME, '.cursor', 'hooks.json'); +const AGENT = flagValue('--agent'); function tryPython(cmd) { try { @@ -204,6 +210,177 @@ function resolveAgentsMdTarget() { return homeCandidate; } +// --- Codex hooks patching --------------------------------------------------- + +const CODEX_BEGIN_MARKER = '# BEGIN GRADATA CODEX HOOKS'; +const CODEX_END_MARKER = '# END GRADATA CODEX HOOKS'; + +function buildCodexHookBlock(pluginRoot) { + const p = pluginRoot.replace(/\\/g, '/').replace(/"/g, '\\"'); + return [ + CODEX_BEGIN_MARKER, + '# Managed by Gradata installer. Re-run installer to update paths.', + '[features]', + 'hooks = true', + '', + '[hooks]', + '', + '[[hooks.SessionStart]]', + 'matcher = "*"', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/session-start.js\\""`, + '', + '[[hooks.UserPromptSubmit]]', + 'matcher = "*"', + '[[hooks.UserPromptSubmit.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/user-prompt.js\\""`, + '', + '[[hooks.PostToolUse]]', + 'matcher = "*"', + '[[hooks.PostToolUse.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/post-edit.js\\""`, + '[[hooks.PostToolUse.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/post-tool-extended.js\\""`, + '', + '[[hooks.PreCompact]]', + 'matcher = "*"', + '[[hooks.PreCompact.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/pre-compact.js\\""`, + '', + '[[hooks.Stop]]', + 'matcher = "*"', + '[[hooks.Stop.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/session-stop.js\\""`, + CODEX_END_MARKER, + '', + ].join('\n'); +} + +function buildClaudeHookBlock(pluginRoot) { + const p = pluginRoot.replace(/\\/g, '/').replace(/"/g, '\\"'); + const buildCommand = name => `node "${p}/hooks/${name}"`; + return { + hooks: { + SessionStart: [ + { matcher: '*', hooks: [{ type: 'command', command: buildCommand('session-start.js') }] }, + ], + UserPromptSubmit: [ + { matcher: '*', hooks: [{ type: 'command', command: buildCommand('user-prompt.js') }] }, + ], + PostToolUse: [ + { matcher: '*', hooks: [ + { type: 'command', command: buildCommand('post-edit.js') }, + { + type: 'command', + name: 'auto_correct', + command: buildCommand('post-tool-extended.js'), + }, + ] }, + ], + PreCompact: [ + { matcher: '*', hooks: [{ type: 'command', command: buildCommand('pre-compact.js') }] }, + ], + Stop: [ + { + matcher: '*', + hooks: [{ + type: 'command', + name: 'session_close', + command: buildCommand('session-stop.js'), + }], + }, + ], + }, + }; +} + +function mergeClaudeSettings(patch) { + fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true }); + let out = {}; + if (fs.existsSync(CLAUDE_SETTINGS_PATH)) { + const existing = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'); + const parsed = existing.trim() ? JSON.parse(existing) : {}; + out = typeof parsed === 'object' && parsed !== null ? parsed : {}; + } + + const current = out.hooks && typeof out.hooks === 'object' && !Array.isArray(out.hooks) + ? out.hooks + : {}; + const next = { ...current, ...patch.hooks }; + const merged = { ...out, hooks: next }; + const before = JSON.stringify(out); + const after = JSON.stringify(merged); + if (before === after) return { action: 'unchanged', path: CLAUDE_SETTINGS_PATH }; + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf8'); + return { action: current && Object.keys(current).length ? 'updated' : 'created', path: CLAUDE_SETTINGS_PATH }; +} + +function patchClaudeSettings(pluginRoot) { + return mergeClaudeSettings(buildClaudeHookBlock(pluginRoot)); +} + +function buildCursorHookBlock(pluginRoot) { + return buildClaudeHookBlock(pluginRoot); +} + +function patchCursorHooks(pluginRoot) { + fs.mkdirSync(path.dirname(CURSOR_CONFIG_PATH), { recursive: true }); + const hadFile = fs.existsSync(CURSOR_CONFIG_PATH); + const next = buildCursorHookBlock(pluginRoot); + if (fs.existsSync(CURSOR_CONFIG_PATH)) { + const before = fs.readFileSync(CURSOR_CONFIG_PATH, 'utf8'); + if (before.trim() === JSON.stringify(next, null, 2)) { + return { action: 'unchanged', path: CURSOR_CONFIG_PATH }; + } + } + fs.writeFileSync(CURSOR_CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', 'utf8'); + return { action: hadFile ? 'updated' : 'created', path: CURSOR_CONFIG_PATH }; +} + +function patchCodexConfig(pluginRoot) { + const block = buildCodexHookBlock(pluginRoot); + fs.mkdirSync(CODEX_CONFIG_DIR, { recursive: true }); + if (!fs.existsSync(CODEX_CONFIG_PATH)) { + fs.writeFileSync(CODEX_CONFIG_PATH, block, 'utf8'); + return { action: 'created', path: CODEX_CONFIG_PATH }; + } + + const original = fs.readFileSync(CODEX_CONFIG_PATH, 'utf8'); + const begin = original.indexOf(CODEX_BEGIN_MARKER); + const end = original.indexOf(CODEX_END_MARKER); + + if (begin === -1 && end === -1) { + let out = original; + if (!out.endsWith('\n')) out += '\n'; + if (!out.endsWith('\n\n')) out += '\n'; + out += block; + fs.writeFileSync(CODEX_CONFIG_PATH, out, 'utf8'); + return { action: 'appended', path: CODEX_CONFIG_PATH }; + } + + if (begin !== -1 && end !== -1 && begin < end) { + const before = original.slice(0, begin).replace(/\s*$/, ''); + const after = original.slice(end + CODEX_END_MARKER.length).replace(/^\s*/, ''); + const body = block.trimEnd(); + let out = ''; + if (before) out += `${before}\n\n`; + out += body; + if (after) out += `\n\n${after}`; + out += '\n'; + if (out === original) return { action: 'unchanged', path: CODEX_CONFIG_PATH }; + fs.writeFileSync(CODEX_CONFIG_PATH, out, 'utf8'); + return { action: 'replaced', path: CODEX_CONFIG_PATH }; + } + + return { action: 'refused', path: CODEX_CONFIG_PATH }; +} + // --- Main ------------------------------------------------------------------- async function main() { @@ -277,6 +454,36 @@ async function main() { console.log(`AGENTS.md patch skipped: ${e.message}`); } + // Wire Codex hooks so graduation and AGENTS.md maintenance run on Codex too. + try { + const codexPatch = patchCodexConfig(path.join(GRADATA_HOME, 'plugin')); + if (codexPatch.action === 'refused') { + console.log(`Codex hooks patch refused: ${codexPatch.path} has ambiguous Gradata markers`); + } else { + console.log(`Codex hooks ${codexPatch.action}: ${codexPatch.path}`); + } + } catch (e) { + console.log(`Codex hooks patch skipped: ${e.message}`); + } + + if (AGENT === 'claude') { + try { + const claudePatch = patchClaudeSettings(path.join(GRADATA_HOME, 'plugin')); + console.log(`Claude settings ${claudePatch.action}: ${claudePatch.path}`); + } catch (e) { + console.log(`Claude settings patch skipped: ${e.message}`); + } + } + + if (AGENT === 'cursor') { + try { + const cursorPatch = patchCursorHooks(path.join(GRADATA_HOME, 'plugin')); + console.log(`Cursor hooks ${cursorPatch.action}: ${cursorPatch.path}`); + } catch (e) { + console.log(`Cursor hooks patch skipped: ${e.message}`); + } + } + console.log('\nReady.'); if (AUTO) { const doctor = path.join(GRADATA_HOME, 'plugin', 'setup', 'doctor.js'); @@ -296,7 +503,21 @@ async function main() { } } -module.exports = { patchAgentsMd, loadTemplate, scanMarkers, BEGIN_MARKER, END_MARKER }; +module.exports = { + patchAgentsMd, + loadTemplate, + scanMarkers, + BEGIN_MARKER, + END_MARKER, + patchCodexConfig, + patchClaudeSettings, + patchCursorHooks, + buildClaudeHookBlock, + buildCursorHookBlock, + buildCodexHookBlock, + CODEX_BEGIN_MARKER, + CODEX_END_MARKER, +}; if (require.main === module) { main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/tests/install-idempotent.test.js b/tests/install-idempotent.test.js index 38ac2d9..24c8e47 100644 --- a/tests/install-idempotent.test.js +++ b/tests/install-idempotent.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { execSync } = require('node:child_process'); const { patchAgentsMd, loadTemplate, BEGIN_MARKER, END_MARKER } = require('../setup/install.js'); @@ -12,6 +13,98 @@ function tmpFile(name) { return path.join(dir, name); } +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeFakePython(binDir, fakePythonPath) { + const scriptPath = path.join(binDir, 'python3'); + const body = [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then', + ' echo "Python 3.11.8"', + ' exit 0', + 'fi', + '', + 'if [ "$1" = "-c" ]; then', + ' echo "$2" | grep -q "gradata.__version__" || true', + ' if echo "$2" | grep -q "sys.version_info"; then', + ' echo "3.11"', + ' elif echo "$2" | grep -q "sys.executable"; then', + ` echo "${fakePythonPath}"`, + ' else', + ' echo "0.3.0"', + ' fi', + ' exit 0', + 'fi', + '', + 'exit 0', + ].join('\n'); + fs.writeFileSync(scriptPath, body, { mode: 0o755 }); + fs.chmodSync(scriptPath, 0o755); + fs.writeFileSync(path.join(binDir, 'python'), '#!/bin/sh\nexec python3 "$@"\n', { mode: 0o755 }); + fs.chmodSync(path.join(binDir, 'python'), 0o755); + return scriptPath; +} + +function runInstall({ home, agent }) { + const binDir = tmpDir('gradata-fake-py-'); + const fakePythonPath = path.join(binDir, 'python3'); + writeFakePython(binDir, fakePythonPath); + + const env = { + ...process.env, + HOME: home, + PATH: `${binDir}${path.delimiter}${process.env.PATH}`, + GRADATA_HOME: path.join(home, '.gradata'), + }; + const scriptPath = path.resolve(__dirname, '..', 'setup', 'install.js'); + execSync(`node "${scriptPath}" --agent ${agent} --auto`, { + env, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); +} + +function extractCommandsFromSettings(settings) { + const result = []; + const hooks = settings?.hooks; + if (!hooks || typeof hooks !== 'object' || hooks === null) return result; + for (const section of Object.values(hooks)) { + if (!Array.isArray(section)) continue; + for (const item of section) { + if (!item || !Array.isArray(item.hooks)) continue; + for (const hook of item.hooks) { + if (hook && typeof hook.command === 'string') result.push(hook.command); + } + } + } + return result; +} + +function extractNamedHooks(settings) { + const result = []; + const hooks = settings?.hooks; + if (!hooks || typeof hooks !== 'object' || hooks === null) return result; + for (const section of Object.values(hooks)) { + if (!Array.isArray(section)) continue; + for (const item of section) { + if (!item || !Array.isArray(item.hooks)) continue; + for (const hook of item.hooks) { + if (hook && typeof hook === 'object') { + result.push(hook); + } + } + } + } + return result; +} + +function commandPath(command) { + const m = command.match(/^node\s+\"([^"]+)\"$/); + return m ? m[1] : command; +} + test('absent → file created with markers', () => { const p = tmpFile('AGENTS.md'); const r = patchAgentsMd(p); @@ -172,3 +265,131 @@ test('doctor: resolveDaemonPort default is 7342 when nothing configured', () => delete require.cache[require.resolve('../setup/doctor.js')]; } }); + +test('codex config: absent -> created with managed markers', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-codex-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCodexConfig, CODEX_BEGIN_MARKER, CODEX_END_MARKER } = require('../setup/install.js'); + try { + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const r = patchCodexConfig(pluginRoot); + assert.strictEqual(r.action, 'created'); + const cfg = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'); + assert.ok(cfg.includes(CODEX_BEGIN_MARKER)); + assert.ok(cfg.includes(CODEX_END_MARKER)); + assert.ok(cfg.includes('hooks = true')); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +}); + +test('codex config: existing content preserved and gradata block appended idempotently', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-codex-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCodexConfig } = require('../setup/install.js'); + try { + const codexDir = path.join(home, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const cfgPath = path.join(codexDir, 'config.toml'); + const original = 'personality = "pragmatic"\n'; + fs.writeFileSync(cfgPath, original, 'utf8'); + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const a = patchCodexConfig(pluginRoot); + const first = fs.readFileSync(cfgPath, 'utf8'); + const b = patchCodexConfig(pluginRoot); + const second = fs.readFileSync(cfgPath, 'utf8'); + assert.strictEqual(a.action, 'appended'); + assert.ok(first.startsWith(original)); + assert.strictEqual(b.action, 'unchanged'); + assert.strictEqual(first, second); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +}); + +test('claude plugin manifest declares hooks so PostToolUse can run', () => { + const manifestPath = path.resolve(__dirname, '..', '.claude-plugin', 'plugin.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.strictEqual(manifest.hooks, './hooks/hooks.json'); + assert.strictEqual(manifest.skills, './skills'); +}); + +test('gradata install --agent claude writes absolute hook commands and required lifecycle hooks', () => { + const home = tmpDir('gradata-home-'); + const gradataHome = path.join(home, '.gradata'); + const command = `node "${path.resolve(__dirname, '..', 'setup', 'install.js')}" --agent claude --auto`; + const binDir = tmpDir('gradata-fake-py-'); + const fakePythonPath = path.join(binDir, 'python3'); + writeFakePython(binDir, fakePythonPath); + + const env = { + ...process.env, + HOME: home, + PATH: `${binDir}${path.delimiter}${process.env.PATH}`, + GRADATA_HOME: gradataHome, + }; + + execSync(command, { env, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); + + const settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')); + const commands = extractCommandsFromSettings(settings).map(commandPath); + assert.ok(commands.length > 0, 'settings has hooks'); + assert.ok(commands.every(cmd => cmd.startsWith(home)), 'commands resolve under isolated HOME'); + assert.ok(commands.every(cmd => path.isAbsolute(cmd)), 'hook commands are absolute'); + assert.ok(commands.some((cmd) => cmd.includes(path.join(gradataHome, 'plugin', 'hooks'))), 'hook commands resolve to gradata home'); + + const hooks = extractNamedHooks(settings); + const named = new Map(hooks.map((hook) => [hook.name, hook.command])); + const postTool = hooks.filter((hook) => path.basename(commandPath(hook.command || '')) === 'post-tool-extended.js').map((hook) => hook.command); + const stop = hooks.filter((hook) => path.basename(commandPath(hook.command || '')) === 'session-stop.js').map((hook) => hook.command); + const postToolBasenames = postTool.map(commandPath).map((p) => path.basename(p)); + const stopBasenames = stop.map(commandPath).map((p) => path.basename(p)); + assert.ok( + named.get('auto_correct') || postToolBasenames.includes('post-tool-extended.js'), + 'auto_correct hook is present' + ); + assert.ok( + named.get('session_close') || stopBasenames.includes('session-stop.js'), + 'session_close hook is present' + ); +}); + +test('gradata install --agent cursor writes absolute hook commands and required lifecycle hooks', () => { + const home = tmpDir('gradata-home-'); + const gradataHome = path.join(home, '.gradata'); + const command = `node "${path.resolve(__dirname, '..', 'setup', 'install.js')}" --agent cursor --auto`; + const binDir = tmpDir('gradata-fake-py-'); + const fakePythonPath = path.join(binDir, 'python3'); + writeFakePython(binDir, fakePythonPath); + + const env = { + ...process.env, + HOME: home, + PATH: `${binDir}${path.delimiter}${process.env.PATH}`, + GRADATA_HOME: gradataHome, + }; + + execSync(command, { env, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); + + const payload = JSON.parse(fs.readFileSync(path.join(home, '.cursor', 'hooks.json'), 'utf8')); + const commands = extractCommandsFromSettings(payload).map(commandPath); + assert.ok(commands.length > 0, 'cursor hooks have entries'); + assert.ok(commands.every(cmd => cmd.startsWith(home)), 'commands resolve under isolated HOME'); + assert.ok(commands.every(cmd => path.isAbsolute(cmd)), 'cursor hook commands are absolute'); + assert.ok(commands.some((cmd) => cmd.includes(path.join(gradataHome, 'plugin', 'hooks'))), 'cursor hook commands resolve to gradata home'); + + const hooks = extractNamedHooks(payload); + const named = new Map(hooks.map((hook) => [hook.name, hook.command])); + const postTool = hooks.filter((hook) => path.basename(commandPath(hook.command || '')) === 'post-tool-extended.js').map((hook) => hook.command); + const stop = hooks.filter((hook) => path.basename(commandPath(hook.command || '')) === 'session-stop.js').map((hook) => hook.command); + assert.ok(postTool.some((cmd) => path.basename(commandPath(cmd)) === 'post-tool-extended.js'), 'auto_correct hook is present'); + assert.ok(stop.some((cmd) => path.basename(commandPath(cmd)) === 'session-stop.js'), 'session_close hook is present'); + assert.ok(named.get('auto_correct') || postTool.length > 0, 'auto_correct hook name present'); + assert.ok(named.get('session_close') || stop.length > 0, 'session_close hook name present'); +});