Skip to content

Commit 6194c2e

Browse files
committed
Add native Oh My Pi (OMP) extension
1 parent c5cc551 commit 6194c2e

5 files changed

Lines changed: 303 additions & 0 deletions

File tree

‎.omp/extensions/superpowers.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { readFile } from "node:fs/promises";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import type { ContextEvent, ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
5+
6+
type Bootstrap = ContextEvent["messages"][number] & { superpowersBootstrap: true };
7+
8+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
9+
const skillsDir = resolve(packageRoot, "skills");
10+
const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md");
11+
12+
// OMP discovers the bundled skills from the package root's skills/ directory.
13+
// This extension only delivers the using-superpowers bootstrap.
14+
export default function superpowersOmpExtension(omp: ExtensionAPI) {
15+
let bootstrap: Bootstrap | undefined;
16+
let generation = 0;
17+
// The warning is persisted in the session; report a lasting failure once per session.
18+
let warned = false;
19+
const reset = () => {
20+
generation++;
21+
bootstrap = undefined;
22+
};
23+
const resetSession = () => {
24+
reset();
25+
warned = false;
26+
};
27+
omp.on("session_start", resetSession);
28+
omp.on("session_switch", resetSession);
29+
omp.on("session_branch", resetSession);
30+
omp.on("session_tree", resetSession);
31+
omp.on("session_shutdown", resetSession);
32+
33+
// Reload once per user run so an updated installation applies to the next run.
34+
omp.on("before_agent_start", async () => {
35+
reset();
36+
const current = generation;
37+
let text: string;
38+
try {
39+
text = bootstrapText(await readFile(bootstrapSkillPath, "utf8"));
40+
} catch (error) {
41+
if (current !== generation || warned) return;
42+
warned = true;
43+
return {
44+
message: {
45+
customType: "superpowers-bootstrap-error",
46+
content: `Superpowers bootstrap unavailable: ${error instanceof Error ? error.message : String(error)}`,
47+
display: true,
48+
},
49+
};
50+
}
51+
if (current !== generation) return;
52+
warned = false;
53+
bootstrap = {
54+
role: "user",
55+
content: [{ type: "text", text }],
56+
timestamp: Date.now(),
57+
superpowersBootstrap: true,
58+
};
59+
});
60+
61+
// The bootstrap is added to every provider request at the same position, so
62+
// it is never persisted in the session, survives compaction and branch
63+
// summaries, and keeps the request prefix byte-stable for provider prompt
64+
// caching. Ownership is the message property, not its text, so quoting the
65+
// bootstrap cannot suppress it or remove a real user message.
66+
omp.on("context", (event) => {
67+
const messages = event.messages.filter(
68+
(message) => !("superpowersBootstrap" in message && message.superpowersBootstrap === true),
69+
);
70+
if (bootstrap) {
71+
let index = 0;
72+
while (messages[index]?.role === "compactionSummary" || messages[index]?.role === "branchSummary") index++;
73+
messages.splice(index, 0, bootstrap);
74+
}
75+
if (bootstrap || messages.length !== event.messages.length) return { messages };
76+
});
77+
}
78+
79+
function bootstrapText(skill: string): string {
80+
const body = skill.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
81+
if (!body) throw new Error(`${bootstrapSkillPath} has no content`);
82+
return `<EXTREMELY_IMPORTANT>
83+
You have superpowers.
84+
85+
The using-superpowers skill content is included below and is already loaded for this Oh My Pi (OMP) session. Follow it now. Do not try to load using-superpowers again.
86+
87+
${body}
88+
89+
## OMP tool mapping
90+
91+
OMP loads Superpowers skills natively. When a Superpowers instruction says to invoke a skill, \`read\` \`skill://<name>\` instead of Claude Code's \`Skill\` tool.
92+
93+
- Subagents: use OMP's \`task\` tool wherever Superpowers says \`Task\` or asks you to dispatch a subagent.
94+
- Task tracking: use OMP's \`todo\` tool wherever Superpowers says \`TodoWrite\` or asks for a todo list.
95+
- Files and search: use \`read\`, \`write\`, \`edit\`, \`bash\`, \`grep\`, and \`glob\`.
96+
</EXTREMELY_IMPORTANT>`;
97+
}

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Superpowers is a complete software development methodology for your coding agent
1818
- [GitHub Copilot CLI](#github-copilot-cli)
1919
- [Grok Build CLI](#grok-build-cli)
2020
- [Kimi Code](#kimi-code)
21+
- [Oh My Pi (OMP)](#oh-my-pi-omp)
2122
- [OpenCode](#opencode)
2223
- [Pi](#pi)
2324
- [Qwen Code](#qwen-code)
@@ -220,6 +221,22 @@ Superpowers is available in Kimi Code's plugin marketplace.
220221

221222
- Detailed docs: [docs/README.kimi.md](docs/README.kimi.md)
222223

224+
### Oh My Pi (OMP)
225+
226+
Install Superpowers as an OMP plugin package from this repository:
227+
228+
```bash
229+
omp plugin install github:obra/superpowers
230+
```
231+
232+
For local development, link this checkout instead:
233+
234+
```bash
235+
omp plugin link /path/to/superpowers
236+
```
237+
238+
OMP discovers the bundled skills natively. The package's `omp` entry loads a small extension that adds the `using-superpowers` bootstrap, with OMP's `task` and `todo` tool mappings, to every model request, including after compaction. OMP uses this entry instead of the Pi extension.
239+
223240
### OpenCode
224241

225242
OpenCode uses its own plugin install; install Superpowers separately even if you

‎docs/porting-to-a-new-harness.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,7 @@ Use this as the live index; when in doubt, read the files, not this table.
804804
| Kimi Code | `.kimi-plugin/plugin.json` | manifest `sessionStart.skill` loads `using-superpowers` | inline `skillInstructions` in manifest | `tests/kimi/` | marketplace or `/plugins install` GitHub URL |
805805
| OpenCode | `.opencode/plugins/superpowers.js` (root `package.json` `main` for package installs; root `index.js` re-export for the V2 directory form) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` (V1) / `session.hook("context")` (V2) injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` `plugin` (V1) / `plugins` (V2) git URL |
806806
| pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields |
807+
| Oh My Pi (OMP) | `.omp/extensions/superpowers.ts` (root `package.json` `omp` field; OMP prefers it over `pi`) | in-process: package-root `skills/` discovered natively; `before_agent_start` reloads the bootstrap each run; `context` adds one owned user message to every request, after compaction and branch summaries | inline in `superpowers.ts` | `tests/omp/` | `omp plugin install` git spec or `omp plugin link` |
807808

808809
## Appendix B — Gotchas that have bitten porters
809810

‎package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"collaboration",
1313
"workflow"
1414
],
15+
"omp": {
16+
"extensions": [
17+
"./.omp/extensions/superpowers.ts"
18+
]
19+
},
1520
"pi": {
1621
"extensions": [
1722
"./.pi/extensions/superpowers.ts"

‎tests/omp/test-omp-extension.mjs‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import assert from 'node:assert/strict';
2+
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { dirname, join, resolve } from 'node:path';
5+
import { fileURLToPath, pathToFileURL } from 'node:url';
6+
import test from 'node:test';
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const repoRoot = resolve(__dirname, '../..');
10+
const extensionSource = resolve(repoRoot, '.omp/extensions/superpowers.ts');
11+
const roots = [];
12+
13+
test.afterEach(() => {
14+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
15+
});
16+
17+
// Installs the real extension file into a throwaway package root, so each test
18+
// controls the bundled using-superpowers skill the extension reads.
19+
function install(skill) {
20+
const root = mkdtempSync(join(tmpdir(), 'superpowers-omp-'));
21+
roots.push(root);
22+
mkdirSync(join(root, '.omp', 'extensions'), { recursive: true });
23+
copyFileSync(extensionSource, join(root, '.omp', 'extensions', 'superpowers.ts'));
24+
mkdirSync(join(root, 'skills', 'using-superpowers'), { recursive: true });
25+
if (skill !== undefined) writeSkill(root, skill);
26+
return root;
27+
}
28+
29+
function writeSkill(root, body) {
30+
writeFileSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'), body);
31+
}
32+
33+
async function load(root) {
34+
const handlers = new Map();
35+
const omp = {
36+
on(event, handler) {
37+
assert.equal(handlers.has(event), false, `duplicate ${event} handler`);
38+
handlers.set(event, handler);
39+
},
40+
};
41+
const mod = await import(pathToFileURL(join(root, '.omp', 'extensions', 'superpowers.ts')).href);
42+
mod.default(omp);
43+
const ctx = { cwd: root, hasUI: false };
44+
const request = [{ role: 'user', content: 'Let\'s make a react todo list', timestamp: 1 }];
45+
return {
46+
handlers,
47+
request,
48+
async run(systemPrompt = ['Base system prompt.']) {
49+
return handlers.get('before_agent_start')({ type: 'before_agent_start', prompt: 'go', systemPrompt }, ctx);
50+
},
51+
async context(messages = request) {
52+
const result = await handlers.get('context')({ type: 'context', messages }, ctx);
53+
return result?.messages ?? messages;
54+
},
55+
async emit(type) {
56+
await handlers.get(type)?.({ type }, ctx);
57+
},
58+
};
59+
}
60+
61+
const text = (message) => JSON.stringify(message.content);
62+
63+
test('package.json declares the native OMP entry and keeps the Pi entry', () => {
64+
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
65+
assert.deepEqual(pkg.omp, { extensions: ['./.omp/extensions/superpowers.ts'] });
66+
assert.deepEqual(pkg.pi.extensions, ['./.pi/extensions/superpowers.ts']);
67+
});
68+
69+
test('delivers the bootstrap as one leading user message, never in the system prompt', async () => {
70+
const omp = await load(install('\uFEFF---\r\nname: frontmatter-only\r\n---\r\n\r\n# Using Superpowers\r\nInvoke skills first.\r\n'));
71+
assert.equal(await omp.run(), undefined, 'system prompt and history stay untouched');
72+
73+
const messages = await omp.context();
74+
assert.equal(messages.length, 2);
75+
assert.equal(messages[0].role, 'user');
76+
assert.match(text(messages[0]), /You have superpowers/);
77+
assert.match(text(messages[0]), /Invoke skills first\./);
78+
assert.match(text(messages[0]), /OMP tool mapping/);
79+
assert.doesNotMatch(text(messages[0]), /frontmatter-only/);
80+
assert.equal(messages[1], omp.request[0]);
81+
assert.equal(omp.request.length, 1, 'the caller\'s array is not mutated');
82+
assert.deepEqual(await omp.context(structuredClone(messages)), messages, 'a re-sent view is not duplicated');
83+
});
84+
85+
// The shared Pi extension clears its flag at agent_end, so on OMP only the first
86+
// run of a session saw the bootstrap. Every provider request must carry it.
87+
test('keeps the bootstrap on later runs and after compaction', async () => {
88+
const omp = await load(install('Policy stays available.'));
89+
await omp.run();
90+
await omp.context();
91+
await omp.emit('agent_end');
92+
// Compaction retries and queued follow-ups can reach the provider without a
93+
// new before_agent_start.
94+
assert.match(text((await omp.context())[0]), /Policy stays available\./);
95+
await omp.run();
96+
assert.match(text((await omp.context())[0]), /Policy stays available\./);
97+
98+
await omp.emit('session_compact');
99+
const summary = { role: 'compactionSummary', summary: 'Earlier work', tokensBefore: 42, timestamp: 2 };
100+
const compacted = await omp.context([summary, ...omp.request]);
101+
assert.equal(compacted.length, 3);
102+
assert.equal(compacted[0], summary, 'the bootstrap follows compaction summaries');
103+
assert.match(text(compacted[1]), /Policy stays available\./);
104+
assert.equal(compacted[2], omp.request[0]);
105+
});
106+
107+
test('keeps the bootstrap after a branch summary', async () => {
108+
const omp = await load(install('Branch policy.'));
109+
await omp.run();
110+
const summary = { role: 'branchSummary', summary: 'Abandoned branch', fromId: 'x', timestamp: 2 };
111+
const messages = await omp.context([summary, ...omp.request]);
112+
assert.equal(messages[0], summary);
113+
assert.match(text(messages[1]), /Branch policy\./);
114+
});
115+
116+
test('refreshes an updated skill at the next run, not mid-run', async () => {
117+
const root = install('Original policy.');
118+
const omp = await load(root);
119+
await omp.run();
120+
writeSkill(root, 'Updated policy.');
121+
assert.match(text((await omp.context())[0]), /Original policy\./);
122+
await omp.run();
123+
const messages = await omp.context();
124+
assert.equal(messages.length, 2);
125+
assert.match(text(messages[0]), /Updated policy\./);
126+
});
127+
128+
test('a missing skill warns visibly, injects nothing, and recovers after repair', async () => {
129+
const root = install('Installed policy.');
130+
const omp = await load(root);
131+
await omp.run();
132+
const previous = await omp.context();
133+
rmSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'));
134+
135+
const missing = await omp.run();
136+
assert.equal(missing.message.display, true);
137+
assert.match(missing.message.content, /SKILL\.md/);
138+
assert.equal(missing.systemPrompt, undefined);
139+
assert.deepEqual(await omp.context(previous), omp.request, 'stale policy is removed');
140+
// The warning is persisted in the session, so a lasting failure reports once.
141+
assert.equal(await omp.run(), undefined);
142+
143+
writeSkill(root, 'Repaired policy.');
144+
assert.equal(await omp.run(), undefined);
145+
assert.match(text((await omp.context())[0]), /Repaired policy\./);
146+
rmSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'));
147+
assert.match((await omp.run()).message.content, /SKILL\.md/, 'a new failure after recovery warns again');
148+
await omp.emit('session_switch');
149+
assert.match((await omp.run()).message.content, /SKILL\.md/, 'another session is warned too');
150+
});
151+
152+
test('a skill without a body warns instead of injecting an empty bootstrap', async () => {
153+
const omp = await load(install('---\nname: using-superpowers\n---\n'));
154+
assert.match((await omp.run()).message.content, /no content/);
155+
assert.deepEqual(await omp.context(), omp.request);
156+
});
157+
158+
test('quoted bootstrap text neither suppresses the bootstrap nor removes user messages', async () => {
159+
const omp = await load(install('Real policy.'));
160+
const quoted = [{ role: 'user', content: 'You have superpowers. <EXTREMELY_IMPORTANT>', timestamp: 1 }];
161+
await omp.run();
162+
const messages = await omp.context(quoted);
163+
assert.equal(messages.length, 2);
164+
assert.match(text(messages[0]), /Real policy\./);
165+
assert.equal(messages[1], quoted[0]);
166+
});
167+
168+
test('session changes drop the old bootstrap and ignore a load that finishes late', async () => {
169+
const root = install('Old session policy.');
170+
const omp = await load(root);
171+
await omp.run();
172+
const old = await omp.context();
173+
for (const event of ['session_switch', 'session_branch', 'session_tree', 'session_shutdown', 'session_start']) {
174+
await omp.run();
175+
await omp.emit(event);
176+
assert.deepEqual(await omp.context(old), omp.request, `${event} clears the bootstrap`);
177+
}
178+
179+
const pending = omp.run();
180+
await omp.emit('session_switch');
181+
await pending;
182+
assert.deepEqual(await omp.context(), omp.request, 'a load from the previous session is discarded');
183+
});

0 commit comments

Comments
 (0)