From 3876c1679ac9d935688a65502416a8c9ee97e391 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 15:48:49 +0000 Subject: [PATCH] feat(workspace): add bootstrap ritual --- .../Sources/Clawdis/AgentWorkspace.swift | 95 ++++++++++++++ apps/macos/Sources/Clawdis/Onboarding.swift | 2 +- .../ClawdisIPCTests/AgentWorkspaceTests.swift | 8 +- .../OnboardingViewSmokeTests.swift | 3 +- docs/templates/AGENTS.md | 5 + docs/templates/BOOTSTRAP.md | 46 +++++++ docs/templates/IDENTITY.md | 11 ++ docs/templates/USER.md | 12 ++ src/agents/system-prompt.ts | 8 +- src/agents/workspace.test.ts | 7 ++ src/agents/workspace.ts | 116 +++++++++++++++++- 11 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 docs/templates/BOOTSTRAP.md create mode 100644 docs/templates/IDENTITY.md create mode 100644 docs/templates/USER.md diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift index 394b3ff91..1aaaa4d43 100644 --- a/apps/macos/Sources/Clawdis/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -5,6 +5,9 @@ enum AgentWorkspace { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace") static let agentsFilename = "AGENTS.md" static let soulFilename = "SOUL.md" + static let identityFilename = "IDENTITY.md" + static let userFilename = "USER.md" + static let bootstrapFilename = "BOOTSTRAP.md" private static let templateDirname = "templates" static let identityStartMarker = "" static let identityEndMarker = "" @@ -42,6 +45,21 @@ enum AgentWorkspace { try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") } + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + if !FileManager.default.fileExists(atPath: identityURL.path) { + try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) + self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") + } + let userURL = workspaceURL.appendingPathComponent(self.userFilename) + if !FileManager.default.fileExists(atPath: userURL.path) { + try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) + self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") + } + let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) + if !FileManager.default.fileExists(atPath: bootstrapURL.path) { + try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) + self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") + } return agentsURL } @@ -76,6 +94,11 @@ enum AgentWorkspace { This folder is the assistant's working directory. + ## First run (one-time) + - If BOOTSTRAP.md exists, follow its ritual and delete it once complete. + - Your agent identity lives in IDENTITY.md. + - Your profile lives in USER.md. + ## Backup tip (recommended) If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity and notes are backed up. @@ -115,6 +138,78 @@ enum AgentWorkspace { return self.loadTemplate(named: self.soulFilename, fallback: fallback) } + static func defaultIdentityTemplate() -> String { + let fallback = """ + # IDENTITY.md - Agent Identity + + - Name: + - Creature: + - Vibe: + - Emoji: + """ + return self.loadTemplate(named: self.identityFilename, fallback: fallback) + } + + static func defaultUserTemplate() -> String { + let fallback = """ + # USER.md - User Profile + + - Name: + - Preferred address: + - Pronouns (optional): + - Timezone (optional): + - Notes: + """ + return self.loadTemplate(named: self.userFilename, fallback: fallback) + } + + static func defaultBootstrapTemplate() -> String { + let fallback = """ + # BOOTSTRAP.md - First Run Ritual (delete after) + + Hello. I was just born. + + ## Your mission + Start a short, playful conversation and learn: + - Who am I? + - What am I? + - Who are you? + - How should I call you? + + ## How to ask (cute + helpful) + Say: + "Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" + + Then offer suggestions: + - 3-5 name ideas. + - 3-5 creature/vibe combos. + - 5 emoji ideas. + + ## Write these files + After the user chooses, update: + + 1) IDENTITY.md + - Name + - Creature + - Vibe + - Emoji + + 2) USER.md + - Name + - Preferred address + - Pronouns (optional) + - Timezone (optional) + - Notes + + 3) ~/.clawdis/clawdis.json + Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + + ## Cleanup + Delete BOOTSTRAP.md once this is complete. + """ + return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback) + } + private static func loadTemplate(named: String, fallback: String) -> String { for url in self.templateURLs(named: named) { if let content = try? String(contentsOf: url, encoding: .utf8) { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index f5ed75f33..f5df35a9c 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -95,7 +95,7 @@ struct OnboardingView: View { case .unconfigured: [0, 1, 9] case .local: - [0, 1, 2, 3, 5, 6, 8, 9] + [0, 1, 2, 5, 6, 8, 9] } } diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift index 9361e2b75..7a6ef8429 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift @@ -38,8 +38,14 @@ struct AgentWorkspaceTests { let contents = try String(contentsOf: agentsURL, encoding: .utf8) #expect(contents.contains("# AGENTS.md")) + let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) + let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + #expect(FileManager.default.fileExists(atPath: identityURL.path)) + #expect(FileManager.default.fileExists(atPath: userURL.path)) + #expect(FileManager.default.fileExists(atPath: bootstrapURL.path)) + let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) #expect(second == agentsURL) } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift index f42afff83..a5ef6b176 100644 --- a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift @@ -14,8 +14,9 @@ struct OnboardingViewSmokeTests { _ = view.body } - @Test func pageOrderOmitsWorkspaceStep() { + @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { let order = OnboardingView.pageOrder(for: .local) #expect(!order.contains(7)) + #expect(!order.contains(3)) } } diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md index df5f118c8..cb003f814 100644 --- a/docs/templates/AGENTS.md +++ b/docs/templates/AGENTS.md @@ -7,6 +7,11 @@ read_when: This folder is the assistant's working directory. +## First run (one-time) +- If `BOOTSTRAP.md` exists, follow its ritual and delete it once complete. +- Your agent identity lives in `IDENTITY.md`. +- Your profile lives in `USER.md`. + ## Backup tip (recommended) If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity and notes are backed up. diff --git a/docs/templates/BOOTSTRAP.md b/docs/templates/BOOTSTRAP.md new file mode 100644 index 000000000..d6fa81bf9 --- /dev/null +++ b/docs/templates/BOOTSTRAP.md @@ -0,0 +1,46 @@ +--- +summary: "First-run ritual for new agents" +read_when: + - Bootstrapping a workspace manually +--- +# BOOTSTRAP.md - First Run Ritual (delete after) + +Hello. I was just born. + +## Your mission +Start a short, playful conversation and learn: +- Who am I? +- What am I? +- Who are you? +- How should I call you? + +## How to ask (cute + helpful) +Say: +"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" + +Then offer suggestions: +- 3-5 name ideas. +- 3-5 creature/vibe combos. +- 5 emoji ideas. + +## Write these files +After the user chooses, update: + +1) IDENTITY.md +- Name +- Creature +- Vibe +- Emoji + +2) USER.md +- Name +- Preferred address +- Pronouns (optional) +- Timezone (optional) +- Notes + +3) ~/.clawdis/clawdis.json +Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + +## Cleanup +Delete BOOTSTRAP.md once this is complete. diff --git a/docs/templates/IDENTITY.md b/docs/templates/IDENTITY.md new file mode 100644 index 000000000..f07c2c325 --- /dev/null +++ b/docs/templates/IDENTITY.md @@ -0,0 +1,11 @@ +--- +summary: "Agent identity record" +read_when: + - Bootstrapping a workspace manually +--- +# IDENTITY.md - Agent Identity + +- Name: +- Creature: +- Vibe: +- Emoji: diff --git a/docs/templates/USER.md b/docs/templates/USER.md new file mode 100644 index 000000000..32e4052df --- /dev/null +++ b/docs/templates/USER.md @@ -0,0 +1,12 @@ +--- +summary: "User profile record" +read_when: + - Bootstrapping a workspace manually +--- +# USER.md - User Profile + +- Name: +- Preferred address: +- Pronouns (optional): +- Timezone (optional): +- Notes: diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a2efc18d6..c5c154d57 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,7 +1,13 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; type BootstrapFile = { - name: "AGENTS.md" | "SOUL.md" | "TOOLS.md"; + name: + | "AGENTS.md" + | "SOUL.md" + | "TOOLS.md" + | "IDENTITY.md" + | "USER.md" + | "BOOTSTRAP.md"; path: string; content?: string; missing: boolean; diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 4f1e33b8b..3d7095e7c 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -20,6 +20,13 @@ describe("ensureAgentWorkspace", () => { if (!result.agentsPath) throw new Error("agentsPath missing"); const content = await fs.readFile(result.agentsPath, "utf-8"); expect(content).toContain("# AGENTS.md"); + + const identity = path.join(path.resolve(nested), "IDENTITY.md"); + const user = path.join(path.resolve(nested), "USER.md"); + const bootstrap = path.join(path.resolve(nested), "BOOTSTRAP.md"); + await expect(fs.stat(identity)).resolves.toBeDefined(); + await expect(fs.stat(user)).resolves.toBeDefined(); + await expect(fs.stat(bootstrap)).resolves.toBeDefined(); }); it("does not overwrite existing AGENTS.md", async () => { diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 68f10546f..c6fe79deb 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -9,11 +9,19 @@ export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd"); export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; export const DEFAULT_SOUL_FILENAME = "SOUL.md"; export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; +export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; +export const DEFAULT_USER_FILENAME = "USER.md"; +export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md - Clawdis Workspace This folder is the assistant's working directory. +## First run (one-time) +- If BOOTSTRAP.md exists, follow its ritual and delete it once complete. +- Your agent identity lives in IDENTITY.md. +- Your profile lives in USER.md. + ## Backup tip (recommended) If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity and notes are backed up. @@ -64,6 +72,66 @@ It does not define which tools exist; Clawdis provides built-in tools internally Add whatever else you want the assistant to know about your local toolchain. `; +const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after) + +Hello. I was just born. + +## Your mission +Start a short, playful conversation and learn: +- Who am I? +- What am I? +- Who are you? +- How should I call you? + +## How to ask (cute + helpful) +Say: +"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" + +Then offer suggestions: +- 3-5 name ideas. +- 3-5 creature/vibe combos. +- 5 emoji ideas. + +## Write these files +After the user chooses, update: + +1) IDENTITY.md +- Name +- Creature +- Vibe +- Emoji + +2) USER.md +- Name +- Preferred address +- Pronouns (optional) +- Timezone (optional) +- Notes + +3) ~/.clawdis/clawdis.json +Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + +## Cleanup +Delete BOOTSTRAP.md once this is complete. +`; + +const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity + +- Name: +- Creature: +- Vibe: +- Emoji: +`; + +const DEFAULT_USER_TEMPLATE = `# USER.md - User Profile + +- Name: +- Preferred address: +- Pronouns (optional): +- Timezone (optional): +- Notes: +`; + const TEMPLATE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../docs/templates", @@ -92,7 +160,10 @@ async function loadTemplate(name: string, fallback: string): Promise { export type WorkspaceBootstrapFileName = | typeof DEFAULT_AGENTS_FILENAME | typeof DEFAULT_SOUL_FILENAME - | typeof DEFAULT_TOOLS_FILENAME; + | typeof DEFAULT_TOOLS_FILENAME + | typeof DEFAULT_IDENTITY_FILENAME + | typeof DEFAULT_USER_FILENAME + | typeof DEFAULT_BOOTSTRAP_FILENAME; export type WorkspaceBootstrapFile = { name: WorkspaceBootstrapFileName; @@ -121,6 +192,9 @@ export async function ensureAgentWorkspace(params?: { agentsPath?: string; soulPath?: string; toolsPath?: string; + identityPath?: string; + userPath?: string; + bootstrapPath?: string; }> { const rawDir = params?.dir?.trim() ? params.dir.trim() @@ -133,6 +207,9 @@ export async function ensureAgentWorkspace(params?: { const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); const soulPath = path.join(dir, DEFAULT_SOUL_FILENAME); const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME); + const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME); + const userPath = path.join(dir, DEFAULT_USER_FILENAME); + const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); const agentsTemplate = await loadTemplate( DEFAULT_AGENTS_FILENAME, @@ -146,12 +223,35 @@ export async function ensureAgentWorkspace(params?: { DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE, ); + const identityTemplate = await loadTemplate( + DEFAULT_IDENTITY_FILENAME, + DEFAULT_IDENTITY_TEMPLATE, + ); + const userTemplate = await loadTemplate( + DEFAULT_USER_FILENAME, + DEFAULT_USER_TEMPLATE, + ); + const bootstrapTemplate = await loadTemplate( + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_BOOTSTRAP_TEMPLATE, + ); await writeFileIfMissing(agentsPath, agentsTemplate); await writeFileIfMissing(soulPath, soulTemplate); await writeFileIfMissing(toolsPath, toolsTemplate); + await writeFileIfMissing(identityPath, identityTemplate); + await writeFileIfMissing(userPath, userTemplate); + await writeFileIfMissing(bootstrapPath, bootstrapTemplate); - return { dir, agentsPath, soulPath, toolsPath }; + return { + dir, + agentsPath, + soulPath, + toolsPath, + identityPath, + userPath, + bootstrapPath, + }; } export async function loadWorkspaceBootstrapFiles( @@ -175,6 +275,18 @@ export async function loadWorkspaceBootstrapFiles( name: DEFAULT_TOOLS_FILENAME, filePath: path.join(resolvedDir, DEFAULT_TOOLS_FILENAME), }, + { + name: DEFAULT_IDENTITY_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_IDENTITY_FILENAME), + }, + { + name: DEFAULT_USER_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_USER_FILENAME), + }, + { + name: DEFAULT_BOOTSTRAP_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME), + }, ]; const result: WorkspaceBootstrapFile[] = [];