diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-gemini-cli-integration.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-gemini-cli-integration.md new file mode 100644 index 000000000..a767fc7f3 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-gemini-cli-integration.md @@ -0,0 +1,3 @@ +### Integrate Gemini CLI into Boatstack as a first-class supported adapter + +Gemini CLI is now fully supported as a first-class adapter for Boatstack. The local projector automatically packages and brands the `gemini.yaml` agent configuration, and exports the full suite of interactive, visible `boatstack-` skills under `.gemini/skills/` to provide a seamless, native conversational delivery and repair experience within the Gemini CLI environment. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/agents/gemini.yaml b/labs/12-product-engineering-loop/product-engineering-loop/agents/gemini.yaml new file mode 100644 index 000000000..2e5572311 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/agents/gemini.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Evidence-Engineered Coding (Gemini)" + short_description: "Build freely; approve, verify, review, and ship with evidence using Gemini CLI." + default_prompt: "Use $product-engineering-loop as an evidence-engineered coding node: keep implementation tactics open and require evidence for approval, completion, review, and shipping." diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index 368dfbc36..a37179b83 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -16,6 +16,7 @@ var allowedAdapters = map[string]bool{ "cursor": true, "claude": true, "codex": true, + "gemini": true, "github": true, } @@ -114,7 +115,7 @@ func ValidateConfig(config ProjectConfig) error { func normalizedAdapters(adapters []string) []string { if len(adapters) == 0 { - return []string{"claude", "codex", "cursor", "github"} + return []string{"claude", "codex", "cursor", "gemini", "github"} } seen := map[string]bool{} for _, adapter := range adapters { @@ -335,6 +336,31 @@ If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside } } } + if contains(adapters, "gemini") { + geminiAdapterSkill := strings.Replace( + adapterSkill, + "\n---\n\n# Boatstack adapter", + "\nuser-invocable: false\n---\n\n# Boatstack adapter", + 1, + ) + files[fmt.Sprintf(".gemini/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(geminiAdapterSkill) + if err != nil { + return ExportBundle{}, err + } + for _, spec := range claudeVisibleSkills { + extra, ok := operations[spec.Name] + if !ok { + return ExportBundle{}, fmt.Errorf("missing operation instructions for Gemini skill %s", spec.Name) + } + path := fmt.Sprintf(".gemini/skills/%s/SKILL.md", spec.Name) + files[path], err = GeneratedFrontmatter( + claudeOperationSkill(spec, commandBody(spec.Name, extra)), + ) + if err != nil { + return ExportBundle{}, err + } + } + } if contains(adapters, "codex") { codexAdapterSkill := strings.Replace( adapterSkill, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go index 1b958db78..81045affd 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go @@ -66,7 +66,7 @@ func testConfig() ProjectConfig { Name: "fixture", Commands: map[string]string{"test": "go test ./..."}, }, Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, - Adapters: []string{"cursor", "claude", "codex", "github"}, + Adapters: []string{"cursor", "claude", "codex", "gemini", "github"}, Integrations: map[string]IntegrationState{ "gstack": {Requested: false, Version: GStackRef}, "spec-kit": {Requested: false, Version: SpecKitVersion}, @@ -106,6 +106,9 @@ func TestExportAndDriftCheck(t *testing.T) { ".claude/skills/boatstack/SKILL.md", ".claude/skills/auto-plan/SKILL.md", ".claude/skills/boatstack-update/SKILL.md", + ".gemini/skills/boatstack/SKILL.md", + ".gemini/skills/auto-plan/SKILL.md", + ".gemini/skills/boatstack-update/SKILL.md", ".agents/skills/boatstack/SKILL.md", ".product-loop/.gitignore", ".product-loop/templates/plan.md", @@ -159,6 +162,48 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatalf("internal or alias operation must not be a visible Claude skill: %s", path) } } + + geminiSkillPaths := map[string]bool{} + for path := range bundle.Files { + if strings.HasPrefix(path, ".gemini/skills/") && strings.HasSuffix(path, "/SKILL.md") { + geminiSkillPaths[path] = true + } + } + if len(geminiSkillPaths) != len(claudeVisibleSkills)+1 { + t.Fatalf("generated %d Gemini skills, want %d: %#v", len(geminiSkillPaths), len(claudeVisibleSkills)+1, geminiSkillPaths) + } + for _, spec := range claudeVisibleSkills { + path := ".gemini/skills/" + spec.Name + "/SKILL.md" + skill := string(bundle.Files[path]) + for _, expected := range []string{ + "name: " + spec.Name, + "description: " + spec.Description, + "disable-model-invocation: true", + "Run the " + spec.Name + " operation", + ".product-loop/workflow.md", + "User-facing response contract", + } { + if !strings.Contains(skill, expected) { + t.Fatalf("%s is missing %q", path, expected) + } + } + } + geminiAutoPlan := string(bundle.Files[".gemini/skills/auto-plan/SKILL.md"]) + for _, expected := range []string{`argument-hint: "[plan-file]"`, "$ARGUMENTS", "/auto-plan "} { + if !strings.Contains(geminiAutoPlan, expected) { + t.Fatalf("Gemini auto-plan skill is missing argument behavior %q", expected) + } + } + geminiRouter := string(bundle.Files[".gemini/skills/boatstack/SKILL.md"]) + if !strings.Contains(geminiRouter, "user-invocable: false") || strings.Contains(geminiRouter, "disable-model-invocation: true") { + t.Fatal("Gemini Boatstack router must be hidden from users but available to the model") + } + for _, operation := range []string{"retro", "review", "ship"} { + path := ".gemini/skills/" + operation + "/SKILL.md" + if geminiSkillPaths[path] { + t.Fatalf("internal or alias operation must not be a visible Gemini skill: %s", path) + } + } if _, exists := bundle.Files[".product-loop/tools/approve_plan.py"]; exists { t.Fatal("public export must not contain Python runtime tools") } @@ -296,7 +341,7 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatalf("run adapter is missing startup recovery rule %q", expected) } } - for _, path := range []string{".claude/skills/boatstack/SKILL.md", ".agents/skills/boatstack/SKILL.md"} { + for _, path := range []string{".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md", ".agents/skills/boatstack/SKILL.md"} { router := string(bundle.Files[path]) if !strings.Contains(router, "automatically use repair") || !strings.Contains(router, "active managed delivery") { t.Fatalf("%s does not auto-route free-form delivery changes", path) @@ -369,7 +414,7 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatalf("canonical workflow is missing safety boundary %q", expected) } } - for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md"} { + for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md"} { adapter := string(bundle.Files[path]) for _, expected := range []string{"User-facing response contract", "exactly one Next step", "a approves the pending plan", "o opens the currently previewed feature/ad-hoc/update PR", "u updates the currently previewed existing PR", "r accepts every recommendation", "Bracketed forms such as [o]", "Continue accepting approve, open PR, update PR, and open update PR for compatibility", "do not advertise them in user-facing responses", "Never interpret r as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization", "1a/1b/1c and 2a/2b/2c", "exactly one recommendation", "Echo the selected question-to-answer mapping", "filesystem username", "Never create or advertise a /pr-brief command", "state-scoped o to open or u to update", "boatstack-update"} { if !strings.Contains(adapter, expected) { @@ -427,6 +472,9 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { if _, exists := bundle.Files[".claude/skills/"+spec.Name+"/SKILL.md"]; !exists { t.Fatalf("Claude does not expose user operation %q", spec.Name) } + if _, exists := bundle.Files[".gemini/skills/"+spec.Name+"/SKILL.md"]; !exists { + t.Fatalf("Gemini does not expose user operation %q", spec.Name) + } } for _, expected := range []string{"source plan", "plan.md", "approval.md", "evidence", "gaps", "review", "pr.md"} { if !strings.Contains(strings.ToLower(artifacts), strings.ToLower(expected)) { @@ -437,6 +485,7 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { hostSurfaces := map[string]string{ "cursor": string(bundle.Files[".cursor/rules/boatstack.mdc"]), "claude": string(bundle.Files[".claude/skills/boatstack/SKILL.md"]), + "gemini": string(bundle.Files[".gemini/skills/boatstack/SKILL.md"]), "codex": string(bundle.Files[".agents/skills/boatstack/SKILL.md"]), } for host, surface := range hostSurfaces { @@ -453,6 +502,9 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { if !strings.Contains(hostSurfaces["claude"], operation) { t.Fatalf("Claude natural-language router does not declare portable operation %q", operation) } + if !strings.Contains(hostSurfaces["gemini"], operation) { + t.Fatalf("Gemini natural-language router does not declare portable operation %q", operation) + } } } @@ -460,6 +512,7 @@ func TestExportRefusesUserOwnedCollision(t *testing.T) { for _, relative := range []string{ ".cursor/rules/boatstack.mdc", ".claude/skills/auto-plan/SKILL.md", + ".gemini/skills/auto-plan/SKILL.md", } { t.Run(relative, func(t *testing.T) { repo := t.TempDir() diff --git a/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter.go b/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter.go index 4558a4de8..afbc819f9 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter.go @@ -111,6 +111,8 @@ func skillFieldsForPath(path string) (map[string]string, error) { return codexSkillFields, nil case strings.HasPrefix(path, ".claude/skills/"): return claudeSkillFields, nil + case strings.HasPrefix(path, ".gemini/skills/"): + return claudeSkillFields, nil default: return nil, fmt.Errorf("unsupported generated skill path") } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter_test.go b/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter_test.go index 644d4ebd4..7d3d98887 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/skill_frontmatter_test.go @@ -26,7 +26,17 @@ func TestGeneratedSkillFrontmatterIsValidYAML(t *testing.T) { t.Errorf("%s has invalid frontmatter: %v", path, err) } } - if expected := len(claudeVisibleSkills) + 2; skillCount != expected { + expected := 0 + if contains(config.Adapters, "claude") { + expected += len(claudeVisibleSkills) + 1 + } + if contains(config.Adapters, "gemini") { + expected += len(claudeVisibleSkills) + 1 + } + if contains(config.Adapters, "codex") { + expected += 1 + } + if skillCount != expected { t.Fatalf("validated %d generated skills, want %d", skillCount, expected) } } @@ -45,6 +55,7 @@ func TestBoatstackRoutersHaveUnindentedTopLevelKeys(t *testing.T) { for _, path := range []string{ ".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md", + ".gemini/skills/boatstack/SKILL.md", } { frontmatter := skillFrontmatterForTest(t, bundle.Files[path]) if !strings.Contains(frontmatter, "\ndescription: Use when") { diff --git a/labs/12-product-engineering-loop/project.example.json b/labs/12-product-engineering-loop/project.example.json index b65c7094f..b8c0781ac 100644 --- a/labs/12-product-engineering-loop/project.example.json +++ b/labs/12-product-engineering-loop/project.example.json @@ -37,5 +37,5 @@ "version": "v0.12.16" } }, - "adapters": ["cursor", "claude", "codex", "github"] + "adapters": ["cursor", "claude", "codex", "gemini", "github"] } diff --git a/labs/12-product-engineering-loop/scripts/build_boatstack.py b/labs/12-product-engineering-loop/scripts/build_boatstack.py index 1e33ad807..f085d0d08 100644 --- a/labs/12-product-engineering-loop/scripts/build_boatstack.py +++ b/labs/12-product-engineering-loop/scripts/build_boatstack.py @@ -61,7 +61,7 @@ def canonical_context_metrics() -> dict[str, object]: def branded_skill(relative: Path, content: bytes) -> bytes: - if relative not in {Path("SKILL.md"), Path("agents/openai.yaml")}: + if relative not in {Path("SKILL.md"), Path("agents/openai.yaml"), Path("agents/gemini.yaml")}: return content rendered = content.decode() if relative == Path("SKILL.md"): @@ -70,6 +70,9 @@ def branded_skill(relative: Path, content: bytes) -> bytes: elif relative == Path("agents/openai.yaml"): rendered = rendered.replace('display_name: "Evidence-Engineered Coding"', 'display_name: "Boatstack"') rendered = rendered.replace("$product-engineering-loop", "$boatstack") + elif relative == Path("agents/gemini.yaml"): + rendered = rendered.replace('display_name: "Evidence-Engineered Coding (Gemini)"', 'display_name: "Boatstack (Gemini)"') + rendered = rendered.replace("$product-engineering-loop", "$boatstack") return rendered.encode() diff --git a/labs/12-product-engineering-loop/tests/test_product_loop.py b/labs/12-product-engineering-loop/tests/test_product_loop.py index 65500cce2..9d8e0b6e1 100644 --- a/labs/12-product-engineering-loop/tests/test_product_loop.py +++ b/labs/12-product-engineering-loop/tests/test_product_loop.py @@ -508,6 +508,7 @@ def test_export_and_drift_check(self) -> None: ): self.assertIn(expected, pr_template) self.assertTrue((repo / ".claude/skills/boatstack/SKILL.md").is_file()) + self.assertTrue((repo / ".gemini/skills/boatstack/SKILL.md").is_file()) self.assertTrue((repo / ".agents/skills/boatstack/SKILL.md").is_file()) codex_skill = (repo / ".agents/skills/boatstack/SKILL.md").read_text() self.assertIn( @@ -520,6 +521,8 @@ def test_export_and_drift_check(self) -> None: self.assertNotIn("into collapsed Technical details", codex_skill) claude_skill = (repo / ".claude/skills/boatstack/SKILL.md").read_text() self.assertIn("into collapsed Technical details", claude_skill) + gemini_skill = (repo / ".gemini/skills/boatstack/SKILL.md").read_text() + self.assertIn("into collapsed Technical details", gemini_skill) workflow = (repo / ".product-loop/workflow.md").read_text() self.assertIn( "Unknown hosts default to the portable Markdown form", workflow @@ -536,6 +539,14 @@ def test_export_and_drift_check(self) -> None: generated_claude_skills, sorted((*visible_claude_skills, "boatstack")), ) + generated_gemini_skills = sorted( + path.parent.name + for path in (repo / ".gemini/skills").glob("*/SKILL.md") + ) + self.assertEqual( + generated_gemini_skills, + sorted((*visible_claude_skills, "boatstack")), + ) for operation in visible_claude_skills: skill = (repo / f".claude/skills/{operation}/SKILL.md").read_text() self.assertIn(f"name: {operation}", skill) @@ -543,6 +554,12 @@ def test_export_and_drift_check(self) -> None: self.assertIn(f"Run the {operation} operation", skill) self.assertIn(".product-loop/workflow.md", skill) self.assertIn("User-facing response contract", skill) + g_skill = (repo / f".gemini/skills/{operation}/SKILL.md").read_text() + self.assertIn(f"name: {operation}", g_skill) + self.assertIn("disable-model-invocation: true", g_skill) + self.assertIn(f"Run the {operation} operation", g_skill) + self.assertIn(".product-loop/workflow.md", g_skill) + self.assertIn("User-facing response contract", g_skill) next_command = (repo / ".cursor/commands/boatstack-next.md").read_text() self.assertIn("next-status --repo . --json", next_command) @@ -558,17 +575,31 @@ def test_export_and_drift_check(self) -> None: ).read_text() self.assertIn('argument-hint: "[plan-file]"', claude_auto_plan) self.assertIn("$ARGUMENTS", claude_auto_plan) + gemini_auto_plan = ( + repo / ".gemini/skills/auto-plan/SKILL.md" + ).read_text() + self.assertIn('argument-hint: "[plan-file]"', gemini_auto_plan) + self.assertIn("$ARGUMENTS", gemini_auto_plan) claude_router = ( repo / ".claude/skills/boatstack/SKILL.md" ).read_text() self.assertIn("user-invocable: false", claude_router) self.assertNotIn("disable-model-invocation: true", claude_router) + gemini_router = ( + repo / ".gemini/skills/boatstack/SKILL.md" + ).read_text() + self.assertIn("user-invocable: false", gemini_router) + self.assertNotIn("disable-model-invocation: true", gemini_router) for hidden in ("retro", "review", "ship"): self.assertFalse( (repo / f".claude/skills/{hidden}/SKILL.md").exists() ) + self.assertFalse( + (repo / f".gemini/skills/{hidden}/SKILL.md").exists() + ) for adapter in ( repo / ".claude/skills/boatstack/SKILL.md", + repo / ".gemini/skills/boatstack/SKILL.md", repo / ".agents/skills/boatstack/SKILL.md", ): value = adapter.read_text()