fix: ralpi-plan properly loads task-manager prompt
This commit is contained in:
104
src/task-manager-prompt.ts
Normal file
104
src/task-manager-prompt.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user