Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
Expand All @@ -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"
Expand All @@ -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"
}
]
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 10 additions & 0 deletions scripts/hooks/before-submit-prompt.mjs
Original file line number Diff line number Diff line change
@@ -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));
10 changes: 10 additions & 0 deletions scripts/hooks/pre-tool-use.mjs
Original file line number Diff line number Diff line change
@@ -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));
8 changes: 8 additions & 0 deletions scripts/hooks/record-work-graph-event.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand All @@ -180,6 +187,7 @@ export async function main({
return {
ok: workGraphSpooled,
work_graph_spooled: workGraphSpooled,
continuity_capture: continuityCapture,
};
}

Expand Down
10 changes: 10 additions & 0 deletions scripts/hooks/session-end.mjs
Original file line number Diff line number Diff line change
@@ -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));
137 changes: 137 additions & 0 deletions scripts/hooks/session-summary-bridge.mjs
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading