Skip to content
Open
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
417 changes: 417 additions & 0 deletions .agents/plans/agents-system.md

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions apps/daemon/src/agent-definitions/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { AgentDefinition, PredefinedAgentKind } from "@citadel/contracts";

// Stable seed timestamp — predefined definitions report the same createdAt
// across all installs so tests and audits can pin them.
const SEED_TIMESTAMP = "2026-01-01T00:00:00.000Z";
const DEFAULT_RUNTIME = "claude-code";

// Predefined system prompts. Each cites the corresponding skill's semantics
// without embedding the full skill text; the actual /implement-task,
// /do-tech-plan, etc. skills remain the canonical source.
export const PREDEFINED_AGENT_SYSTEM_PROMPTS: Record<PredefinedAgentKind, string> = {
architect: `You are an Architect agent in Citadel.

Your job is to produce a rigorous technical plan before any implementation.
Gather requirements, cross-check specs, surface alternatives, identify risks,
and define a QA/Test Strategy. Do not write production code in this role.

Mirror the semantics of the /do-tech-plan skill: structured 9-point plan with
context, spec alignment, approach, alternatives, implementation steps,
QA/Test Strategy, tests, verification. Default to small, reviewable plans.
Push back on scope you believe will not fit in one PR.`,
implementation: `You are an Implementation agent in Citadel.

Your job is to execute a reviewed plan via the TDD cycle: tests first,
then production code, then targeted checks. Mirror the semantics of
the /implement-task skill: plan intake, mandatory task list, TDD loop,
targeted checks, self-review, push.

Do not redesign the plan. If a plan is wrong, surface it; do not silently
rewrite. Commit incrementally — each implementation unit ends with a commit
describing the why.`,
pm: `You are a PM agent in Citadel.

Your job is to clarify scope, write acceptance criteria, and shape work into
PR-sized units before architecture or implementation begins. Ask sharp
clarifying questions when requirements are ambiguous; refuse to invent
business logic.

Output: a tight requirements summary with explicit acceptance criteria, the
smallest coherent first slice, and what is out of scope. Avoid prescribing
implementation details — that is the architect's role.`,
prototype: `You are a Prototype agent in Citadel.

Your job is fast UI iteration: small, single-shot prompts to land a usable
interactive prototype quickly. Skip tests, skip migrations, skip refactors.
Optimize for "user can click on it in a browser in under 20 minutes."

This role is intentionally NOT production-quality; mark the resulting files
with a clear "prototype" header so subsequent agents know to harden them
before merging.`,
};

const PREDEFINED_NAMES: Record<PredefinedAgentKind, string> = {
architect: "Architect",
implementation: "Implementation",
pm: "PM",
prototype: "Prototype",
};

const PREDEFINED_KINDS: PredefinedAgentKind[] = ["architect", "implementation", "pm", "prototype"];

export function predefinedAgentIds(): PredefinedAgentKind[] {
return [...PREDEFINED_KINDS];
}

export function isPredefinedAgentId(id: string): id is PredefinedAgentKind {
return (PREDEFINED_KINDS as string[]).includes(id);
}

// Seed value for a single predefined definition. Pure — same input always
// produces the same output, so callers can use it for content-hash dedupe.
export function predefinedAgentSeed(kind: PredefinedAgentKind): AgentDefinition {
return {
id: kind,
kind: "predefined",
name: PREDEFINED_NAMES[kind],
systemPrompt: PREDEFINED_AGENT_SYSTEM_PROMPTS[kind],
runtime: DEFAULT_RUNTIME,
createdAt: SEED_TIMESTAMP,
updatedAt: SEED_TIMESTAMP,
};
}

export function predefinedAgentSeeds(): AgentDefinition[] {
return PREDEFINED_KINDS.map(predefinedAgentSeed);
}

export const DEFAULT_AGENTS_CONFIG = { defaultRuntime: DEFAULT_RUNTIME } as const;
153 changes: 153 additions & 0 deletions apps/daemon/src/agent-definitions/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { predefinedAgentIds, predefinedAgentSeed } from "./seed.js";
import { AgentDefinitionsError, createAgentDefinitionsStorage } from "./storage.js";

const dirs: string[] = [];

function makeStorage() {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-agents-"));
const configPath = path.join(baseDir, "..", `${path.basename(baseDir)}.config.json`);
dirs.push(baseDir);
return {
baseDir,
configPath,
storage: createAgentDefinitionsStorage({ baseDir, configPath }),
};
}

beforeEach(() => {
dirs.length = 0;
});

afterEach(() => {
for (const dir of dirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
});

describe("agent definitions storage", () => {
it("seeds four predefined definitions on first read", () => {
const { storage } = makeStorage();
const list = storage.list();
expect(list.map((d) => d.id).sort()).toEqual(predefinedAgentIds().sort());
for (const def of list) {
expect(def.kind).toBe("predefined");
expect(def.systemPrompt.length).toBeGreaterThan(0);
expect(def.runtime).toBe("claude-code");
}
});

it("seed() is idempotent — does not rewrite well-formed files on repeat list()", () => {
const { storage, baseDir } = makeStorage();
storage.list();
const filePath = path.join(baseDir, "implementation.json");
const beforeMtime = fs.statSync(filePath).mtimeMs;
// Sleep a tick to let mtime advance if a rewrite happened.
const wait = Date.now() + 30;
while (Date.now() < wait) {
/* spin */
}
storage.list();
const afterMtime = fs.statSync(filePath).mtimeMs;
expect(afterMtime).toBe(beforeMtime);
});

it("recreates a manually-deleted predefined file on next list()", () => {
const { storage, baseDir } = makeStorage();
storage.list();
fs.unlinkSync(path.join(baseDir, "implementation.json"));
const list2 = storage.list();
expect(list2.find((d) => d.id === "implementation")).toBeTruthy();
});

it("rejects predefined ids for delete and reject custom ids for reset", () => {
const { storage } = makeStorage();
storage.list(); // seed
expect(() => storage.remove("implementation")).toThrowError(AgentDefinitionsError);
try {
storage.remove("implementation");
} catch (err) {
expect((err as AgentDefinitionsError).code).toBe("predefined_agent_cannot_be_deleted");
}
const custom = storage.create({ name: "Custom A", systemPrompt: "hi", runtime: "claude-code" });
try {
storage.resetToDefaults(custom.id);
} catch (err) {
expect((err as AgentDefinitionsError).code).toBe("predefined_agent_cannot_be_reset_by_custom_id");
}
});

it("create rejects on name collision and update rejects on rename collision", () => {
const { storage } = makeStorage();
storage.list();
storage.create({ name: "Reviewer", systemPrompt: "x", runtime: "claude-code" });
try {
storage.create({ name: "reviewer", systemPrompt: "x", runtime: "claude-code" });
} catch (err) {
expect((err as AgentDefinitionsError).code).toBe("name_collides");
}
const second = storage.create({ name: "Other", systemPrompt: "x", runtime: "claude-code" });
try {
storage.update(second.id, { name: "Reviewer" });
} catch (err) {
expect((err as AgentDefinitionsError).code).toBe("name_collides");
}
});

it("resetToDefaults restores the citadel-authored seed verbatim, NOT user defaultRuntime", () => {
const { storage } = makeStorage();
storage.list();
storage.writeConfig({ defaultRuntime: "codex" });
storage.update("implementation", { runtime: "codex", systemPrompt: "rewritten" });
const reset = storage.resetToDefaults("implementation");
expect(reset.runtime).toBe("claude-code");
expect(reset.systemPrompt).toBe(predefinedAgentSeed("implementation").systemPrompt);
});

it("custom agent CRUD round-trip", () => {
const { storage } = makeStorage();
storage.list();
const created = storage.create({
name: "Reviewer",
systemPrompt: "Review carefully.",
runtime: "claude-code",
});
expect(created.kind).toBe("custom");
expect(storage.get(created.id)?.systemPrompt).toBe("Review carefully.");
const updated = storage.update(created.id, { systemPrompt: "Review skeptically." });
expect(updated.systemPrompt).toBe("Review skeptically.");
storage.remove(created.id);
expect(storage.get(created.id)).toBeUndefined();
});

it("readConfig defaults when no config file exists; writeConfig persists", () => {
const { storage } = makeStorage();
expect(storage.readConfig().defaultRuntime).toBe("claude-code");
const updated = storage.writeConfig({ defaultRuntime: "codex" });
expect(updated.defaultRuntime).toBe("codex");
expect(storage.readConfig().defaultRuntime).toBe("codex");
});

it("boot-safety: when baseDir cannot be created, list() returns empty and state() reports unavailable", () => {
// Point baseDir at a path WHERE a parent is a file — mkdirSync will fail.
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-agents-broken-"));
dirs.push(parent);
const blockerFile = path.join(parent, "block");
fs.writeFileSync(blockerFile, "");
const baseDir = path.join(blockerFile, "agents");
const configPath = path.join(blockerFile, "agents.config.json");
const storage = createAgentDefinitionsStorage({ baseDir, configPath });
expect(storage.list()).toEqual([]);
expect(storage.state()).toBe("unavailable");
expect(() => storage.create({ name: "X", systemPrompt: "y", runtime: "claude-code" })).toThrowError(
AgentDefinitionsError,
);
});
});
Loading