diff --git a/.claude/skills/bobby-test/SKILL.md b/.claude/skills/bobby-test/SKILL.md index fa26446..41051a3 100644 --- a/.claude/skills/bobby-test/SKILL.md +++ b/.claude/skills/bobby-test/SKILL.md @@ -22,6 +22,14 @@ argument-hint: "" ## Health Check +**No live app?** If `health_checks` in .bobbyrc.yml is empty or missing, there is +no live app to test — this project should not be running a test stage at all. Do +NOT emit BLOCKED test cases one by one. Move the ticket to blocked and stop: +`bobby ticket move block "no live app to test — use the library workflow"`. +The fix is `default_workflow: library` in .bobbyrc.yml (the library workflow ends +at review, which runs the test suite), or add `health_checks` if this project +does have a live app. + Verify the dev environment is running before testing: - `curl -s -o /dev/null -w "%{http_code}" ` (app — configure `health_checks` in .bobbyrc.yml) diff --git a/README.md b/README.md index 2b448f1..377b281 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ max_retries: 3 Optional configuration (commented out in generated file) ```yaml -# Custom + override workflows (built-in: default, secure, quick, freewill, design, define) +# Custom + override workflows (built-in: default, secure, quick, library, library-secure, freewill, design, define) workflows: default: [plan, build, review, test] secure: [plan, build, security, review, test] @@ -1020,7 +1020,7 @@ Bobby scaffolds Claude Code slash commands in `.claude/commands/` so you can inv ## Custom Workflows -Bobby ships built-in workflows — `default`, `secure`, `quick`, `freewill`, plus the `design` and `define` pipelines. Define your own (or override a built-in) in `.bobbyrc.yml`: +Bobby ships built-in workflows — `default`, `secure`, `quick`, `library`, `library-secure`, `freewill`, plus the `design` and `define` pipelines. Define your own (or override a built-in) in `.bobbyrc.yml`: ```yaml workflows: @@ -1033,6 +1033,22 @@ Run a named workflow: bobby run workflow TKT-001 --workflow secure ``` +### The library workflow + +CLI tools, libraries, and npm packages have no live app, and the test stage is a +live-app stage — bobby-test verifies through a running server, never by running +specs. The `library` workflow ends at review instead: `plan → build → review` +(review runs the test suite independently, the correct verification for a +library). `library-secure` adds a security stage: `plan → build → security → review`. + +Bobby picks `library` automatically: when `bobby init` (or `bobby new`) detects +no dev server and no health checks, it writes `default_workflow: library` into +`.bobbyrc.yml` with a comment explaining why. Delete that line to return to the +standard default, or add `health_checks` when the project grows a live app. +Note that `default_workflow` redirects the *name* `default` — on a library +project, run the four-stage flow by defining it under another name +(e.g. `workflows: { live: [plan, build, review, test] }`). + ## Contributing Contributions are welcome! To get started: diff --git a/commands/init.js b/commands/init.js index d93636c..f88659e 100644 --- a/commands/init.js +++ b/commands/init.js @@ -8,6 +8,7 @@ import { createRequire } from 'module'; import { success, warn, error, bold, dim } from '../lib/colors.js'; import { getTarget, TARGETS } from '../lib/targets/index.js'; import { detectServices, aggregateAreas, aggregateHealthChecks } from '../lib/services.js'; +import { deriveDefaultWorkflow } from '../lib/workflow.js'; import { runLocalProfileWizard, saveLocalProfile } from './local-init.js'; import { detectProjectContext, detectGitIdentity } from '../lib/detect.js'; import { mergeRulesContent, isBobbyGenerated } from '../lib/rules-merge.js'; @@ -733,6 +734,13 @@ export function registerInit(program) { health_checks: aggregateHealthChecks(configBase), }; + // BOB-090: no live app (no health checks, no dev command) means the + // test stage has nothing to exercise — default such projects to the + // `library` workflow, visibly in .bobbyrc.yml. Derived HERE and not in + // scaffoldProject: refresh/re-scaffold never rewrites config (BOB-123). + const derivedWorkflow = deriveDefaultWorkflow(stack, config); + if (derivedWorkflow) config.default_workflow = derivedWorkflow; + const scaffoldResult = scaffoldProject(rootDir, config); const gitInitialized = gitInitializedEarly || scaffoldResult?.gitInitialized; diff --git a/commands/run.js b/commands/run.js index a8e82f9..87caddf 100644 --- a/commands/run.js +++ b/commands/run.js @@ -28,7 +28,7 @@ export function registerRun(program) { .description('Run an agent on ticket(s) — workflow, feature, next, plan, build, review, test, ship, and more') .addHelpText('after', ` Modes: - Workflow: bobby run workflow — auto-chains the default workflow (or: secure, quick) + Workflow: bobby run workflow — auto-chains the default workflow (or: secure, quick, library) Feature: bobby run feature [epic] — full epic workflow on one branch Slow mode: bobby run next — runs next agent for current stage Batch: bobby run plan — runs agent on all tickets in matching stage @@ -43,7 +43,7 @@ Modes: Debug: bobby run debug — root-cause investigation Freewill: bobby run freewill — one agent, whole ticket, few instructions (Opus 5 / Fable 5) Freeform: bobby run docs|performance|watchdog — no ticket required - Workflow: bobby run — run a named workflow (default, secure, quick, or your own)`) + Workflow: bobby run — run a named workflow (default, secure, quick, library, or your own)`) .option('--max-retries ', 'Max retry loops on rejection per ticket', '3') .option('--max-iterations ', 'Max total agent invocations across all tickets') .option('--workflow ', 'Named workflow to use (built-in or from .bobbyrc.yml workflows)', 'default') diff --git a/commands/sprint.js b/commands/sprint.js index 082104b..29ce443 100644 --- a/commands/sprint.js +++ b/commands/sprint.js @@ -254,6 +254,9 @@ export function registerSprint(program) { const sprintPlanPath = path.join(sprintsDir, path.basename(sprint.path), 'sprint-plan.md'); const prompt = buildSprintPrompt(d, sprintTickets, pipeline, { maxRetries, + // BOB-090: no health checks = no live app for the testing stage — + // the prompt blocks the ticket loudly instead of launching bobby-test. + hasHealthChecks: (config.health_checks ?? []).length > 0, // Absolute, main-rooted: a sprint runs in an isolated worktree, which // does not contain tickets created on a feature branch (TKT-052). ticketsDir, diff --git a/commands/workflow.js b/commands/workflow.js index 8ceb952..e9693aa 100644 --- a/commands/workflow.js +++ b/commands/workflow.js @@ -37,8 +37,13 @@ export function registerWorkflow(program) { console.log(''); console.log(` ${dim('Built-in:')}`); for (const [name, steps] of Object.entries(BUILT_IN_WORKFLOWS)) { - const overridden = userDefined[name] ? dim(' (overridden)') : ''; - console.log(` ${bold(name)}${overridden} ${dim(steps.join(' → '))}`); + const ov = userDefined[name]; + const overridden = ov ? dim(' (overridden)') : ''; + // Show the EFFECTIVE stages: an overridden built-in runs the user's + // steps, and printing the built-in list next to "(overridden)" + // misstated what the workflow actually does (BOB-090). + const effective = Array.isArray(ov) ? ov : steps; + console.log(` ${bold(name)}${overridden} ${dim(effective.join(' → '))}`); } const custom = Object.entries(userDefined).filter(([n]) => !BUILT_IN_WORKFLOWS[n]); if (custom.length > 0) { @@ -49,6 +54,10 @@ export function registerWorkflow(program) { console.log(` ${bold(name)} ${dim(stepList)}`); } } + if (config.default_workflow) { + console.log(''); + console.log(` Default for this project: ${bold(config.default_workflow)} ${dim('(default_workflow in .bobbyrc.yml)')}`); + } console.log(''); console.log(` ${dim('Add one: bobby workflow add fast build test')}`); console.log(''); diff --git a/lib/config.js b/lib/config.js index ee398cd..ce9a339 100644 --- a/lib/config.js +++ b/lib/config.js @@ -241,6 +241,17 @@ export function writeConfigCommented(rootDir, config) { lines.push(`ticket_prefix: ${config.ticket_prefix || 'TKT'}`); lines.push(''); + // Default workflow (BOB-090) — near the top so the choice is visible. + if (config.default_workflow) { + lines.push('# Default workflow. This project has no dev server and no health checks —'); + lines.push('# there is no live app for the test stage to exercise, so tickets run the'); + lines.push('# `library` workflow: plan → build → review (review runs the test suite).'); + lines.push('# Delete this line to return to the standard default, or add health_checks'); + lines.push('# if this project does grow a live app.'); + lines.push(`default_workflow: ${config.default_workflow}`); + lines.push(''); + } + // Health checks lines.push('# Health check URLs verified before agents start work.'); lines.push('# Add your dev server URL(s) so agents can confirm the app is running.'); @@ -328,7 +339,8 @@ export function writeConfigCommented(rootDir, config) { if (!config.workflows && !config.pipelines) { lines.push('# Workflows — the ordered stages a ticket runs through. Built-in: default,'); - lines.push('# secure, quick. Add or override your own here, then: bobby run '); + lines.push('# secure, quick, library, library-secure. Add or override your own here,'); + lines.push('# then: bobby run '); lines.push('# workflows:'); lines.push('# default: [plan, build, review, test]'); lines.push('# secure: [plan, build, security, review, test]'); diff --git a/lib/project.js b/lib/project.js index 72c2a17..fdc1dc6 100644 --- a/lib/project.js +++ b/lib/project.js @@ -30,6 +30,7 @@ import path from 'path'; import { execSync } from 'child_process'; import { scaffoldProject, loadStack } from '../commands/init.js'; import { aggregateAreas, aggregateHealthChecks } from './services.js'; +import { deriveDefaultWorkflow } from './workflow.js'; import { renderTemplate } from './template.js'; import { createTicket, slugify, readTicket, writeTicket } from './tickets.js'; import { applyStarter } from './starters.js'; @@ -114,6 +115,11 @@ export function createProject(idea, { dir, stack: stackName = 'node', cwd = proc health_checks: aggregateHealthChecks(configBase), }; + // BOB-090: same derivation as `bobby init` — a stack with no live app + // defaults to the `library` workflow, written visibly into .bobbyrc.yml. + const derivedWorkflow = deriveDefaultWorkflow(stack, config); + if (derivedWorkflow) config.default_workflow = derivedWorkflow; + // Scaffold Bobby (identical to `bobby init`): .bobby/, .claude/, config, git init. scaffoldProject(root, config); diff --git a/lib/workflow.js b/lib/workflow.js index b79b04f..6f975ee 100644 --- a/lib/workflow.js +++ b/lib/workflow.js @@ -18,6 +18,12 @@ export const BUILT_IN_WORKFLOWS = { default: ['plan', 'build', 'review', 'test'], secure: ['plan', 'build', 'security', 'review', 'test'], quick: ['plan', 'build', 'test'], + // No live app to exercise (CLI tools, libraries, npm packages), so the flow + // ends at review — which runs the test suite independently, the correct + // verification for a library. Selected automatically as `default_workflow` + // when a project has no health checks and no dev command (BOB-090). + library: ['plan', 'build', 'review'], + 'library-secure': ['plan', 'build', 'security', 'review'], // Design gets the same treatment code does: research -> analyze -> mockup // -> spec -> build -> check, with review as a stage rather than a suggestion. design: ['design-research', 'design-analyze', 'design-mockup', 'design-spec', 'design-build', 'design-check'], @@ -82,8 +88,14 @@ function allWorkflows(config) { * Resolve the workflow to use, with this precedence: * 1. Explicit --workflow flag (unless 'default') * 2. Ticket frontmatter `workflow` field - * 3. A named workflow (built-in or config) - * 4. Built-in DEFAULT_WORKFLOW + * 3. User-defined `workflows.default` (or legacy `pipelines.default`) — an + * explicit override of the default workflow beats `default_workflow`, + * because a user who runs `bobby workflow add default …` must see it take + * effect; a stale stack-derived key silently winning would be a new + * silent failure (BOB-090) + * 4. `config.default_workflow` — the project's derived default (a no-live-app + * project gets `library` at init) + * 5. Built-in DEFAULT_WORKFLOW * * @param {object} config - readConfig() result * @param {string} name - workflow name from --workflow flag (default: 'default') @@ -92,7 +104,16 @@ function allWorkflows(config) { */ export function resolveWorkflow(config, name = 'default', ticketPipeline = null) { // Explicit flag takes priority (unless it's just the default placeholder) - const effectiveName = (name !== 'default') ? name : (ticketPipeline || 'default'); + let effectiveName = (name !== 'default') ? name : (ticketPipeline || 'default'); + + // `default_workflow` redirects only the NAME 'default', and only when the + // user has not defined a default workflow of their own. + const userDefined = config.workflows || config.pipelines || {}; + let fromDefaultWorkflowKey = false; + if (effectiveName === 'default' && !userDefined.default && config.default_workflow) { + effectiveName = config.default_workflow; + fromDefaultWorkflowKey = true; + } const workflows = allWorkflows(config); if (workflows[effectiveName]) { @@ -100,11 +121,50 @@ export function resolveWorkflow(config, name = 'default', ticketPipeline = null) return Array.isArray(def) ? parseSteps(def) : DEFAULT_WORKFLOW; } if (effectiveName !== 'default') { - throw new Error(`Unknown workflow '${effectiveName}'. Available: ${Object.keys(workflows).join(', ')}`); + const hint = fromDefaultWorkflowKey + ? ` (named by \`default_workflow:\` in .bobbyrc.yml — fix or delete that line)` + : ''; + throw new Error(`Unknown workflow '${effectiveName}'${hint}. Available: ${Object.keys(workflows).join(', ')}`); } return DEFAULT_WORKFLOW; } +/** + * The workflow name a freshly initialized project should default to, or null + * to leave the built-in default alone (BOB-090). + * + * An explicit `default_workflow` in the stack JSON wins outright. Otherwise a + * project with no health checks and no dev command has no live app for the + * test stage to exercise, so it gets `library` (plan → build → review). + * Called at the two config-construction sites (`bobby init`, `bobby new`) — + * deliberately NOT inside scaffoldProject, so refresh/re-scaffold never + * rewrites config (BOB-123). + */ +export function deriveDefaultWorkflow(stack, config) { + if (stack?.default_workflow) return stack.default_workflow; + const noHealthChecks = (config?.health_checks ?? []).length === 0; + const noDevCommand = !config?.commands?.dev; + return (noHealthChecks && noDevCommand) ? 'library' : null; +} + +/** + * BOB-090: what to do when a ticket reaches `testing` but the project has no + * live app to exercise. bobby-test is a live-app agent by contract (never runs + * specs, never reads source), so with nothing to probe it can only emit + * BLOCKED test cases one by one — a silent stall. This wording exists once so + * every prompt (orchestration, sprint, feature, slow mode, single/batch test + * runs) blocks the ticket loudly and names the fix instead. + */ +const NO_LIVE_APP_REASON = 'no live app to test — use the library workflow'; + +function testBlockInstruction(ticketRef) { + return `this project has no \`health_checks\` configured — there is no live app for bobby-test to exercise. ` + + `Do NOT launch a test subagent and do NOT emit per-case BLOCKED results. ` + + `Instead run \`bobby ticket move ${ticketRef} block "${NO_LIVE_APP_REASON}"\`, report it, and continue to the next ticket. ` + + `The fix: set \`default_workflow: library\` in .bobbyrc.yml (the library workflow ends at review, which runs the test suite), ` + + `or add \`health_checks\` if this project does have a live app.`; +} + /** * List all workflow names (built-ins + config). */ @@ -201,13 +261,16 @@ function resolveTicketFolder(ticketsDir, id) { * and sprint orchestrators (the feature workflow runs agents inline instead). * `planReadPath` injects an extra "read the shared plan" step (sprints). */ -function buildSubagentSteps(workflow, { ticketsDir, agentsPath, planReadPath = '' }) { +function buildSubagentSteps(workflow, { ticketsDir, agentsPath, planReadPath = '', hasHealthChecks = true }) { const planRead = planReadPath ? `\n 2. Read \`${planReadPath}\` for sprint-wide context (sequencing, shared decisions, out-of-scope).` : ''; const planOffset = planReadPath ? 1 : 0; return workflow.map((s, i) => { + if (s.stage === 'testing' && !hasHealthChecks) { + return `${i + 1}. If stage is "${s.stage}": ${testBlockInstruction('{TICKET_ID}')}`; + } const next = nextStageName(s.stage, workflow); const preGate = s.stage === 'testing' ? `\n a) **Pre-gate:** Before launching the subagent, verify the app is running by curling the project's health check URLs. If health checks fail, start the dev server first. The test agent must test the live app, not run specs.` @@ -244,7 +307,7 @@ function buildRetryDebugClause(maxRetries, agentsPath) { return `If the stage went back to "building" (rejection), the subagent rejected the work. Loop back and launch a bobby-build subagent again. Maximum retries: ${maxRetries}. **On the 2nd retry failure**, launch a bobby-debug subagent (prompt: "Follow \`${agentsPath}/bobby-debug.md\` on ticket {TICKET_ID}") before the 3rd build attempt — the debug agent's structured investigation often breaks deadlocks that repeated build attempts cannot.`; } -export function buildOrchestrationPrompt(ticketIds, workflow, maxRetries = 3, ticketsDir = '.bobby/tickets', maxIterations = 20, agentsPath = '.claude/agents', hasServices = false, gitConventions = {}) { +export function buildOrchestrationPrompt(ticketIds, workflow, maxRetries = 3, ticketsDir = '.bobby/tickets', maxIterations = 20, agentsPath = '.claude/agents', hasServices = false, gitConventions = {}, hasHealthChecks = true) { const ids = Array.isArray(ticketIds) ? ticketIds : [ticketIds]; const ticketBranchPrefix = gitConventions.ticket_branch_prefix || 'tkt'; const firstStage = workflow[0]?.stage || 'planning'; @@ -253,7 +316,7 @@ export function buildOrchestrationPrompt(ticketIds, workflow, maxRetries = 3, ti ? `Read the ticket's \`services\` frontmatter field. If present, read \`.bobbyrc.yml\` services to get each service's path and commands — include this context in the subagent prompt so it runs test/lint/build from the correct service directory.` : ''; - const steps = buildSubagentSteps(workflow, { ticketsDir, agentsPath }); + const steps = buildSubagentSteps(workflow, { ticketsDir, agentsPath, hasHealthChecks }); const ticketList = ids.map(id => `- ${id}`).join('\n'); @@ -373,7 +436,7 @@ export function buildGenericPrompt(entry, { ticketId, ticketsDir = '.bobby/ticke * @param {string} ticketsPath - the path written into the prompt text; callers * pass the resolved absolute dir (see buildPromptFor) */ -export function buildNextStepPrompt(ticketId, workflow, ticketsDir, ticketsPath = '.bobby/tickets', agentsPath = '.claude/agents') { +export function buildNextStepPrompt(ticketId, workflow, ticketsDir, ticketsPath = '.bobby/tickets', agentsPath = '.claude/agents', hasHealthChecks = true) { const ticket = findTicket(ticketsDir, ticketId); if (!ticket) throw new Error(`Ticket ${ticketId} not found`); const { stage } = ticket.data; @@ -390,6 +453,8 @@ export function buildNextStepPrompt(ticketId, workflow, ticketsDir, ticketsPath const agent = resolveNextAgent(workflow, stage); if (!agent) return `No agent mapped for stage "${stage}".`; + if (agent === 'bobby-test' && !hasHealthChecks) + return `Ticket ${ticketId} is in testing, but ${testBlockInstruction(ticketId)}`; return buildSingleAgentPrompt(agent, ticketId, ticketsPath, agentsPath, false, false, nextStageForAgent(agent, workflow)); } @@ -579,6 +644,11 @@ export function buildPromptFor(agent, ticketIds, ctx) { const ids = Array.isArray(ticketIds) ? ticketIds : (ticketIds ? [ticketIds] : []); + // BOB-090: service health checks are already aggregated into the top-level + // key at init — do not re-aggregate here. No health checks means no live app + // for the test stage, so testing prompts block loudly instead of stalling. + const hasHealthChecks = (config.health_checks ?? []).length > 0; + // --- Custom agents --- if (agent === 'ship') { return { @@ -595,7 +665,7 @@ export function buildPromptFor(agent, ticketIds, ctx) { if (!findTicket(ticketsDir, id)) throw new Error(`Ticket ${id} not found`); } return { - prompt: buildOrchestrationPrompt(ids, workflow, maxRetries, ticketsPath, maxIterations, agentsPath, hasServices, gitConventions), + prompt: buildOrchestrationPrompt(ids, workflow, maxRetries, ticketsPath, maxIterations, agentsPath, hasServices, gitConventions, hasHealthChecks), label: `Bobby Workflow — ${ids.length} ticket(s)`, }; } @@ -614,7 +684,7 @@ export function buildPromptFor(agent, ticketIds, ctx) { ? `Phase 1: Plan ${needsPlanning.length} ticket(s) → Phase 2: Execute ${childIds.join(' → ')}` : `All planned → Execute ${childIds.join(' → ')}`; return { - prompt: buildFeaturePrompt(epicId, epic.data.title, children, workflow, maxRetries, ticketsPath, maxIterations, agentsPath, gitConventions), + prompt: buildFeaturePrompt(epicId, epic.data.title, children, workflow, maxRetries, ticketsPath, maxIterations, agentsPath, gitConventions, hasHealthChecks), label: `Bobby Feature — ${epicId}: ${epic.data.title}`, subtitle, }; @@ -626,7 +696,7 @@ export function buildPromptFor(agent, ticketIds, ctx) { const found = findTicket(ticketsDir, ticketId); const stageLabel = found ? ` [${found.data.stage}]` : ''; return { - prompt: buildNextStepPrompt(ticketId, workflow, ticketsDir, ticketsPath, agentsPath), + prompt: buildNextStepPrompt(ticketId, workflow, ticketsDir, ticketsPath, agentsPath, hasHealthChecks), label: `Bobby Next — ${ticketId}${stageLabel}`, }; } @@ -698,6 +768,16 @@ export function buildPromptFor(agent, ticketIds, ctx) { throw new Error(`No tickets in "${stage}" stage.`); } const batchIds = available.map(t => t.id); + // BOB-090: with no live app there is nothing for bobby-test to exercise — + // block each ticket loudly instead of launching agents that can only stall. + if (reg.agentName === 'bobby-test' && !hasHealthChecks) { + return { + prompt: `The following ticket(s) are in testing, but ${testBlockInstruction('')}\n\n` + + `Block each of these tickets with that exact command and reason:\n` + + batchIds.map(id => `- ${id}`).join('\n'), + label: `${reg.label} — ${batchIds.length} ticket(s) in ${stage} (no live app)`, + }; + } const isolation = config.parallel_isolation || 'none'; const isolationLabel = isolation === 'worktree' ? ' (worktree-isolated)' : ''; return { @@ -708,6 +788,13 @@ export function buildPromptFor(agent, ticketIds, ctx) { const ticketId = ids[0]; if (!findTicket(ticketsDir, ticketId)) throw new Error(`Ticket ${ticketId} not found`); + // BOB-090: same no-live-app gate for a single-ticket test run. + if (reg.agentName === 'bobby-test' && !hasHealthChecks) { + return { + prompt: `Ticket ${ticketId} is in testing, but ${testBlockInstruction(ticketId)}`, + label: `${reg.label} — ${ticketId} (no live app)`, + }; + } // NO claim check here. buildPromptFor is a pure builder and the ORCHESTRATOR // calls it in-process for its own agents (orchestrator.js:449) — it already // holds the claim, so checking here refused every app-driven run. The guard @@ -733,7 +820,7 @@ export function assertTicketFree(ticketsDir, ticketId, known = null) { /** * Build a prompt for running a full feature workflow (epic + children on one branch) */ -export function buildFeaturePrompt(epicId, epicTitle, childTickets, workflow, maxRetries = 3, ticketsDir = '.bobby/tickets', maxIterations, agentsPath = '.claude/agents', gitConventions = {}) { +export function buildFeaturePrompt(epicId, epicTitle, childTickets, workflow, maxRetries = 3, ticketsDir = '.bobby/tickets', maxIterations, agentsPath = '.claude/agents', gitConventions = {}, hasHealthChecks = true) { // Dynamic default: each ticket needs up to workflow.length agents, ×2 for retry headroom if (!maxIterations) { maxIterations = childTickets.length * workflow.length * 2; @@ -751,6 +838,9 @@ export function buildFeaturePrompt(epicId, epicTitle, childTickets, workflow, ma // Build execution-phase agent steps (building onward — skip planning stage) const execPipeline = workflow.filter(s => s.stage !== 'planning'); const execSteps = execPipeline.map((s, i) => { + if (s.stage === 'testing' && !hasHealthChecks) { + return `${i + 1}. If stage is "${s.stage}": ${testBlockInstruction('{TICKET_ID}')}`; + } const next = nextStageName(s.stage, workflow); return `${i + 1}. If stage is "${s.stage}":\n` + ` a) Claim: \`bobby ticket assign {TICKET_ID} ${s.agent}\`\n` + @@ -888,6 +978,7 @@ export function buildSprintPrompt(sprint, tickets, workflow, { agentsPath = '.claude/agents', hasServices = false, sprintPlanPath = '', + hasHealthChecks = true, } = {}) { const ids = tickets.map(t => t.id); if (!maxIterations) { @@ -899,7 +990,7 @@ export function buildSprintPrompt(sprint, tickets, workflow, { ? `\n**Multi-service project:** Read each ticket's \`services\` frontmatter field. If present, read \`.bobbyrc.yml\` services to get each service's path and commands — include this context in the subagent prompt so it runs test/lint/build from the correct service directory.\n` : ''; - const steps = buildSubagentSteps(workflow, { ticketsDir, agentsPath, planReadPath: sprintPlanPath }); + const steps = buildSubagentSteps(workflow, { ticketsDir, agentsPath, planReadPath: sprintPlanPath, hasHealthChecks }); const ticketList = tickets.map((t, i) => `${i + 1}. ${t.id} — "${t.title}" [${t.priority || 'medium'}] (${t.stage || 'backlog'})` diff --git a/templates/CLAUDE.md.ejs b/templates/CLAUDE.md.ejs index a83ef90..79127dc 100644 --- a/templates/CLAUDE.md.ejs +++ b/templates/CLAUDE.md.ejs @@ -114,7 +114,7 @@ All agents must follow these rules. Violations are treated as critical failures. ## Workflows -A workflow is the ordered set of stages a ticket runs through. Built-in: **default** (plan→build→review→test), **secure** (adds a security stage), **quick** (plan→build→test), **freewill** (one agent does all four on few instructions — Opus 5 / Fable 5 only). +A workflow is the ordered set of stages a ticket runs through. Built-in: **default** (plan→build→review→test), **secure** (adds a security stage), **quick** (plan→build→test), **library** (plan→build→review — for CLI/library projects with no live app; review runs the suite), **library-secure** (adds a security stage), **freewill** (one agent does all four on few instructions — Opus 5 / Fable 5 only). - **Run one:** `bobby run workflow TKT-001` — auto-chains the default workflow - **Named workflow:** `bobby run secure TKT-001` (or `--workflow secure`) — built-in or your own diff --git a/templates/skills/bobby-test/SKILL.md.ejs b/templates/skills/bobby-test/SKILL.md.ejs index c9a6cba..61f7538 100644 --- a/templates/skills/bobby-test/SKILL.md.ejs +++ b/templates/skills/bobby-test/SKILL.md.ejs @@ -22,6 +22,14 @@ argument-hint: "" ## Health Check +**No live app?** If `health_checks` in .bobbyrc.yml is empty or missing, there is +no live app to test — this project should not be running a test stage at all. Do +NOT emit BLOCKED test cases one by one. Move the ticket to blocked and stop: +`bobby ticket move block "no live app to test — use the library workflow"`. +The fix is `default_workflow: library` in .bobbyrc.yml (the library workflow ends +at review, which runs the test suite), or add `health_checks` if this project +does have a live app. + Verify the dev environment is running before testing: <% if (typeof services !== 'undefined' && services && Object.keys(services).length > 0) { %> <% Object.entries(services).forEach(function([name, svc]) { if (svc.health_checks) { svc.health_checks.forEach(function(check) { %> diff --git a/test/commands/init.test.js b/test/commands/init.test.js index 3ebf159..0cbeba2 100644 --- a/test/commands/init.test.js +++ b/test/commands/init.test.js @@ -396,6 +396,40 @@ dashboard: expect(rc).toContain('stack: generic'); }); + // BOB-090: a project with no dev server and no health checks has no live app + // for the test stage, so init writes the choice of the library workflow + // visibly into .bobbyrc.yml with an explanatory comment. + test('BOB-090: init on a no-live-app project writes default_workflow: library with a comment', async () => { + const program = mockProgram(); + registerInit(program); + await program.getAction()({ yes: true, stack: 'generic' }); + + const rc = fs.readFileSync(path.join(tmpDir, '.bobbyrc.yml'), 'utf8'); + expect(rc).toContain('default_workflow: library'); + expect(rc).toMatch(/#.*no dev server and no health checks/); + expect(rc).toMatch(/#.*Delete this line/); + }); + + test('BOB-090: init on a stack with health checks does not write default_workflow', async () => { + let promptCall = 0; + promptSpy = jest.spyOn(inquirer, 'prompt').mockImplementation(async () => { + promptCall++; + if (promptCall === 1) return { setupMode: 'full' }; + if (promptCall === 2) return { project: 'test-proj', stack: 'nextjs' }; + if (promptCall === 3) return { targetName: 'claude-code' }; + if (promptCall === 4) return { devUrl: 'http://localhost:3000' }; + if (promptCall === 5) return { bobbyDir: '.bobby' }; + return {}; + }); + + const program = mockProgram(); + registerInit(program); + await program.getAction()({ custom: true }); + + const rc = fs.readFileSync(path.join(tmpDir, '.bobbyrc.yml'), 'utf8'); + expect(rc).not.toContain('default_workflow'); + }); + test('re-init with cancel exits', async () => { scaffoldProject(tmpDir, { project: 'existing', stack: 'nextjs', diff --git a/test/commands/workflow.test.js b/test/commands/workflow.test.js index a36aeaf..7693ece 100644 --- a/test/commands/workflow.test.js +++ b/test/commands/workflow.test.js @@ -51,4 +51,34 @@ describe('bobby workflow', () => { test('workflows alias works', () => { expect(run('workflows list')).toContain('default'); }); + + // BOB-090 + test('list shows the library built-ins', () => { + const out = run('workflow list'); + expect(out).toMatch(/library.*plan → build → review/); + expect(out).toMatch(/library-secure.*plan → build → security → review/); + }); + + test('list shows the effective stages for an overridden built-in', () => { + run('workflow add default plan build review'); + let out = run('workflow list'); + let row = out.split('\n').find(l => l.includes('default') && l.includes('(overridden)')); + expect(row).toContain('plan → build → review'); + expect(row).not.toContain('test'); + // Write path: the display tracks the config when the override changes. + run('workflow add default plan build test'); + out = run('workflow list'); + row = out.split('\n').find(l => l.includes('default') && l.includes('(overridden)')); + expect(row).toContain('plan → build → test'); + }); + + test('list names the project default when default_workflow is set', () => { + const cfgPath = path.join(tmpDir, '.bobbyrc.yml'); + const cfg = YAML.parse(fs.readFileSync(cfgPath, 'utf8')); + cfg.default_workflow = 'library'; + fs.writeFileSync(cfgPath, YAML.stringify(cfg)); + const out = run('workflow list'); + expect(out).toContain('Default for this project: library'); + expect(out).toContain('default_workflow in .bobbyrc.yml'); + }); }); diff --git a/test/lib/dashboard/orchestrator-fsm.test.js b/test/lib/dashboard/orchestrator-fsm.test.js index e284172..8eb4111 100644 --- a/test/lib/dashboard/orchestrator-fsm.test.js +++ b/test/lib/dashboard/orchestrator-fsm.test.js @@ -105,6 +105,14 @@ function resolveTicketPathFromPrompt(prompt, ticketId, cwd) { * what the fake agent does on each run; the default obeys the prompt. */ function makeOrchestrator({ config = {}, pipelineName = 'default', behaviour } = {}) { + // BOB-090: every scenario here models a LIVE-APP project (its workflows end + // at the testing stage). A config without health_checks now gets the + // no-live-app block prompt at testing instead of a bobby-test launch — the + // fake executor would then parse `block` as a stage and throw. Give the + // fixture a health check unless a test brings its own key. + if (!('health_checks' in config)) { + config = { ...config, health_checks: [{ name: 'app', url: 'http://localhost:3000' }] }; + } const store = new WorkspaceStore(path.join(tmp, 'workspaces.json')); const ticketsDir = path.join(tmp, 'main', '.bobby', 'tickets'); fs.mkdirSync(ticketsDir, { recursive: true }); diff --git a/test/lib/project.test.js b/test/lib/project.test.js index 6fd29bd..d43fd4b 100644 --- a/test/lib/project.test.js +++ b/test/lib/project.test.js @@ -58,6 +58,22 @@ describe('createProject', () => { expect(fs.existsSync(path.join(result.root, 'README.md'))).toBe(true); }); + // BOB-090: `bobby new` derives the default workflow per stack — a stack with + // no dev command and no health checks has no live app for the test stage. + it('derives default_workflow: library for a no-live-app stack', () => { + const result = createProject('a cli thing', { dir: 'libproj', stack: 'generic', cwd: tmp }); + expect(result.config.default_workflow).toBe('library'); + const rc = fs.readFileSync(path.join(result.root, '.bobbyrc.yml'), 'utf8'); + expect(rc).toContain('default_workflow: library'); + }); + + it('does not set default_workflow for a stack with a dev server', () => { + const result = createProject('an api', { dir: 'appproj', stack: 'node', cwd: tmp }); + expect(result.config.default_workflow).toBeUndefined(); + const rc = fs.readFileSync(path.join(result.root, '.bobbyrc.yml'), 'utf8'); + expect(rc).not.toContain('default_workflow'); + }); + it('returns the facts a caller needs instead of printing them', () => { const result = createProject('a url shortener', { dir: 'short', cwd: tmp }); diff --git a/test/lib/workflow.test.js b/test/lib/workflow.test.js index 0f84f49..bf4132d 100644 --- a/test/lib/workflow.test.js +++ b/test/lib/workflow.test.js @@ -7,6 +7,7 @@ import { buildSprintPrompt, resolveNextAgent, DEFAULT_WORKFLOW, resolveWorkflow, listWorkflows, BUILT_IN_WORKFLOWS, STAGE_MAP, nextStageForAgent, + deriveDefaultWorkflow, buildPromptFor, } from '../../lib/workflow.js'; import { createTicket, moveTicket, findTicket, writeTicket } from '../../lib/tickets.js'; import { isValidStage } from '../../lib/stages.js'; @@ -732,7 +733,7 @@ describe('workflow', () => { describe('listWorkflows', () => { test('returns the built-in workflows when none configured', () => { - expect(listWorkflows({})).toEqual(['default', 'secure', 'quick', 'design', 'define', 'freewill']); + expect(listWorkflows({})).toEqual(['default', 'secure', 'quick', 'library', 'library-secure', 'design', 'define', 'freewill']); }); test('includes custom workflow names plus default', () => { @@ -1114,3 +1115,201 @@ describe('ticket-path resolution (PRO-029)', () => { expect(prompt).toContain('bobby ticket assign TKT-999 bobby-plan'); }); }); + +// BOB-090. Every built-in workflow except `design`/`define` ended at the +// live-app `testing` stage, so CLI/library projects (no dev server, no +// health checks) stranded every ticket there: bobby-test is forbidden to run +// specs or read source, and with nothing to curl it emitted BLOCKED test +// cases one by one. These cover the fix's three parts: the built-in `library` +// workflows, the `default_workflow` config key (derived at init), and the +// loud block instruction that replaces the silent stall. +describe('library workflow and no-live-app gates (BOB-090)', () => { + const REASON = 'no live app to test — use the library workflow'; + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bobby-bob090-')); + fs.writeFileSync(path.join(tmpDir, '.counter'), '0'); + }); + + afterEach(() => { fs.rmSync(tmpDir, { recursive: true }); }); + + describe('built-in library workflows', () => { + test('library resolves to plan → build → review', () => { + expect(resolveWorkflow({}, 'library')).toEqual([ + { stage: 'planning', agent: 'bobby-plan' }, + { stage: 'building', agent: 'bobby-build' }, + { stage: 'reviewing', agent: 'bobby-review' }, + ]); + }); + + test('library-secure resolves to plan → build → security → review', () => { + expect(resolveWorkflow({}, 'library-secure')).toEqual([ + { stage: 'planning', agent: 'bobby-plan' }, + { stage: 'building', agent: 'bobby-build' }, + { stage: 'security', agent: 'bobby-security' }, + { stage: 'reviewing', agent: 'bobby-review' }, + ]); + }); + }); + + describe('default_workflow config key', () => { + test('redirects the name default', () => { + const steps = resolveWorkflow({ default_workflow: 'library' }, 'default'); + expect(steps.map(s => s.stage)).toEqual(['planning', 'building', 'reviewing']); + }); + + test('loses to ticket frontmatter workflow', () => { + const steps = resolveWorkflow({ default_workflow: 'library' }, 'default', 'quick'); + expect(steps.map(s => s.stage)).toEqual(['planning', 'building', 'testing']); + }); + + test('loses to an explicit workflow name', () => { + const steps = resolveWorkflow({ default_workflow: 'library' }, 'secure'); + expect(steps.map(s => s.stage)).toEqual(['planning', 'building', 'security', 'reviewing', 'testing']); + }); + + test('loses to a user-defined workflows.default', () => { + const config = { default_workflow: 'library', workflows: { default: ['plan', 'build', 'test'] } }; + const steps = resolveWorkflow(config, 'default'); + expect(steps.map(s => s.stage)).toEqual(['planning', 'building', 'testing']); + }); + + test('unknown name throws and points at default_workflow in .bobbyrc.yml', () => { + expect(() => resolveWorkflow({ default_workflow: 'nope' }, 'default')) + .toThrow(/Unknown workflow 'nope'[\s\S]*default_workflow[\s\S]*\.bobbyrc\.yml/); + }); + }); + + describe('deriveDefaultWorkflow', () => { + test('explicit stack field wins even with health checks present', () => { + const stack = { default_workflow: 'library' }; + const config = { health_checks: [{ name: 'app', url: 'http://localhost:3000' }], commands: { dev: 'npm run dev' } }; + expect(deriveDefaultWorkflow(stack, config)).toBe('library'); + }); + + test('no health checks and no dev command derives library', () => { + expect(deriveDefaultWorkflow({}, { health_checks: [], commands: { dev: '' } })).toBe('library'); + }); + + test('missing health_checks key still derives library', () => { + expect(deriveDefaultWorkflow({}, { commands: {} })).toBe('library'); + }); + + test('health checks present derives null', () => { + expect(deriveDefaultWorkflow({}, { health_checks: [{ name: 'app', url: 'x' }], commands: {} })).toBeNull(); + }); + + test('dev command present derives null', () => { + expect(deriveDefaultWorkflow({}, { health_checks: [], commands: { dev: 'npm run dev' } })).toBeNull(); + }); + }); + + describe('orchestration prompt testing gate', () => { + test('hasHealthChecks false replaces the pre-gate with a block instruction', () => { + const prompt = buildOrchestrationPrompt('TKT-001', DEFAULT_WORKFLOW, 3, '.bobby/tickets', 20, '.claude/agents', false, {}, false); + expect(prompt).toContain(`bobby ticket move {TICKET_ID} block "${REASON}"`); + expect(prompt).toContain('default_workflow: library'); + expect(prompt).not.toContain('Pre-gate'); + // The testing stage launches no subagent at all. + expect(prompt).not.toContain('bobby ticket assign {TICKET_ID} bobby-test'); + }); + + test('hasHealthChecks true (explicit) keeps the existing pre-gate wording', () => { + const prompt = buildOrchestrationPrompt('TKT-001', DEFAULT_WORKFLOW, 3, '.bobby/tickets', 20, '.claude/agents', false, {}, true); + expect(prompt).toContain('Pre-gate'); + expect(prompt).toContain('health check'); + expect(prompt).toContain('live app, not run specs'); + expect(prompt).not.toContain(REASON); + }); + + test('sprint prompt threads hasHealthChecks through its opts', () => { + const sprint = { id: 'SPR-001', name: 'Sprint', goal: '', branch: 'sprint/spr-001' }; + const tickets = [{ id: 'TKT-001', title: 'T', stage: 'backlog', priority: 'high' }]; + const blocked = buildSprintPrompt(sprint, tickets, DEFAULT_WORKFLOW, { hasHealthChecks: false }); + expect(blocked).toContain(REASON); + expect(blocked).not.toContain('Pre-gate'); + const normal = buildSprintPrompt(sprint, tickets, DEFAULT_WORKFLOW, {}); + expect(normal).toContain('Pre-gate'); + expect(normal).not.toContain(REASON); + }); + + test('feature prompt emits the block clause for the testing stage', () => { + const children = [{ id: 'TKT-002', title: 'Child', stage: 'building', priority: 'high' }]; + const blocked = buildFeaturePrompt('TKT-001', 'Epic', children, DEFAULT_WORKFLOW, 3, '.bobby/tickets', undefined, '.claude/agents', {}, false); + expect(blocked).toContain(REASON); + expect(blocked).not.toContain('bobby ticket assign {TICKET_ID} bobby-test'); + const normal = buildFeaturePrompt('TKT-001', 'Epic', children, DEFAULT_WORKFLOW, 3, '.bobby/tickets', undefined, '.claude/agents', {}); + expect(normal).not.toContain(REASON); + expect(normal).toContain('bobby ticket assign {TICKET_ID} bobby-test'); + }); + }); + + describe('bobby-test gates in buildNextStepPrompt and buildPromptFor', () => { + test('slow mode on a ticket in testing gets the block instruction', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + moveTicket(tmpDir, 'TKT-001', 'testing', 'dev'); + const prompt = buildNextStepPrompt('TKT-001', DEFAULT_WORKFLOW, tmpDir, tmpDir, '.claude/agents', false); + expect(prompt).toContain(`bobby ticket move TKT-001 block "${REASON}"`); + expect(prompt).toContain('default_workflow: library'); + expect(prompt).not.toContain('bobby ticket assign TKT-001 bobby-test'); + }); + + test('slow mode with health checks builds the normal test prompt', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + moveTicket(tmpDir, 'TKT-001', 'testing', 'dev'); + const prompt = buildNextStepPrompt('TKT-001', DEFAULT_WORKFLOW, tmpDir, tmpDir, '.claude/agents', true); + expect(prompt).toContain('bobby ticket assign TKT-001 bobby-test'); + expect(prompt).not.toContain(REASON); + }); + + const ctxFor = (config) => ({ + config, ticketsDir: tmpDir, ticketsPath: tmpDir, + agentsPath: '.claude/agents', workflow: DEFAULT_WORKFLOW, + }); + + test('buildPromptFor test agent without health checks returns a block prompt', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + const { prompt, label } = buildPromptFor('test', ['TKT-001'], ctxFor({ health_checks: [] })); + expect(label).toBe('Bobby Test — TKT-001 (no live app)'); + expect(prompt).toContain(`bobby ticket move TKT-001 block "${REASON}"`); + expect(prompt).not.toContain('bobby ticket assign TKT-001 bobby-test'); + }); + + test('buildPromptFor test agent with health checks builds the normal prompt', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + const { prompt, label } = buildPromptFor('test', ['TKT-001'], ctxFor({ health_checks: [{ name: 'app', url: 'x' }] })); + expect(label).toBe('Bobby Test — TKT-001'); + expect(prompt).toContain('bobby ticket assign TKT-001 bobby-test'); + expect(prompt).not.toContain(REASON); + }); + + test('batch test run without health checks instructs blocking each listed ticket', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'First' }); + createTicket(tmpDir, { prefix: 'TKT', title: 'Second' }); + moveTicket(tmpDir, 'TKT-001', 'testing', 'dev'); + moveTicket(tmpDir, 'TKT-002', 'testing', 'dev'); + const { prompt, label } = buildPromptFor('test', [], ctxFor({ health_checks: [] })); + expect(label).toContain('(no live app)'); + expect(prompt).toContain('TKT-001'); + expect(prompt).toContain('TKT-002'); + expect(prompt).toContain(REASON); + }); + + test('workflow mode threads the gate from config', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + const { prompt } = buildPromptFor('workflow', ['TKT-001'], ctxFor({ health_checks: [] })); + expect(prompt).toContain(REASON); + const withHc = buildPromptFor('workflow', ['TKT-001'], ctxFor({ health_checks: [{ name: 'app', url: 'x' }] })); + expect(withHc.prompt).toContain('Pre-gate'); + expect(withHc.prompt).not.toContain(REASON); + }); + + test('next mode threads the gate from config', () => { + createTicket(tmpDir, { prefix: 'TKT', title: 'A CLI thing' }); + moveTicket(tmpDir, 'TKT-001', 'testing', 'dev'); + const { prompt } = buildPromptFor('next', ['TKT-001'], ctxFor({ health_checks: [] })); + expect(prompt).toContain(REASON); + }); + }); +});