From d7d7ab0d11fab2ac554c34b64a1dce0cab833efe Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Tue, 12 May 2026 15:02:33 +0100 Subject: [PATCH 1/2] feat: add guided Sprint 0 bootstrapping workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bootstrap_sprint_zero MCP tool that drives a multi-step workflow (survey → draft → populate → review → commit) to create a fully scaffolded Sprint 0 with linked bootstrapping actions. The populate step consumes the Sprint 0 checklist from the concept registry, so adding new bootstrapping categories requires no tool changes. AEM-specific addendum items are auto-included when the project methodology is sap-aem. Duplicate Sprint 0 creation is prevented. Also updates get_started to recommend bootstrap_sprint_zero by name when the project has work items but no sprints, and adds normalizeMethodology() to map config IDs (sap-aem) to concept registry values (aem). --- src/agent/mcp-server.ts | 5 + src/doctor/health/onboarding.ts | 10 +- src/methodology/bootstrap-tools.ts | 91 +++++ src/methodology/bootstrap.ts | 368 ++++++++++++++++++++ test/methodology/bootstrap-tools.test.ts | 120 +++++++ test/methodology/bootstrap.test.ts | 413 +++++++++++++++++++++++ 6 files changed, 1004 insertions(+), 3 deletions(-) create mode 100644 src/methodology/bootstrap-tools.ts create mode 100644 src/methodology/bootstrap.ts create mode 100644 test/methodology/bootstrap-tools.test.ts create mode 100644 test/methodology/bootstrap.test.ts diff --git a/src/agent/mcp-server.ts b/src/agent/mcp-server.ts index 28a0b45..fc48a0a 100644 --- a/src/agent/mcp-server.ts +++ b/src/agent/mcp-server.ts @@ -16,6 +16,7 @@ import { createSessionTools } from "./tools/sessions.js"; import { createWebTools } from "./tools/web.js"; import { createDoctorTools } from "./tools/doctor.js"; import { createConceptTools } from "../methodology/tools.js"; +import { createBootstrapTools } from "../methodology/bootstrap-tools.js"; import type { NavGroup } from "../web/templates/layout.js"; export interface McpServerOptions { @@ -52,6 +53,10 @@ export function createMarvinMcpServer( marvinDir: options?.marvinDir, }), ...createConceptTools(options?.config), + ...createBootstrapTools(store, { + config: options?.config, + manifest: options?.manifest, + }), ]; return createSdkMcpServer({ diff --git a/src/doctor/health/onboarding.ts b/src/doctor/health/onboarding.ts index a312589..9d0d36d 100644 --- a/src/doctor/health/onboarding.ts +++ b/src/doctor/health/onboarding.ts @@ -105,12 +105,16 @@ export function buildOnboardingGuide(ctx: HealthContext): OnboardingGuide { }); // Step 6: Set up Sprint 0 + const hasWorkItems = hasActions || hasFeatures || hasUseCases; steps.push({ order: order++, title: "Set up Sprint 0", - description: - "As DM, create a Sprint 0 to organize bootstrapping work: infrastructure provisioning, CI/CD setup, backlog refinement, and ceremony scheduling. Sprint 0 is not a regular sprint — it's a variable-duration bootstrapping phase that ensures the team is ready for Sprint 1.", - tool: "create_sprint", + description: hasSprints + ? "Sprint 0 has been created." + : hasWorkItems + ? "As DM, run bootstrap_sprint_zero to create a guided Sprint 0 with linked bootstrapping actions for infrastructure, backlog refinement, ceremonies, and integrations. This generates a fully scaffolded sprint with checklist items pre-populated." + : "As DM, create a Sprint 0 to organize bootstrapping work: infrastructure provisioning, CI/CD setup, backlog refinement, and ceremony scheduling. Sprint 0 is not a regular sprint — it's a variable-duration bootstrapping phase that ensures the team is ready for Sprint 1.", + tool: hasWorkItems && !hasSprints ? "bootstrap_sprint_zero" : "create_sprint", done: hasSprints, }); diff --git a/src/methodology/bootstrap-tools.ts b/src/methodology/bootstrap-tools.ts new file mode 100644 index 0000000..3ca4825 --- /dev/null +++ b/src/methodology/bootstrap-tools.ts @@ -0,0 +1,91 @@ +import { z } from "zod/v4"; +import { tool, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; +import type { DocumentStore } from "../storage/store.js"; +import type { MarvinProjectConfig } from "../core/config.js"; +import type { SourceManifestManager } from "../sources/manifest.js"; +import { runStep, type BootstrapStep, type BootstrapSection } from "./bootstrap.js"; + +export interface BootstrapToolOptions { + config?: MarvinProjectConfig; + manifest?: SourceManifestManager; +} + +export function createBootstrapTools( + store: DocumentStore, + options?: BootstrapToolOptions, +): SdkMcpToolDefinition[] { + return [ + tool( + "bootstrap_sprint_zero", + "Guided multi-step workflow that produces a draft Sprint 0 with linked bootstrapping actions. Call with no arguments to start at survey step. Each step returns next_step to chain calls. Only the commit step writes to disk; all other steps are read-only.", + { + step: z + .enum(["survey", "draft", "populate", "review", "commit"]) + .optional() + .describe("Workflow step. Omit on first call to start at survey."), + section: z + .enum([ + "infrastructure-provisioning", + "backlog-refinement", + "ceremony-scheduling", + "integration-setup", + "aem-addendum", + ]) + .optional() + .describe("Restrict populate step to one section."), + includeAemAddendum: z + .boolean() + .optional() + .describe( + "Override AEM addendum inclusion. Default: auto-detect from methodology config.", + ), + }, + async (args) => { + if (!options?.config) { + return { + content: [ + { + type: "text" as const, + text: "Bootstrap unavailable: project config not initialized.", + }, + ], + isError: true, + }; + } + + const ctx = { + store, + config: options.config, + manifest: options.manifest, + }; + + try { + const result = runStep( + ctx, + args.step as BootstrapStep | undefined, + args.section as BootstrapSection | undefined, + ); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Bootstrap error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ), + ]; +} diff --git a/src/methodology/bootstrap.ts b/src/methodology/bootstrap.ts new file mode 100644 index 0000000..08d94c1 --- /dev/null +++ b/src/methodology/bootstrap.ts @@ -0,0 +1,368 @@ +import type { DocumentStore } from "../storage/store.js"; +import type { MarvinProjectConfig } from "../core/config.js"; +import type { SourceManifestManager } from "../sources/manifest.js"; +import type { Methodology, ChecklistItem } from "./types.js"; +import { normalizeMethodology } from "./types.js"; +import { getConceptRegistry } from "./registry.js"; + +export type BootstrapStep = "survey" | "draft" | "populate" | "review" | "commit"; + +export type BootstrapSection = + | "infrastructure-provisioning" + | "backlog-refinement" + | "ceremony-scheduling" + | "integration-setup" + | "aem-addendum"; + +export interface SurveyResult { + step: "survey"; + projectState: { + methodology: string; + existingSprints: number; + featuresCount: number; + actionsCount: number; + decisionsCount: number; + integrationsConfigured: { jira: boolean; confluence: boolean }; + }; + alreadySatisfied: { item: string; evidence: string }[]; + nextStep: "draft"; +} + +export interface DraftResult { + step: "draft"; + sprintDraft: { + idSuggested: string; + title: string; + goal: string; + startDate: null; + endDate: null; + type: "sprint"; + tags: string[]; + }; + nextStep: "populate"; +} + +export interface ProposedItem { + kind: "action"; + title: string; + ownerPersona: string; + status: "pending"; + skipReason: string | null; +} + +export interface PopulateResult { + step: "populate"; + section: string; + proposedItems: ProposedItem[]; + nextStep: string; +} + +export interface ReviewResult { + step: "review"; + summary: { + sprint: DraftResult["sprintDraft"]; + itemsToCreate: number; + itemsSkipped: number; + warnings: string[]; + }; + nextStep: "commit"; +} + +export interface CommitResult { + step: "commit"; + sprintId: string; + actionIds: string[]; + totalCreated: number; +} + +export type BootstrapResult = + | SurveyResult + | DraftResult + | PopulateResult + | ReviewResult + | CommitResult; + +export interface BootstrapContext { + store: DocumentStore; + config: MarvinProjectConfig; + manifest?: SourceManifestManager; +} + +function getMethodology(config: MarvinProjectConfig): Methodology { + return normalizeMethodology(config.methodology); +} + +function getChecklist(methodology: Methodology): ChecklistItem[] { + const registry = getConceptRegistry(); + const sprint0 = registry.explain("sprint-0", methodology); + return sprint0?.checklist ?? []; +} + +function getSections(methodology: Methodology): string[] { + return getChecklist(methodology).map((c) => c.category); +} + +function findExistingSprint0(store: DocumentStore): string | undefined { + const sprints = store.list({ type: "sprint" }); + const match = sprints.find((s) => { + const tags: string[] = s.frontmatter.tags ?? []; + return ( + tags.includes("sprint-0") || + tags.includes("bootstrapping") || + s.frontmatter.title?.toLowerCase().includes("sprint 0") + ); + }); + return match?.frontmatter.id; +} + +export function survey(ctx: BootstrapContext): SurveyResult { + const { store, config } = ctx; + const counts = store.counts(); + const methodology = getMethodology(config); + + const alreadySatisfied: { item: string; evidence: string }[] = []; + + // Check Marvin init + alreadySatisfied.push({ + item: "Marvin project initialized", + evidence: ".marvin/config.yaml exists", + }); + + // Check if Jira is configured + if (config.jira?.projectKey?.trim()) { + alreadySatisfied.push({ + item: "Jira integration configured", + evidence: `projectKey: ${config.jira.projectKey}`, + }); + } + + return { + step: "survey", + projectState: { + methodology, + existingSprints: counts["sprint"] ?? 0, + featuresCount: counts["feature"] ?? 0, + actionsCount: counts["action"] ?? 0, + decisionsCount: counts["decision"] ?? 0, + integrationsConfigured: { + jira: !!config.jira?.projectKey?.trim(), + confluence: !!config.jira?.projectKey?.trim(), + }, + }, + alreadySatisfied, + nextStep: "draft", + }; +} + +export function draft(ctx: BootstrapContext): DraftResult { + const methodology = getMethodology(ctx.config); + const sections = getSections(methodology); + + const goalParts = sections.map((s) => s.replace(/-/g, " ")); + const goal = `Project bootstrapping — ${goalParts.join(", ")}`; + + return { + step: "draft", + sprintDraft: { + idSuggested: `SP-${String(ctx.store.list({ type: "sprint" }).length + 1).padStart(3, "0")}`, + title: "Sprint 0", + goal, + startDate: null, + endDate: null, + type: "sprint", + tags: ["sprint-0", "bootstrapping"], + }, + nextStep: "populate", + }; +} + +export function populate(ctx: BootstrapContext, section?: BootstrapSection): PopulateResult { + const methodology = getMethodology(ctx.config); + const checklist = getChecklist(methodology); + const surveyResult = survey(ctx); + const satisfiedItems = new Set(surveyResult.alreadySatisfied.map((s) => s.item)); + + // Find the target section + const targetSections = section ? checklist.filter((c) => c.category === section) : checklist; + + if (targetSections.length === 0) { + return { + step: "populate", + section: section ?? "all", + proposedItems: [], + nextStep: "review", + }; + } + + const currentSection = targetSections[0]; + const allSections = getSections(methodology); + const currentIdx = allSections.indexOf(currentSection.category); + const hasMore = !section && currentIdx < allSections.length - 1; + + const ownerMap: Record = { + "infrastructure-provisioning": "tl", + "backlog-refinement": "po", + "ceremony-scheduling": "dm", + "integration-setup": "dm", + "aem-addendum": "dm", + }; + + const proposedItems: ProposedItem[] = []; + + for (const sec of targetSections) { + const owner = ownerMap[sec.category] ?? "dm"; + for (const item of sec.items) { + const isAlreadySatisfied = satisfiedItems.has(item); + proposedItems.push({ + kind: "action", + title: `[Sprint 0] ${formatItemTitle(sec.category, item)}`, + ownerPersona: owner, + status: "pending", + skipReason: isAlreadySatisfied ? "Already satisfied" : null, + }); + } + } + + const nextStep = hasMore ? `populate (next: ${allSections[currentIdx + 1]})` : "review"; + + return { + step: "populate", + section: section ?? currentSection.category, + proposedItems, + nextStep, + }; +} + +function formatItemTitle(category: string, item: string): string { + const categoryLabels: Record = { + "infrastructure-provisioning": "Infrastructure", + "backlog-refinement": "Backlog", + "ceremony-scheduling": "Ceremonies", + "integration-setup": "Integration", + "aem-addendum": "AEM", + }; + const label = categoryLabels[category] ?? category; + return `${label}: ${item}`; +} + +export function review(ctx: BootstrapContext): ReviewResult { + const allItems = populate(ctx); + const draftResult = draft(ctx); + + const toCreate = allItems.proposedItems.filter((i) => !i.skipReason); + const skipped = allItems.proposedItems.filter((i) => i.skipReason); + + const warnings: string[] = []; + if (!draftResult.sprintDraft.startDate) { + warnings.push("startDate not yet set"); + } + if (!draftResult.sprintDraft.endDate) { + warnings.push("endDate not yet set"); + } + + // Check for existing Sprint 0 + const existingId = findExistingSprint0(ctx.store); + if (existingId) { + warnings.push(`Sprint 0 already exists: ${existingId}`); + } + + // Check if we have enough items + const populateAll = populate(ctx); + if (populateAll.proposedItems.length === 0) { + warnings.push("No checklist items found for this methodology"); + } + + return { + step: "review", + summary: { + sprint: draftResult.sprintDraft, + itemsToCreate: toCreate.length, + itemsSkipped: skipped.length, + warnings, + }, + nextStep: "commit", + }; +} + +export function commit(ctx: BootstrapContext): CommitResult { + const { store } = ctx; + + // Check for existing Sprint 0 + const existingId = findExistingSprint0(store); + if (existingId) { + throw new Error( + `Sprint 0 already exists (${existingId}). Cannot create a duplicate. Use get_sprint("${existingId}") to view it.`, + ); + } + + const draftResult = draft(ctx); + + // Create the sprint document + const sprintDoc = store.create( + "sprint", + { + title: draftResult.sprintDraft.title, + status: "planned", + goal: draftResult.sprintDraft.goal, + tags: draftResult.sprintDraft.tags, + linkedEpics: [], + }, + `# ${draftResult.sprintDraft.title}\n\n${draftResult.sprintDraft.goal}\n\nThis sprint was generated by bootstrap_sprint_zero.`, + ); + const sprintId = sprintDoc.frontmatter.id; + + // Create action items for each non-skipped checklist item + const populateResult = populate(ctx); + const actionIds: string[] = []; + + for (const item of populateResult.proposedItems) { + if (item.skipReason) continue; + + const actionDoc = store.create( + "action", + { + title: item.title, + status: "open", + owner: item.ownerPersona, + tags: [`sprint:${sprintId}`, "sprint-0"], + }, + `Bootstrapping action for Sprint 0.\n\nCategory: ${extractCategory(item.title)}\nOwner persona: ${item.ownerPersona}`, + ); + actionIds.push(actionDoc.frontmatter.id); + } + + return { + step: "commit", + sprintId, + actionIds, + totalCreated: actionIds.length + 1, // actions + sprint + }; +} + +function extractCategory(title: string): string { + const match = title.match(/\[Sprint 0\] (\w+):/); + return match ? match[1] : "general"; +} + +export function runStep( + ctx: BootstrapContext, + step?: BootstrapStep, + section?: BootstrapSection, +): BootstrapResult { + const effectiveStep = step ?? "survey"; + + switch (effectiveStep) { + case "survey": + return survey(ctx); + case "draft": + return draft(ctx); + case "populate": + return populate(ctx, section); + case "review": + return review(ctx); + case "commit": + return commit(ctx); + default: + throw new Error(`Unknown step: ${effectiveStep}`); + } +} diff --git a/test/methodology/bootstrap-tools.test.ts b/test/methodology/bootstrap-tools.test.ts new file mode 100644 index 0000000..cead7a7 --- /dev/null +++ b/test/methodology/bootstrap-tools.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { DocumentStore } from "../../src/storage/store.js"; +import type { MarvinProjectConfig } from "../../src/core/config.js"; +import { COMMON_REGISTRATIONS } from "../../src/plugins/common.js"; +import { createBootstrapTools } from "../../src/methodology/bootstrap-tools.js"; + +function extractHandler(tools: any[], name: string): (args: any) => Promise { + const t = tools.find((tool) => tool.name === name); + if (!t) throw new Error(`Tool "${name}" not found`); + return (t as any).handler; +} + +function parseResult(result: any): any { + return JSON.parse(result.content[0].text); +} + +describe("bootstrap_sprint_zero tool", () => { + let tmpDir: string; + let marvinDir: string; + let store: DocumentStore; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "marvin-bootstrap-tool-test-")); + marvinDir = path.join(tmpDir, ".marvin"); + fs.mkdirSync(path.join(marvinDir, "docs", "decisions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "actions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "questions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "sprints"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "features"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "epics"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "tasks"), { recursive: true }); + store = new DocumentStore(marvinDir, COMMON_REGISTRATIONS); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function getConfig(methodology = "generic-agile"): MarvinProjectConfig { + return { name: "test", methodology } as MarvinProjectConfig; + } + + it("creates exactly 1 tool", () => { + const tools = createBootstrapTools(store, { config: getConfig() }); + expect(tools.length).toBe(1); + expect(tools[0].name).toBe("bootstrap_sprint_zero"); + }); + + it("starts at survey when no step specified", async () => { + const tools = createBootstrapTools(store, { config: getConfig() }); + const handler = extractHandler(tools, "bootstrap_sprint_zero"); + + const result = await handler({}); + const data = parseResult(result); + expect(data.step).toBe("survey"); + expect(data.nextStep).toBe("draft"); + }); + + it("returns error when config not available", async () => { + const tools = createBootstrapTools(store); + const handler = extractHandler(tools, "bootstrap_sprint_zero"); + + const result = await handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("config not initialized"); + }); + + it("runs each step correctly", async () => { + store.create("feature", { title: "F1", status: "draft" }, "desc"); + const tools = createBootstrapTools(store, { config: getConfig() }); + const handler = extractHandler(tools, "bootstrap_sprint_zero"); + + const surveyResult = await handler({}); + expect(parseResult(surveyResult).step).toBe("survey"); + + const draftResult = await handler({ step: "draft" }); + expect(parseResult(draftResult).step).toBe("draft"); + + const populateResult = await handler({ step: "populate" }); + expect(parseResult(populateResult).step).toBe("populate"); + + const reviewResult = await handler({ step: "review" }); + expect(parseResult(reviewResult).step).toBe("review"); + + const commitResult = await handler({ step: "commit" }); + expect(parseResult(commitResult).step).toBe("commit"); + expect(parseResult(commitResult).sprintId).toMatch(/^SP-/); + }); + + it("returns error on duplicate commit", async () => { + const tools = createBootstrapTools(store, { config: getConfig() }); + const handler = extractHandler(tools, "bootstrap_sprint_zero"); + + // First commit + await handler({ step: "commit" }); + + // Second commit should error + const result = await handler({ step: "commit" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("already exists"); + }); + + it("restricts populate to a specific section", async () => { + const tools = createBootstrapTools(store, { config: getConfig() }); + const handler = extractHandler(tools, "bootstrap_sprint_zero"); + + const result = await handler({ + step: "populate", + section: "ceremony-scheduling", + }); + const data = parseResult(result); + expect(data.section).toBe("ceremony-scheduling"); + for (const item of data.proposedItems) { + expect(item.title).toContain("Ceremonies"); + } + }); +}); diff --git a/test/methodology/bootstrap.test.ts b/test/methodology/bootstrap.test.ts new file mode 100644 index 0000000..e4eee40 --- /dev/null +++ b/test/methodology/bootstrap.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { DocumentStore } from "../../src/storage/store.js"; +import type { MarvinProjectConfig } from "../../src/core/config.js"; +import { COMMON_REGISTRATIONS } from "../../src/plugins/common.js"; +import { + survey, + draft, + populate, + review, + commit, + runStep, + type BootstrapContext, +} from "../../src/methodology/bootstrap.js"; + +function makeStore(marvinDir: string): DocumentStore { + return new DocumentStore(marvinDir, COMMON_REGISTRATIONS); +} + +function makeConfig(overrides?: Partial): MarvinProjectConfig { + return { + name: "test-project", + methodology: "generic-agile", + ...overrides, + }; +} + +describe("Sprint 0 Bootstrap", () => { + let tmpDir: string; + let marvinDir: string; + let store: DocumentStore; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "marvin-bootstrap-test-")); + marvinDir = path.join(tmpDir, ".marvin"); + fs.mkdirSync(path.join(marvinDir, "docs", "decisions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "actions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "questions"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "sprints"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "features"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "epics"), { recursive: true }); + fs.mkdirSync(path.join(marvinDir, "docs", "tasks"), { recursive: true }); + store = makeStore(marvinDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function seedProject(): void { + store.create("feature", { title: "Feature 1", status: "draft" }, "Feature 1 desc"); + store.create("feature", { title: "Feature 2", status: "approved" }, "Feature 2 desc"); + store.create("feature", { title: "Feature 3", status: "draft" }, "Feature 3 desc"); + store.create("action", { title: "Action 1", status: "open", owner: "dm" }, "Action 1 desc"); + store.create("decision", { title: "Decision 1", status: "proposed" }, "Decision 1 desc"); + } + + describe("survey", () => { + it("returns project state counts", () => { + seedProject(); + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = survey(ctx); + + expect(result.step).toBe("survey"); + expect(result.projectState.methodology).toBe("generic-agile"); + expect(result.projectState.existingSprints).toBe(0); + expect(result.projectState.featuresCount).toBe(3); + expect(result.projectState.actionsCount).toBe(1); + expect(result.projectState.decisionsCount).toBe(1); + expect(result.nextStep).toBe("draft"); + }); + + it("identifies Marvin init as already satisfied", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = survey(ctx); + + const initItem = result.alreadySatisfied.find((s) => s.item === "Marvin project initialized"); + expect(initItem).toBeDefined(); + }); + + it("identifies Jira as already configured when present", () => { + const config = makeConfig({ + jira: { projectKey: "TEST", host: "https://test.atlassian.net" }, + }); + const ctx: BootstrapContext = { store, config }; + const result = survey(ctx); + + const jiraItem = result.alreadySatisfied.find( + (s) => s.item === "Jira integration configured", + ); + expect(jiraItem).toBeDefined(); + expect(result.projectState.integrationsConfigured.jira).toBe(true); + }); + + it("does not flag Jira as satisfied when not configured", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = survey(ctx); + + const jiraItem = result.alreadySatisfied.find( + (s) => s.item === "Jira integration configured", + ); + expect(jiraItem).toBeUndefined(); + expect(result.projectState.integrationsConfigured.jira).toBe(false); + }); + }); + + describe("draft", () => { + it("suggests Sprint 0 with SP-001 ID when no sprints exist", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = draft(ctx); + + expect(result.step).toBe("draft"); + expect(result.sprintDraft.idSuggested).toBe("SP-001"); + expect(result.sprintDraft.title).toBe("Sprint 0"); + expect(result.sprintDraft.tags).toContain("sprint-0"); + expect(result.sprintDraft.tags).toContain("bootstrapping"); + expect(result.sprintDraft.startDate).toBeNull(); + expect(result.sprintDraft.endDate).toBeNull(); + expect(result.nextStep).toBe("populate"); + }); + + it("includes all checklist sections in goal (generic-agile)", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = draft(ctx); + + expect(result.sprintDraft.goal).toContain("infrastructure"); + expect(result.sprintDraft.goal).toContain("backlog"); + expect(result.sprintDraft.goal).toContain("ceremony"); + expect(result.sprintDraft.goal).toContain("integration"); + }); + + it("includes AEM addendum in goal for AEM methodology", () => { + const ctx: BootstrapContext = { + store, + config: makeConfig({ methodology: "sap-aem" }), + }; + const result = draft(ctx); + + expect(result.sprintDraft.goal).toContain("aem"); + }); + }); + + describe("populate", () => { + it("returns proposed items for all sections", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = populate(ctx); + + expect(result.step).toBe("populate"); + expect(result.proposedItems.length).toBeGreaterThan(0); + + for (const item of result.proposedItems) { + expect(item.kind).toBe("action"); + expect(item.title).toContain("[Sprint 0]"); + expect(item.status).toBe("pending"); + } + }); + + it("filters to a specific section", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = populate(ctx, "integration-setup"); + + expect(result.section).toBe("integration-setup"); + for (const item of result.proposedItems) { + expect(item.title).toContain("Integration"); + } + }); + + it("includes AEM addendum for AEM methodology", () => { + const ctx: BootstrapContext = { + store, + config: makeConfig({ methodology: "sap-aem" }), + }; + const result = populate(ctx); + + const aemItems = result.proposedItems.filter((i) => i.title.includes("AEM")); + expect(aemItems.length).toBeGreaterThan(0); + }); + + it("excludes AEM addendum for generic-agile methodology", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = populate(ctx); + + const aemItems = result.proposedItems.filter((i) => i.title.includes("AEM")); + expect(aemItems.length).toBe(0); + }); + + it("assigns correct owner personas per section", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = populate(ctx); + + const infraItems = result.proposedItems.filter((i) => i.title.includes("Infrastructure")); + for (const item of infraItems) { + expect(item.ownerPersona).toBe("tl"); + } + + const ceremonyItems = result.proposedItems.filter((i) => i.title.includes("Ceremonies")); + for (const item of ceremonyItems) { + expect(item.ownerPersona).toBe("dm"); + } + }); + }); + + describe("review", () => { + it("summarizes items to create and skip", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = review(ctx); + + expect(result.step).toBe("review"); + expect(result.summary.itemsToCreate).toBeGreaterThan(0); + expect(result.summary.sprint.title).toBe("Sprint 0"); + expect(result.nextStep).toBe("commit"); + }); + + it("warns when startDate and endDate are not set", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = review(ctx); + + expect(result.summary.warnings).toContain("startDate not yet set"); + expect(result.summary.warnings).toContain("endDate not yet set"); + }); + + it("warns when Sprint 0 already exists", () => { + store.create( + "sprint", + { title: "Sprint 0", status: "planned", tags: ["sprint-0"] }, + "Existing sprint 0", + ); + + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = review(ctx); + + const existsWarning = result.summary.warnings.find((w) => w.includes("already exists")); + expect(existsWarning).toBeDefined(); + }); + }); + + describe("commit", () => { + it("creates sprint and linked actions", () => { + seedProject(); + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = commit(ctx); + + expect(result.step).toBe("commit"); + expect(result.sprintId).toMatch(/^SP-\d{3}$/); + expect(result.actionIds.length).toBeGreaterThan(0); + expect(result.totalCreated).toBe(result.actionIds.length + 1); + + // Verify sprint was actually created + const sprint = store.get(result.sprintId); + expect(sprint).toBeDefined(); + expect(sprint!.frontmatter.title).toBe("Sprint 0"); + expect(sprint!.frontmatter.tags).toContain("sprint-0"); + + // Verify actions were created with sprint tags + for (const actionId of result.actionIds) { + const action = store.get(actionId); + expect(action).toBeDefined(); + expect(action!.frontmatter.tags).toContain(`sprint:${result.sprintId}`); + expect(action!.frontmatter.tags).toContain("sprint-0"); + } + }); + + it("refuses to commit if Sprint 0 already exists", () => { + store.create( + "sprint", + { title: "Sprint 0", status: "planned", tags: ["sprint-0"] }, + "Existing", + ); + + const ctx: BootstrapContext = { store, config: makeConfig() }; + expect(() => commit(ctx)).toThrow("already exists"); + }); + + it("refuses to commit if sprint with bootstrapping tag exists", () => { + store.create( + "sprint", + { title: "Bootstrap Sprint", status: "planned", tags: ["bootstrapping"] }, + "Existing", + ); + + const ctx: BootstrapContext = { store, config: makeConfig() }; + expect(() => commit(ctx)).toThrow("already exists"); + }); + + it("creates AEM-specific actions for AEM methodology", () => { + seedProject(); + const ctx: BootstrapContext = { + store, + config: makeConfig({ methodology: "sap-aem" }), + }; + const result = commit(ctx); + + // Should have more actions due to AEM addendum + const actions = result.actionIds.map((id) => store.get(id)!); + const aemActions = actions.filter((a) => a.frontmatter.title.includes("AEM")); + expect(aemActions.length).toBeGreaterThan(0); + }); + + it("does not create AEM actions for generic-agile", () => { + seedProject(); + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = commit(ctx); + + const actions = result.actionIds.map((id) => store.get(id)!); + const aemActions = actions.filter((a) => a.frontmatter.title.includes("AEM")); + expect(aemActions.length).toBe(0); + }); + }); + + describe("runStep", () => { + it("defaults to survey when no step specified", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + const result = runStep(ctx); + expect(result.step).toBe("survey"); + }); + + it("runs the specified step", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + + expect(runStep(ctx, "survey").step).toBe("survey"); + expect(runStep(ctx, "draft").step).toBe("draft"); + expect(runStep(ctx, "populate").step).toBe("populate"); + expect(runStep(ctx, "review").step).toBe("review"); + }); + + it("throws for unknown step", () => { + const ctx: BootstrapContext = { store, config: makeConfig() }; + expect(() => runStep(ctx, "unknown" as any)).toThrow("Unknown step"); + }); + }); + + describe("AC2.1 — fresh project survey", () => { + it("matches expected state for a project with features, actions, decisions, 0 sprints", () => { + seedProject(); + const ctx: BootstrapContext = { + store, + config: makeConfig({ methodology: "sap-aem" }), + }; + const result = survey(ctx); + + expect(result.projectState.existingSprints).toBe(0); + expect(result.projectState.featuresCount).toBe(3); + expect(result.projectState.actionsCount).toBe(1); + expect(result.projectState.decisionsCount).toBe(1); + expect(result.projectState.methodology).toBe("aem"); + expect(result.alreadySatisfied.some((s) => s.item === "Marvin project initialized")).toBe( + true, + ); + }); + }); + + describe("AC2.2 — full workflow produces sprint + linked actions", () => { + it("walking all steps produces SP sprint with N actions", () => { + seedProject(); + const ctx: BootstrapContext = { store, config: makeConfig() }; + + // Walk through steps + const surveyResult = runStep(ctx, "survey"); + expect(surveyResult.step).toBe("survey"); + + const draftResult = runStep(ctx, "draft"); + expect(draftResult.step).toBe("draft"); + + const populateResult = runStep(ctx, "populate"); + expect(populateResult.step).toBe("populate"); + + const reviewResult = runStep(ctx, "review"); + expect(reviewResult.step).toBe("review"); + + const commitResult = runStep(ctx, "commit"); + expect(commitResult.step).toBe("commit"); + + const cr = commitResult as any; + expect(cr.sprintId).toBeDefined(); + expect(cr.actionIds.length).toBeGreaterThan(0); + + // Verify action count matches populate's non-skipped items + const allItems = populate(ctx); + const expectedCount = allItems.proposedItems.filter((i) => !i.skipReason).length; + expect(cr.actionIds.length).toBe(expectedCount); + }); + }); + + describe("AC2.3 — duplicate Sprint 0 detection", () => { + it("second commit after first returns error", () => { + seedProject(); + const ctx: BootstrapContext = { store, config: makeConfig() }; + + // First commit succeeds + const first = commit(ctx); + expect(first.sprintId).toBeDefined(); + + // Second commit fails + expect(() => commit(ctx)).toThrow("already exists"); + }); + }); + + describe("AC2.5 — generic-agile excludes AEM addendum", () => { + it("no AEM section in generic-agile populate", () => { + const ctx: BootstrapContext = { + store, + config: makeConfig({ methodology: "generic-agile" }), + }; + const result = populate(ctx); + + for (const item of result.proposedItems) { + expect(item.title).not.toContain("AEM"); + } + }); + }); +}); From bbd52a47ec9789fcc3ced50c65e975ac6125c650 Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Wed, 13 May 2026 09:30:09 +0100 Subject: [PATCH 2/2] fix: address PR review findings for Sprint 0 bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect actual Sprint 0 (by tag/title) in onboarding instead of any sprint - Wire includeAemAddendum through to bootstrap context and checklist - Fix full-populate nextStep to return "review" instead of "populate (next:…)" - Fix confluence check to return false (no independent config field exists) - Use ConfigError instead of plain Error for duplicate sprint and unknown step --- src/doctor/health/onboarding.ts | 15 +++++++++---- src/methodology/bootstrap-tools.ts | 1 + src/methodology/bootstrap.ts | 34 ++++++++++++++++++++---------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/doctor/health/onboarding.ts b/src/doctor/health/onboarding.ts index 9d0d36d..cd9766b 100644 --- a/src/doctor/health/onboarding.ts +++ b/src/doctor/health/onboarding.ts @@ -29,7 +29,6 @@ export function buildOnboardingGuide(ctx: HealthContext): OnboardingGuide { const pendingSources = ctx.manifest?.list("pending")?.length ?? 0; const hasActions = (counts["action"] ?? 0) > 0; const hasEpics = (counts["epic"] ?? 0) > 0; - const hasSprints = (counts["sprint"] ?? 0) > 0; const hasFeatures = (counts["feature"] ?? 0) > 0; const hasUseCases = (counts["use-case"] ?? 0) > 0; const hasJira = !!ctx.config.jira?.projectKey?.trim(); @@ -106,16 +105,24 @@ export function buildOnboardingGuide(ctx: HealthContext): OnboardingGuide { // Step 6: Set up Sprint 0 const hasWorkItems = hasActions || hasFeatures || hasUseCases; + const hasSprintZero = ctx.store.list({ type: "sprint" }).some((s) => { + const tags: string[] = s.frontmatter.tags ?? []; + return ( + tags.includes("sprint-0") || + tags.includes("bootstrapping") || + s.frontmatter.title?.toLowerCase().includes("sprint 0") + ); + }); steps.push({ order: order++, title: "Set up Sprint 0", - description: hasSprints + description: hasSprintZero ? "Sprint 0 has been created." : hasWorkItems ? "As DM, run bootstrap_sprint_zero to create a guided Sprint 0 with linked bootstrapping actions for infrastructure, backlog refinement, ceremonies, and integrations. This generates a fully scaffolded sprint with checklist items pre-populated." : "As DM, create a Sprint 0 to organize bootstrapping work: infrastructure provisioning, CI/CD setup, backlog refinement, and ceremony scheduling. Sprint 0 is not a regular sprint — it's a variable-duration bootstrapping phase that ensures the team is ready for Sprint 1.", - tool: hasWorkItems && !hasSprints ? "bootstrap_sprint_zero" : "create_sprint", - done: hasSprints, + tool: hasWorkItems && !hasSprintZero ? "bootstrap_sprint_zero" : "create_sprint", + done: hasSprintZero, }); // Step 7: Configure Jira integration diff --git a/src/methodology/bootstrap-tools.ts b/src/methodology/bootstrap-tools.ts index 3ca4825..75f404e 100644 --- a/src/methodology/bootstrap-tools.ts +++ b/src/methodology/bootstrap-tools.ts @@ -57,6 +57,7 @@ export function createBootstrapTools( store, config: options.config, manifest: options.manifest, + includeAemAddendum: args.includeAemAddendum, }; try { diff --git a/src/methodology/bootstrap.ts b/src/methodology/bootstrap.ts index 08d94c1..4c07780 100644 --- a/src/methodology/bootstrap.ts +++ b/src/methodology/bootstrap.ts @@ -4,6 +4,7 @@ import type { SourceManifestManager } from "../sources/manifest.js"; import type { Methodology, ChecklistItem } from "./types.js"; import { normalizeMethodology } from "./types.js"; import { getConceptRegistry } from "./registry.js"; +import { ConfigError } from "../core/errors.js"; export type BootstrapStep = "survey" | "draft" | "populate" | "review" | "commit"; @@ -86,20 +87,29 @@ export interface BootstrapContext { store: DocumentStore; config: MarvinProjectConfig; manifest?: SourceManifestManager; + /** Override AEM addendum inclusion. undefined = auto-detect from methodology. */ + includeAemAddendum?: boolean; } function getMethodology(config: MarvinProjectConfig): Methodology { return normalizeMethodology(config.methodology); } -function getChecklist(methodology: Methodology): ChecklistItem[] { +function getChecklist(methodology: Methodology, includeAemAddendum?: boolean): ChecklistItem[] { const registry = getConceptRegistry(); - const sprint0 = registry.explain("sprint-0", methodology); + // When overridden, force "aem" to include or "generic-agile" to exclude the addendum + const effectiveMethodology = + includeAemAddendum === true + ? "aem" + : includeAemAddendum === false + ? "generic-agile" + : methodology; + const sprint0 = registry.explain("sprint-0", effectiveMethodology); return sprint0?.checklist ?? []; } -function getSections(methodology: Methodology): string[] { - return getChecklist(methodology).map((c) => c.category); +function getSections(methodology: Methodology, includeAemAddendum?: boolean): string[] { + return getChecklist(methodology, includeAemAddendum).map((c) => c.category); } function findExistingSprint0(store: DocumentStore): string | undefined { @@ -146,7 +156,7 @@ export function survey(ctx: BootstrapContext): SurveyResult { decisionsCount: counts["decision"] ?? 0, integrationsConfigured: { jira: !!config.jira?.projectKey?.trim(), - confluence: !!config.jira?.projectKey?.trim(), + confluence: false, // No independent Confluence config field exists yet }, }, alreadySatisfied, @@ -156,7 +166,7 @@ export function survey(ctx: BootstrapContext): SurveyResult { export function draft(ctx: BootstrapContext): DraftResult { const methodology = getMethodology(ctx.config); - const sections = getSections(methodology); + const sections = getSections(methodology, ctx.includeAemAddendum); const goalParts = sections.map((s) => s.replace(/-/g, " ")); const goal = `Project bootstrapping — ${goalParts.join(", ")}`; @@ -178,7 +188,7 @@ export function draft(ctx: BootstrapContext): DraftResult { export function populate(ctx: BootstrapContext, section?: BootstrapSection): PopulateResult { const methodology = getMethodology(ctx.config); - const checklist = getChecklist(methodology); + const checklist = getChecklist(methodology, ctx.includeAemAddendum); const surveyResult = survey(ctx); const satisfiedItems = new Set(surveyResult.alreadySatisfied.map((s) => s.item)); @@ -195,9 +205,11 @@ export function populate(ctx: BootstrapContext, section?: BootstrapSection): Pop } const currentSection = targetSections[0]; - const allSections = getSections(methodology); + const allSections = getSections(methodology, ctx.includeAemAddendum); const currentIdx = allSections.indexOf(currentSection.category); - const hasMore = !section && currentIdx < allSections.length - 1; + // Full populate (no section filter) returns all items at once → next is review + const isFullPopulate = !section; + const hasMore = !isFullPopulate && currentIdx < allSections.length - 1; const ownerMap: Record = { "infrastructure-provisioning": "tl", @@ -290,7 +302,7 @@ export function commit(ctx: BootstrapContext): CommitResult { // Check for existing Sprint 0 const existingId = findExistingSprint0(store); if (existingId) { - throw new Error( + throw new ConfigError( `Sprint 0 already exists (${existingId}). Cannot create a duplicate. Use get_sprint("${existingId}") to view it.`, ); } @@ -363,6 +375,6 @@ export function runStep( case "commit": return commit(ctx); default: - throw new Error(`Unknown step: ${effectiveStep}`); + throw new ConfigError(`Unknown step: ${effectiveStep}`); } }