port fix
All checks were successful
port-to-omp / verify (push) Successful in 7s
port-to-omp / publish (push) Successful in 6s

This commit is contained in:
2026-08-12 14:13:14 -04:00
parent 100262b94f
commit c870efa15a
2 changed files with 232 additions and 107 deletions

View File

@@ -1,7 +1,17 @@
name: port-to-omp
# Regenerate the omp port of ralpi from this repo and push it to
# Mike/omp-ralpi.
# Keep the omp port of ralpi in lockstep with this repo.
#
# Two jobs:
# - verify: port to a scratch dir, then typecheck + test the ported tree on
# EVERY push and PR. Source drift — a base change that breaks a port op —
# fails here, on the branch that introduced it, before it reaches master.
# (The ported tree contains the full test suite, so this also gives the
# base repo its CI test coverage.)
# - publish: regenerate the omp port into Mike/omp-ralpi and push it, but
# only on master, and only after verify passed — a broken port can never
# ship. The port commit lands in the omp repo, never here, so this
# workflow cannot re-trigger itself.
#
# Prerequisites on git.freno.me:
# - an access token with write:repository scope, stored as the repo secret
@@ -10,16 +20,32 @@ name: port-to-omp
# - the omp repo must exist (Mike/omp-ralpi)
#
# Manual run: Actions tab → Run workflow (workflow_dispatch), or push.
# Safe by construction: the port commit lands in the omp repo, never here, so
# this workflow cannot re-trigger itself.
on:
push:
branches: [master]
workflow_dispatch:
pull_request:
jobs:
port:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Port to scratch dir (drift check)
run: |
set -euo pipefail
bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port-check"
cd "$RUNNER_TEMP/omp-port-check"
bun run typecheck
bun test
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@@ -25,7 +25,14 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { join, relative, resolve, isAbsolute, dirname, basename } from "node:path";
import {
join,
relative,
resolve,
isAbsolute,
dirname,
basename,
} from "node:path";
import { execSync } from "node:child_process";
import { homedir } from "node:os";
@@ -50,11 +57,62 @@ const OMP_SDK = "17.2.12";
// ─── helpers ────────────────────────────────────────────────────────────────
/** Appended to op failures: the base repo is the source of truth, so a failed
* assertion means the source drifted and the op needs updating — never a
* reason to weaken the assertion. */
const DRIFT_HINT =
"\n\nBase repo drifted since this op was written — update the op (from/to) in" +
" port-to-omp.mjs. The verify CI job runs this script on every push, so this" +
" should surface on the branch that introduced the drift.";
/** Point at the first divergence between an expected op target and the actual
* file text, with a little surrounding context. */
function driftHint(src, from) {
const fromLines = from.split("\n");
const srcLines = src.split("\n");
const needle = fromLines[0].slice(0, 60);
// indexOf failed, so no full match exists. Anchor on the occurrence of the
// op's opening line that shares the LONGEST consecutive run with the
// expected text — a bare ` try {` can match unrelated blocks.
let anchor = -1;
let bestRun = -1;
for (let i = 0; i < srcLines.length; i++) {
if (!srcLines[i].includes(needle)) continue;
let run = 0;
while (run < fromLines.length && fromLines[run] === srcLines[i + run]) run++;
if (run > bestRun) {
bestRun = run;
anchor = i;
}
}
if (anchor === -1) {
return `\n\ncould not locate the op's opening line anywhere in the file:\n ${fromLines[0]}`;
}
const ctx = 2;
const i = Math.min(bestRun, fromLines.length - 1);
const before = srcLines
.slice(Math.max(0, anchor + i - ctx), anchor + i)
.map((l) => ` ${l}`);
const after = srcLines
.slice(anchor + i + 1, anchor + i + 1 + ctx)
.map((l) => ` ${l}`);
return (
`\n\nop matches at file line ${anchor + 1} for ${bestRun} line(s), then diverges at expected line ${i + 1}:\n` +
` expected: ${fromLines[i]}\n` +
` actual : ${srcLines[anchor + i] ?? "<end of file>"}\n` +
(before.length ? `\nactual context before:\n${before.join("\n")}\n` : "") +
(after.length ? `\nactual context after:\n${after.join("\n")}` : "")
);
}
function assertEdit(src, from, to, file, label = "") {
const idx = src.indexOf(from);
if (idx === -1) {
throw new Error(
`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`,
`[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
src,
from,
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
);
}
return src.slice(0, idx) + to + src.slice(idx + from.length);
@@ -63,7 +121,12 @@ function assertEdit(src, from, to, file, label = "") {
function replaceAll(src, from, to, file, label) {
const parts = src.split(from);
if (parts.length === 1) {
throw new Error(`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`);
throw new Error(
`[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
src,
from,
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
);
}
return parts.join(to);
}
@@ -71,7 +134,9 @@ function replaceAll(src, from, to, file, label) {
function reEdit(src, re, to, file, label) {
const out = src.replace(re, to);
if (out === src) {
throw new Error(`[${file}] regex matched nothing (${label}): ${re}`);
throw new Error(
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
);
}
return out;
}
@@ -83,7 +148,9 @@ function reAll(src, re, to, file, label) {
return typeof to === "function" ? to(...args) : to;
});
if (count === 0) {
throw new Error(`[${file}] regex matched nothing (${label}): ${re}`);
throw new Error(
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
);
}
return out;
}
@@ -92,9 +159,13 @@ function reAll(src, re, to, file, label) {
function applyOps(src, ops, file) {
for (const op of ops) {
if ("from" in op) {
src = op.all ? replaceAll(src, op.from, op.to, file, op.label) : assertEdit(src, op.from, op.to, file, op.label);
src = op.all
? replaceAll(src, op.from, op.to, file, op.label)
: assertEdit(src, op.from, op.to, file, op.label);
} else {
src = op.all ? reAll(src, op.re, op.to, file, op.label) : reEdit(src, op.re, op.to, file, op.label);
src = op.all
? reAll(src, op.re, op.to, file, op.label)
: reEdit(src, op.re, op.to, file, op.label);
}
}
return src;
@@ -110,7 +181,8 @@ function mirrorTree(srcDir, dstDir) {
// drop stale files in dst that no longer exist in src; keep .git* intact
for (const rel of walk(dstDir)) {
if (rel.startsWith(".git")) continue;
if (!existsSync(join(srcDir, rel))) rmSync(join(dstDir, rel), { force: true, recursive: true });
if (!existsSync(join(srcDir, rel)))
rmSync(join(dstDir, rel), { force: true, recursive: true });
}
}
@@ -137,15 +209,17 @@ function assertDstOutsideSrc(srcDir, dstDir) {
const rel = relative(real(srcDir), real(dstDir));
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
throw new Error(
`refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}`
`refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}`,
);
}
}function walk(dir) {
}
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue;
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(p).map((r) => join(entry.name, r)));
if (entry.isDirectory())
out.push(...walk(p).map((r) => join(entry.name, r)));
else out.push(entry.name);
}
return out;
@@ -166,7 +240,8 @@ function rewriteSpecifiers(src) {
// ─── package.json transforms ────────────────────────────────────────────────
function pkgName(piName) {
if (piName.startsWith("@mikefreno/")) return piName.replace(/^@mikefreno\//, "@mikefreno/omp-");
if (piName.startsWith("@mikefreno/"))
return piName.replace(/^@mikefreno\//, "@mikefreno/omp-");
return `@mikefreno/omp-${piName}`;
}
@@ -177,9 +252,25 @@ function reorder(obj, keys) {
return out;
}
const PKG_RULES = {
order: ["name", "version", "description", "keywords", "author", "license", "homepage", "repository", "bugs", "files", "scripts", "engines", "omp", "dependencies", "publishConfig", "devDependencies"],
order: [
"name",
"version",
"description",
"keywords",
"author",
"license",
"homepage",
"repository",
"bugs",
"files",
"scripts",
"engines",
"omp",
"dependencies",
"publishConfig",
"devDependencies",
],
transform(p) {
p.name = pkgName(p.name);
p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)];
@@ -197,7 +288,9 @@ const PKG_RULES = {
},
};
function transformPkg() {
const raw = JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8"));
const raw = JSON.parse(
readFileSync(join(import.meta.dir, "package.json"), "utf8"),
);
if (!raw.pi) throw new Error('expected "pi" manifest key in package.json');
const rule = PKG_RULES;
rule.transform(raw);
@@ -220,72 +313,78 @@ This is the omp port of [Mike/ralpi](https://git.freno.me/Mike/ralpi), regenerat
const FILE_RULES = {
"src/utils.ts": [
{
from:
'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";',
from: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";',
label: "import block",
},
{ from: "/** Path to the global ralpi config under the user's Pi home directory. */", to: "/** Path to the global ralpi config under the user's omp home directory. */" },
{ from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",', to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",', label: "GLOBAL_CONFIG_PATH" },
{ from: "~/.pi/ralpi/config.yaml", to: "~/.omp/ralpi/config.yaml", all: true, label: "config path mentions" },
{
from:
" /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,",
to:
" /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,",
from: "/** Path to the global ralpi config under the user's Pi home directory. */",
to: "/** Path to the global ralpi config under the user's omp home directory. */",
},
{
from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",',
to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",',
label: "GLOBAL_CONFIG_PATH",
},
{
from: "~/.pi/ralpi/config.yaml",
to: "~/.omp/ralpi/config.yaml",
all: true,
label: "config path mentions",
},
{
from: " /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,",
to: " /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,",
label: "modelRuntime signature",
},
{
from:
" try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(),\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });",
to:
" try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(cwd),\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });",
from: ' try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = SessionManager.open(resumeSessionFile, sessionDir, cwd);\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });',
to: ' try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = await SessionManager.open(resumeSessionFile, sessionDir, undefined, {\n initialCwd: cwd,\n });\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });',
label: "runAgentSession body",
},
],
"index.ts": [
{
from:
'import type {\n\tExtensionAPI,\n\tExtensionContext,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type {\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionStartEvent,\n} from "@oh-my-pi/pi-coding-agent";',
from: 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionStartEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "SessionStartEvent import",
},
{
from: '\tpi.on("session_start", async (event, ctx) => {\n\t\tif (event.reason !== "reload") return;',
to:
'\tpi.on("session_start", async (event: SessionStartEvent, ctx) => {\n\t\t// omp\'s SessionStartEvent has no reason/reload field; the in_progress-task\n\t\t// check below already scopes recovery to genuinely interrupted loops (a\n\t\t// completed loop has no in_progress tasks), so recovery runs on any start\n\t\t// where a stalled loop marker exists.',
to: '\tpi.on("session_start", async (event: SessionStartEvent, ctx) => {\n\t\t// omp\'s SessionStartEvent has no reason/reload field; the in_progress-task\n\t\t// check below already scopes recovery to genuinely interrupted loops (a\n\t\t// completed loop has no in_progress tasks), so recovery runs on any start\n\t\t// where a stalled loop marker exists.',
label: "session_start handler",
},
],
"src/executor.ts": [
{
from:
'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
from: 'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "ModelRuntime import removal",
},
{ from: "(ctx.modelRegistry as any).runtime as ModelRuntime,", to: "ctx.modelRegistry,", all: true, label: "modelRegistry pass-through" },
{ from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on", to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on", label: "retry comment" },
{
from:
'\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));',
from: "(ctx.modelRegistry as any).runtime as ModelRuntime,",
to: "ctx.modelRegistry,",
all: true,
label: "modelRegistry pass-through",
},
{
from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on",
to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on",
label: "retry comment",
},
{
from: '\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));',
to: '\t\tcase "glob":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);',
label: "tool labeler find/ls",
},
],
"src/task-manager-prompt.ts": [
{
from:
'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");',
to:
'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}',
from: 'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");',
to: 'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}',
label: "vendor stripFrontmatter",
},
],
};
// ─── main ───────────────────────────────────────────────────────────────────