From c870efa15a3b670aae02f226e82a60271106e9f1 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Aug 2026 14:13:14 -0400 Subject: [PATCH] port fix --- .gitea/workflows/port-to-omp.yml | 40 ++++- port-to-omp.mjs | 299 ++++++++++++++++++++----------- 2 files changed, 232 insertions(+), 107 deletions(-) diff --git a/.gitea/workflows/port-to-omp.yml b/.gitea/workflows/port-to-omp.yml index 9b87649..829b375 100644 --- a/.gitea/workflows/port-to-omp.yml +++ b/.gitea/workflows/port-to-omp.yml @@ -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 diff --git a/port-to-omp.mjs b/port-to-omp.mjs index ea51b64..a19e993 100644 --- a/port-to-omp.mjs +++ b/port-to-omp.mjs @@ -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] ?? ""}\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,27 +252,45 @@ 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"], - transform(p) { - p.name = pkgName(p.name); - p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)]; - delete p.scripts.prepublishOnly; - p.engines.bun = ">=1.3.14"; - p.omp = p.pi; - delete p.pi; - delete p.omp.prompts; - delete p.peerDependencies; - p.devDependencies = { - "@oh-my-pi/pi-coding-agent": OMP_SDK, - "@oh-my-pi/pi-tui": OMP_SDK, - ...p.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)]; + delete p.scripts.prepublishOnly; + p.engines.bun = ">=1.3.14"; + p.omp = p.pi; + delete p.pi; + delete p.omp.prompts; + delete p.peerDependencies; + p.devDependencies = { + "@oh-my-pi/pi-coding-agent": OMP_SDK, + "@oh-my-pi/pi-tui": OMP_SDK, + ...p.devDependencies, + }; + }, +}; 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); @@ -218,75 +311,81 @@ 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";', - 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,", - 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 });", - 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";', - 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.', - 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";', - 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));', - 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}', - label: "vendor stripFrontmatter", - }, - ], - - }; + "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";', + 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,", + 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 // 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 `/.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 `/.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";', + 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.', + 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";', + 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));', + 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}', + label: "vendor stripFrontmatter", + }, + ], +}; // ─── main ───────────────────────────────────────────────────────────────────