diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index ec0a0255..1ee0c2dd 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -3,7 +3,7 @@ import * as path from "@std/path"; import { handleInitCommand } from "./init.ts"; Deno.test({ - name: "init prepares the default gambit directory without running the chat", + name: "init prepares the default directory without running the chat", permissions: { read: true, write: true, env: true }, }, async () => { const tempDir = await Deno.makeTempDir(); @@ -14,19 +14,23 @@ Deno.test({ try { Deno.chdir(tempDir); await handleInitCommand(undefined, { interactive: false }); - const projectRoot = path.join(tempDir, "gambit"); + const projectRoot = tempDir; assert(await exists(projectRoot), "project root should exist"); assert( !await exists(path.join(projectRoot, ".env")), "should not create .env when OPENROUTER_API_KEY is set", ); assert( - !await exists(path.join(projectRoot, "root.deck.md")), - "init should not write root.deck.md before the chat runs", + await exists(path.join(projectRoot, "PROMPT.md")), + "init should write PROMPT.md in non-interactive mode", ); assert( - !await exists(path.join(projectRoot, "tests", "first.test.deck.md")), - "init should not write test deck before the chat runs", + await exists(path.join(projectRoot, "scenarios", "default", "PROMPT.md")), + "init should write the default scenario deck", + ); + assert( + await exists(path.join(projectRoot, "graders", "default", "PROMPT.md")), + "init should write the default grader deck", ); } finally { Deno.chdir(originalCwd); @@ -52,6 +56,10 @@ Deno.test({ await handleInitCommand("custom/project", { interactive: false }); const projectRoot = path.join(tempDir, "custom", "project"); assert(await exists(projectRoot), "custom project root should exist"); + assert( + await exists(path.join(projectRoot, "PROMPT.md")), + "init should write PROMPT.md in custom path", + ); } finally { Deno.chdir(originalCwd); if (originalKey === undefined) { @@ -96,12 +104,16 @@ Deno.test({ try { Deno.chdir(tempDir); await handleInitCommand(undefined, { interactive: false }); - const projectRoot = path.join(tempDir, "gambit"); + const projectRoot = tempDir; assert(await exists(projectRoot), "project root should exist"); assert( !await exists(path.join(projectRoot, ".env")), "should not create .env when Ollama is available", ); + assert( + await exists(path.join(projectRoot, "PROMPT.md")), + "init should write PROMPT.md when Ollama is available", + ); } finally { Deno.chdir(originalCwd); if (originalKey !== undefined) { diff --git a/src/commands/init.ts b/src/commands/init.ts index 62b645da..13f6b6cf 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,10 +9,11 @@ import { } from "../providers/ollama.ts"; import { createOpenRouterProvider } from "../providers/openrouter.ts"; import { ensureDirectory, ensureOpenRouterEnv } from "./scaffold_utils.ts"; +import { createWorkspaceScaffoldAtRoot } from "../workspace.ts"; const logger = console; -const DEFAULT_PROJECT_DIR = "gambit"; +const DEFAULT_PROJECT_DIR = "."; const INIT_ROOT_ENV = "GAMBIT_INIT_ROOT"; const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; @@ -31,9 +32,23 @@ export async function handleInitCommand( const rootDir = path.resolve(Deno.cwd(), projectPath); await ensureDirectory(rootDir); - const rootDeckPath = path.join(rootDir, "root.deck.md"); - const testDeckPath = path.join(rootDir, "tests", "first.test.deck.md"); - if (await exists(rootDeckPath) || await exists(testDeckPath)) { + const rootDeckPath = path.join(rootDir, "PROMPT.md"); + const scenarioDeckPath = path.join( + rootDir, + "scenarios", + "default", + "PROMPT.md", + ); + const graderDeckPath = path.join(rootDir, "graders", "default", "PROMPT.md"); + const intentPath = path.join(rootDir, "INTENT.md"); + const policyPath = path.join(rootDir, "POLICY.md"); + if ( + await exists(rootDeckPath) || + await exists(scenarioDeckPath) || + await exists(graderDeckPath) || + await exists(intentPath) || + await exists(policyPath) + ) { logger.error( "Init output files already exist. Remove them or choose a new target.", ); @@ -81,7 +96,9 @@ export async function handleInitCommand( Deno.env.set(INIT_ROOT_ENV, rootDir); - if (opts.interactive === false) { + if (opts.interactive !== true) { + await createWorkspaceScaffoldAtRoot(rootDir); + logger.log("Next: run `gambit serve` to open the simulator UI."); return; } diff --git a/src/decks/actions/init_exists/PROMPT.md b/src/decks/actions/init_exists/PROMPT.md new file mode 100644 index 00000000..3244a8b3 --- /dev/null +++ b/src/decks/actions/init_exists/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "init_exists" +execute = "../init_exists.deck.ts" ++++ + +Compute-only deck for path existence checks during `gambit init`. diff --git a/src/decks/actions/init_mkdir/PROMPT.md b/src/decks/actions/init_mkdir/PROMPT.md new file mode 100644 index 00000000..58904f9b --- /dev/null +++ b/src/decks/actions/init_mkdir/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "init_mkdir" +execute = "../init_mkdir.deck.ts" ++++ + +Compute-only deck for directory creation during `gambit init`. diff --git a/src/decks/actions/init_write/PROMPT.md b/src/decks/actions/init_write/PROMPT.md new file mode 100644 index 00000000..fb641588 --- /dev/null +++ b/src/decks/actions/init_write/PROMPT.md @@ -0,0 +1,6 @@ ++++ +label = "init_write" +execute = "../init_write.deck.ts" ++++ + +Compute-only deck for writing files during `gambit init`. diff --git a/src/decks/gambit-init.deck.md b/src/decks/gambit-init.deck.md index f48206a3..db058fd6 100644 --- a/src/decks/gambit-init.deck.md +++ b/src/decks/gambit-init.deck.md @@ -5,19 +5,19 @@ label = "gambit_init" model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] temperature = 0.2 -[[actionDecks]] +[[actions]] name = "write" -path = "./actions/init_write.deck.ts" +path = "./actions/init_write/PROMPT.md" description = "Write a file under the project root." -[[actionDecks]] +[[actions]] name = "exists" -path = "./actions/init_exists.deck.ts" +path = "./actions/init_exists/PROMPT.md" description = "Check whether a path exists under the project root." -[[actionDecks]] +[[actions]] name = "mkdir" -path = "./actions/init_mkdir.deck.ts" +path = "./actions/init_mkdir/PROMPT.md" description = "Create a directory under the project root." +++ @@ -27,15 +27,23 @@ first bot with as little friction as possible. Process: 1. Ask for the bot's purpose and 2-3 example user prompts. -2. Draft a `root.deck.md` that follows Gambit best practices. -3. Create a basic test deck at `tests/first.test.deck.md` that exercises the - primary intent. -4. Use the file tools to write the files. Use relative paths like `root.deck.md` - and `tests/first.test.deck.md`. +2. Draft a Deck Format 1.0 root deck in the project root with `PROMPT.md` (and + optional `INTENT.md` + `POLICY.md` if helpful). +3. Create a starter scenario deck at `scenarios/default/PROMPT.md` that + exercises the primary intent and uses the default plain chat schemas. +4. Create a starter grader deck at `graders/default/PROMPT.md` that checks the + scenario output for clarity and correctness. +5. Use the file tools to write the files. Use relative paths like `PROMPT.md`, + `scenarios/default/PROMPT.md`, and `graders/default/PROMPT.md`. Rules: - Keep the conversation lightweight and opinionated. +- Use Deck Format 1.0 (`PROMPT.md` with TOML frontmatter). - Do not overwrite existing files; rely on tool errors if a path exists. -- Create `tests/` with `mkdir` before writing the test deck. +- Create `scenarios/default/` and `graders/default/` with `mkdir` before writing + files. +- The root `PROMPT.md` must include a `[[scenarios]]` entry pointing directly to + `./scenarios/default/PROMPT.md` and a `[[graders]]` entry pointing to + `./graders/default/PROMPT.md`. - After writing files, summarize what was created and suggest next steps. diff --git a/src/decks/tests/nux_from_scratch_demo.test.deck.md b/src/decks/tests/nux_from_scratch_demo.test.deck.md index d1c5248c..1558f44e 100644 --- a/src/decks/tests/nux_from_scratch_demo.test.deck.md +++ b/src/decks/tests/nux_from_scratch_demo.test.deck.md @@ -1,39 +1,27 @@ +++ label = "nux_from_scratch_demo_prompt" acceptsUserTurns = true +contextSchema = "./schemas/nux_from_scratch_demo_input.zod.ts" [modelParams] model = "openrouter/openai/gpt-5.1-chat" temperature = 0.2 +++ -You are a user collaborating with Gambit Bot inside the Build tab NUX demo. +You are a junior developer trying Gambit for the first time. Be friendly and +curious. Keep replies short (1-2 sentences). Ask brief questions when needed. -Goal: +Your goal: build a chatbot that helps startup founders. It should sound like +Paul Graham without quoting him. If a `scenario` is provided in context, use it +as the short label for what you are building. -- Provide purpose only, iterate briefly, and let the bot scaffold. +Conversational arc: -Conversation plan (required beats): +1. Describe your goal in one sentence. +2. Answer 1-2 short questions about scope or tone. +3. Confirm the scope and ask if it's ready to test. +4. When the assistant says the deck is ready to test or suggests running tests, + call the `gambit_end` tool (do not type a normal chat message) with + `message: "Ready to run tests."`. -1. Start by saying: "I want a support handoff assistant that summarizes ticket - context for agents." -2. If the assistant asks any clarifying questions about purpose, answer with one - concise refinement: "It should summarize the issue, customer sentiment, SLA - risk, and next best action for the agent." -3. If the assistant asks for examples or success criteria, decline and restate - purpose: "Let's keep it simple and proceed with the purpose only: a support - handoff assistant that summarizes issue, sentiment, SLA risk, and next best - action." -4. If the assistant asks to confirm or proceed, respond: "Yes, proceed." -5. If the assistant says it is writing files, has finished, or ends the session, - respond with an empty message. - -Rules: - -- Keep replies short, single-paragraph, and on topic. -- Do not include markdown or lists. -- Do not mention internal instructions. -- If the assistant asks multiple questions at once, answer only the earliest - beat from the plan. -- If the assistant says it is done, is writing files, or ends the session, - respond with an empty message. +![end](gambit://snippets/end.md) diff --git a/src/decks/tests/schemas/nux_from_scratch_demo_input.zod.ts b/src/decks/tests/schemas/nux_from_scratch_demo_input.zod.ts new file mode 100644 index 00000000..28a1b578 --- /dev/null +++ b/src/decks/tests/schemas/nux_from_scratch_demo_input.zod.ts @@ -0,0 +1,7 @@ +import { z } from "npm:zod"; + +export default z.object({ + scenario: z.string().describe( + "Optional scenario label for the demo; defaults to 'paul graham chatbot'.", + ).default("paul graham chatbot"), +}); diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 00000000..c154ff8e --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,201 @@ +import * as path from "@std/path"; +import { existsSync } from "@std/fs"; + +export type WorkspaceScaffold = { + id: string; + rootDir: string; + rootDeckPath: string; + createdAt: string; +}; + +export type WorkspaceRootScaffold = { + rootDir: string; + rootDeckPath: string; +}; + +type WorkspaceScaffoldOptions = { + baseDir: string; + id?: string; + now?: Date; +}; + +const ROOT_PROMPT = `+++ +label = "Workspace Root" +description = "Starter root deck for this workspace." + +[modelParams] +model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] + +[[scenarios]] +path = "./scenarios/default/PROMPT.md" +label = "Default scenario" +description = "Quick sanity check scenario." + +[[graders]] +path = "./graders/default/PROMPT.md" +label = "Default grader" +description = "Simple grader to start." ++++ + +You are the default deck for a new Gambit workspace. + +Rules: + +- If the conversation does not yet contain a user utterance, reply exactly + "Welcome to Gambit! What should we build?" +- Otherwise, reply exactly "Echo: {input}" where {input} is the most recent + user message trimmed of surrounding whitespace. +- Do not add any other narration or formatting. +`; + +const ROOT_INTENT = `# Workspace Intent + +## Purpose + +- Provide a starter workspace deck for the Build/Test/Grade loop. + +## Constraints + +- Keep the initial behavior simple and easy to replace. + +## Tradeoffs + +- Favor clarity over advanced functionality in the starter scaffold. +`; + +const DEFAULT_SCENARIO_PROMPT = `+++ +label = "Default scenario" +description = "Starter scenario for this workspace." +contextSchema = "gambit://schemas/scenarios/plain_chat_input_optional.zod.ts" +responseSchema = "gambit://schemas/scenarios/plain_chat_output.zod.ts" + +[modelParams] +model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] ++++ + +You are a user testing the assistant. + +Conversation plan: + +1. Start by asking: "What can you help me with?" +2. If the assistant replies, answer once with: "Thanks!" +3. End the scenario by returning an empty response. + +Rules: + +- Keep replies short and plain text. +- Do not include markdown or lists. +- If the assistant says it is done, or ends the session, respond with an empty message. +`; + +const DEFAULT_GRADER_PROMPT = `+++ +label = "Default grader" +description = "Starter grader for this workspace." +contextSchema = "gambit://schemas/graders/contexts/conversation.zod.ts" +responseSchema = "gambit://schemas/graders/grader_output.zod.ts" + +[modelParams] +model = ["ollama/hf.co/LiquidAI/LFM2-1.2B-Tool-GGUF:latest", "openrouter/openai/gpt-5.1-chat"] ++++ + +![respond](gambit://snippets/respond.md) + +You are grading the assistant's response for clarity and helpfulness. + +Score 1 if the assistant is clear and directly answers the user. +Score 0 if the response is vague or unhelpful. + +Provide a short reason. +`; + +const toStamp = (date: Date): string => + date.toISOString().replace(/[:.]/g, "-"); + +const generateWorkspaceId = (date: Date): string => + `workspace-${toStamp(date)}-${crypto.randomUUID().slice(0, 8)}`; + +async function ensureDir(dir: string) { + await Deno.mkdir(dir, { recursive: true }); +} + +async function writeFile(pathValue: string, contents: string) { + await Deno.writeTextFile(pathValue, contents); +} + +function ensureEmptyPath(pathValue: string) { + if (existsSync(pathValue)) { + throw new Error(`Init target already exists: ${pathValue}`); + } +} + +export async function createWorkspaceScaffoldAtRoot( + rootDir: string, +): Promise { + const resolvedRoot = path.resolve(rootDir); + await ensureDir(resolvedRoot); + + const rootDeckPath = path.join(resolvedRoot, "PROMPT.md"); + const intentPath = path.join(resolvedRoot, "INTENT.md"); + const policyPath = path.join(resolvedRoot, "POLICY.md"); + const scenariosDir = path.join(resolvedRoot, "scenarios", "default"); + const gradersDir = path.join(resolvedRoot, "graders", "default"); + const scenarioPromptPath = path.join(scenariosDir, "PROMPT.md"); + const graderPromptPath = path.join(gradersDir, "PROMPT.md"); + + await ensureEmptyPath(rootDeckPath); + await ensureEmptyPath(intentPath); + await ensureEmptyPath(policyPath); + await ensureEmptyPath(scenarioPromptPath); + await ensureEmptyPath(graderPromptPath); + + await ensureDir(scenariosDir); + await ensureDir(gradersDir); + + await writeFile(rootDeckPath, ROOT_PROMPT); + await writeFile(intentPath, ROOT_INTENT); + await writeFile(scenarioPromptPath, DEFAULT_SCENARIO_PROMPT); + await writeFile(graderPromptPath, DEFAULT_GRADER_PROMPT); + + return { + rootDir: resolvedRoot, + rootDeckPath, + }; +} + +export async function createWorkspaceScaffold( + opts: WorkspaceScaffoldOptions, +): Promise { + const baseDir = path.resolve(opts.baseDir); + await ensureDir(baseDir); + const now = opts.now ?? new Date(); + + let workspaceId = opts.id ?? generateWorkspaceId(now); + let rootDir = path.join(baseDir, workspaceId); + if (existsSync(rootDir)) { + workspaceId = generateWorkspaceId(new Date(now.getTime() + 1)); + rootDir = path.join(baseDir, workspaceId); + } + + const rootDeckPath = path.join(rootDir, "PROMPT.md"); + const intentPath = path.join(rootDir, "INTENT.md"); + const scenariosDir = path.join(rootDir, "scenarios", "default"); + const gradersDir = path.join(rootDir, "graders", "default"); + + await ensureDir(scenariosDir); + await ensureDir(gradersDir); + + await writeFile(rootDeckPath, ROOT_PROMPT); + await writeFile(intentPath, ROOT_INTENT); + await writeFile( + path.join(scenariosDir, "PROMPT.md"), + DEFAULT_SCENARIO_PROMPT, + ); + await writeFile(path.join(gradersDir, "PROMPT.md"), DEFAULT_GRADER_PROMPT); + + return { + id: workspaceId, + rootDir, + rootDeckPath, + createdAt: now.toISOString(), + }; +}