fix: ralpi-plan properly loads task-manager prompt

This commit is contained in:
2026-08-02 16:43:08 -04:00
parent fc2f8a879f
commit 9f1250d1a1
3 changed files with 149 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionContext,
@@ -14,6 +15,7 @@ import {
} from "./src/dag";
import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts";
import { loadTaskManagerPrompt } from "./src/task-manager-prompt";
import { formatReflections } from "./src/reflection";
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
import type { ReviewResult } from "./src/types";
@@ -255,6 +257,23 @@ async function executePlanBatches(
projectDir?: string,
isResume?: boolean,
): Promise<void> {
// Refresh the model registry so the host reloads models.json before we
// resolve the round-robin model pool. The registry snapshot is captured at
// host startup and only reloaded here; a long-running host would otherwise
// skip providers added to models.json after it booted (e.g. "strix").
// Best-effort: a failed refresh shouldn't block execution — the pool just
// resolves against the existing snapshot.
try {
await ctx.modelRegistry?.refresh();
} catch (error) {
ctx.ui.notify(
`ralpi: model registry refresh failed — continuing with existing snapshot: ${
error instanceof Error ? error.message : String(error)
}`,
"warning",
);
}
// 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).
@@ -692,12 +711,18 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
},
});
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
pi.registerCommand("ralpi-plan", {
description: "Open the Task Manager to plan a ralpi run",
handler: async (args: string, ctx: ExtensionContext) => {
const prompt = (args || "").trim();
const message = prompt ? `@task-manager\n\n${prompt}` : "@task-manager";
pi.sendUserMessage(message);
// pi.sendUserMessage() sends with expandPromptTemplates: false, so it
// would NOT expand `/task-manager` — and `@task-manager` is an
// @-mention, not a template invocation. Load the bundled template,
// strip frontmatter, substitute $@ args ourselves, and send the
// expanded body directly.
const body = loadTaskManagerPrompt(extensionDir, args ?? "");
pi.sendUserMessage(body);
ctx.ui.notify("Opening Task Manager...", "info");
},
});

104
src/task-manager-prompt.ts Normal file
View File

@@ -0,0 +1,104 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
const TEMPLATE_REL = path.join("prompts", "task-manager.md");
/**
* Parse command arguments respecting quoted strings (bash-style).
* Ported from pi's core/prompt-templates.js so the task-manager template
* receives the same arg-splitting a real `/task-manager` invocation would.
*/
function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) args.push(current);
return args;
}
/**
* Substitute argument placeholders in template content.
* Faithful port of pi's substituteArgs (core/prompt-templates.js):
* - $1, $2, ... positional args
* - $@ / $ARGUMENTS all args joined
* - ${N:-default} positional N with default when missing/empty
* - ${@:-default} all args with default when empty
* - ${@:N} / ${@:N:L} bash-style slicing
*
* Replacement runs once over the template only; argument/default values
* containing patterns like $1 or $@ are NOT recursively substituted.
*/
function substituteArgs(content: string, args: string[]): string {
const allArgs = args.join(" ");
return content.replace(
/\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,
(_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => {
if (defaultTarget) {
const value =
defaultTarget === "@" || defaultTarget === "ARGUMENTS"
? allArgs
: args[parseInt(defaultTarget, 10) - 1];
return value ? value : defaultValue;
}
if (sliceStart) {
let start = parseInt(sliceStart, 10) - 1; // 1-indexed → 0-indexed
if (start < 0) start = 0;
if (sliceLength) {
const length = parseInt(sliceLength, 10);
return args.slice(start, start + length).join(" ");
}
return args.slice(start).join(" ");
}
if (simple === "ARGUMENTS" || simple === "@") {
return allArgs;
}
const index = parseInt(simple, 10) - 1;
return args[index] ?? "";
},
);
}
/**
* Load and expand the task-manager prompt template bundled with the extension.
*
* `pi.sendUserMessage()` sends with `expandPromptTemplates: false`, so it will
* NOT expand a `/task-manager` invocation — and `@task-manager` is an
* @-mention, not a template invocation anyway. We therefore read the
* template ourselves, strip its frontmatter, substitute args ($@ etc.), and
* return the fully-expanded prompt body ready to send as a user message.
*
* @param extensionDir Absolute path to the extension root (where index.ts
* lives), used to locate `prompts/task-manager.md`.
* @param argsString Raw argument string from the slash command (may be "").
* @throws if the template file is missing or unreadable.
*/
export function loadTaskManagerPrompt(
extensionDir: string,
argsString: string,
): string {
const templatePath = path.join(extensionDir, TEMPLATE_REL);
const raw = fs.readFileSync(templatePath, "utf-8");
const body = stripFrontmatter(raw);
const args = parseCommandArgs(argsString);
return substituteArgs(body, args).trim();
}

View File

@@ -172,6 +172,23 @@ export function createWorktree(
if (existing && existing.includes(`worktree ${wtDir}`)) {
// The worktree is registered — sanity-check it's a valid checkout.
if (getGitHead(wtDir)) {
// Return the branch the worktree is ACTUALLY checked out on, NOT the
// slug recomputed from the (possibly changed) task title. Mismatch
// happens routinely on resume: the task agent may have created its
// own feature branch (e.g. `proctored-exam-delivery-mode-10-exam-...`)
// once it saw the convention in the git log, the title may have been
// edited between runs, or an older ralpi version used a different
// naming scheme. Returning the slug here makes `git merge <slug>`
// fail with "not something we can merge" because no such ref exists —
// exactly the spurious merge-conflict we see on resumes.
const actual = getCurrentBranch(wtDir);
if (actual && actual !== "HEAD" && actual !== "detached") {
return { dir: wtDir, branch: actual, mainDir };
}
// Detached-HEAD worktree (e.g. left by a prior `--detach` fallback).
// The slug ref doesn't exist as a branch — create one matching the
// slug from the worktree's current HEAD so the merge step resolves.
git(`branch "${branch}" HEAD`, mainDir);
return { dir: wtDir, branch, mainDir };
}
// Registered but broken (dir gone / checkout corrupt) — drop its