From 9cba70679226a581b6a646e99c31ab9fe4ffb0a5 Mon Sep 17 00:00:00 2001 From: Lucas Medeiros Date: Wed, 15 Jul 2026 17:28:22 -0400 Subject: [PATCH] feat(omp): add native OMP harness support --- .omp/INSTALL.md | 54 +++ .omp/extensions/superpowers.ts | 58 +++ .pi/extensions/superpowers.ts | 117 ++---- README.md | 18 +- docs/porting-to-a-new-harness.md | 3 + integrations/shared/bootstrap.ts | 133 +++++++ package.json | 5 + tests/integrations/test-bootstrap-core.mjs | 227 +++++++++++ tests/omp/test-omp-docs.mjs | 98 +++++ tests/omp/test-omp-extension.mjs | 312 ++++++++++++++++ tests/pi/test-pi-extension.mjs | 413 ++++++++++++++++----- 11 files changed, 1255 insertions(+), 183 deletions(-) create mode 100644 .omp/INSTALL.md create mode 100644 .omp/extensions/superpowers.ts create mode 100644 integrations/shared/bootstrap.ts create mode 100644 tests/integrations/test-bootstrap-core.mjs create mode 100644 tests/omp/test-omp-docs.mjs create mode 100644 tests/omp/test-omp-extension.mjs diff --git a/.omp/INSTALL.md b/.omp/INSTALL.md new file mode 100644 index 0000000000..0680a409a7 --- /dev/null +++ b/.omp/INSTALL.md @@ -0,0 +1,54 @@ +# Installing Superpowers for oh-my-pi (omp) + +## Prerequisite + +OMP must already be installed. This integration was verified with omp 16.5.2. +This records the tested environment, not a minimum version or compatibility +floor. + +## Install from git + +Use omp's native plugin installer: + +```bash +omp plugin install github:obra/superpowers +``` + +omp marketplace installation does not load `omp.extensions` modules and +therefore is not the native installation path for this integration. Use the Git +install above instead. + +## Local development + +Link an absolute path to your checkout: + +```bash +omp plugin link /absolute/path/to/superpowers +``` + +After installing or linking, restart OMP or start a new session. + +## Verify + +Confirm that omp registered the plugin: + +```bash +omp plugin list --json +``` + +(`omp plugin list` is also available for human-readable output.) + +Then start a clean session and send this exact prompt: + +> Let's make a react todo list + +The Superpowers bootstrap should load, and `brainstorming` should auto-trigger +before any code is written. + +If registration or startup fails, inspect omp diagnostics under `~/.omp/logs/`. + +## Remove + +```bash +omp plugin uninstall superpowers +``` diff --git a/.omp/extensions/superpowers.ts b/.omp/extensions/superpowers.ts new file mode 100644 index 0000000000..05fd298546 --- /dev/null +++ b/.omp/extensions/superpowers.ts @@ -0,0 +1,58 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ContextEvent, ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { createBootstrapController } from "../../integrations/shared/bootstrap.ts"; + +const BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for omp"; +const LOADED_MESSAGE = + "The using-superpowers skill content is included below and is already loaded for this OMP session. Follow it now. Do not reload using-superpowers."; + +const extensionDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(extensionDir, "../.."); +const skillsDir = resolve(packageRoot, "skills"); +const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md"); + +export default function superpowersOmpExtension(omp: ExtensionAPI) { + const controller = createBootstrapController< + ContextEvent["messages"][number] + >({ + harness: "omp", + bootstrapSkillPath, + bootstrapMarker: BOOTSTRAP_MARKER, + loadedMessage: LOADED_MESSAGE, + toolMapping: ompToolMapping(), + reportDiagnostic(diagnostic) { + omp.logger.warn("Superpowers bootstrap unavailable", diagnostic); + }, + }); + + omp.on("session_start", async () => { + controller.arm(); + }); + + omp.on("session_compact", async () => { + controller.arm(); + }); + + omp.on("context", async (event: ContextEvent) => + controller.inject(event.messages), + ); + + omp.on("agent_end", async () => { + controller.disarm(); + }); +} + +function ompToolMapping(): string { + return `## OMP tool mapping + +Use OMP native skill discovery when a Superpowers instruction says to invoke a skill: read \`skill:///SKILL.md\` when the skill applies; \`/skill:\` is available for explicit human invocation. + +OMP's lowercase built-ins are \`read\`, \`write\`, \`edit\`, \`bash\`, \`grep\`, and \`glob\`. Use them for the corresponding file, shell, and search actions. + +For Superpowers subagent workflows, use the built-in lowercase \`task\`. + +For legacy \`TodoWrite\` task tracking, use the built-in lowercase \`todo\`. + +Never invent capitalized \`Skill\`, \`Task\`, or \`TodoWrite\` calls.`; +} diff --git a/.pi/extensions/superpowers.ts b/.pi/extensions/superpowers.ts index a978e80ee0..faf91af58e 100644 --- a/.pi/extensions/superpowers.ts +++ b/.pi/extensions/superpowers.ts @@ -1,88 +1,62 @@ -import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { + ContextEvent, + ExtensionAPI, +} from "@earendil-works/pi-coding-agent"; +import { createBootstrapController } from "../../integrations/shared/bootstrap.ts"; -const EXTREMELY_IMPORTANT_MARKER = ""; const BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for pi"; +const LOADED_MESSAGE = + "The using-superpowers skill content is included below and is already loaded for this Pi session. Follow it now. Do not try to load using-superpowers again."; const extensionDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(extensionDir, "../.."); const skillsDir = resolve(packageRoot, "skills"); const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md"); -let cachedBootstrap: string | null | undefined; - export default function superpowersPiExtension(pi: ExtensionAPI) { - let injectBootstrap = true; + const logger = ( + pi as ExtensionAPI & { + logger?: { warn?: (...args: unknown[]) => void }; + } + ).logger; + const controller = createBootstrapController< + ContextEvent["messages"][number] + >({ + harness: "pi", + bootstrapSkillPath, + bootstrapMarker: BOOTSTRAP_MARKER, + loadedMessage: LOADED_MESSAGE, + toolMapping: piToolMapping(), + reportDiagnostic(diagnostic) { + if (logger?.warn) { + logger.warn("Superpowers bootstrap unavailable", diagnostic); + } else { + console.warn("Superpowers bootstrap unavailable", diagnostic); + } + }, + }); pi.on("resources_discover", async () => ({ skillPaths: [skillsDir], })); pi.on("session_start", async () => { - injectBootstrap = true; + controller.arm(); }); pi.on("session_compact", async () => { - injectBootstrap = true; + controller.arm(); }); pi.on("agent_end", async () => { - injectBootstrap = false; - }); - - pi.on("context", async (event) => { - if (!injectBootstrap) return; - if (event.messages.some(messageContainsBootstrap)) return; - - const bootstrap = getBootstrapContent(); - if (!bootstrap) return; - - const bootstrapMessage = { - role: "user" as const, - content: [{ type: "text" as const, text: bootstrap }], - timestamp: Date.now(), - }; - - const insertAt = firstNonCompactionSummaryIndex(event.messages); - return { - messages: [ - ...event.messages.slice(0, insertAt), - bootstrapMessage, - ...event.messages.slice(insertAt), - ], - }; + controller.disarm(); }); -} - -function getBootstrapContent(): string | null { - if (cachedBootstrap !== undefined) return cachedBootstrap; - - try { - const skillContent = readFileSync(bootstrapSkillPath, "utf8"); - const body = stripFrontmatter(skillContent); - cachedBootstrap = `${EXTREMELY_IMPORTANT_MARKER} -${BOOTSTRAP_MARKER} -You have superpowers. - -The using-superpowers skill content is included below and is already loaded for this Pi session. Follow it now. Do not try to load using-superpowers again. - -${body} - -${piToolMapping()} -`; - return cachedBootstrap; - } catch { - cachedBootstrap = null; - return null; - } -} - -function stripFrontmatter(content: string): string { - const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); - return (match ? match[1] : content).trim(); + pi.on("context", async (event: ContextEvent) => + controller.inject(event.messages), + ); } function piToolMapping(): string { @@ -96,26 +70,3 @@ Pi does not ship a standard subagent tool. If a subagent tool such as \`subagent Pi does not ship a standard task-list tool. If an installed todo/task tool is available, use it. Otherwise track work in plan files or a repo-local \`TODO.md\` when task tracking is needed. Treat older \`TodoWrite\` references as this task-tracking action.`; } - -function messageContainsBootstrap(message: unknown): boolean { - const content = (message as { content?: unknown }).content; - if (typeof content === "string") return content.includes(BOOTSTRAP_MARKER); - if (!Array.isArray(content)) return false; - return content.some((part) => { - return ( - part && - typeof part === "object" && - (part as { type?: unknown }).type === "text" && - typeof (part as { text?: unknown }).text === "string" && - (part as { text: string }).text.includes(BOOTSTRAP_MARKER) - ); - }); -} - -function firstNonCompactionSummaryIndex(messages: unknown[]): number { - let index = 0; - while ((messages[index] as { role?: unknown } | undefined)?.role === "compactionSummary") { - index += 1; - } - return index; -} diff --git a/README.md b/README.md index bb398c6b68..8defcd42c8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If this sounds like someone you know, definitely send them our way. ## Quickstart -Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi). +Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi), [oh-my-pi](#oh-my-pi-omp). ## How it works @@ -199,6 +199,22 @@ pi -e /path/to/superpowers The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages. +### oh-my-pi (omp) + +Install Superpowers from Git through omp's native plugin manager: + +```bash +omp plugin install github:obra/superpowers +``` + +For local development, link an absolute path to this checkout: + +```bash +omp plugin link /absolute/path/to/superpowers +``` + +OMP uses its native manifest plus built-in lowercase `task` subagents and built-in lowercase `todo` tracking. See the [OMP installation guide](.omp/INSTALL.md) for verification, diagnostics, and removal. + ## The Basic Workflow 1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document. diff --git a/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index 4ae9603def..e640a8378a 100644 --- a/docs/porting-to-a-new-harness.md +++ b/docs/porting-to-a-new-harness.md @@ -128,6 +128,9 @@ installer. Factory's Droid, for example, consumes the Claude Code plugin via its own `plugin install` command and needs no new files here. Before building, check whether the harness can simply load an existing manifest. A port that adds nothing to this repo but a paragraph in the README is a perfectly good outcome. +Compatibility manifests may be consumed by child or fork harnesses. For +first-class support, when the child exposes a native manifest or adapter contract +and behavior differs, use and verify that native boundary. --- diff --git a/integrations/shared/bootstrap.ts b/integrations/shared/bootstrap.ts new file mode 100644 index 0000000000..d37a3a880d --- /dev/null +++ b/integrations/shared/bootstrap.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface BootstrapDiagnostic { + level: "warning"; + code: "bootstrap-read-failed"; + harness: string; + path: string; + error: string; +} + +export interface BootstrapControllerConfig { + harness: string; + bootstrapSkillPath: string; + bootstrapMarker: string; + loadedMessage: string; + toolMapping: string; + reportDiagnostic?: (diagnostic: BootstrapDiagnostic) => void; +} + +export interface BootstrapController { + arm(): void; + disarm(): void; + inject(messages: Message[]): { messages: Message[] } | undefined; +} + +export function createBootstrapController( + config: BootstrapControllerConfig, +): BootstrapController { + const bootstrapSkillPath = resolve(config.bootstrapSkillPath); + let armed = false; + let cachedBootstrap: string | null | undefined; + + function getBootstrap(): string | null { + if (cachedBootstrap !== undefined) return cachedBootstrap; + + try { + const body = stripFrontmatter(readFileSync(bootstrapSkillPath, "utf8")); + cachedBootstrap = ` +${config.bootstrapMarker} + +You have superpowers. + +${config.loadedMessage} + +${body} + +${config.toolMapping} +`; + return cachedBootstrap; + } catch (error) { + cachedBootstrap = null; + try { + config.reportDiagnostic?.({ + level: "warning", + code: "bootstrap-read-failed", + harness: config.harness, + path: bootstrapSkillPath, + error: error instanceof Error ? error.message : String(error), + }); + } catch (callbackError) { + void callbackError; + } + return null; + } + } + + return { + arm() { + armed = true; + }, + disarm() { + armed = false; + }, + inject(messages) { + if (!armed || messages.some(messageContainsMarker)) return undefined; + + const bootstrap = getBootstrap(); + if (bootstrap === null) return undefined; + + const bootstrapMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: bootstrap }], + timestamp: Date.now(), + } as Message; + const insertAt = firstNonCompactionSummaryIndex(messages); + return { + messages: [ + ...messages.slice(0, insertAt), + bootstrapMessage, + ...messages.slice(insertAt), + ], + }; + }, + }; + + function messageContainsMarker(message: unknown): boolean { + if (!isRecord(message)) return false; + const { content } = message; + if (typeof content === "string") { + return content.includes(config.bootstrapMarker); + } + if (!Array.isArray(content)) return false; + + return content.some((part) => { + return ( + isRecord(part) && + part.type === "text" && + typeof part.text === "string" && + part.text.includes(config.bootstrapMarker) + ); + }); + } +} + +function stripFrontmatter(content: string): string { + const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + return (match ? match[1] : content).trim(); +} + +function firstNonCompactionSummaryIndex(messages: unknown[]): number { + let index = 0; + while (true) { + const message = messages[index]; + if (!isRecord(message) || message.role !== "compactionSummary") + return index; + index += 1; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/package.json b/package.json index c24b3721de..53c17ee6b4 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,10 @@ "skills": [ "./skills" ] + }, + "omp": { + "extensions": [ + "./.omp/extensions/superpowers.ts" + ] } } diff --git a/tests/integrations/test-bootstrap-core.mjs b/tests/integrations/test-bootstrap-core.mjs new file mode 100644 index 0000000000..5809bf6b3b --- /dev/null +++ b/tests/integrations/test-bootstrap-core.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { createBootstrapController } from "../../integrations/shared/bootstrap.ts"; + +const marker = "superpowers:using-superpowers bootstrap for test-harness"; + +async function fixture(t, overrides = {}) { + const directory = await mkdtemp( + join(tmpdir(), "superpowers-bootstrap-core-"), + ); + const bootstrapSkillPath = join(directory, "SKILL.md"); + await writeFile( + bootstrapSkillPath, + "---\nname: using-superpowers\ndescription: fixture\n---\n# Bootstrap body\n\nFollow the fixture skill.\n", + ); + t.after(() => rm(directory, { recursive: true, force: true })); + + return { + bootstrapSkillPath, + controller: createBootstrapController({ + harness: "Test Harness", + bootstrapSkillPath, + bootstrapMarker: marker, + loadedMessage: + "The bootstrap is already loaded for this Test Harness session. Follow it now.", + toolMapping: "## Test Harness tool mapping\n\nUse native test tools.", + ...overrides, + }), + }; +} + +function injectedMessage(result) { + assert.ok(result); + return result.messages.find((message) => { + return ( + message && + typeof message === "object" && + message.role === "user" && + Array.isArray(message.content) && + message.content.some( + (part) => + part?.type === "text" && + typeof part.text === "string" && + part.text.includes(marker), + ) + ); + }); +} + +test("arm injects one wrapped user message, strips frontmatter, and caches successful content", async (t) => { + const { bootstrapSkillPath, controller } = await fixture(t); + const original = { role: "user", content: "Build something", timestamp: 1 }; + const messages = [original]; + + assert.equal( + controller.inject(messages), + undefined, + "controller starts disarmed", + ); + controller.arm(); + const before = Date.now(); + const first = controller.inject(messages); + const after = Date.now(); + const bootstrap = injectedMessage(first); + + assert.ok(bootstrap); + assert.equal(first.messages.length, 2); + assert.notEqual(first.messages, messages); + assert.deepEqual(messages, [original], "input array is not mutated"); + assert.equal( + first.messages[1], + original, + "existing message identity is preserved", + ); + assert.equal(bootstrap.role, "user"); + assert.equal(bootstrap.content.length, 1); + assert.equal(bootstrap.content[0].type, "text"); + assert.equal(typeof bootstrap.timestamp, "number"); + assert.ok(bootstrap.timestamp >= before && bootstrap.timestamp <= after); + + const text = bootstrap.content[0].text; + assert.match(text, /^\n/); + assert.match(text, new RegExp(marker)); + assert.match(text, /You have superpowers\./); + assert.match(text, /already loaded for this Test Harness session/); + assert.match(text, /# Bootstrap body/); + assert.match(text, /Follow the fixture skill\./); + assert.match(text, /## Test Harness tool mapping/); + assert.match(text, /<\/EXTREMELY_IMPORTANT>$/); + assert.doesNotMatch(text, /name: using-superpowers|description: fixture/); + + const repeated = controller.inject(messages); + assert.equal(injectedMessage(repeated).content[0].text, text); + assert.equal(repeated.messages[1], original); + + await rm(bootstrapSkillPath); + controller.disarm(); + assert.equal(controller.inject(messages), undefined); + controller.arm(); + const cached = controller.inject(messages); + assert.equal(injectedMessage(cached).content[0].text, text); +}); + +test("existing markers in string or multipart text content suppress injection", async (t) => { + const { controller } = await fixture(t); + controller.arm(); + + assert.equal( + controller.inject([ + { role: "assistant", content: `prefix ${marker} suffix` }, + ]), + undefined, + ); + assert.equal( + controller.inject([ + { + role: "user", + content: [ + { type: "image", data: marker }, + { type: "text", text: `prefix ${marker} suffix` }, + ], + }, + ]), + undefined, + ); +}); + +test("injection follows all leading compaction summaries and preserves message order and identity", async (t) => { + const { controller } = await fixture(t); + const firstSummary = { role: "compactionSummary", summary: "first" }; + const secondSummary = { role: "compactionSummary", summary: "second" }; + const user = { role: "user", content: "Continue" }; + const trailingSummary = { role: "compactionSummary", summary: "not leading" }; + const messages = [firstSummary, secondSummary, user, trailingSummary]; + controller.arm(); + + const result = controller.inject(messages); + + assert.ok(result); + assert.equal(result.messages.length, 5); + assert.equal(result.messages[0], firstSummary); + assert.equal(result.messages[1], secondSummary); + assert.ok(injectedMessage(result)); + assert.equal(result.messages[3], user); + assert.equal(result.messages[4], trailingSummary); + assert.deepEqual(messages, [ + firstSummary, + secondSummary, + user, + trailingSummary, + ]); +}); + +test("read failure fails soft and reports one structured diagnostic without retrying", async (t) => { + const directory = await mkdtemp( + join(tmpdir(), "superpowers-bootstrap-core-"), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const configuredPath = join(directory, "nested", "..", "missing-SKILL.md"); + const diagnostics = []; + const controller = createBootstrapController({ + harness: "Test Harness", + bootstrapSkillPath: configuredPath, + bootstrapMarker: marker, + loadedMessage: "Loaded guidance", + toolMapping: "Tool guidance", + reportDiagnostic(diagnostic) { + diagnostics.push(diagnostic); + }, + }); + controller.arm(); + + assert.equal(controller.inject([]), undefined); + assert.equal(diagnostics.length, 1); + assert.deepEqual( + { + level: diagnostics[0].level, + code: diagnostics[0].code, + harness: diagnostics[0].harness, + path: diagnostics[0].path, + }, + { + level: "warning", + code: "bootstrap-read-failed", + harness: "Test Harness", + path: resolve(configuredPath), + }, + ); + assert.equal(typeof diagnostics[0].error, "string"); + assert.match(diagnostics[0].error, /ENOENT/); + + await mkdir(join(directory, "nested"), { recursive: true }); + await writeFile(resolve(configuredPath), "# Late bootstrap"); + assert.equal( + controller.inject([]), + undefined, + "failed reads are not retried", + ); + controller.disarm(); + controller.arm(); + assert.equal( + controller.inject([]), + undefined, + "re-arming does not retry a failed read", + ); + assert.equal(diagnostics.length, 1, "diagnostic is reported exactly once"); +}); + +test("throwing diagnostic callback does not escape injection or cause a retry", async (t) => { + let reportCount = 0; + const { bootstrapSkillPath, controller } = await fixture(t, { + reportDiagnostic() { + reportCount += 1; + throw new Error("reporter failed"); + }, + }); + await rm(bootstrapSkillPath); + controller.arm(); + + assert.equal(controller.inject([]), undefined); + assert.equal(controller.inject([]), undefined); + assert.equal(reportCount, 1, "diagnostic callback is invoked exactly once"); +}); diff --git a/tests/omp/test-omp-docs.mjs b/tests/omp/test-omp-docs.mjs new file mode 100644 index 0000000000..9cbdf83008 --- /dev/null +++ b/tests/omp/test-omp-docs.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); + +async function readRepoFile(path) { + return readFile(resolve(repoRoot, path), "utf8"); +} + +test("README Quickstart links to a neighboring native OMP install section", async () => { + const readme = await readRepoFile("README.md"); + + assert.match(readme, /\[Oh My Pi \(OMP\)\]\(#oh-my-pi-omp\)/); + assert.match(readme, /^### Oh My Pi \(OMP\)$/m); + assert.ok( + readme.indexOf("### Oh My Pi (OMP)") > readme.indexOf("### Pi"), + "OMP install guidance should follow the existing Pi section", + ); + assert.match(readme, /omp plugin install github:obra\/superpowers/); + assert.match(readme, /omp plugin link \/absolute\/path\/to\/superpowers/); + assert.match(readme, /\[[^\]]+\]\(\.omp\/INSTALL\.md\)/); + assert.match(readme, /native manifest/i); + assert.match(readme, /built-in lowercase `task` subagents/i); + assert.match(readme, /built-in lowercase `todo` tracking/i); +}); + +test("OMP install guide documents native install, verification, diagnostics, and removal", async () => { + const installGuide = await readRepoFile(".omp/INSTALL.md"); + + for (const required of [ + "omp plugin install github:obra/superpowers", + "omp plugin link /absolute/path/to/superpowers", + "omp plugin list --json", + "omp plugin list", + "omp plugin uninstall superpowers", + "~/.omp/logs/", + "> Let's make a react todo list", + ]) { + assert.ok( + installGuide.includes(required), + `missing install contract: ${required}`, + ); + } + + assert.match(installGuide, /prerequisite[\s\S]{0,120}OMP.*installed/i); + assert.match(installGuide, /verified with OMP 16\.5\.2/i); + assert.match(installGuide, /not a minimum version or compatibility\s+floor/i); + assert.doesNotMatch(installGuide, /OMP\s*(?:>=|≥)\s*16\.5\.2/i); + assert.doesNotMatch(installGuide, /OMP 16\.5\.2\+/i); + assert.doesNotMatch( + installGuide, + /OMP 16\.5\.2\s+(?:or newer|or later|and above)/i, + ); + assert.doesNotMatch( + installGuide, + /(?:requires?|minimum|at least)\s+(?:version\s+)?OMP\s*16\.5\.2/i, + ); + assert.match(installGuide, /`omp plugin list`/); + assert.match(installGuide, /restart|start a new session/i); + assert.match( + installGuide, + /bootstrap[\s\S]{0,160}`brainstorming`[\s\S]{0,160}auto-trigger[\s\S]{0,160}before any code/i, + ); +}); + +test("OMP install guide rejects marketplace installation as the native path", async () => { + const installGuide = await readRepoFile(".omp/INSTALL.md"); + + assert.match( + installGuide, + /OMP marketplace installation[\s\S]{0,180}does not load `omp\.extensions` modules/i, + ); + assert.match( + installGuide, + /marketplace[\s\S]{0,220}not the native installation path for this integration/i, + ); +}); + +test("porting guide distinguishes compatibility reuse from first-class native support", async () => { + const portingGuide = await readRepoFile("docs/porting-to-a-new-harness.md"); + + assert.match( + portingGuide, + /compatibility manifests? may be consumed by child or fork harnesses/i, + ); + assert.match( + portingGuide, + /first-class support[\s\S]{0,240}native manifest or adapter contract/i, + ); + assert.match( + portingGuide, + /behavior differs[\s\S]{0,160}use and verify[\s\S]{0,160}native (?:manifest or adapter contract|boundary)/i, + ); +}); diff --git a/tests/omp/test-omp-extension.mjs b/tests/omp/test-omp-extension.mjs new file mode 100644 index 0000000000..2c872d7605 --- /dev/null +++ b/tests/omp/test-omp-extension.mjs @@ -0,0 +1,312 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); +const packageJsonPath = resolve(repoRoot, "package.json"); +const extensionPath = resolve(repoRoot, ".omp/extensions/superpowers.ts"); +const sharedBootstrapPath = resolve( + repoRoot, + "integrations/shared/bootstrap.ts", +); +const marker = "superpowers:using-superpowers bootstrap for omp"; +const registeredEvents = [ + "session_start", + "session_compact", + "context", + "agent_end", +]; + +async function loadExtension( + path = extensionPath, + exposedMessages = undefined, +) { + const handlers = new Map(); + const registrations = []; + const warnings = []; + const activity = { handlerExecutions: 0 }; + const omp = { + messages: exposedMessages, + on(event, handler) { + const recordedHandler = (...args) => { + activity.handlerExecutions += 1; + return handler(...args); + }; + registrations.push({ event, handler: recordedHandler }); + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(recordedHandler); + }, + logger: { + warn(message, metadata) { + warnings.push([message, metadata]); + }, + }, + }; + const mod = await import( + pathToFileURL(path).href + `?cachebust=${Date.now()}-${Math.random()}` + ); + mod.default(omp); + return { activity, handlers, registrations, warnings }; +} + +function firstHandler(handlers, event) { + const eventHandlers = handlers.get(event) ?? []; + assert.equal(eventHandlers.length, 1, `expected one ${event} handler`); + return eventHandlers[0]; +} + +function textOf(message) { + if (typeof message.content === "string") return message.content; + return message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +test("package.json declares only the native OMP extension and preserves Pi", async () => { + let pkg; + try { + pkg = JSON.parse(await readFile(packageJsonPath, "utf8")); + } catch (error) { + assert.fail(`package.json must be valid JSON: ${String(error)}`); + } + + assert.deepEqual(pkg.omp, { + extensions: ["./.omp/extensions/superpowers.ts"], + }); + assert.equal(Object.hasOwn(pkg.omp, "skills"), false); + assert.deepEqual(pkg.pi, { + extensions: ["./.pi/extensions/superpowers.ts"], + skills: ["./skills"], + }); +}); + +test("OMP adapter has native host types and the intended dependency direction", async () => { + const source = await readFile(extensionPath, "utf8"); + + assert.match( + source, + /import type \{[\s\S]*ContextEvent[\s\S]*ExtensionAPI[\s\S]*\} from "@oh-my-pi\/pi-coding-agent";/, + ); + assert.match( + source, + /import \{ createBootstrapController \} from "\.\.\/\.\.\/integrations\/shared\/bootstrap\.ts";/, + ); + assert.match( + source, + /createBootstrapController<\s*ContextEvent\["messages"\]\[number\]\s*>/, + ); + assert.doesNotMatch( + source, + /from\s+["'][^"']*\.pi\/extensions\/superpowers\.ts["']/, + ); + assert.doesNotMatch(source, /console\.warn/); +}); + +test("initialization only registers exactly the native OMP handlers", async () => { + const sentinelMessages = [ + { role: "user", content: "unchanged", timestamp: 1 }, + ]; + const before = structuredClone(sentinelMessages); + const { activity, handlers, registrations, warnings } = await loadExtension( + extensionPath, + sentinelMessages, + ); + + assert.deepEqual( + registrations.map(({ event }) => event), + registeredEvents, + ); + for (const event of registeredEvents) { + assert.equal((handlers.get(event) ?? []).length, 1); + } + assert.equal((handlers.get("session_before_compact") ?? []).length, 0); + assert.equal( + registrations.every(({ handler }) => typeof handler === "function"), + true, + ); + assert.equal(activity.handlerExecutions, 0); + assert.deepEqual(sentinelMessages, before); + assert.deepEqual(warnings, []); +}); + +test("native package root exposes conventional skills without a dormant resource hook", async () => { + const { handlers } = await loadExtension(); + const bootstrapSkill = await readFile( + resolve(repoRoot, "skills/using-superpowers/SKILL.md"), + "utf8", + ); + + assert.equal(handlers.has("resources_discover"), false); + assert.match(bootstrapSkill, /^---\nname: using-superpowers\n/m); +}); + +test("startup injects OMP-only guidance, deduplicates markers, and agent_end disarms", async () => { + const { handlers, warnings } = await loadExtension(); + const sessionStart = firstHandler(handlers, "session_start"); + const context = firstHandler(handlers, "context"); + const agentEnd = firstHandler(handlers, "agent_end"); + const originalMessages = [ + { role: "user", content: "Build it", timestamp: 1 }, + ]; + const before = structuredClone(originalMessages); + + assert.equal( + await context({ type: "context", messages: originalMessages }, {}), + undefined, + "context must be disarmed before startup", + ); + await sessionStart({ type: "session_start", reason: "startup" }, {}); + const result = await context( + { type: "context", messages: originalMessages }, + {}, + ); + + assert.deepEqual(originalMessages, before); + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].role, "user"); + assert.equal(result.messages[1], originalMessages[0]); + const text = textOf(result.messages[0]); + assert.match(text, new RegExp(marker)); + assert.match(text, /already loaded for this OMP session/); + assert.match( + text, + /Do not (?:try to )?reload using-superpowers|Do not try to load using-superpowers again/, + ); + assert.match(text, /## OMP tool mapping/); + assert.match(text, /native skill discovery/); + assert.match(text, /skill:\/\/\/SKILL\.md/); + assert.match(text, /\/skill:/); + for (const tool of ["read", "write", "edit", "bash", "grep", "glob"]) { + assert.match( + text, + new RegExp("lowercase built-ins[\\s\\S]*`" + tool + "`"), + ); + } + assert.match(text, /built-in lowercase `task`/); + assert.match(text, /built-in lowercase `todo`/); + assert.match(text, /legacy `TodoWrite` task tracking/); + assert.match( + text, + /never invent capitalized `Skill`, `Task`, or `TodoWrite` calls/i, + ); + assert.doesNotMatch(text, /Pi tool mapping/); + assert.doesNotMatch(text, /pi-subagents/); + assert.doesNotMatch( + text, + /subagents? (?:are|is) optional|optional subagents?/i, + ); + + const stringMarker = { + role: "user", + content: `prefix ${marker} suffix`, + timestamp: 2, + }; + assert.equal( + await context({ type: "context", messages: [stringMarker] }, {}), + undefined, + ); + const multipartMarker = { + role: "user", + content: [ + { type: "image", data: "fixture" }, + { type: "text", text: marker }, + ], + timestamp: 3, + }; + assert.equal( + await context({ type: "context", messages: [multipartMarker] }, {}), + undefined, + ); + + await agentEnd({ type: "agent_end", messages: [] }, {}); + assert.equal( + await context({ type: "context", messages: originalMessages }, {}), + undefined, + ); + assert.deepEqual(warnings, []); +}); + +test("post-compaction injection follows every leading summary", async () => { + const { handlers } = await loadExtension(); + const sessionCompact = firstHandler(handlers, "session_compact"); + const context = firstHandler(handlers, "context"); + const summaries = [ + { role: "compactionSummary", summary: "first", timestamp: 1 }, + { role: "compactionSummary", summary: "second", timestamp: 2 }, + ]; + const ordinary = { role: "user", content: "Continue", timestamp: 3 }; + + await sessionCompact( + { + type: "session_compact", + compactionEntry: {}, + fromExtension: false, + }, + {}, + ); + const result = await context( + { type: "context", messages: [...summaries, ordinary] }, + {}, + ); + + assert.equal(result.messages.length, 4); + assert.equal(result.messages[0], summaries[0]); + assert.equal(result.messages[1], summaries[1]); + assert.match(textOf(result.messages[2]), new RegExp(marker)); + assert.equal(result.messages[3], ordinary); +}); + +test("missing bootstrap fails soft and warns exactly once through omp.logger", async () => { + const tempRoot = await mkdtemp( + resolve(tmpdir(), "superpowers-omp-missing-bootstrap-"), + ); + const isolatedExtensionPath = resolve( + tempRoot, + ".omp/extensions/superpowers.ts", + ); + const isolatedSharedBootstrapPath = resolve( + tempRoot, + "integrations/shared/bootstrap.ts", + ); + + try { + await mkdir(dirname(isolatedExtensionPath), { recursive: true }); + await mkdir(dirname(isolatedSharedBootstrapPath), { recursive: true }); + await copyFile(extensionPath, isolatedExtensionPath); + await copyFile(sharedBootstrapPath, isolatedSharedBootstrapPath); + + const { handlers, warnings } = await loadExtension(isolatedExtensionPath); + const sessionStart = firstHandler(handlers, "session_start"); + const context = firstHandler(handlers, "context"); + const messages = [{ role: "user", content: "Continue", timestamp: 1 }]; + await sessionStart({ type: "session_start", reason: "startup" }, {}); + + assert.equal(await context({ type: "context", messages }, {}), undefined); + assert.equal(await context({ type: "context", messages }, {}), undefined); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], "Superpowers bootstrap unavailable"); + assert.deepEqual( + { + level: warnings[0][1].level, + code: warnings[0][1].code, + harness: warnings[0][1].harness, + path: warnings[0][1].path, + }, + { + level: "warning", + code: "bootstrap-read-failed", + harness: "omp", + path: resolve(tempRoot, "skills/using-superpowers/SKILL.md"), + }, + ); + assert.equal(typeof warnings[0][1].error, "string"); + assert.ok(warnings[0][1].error.length > 0); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/pi/test-pi-extension.mjs b/tests/pi/test-pi-extension.mjs index d7af9cb2ae..81ad337fdd 100644 --- a/tests/pi/test-pi-extension.mjs +++ b/tests/pi/test-pi-extension.mjs @@ -1,137 +1,352 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, '../..'); -const packageJsonPath = resolve(repoRoot, 'package.json'); -const extensionPath = resolve(repoRoot, '.pi/extensions/superpowers.ts'); -const piToolsPath = resolve(repoRoot, 'skills/using-superpowers/references/pi-tools.md'); +const repoRoot = resolve(__dirname, "../.."); +const packageJsonPath = resolve(repoRoot, "package.json"); +const extensionPath = resolve(repoRoot, ".pi/extensions/superpowers.ts"); +const sharedBootstrapPath = resolve( + repoRoot, + "integrations/shared/bootstrap.ts", +); +const piToolsPath = resolve( + repoRoot, + "skills/using-superpowers/references/pi-tools.md", +); async function readPackageJson() { - return JSON.parse(await readFile(packageJsonPath, 'utf8')); + try { + return JSON.parse(await readFile(packageJsonPath, "utf8")); + } catch (error) { + assert.fail(`package.json fixture must be valid JSON: ${String(error)}`); + } } -async function loadExtension() { - const handlers = new Map(); - const pi = { - on(event, handler) { - if (!handlers.has(event)) handlers.set(event, []); - handlers.get(event).push(handler); - }, - }; - const mod = await import(pathToFileURL(extensionPath).href + `?cachebust=${Date.now()}-${Math.random()}`); - mod.default(pi); - return { handlers }; +async function loadExtension(path = extensionPath) { + const handlers = new Map(); + const pi = { + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(handler); + }, + }; + const mod = await import( + pathToFileURL(path).href + `?cachebust=${Date.now()}-${Math.random()}` + ); + mod.default(pi); + return { handlers }; } function firstHandler(handlers, event) { - const eventHandlers = handlers.get(event) ?? []; - assert.equal(eventHandlers.length, 1, `expected one ${event} handler`); - return eventHandlers[0]; + const eventHandlers = handlers.get(event) ?? []; + assert.equal(eventHandlers.length, 1, `expected one ${event} handler`); + return eventHandlers[0]; } function textOf(message) { - if (typeof message.content === 'string') return message.content; - return message.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n'); + if (typeof message.content === "string") return message.content; + return message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); } -test('package.json declares a pi package with skills and extension resources', async () => { - const pkg = await readPackageJson(); +function hasWarningCode(args, expectedCode) { + return args.some((value) => { + if (value && typeof value === "object") return value.code === expectedCode; + if (typeof value !== "string") return false; - assert.equal(pkg.name, 'superpowers'); - assert.ok(pkg.keywords.includes('pi-package')); - assert.deepEqual(pkg.pi.skills, ['./skills']); - assert.deepEqual(pkg.pi.extensions, ['./.pi/extensions/superpowers.ts']); + try { + const parsed = JSON.parse(value); + return ( + parsed !== null && + typeof parsed === "object" && + parsed.code === expectedCode + ); + } catch { + return false; + } + }); +} + +test("package.json declares a pi package with skills and extension resources", async () => { + const pkg = await readPackageJson(); + + assert.equal(pkg.name, "superpowers"); + assert.ok(pkg.keywords.includes("pi-package")); + assert.deepEqual(pkg.pi, { + extensions: ["./.pi/extensions/superpowers.ts"], + skills: ["./skills"], + }); +}); + +test("pi adapter depends on shared core, never the OMP adapter", async () => { + const source = await readFile(extensionPath, "utf8"); + + assert.match( + source, + /import \{ createBootstrapController \} from "\.\.\/\.\.\/integrations\/shared\/bootstrap\.ts";/, + ); + assert.doesNotMatch(source, /from\s+["'][^"']*\.omp\//); }); -test('extension registers lifecycle hooks without pre-compaction injection', async () => { - const { handlers } = await loadExtension(); +test("extension registers lifecycle hooks without pre-compaction injection", async () => { + const { handlers } = await loadExtension(); - for (const event of ['resources_discover', 'session_start', 'session_compact', 'context', 'agent_end']) { - assert.equal((handlers.get(event) ?? []).length, 1, `missing ${event} handler`); - } - assert.equal((handlers.get('session_before_compact') ?? []).length, 0); + for (const event of [ + "resources_discover", + "session_start", + "session_compact", + "context", + "agent_end", + ]) { + assert.equal( + (handlers.get(event) ?? []).length, + 1, + `missing ${event} handler`, + ); + } + assert.equal((handlers.get("session_before_compact") ?? []).length, 0); }); -test('resources_discover contributes the bundled skills directory', async () => { - const { handlers } = await loadExtension(); - const discover = firstHandler(handlers, 'resources_discover'); +test("resources_discover contributes the bundled skills directory", async () => { + const { handlers } = await loadExtension(); + const discover = firstHandler(handlers, "resources_discover"); - const result = await discover({ type: 'resources_discover', cwd: repoRoot, reason: 'startup' }, {}); + const result = await discover( + { type: "resources_discover", cwd: repoRoot, reason: "startup" }, + {}, + ); - assert.deepEqual(result.skillPaths, [resolve(repoRoot, 'skills')]); + assert.equal(result.skillPaths.length, 1); + assert.equal(isAbsolute(result.skillPaths[0]), true); + assert.equal(result.skillPaths[0], resolve(repoRoot, "skills")); }); -test('startup context injects the bootstrap as one user message until agent_end', async () => { - const { handlers } = await loadExtension(); - const sessionStart = firstHandler(handlers, 'session_start'); - const context = firstHandler(handlers, 'context'); - const agentEnd = firstHandler(handlers, 'agent_end'); +test("startup context injects the bootstrap as one user message until agent_end", async () => { + const { handlers } = await loadExtension(); + const sessionStart = firstHandler(handlers, "session_start"); + const context = firstHandler(handlers, "context"); + const agentEnd = firstHandler(handlers, "agent_end"); + + await sessionStart({ type: "session_start", reason: "startup" }, {}); + + const originalMessages = [ + { + role: "user", + content: [{ type: "text", text: "Let us make a react todo list" }], + timestamp: 1, + }, + ]; + const result = await context( + { type: "context", messages: originalMessages }, + {}, + ); - await sessionStart({ type: 'session_start', reason: 'startup' }, {}); + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].role, "user"); + const bootstrapText = textOf(result.messages[0]); + assert.match(bootstrapText, /You have superpowers/); + assert.match(bootstrapText, /superpowers:using-superpowers bootstrap for pi/); + assert.match(bootstrapText, /already loaded for this Pi session/); + assert.match(bootstrapText, /## Pi tool mapping/); + assert.match(bootstrapText, /pi-subagents/); + assert.doesNotMatch(bootstrapText, /## OMP tool mapping/); + assert.equal(result.messages[1], originalMessages[0]); - const originalMessages = [ - { role: 'user', content: [{ type: 'text', text: 'Let us make a react todo list' }], timestamp: 1 }, - ]; - const result = await context({ type: 'context', messages: originalMessages }, {}); + const repeatedProviderRequest = await context( + { type: "context", messages: originalMessages }, + {}, + ); + assert.equal(repeatedProviderRequest.messages.length, 2); + assert.match( + textOf(repeatedProviderRequest.messages[0]), + /You have superpowers/, + ); - assert.equal(result.messages.length, 2); - assert.equal(result.messages[0].role, 'user'); - assert.match(textOf(result.messages[0]), /You have superpowers/); - assert.match(textOf(result.messages[0]), /Pi tool mapping/); - assert.equal(result.messages[1], originalMessages[0]); + const alreadyInjected = await context( + { type: "context", messages: result.messages }, + {}, + ); + assert.equal( + alreadyInjected, + undefined, + "bootstrap should not duplicate when already present", + ); - const repeatedProviderRequest = await context({ type: 'context', messages: originalMessages }, {}); - assert.equal(repeatedProviderRequest.messages.length, 2); - assert.match(textOf(repeatedProviderRequest.messages[0]), /You have superpowers/); + const stringMarker = { + role: "user", + content: "superpowers:using-superpowers bootstrap for pi", + timestamp: 2, + }; + const alreadyInjectedAsString = await context( + { type: "context", messages: [stringMarker] }, + {}, + ); + assert.equal( + alreadyInjectedAsString, + undefined, + "string marker should prevent duplicate injection", + ); - const alreadyInjected = await context({ type: 'context', messages: result.messages }, {}); - assert.equal(alreadyInjected, undefined, 'bootstrap should not duplicate when already present'); + const multipartMarker = { + role: "user", + content: [ + { type: "image", data: "fixture" }, + { type: "text", text: "superpowers:using-superpowers bootstrap for pi" }, + ], + timestamp: 3, + }; + const alreadyInjectedAsMultipart = await context( + { type: "context", messages: [multipartMarker] }, + {}, + ); + assert.equal( + alreadyInjectedAsMultipart, + undefined, + "multipart marker should prevent duplicate injection", + ); - await agentEnd({ type: 'agent_end', messages: [] }, {}); - const afterEnd = await context({ type: 'context', messages: originalMessages }, {}); - assert.equal(afterEnd, undefined, 'startup bootstrap should clear after agent_end'); + await agentEnd({ type: "agent_end", messages: [] }, {}); + const afterEnd = await context( + { type: "context", messages: originalMessages }, + {}, + ); + assert.equal( + afterEnd, + undefined, + "startup bootstrap should clear after agent_end", + ); }); -test('session_compact injects bootstrap after compaction summaries, not before compaction', async () => { - const { handlers } = await loadExtension(); - const sessionCompact = firstHandler(handlers, 'session_compact'); - const context = firstHandler(handlers, 'context'); +test("session_compact injects bootstrap after compaction summaries, not before compaction", async () => { + const { handlers } = await loadExtension(); + const sessionCompact = firstHandler(handlers, "session_compact"); + const context = firstHandler(handlers, "context"); - await sessionCompact({ type: 'session_compact', compactionEntry: {}, fromExtension: false }, {}); + await sessionCompact( + { type: "session_compact", compactionEntry: {}, fromExtension: false }, + {}, + ); - const summary = { role: 'compactionSummary', summary: 'Prior work summary', tokensBefore: 123, timestamp: 1 }; - const user = { role: 'user', content: [{ type: 'text', text: 'Continue' }], timestamp: 2 }; - const result = await context({ type: 'context', messages: [summary, user] }, {}); + const firstSummary = { + role: "compactionSummary", + summary: "Prior work summary", + tokensBefore: 123, + timestamp: 1, + }; + const secondSummary = { + role: "compactionSummary", + summary: "Earlier work summary", + tokensBefore: 456, + timestamp: 2, + }; + const user = { + role: "user", + content: [{ type: "text", text: "Continue" }], + timestamp: 3, + }; + const result = await context( + { type: "context", messages: [firstSummary, secondSummary, user] }, + {}, + ); - assert.equal(result.messages.length, 3); - assert.equal(result.messages[0], summary); - assert.equal(result.messages[1].role, 'user'); - assert.match(textOf(result.messages[1]), /You have superpowers/); - assert.equal(result.messages[2], user); + assert.equal(result.messages.length, 4); + assert.equal(result.messages[0], firstSummary); + assert.equal(result.messages[1], secondSummary); + assert.equal(result.messages[2].role, "user"); + assert.match(textOf(result.messages[2]), /You have superpowers/); + assert.equal(result.messages[3], user); }); -test('pi tools reference documents pi-specific mappings', async () => { - assert.equal(existsSync(piToolsPath), true, 'pi-tools.md should exist'); - const text = await readFile(piToolsPath, 'utf8'); - - // Assert against the mapping-table rows only. The surrounding prose mentions - // these same tokens, so matching the whole file would still pass if the table - // were deleted — the exact regression this test exists to catch. - const rows = text.split('\n').filter((line) => line.startsWith('|')); - assert.ok( - rows.some((row) => /subagent/i.test(row)), - 'mapping table documents subagent dispatch', - ); - assert.ok( - rows.some((row) => /todo|task/i.test(row)), - 'mapping table documents task tracking', - ); +test("pi tools reference documents pi-specific mappings", async () => { + assert.equal(existsSync(piToolsPath), true, "pi-tools.md should exist"); + const text = await readFile(piToolsPath, "utf8"); + + const rows = text.split("\n").filter((line) => line.startsWith("|")); + assert.ok( + rows.some((row) => /subagent/i.test(row)), + "mapping table documents subagent dispatch", + ); + assert.ok( + rows.some((row) => /todo|task/i.test(row)), + "mapping table documents task tracking", + ); +}); + +test("missing bootstrap skips injection and reports one structured warning", async () => { + assert.equal( + hasWarningCode( + [{ code: "bootstrap-read-failed" }], + "bootstrap-read-failed", + ), + true, + ); + assert.equal( + hasWarningCode( + ['{"code":"bootstrap-read-failed"}'], + "bootstrap-read-failed", + ), + true, + ); + assert.equal( + hasWarningCode(["bootstrap-read-failed"], "bootstrap-read-failed"), + false, + ); + + const tempRoot = await mkdtemp( + resolve(tmpdir(), "superpowers-pi-missing-bootstrap-"), + ); + const isolatedExtensionPath = resolve( + tempRoot, + ".pi/extensions/superpowers.ts", + ); + const isolatedSharedBootstrapPath = resolve( + tempRoot, + "integrations/shared/bootstrap.ts", + ); + const warnings = []; + const originalWarn = console.warn; + + try { + await mkdir(dirname(isolatedExtensionPath), { recursive: true }); + await mkdir(dirname(isolatedSharedBootstrapPath), { recursive: true }); + await copyFile(extensionPath, isolatedExtensionPath); + await copyFile(sharedBootstrapPath, isolatedSharedBootstrapPath); + console.warn = (...args) => warnings.push(args); + + const { handlers } = await loadExtension(isolatedExtensionPath); + const sessionStart = firstHandler(handlers, "session_start"); + const context = firstHandler(handlers, "context"); + await sessionStart({ type: "session_start", reason: "startup" }, {}); + + const messages = [{ role: "user", content: "Continue", timestamp: 1 }]; + const firstResult = await context({ type: "context", messages }, {}); + const secondResult = await context({ type: "context", messages }, {}); + + assert.equal( + firstResult, + undefined, + "missing bootstrap must not inject a message", + ); + assert.equal( + secondResult, + undefined, + "cached read failure must continue without injection", + ); + assert.equal( + warnings.length, + 1, + "missing bootstrap should report exactly once", + ); + assert.equal(hasWarningCode(warnings[0], "bootstrap-read-failed"), true); + } finally { + console.warn = originalWarn; + await rm(tempRoot, { recursive: true, force: true }); + } });