From 2cb7e36928bd10a19a840edbb9606b40a4e865b3 Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Sat, 19 Sep 2026 15:13:38 -0500 Subject: [PATCH 1/2] feat(#85): add OMP (Oh My Pi) as a 4th delivery format .omp-plugin/ mirrors the Codex reuse pattern rather than OpenCode's full TypeScript port: OMP's hooks are in-process pi.on() event handlers, so hooks/post/throughline.ts is a thin shim that translates OMP's own event shapes into the JSON the existing hooks/*.sh scripts already jq-parse, then shells out to them. No capture/redaction logic is duplicated. Skills are auto-discovered via .omp-plugin/skills, a symlink to the shared skills/ directory - zero extra step, same as .omp-plugin/hooks -> ../hooks. Verified against OMP's actual TypeScript source (installed locally via `npm install @oh-my-pi/pi-coding-agent`), not just its docs: the real pi.on() event names/payload shapes, the omp-plugins/claude-plugins discovery providers, and the omp plugin link mechanism for installing a monorepo subdirectory. A bun-based integration test suite (.omp-plugin/) fires synthetic OMP events at the real shim and asserts on the resulting hooks/*.sh output, rather than mocking child_process - the same principle tests/run.sh already applies to the Claude Code hooks, and the layer that would have caught the two silent bugs .opencode-plugin's full port shipped. Live verification against a real OMP session (tool_result field-shape confirmation for web_search/task, session_shutdown reliability on a clean exit, sendMessage reaching the model on turn 1) is still open - tracked in issue #85, not closed by this commit. Also updates CI (.github/workflows/ci.yml, .local-ci.json) with a 4th manifest in the version-agreement check and a new omp-plugin job, and documents OMP as a delivery format in README.md, docs/INSTALL.md, and docs/index.html. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 21 +++- .local-ci.json | 8 +- .omp-plugin/.gitignore | 3 + .omp-plugin/bun.lock | 23 ++++ .omp-plugin/hooks | 1 + .omp-plugin/package.json | 24 ++++ .omp-plugin/skills | 1 + .omp-plugin/throughline.test.ts | 187 ++++++++++++++++++++++++++++++++ .omp-plugin/tsconfig.json | 13 +++ CHANGELOG.md | 9 ++ README.md | 41 ++++--- docs/INSTALL.md | 48 +++++++- docs/index.html | 28 +++-- hooks/post/throughline.ts | 169 +++++++++++++++++++++++++++++ 14 files changed, 548 insertions(+), 28 deletions(-) create mode 100644 .omp-plugin/.gitignore create mode 100644 .omp-plugin/bun.lock create mode 120000 .omp-plugin/hooks create mode 100644 .omp-plugin/package.json create mode 120000 .omp-plugin/skills create mode 100644 .omp-plugin/throughline.test.ts create mode 100644 .omp-plugin/tsconfig.json create mode 100644 hooks/post/throughline.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e64fe2..1c8ff32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,14 +43,16 @@ jobs: jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null + jq -e '.name and .version' .omp-plugin/package.json >/dev/null - name: Check plugin version agreement (Linux) if: runner.os == 'Linux' run: | c=$(jq -r .version .claude-plugin/plugin.json) x=$(jq -r .version .codex-plugin/plugin.json) o=$(jq -r .version .opencode-plugin/package.json) - if [ "$c" != "$x" ] || [ "$c" != "$o" ]; then - echo "version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o" >&2 + m=$(jq -r .version .omp-plugin/package.json) + if [ "$c" != "$x" ] || [ "$c" != "$o" ] || [ "$c" != "$m" ]; then + echo "version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o .omp-plugin/package.json=$m" >&2 exit 1 fi - name: Typography (Linux) @@ -105,3 +107,18 @@ jobs: run: npm run typecheck - name: Run tests run: npm test + + omp-plugin: + runs-on: ubuntu-latest + defaults: + run: + working-directory: .omp-plugin + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Typecheck + run: bun run typecheck + - name: Run tests + run: bun test diff --git a/.local-ci.json b/.local-ci.json index a03f5bd..9032cb7 100644 --- a/.local-ci.json +++ b/.local-ci.json @@ -2,11 +2,11 @@ "checks": [ { "label": "manifest validation", - "run": "jq -e '.hooks | keys' hooks/hooks.json >/dev/null && jq -e '.name and .version' .claude-plugin/plugin.json >/dev/null && jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null && jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null && jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null" + "run": "jq -e '.hooks | keys' hooks/hooks.json >/dev/null && jq -e '.name and .version' .claude-plugin/plugin.json >/dev/null && jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null && jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null && jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null && jq -e '.name and .version' .omp-plugin/package.json >/dev/null" }, { "label": "plugin version agreement", - "run": "set -e; c=$(jq -r .version .claude-plugin/plugin.json); x=$(jq -r .version .codex-plugin/plugin.json); o=$(jq -r .version .opencode-plugin/package.json); [ \"$c\" = \"$x\" ] && [ \"$c\" = \"$o\" ] || { echo \"version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o\" >&2; exit 1; }" + "run": "set -e; c=$(jq -r .version .claude-plugin/plugin.json); x=$(jq -r .version .codex-plugin/plugin.json); o=$(jq -r .version .opencode-plugin/package.json); m=$(jq -r .version .omp-plugin/package.json); [ \"$c\" = \"$x\" ] && [ \"$c\" = \"$o\" ] && [ \"$c\" = \"$m\" ] || { echo \"version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o .omp-plugin/package.json=$m\" >&2; exit 1; }" }, { "label": "hook tests", @@ -19,6 +19,10 @@ { "label": "opencode plugin", "run": "cd .opencode-plugin && npm ci && npm run typecheck && npm test" + }, + { + "label": "omp plugin", + "run": "cd .omp-plugin && bun install --frozen-lockfile && bun run typecheck && bun test" } ] } diff --git a/.omp-plugin/.gitignore b/.omp-plugin/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.omp-plugin/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/.omp-plugin/bun.lock b/.omp-plugin/bun.lock new file mode 100644 index 0000000..b9671ff --- /dev/null +++ b/.omp-plugin/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@dynamicagency/throughline-omp", + "devDependencies": { + "@types/node": "^20.0.0", + "bun-types": "^1.3.0", + "typescript": "^5.4.0", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/.omp-plugin/hooks b/.omp-plugin/hooks new file mode 120000 index 0000000..f631275 --- /dev/null +++ b/.omp-plugin/hooks @@ -0,0 +1 @@ +../hooks \ No newline at end of file diff --git a/.omp-plugin/package.json b/.omp-plugin/package.json new file mode 100644 index 0000000..d3b709c --- /dev/null +++ b/.omp-plugin/package.json @@ -0,0 +1,24 @@ +{ + "name": "@dynamicagency/throughline-omp", + "version": "0.15.0", + "private": true, + "type": "module", + "description": "Continuous, state-aware session memory for OMP (Oh My Pi). Captures what you did and what is (commands, file changes, decisions, and live git/PR state), then hands it off with judgment at session wrap-up. Readable, local-first artifacts (commit them if you choose).", + "author": { + "name": "Dynamic Agency", + "email": "support@dynamicagency.com" + }, + "homepage": "https://github.com/dynamic/throughline", + "repository": "https://github.com/dynamic/throughline", + "license": "MIT", + "keywords": ["memory", "handoff", "session", "context", "continuity", "compaction", "git-state", "cross-harness", "resume", "onboarding", "omp", "oh-my-pi"], + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "bun test" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "bun-types": "^1.3.0", + "typescript": "^5.4.0" + } +} diff --git a/.omp-plugin/skills b/.omp-plugin/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/.omp-plugin/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.omp-plugin/throughline.test.ts b/.omp-plugin/throughline.test.ts new file mode 100644 index 0000000..2599ad6 --- /dev/null +++ b/.omp-plugin/throughline.test.ts @@ -0,0 +1,187 @@ +// Integration test for the OMP hook shim (hooks/post/throughline.ts). +// +// Deliberately does NOT mock node:child_process: it imports the real shim +// and fires synthetic OMP events at it, letting each handler drive the real +// hooks/*.sh scripts against a scratch project directory, then asserts on +// the resulting buffer file. A mocked-execFile unit test would only prove +// "the shim called execFile with some string" — it can't catch a JSON +// field-name mismatch against what the shell scripts' own `jq` filters +// expect, which is exactly the class of bug this design is meant to avoid +// (see dynamic/throughline#85 and the .opencode-plugin tool-naming bug it +// cites). Running the real scripts, as tests/run.sh already does for the +// Claude Code hooks, is the layer that actually catches that. +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const SHIM = path.join(import.meta.dir, "..", "hooks", "post", "throughline.ts"); +const SESSION_ID = "test-session-id"; + +let scratchDir: string; +let originalCwd: string; +let registered: Record Promise | void)[]>; +let sentMessages: { customType?: string; content?: string }[]; +let ctx: { cwd: string; sessionManager: { getSessionId: () => string } }; + +async function fire(event: string, payload: unknown): Promise { + const handlers = registered[event] ?? []; + expect(handlers.length).toBeGreaterThan(0); + for (const h of handlers) await h(payload, ctx); +} + +function bufferPath(): string { + return path.join(scratchDir, ".claude", "throughline", "buffer", `session-${SESSION_ID}.md`); +} + +beforeEach(async () => { + scratchDir = mkdtempSync(path.join(os.tmpdir(), "tl-omp-test-")); + originalCwd = process.cwd(); + process.chdir(scratchDir); + + registered = {}; + sentMessages = []; + const pi = { + on(event: string, handler: (event: unknown, ctx: unknown) => Promise | void) { + (registered[event] ??= []).push(handler); + }, + sendMessage(msg: { customType?: string; content?: string }) { + sentMessages.push(msg); + }, + }; + ctx = { cwd: scratchDir, sessionManager: { getSessionId: () => SESSION_ID } }; + + // Cache-busting query so bun re-evaluates the module (and its registered + // handlers) fresh for every test rather than reusing a prior import. + const mod = await import(`${SHIM}?t=${Date.now()}-${Math.random()}`); + mod.default(pi); +}); + +afterEach(() => { + process.chdir(originalCwd); + rmSync(scratchDir, { recursive: true, force: true }); +}); + +describe("throughline OMP shim", () => { + test("registers all six lifecycle events", () => { + for (const event of [ + "session_start", + "before_agent_start", + "tool_result", + "session_before_compact", + "session_compact", + "session_shutdown", + ]) { + expect(registered[event]?.length).toBe(1); + } + }); + + test("session_start sends an onboarding message", async () => { + await fire("session_start", { type: "session_start" }); + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].customType).toBe("throughline-onboard"); + expect(sentMessages[0].content).toContain("throughline"); + }); + + test("before_agent_start captures the prompt", async () => { + await fire("before_agent_start", { type: "before_agent_start", prompt: "hello from omp" }); + const buf = readFileSync(bufferPath(), "utf8"); + expect(buf).toContain("**prompt** hello from omp"); + }); + + test("tool_result captures bash, write, grep, and mcp__* tools", async () => { + await fire("tool_result", { + type: "tool_result", + toolName: "bash", + toolCallId: "1", + input: { command: "echo hi" }, + content: [], + isError: false, + }); + await fire("tool_result", { + type: "tool_result", + toolName: "write", + toolCallId: "2", + input: { path: "foo.txt", content: "x" }, + content: [], + isError: false, + }); + await fire("tool_result", { + type: "tool_result", + toolName: "grep", + toolCallId: "3", + input: { pattern: "TODO" }, + content: [], + isError: false, + }); + await fire("tool_result", { + type: "tool_result", + toolName: "mcp__github_search_issues", + toolCallId: "4", + input: {}, + content: [], + isError: false, + }); + + const buf = readFileSync(bufferPath(), "utf8"); + expect(buf).toContain("**bash**"); + expect(buf).toContain("echo hi"); + expect(buf).toContain("**Write** foo.txt"); + expect(buf).toContain("**grep** `TODO`"); + expect(buf).toContain("**mcp__github_search_issues**"); + }); + + test("tool_result skips read and glob (matches Claude Code's own exclusion)", async () => { + await fire("tool_result", { + type: "tool_result", + toolName: "read", + toolCallId: "1", + input: { path: "foo.txt" }, + content: [], + isError: false, + }); + await fire("tool_result", { + type: "tool_result", + toolName: "glob", + toolCallId: "2", + input: { pattern: "*.ts" }, + content: [], + isError: false, + }); + expect(existsSync(bufferPath())).toBe(false); + }); + + test("tool_result drops unrecognized non-mcp tools rather than guessing a shape", async () => { + await fire("tool_result", { + type: "tool_result", + toolName: "todo", + toolCallId: "1", + input: {}, + content: [], + isError: false, + }); + expect(existsSync(bufferPath())).toBe(false); + }); + + test("session_before_compact stamps a compaction boundary", async () => { + // Needs an existing buffer to stamp — capture something first. + await fire("before_agent_start", { type: "before_agent_start", prompt: "hi" }); + await fire("session_before_compact", { type: "session_before_compact" }); + const buf = readFileSync(bufferPath(), "utf8"); + expect(buf).toContain("