From 9932ff2069e8817ecd5544463ca4a4025d3ce189 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Thu, 7 May 2026 09:19:10 -0500 Subject: [PATCH] Add Work Graph hook outbox for Claude --- .claude-plugin/plugin.json | 4 +- README.md | 9 +- hooks/scripts/post-reporting-event.mjs | 132 ++++++++++++++++++++++++- package-lock.json | 15 ++- package.json | 4 +- plugin.manifest.json | 2 +- scripts/verify-plugin.mjs | 11 +++ skills/orgx-runtime-reporting/SKILL.md | 30 ++++++ tests/post-reporting-event.test.mjs | 82 ++++++++++++++- 9 files changed, 276 insertions(+), 13 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b3cb13..764d206 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "orgx-claude-code-plugin", - "version": "0.1.1", - "description": "OrgX MCP tools and runtime telemetry hooks for Claude Code.", + "version": "0.1.2", + "description": "OrgX MCP tools, runtime telemetry hooks, and Work Graph reconciliation for Claude Code.", "author": { "name": "OrgX Team", "url": "https://useorgx.com" diff --git a/README.md b/README.md index 748f73c..b533c8e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Claude Code plugin package for OrgX: - OrgX MCP server wiring (`mcp.useorgx.com`) -- Runtime hooks that post activity/progress back to OrgX +- Runtime hooks that post activity/progress back to OrgX and spool compact Work + Graph events for reconciliation - Browser pairing login (`/orgx-login`) with macOS keychain storage - Session env hydration from keychain (`hooks/scripts/load-orgx-env.mjs`) - Skill-pack sync from OrgX to local `SKILL.md` files (`/orgx-sync-skills`) @@ -45,6 +46,8 @@ skills/**/SKILL.md # Reusable guidance - `ORGX_SKILLS_DIR` (optional skills root override; default `.claude/orgx-skills`) - `ORGX_SKILL_PACK_NAME` (optional; default `orgx-agent-suite`) - `ORGX_RUNTIME_HOOK_URL` and `ORGX_HOOK_TOKEN` (optional local runtime relay) +- `ORGX_WIZARD_HOOK_OUTBOX` (optional local JSONL outbox; default + `~/.config/useorgx/wizard/hooks/events.jsonl`) ## Login + Autopilot @@ -100,8 +103,12 @@ claude --plugin-dir . --permission-mode bypassPermissions -p "Use the orgx_statu - activity events -> `/api/client/live/activity` - optional completion changeset -> `/api/client/live/changesets/apply` - optional local runtime relay -> `ORGX_RUNTIME_HOOK_URL` +- compact, redacted Work Graph hook events -> local wizard outbox The script is best-effort and exits cleanly on failures to avoid interrupting Claude sessions. +It never writes raw transcripts or full hook payloads; the reconciler should keep +raw client history local and promote only redacted summaries, evidence refs, +Work Graph fingerprints, and approved OrgX activity. ## Next Steps diff --git a/hooks/scripts/post-reporting-event.mjs b/hooks/scripts/post-reporting-event.mjs index ce29c19..5f46860 100644 --- a/hooks/scripts/post-reporting-event.mjs +++ b/hooks/scripts/post-reporting-event.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { appendFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; @@ -36,6 +39,37 @@ export function normalizeSourceClient(value, fallback = "claude-code") { return normalized; } +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export function parseJsonRecord(value) { + try { + const parsed = JSON.parse(value || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return {}; + } +} + +export function sanitizeArgs(args) { + const redacted = {}; + for (const [key, value] of Object.entries(args ?? {})) { + if (/token|api[_-]?key|authorization|cookie|secret/i.test(key)) { + redacted[key] = "[redacted]"; + } else { + redacted[key] = value; + } + } + return redacted; +} + async function postJson(url, payload, headers, fetchImpl = fetch) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); @@ -89,7 +123,7 @@ export function buildRuntimePayload({ message, metadata: { source: "hook_runtime_relay", - raw_args: args, + raw_args: sanitizeArgs(args), }, timestamp: new Date().toISOString(), }; @@ -116,7 +150,7 @@ export function buildActivityPayload({ metadata: { source: "hook_backstop", hook_event: event, - raw_args: args, + raw_args: sanitizeArgs(args), }, }; } @@ -145,13 +179,82 @@ export function buildCompletionChangesetPayload({ }; } +export function buildWorkGraphHookRecord({ + args, + payload, + sourceClient, + event, + cwd = process.cwd(), + timestamp = new Date().toISOString(), +}) { + const toolName = pickString( + payload.tool_name, + payload.toolName, + payload.tool?.name, + payload.name + ); + const prompt = pickString(payload.prompt, payload.user_prompt, payload.userPrompt); + const sessionId = pickString( + payload.session_id, + payload.sessionId, + payload.conversation_id, + payload.conversationId, + args.session_id, + args.sessionId + ); + + return { + schema_version: "2026-05-07", + source: "orgx_claude_code_plugin_runtime_hook", + source_client: sourceClient, + event, + session_id: sessionId, + turn_id: pickString(payload.turn_id, payload.turnId, args.turn_id, args.turnId), + cwd: pickString( + payload.cwd, + payload.working_directory, + payload.workspace, + args.cwd, + cwd + ), + transcript_path: pickString(payload.transcript_path, payload.transcriptPath), + timestamp, + summary: { + tool_name: toolName, + prompt_chars: prompt ? prompt.length : undefined, + payload_keys: Object.keys(payload).slice(0, 40), + initiative_id: pickString(args.initiative, args.initiative_id), + workstream_id: pickString(args.workstream_id), + task_id: pickString(args.task_id), + run_id: pickString(args.run_id), + correlation_id: pickString(args.correlation_id), + }, + }; +} + +export function appendWorkGraphHookRecord(record, outbox) { + try { + mkdirSync(dirname(outbox), { recursive: true, mode: 0o700 }); + appendFileSync(outbox, `${JSON.stringify(record)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return true; + } catch { + return false; + } +} + export async function main({ argv = process.argv.slice(2), env = process.env, fetchImpl = fetch, now = () => Date.now(), + stdinText = "", + cwd = process.cwd(), } = {}) { const args = parseArgs(argv); + const stdinPayload = parseJsonRecord(stdinText); const runtimeHookUrl = pickString( args.runtime_hook_url, @@ -186,6 +289,22 @@ export async function main({ const progressPctRaw = pickString(args.progress_pct, env.ORGX_PROGRESS_PCT); const progressPct = progressPctRaw ? Number(progressPctRaw) : undefined; const message = pickString(args.message, `Hook event: ${event}`); + const outbox = pickString( + args.outbox, + env.ORGX_WIZARD_HOOK_OUTBOX, + join(homedir(), ".config", "useorgx", "wizard", "hooks", "events.jsonl") + ); + const workGraphSpooled = appendWorkGraphHookRecord( + buildWorkGraphHookRecord({ + args, + payload: stdinPayload, + sourceClient, + event, + cwd, + timestamp: new Date(now()).toISOString(), + }), + outbox + ); let runtimePosted = false; let runtimePostFailed = false; @@ -222,6 +341,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "missing_api_key", ...(runtimePostFailed ? { runtime_skipped: "runtime_post_failed" } : {}), }; @@ -230,6 +350,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "missing_initiative_id", ...(runtimePostFailed ? { runtime_skipped: "runtime_post_failed" } : {}), }; @@ -256,6 +377,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "activity_post_failed", }; } @@ -265,6 +387,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: false, }; @@ -285,6 +408,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: false, skipped: "changeset_post_failed", @@ -294,13 +418,15 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: true, }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main() + readStdin() + .then((stdinText) => main({ stdinText })) .then(() => { process.exit(0); }) diff --git a/package-lock.json b/package-lock.json index 7cee337..62f033d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,18 @@ { "name": "@useorgx/claude-code-plugin", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/claude-code-plugin", - "version": "0.1.1", + "version": "0.1.2", + "dependencies": { + "@useorgx/orgx-gateway-sdk": "github:useorgx/orgx-gateway-sdk" + }, + "bin": { + "orgx-claude-code-peer": "lib/peer/cli.mjs" + }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" @@ -25,6 +31,11 @@ "undici-types": "~7.16.0" } }, + "node_modules/@useorgx/orgx-gateway-sdk": { + "version": "0.1.0-alpha.0", + "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#f521c4d775ce541bf1341cea14b70a78b7c98e5c", + "license": "UNLICENSED" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 54ec888..4030a51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@useorgx/claude-code-plugin", - "version": "0.1.1", - "description": "OrgX Claude Code plugin with MCP + runtime telemetry hooks + Sovereign Execution peer sidecar", + "version": "0.1.2", + "description": "OrgX Claude Code plugin with MCP, runtime telemetry hooks, Work Graph reconciliation, and Sovereign Execution peer sidecar", "type": "module", "private": true, "bin": { diff --git a/plugin.manifest.json b/plugin.manifest.json index 0b1eccf..13a1c1a 100644 --- a/plugin.manifest.json +++ b/plugin.manifest.json @@ -1,6 +1,6 @@ { "plugin_name": "@useorgx/claude-code-plugin", - "version": "0.1.1", + "version": "0.1.2", "manifest_fingerprint": "", "signature": "", "capabilities": ["gateway:drive", "plugin:heartbeat"], diff --git a/scripts/verify-plugin.mjs b/scripts/verify-plugin.mjs index 3a5294a..8673944 100644 --- a/scripts/verify-plugin.mjs +++ b/scripts/verify-plugin.mjs @@ -56,6 +56,17 @@ for (const eventName of ["SessionStart", "PostToolUse", "SubagentStop", "Stop"]) if (!Array.isArray(hooks.hooks[eventName])) fail(`hooks.${eventName} must be an array`); } +const hookScript = readFileSync(hookScriptPath, "utf8"); +if (!hookScript.includes("orgx_claude_code_plugin_runtime_hook")) { + fail("hook script must emit orgx_claude_code_plugin_runtime_hook records"); +} +if (!hookScript.includes("ORGX_WIZARD_HOOK_OUTBOX")) { + fail("hook script must support ORGX_WIZARD_HOOK_OUTBOX"); +} +if (hookScript.includes("appendFileSync(outbox, stdinText")) { + fail("hook script must not persist raw hook stdin"); +} + console.log("verify-plugin: ok"); console.log(`manifest: ${manifest.name}@${manifest.version}`); console.log(`mcp server: ${orgxServer.url}`); diff --git a/skills/orgx-runtime-reporting/SKILL.md b/skills/orgx-runtime-reporting/SKILL.md index 68fb586..6681135 100644 --- a/skills/orgx-runtime-reporting/SKILL.md +++ b/skills/orgx-runtime-reporting/SKILL.md @@ -2,6 +2,19 @@ Use this skill when a Claude Code session must report execution state to OrgX. +## Reporting contract + +There are two reporting paths: + +- **Active path:** call OrgX MCP tools or client APIs during the work when you + know the initiative, task, decision, blocker, or artifact context. +- **Passive backstop:** Claude Code runtime hooks record compact session events + into the local OrgX wizard outbox for later Work Graph reconciliation. + +Do not treat hook presence as a substitute for intentional OrgX writes. Hooks +answer whether OrgX was used; MCP/API calls make the work durable while the +session is still fresh. + ## Workflow 1. Resolve context IDs from env/args: @@ -23,8 +36,25 @@ Use this skill when a Claude Code session must report execution state to OrgX. 5. On completion: - mark task done using a changeset when task id is known +6. If no OrgX IDs are available: +- Continue the work, but make the final response easy for the hook reconciler to + classify: name decisions, artifacts, blockers, next actions, and verification. +- Do not claim OrgX was updated unless an MCP tool or API call actually + succeeded. + +7. Preserve Work Graph continuity: +- When a Work Graph report is generated, include its `work_graph_fingerprint` + and `signup_hydration.hydration_key` in summaries or artifacts that are safe + to store. +- Treat the fingerprint as the durable claim key that lets OrgX hydrate + pre-signup audit value into a user's future workspace. +- Never derive the fingerprint from secrets or raw transcripts that would need + to leave the local machine. + ## Quality Bar - Never post empty or generic updates. - Include IDs whenever available. - Use `source_client=claude-code`. +- Preserve secrets: never emit tokens, cookies, API keys, or storage state into + activity, retro, hook summaries, or final reports. diff --git a/tests/post-reporting-event.test.mjs b/tests/post-reporting-event.test.mjs index 76f8581..87f9bb4 100644 --- a/tests/post-reporting-event.test.mjs +++ b/tests/post-reporting-event.test.mjs @@ -1,16 +1,26 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { parseArgs, pickString, normalizeSourceClient, + sanitizeArgs, + buildWorkGraphHookRecord, buildRuntimePayload, buildActivityPayload, buildCompletionChangesetPayload, main, } from "../hooks/scripts/post-reporting-event.mjs"; +async function createOutboxPath(prefix = "orgx-claude-hook-") { + const dir = await mkdtemp(join(tmpdir(), prefix)); + return join(dir, "events.jsonl"); +} + test("parseArgs parses key/value and boolean flags", () => { const parsed = parseArgs(["--event=stop", "--apply_completion", "--phase=completed"]); assert.equal(parsed.event, "stop"); @@ -30,6 +40,19 @@ test("normalizeSourceClient falls back for invalid values", () => { assert.equal(normalizeSourceClient("bad source"), "claude-code"); }); +test("sanitizeArgs redacts token-like hook arguments", () => { + const sanitized = sanitizeArgs({ + event: "stop", + hook_token: "secret-token", + runtime_hook_token: "secret-token", + api_key: "oxk_secret", + }); + assert.equal(sanitized.event, "stop"); + assert.equal(sanitized.hook_token, "[redacted]"); + assert.equal(sanitized.runtime_hook_token, "[redacted]"); + assert.equal(sanitized.api_key, "[redacted]"); +}); + test("payload builders shape expected OrgX fields", () => { const runtime = buildRuntimePayload({ initiativeId: "init-1", @@ -74,7 +97,7 @@ test("payload builders shape expected OrgX fields", () => { test("main skips when api key is missing", async () => { const result = await main({ - argv: ["--event=session_start"], + argv: ["--event=session_start", `--outbox=${await createOutboxPath()}`], env: { ORGX_INITIATIVE_ID: "init-1" }, fetchImpl: async () => { throw new Error("should not call fetch"); @@ -108,6 +131,7 @@ test("main posts activity and completion changeset", async () => { "--message=done", "--task_id=task-1", "--apply_completion=true", + `--outbox=${await createOutboxPath()}`, ], env: { ORGX_API_KEY: "oxk_test", @@ -145,7 +169,11 @@ test("main normalizes invalid ORGX_SOURCE_CLIENT env value", async () => { }; const result = await main({ - argv: ["--event=post_tool_use", "--message=hello"], + argv: [ + "--event=post_tool_use", + "--message=hello", + `--outbox=${await createOutboxPath()}`, + ], env: { ORGX_API_KEY: "oxk_test", ORGX_INITIATIVE_ID: "init-1", @@ -159,3 +187,53 @@ test("main normalizes invalid ORGX_SOURCE_CLIENT env value", async () => { const payload = JSON.parse(calls[0].init.body); assert.equal(payload.source_client, "claude-code"); }); + +test("buildWorkGraphHookRecord emits redacted reconciliation metadata", () => { + const record = buildWorkGraphHookRecord({ + args: { task_id: "task-1", run_id: "run-1" }, + payload: { + session_id: "sess-1", + transcript_path: "/tmp/transcript.jsonl", + prompt: "do the work", + secret: "do-not-copy", + }, + sourceClient: "claude-code", + event: "Stop", + cwd: "/repo", + timestamp: "2026-05-07T00:00:00.000Z", + }); + + assert.equal(record.source, "orgx_claude_code_plugin_runtime_hook"); + assert.equal(record.source_client, "claude-code"); + assert.equal(record.session_id, "sess-1"); + assert.equal(record.summary.prompt_chars, 11); + assert.equal(record.summary.task_id, "task-1"); + assert.equal(JSON.stringify(record).includes("do the work"), false); + assert.equal(JSON.stringify(record).includes("do-not-copy"), false); +}); + +test("main spools Work Graph event even when live API is unavailable", async () => { + const outbox = await createOutboxPath(); + + const result = await main({ + argv: ["--event=stop", `--outbox=${outbox}`], + env: {}, + stdinText: JSON.stringify({ session_id: "sess-1", prompt: "hello" }), + fetchImpl: async () => { + throw new Error("should not call fetch"); + }, + now: () => Date.parse("2026-05-07T00:00:00.000Z"), + cwd: "/repo", + }); + + assert.equal(result.ok, true); + assert.equal(result.work_graph_spooled, true); + assert.equal(result.skipped, "missing_api_key"); + + const lines = (await readFile(outbox, "utf8")).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.source, "orgx_claude_code_plugin_runtime_hook"); + assert.equal(event.event, "stop"); + assert.equal(event.summary.prompt_chars, 5); +});