diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-codex-operation-skills.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-codex-operation-skills.md new file mode 100644 index 00000000..a9c0b433 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-codex-operation-skills.md @@ -0,0 +1,3 @@ +### Codex receives Boatstack operation drivers + +Codex installations and detached controllers now include the same operation-specific Boatstack skills as other supported coding hosts. A `boatstack-run` session can follow the released planning, preflight, build, evidence, review, and PR boundaries instead of inferring those transitions from the generic adapter. 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 f96700bd..62e6942c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -288,6 +288,16 @@ description: %s `, spec.Name, spec.Description, argumentHint, strings.TrimSpace(operationBody), arguments) } +func codexOperationSkill(spec claudeSkillSpec, operationBody string) string { + return fmt.Sprintf(`--- +name: %s +description: %s +--- + +%s +`, spec.Name, spec.Description, strings.TrimSpace(operationBody)) +} + func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte, adapterName string) (ExportBundle, error) { if !adapterNamePattern.MatchString(adapterName) { return ExportBundle{}, fmt.Errorf("adapter name must be a lowercase kebab-case slug") @@ -518,6 +528,19 @@ If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside 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 Codex skill %s", spec.Name) + } + path := fmt.Sprintf(".agents/skills/%s/SKILL.md", spec.Name) + files[path], err = GeneratedFrontmatter( + codexOperationSkill(spec, commandBody(spec.Name, extra)), + ) + if err != nil { + return ExportBundle{}, err + } + } } if contains(adapters, "github") { files[fmt.Sprintf(".github/PULL_REQUEST_TEMPLATE/%s.md", adapterName)] = GeneratedMarkdown(`# Reviewer-ready change 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 f7babc29..4fb53027 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 @@ -110,6 +110,8 @@ func TestExportAndDriftCheck(t *testing.T) { ".gemini/skills/auto-plan/SKILL.md", ".gemini/skills/boatstack-update/SKILL.md", ".agents/skills/boatstack/SKILL.md", + ".agents/skills/boatstack-run/SKILL.md", + ".agents/skills/auto-plan/SKILL.md", ".product-loop/.gitignore", ".product-loop/templates/plan.md", ".product-loop/templates/approval.md", @@ -163,6 +165,40 @@ func TestExportAndDriftCheck(t *testing.T) { } } + codexSkillPaths := map[string]bool{} + for path := range bundle.Files { + if strings.HasPrefix(path, ".agents/skills/") && strings.HasSuffix(path, "/SKILL.md") { + codexSkillPaths[path] = true + } + } + if len(codexSkillPaths) != len(claudeVisibleSkills)+1 { + t.Fatalf("generated %d Codex skills, want %d: %#v", len(codexSkillPaths), len(claudeVisibleSkills)+1, codexSkillPaths) + } + for _, spec := range claudeVisibleSkills { + path := ".agents/skills/" + spec.Name + "/SKILL.md" + skill := string(bundle.Files[path]) + for _, expected := range []string{ + "name: " + spec.Name, + "description: " + spec.Description, + "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) + } + } + } + codexRun := string(bundle.Files[".agents/skills/boatstack-run/SKILL.md"]) + for _, expected := range []string{ + "If status is NOT_STARTED, route to auto-plan", + "planning and plan-gate do not require delivery preflight", + } { + if !strings.Contains(codexRun, expected) { + t.Fatalf("Codex boatstack-run skill is missing operation boundary %q", expected) + } + } + geminiSkillPaths := map[string]bool{} for path := range bundle.Files { if strings.HasPrefix(path, ".gemini/skills/") && strings.HasSuffix(path, "/SKILL.md") { @@ -495,6 +531,9 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { if _, exists := bundle.Files[".gemini/skills/"+spec.Name+"/SKILL.md"]; !exists { t.Fatalf("Gemini does not expose user operation %q", spec.Name) } + if _, exists := bundle.Files[".agents/skills/"+spec.Name+"/SKILL.md"]; !exists { + t.Fatalf("Codex 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)) { 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 7d3d9888..5c4dd0d4 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 @@ -34,7 +34,7 @@ func TestGeneratedSkillFrontmatterIsValidYAML(t *testing.T) { expected += len(claudeVisibleSkills) + 1 } if contains(config.Adapters, "codex") { - expected += 1 + expected += len(claudeVisibleSkills) + 1 } if skillCount != expected { t.Fatalf("validated %d generated skills, want %d", skillCount, expected)