Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/prompt-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@missingstudio/sanddune-core": patch
"@missingstudio/sanddune": patch
---

Add the **`PromptResolver`** — the first stage of the **prompt** pipeline. `resolvePrompt({ prompt | promptFile, promptArgs? })` returns a tagged `ResolvedPrompt`:

- `kind: "inline"` — the verbatim string. No `{{KEY}}` scan, no shell-expression scan, no built-in argument injection (per ADR-0008's "inline = literal").
- `kind: "template"` — the file contents read from disk plus the untouched `promptArgs` map and the resolved absolute path. **Prompt argument substitution** and **prompt expansion** are out of scope for this slice — they consume the tag in later slices.

Runtime guards (defense in depth on the existing compile-time mutual exclusion):

- Passing both `prompt` and `promptFile` throws.
- Passing `promptArgs` alongside an inline `prompt` throws (per ADR-0008).
- A missing `promptFile` throws an error naming the resolved absolute path.

`promptFile` resolves relative paths against `process.cwd()` (the caller's perspective), **not** against `RunOptions.cwd` — those are deliberately different per CONTEXT.md's "two perspectives" rule.

`run()` no longer hard-rejects `promptFile`; it now calls the resolver in place of the previous "promptFile is not yet supported" branch and feeds the resolved text into the iteration loop. Templates without `{{KEY}}` or shell expressions work today; substitution and expansion land in later slices.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,17 @@ const result = await run({
| ---------------- | ------------------------------- | ------------------------------------------------------------- |
| `agent` | `AgentProvider` | **Required.** Today: `claudeCode(model, { env? })` |
| `sandbox` | `BindMountSandboxProvider` | **Required.** Today: `docker()` or your own bind-mount provider |
| `prompt` | `string` | **Required.** Inline prompt; passed to the agent as-is |
| `prompt` | `string` | Inline prompt; passed to the agent verbatim (ADR-0008) |
| `promptFile` | `string` | Path to a prompt template file; relative paths resolve from `process.cwd()` |
| `cwd` | `string` | Host repo dir; relative paths resolve from `process.cwd()` |
| `branchStrategy` | `BranchStrategy` | `head` (default) / `merge-to-head` / `branch` |
| `env` | `Record<string, string>` | Call-site env override; highest precedence (ADR-0012) |

Exactly one of `prompt` / `promptFile` is required. `promptFile` contents are loaded and forwarded to the agent as-is — `{{KEY}}` substitution and `` !`...` `` shell expansion are not implemented yet. Combining `prompt` with `promptArgs` throws (per ADR-0008); on the template path `promptArgs` is currently silently ignored until substitution lands.

#### Accepted by the type but not yet wired

`promptFile`, `promptArgs`, `maxIterations`, `completionSignal`, `hooks`, `timeouts`, `logging`, `signal`, `resumeSession`, `copyToWorktree`. Passing `promptFile` throws; the rest are silently ignored. Don't depend on them.
`maxIterations`, `completionSignal`, `hooks`, `timeouts`, `logging`, `signal`, `resumeSession`, `copyToWorktree`. Silently ignored. Don't depend on them.

### `RunResult`

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export type {
export type { Timeouts } from "./timeouts";
export type { LoggingOption } from "./logging";
export type { PromptArgs, PromptOption } from "./prompt";
export { resolvePrompt } from "./prompt-resolver";
export type { PromptResolverInput, ResolvedPrompt } from "./prompt-resolver";

export type {
CompletionSignal,
Expand Down
126 changes: 126 additions & 0 deletions packages/core/src/prompt-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { resolvePrompt } from "./prompt-resolver";

describe("resolvePrompt", () => {
let tmp: string;
let originalCwd: string;

beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), "sanddune-prompt-"));
originalCwd = process.cwd();
});

afterEach(async () => {
process.chdir(originalCwd);
await rm(tmp, { recursive: true, force: true });
});

test("inline path returns the string verbatim", async () => {
const result = await resolvePrompt({ prompt: "do the thing {{NOT_A_KEY}}" });
expect(result).toEqual({
kind: "inline",
text: "do the thing {{NOT_A_KEY}}",
});
});

test("template path returns file contents and the promptArgs map untouched", async () => {
const file = join(tmp, "prompt.md");
await writeFile(file, "Work on {{ISSUE}}\n");

const result = await resolvePrompt({
promptFile: file,
promptArgs: { ISSUE: "42" },
});

expect(result).toEqual({
kind: "template",
text: "Work on {{ISSUE}}\n",
promptArgs: { ISSUE: "42" },
absolutePath: file,
});
});

test("template path defaults promptArgs to an empty object when omitted", async () => {
const file = join(tmp, "prompt.md");
await writeFile(file, "no placeholders here\n");

const result = await resolvePrompt({ promptFile: file });

expect(result.kind).toBe("template");
if (result.kind === "template") {
expect(result.promptArgs).toEqual({});
}
});

test("rejects when both prompt and promptFile are passed", async () => {
await expect(
resolvePrompt({
prompt: "x",
promptFile: "y.md",
} as { prompt: string; promptFile: string }),
).rejects.toThrow(/mutually exclusive/);
});

test("rejects when neither prompt nor promptFile is passed", async () => {
await expect(resolvePrompt({})).rejects.toThrow(/prompt is required/);
});

test("rejects promptArgs combined with an inline prompt", async () => {
await expect(
resolvePrompt({
prompt: "x",
promptArgs: { K: "v" },
} as { prompt: string; promptArgs: { K: string } }),
).rejects.toThrow(/promptArgs.*inline/);
});

test("missing promptFile throws an error naming the resolved absolute path", async () => {
const missing = join(tmp, "nope.md");
await expect(resolvePrompt({ promptFile: missing })).rejects.toThrow(
missing,
);
});

test("relative promptFile resolves against process.cwd(), not the caller's other notion of cwd", async () => {
const callerDir = await mkdtemp(join(tmpdir(), "sanddune-prompt-cwd-"));
const otherDir = await mkdtemp(join(tmpdir(), "sanddune-prompt-other-"));
try {
await writeFile(join(callerDir, "prompt.md"), "from caller cwd\n");
await writeFile(join(otherDir, "prompt.md"), "from other dir\n");

process.chdir(callerDir);
// macOS reports the realpath of /var/folders/... as /private/var/folders/...
// so derive the expected path from process.cwd() after chdir.
const canonicalCallerDir = process.cwd();

const result = await resolvePrompt({ promptFile: "prompt.md" });

expect(result.kind).toBe("template");
if (result.kind === "template") {
expect(result.text).toBe("from caller cwd\n");
expect(result.absolutePath).toBe(
resolvePath(canonicalCallerDir, "prompt.md"),
);
}
} finally {
await rm(callerDir, { recursive: true, force: true });
await rm(otherDir, { recursive: true, force: true });
}
});

test("absolute promptFile is used as-is", async () => {
const file = join(tmp, "abs.md");
await writeFile(file, "absolute\n");

const result = await resolvePrompt({ promptFile: file });

expect(result.kind).toBe("template");
if (result.kind === "template") {
expect(result.absolutePath).toBe(file);
expect(result.text).toBe("absolute\n");
}
});
});
71 changes: 71 additions & 0 deletions packages/core/src/prompt-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { readFile } from "node:fs/promises";
import { isAbsolute, resolve as resolvePath } from "node:path";
import type { PromptArgs } from "./prompt";

export type ResolvedPrompt =
| {
readonly kind: "inline";
readonly text: string;
}
| {
readonly kind: "template";
readonly text: string;
readonly promptArgs: PromptArgs;
readonly absolutePath: string;
};

export interface PromptResolverInput {
readonly prompt?: string;
readonly promptFile?: string;
readonly promptArgs?: PromptArgs;
}

export async function resolvePrompt(
input: PromptResolverInput,
): Promise<ResolvedPrompt> {
const hasInline = typeof input.prompt === "string";
const hasFile = typeof input.promptFile === "string";
const hasArgs = input.promptArgs !== undefined;

if (hasInline && hasFile) {
throw new Error(
"`prompt` and `promptFile` are mutually exclusive — pass one, not both.",
);
}
if (!hasInline && !hasFile) {
throw new Error(
"A prompt is required — pass either `prompt` (inline) or `promptFile` (template).",
);
}

if (hasInline) {
if (hasArgs) {
throw new Error(
"`promptArgs` cannot be combined with an inline `prompt` — `promptArgs` only applies to `promptFile` templates (see ADR-0008).",
);
}
return { kind: "inline", text: input.prompt as string };
}

const promptFile = input.promptFile as string;
const absolutePath = isAbsolute(promptFile)
? promptFile
: resolvePath(process.cwd(), promptFile);

let text: string;
try {
text = await readFile(absolutePath, "utf8");
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`promptFile not found: ${absolutePath}`);
}
throw cause;
}

return {
kind: "template",
text,
promptArgs: input.promptArgs ?? {},
absolutePath,
};
}
9 changes: 3 additions & 6 deletions packages/sanddune/src/internal/run-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join, resolve as resolvePath } from "node:path";
import {
AgentInvoker,
resolveBranchStrategy,
resolvePrompt,
type BindMountSandboxHandle,
type RunOptions,
type RunResult,
Expand All @@ -25,11 +26,7 @@ export async function runProgram(
options: RunOptions<RunSandboxProvider>,
seams: RunProgramTestSeams = {},
): Promise<RunResult> {
if (typeof options.prompt !== "string") {
throw new Error(
"run() requires an inline `prompt` string in this release. `promptFile` is not yet supported.",
);
}
const resolvedPrompt = await resolvePrompt(options);
if (options.sandbox.kind !== "bind-mount") {
throw new Error(
`run() supports only bind-mount sandbox providers in this release; got ${options.sandbox.kind}.`,
Expand Down Expand Up @@ -96,7 +93,7 @@ export async function runProgram(

const loopResult = await Effect.runPromise(
runIterationLoop({
prompt: options.prompt,
prompt: resolvedPrompt.text,
runLog,
cwd: strategy.worktreePath,
beforeSha,
Expand Down