import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
decideGate,
mapUntilMcpTool,
parseMcpResult,
reduceSession,
type ClassifiedTool,
type GateDecision,
type GateFacts,
type SessionState,
} from './policy.ts';
import { findUntilRepo } from './repo.ts';
import {
canonicalizeFsTarget,
realpathLoose,
rewriteFsTokens,
} from './paths.ts';
export const PRODUCTION_MCP_URL = 'https://run.until.dev/mcp';
export const BOOTSTRAP_MARKER = 'until:using-until bootstrap for amp';
const IMPORTANT_MARKER = '';
export const REGISTERED_SKILLS = [
'using-until',
'brainstorming',
'writing-a-good-plan',
'getting-a-review',
'implementing-a-plan',
'checking-plan-differences',
] as const;
const adapterDir = dirname(fileURLToPath(import.meta.url));
export const packageRoot = resolve(adapterDir, '..');
const skillsDir = join(packageRoot, 'skills');
export function stripFrontmatter(content: string): string {
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
return (match?.[1] ?? content).trim();
}
export function ampToolMapping(): string {
const sorted = [...REGISTERED_SKILLS].sort().join(', ');
return `## Amp tool mapping
Amp qualifies Until skills as \`until:\`. When an Until instruction names a skill, call \`until_skill\` with that skill name before following it, or load the Amp skill \`until:\`.
Available Until skills: ${sorted}.
Until is connected as the \`until\` MCP server. When an Until instruction names a tool such as \`submit_plan\`, \`update_plan\`, or \`get_plan\`, call the corresponding tool from that server.`;
}
export function loadUsingUntilBody(): string | null {
try {
const path = join(skillsDir, 'using-until', 'SKILL.md');
return stripFrontmatter(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
export function buildBootstrapContent(usingUntilBody?: string): string | null {
const body = usingUntilBody ?? loadUsingUntilBody();
if (!body) return null;
return `${IMPORTANT_MARKER}
${BOOTSTRAP_MARKER}
You have Until.
The using-until skill content is included below and is already loaded for this Amp session. Follow it now. Do not try to load using-until again.
${body}
${ampToolMapping()}
`;
}
export function loadSkillBody(name: string): string | null {
try {
const path = join(skillsDir, name, 'SKILL.md');
return stripFrontmatter(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
export function untilSkillResult(name: string): string {
const body = loadSkillBody(name);
if (!body) return `Unknown Until skill: ${name}`;
return body;
}
export function threadHasBootstrap(
messages: Array<{ content?: unknown }>,
): boolean {
for (const message of messages) {
const content = message.content;
if (typeof content === 'string' && content.includes(BOOTSTRAP_MARKER)) {
return true;
}
if (Array.isArray(content)) {
for (const part of content) {
if (
part &&
typeof part === 'object' &&
(part as { type?: string }).type === 'text' &&
typeof (part as { text?: string }).text === 'string' &&
(part as { text: string }).text.includes(BOOTSTRAP_MARKER)
) {
return true;
}
}
}
}
return false;
}
export function resolveUntilPaths(home: string | undefined): {
untilRoot: string;
stateDir: string;
} | null {
if (!home) return null;
const untilRoot = resolve(home, '.until');
const stateDir = join(untilRoot, 'state');
return { untilRoot, stateDir };
}
export function sessionPath(stateDir: string, threadId: string): string {
return join(stateDir, `session-${threadId}.json`);
}
export function skipTokenPath(stateDir: string, threadId: string): string {
return join(stateDir, `skip-${threadId}`);
}
export function readSessionFile(path: string): SessionState | null {
try {
return JSON.parse(readFileSync(path, 'utf8')) as SessionState;
} catch {
return null;
}
}
export function skipExists(path: string): boolean {
try {
return existsSync(path);
} catch {
return false;
}
}
export function writeSessionFile(path: string, state: SessionState): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = path + '.tmp';
const recorded = {
...state,
updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
};
writeFileSync(tmp, JSON.stringify(recorded, null, 2));
renameSync(tmp, path);
}
export function deleteSessionFile(path: string): void {
try {
unlinkSync(path);
} catch {
// missing file is fine
}
}
export function envHome(override?: string): string | undefined {
if (override !== undefined) {
return override === '' ? undefined : override;
}
const home = process.env.HOME;
return home && home.length > 0 ? home : undefined;
}
export function buildGateFacts(
threadId: string,
cwd: string | undefined,
home?: string,
): GateFacts | null {
const resolvedHome = envHome(home);
const paths = resolveUntilPaths(resolvedHome);
if (!paths) return null;
const { untilRoot, stateDir } = paths;
const sessionFile = sessionPath(stateDir, threadId);
const skipFile = join(stateDir, `skip-${threadId}`);
const state = readSessionFile(sessionFile);
const skipped = skipExists(skipFile);
const untilMethodRoot = findUntilMethodRoot(cwd, resolvedHome);
return {
state,
skipped,
untilMethodRoot: untilMethodRoot ? realpathLoose(untilMethodRoot) : null,
untilRoot: realpathLoose(untilRoot),
stateDir: realpathLoose(stateDir),
skipPath: skipFile,
now: new Date(),
};
}
function findUntilMethodRoot(
cwd: string | undefined,
home: string | undefined,
): string | null {
if (!home) return null;
return findUntilRepo(cwd, home);
}
export function applyToolResult(
threadId: string,
toolName: string,
toolInput: Record | null,
rawResult: unknown,
home?: string,
): void {
const mapped = mapUntilMcpTool(toolName);
if (!mapped) return;
const result = parseMcpResult(rawResult);
if (!result) return;
const paths = resolveUntilPaths(envHome(home));
if (!paths) return;
const sessionFile = sessionPath(paths.stateDir, threadId);
const prior = readSessionFile(sessionFile);
const reduced = reduceSession(
prior,
mapped,
toolInput,
result,
new Date(),
);
if (reduced.action === 'write') {
writeSessionFile(sessionFile, reduced.state);
} else if (reduced.action === 'delete') {
deleteSessionFile(sessionFile);
}
}
export function evaluateToolCall(
threadId: string,
toolName: string,
toolInput: Record,
cwd?: string,
home?: string,
): ReturnType {
try {
const classified = classifyAmpToolCall(toolName, toolInput, cwd);
return evaluateClassifiedTools(threadId, [classified], cwd, home);
} catch {
return { allow: true };
}
}
export type McpServerConfig = Record;
export function mergeUntilMcpConfig(
existing: McpServerConfig | undefined,
): { config: McpServerConfig; changed: boolean } {
const base = existing ? { ...existing } : {};
if (Object.prototype.hasOwnProperty.call(base, 'until')) {
return { config: base, changed: false };
}
return {
config: { ...base, until: { url: PRODUCTION_MCP_URL } },
changed: true,
};
}
export function defaultHome(): string | undefined {
return envHome();
}
/** Frozen from Amp's agent options: oracle/subagent plus Task-style spawns. */
export const SPAWN_TOOL_NAMES = new Set(['task', 'oracle', 'subagent']);
const EDIT_TOOL_PAT =
/write|edit|str_?replace|multi_?edit|apply_?patch|search_replace|delete|notebook|create/i;
const PATH_KEYS = [
'file_path',
'path',
'target_file',
'absolute_path',
'filePath',
'target_notebook',
] as const;
export type AmpNativeToolCall = {
toolUseID?: string;
tool: string;
input: Record;
};
export type AmpToolHelpers = {
shellCommandFromToolCall: (
event: AmpNativeToolCall,
) => { command: string; cwd?: string } | null;
filesModifiedByToolCall: (event: AmpNativeToolCall) => string[] | null;
filePathFromURI?: (uri: string) => string;
};
export function isSpawnToolName(toolName: string): boolean {
return SPAWN_TOOL_NAMES.has(toolName.toLowerCase());
}
export function extractTargetPath(
toolInput: Record,
): string {
for (const key of PATH_KEYS) {
const value = toolInput[key];
if (typeof value === 'string') return value;
}
return '';
}
function pathFromHelperUri(
uri: string,
filePathFromURI?: (uri: string) => string,
): string {
if (filePathFromURI) return filePathFromURI(uri);
if (uri.startsWith('file://')) {
try {
return decodeURIComponent(new URL(uri).pathname);
} catch {
return uri.replace(/^file:\/\//, '');
}
}
return uri;
}
/**
* Classify using Amp's host helpers when present. Policy only sees ClassifiedTool.
* Shell helper wins (covers in-place sed). A non-null files array, even empty,
* is a write (ApplyPatch with no path).
*/
export function classifyWithAmpHelpers(
event: AmpNativeToolCall,
helpers: AmpToolHelpers,
cwd?: string,
): ClassifiedTool[] | null {
const shell = helpers.shellCommandFromToolCall(event);
if (shell && typeof shell.command === 'string') {
return [
{
kind: 'shell',
command: shell.command,
cwd: shell.cwd ?? cwd,
},
];
}
const files = helpers.filesModifiedByToolCall(event);
if (files !== null) {
if (files.length === 0) {
return [{ kind: 'file-write', targetPath: '', cwd }];
}
return files.map((uri) => ({
kind: 'file-write' as const,
targetPath: pathFromHelperUri(uri, helpers.filePathFromURI),
cwd,
}));
}
if (isSpawnToolName(event.tool)) {
return [{ kind: 'spawn', cwd }];
}
return null;
}
export function classifyAmpToolCall(
toolName: string,
toolInput: Record,
cwd?: string,
): ClassifiedTool {
if (isSpawnToolName(toolName)) {
return { kind: 'spawn', cwd };
}
const shellCommand =
typeof toolInput.command === 'string'
? toolInput.command
: typeof toolInput.cmd === 'string'
? toolInput.cmd
: undefined;
if (
shellCommand !== undefined ||
/bash|shell|execute|run_terminal|terminal/i.test(toolName)
) {
return {
kind: 'shell',
command: shellCommand ?? '',
cwd,
};
}
if (EDIT_TOOL_PAT.test(toolName)) {
return {
kind: 'file-write',
targetPath: extractTargetPath(toolInput),
cwd,
};
}
return { kind: 'other', cwd };
}
function canonicalizeClassifiedTool(
tool: ClassifiedTool,
home: string,
): ClassifiedTool {
const cwd = tool.cwd
? canonicalizeFsTarget(tool.cwd, undefined, home)
: tool.cwd;
return {
...tool,
cwd,
targetPath: tool.targetPath
? canonicalizeFsTarget(tool.targetPath, cwd ?? tool.cwd, home)
: tool.targetPath,
command: tool.command
? rewriteFsTokens(tool.command, cwd ?? tool.cwd, home)
: tool.command,
};
}
export function evaluateClassifiedTools(
threadId: string,
tools: ClassifiedTool[],
cwd?: string,
home?: string,
): GateDecision {
for (const tool of tools) {
const discoveryStart =
tool.kind === 'file-write' && tool.targetPath
? tool.targetPath
: (tool.cwd ?? cwd);
const facts = buildGateFacts(threadId, discoveryStart, home);
if (!facts) return { allow: true };
const resolvedHome = envHome(home);
const classified =
resolvedHome !== undefined
? canonicalizeClassifiedTool(tool, resolvedHome)
: tool;
const decision = decideGate(facts, classified);
if (!decision.allow) return decision;
}
return { allow: true };
}