diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 1b29d37..573098a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "@useorgx/cursor-plugin", "description": "OrgX execution control plane for Cursor with bootstrap, resumable workstreams, proof, decisions, Work Graph hooks, and specialist agents.", - "version": "0.1.3", + "version": "0.1.4", "author": { "name": "OrgX Team", "email": "team@useorgx.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..150b1ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci --no-audit --no-fund + - run: npm run check + - run: npm run pack diff --git a/README.md b/README.md index eeee2e5..10475c5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This repo contains the plugin artifact needed for Cursor Marketplace submission - `.mcp.json` pointing at the hosted OrgX MCP server - Cursor rules for the OrgX execution loop - Commands for starting and resuming workstreams, checking proof, and reviewing decisions -- Quiet hooks for session, tool, and subagent lifecycle events +- Quiet hooks for prompts, session, tool, subagent, agent-run, and terminal lifecycle events - Passive Work Graph hook outbox for audit-first reconciliation - Specialist agents for engineering, product, design, operations, marketing, sales, and orchestration @@ -52,6 +52,19 @@ script writes compact, redacted JSONL events to `~/.config/useorgx/wizard/hooks/events.jsonl` by default, or to `ORGX_WIZARD_HOOK_OUTBOX` when set. +When the Wizard session-summary hook is installed, the same adapter also sends +an allowlisted lifecycle shape to that local hook. `stop` becomes a terminal +`RunEnd` capture, while local `sessionEnd` remains a whole-conversation terminal +capture. The Wizard owns the durable queue, acknowledgement, retry, and AWR +delivery path. The adapter never forwards prompts, tool inputs or outputs, +transcript paths, user email, or error text. + +Cursor cloud agents support the prompt, tool, subagent, and `stop` subset but +do not run the local `sessionStart` or `sessionEnd` hooks. Cloud proof is +therefore capability-bounded: `RunEnd` can issue a run receipt when the shared +Wizard hook and authenticated delivery worker are available, while a missing +local Wizard is reported as capture unavailable rather than silently claimed. + These hook records are a passive backstop for later Work Graph reconciliation. They should answer whether meaningful work happened without durable OrgX writeback. They do not store raw prompts, raw transcripts, API keys, tokens, or diff --git a/hooks/hooks.json b/hooks/hooks.json index 37ece7f..b072241 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,4 +1,5 @@ { + "version": 1, "hooks": { "sessionStart": [ { @@ -18,6 +19,16 @@ "command": "node ./scripts/hooks/post-tool-use-failure.mjs" } ], + "preToolUse": [ + { + "command": "node ./scripts/hooks/pre-tool-use.mjs" + } + ], + "beforeSubmitPrompt": [ + { + "command": "node ./scripts/hooks/before-submit-prompt.mjs" + } + ], "subagentStart": [ { "command": "node ./scripts/hooks/subagent-start.mjs" @@ -27,6 +38,17 @@ { "command": "node ./scripts/hooks/subagent-stop.mjs" } + ], + "stop": [ + { + "command": "node ./scripts/hooks/stop.mjs", + "loop_limit": 1 + } + ], + "sessionEnd": [ + { + "command": "node ./scripts/hooks/session-end.mjs" + } ] } } diff --git a/package-lock.json b/package-lock.json index 1fe7c2e..61fb22a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@useorgx/cursor-plugin", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/cursor-plugin", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@sentry/node": "10.65.0" diff --git a/package.json b/package.json index e2170d8..2c5a3c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/cursor-plugin", - "version": "0.1.3", + "version": "0.1.4", "description": "OrgX plugin for Cursor with MCP, rules, skills, passive Work Graph hooks, commands, and specialist agents.", "type": "module", "private": false, diff --git a/scripts/hooks/before-submit-prompt.mjs b/scripts/hooks/before-submit-prompt.mjs new file mode 100644 index 0000000..496b2dc --- /dev/null +++ b/scripts/hooks/before-submit-prompt.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { exitCodeForResult, main, readStdin } from './record-work-graph-event.mjs'; + +readStdin() + .then((stdinText) => + main({ argv: ['--event=user_prompt', '--source_client=cursor'], stdinText }) + ) + .then((result) => process.exit(exitCodeForResult(result))) + .catch(() => process.exit(1)); diff --git a/scripts/hooks/pre-tool-use.mjs b/scripts/hooks/pre-tool-use.mjs new file mode 100644 index 0000000..7a64dac --- /dev/null +++ b/scripts/hooks/pre-tool-use.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { exitCodeForResult, main, readStdin } from './record-work-graph-event.mjs'; + +readStdin() + .then((stdinText) => + main({ argv: ['--event=pre_tool_use', '--source_client=cursor'], stdinText }) + ) + .then((result) => process.exit(exitCodeForResult(result))) + .catch(() => process.exit(1)); diff --git a/scripts/hooks/record-work-graph-event.mjs b/scripts/hooks/record-work-graph-event.mjs index 838b18f..b4e7042 100644 --- a/scripts/hooks/record-work-graph-event.mjs +++ b/scripts/hooks/record-work-graph-event.mjs @@ -7,6 +7,7 @@ import process from 'node:process'; import { pathToFileURL } from 'node:url'; import { captureCursorHookException } from './sentry.mjs'; +import { bridgeCursorSessionSummary } from './session-summary-bridge.mjs'; const SENSITIVE_PAYLOAD_KEYS = new Set([ 'api_key', @@ -168,6 +169,12 @@ export async function main({ }); const workGraphSpooled = appendWorkGraphHookRecord(record, outbox); + const continuityCapture = await bridgeCursorSessionSummary({ + event, + payload, + env, + cwd, + }); if (!workGraphSpooled) { await captureCursorHookException( @@ -180,6 +187,7 @@ export async function main({ return { ok: workGraphSpooled, work_graph_spooled: workGraphSpooled, + continuity_capture: continuityCapture, }; } diff --git a/scripts/hooks/session-end.mjs b/scripts/hooks/session-end.mjs new file mode 100644 index 0000000..441bdeb --- /dev/null +++ b/scripts/hooks/session-end.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { exitCodeForResult, main, readStdin } from './record-work-graph-event.mjs'; + +readStdin() + .then((stdinText) => + main({ argv: ['--event=session_end', '--source_client=cursor'], stdinText }) + ) + .then((result) => process.exit(exitCodeForResult(result))) + .catch(() => process.exit(1)); diff --git a/scripts/hooks/session-summary-bridge.mjs b/scripts/hooks/session-summary-bridge.mjs new file mode 100644 index 0000000..2047fa3 --- /dev/null +++ b/scripts/hooks/session-summary-bridge.mjs @@ -0,0 +1,137 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const EVENT_MAP = new Map([ + ['session_start', 'SessionStart'], + ['user_prompt', 'UserPromptSubmit'], + ['pre_tool_use', 'PreToolUse'], + ['post_tool_use', 'PostToolUse'], + ['post_tool_use_failure', 'PostToolUseFailure'], + ['subagent_start', 'SubagentStart'], + ['subagent_stop', 'SubagentStop'], + ['run_end', 'RunEnd'], + ['session_end', 'SessionEnd'], +]); + +function string(...values) { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function finiteDuration(...values) { + const value = values.find( + (candidate) => typeof candidate === 'number' && Number.isFinite(candidate) + ); + return value === undefined ? undefined : Math.max(0, Math.round(value)); +} + +export function canonicalCursorEvent(event) { + return EVENT_MAP.get(String(event || '').trim().toLowerCase()) ?? null; +} + +/** + * Keep only metadata admitted by the Wizard summary hook. Cursor also provides + * prompts, tool inputs/results, transcript paths, user email, and error text; + * none of those cross this adapter boundary. + */ +export function sanitizeCursorPayload(payload = {}, cwd = process.cwd()) { + return { + session_id: string( + payload.session_id, + payload.sessionId, + payload.conversation_id, + payload.conversationId, + payload.thread_id, + payload.threadId + ), + turn_id: string( + payload.turn_id, + payload.turnId, + payload.generation_id, + payload.generationId + ), + cwd: string(payload.cwd, payload.working_directory, payload.workspace, cwd), + tool_name: string(payload.tool_name, payload.toolName, payload.tool?.name), + tool_use_id: string(payload.tool_use_id, payload.toolUseId), + duration_ms: finiteDuration(payload.duration_ms, payload.duration), + permission_mode: string(payload.permission_mode, payload.permissionMode), + }; +} + +function defaultHookPath(env) { + return ( + string(env.ORGX_SESSION_SUMMARY_HOOK_PATH) ?? + join( + string(env.XDG_CONFIG_HOME) ?? join(homedir(), '.config'), + 'useorgx', + 'wizard', + 'hooks', + 'orgx-session-summary.mjs' + ) + ); +} + +function triggerFlush({ env, spawnImpl, queueDir }) { + try { + const args = ['hooks', 'flush', '--background', '--limit=25']; + if (queueDir) args.push(`--queue=${queueDir}`); + const child = spawnImpl('orgx-wizard', args, { + detached: true, + stdio: 'ignore', + env, + }); + child?.on?.('error', () => undefined); + child?.unref?.(); + return true; + } catch { + return false; + } +} + +export async function bridgeCursorSessionSummary({ + event, + payload = {}, + env = process.env, + cwd = process.cwd(), + hookPath = defaultHookPath(env), + moduleLoader = (url) => import(url), + spawnImpl = spawn, +} = {}) { + const canonicalEvent = canonicalCursorEvent(event); + if (!canonicalEvent) return { ok: true, skipped: 'unsupported_event' }; + if (!existsSync(hookPath)) return { ok: true, skipped: 'wizard_hook_unavailable' }; + + const hook = await moduleLoader(pathToFileURL(hookPath).href); + if (typeof hook?.main !== 'function') { + return { ok: true, skipped: 'wizard_hook_incompatible' }; + } + + const queueDir = string(env.ORGX_SESSION_SUMMARY_QUEUE_DIR); + const result = await hook.main({ + argv: [ + `--event=${canonicalEvent}`, + '--source_client=cursor', + ...(queueDir ? [`--queue_dir=${queueDir}`] : []), + ], + env, + stdinText: JSON.stringify(sanitizeCursorPayload(payload, cwd)), + }); + const fallbackDeliveryTriggered = + result?.queued === true && result?.delivery_triggered !== true + ? triggerFlush({ env, spawnImpl, queueDir }) + : false; + + return { + ...result, + adapter: 'cursor', + canonical_event: canonicalEvent, + fallback_delivery_triggered: fallbackDeliveryTriggered, + }; +} diff --git a/scripts/hooks/session-summary-bridge.test.mjs b/scripts/hooks/session-summary-bridge.test.mjs new file mode 100644 index 0000000..cb2b1e4 --- /dev/null +++ b/scripts/hooks/session-summary-bridge.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + bridgeCursorSessionSummary, + canonicalCursorEvent, + sanitizeCursorPayload, +} from './session-summary-bridge.mjs'; + +test('maps Cursor completion to the shared terminal run boundary', () => { + assert.equal(canonicalCursorEvent('run_end'), 'RunEnd'); + assert.equal(canonicalCursorEvent('session_end'), 'SessionEnd'); + assert.equal(canonicalCursorEvent('post_tool_use'), 'PostToolUse'); + assert.equal(canonicalCursorEvent('unknown'), null); +}); + +test('allowlists lifecycle metadata and drops content-bearing Cursor fields', () => { + const payload = sanitizeCursorPayload({ + conversation_id: 'conversation-1', + generation_id: 'generation-2', + cwd: '/work/repo', + tool_name: 'Shell', + tool_use_id: 'tool-3', + duration: 42.4, + user_email: 'private@example.test', + transcript_path: '/private/transcript.jsonl', + prompt: 'private prompt', + tool_input: { command: 'private command' }, + tool_output: 'private output', + error_message: 'private error', + }); + + assert.deepEqual(payload, { + session_id: 'conversation-1', + turn_id: 'generation-2', + cwd: '/work/repo', + tool_name: 'Shell', + tool_use_id: 'tool-3', + duration_ms: 42, + permission_mode: undefined, + }); + const serialized = JSON.stringify(payload); + for (const secret of ['private@example.test', 'transcript', 'private prompt', 'private command', 'private output', 'private error']) { + assert.equal(serialized.includes(secret), false); + } +}); + +test('delegates to the installed Wizard hook and starts fallback delivery', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orgx-cursor-bridge-')); + const hookPath = join(dir, 'orgx-session-summary.mjs'); + writeFileSync(hookPath, 'export async function main() {}\n', 'utf8'); + const calls = []; + const spawns = []; + let unrefed = false; + try { + const result = await bridgeCursorSessionSummary({ + event: 'run_end', + payload: { conversation_id: 'conversation-1' }, + hookPath, + env: { PATH: process.env.PATH }, + moduleLoader: async () => ({ + main: async (input) => { + calls.push(input); + return { ok: true, queued: true, delivery_triggered: false }; + }, + }), + spawnImpl: (command, args, options) => { + spawns.push({ command, args, options }); + return { + on: () => undefined, + unref: () => { + unrefed = true; + }, + }; + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].argv, [ + '--event=RunEnd', + '--source_client=cursor', + ]); + assert.deepEqual(JSON.parse(calls[0].stdinText), { + session_id: 'conversation-1', + cwd: process.cwd(), + }); + assert.equal(result.fallback_delivery_triggered, true); + assert.equal(spawns[0].command, 'orgx-wizard'); + assert.deepEqual(spawns[0].args, [ + 'hooks', + 'flush', + '--background', + '--limit=25', + ]); + assert.equal(spawns[0].options.detached, true); + assert.equal(unrefed, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('reports a missing shared hook without inventing capture', async () => { + const result = await bridgeCursorSessionSummary({ + event: 'run_end', + hookPath: '/definitely/absent/orgx-session-summary.mjs', + }); + assert.deepEqual(result, { ok: true, skipped: 'wizard_hook_unavailable' }); +}); diff --git a/scripts/hooks/stop.mjs b/scripts/hooks/stop.mjs new file mode 100644 index 0000000..60f8127 --- /dev/null +++ b/scripts/hooks/stop.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { exitCodeForResult, main, readStdin } from './record-work-graph-event.mjs'; + +readStdin() + .then((stdinText) => + main({ argv: ['--event=run_end', '--source_client=cursor'], stdinText }) + ) + .then((result) => process.exit(exitCodeForResult(result))) + .catch(() => process.exit(1)); diff --git a/scripts/verify-plugin.mjs b/scripts/verify-plugin.mjs index 3eede39..221c06a 100644 --- a/scripts/verify-plugin.mjs +++ b/scripts/verify-plugin.mjs @@ -10,6 +10,7 @@ const requiredFiles = [ 'rules/orgx-execution-loop.mdc', 'hooks/hooks.json', 'scripts/hooks/record-work-graph-event.mjs', + 'scripts/hooks/session-summary-bridge.mjs', 'scripts/hooks/orgx-work-graph-reconcile.mjs', 'commands/orgx-start-workstream.md', 'skills/orgx-execution-control-plane/SKILL.md', @@ -56,16 +57,24 @@ if (JSON.stringify(decodedDeeplinkConfig) !== JSON.stringify(mcp.mcpServers.orgx throw new Error('Cursor MCP deeplink config must match .mcp.json mcpServers.orgx'); } +if (hooks.version !== 1) { + throw new Error('hooks/hooks.json must declare Cursor hook schema version 1'); +} + if (!hooks.hooks || !hooks.hooks.sessionStart) { throw new Error('hooks/hooks.json must include sessionStart hooks'); } for (const [eventName, scriptName] of [ ['sessionStart', 'session-start.mjs'], + ['sessionEnd', 'session-end.mjs'], + ['beforeSubmitPrompt', 'before-submit-prompt.mjs'], + ['preToolUse', 'pre-tool-use.mjs'], ['postToolUse', 'post-tool-use.mjs'], ['postToolUseFailure', 'post-tool-use-failure.mjs'], ['subagentStart', 'subagent-start.mjs'], - ['subagentStop', 'subagent-stop.mjs'] + ['subagentStop', 'subagent-stop.mjs'], + ['stop', 'stop.mjs'] ]) { if (!Array.isArray(hooks.hooks[eventName]) || hooks.hooks[eventName].length === 0) { throw new Error(`hooks/hooks.json must include ${eventName} hooks`); @@ -82,6 +91,7 @@ for (const [eventName, scriptName] of [ } const hookScript = readFileSync(resolve('scripts/hooks/record-work-graph-event.mjs'), 'utf8'); +const summaryBridgeScript = readFileSync(resolve('scripts/hooks/session-summary-bridge.mjs'), 'utf8'); const installScript = readFileSync(resolve('scripts/install-local.mjs'), 'utf8'); if (!hookScript.includes('orgx_cursor_plugin_runtime_hook')) { throw new Error('record-work-graph-event.mjs must emit orgx_cursor_plugin_runtime_hook records'); @@ -95,6 +105,17 @@ if (hookScript.includes('transcript_path:')) { if (!hookScript.includes('exitCodeForResult')) { throw new Error('record-work-graph-event.mjs must expose hook failure exit handling'); } +if (!summaryBridgeScript.includes('orgx-session-summary.mjs')) { + throw new Error('session-summary bridge must delegate to the Wizard capture hook'); +} +if (!summaryBridgeScript.includes("['run_end', 'RunEnd']")) { + throw new Error('session-summary bridge must preserve the terminal run boundary'); +} +for (const forbiddenField of ['tool_input:', 'tool_output:', 'transcript_path:', 'user_email:', 'error_message:']) { + if (summaryBridgeScript.includes(forbiddenField)) { + throw new Error(`session-summary bridge must not persist ${forbiddenField}`); + } +} if (!installScript.includes("fileURLToPath(import.meta.url)")) { throw new Error('install-local.mjs must resolve plugin root with fileURLToPath'); }