Compare commits
5 Commits
100262b94f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a65f68ac | |||
| 1aec74216a | |||
| 807704aabe | |||
| 3cc7c0469b | |||
| c870efa15a |
@@ -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
|
||||
|
||||
51
.gitea/workflows/publish.yml
Normal file
51
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: publish
|
||||
|
||||
# Publish this package and its omp port to the npm registry on version tags.
|
||||
#
|
||||
# Prerequisites on npmjs.com / git.freno.me:
|
||||
# - an npm automation token (publish scope) stored as the repo secret
|
||||
# NPM_TOKEN (consumed via actions/setup-node registry auth)
|
||||
# - the scoped package names claimed on npm (@mikefreno/<name> and
|
||||
# @mikefreno/omp-<name>)
|
||||
#
|
||||
# Both packages ship TypeScript sources — nothing is built — so lifecycle
|
||||
# scripts (prepublishOnly tsc) are skipped: the port workflow already
|
||||
# typechecks the generated port against the pinned @oh-my-pi SDK, and the pi
|
||||
# package typechecks against the global pi SDK that CI does not install.
|
||||
#
|
||||
# Manual publish: Actions tab → Run workflow (workflow_dispatch).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup node (npm registry auth)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish pi package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --ignore-scripts
|
||||
|
||||
- name: Port to omp
|
||||
run: bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port"
|
||||
|
||||
- name: Publish omp package
|
||||
working-directory: ${{ runner.temp }}/omp-port
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --ignore-scripts
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -36,7 +36,7 @@ is unavailable).
|
||||
|
||||
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
||||
prompts, reload auto-resume via `session_start`, progress message renderer
|
||||
prompts, progress message renderer
|
||||
- `src/` — all logic modules:
|
||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||
YAML formats), dependency + parallel-group + timeout parsing,
|
||||
@@ -69,18 +69,16 @@ is unavailable).
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; drives
|
||||
auto-resume after a session reload
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; snapshots the
|
||||
mode + loop options so `/ralpi-resume` can continue non-interactively
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||
`saveReviews` is on)
|
||||
- `.ralpi/sessions/` — agent session JSONL files per task (persisted so an
|
||||
interrupted task can be resumed from its prior conversation)
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
- `.ralpi/config.yaml` — project-level config (optional)
|
||||
|
||||
There is no `.ralpi/sessions/` directory anymore — full task output is shown
|
||||
inline via expandable `ralpi-progress` chat messages, and review output is
|
||||
persisted under `.ralpi/reviews/`.
|
||||
|
||||
## Task ID convention
|
||||
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
||||
@@ -93,8 +91,10 @@ use raw numeric IDs.
|
||||
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||
error suggesting the dash commands
|
||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
||||
exists for the file; otherwise prompts for execution mode + loop options)
|
||||
- `/ralpi-run [task-file]` — run tasks (always prompts for execution mode +
|
||||
loop options; a cancelled loop for the file is continued from its progress
|
||||
with the newly chosen settings — it never silently resumes with the old
|
||||
loop's settings, only `/ralpi-resume` does that)
|
||||
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||
with `expandPromptTemplates: false`, so the extension does its own
|
||||
|
||||
283
index.ts
283
index.ts
@@ -306,9 +306,10 @@ async function executePlanBatches(
|
||||
);
|
||||
}
|
||||
|
||||
// Write loop-active marker so a session reload can detect an interrupted
|
||||
// loop and resume it (in-process agent sessions die on reload — the marker
|
||||
// + progress.json in_progress tasks are the signal to re-run them).
|
||||
// Write the loop-active marker so an interrupted loop can be resumed
|
||||
// non-interactively via /ralpi-resume: it snapshots the execution mode and
|
||||
// loop options (autoCommit/autoReview/saveReviews) that /ralpi-resume
|
||||
// would otherwise re-prompt for.
|
||||
if (projectDir) {
|
||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||
writeLoopActive(projectDir, {
|
||||
@@ -764,237 +765,6 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Reload detection: resume interrupted loops when session reloads ──
|
||||
//
|
||||
// ralpi runs task agent sessions in-process (createAgentSession), so they
|
||||
// do NOT survive a /reload. When the new session starts, this handler
|
||||
// reads the persisted loop-active marker + progress.json: if any task is
|
||||
// still `in_progress`, the loop was interrupted mid-task and we resume it
|
||||
// (resetting those tasks to pending so the DAG re-schedules them), using
|
||||
// the mode + loop options snapshotted in loop-active.json so the resume is
|
||||
// non-interactive.
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
if (event.reason !== "reload") return;
|
||||
|
||||
// Find the ralpi project directory
|
||||
const projectDir = findRalpiDir(ctx.cwd);
|
||||
if (!projectDir) return;
|
||||
|
||||
// Check if a task execution loop was active before the reload
|
||||
const loopState = readLoopActive(projectDir);
|
||||
if (!loopState) return;
|
||||
|
||||
// The auto-resume path has no CLI flag, so the gitignore guard is
|
||||
// always on: keep `.ralpi/` out of the user's repo on reload too.
|
||||
ensureRalpiIgnored(projectDir);
|
||||
|
||||
// Load progress state
|
||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||
|
||||
/** Re-read progress from disk. */
|
||||
const readTasks = (): Record<string, { status: string }> | null => {
|
||||
try {
|
||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ralpi agent sessions run in-process (createAgentSession), so they do
|
||||
// NOT survive a session reload. Any task left `in_progress` is therefore
|
||||
// stalled — its agent died with the previous session. Detect that state
|
||||
// and actively resume the loop instead of passively polling (which would
|
||||
// spin forever waiting for a dead task to complete).
|
||||
const initialTasks = readTasks();
|
||||
if (initialTasks) {
|
||||
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status === "in_progress" ? [id] : [],
|
||||
);
|
||||
|
||||
// Build the sendProgress wrapper so resumed task messages render the
|
||||
// same expandable tool-call tree as an interactive run. Defined before
|
||||
// the finalize path below so it can report self-healed merges.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: {
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
},
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: "progress",
|
||||
toolCalls: meta?.toolCalls,
|
||||
reviewText: meta?.reviewText,
|
||||
reviewPath: meta?.reviewPath,
|
||||
reviewResult: meta?.reviewResult,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (inProgressIds.length === 0) {
|
||||
// Nothing was mid-flight — the loop either finished cleanly between
|
||||
// the reload landing and this handler running, or was stopped
|
||||
// between tasks. Either way, committed worktree branches from an
|
||||
// interrupted loop may still be unmerged (e.g. a prior resume
|
||||
// attempt reset tasks to pending before it was itself interrupted).
|
||||
// Finalize those first so committed code lands in the workspace,
|
||||
// persist the state to progress.json, update the PRD file, THEN
|
||||
// clean up the stale marker.
|
||||
try {
|
||||
const config = loadConfig(projectDir);
|
||||
// Clear any half-done merge left by an interrupted
|
||||
// conflict-resolution session (it would block every merge below).
|
||||
abortMerge(projectDir);
|
||||
const allIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
||||
);
|
||||
const fin = finalizeCommittedWorktrees(
|
||||
projectDir,
|
||||
config.paths.stateDir,
|
||||
loopState.prdKey,
|
||||
allIds,
|
||||
);
|
||||
// Persist finalized tasks to progress.json + PRD file so the
|
||||
// state is correct for subsequent /ralpi resume calls.
|
||||
const stateDir = config.paths.stateDir;
|
||||
const progressPath = path.join(projectDir, stateDir, "progress.json");
|
||||
// Batch-update progress.json and PRD file for all finalized tasks
|
||||
if (fin.finalized.length > 0) {
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of fin.finalized) {
|
||||
sendProgress?.(
|
||||
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
||||
);
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "completed";
|
||||
tasks[id].completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "completed");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
// ── Handle conflicted tasks ──
|
||||
// Same logic as resumeLoop: reset to pending so the DAG can
|
||||
// re-schedule them, keep the worktree for in-place re-run.
|
||||
const conflictIds = Object.keys(fin.conflicts);
|
||||
if (conflictIds.length > 0) {
|
||||
const detail = conflictIds
|
||||
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
||||
.join("; ");
|
||||
// Batch-reset all conflicted tasks to pending, then write once
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of conflictIds) {
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "pending";
|
||||
delete tasks[id].startedAt;
|
||||
delete tasks[id].error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "pending");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — the marker is removed either way; the worktrees
|
||||
// stay on disk for a manual /ralpi-resume.
|
||||
}
|
||||
ctx.ui.notify(
|
||||
"ralpi loop has no in-progress task to resume — marking complete.",
|
||||
"info",
|
||||
);
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Load config from the project directory so model + thinking level
|
||||
// resolve the same way the interactive command handler does.
|
||||
const config = loadConfig(projectDir);
|
||||
|
||||
try {
|
||||
await resumeLoop(
|
||||
ctx,
|
||||
loopState.taskFile,
|
||||
projectDir,
|
||||
loopState.prdKey,
|
||||
sendProgress,
|
||||
config.model ?? ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
{
|
||||
mode: loopState.mode,
|
||||
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
|
||||
autoReview: loopState.autoReview ?? config.execution.autoReview,
|
||||
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
|
||||
skipFinalStatus: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
|
||||
// Leave loop-active.json in place so the user can retry via
|
||||
// /ralpi resume after addressing the underlying error.
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi", {
|
||||
description:
|
||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||
@@ -1118,38 +888,15 @@ async function handleRun(
|
||||
const noGitignore = stripNoGitignore(args);
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||
|
||||
// If targeting a specific task file and there's existing progress for it,
|
||||
// auto-resume instead of starting fresh
|
||||
// A cancelled loop for this file is continued below AFTER re-prompting for
|
||||
// settings — /ralpi-run never silently resumes with the old loop's
|
||||
// settings (only /ralpi-resume does that). isResume below keeps the
|
||||
// interrupted task's worktree so it continues rather than restarts.
|
||||
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
||||
if (existingProgress) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
args.slice(0, 1),
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
|
||||
// No existing progress for this task — check for any progress at all
|
||||
// Resolve the project root from any existing progress so running from a
|
||||
// subdirectory still targets the loop's project.
|
||||
const found = findProgressFile(ctx.cwd);
|
||||
if (found && !args[0]) {
|
||||
// Offer to resume instead of starting fresh
|
||||
const shouldResume = await ctx.ui.select(
|
||||
"Found existing ralpi progress. Resume?",
|
||||
["Yes, resume", "No, start fresh"],
|
||||
);
|
||||
|
||||
if (shouldResume?.startsWith("Yes")) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
[],
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||
@@ -1196,6 +943,7 @@ async function handleRun(
|
||||
mode,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
|
||||
);
|
||||
|
||||
const state = progress.getState();
|
||||
@@ -1217,11 +965,10 @@ async function handleRun(
|
||||
|
||||
/**
|
||||
* Resume core: given a resolved task file, project dir, and PRD key,
|
||||
* build the remaining plan and execute it. Used by both the explicit
|
||||
* `/ralpi resume` command and the auto-resume on session reload.
|
||||
* build the remaining plan and execute it.
|
||||
*
|
||||
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
||||
* passed to skip interactive prompts — this is how a reload resumes
|
||||
* passed to skip interactive prompts — this is how /ralpi-resume resumes
|
||||
* non-interactively using the snapshot stored in loop-active.json.
|
||||
* When omitted, the user is prompted as usual.
|
||||
*/
|
||||
@@ -1477,8 +1224,8 @@ async function handleResume(
|
||||
|
||||
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||
// persisted when the loop started, so an interrupted loop resumes
|
||||
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
||||
// back to interactive prompts when no snapshot is present.
|
||||
// non-interactively. Only fall back to interactive prompts when no
|
||||
// snapshot is present.
|
||||
const snapshot = readLoopActive(projectDir);
|
||||
const loopOpts = (() => {
|
||||
if (
|
||||
|
||||
297
port-to-omp.mjs
297
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";
|
||||
|
||||
@@ -35,6 +42,7 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
|
||||
|
||||
const SKIP = new Set([
|
||||
"port-to-omp.mjs",
|
||||
"release-tag.sh",
|
||||
".gitea",
|
||||
".github",
|
||||
"node_modules",
|
||||
@@ -50,11 +58,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 +122,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 +135,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 +149,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 +160,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 +182,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 +210,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 +241,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 +253,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);
|
||||
@@ -217,76 +311,75 @@ omp install @mikefreno/omp-ralpi
|
||||
This is the omp port of [Mike/ralpi](https://git.freno.me/Mike/ralpi), regenerated automatically from the source repo. See the source repo for full documentation.
|
||||
`;
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
|
||||
};
|
||||
// publish workflow emitted into the port repo so the omp package can be
|
||||
// released independently (tag push or manual dispatch on the omp repo).
|
||||
const PORT_PUBLISH_WORKFLOW = "name: publish\n\n# Publish this omp port package to the npm registry on version tags.\n#\n# Prerequisites:\n# - npm automation token (publish scope) stored as the repo secret NPM_TOKEN\n# - package name claimed on npm (@mikefreno/omp-<name>)\n#\n# Manual publish: Actions tab → Run workflow (workflow_dispatch).\n\non:\n push:\n tags: ['v*']\n workflow_dispatch:\n\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup node (npm registry auth)\n uses: actions/setup-node@v4\n with:\n node-version: '22'\n registry-url: 'https://registry.npmjs.org'\n\n - name: Publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n run: npm publish --access public --ignore-scripts\n";
|
||||
|
||||
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 // 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",
|
||||
},
|
||||
],
|
||||
"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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -322,6 +415,10 @@ function portExtension() {
|
||||
writeFileSync(p, text);
|
||||
}
|
||||
}
|
||||
const wfDir = join(dstDir, ".gitea", "workflows");
|
||||
mkdirSync(wfDir, { recursive: true });
|
||||
writeFileSync(join(wfDir, "publish.yml"), PORT_PUBLISH_WORKFLOW);
|
||||
|
||||
console.log("== bun install (regenerates bun.lock + node_modules)");
|
||||
execSync("bun install", { cwd: dstDir, stdio: "inherit" });
|
||||
console.log("== done");
|
||||
|
||||
196
scripts/release-tag.sh
Executable file
196
scripts/release-tag.sh
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/bin/bash
|
||||
|
||||
# release-tag.sh — version bump, commit, tag, and push for this pi extension.
|
||||
#
|
||||
# Mirrors the release flow from PodTui's scripts/release-tag.sh, adapted for
|
||||
# the pi extension repos: the version lives in package.json, and the omp port
|
||||
# derives its version from it during regeneration.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release-tag.sh interactive release
|
||||
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
|
||||
#
|
||||
# After this script runs:
|
||||
# - pushing master triggers the port-to-omp workflow, which regenerates and
|
||||
# pushes the omp port (picking up the new version)
|
||||
# - pushing the v* tag triggers the publish workflow, which npm-publishes
|
||||
# this package and its omp port together
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h | --help)
|
||||
echo "usage: $0 [--dry-run]"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -d .git ] && [ ! -f .git ]; then
|
||||
echo -e "${RED}✗ not a git repository: $PROJECT_ROOT${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo -e "${RED}✗ working tree is dirty — commit or stash before releasing${NC}"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAME=$(python3 -c "import json;print(json.load(open('package.json'))['name'])")
|
||||
OMP_NAME=$(python3 -c "import json,re;n=json.load(open('package.json'))['name'];print(re.sub(r'^@mikefreno/(.+)$', r'@mikefreno/omp-\1', n))")
|
||||
CURRENT_VERSION=$(python3 -c "import json;print(json.load(open('package.json'))['version'])")
|
||||
|
||||
echo -e "${CYAN}Package:${NC} ${GREEN}${NAME}${NC}"
|
||||
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
|
||||
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
|
||||
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
|
||||
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
|
||||
|
||||
echo -e "${CYAN}Select version bump type:${NC}"
|
||||
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR + 1)).0.0"
|
||||
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR + 1)).0"
|
||||
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo " 4) Custom version"
|
||||
echo " 5) Cancel"
|
||||
echo ""
|
||||
read -p "Enter choice (1-5): " -n 1 -r CHOICE
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
case $CHOICE in
|
||||
1) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
|
||||
2) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;;
|
||||
3) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||
4)
|
||||
read -p "Enter new version (e.g. 0.6.1): " -r NEW_VERSION
|
||||
echo ""
|
||||
;;
|
||||
5)
|
||||
echo -e "${YELLOW}Cancelled.${NC}"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}✗ invalid choice${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo -e "${RED}✗ version must be MAJOR.MINOR.PATCH (got: ${NEW_VERSION})${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
|
||||
echo -e "${RED}✗ tag v${NEW_VERSION} already exists${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will:${NC}"
|
||||
echo " 1. Set package.json → \"version\": \"${NEW_VERSION}\""
|
||||
echo " 2. Commit the bump"
|
||||
echo " 3. Create annotated tag v${NEW_VERSION}"
|
||||
echo " 4. Push master and the tag to every remote"
|
||||
REMOTES=$(git remote)
|
||||
for r in $REMOTES; do
|
||||
echo " → $r"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: master push triggers the port-to-omp workflow; the v* tag${NC}"
|
||||
echo -e "push triggers the npm publish workflow (this package + omp port).${NC}"
|
||||
echo ""
|
||||
read -p "Proceed? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Aborted.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo -e "${BLUE}── dry run: no changes made ──${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Apply the bump ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${CYAN}[1/4]${NC} Updating package.json..."
|
||||
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"${NEW_VERSION}\"/" package.json
|
||||
rm -f package.json.bak
|
||||
echo -e "${GREEN}✓ package.json updated${NC}"
|
||||
|
||||
if git diff --quiet -- package.json; then
|
||||
echo -e "${RED}✗ package.json did not change${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[2/4]${NC} Committing..."
|
||||
git add package.json
|
||||
git commit -m "chore: bump version to v${NEW_VERSION}"
|
||||
echo -e "${GREEN}✓ committed${NC}"
|
||||
|
||||
echo -e "${CYAN}[3/4]${NC} Tagging..."
|
||||
git tag -a "v${NEW_VERSION}" -m "${NAME} v${NEW_VERSION}"
|
||||
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}[4/4]${NC} Pushing..."
|
||||
FAILED=""
|
||||
for r in $REMOTES; do
|
||||
echo -e " → ${BLUE}${r}${NC} master..."
|
||||
if git push "$r" master >/dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✓ master pushed${NC}"
|
||||
else
|
||||
echo -e " ${RED}✗ master push failed${NC}"
|
||||
FAILED="$FAILED $r"
|
||||
continue
|
||||
fi
|
||||
echo -e " → ${BLUE}${r}${NC} v${NEW_VERSION}..."
|
||||
if git push "$r" "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✓ tag pushed${NC}"
|
||||
else
|
||||
echo -e " ${RED}✗ tag push failed${NC}"
|
||||
FAILED="$FAILED $r"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo -e "${RED}✗ push failed for remote(s):${FAILED}${NC}"
|
||||
echo -e "${YELLOW}Re-run: git push <remote> master && git push <remote> v${NEW_VERSION}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}✓ ${NAME} v${NEW_VERSION} released${NC}"
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION} → ${GREEN}${NEW_VERSION}${NC}"
|
||||
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||
echo " 1. Gitea Actions port-to-omp regenerates and pushes the omp port"
|
||||
echo " (which now carries version ${NEW_VERSION})"
|
||||
echo " 2. Gitea Actions publish npm-publishes ${NAME} and"
|
||||
echo " ${OMP_NAME} to the npm registry"
|
||||
echo ""
|
||||
echo -e "${CYAN}Local port (optional):${NC} run 'bun port-to-omp.mjs' in this repo, or"
|
||||
echo "pull in ~/.omp once CI has pushed."
|
||||
Reference in New Issue
Block a user