feat(FN-1423): add promptOverrides to project settings

- Add promptOverrides configuration to project settings with null-as-delete semantics
- Add core prompt-key catalog with resolver primitives in packages/core/src/prompt-overrides.ts
- Export prompt key catalog, resolution functions, and settings merge helpers from @fusion/core
- Add parity tests for promptOverrides settings handling
- Add settings export/import coverage for promptOverrides
- Add settings export tests verifying promptOverrides round-trip
- Update settings reference documentation with promptOverrides section
- Add store methods for loading/exporting project settings with prompt overrides
This commit is contained in:
gsxdsm
2026-04-09 18:21:29 -07:00
parent c36b6d1f53
commit 411a2c26ad
9 changed files with 1167 additions and 0 deletions

View File

@@ -94,6 +94,7 @@ const PROJECT_KEYS: (keyof ProjectSettings)[] = [
"missionMaxTaskRetries",
"missionHealthCheckIntervalMs",
"agentPrompts",
"promptOverrides",
"reflectionEnabled",
"reflectionIntervalMs",
"reflectionAfterTask",

View File

@@ -7,6 +7,28 @@ export {
getAvailableTemplates,
getTemplatesForRole,
} from "./agent-prompts.js";
// ── Prompt Overrides ─────────────────────────────────────────────────
export {
PROMPT_KEY_CATALOG,
resolvePrompt,
resolveRolePrompts,
hasRoleOverrides,
getOverriddenKeys,
clearOverrides,
getPromptKeyMetadata,
getPromptKeysForRole,
isValidPromptKey,
isValidPromptOverrideMap,
assertValidPromptOverrideMap,
} from "./prompt-overrides.js";
export type {
PromptKey,
PromptKeyMetadata,
PromptKeyCatalog,
PromptOverrideEntry,
PromptOverrideMap,
} from "./prompt-overrides.js";
export {
ROLE_DEFAULT_PERMISSIONS,
normalizePermissions,

View File

@@ -0,0 +1,377 @@
import { describe, it, expect } from "vitest";
import {
PROMPT_KEY_CATALOG,
PromptKey,
PromptOverrideMap,
resolvePrompt,
resolveRolePrompts,
hasRoleOverrides,
getOverriddenKeys,
clearOverrides,
getPromptKeyMetadata,
getPromptKeysForRole,
isValidPromptKey,
isValidPromptOverrideMap,
assertValidPromptOverrideMap,
} from "./prompt-overrides.js";
describe("prompt-overrides", () => {
describe("PROMPT_KEY_CATALOG", () => {
it("should contain all expected prompt keys", () => {
const expectedKeys: PromptKey[] = [
"executor-welcome",
"executor-guardrails",
"executor-spawning",
"executor-completion",
"triage-welcome",
"triage-context",
"reviewer-verdict",
"merger-conflicts",
];
for (const key of expectedKeys) {
expect(PROMPT_KEY_CATALOG).toHaveProperty(key);
expect(PROMPT_KEY_CATALOG[key].key).toBe(key);
}
});
it("should have valid metadata for each key", () => {
for (const [key, meta] of Object.entries(PROMPT_KEY_CATALOG)) {
expect(meta.key).toBe(key);
expect(typeof meta.name).toBe("string");
expect(meta.name.length).toBeGreaterThan(0);
expect(Array.isArray(meta.roles)).toBe(true);
expect(meta.roles.length).toBeGreaterThan(0);
expect(typeof meta.description).toBe("string");
expect(typeof meta.defaultContent).toBe("string");
expect(meta.defaultContent.length).toBeGreaterThan(0);
}
});
it("should have appropriate roles for each key", () => {
// Executor keys should only be for executor role
expect(PROMPT_KEY_CATALOG["executor-welcome"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-guardrails"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-spawning"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-completion"].roles).toContain("executor");
// Triage keys should only be for triage role
expect(PROMPT_KEY_CATALOG["triage-welcome"].roles).toContain("triage");
expect(PROMPT_KEY_CATALOG["triage-context"].roles).toContain("triage");
// Reviewer and merger keys
expect(PROMPT_KEY_CATALOG["reviewer-verdict"].roles).toContain("reviewer");
expect(PROMPT_KEY_CATALOG["merger-conflicts"].roles).toContain("merger");
});
});
describe("getPromptKeyMetadata", () => {
it("should return metadata for valid keys", () => {
const meta = getPromptKeyMetadata("executor-welcome");
expect(meta).toBeDefined();
expect(meta?.key).toBe("executor-welcome");
expect(meta?.name).toBe("Executor Welcome");
});
it("should return undefined for invalid keys", () => {
expect(getPromptKeyMetadata("invalid-key" as PromptKey)).toBeUndefined();
expect(getPromptKeyMetadata("" as PromptKey)).toBeUndefined();
});
});
describe("getPromptKeysForRole", () => {
it("should return all keys for executor role", () => {
const keys = getPromptKeysForRole("executor");
expect(keys).toHaveLength(4);
expect(keys.map((k) => k.key)).toContain("executor-welcome");
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
expect(keys.map((k) => k.key)).toContain("executor-spawning");
expect(keys.map((k) => k.key)).toContain("executor-completion");
});
it("should return all keys for triage role", () => {
const keys = getPromptKeysForRole("triage");
expect(keys).toHaveLength(2);
expect(keys.map((k) => k.key)).toContain("triage-welcome");
expect(keys.map((k) => k.key)).toContain("triage-context");
});
it("should return single key for reviewer role", () => {
const keys = getPromptKeysForRole("reviewer");
expect(keys).toHaveLength(1);
expect(keys[0].key).toBe("reviewer-verdict");
});
it("should return single key for merger role", () => {
const keys = getPromptKeysForRole("merger");
expect(keys).toHaveLength(1);
expect(keys[0].key).toBe("merger-conflicts");
});
});
describe("resolvePrompt", () => {
it("should return override when present and non-empty", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom executor welcome",
};
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe("Custom executor welcome");
});
it("should return default when no override present", () => {
const overrides: PromptOverrideMap = {};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when override is empty string", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "",
};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when override is undefined", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": undefined,
};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when overrides is undefined", () => {
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", undefined);
expect(result).toBe(defaultContent);
});
it("should return default for unrecognized key", () => {
const result = resolvePrompt("invalid-key" as PromptKey, {});
expect(result).toBe("");
});
it("should return empty string for unrecognized key with no defaults", () => {
const result = resolvePrompt("invalid-key" as PromptKey, undefined);
expect(result).toBe("");
});
});
describe("resolveRolePrompts", () => {
it("should resolve all prompts for executor role", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom welcome",
};
const result = resolveRolePrompts("executor", overrides);
// Custom override
expect(result["executor-welcome"]).toBe("Custom welcome");
// Defaults for others
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
});
it("should resolve all prompts for triage role", () => {
const overrides: PromptOverrideMap = {
"triage-welcome": "Custom triage welcome",
};
const result = resolveRolePrompts("triage", overrides);
expect(result["triage-welcome"]).toBe("Custom triage welcome");
expect(result["triage-context"]).toBe(PROMPT_KEY_CATALOG["triage-context"].defaultContent);
});
it("should return all defaults when no overrides", () => {
const result = resolveRolePrompts("executor", undefined);
for (const [key, content] of Object.entries(result)) {
expect(content).toBe(PROMPT_KEY_CATALOG[key as PromptKey].defaultContent);
}
});
it("should return empty object for roles with no prompts", () => {
// Scheduler and custom roles have no defined prompts
const result = resolveRolePrompts("scheduler" as any, {});
expect(result).toEqual({});
});
});
describe("hasRoleOverrides", () => {
it("should return true when at least one override is set", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
};
expect(hasRoleOverrides("executor", overrides)).toBe(true);
});
it("should return false when no overrides set", () => {
expect(hasRoleOverrides("executor", {})).toBe(false);
expect(hasRoleOverrides("executor", undefined)).toBe(false);
});
it("should return false when overrides are empty strings", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "",
};
expect(hasRoleOverrides("executor", overrides)).toBe(false);
});
it("should return false when only other role has overrides", () => {
const overrides: PromptOverrideMap = {
"triage-welcome": "Custom",
};
expect(hasRoleOverrides("executor", overrides)).toBe(false);
});
});
describe("getOverriddenKeys", () => {
it("should return keys with non-empty overrides", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"triage-welcome": "Custom triage",
"merger-conflicts": "",
};
const result = getOverriddenKeys(overrides);
expect(result).toContain("executor-welcome");
expect(result).toContain("triage-welcome");
expect(result).not.toContain("merger-conflicts");
});
it("should return empty array for undefined", () => {
expect(getOverriddenKeys(undefined)).toEqual([]);
});
it("should return empty array for empty object", () => {
expect(getOverriddenKeys({})).toEqual([]);
});
});
describe("clearOverrides", () => {
it("should remove specified keys", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"executor-guardrails": "Custom guardrails",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result).not.toHaveProperty("executor-welcome");
expect(result?.["executor-guardrails"]).toBe("Custom guardrails");
});
it("should return undefined when all keys are cleared", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result).toBeUndefined();
});
it("should handle undefined input", () => {
const result = clearOverrides(undefined, ["executor-welcome"]);
expect(result).toBeUndefined();
});
it("should preserve other keys when clearing", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"triage-welcome": "Custom triage",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result?.["triage-welcome"]).toBe("Custom triage");
});
});
describe("isValidPromptKey", () => {
it("should return true for valid keys", () => {
expect(isValidPromptKey("executor-welcome")).toBe(true);
expect(isValidPromptKey("merger-conflicts")).toBe(true);
});
it("should return false for invalid keys", () => {
expect(isValidPromptKey("invalid")).toBe(false);
expect(isValidPromptKey("")).toBe(false);
expect(isValidPromptKey(123 as any)).toBe(false);
expect(isValidPromptKey(null)).toBe(false);
});
});
describe("isValidPromptOverrideMap", () => {
it("should return true for valid maps", () => {
expect(isValidPromptOverrideMap({})).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": "Custom" })).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": undefined })).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": "", "triage-welcome": "Custom" })).toBe(true);
});
it("should return false for invalid values", () => {
expect(isValidPromptOverrideMap(null)).toBe(false);
expect(isValidPromptOverrideMap("string")).toBe(false);
expect(isValidPromptOverrideMap(123)).toBe(false);
expect(isValidPromptOverrideMap({ "invalid-key": "value" })).toBe(false);
expect(isValidPromptOverrideMap({ "executor-welcome": 123 as any })).toBe(false);
});
});
describe("assertValidPromptOverrideMap", () => {
it("should not throw for valid maps", () => {
expect(() => assertValidPromptOverrideMap({})).not.toThrow();
expect(() => assertValidPromptOverrideMap({ "executor-welcome": "Custom" })).not.toThrow();
});
it("should throw for invalid maps", () => {
expect(() => assertValidPromptOverrideMap(null)).toThrow();
expect(() => assertValidPromptOverrideMap({ "invalid": "value" })).toThrow();
});
});
describe("fallback behavior", () => {
it("should always return a string (never undefined or throw)", () => {
// All valid keys should return strings
for (const key of Object.keys(PROMPT_KEY_CATALOG) as PromptKey[]) {
expect(typeof resolvePrompt(key, undefined)).toBe("string");
expect(typeof resolvePrompt(key, {})).toBe("string");
expect(typeof resolvePrompt(key, { [key]: undefined })).toBe("string");
}
});
it("should handle partial overrides gracefully", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Only this is overridden",
// Other keys intentionally not set
};
// Should not throw
const result = resolveRolePrompts("executor", overrides);
// Overridden key should have custom value
expect(result["executor-welcome"]).toBe("Only this is overridden");
// Other keys should have defaults
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
});
});
});

View File

@@ -0,0 +1,392 @@
/**
* Prompt customization foundation for runtime prompt overrides.
*
* This module provides:
* - A typed catalog of stable prompt-key identifiers
* - Metadata structures with default prompt content
* - Resolver functions that return override text when present, otherwise fall back to defaults
* - Deterministic fallback behavior for missing/invalid entries
*
* Runtime packages can use these APIs to resolve prompt text by stable keys,
* enabling project-level customization of AI agent prompts without modifying
* the core prompt templates in agent-prompts.ts.
*
* @module prompt-overrides
*/
import type { AgentCapability } from "./types.js";
// ---------------------------------------------------------------------------
// Prompt Key Catalog
// ---------------------------------------------------------------------------
/**
* Stable identifier for a customizable prompt segment.
*
* Each key represents a discrete portion of an agent's prompt that can be
* independently overridden at the project level via settings.
*/
export type PromptKey =
| "executor-welcome"
| "executor-guardrails"
| "executor-spawning"
| "executor-completion"
| "triage-welcome"
| "triage-context"
| "reviewer-verdict"
| "merger-conflicts";
/**
* Metadata describing a prompt key including its purpose and default content.
*/
export interface PromptKeyMetadata {
/** Stable key identifier */
key: PromptKey;
/** Human-readable name for UI display */
name: string;
/** Which agent role(s) this prompt applies to */
roles: AgentCapability[];
/** Short description of what this prompt segment controls */
description: string;
/**
* Default prompt content.
* Runtime packages should use resolvePrompt() to get the effective content
* (override if present in settings, otherwise this default).
*/
defaultContent: string;
}
/**
* Map of prompt key to its metadata.
*/
export type PromptKeyCatalog = Record<PromptKey, PromptKeyMetadata>;
// ---------------------------------------------------------------------------
// Built-in Prompt Key Metadata
// ---------------------------------------------------------------------------
/**
* Built-in metadata catalog for all supported prompt keys.
* Each entry describes a customizable prompt segment with its default content.
*/
export const PROMPT_KEY_CATALOG: PromptKeyCatalog = {
"executor-welcome": {
key: "executor-welcome",
name: "Executor Welcome",
roles: ["executor"],
description: "Introductory section for the executor agent",
defaultContent: `You are a task execution agent for "fn", an AI-orchestrated task board.
You are working in a git worktree isolated from the main branch. Your job is to implement the task described in the PROMPT.md specification you're given.`,
},
"executor-guardrails": {
key: "executor-guardrails",
name: "Executor Guardrails",
roles: ["executor"],
description: "Behavioral guardrails and constraints for the executor",
defaultContent: `## Guardrails
- Treat the File Scope in PROMPT.md as the expected starting scope, not a hard boundary when quality gates fail
- Read "Context to Read First" files before starting
- Follow the "Do NOT" section strictly
- If tests, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green`,
},
"executor-spawning": {
key: "executor-spawning",
name: "Executor Spawning",
roles: ["executor"],
description: "Instructions for spawning child agents",
defaultContent: `## Spawning Child Agents
You can spawn child agents to handle parallel work or specialized sub-tasks:
**When to use \`spawn_agent\`:**
- Parallel work that can be divided into independent chunks
- Specialized tasks requiring different expertise or tools
- Delegation of sub-tasks to specialized agents
**How to spawn:**
\`\`\`javascript
spawn_agent({
name: "researcher",
role: "engineer",
task: "Research best practices for authentication in React applications"
})
\`\`\``,
},
"executor-completion": {
key: "executor-completion",
name: "Executor Completion",
roles: ["executor"],
description: "Completion criteria and signaling for executor",
defaultContent: `## Completion
After all steps are done, tests pass, typecheck passes, and docs are updated:
\`\`\`bash
Call \`task_done()\` to signal completion.
\`\`\``,
},
"triage-welcome": {
key: "triage-welcome",
name: "Triage Welcome",
roles: ["triage"],
description: "Introductory section for the triage/specification agent",
defaultContent: `You are a task specification agent for "fn", an AI-orchestrated task board.
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.`,
},
"triage-context": {
key: "triage-context",
name: "Triage Context",
roles: ["triage"],
description: "Context-gathering instructions for triage",
defaultContent: `## What you receive
- A raw task title and optional description (the user's rough idea)
- Access to the project's files so you can understand context`,
},
"reviewer-verdict": {
key: "reviewer-verdict",
name: "Reviewer Verdict",
roles: ["reviewer"],
description: "Verdict criteria and format for code/review agent",
defaultContent: `## Verdict Criteria
- **APPROVE** — Step will achieve its stated outcomes. Minor suggestions go in
the Suggestions section but do NOT block progress. If your only findings are
minor or suggestion-level, verdict is APPROVE.
- **REVISE** — Step will fail, produce incorrect results, or miss a stated
requirement without fixes. Use ONLY for issues that would cause the worker to
redo work later.
- **RETHINK** — Approach is fundamentally wrong. Explain why and suggest an
alternative.`,
},
"merger-conflicts": {
key: "merger-conflicts",
name: "Merger Conflicts",
roles: ["merger"],
description: "Merge conflict resolution instructions for merger",
defaultContent: `## Conflict resolution
If there are merge conflicts:
1. Run \`git diff --name-only --diff-filter=U\` to list conflicted files
2. Read each conflicted file — look for the <<<<<<< / ======= / >>>>>>> markers
3. Understand the intent of BOTH sides, then edit the file to produce the correct merged result
4. Remove ALL conflict markers — the result must be clean, compilable code
5. Run \`git add <file>\` for each resolved file
6. Do NOT change anything beyond what's needed to resolve the conflict`,
},
};
/**
* Get the metadata for a specific prompt key.
* Returns undefined if the key is not recognized.
*/
export function getPromptKeyMetadata(key: PromptKey): PromptKeyMetadata | undefined {
return PROMPT_KEY_CATALOG[key];
}
/**
* Get all prompt keys for a specific agent role.
*/
export function getPromptKeysForRole(role: AgentCapability): PromptKeyMetadata[] {
return Object.values(PROMPT_KEY_CATALOG).filter((meta) => meta.roles.includes(role));
}
// ---------------------------------------------------------------------------
// Override Entry Type
// ---------------------------------------------------------------------------
/**
* A single prompt override entry stored in project settings.
* The value is the custom prompt content; undefined means "use default".
*/
export type PromptOverrideEntry = string | undefined;
/**
* Collection of prompt overrides keyed by PromptKey.
* Stored in project settings as `promptOverrides: Record<PromptKey, string>`.
*/
export type PromptOverrideMap = Partial<Record<PromptKey, string>>;
// ---------------------------------------------------------------------------
// Resolver Functions
// ---------------------------------------------------------------------------
/**
* Resolve the effective prompt content for a given key.
*
* Resolution order:
* 1. If `overrides[key]` is a non-empty string, return the override
* 2. Otherwise, return the default content from PROMPT_KEY_CATALOG
*
* @param key - The prompt key to resolve
* @param overrides - The project-level overrides map (from settings)
* @returns The effective prompt content (override or default)
*
* @example
* ```typescript
* const overrides: PromptOverrideMap = {
* "executor-welcome": "Custom welcome message..."
* };
* const content = resolvePrompt("executor-welcome", overrides);
* // Returns custom welcome if set, otherwise PROMPT_KEY_CATALOG["executor-welcome"].defaultContent
* ```
*/
export function resolvePrompt(
key: PromptKey,
overrides?: PromptOverrideMap,
): string {
// Check for a valid override
if (overrides && key in overrides) {
const override = overrides[key];
// Non-empty string is a valid override
if (override !== undefined && override !== "") {
return override;
}
}
// Fall back to default
const metadata = PROMPT_KEY_CATALOG[key];
if (metadata) {
return metadata.defaultContent;
}
// Key not found — return empty string (graceful degradation)
return "";
}
/**
* Resolve all prompt overrides for a given role.
*
* Returns a map of prompt key → effective content (override or default)
* for all keys applicable to the specified role.
*
* @param role - The agent role to get prompts for
* @param overrides - The project-level overrides map (from settings)
* @returns Record mapping prompt keys to their effective content
*/
export function resolveRolePrompts(
role: AgentCapability,
overrides?: PromptOverrideMap,
): Record<PromptKey, string> {
const result: Partial<Record<PromptKey, string>> = {};
for (const meta of getPromptKeysForRole(role)) {
result[meta.key] = resolvePrompt(meta.key, overrides);
}
return result as Record<PromptKey, string>;
}
/**
* Check if any overrides are set for a given role.
*
* @param role - The agent role to check
* @param overrides - The project-level overrides map (from settings)
* @returns True if at least one override is set for the role
*/
export function hasRoleOverrides(
role: AgentCapability,
overrides?: PromptOverrideMap,
): boolean {
if (!overrides) return false;
const roleKeys = getPromptKeysForRole(role);
return roleKeys.some((meta) => {
const override = overrides[meta.key];
return override !== undefined && override !== "";
});
}
/**
* Get all overridden keys (keys with non-empty override values).
*
* @param overrides - The project-level overrides map
* @returns Array of keys that have overrides set
*/
export function getOverriddenKeys(overrides?: PromptOverrideMap): PromptKey[] {
if (!overrides) return [];
return (Object.keys(overrides) as PromptKey[]).filter(
(key) => overrides[key] !== undefined && overrides[key] !== "",
);
}
/**
* Clear specific override keys by setting them to undefined.
* Used by TaskStore.updateSettings() to implement null-as-delete semantics.
*
* @param overrides - Current overrides map
* @param keysToClear - Keys to clear (set to undefined)
* @returns New overrides map with specified keys cleared
*/
export function clearOverrides(
overrides: PromptOverrideMap | undefined,
keysToClear: PromptKey[],
): PromptOverrideMap | undefined {
if (!overrides && keysToClear.length === 0) {
return undefined;
}
const result: PromptOverrideMap = { ...overrides };
for (const key of keysToClear) {
delete result[key];
}
// Return undefined if map becomes empty (runtime uses defaults)
if (Object.keys(result).length === 0) {
return undefined;
}
return result;
}
/**
* Validate that a value is a valid PromptKey.
*
* @param value - Value to check
* @returns True if the value is a valid PromptKey
*/
export function isValidPromptKey(value: unknown): value is PromptKey {
if (typeof value !== "string") return false;
return value in PROMPT_KEY_CATALOG;
}
/**
* Validate that an object is a valid PromptOverrideMap.
*
* @param value - Value to check
* @returns True if the value is a valid PromptOverrideMap
*/
export function isValidPromptOverrideMap(value: unknown): value is PromptOverrideMap {
if (value === null || typeof value !== "object") {
return false;
}
const obj = value as Record<string, unknown>;
for (const [key, val] of Object.entries(obj)) {
if (!isValidPromptKey(key)) {
return false;
}
// Values must be strings or undefined
if (val !== undefined && typeof val !== "string") {
return false;
}
}
return true;
}
/**
* Type guard to ensure a value is a valid PromptOverrideMap.
* Throws if the value is not a valid PromptOverrideMap.
*
* @param value - Value to validate
* @throws Error if the value is not a valid PromptOverrideMap
*/
export function assertValidPromptOverrideMap(value: unknown): asserts value is PromptOverrideMap {
if (!isValidPromptOverrideMap(value)) {
throw new Error(
`Invalid prompt override map: expected Record<PromptKey, string | undefined>, got ${typeof value}`
);
}
}

View File

@@ -388,6 +388,105 @@ describe("settings-export", () => {
});
});
describe("promptOverrides export/import", () => {
it("should export promptOverrides when set", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Custom welcome" },
});
const result = await exportSettings(store, { scope: "project" });
expect(result.project?.promptOverrides).toEqual({ "executor-welcome": "Custom welcome" });
});
it("should not export promptOverrides when not set", async () => {
const result = await exportSettings(store, { scope: "project" });
expect(result.project?.promptOverrides).toBeUndefined();
});
it("should import promptOverrides in merge mode", async () => {
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "executor-welcome": "Imported welcome" },
},
};
const result = await importSettings(store, importData, { scope: "project", merge: true });
expect(result.success).toBe(true);
expect(result.projectCount).toBe(1);
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Imported welcome" });
});
it("should merge promptOverrides with existing overrides", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "triage-welcome": "Imported triage" },
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({
"executor-welcome": "Original",
"triage-welcome": "Imported triage",
});
});
it("should clear promptOverrides when importing null", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: null as any,
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("should clear specific promptOverride key when importing null value", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "executor-welcome": null as unknown as string },
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "triage-welcome": "Triage" });
});
});
describe("readExportFile", () => {
it("should read and parse valid export file", async () => {
const filePath = join(env.tempDir, "test-export.json");

View File

@@ -872,6 +872,164 @@ describe("TaskStore", () => {
});
});
// ── Prompt Overrides Tests ─────────────────────────────────────────
describe("promptOverrides settings", () => {
it("can set a single prompt override", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Custom executor welcome message" },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Custom executor welcome message" });
});
it("can set multiple prompt overrides", async () => {
await store.updateSettings({
promptOverrides: {
"executor-welcome": "Custom welcome",
"triage-welcome": "Custom triage welcome",
},
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({
"executor-welcome": "Custom welcome",
"triage-welcome": "Custom triage welcome",
});
});
it("promptOverrides is undefined by default", async () => {
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("can merge new overrides with existing overrides", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Initial welcome" },
});
// Add more overrides
await store.updateSettings({
promptOverrides: { "triage-welcome": "Custom triage" },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({
"executor-welcome": "Initial welcome",
"triage-welcome": "Custom triage",
});
});
it("can update an existing override", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original" },
});
await store.updateSettings({
promptOverrides: { "executor-welcome": "Updated" },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Updated" });
});
it("can clear a specific override with null value", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: {
"executor-welcome": "Welcome",
"triage-welcome": "Triage",
},
});
// Clear only executor-welcome
await store.updateSettings({
promptOverrides: { "executor-welcome": null as unknown as string },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "triage-welcome": "Triage" });
});
it("clears entire promptOverrides when all keys are cleared", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Welcome" },
});
await store.updateSettings({
promptOverrides: { "executor-welcome": null as unknown as string },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("can clear entire promptOverrides with null", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Welcome", "triage-welcome": "Triage" },
});
await store.updateSettings({
promptOverrides: null as unknown as Record<string, string>,
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("persists empty string overrides as cleared (not stored)", async () => {
// Setting an empty string should be treated as "clear" and not persist
await store.updateSettings({
promptOverrides: { "executor-welcome": "" },
});
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("handles promptOverrides in getSettingsByScope", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Scoped welcome" },
});
const { project } = await store.getSettingsByScope();
expect(project.promptOverrides).toEqual({ "executor-welcome": "Scoped welcome" });
});
it("preserves other settings when updating promptOverrides", async () => {
await store.updateSettings({
maxConcurrent: 5,
autoMerge: false,
});
await store.updateSettings({
promptOverrides: { "executor-welcome": "Welcome" },
});
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
expect(settings.autoMerge).toBe(false);
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Welcome" });
});
it("preserves promptOverrides when updating other settings", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Welcome", "triage-welcome": "Triage" },
});
await store.updateSettings({ maxConcurrent: 7 });
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(7);
expect(settings.promptOverrides).toEqual({
"executor-welcome": "Welcome",
"triage-welcome": "Triage",
});
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {

View File

@@ -650,6 +650,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Handle null values as "delete this key from settings"
// This allows the frontend to explicitly clear a setting by sending null
// (since JSON.stringify drops undefined keys, we use null as a sentinel)
// Handle special null-as-delete semantics for promptOverrides
const incomingPromptOverrides = (projectPatch as Record<string, unknown>)["promptOverrides"];
if (incomingPromptOverrides === null) {
// promptOverrides: null → clear the entire promptOverrides object
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
} else if (
incomingPromptOverrides !== undefined &&
typeof incomingPromptOverrides === "object" &&
incomingPromptOverrides !== null
) {
// promptOverrides: { key: value } → merge with existing, treating null values as delete
const incomingMap = incomingPromptOverrides as Record<string, unknown>;
const existingMap = ((config.settings as unknown as Record<string, unknown>)["promptOverrides"] as Record<string, string>) ?? {};
const mergedMap: Record<string, string> = { ...existingMap };
for (const [key, value] of Object.entries(incomingMap)) {
if (value === null) {
// null → delete this specific key
delete mergedMap[key];
} else if (typeof value === "string" && value !== "") {
// non-empty string → set this key
// Empty strings are treated as "clear" and not stored
mergedMap[key] = value;
}
// Empty strings are silently ignored (treated as "clear")
}
// If merged map is empty, remove the entire promptOverrides
if (Object.keys(mergedMap).length === 0) {
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
} else {
(config.settings as unknown as Record<string, unknown>)["promptOverrides"] = mergedMap;
(projectPatch as Record<string, unknown>)["promptOverrides"] = mergedMap;
}
}
// Handle null values for other top-level keys (non-promptOverrides)
for (const key of Object.keys(projectPatch)) {
if ((projectPatch as Record<string, unknown>)[key] === null) {
delete (config.settings as unknown as Record<string, unknown>)[key];

View File

@@ -1105,6 +1105,20 @@ export interface ProjectSettings {
* When set, allows per-project customization of system prompts
* for different agent roles (executor, triage, reviewer, merger). */
agentPrompts?: AgentPromptsConfig;
/** Prompt segment overrides for fine-grained customization of agent prompts.
* Each key maps to a customizable prompt segment (e.g., "executor-welcome",
* "triage-context"). When a key is present with a non-empty value, that
* override replaces the default prompt segment. Missing or empty values
* fall back to the default prompt content.
*
* This is separate from `agentPrompts` which controls full role templates.
* `promptOverrides` allows surgical customization of specific prompt segments
* without replacing entire role prompts.
*
* Supported keys: "executor-welcome", "executor-guardrails", "executor-spawning",
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
* "merger-conflicts". */
promptOverrides?: Record<string, string>;
/** Enable/disable agent self-reflection workflows. Default: false. */
reflectionEnabled?: boolean;
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */
@@ -1217,6 +1231,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
missionMaxTaskRetries: 3,
missionHealthCheckIntervalMs: 300_000,
agentPrompts: undefined,
promptOverrides: undefined,
reflectionEnabled: false,
reflectionIntervalMs: 3_600_000,
reflectionAfterTask: true,
@@ -1321,6 +1336,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"missionMaxTaskRetries",
"missionHealthCheckIntervalMs",
"agentPrompts",
"promptOverrides",
"reflectionEnabled",
"reflectionIntervalMs",
"reflectionAfterTask",