diff --git a/AGENTS.md b/AGENTS.md index 404e660..5d72bf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,46 @@ The per-story flow depends on the active `complexity_profile` in This policy is enforced by the orchestrator state machine (`_Sprintpilot/lib/orchestrator/state-machine.js` and `adapt.js`) driven from `_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md`, using profile resolution from `_Sprintpilot/scripts/resolve-profile.js`. When the profile key is absent, the autopilot falls back to `medium`. -### Mandatory sequence per story (all profiles except `nano`) +### Fast lane — sanctioned per-story quick-dev under full profiles (opt-in, default OFF) + +When `autopilot.fast_lane.enabled` is `true`, the full profiles (`small`, +`medium`, `large`, `legacy`) may route **individual LOW-RISK stories** through +`bmad-quick-dev` (one-shot) instead of the 7-step cycle, while every +substantial story keeps the full cycle. This is the **same kind of sanctioned, +opt-in exception as `nano`** — not an implicit relaxation of the RED-first +rule. It is **OFF by default**; the installer asks whether to enable it, and +full profiles behave exactly as before when it is off. + +A fast-laned story still runs `bmad-create-story` first (only then does it go +to `bmad-quick-dev` instead of the 7-step cycle) — the gate needs the story +file's Acceptance Criteria and declared paths to enforce its guardrails, and +that file doesn't exist until create-story writes it. (nano, by contrast, +skips create-story entirely.) + +A story is fast-laned only when a deterministic pre-story gate +(`_Sprintpilot/lib/orchestrator/fast-lane-gate.js`) says so. The gate is +**conservative — it defaults to `full` on any uncertainty**: + +- more Acceptance Criteria than `fast_lane.max_ac` → full; +- any declared path matching a `deny_globs` entry (auth / migrations / + secrets) → full, even if the story is tagged `fast_lane: true`; +- inference only routes `fast` when **every** path the story declares is + covered by `allow_globs`; +- an explicit story tag (`fast_lane: true|false` / `risk: low|high`) can force + the decision (a `full`-forcing tag always wins over a `fast` one). + +Guardrails that still hold on a fast-laned story: **tests are still required** +(`verifyNanoQuickDev` needs `tests_run > 0`, a commit SHA, and +sprint-status `done`). If the story's quick-dev run **fails outright**, the +autopilot re-runs the full 7-step cycle for it (from `bmad-create-story`); if it +**completes but reports failing tests or a high-severity finding**, the +autopilot routes it through the full adversarial `bmad-code-review` it skipped. +Either way the story is remembered (`fast_lane_forced_full`) so it is never +re-fast-laned — a misclassified story self-corrects rather than shipping +unreviewed. Every routing choice is auditable via the `fast_lane_decision` +ledger entry. + +### Mandatory sequence per story (all profiles except `nano`, and non-fast-laned stories under the fast lane) 1. `bmad-create-story` — story file complete 2. `bmad-check-implementation-readiness` — no blockers diff --git a/CLAUDE.md b/CLAUDE.md index 4999487..5e74677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ The orchestrator separates **decision logic** (deterministic, pure, fully tested - `state-store.js` — the **single chokepoint** for `autopilot-state.yaml` writes. `coalesce_state_writes` splits CRITICAL_KEYS (write-through) from non-critical (buffered, flushed at story boundary). Atomic write via tmp + rename. **Deep-merges; cannot delete a nested key** — that constraint is why `excluded-stories.json` is its own file rather than a field on `autopilot-state.yaml`. - `action-ledger.js` — append-only JSONL audit at `_bmad-output/implementation-artifacts/ledger.jsonl`. Unknown kinds are tolerated by consumers; add new kinds freely. - `excluded-stories.js` — Sprintpilot-owned authoritative exclusion ledger. Resolver consults it; `apply_user_commands` populates from `skip_story` / `remove_from_sprint`; `reconcileFromSprintStatus` folds sprint-status terminal-non-done values in. Designed to survive BMad-side clobbers. +- `fast-lane-gate.js` — pure, deterministic pre-story classifier for the opt-in quick-dev fast lane (default OFF). `(story signals + fast_lane config + forced-full ledger) → fast|full`, conservative (defaults `full`). The CLI (`deriveEffectiveProfile` in `autopilot.js`) reads the story file + `fast_lane_*` profile fields and flips `implementation_flow` to `quick` per-story when the gate says `fast`; a fast-laned quick-dev failure escalates via `escalateOnFailure` (`escalated_from: 'fast_lane'`) and records the story in `state.fast_lane_forced_full` so it re-runs the full cycle and never re-fast-lanes. +- `fast-lane-overrides.js` — Sprintpilot-owned, durable per-story/epic `fast|full` marks (`fast-lane-overrides.json`), clobber-resistant like `excluded-stories.js`. The highest-authority routing signal: `deriveEffectiveProfile` consults it before the gate (a `fast` mark beats deny-globs/size/tags and applies even when the lane is off; `fast_lane_forced_full` still wins to prevent loops). Set via the `set_fast_lane` UserCommand, the `autopilot fast-lane` CLI, or `/sprintpilot-plan-sprint`. - `user-commands.js` / `user-command-applier.js` — validates and applies `UserCommand`s. The applier is pure: `(state, profile, commands) → { newState, newProfile, sideEffects }`. The CLI runs the side-effects. - `sprint-plan.js` + `_Sprintpilot/scripts/sprint-plan.js` — dependency-aware plan and the mirror parser. `TERMINAL_STATUSES` is duplicated across `autopilot.js` and `sprint-plan.js`; tests assert the mirror. @@ -87,6 +89,7 @@ Knowing who writes what is critical to avoid stepping on BMad's domain: | `autopilot-state.yaml` | Sprintpilot | Volatile per-session state; deep-merged, never wholesale-replaced. | | `ledger.jsonl` | Sprintpilot | Append-only audit. | | `excluded-stories.json` | Sprintpilot | Durable exclusion ledger; replace-on-write semantics. | +| `fast-lane-overrides.json` | Sprintpilot | Durable per-story/epic fast\|full marks; replace-on-write; clobber-resistant (survives re-plan). | | `sprint-plan.yaml` | Sprintpilot | Dependency-aware plan; validated against the DAG. | | `decision-log.yaml` | Sprintpilot | Per-phase decisions audit. | | `flaky-quarantine.yaml` | Sprintpilot | Flaky test flip counts + quarantine. | diff --git a/README.md b/README.md index f1e8eb6..abf4a63 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Sprintpilot turns a planned BMad sprint into merged, reviewed, tested code **wit **`nano` profile** swaps the 7-step cycle for BMad's one-shot `bmad-quick-dev` (Implement → Review → Classify → Commit) — and automatically escalates back to the full cycle if its tests fail or its review flags a high-severity finding. +**The quick-dev fast lane (opt-in, default OFF)** brings that speed to the full profiles *per story*: a conservative pre-story gate routes genuinely low-risk stories (docs, tiny config, small pure functions) through one-shot `bmad-quick-dev` while substantial stories keep the full 7-step cycle. The story spec is still written first (`bmad-create-story`) so the gate can judge it from real acceptance criteria and declared paths; the gate defaults to `full` on any doubt and hard-denies auth / migrations / secrets. Tests remain required, and a fast-laned story that fails re-runs the full cycle (or, if it completed but flagged a problem, gets the adversarial code review it skipped). Off unless you enable it at install; every routing choice is auditable and shown in `autopilot progress`. + **You control the loop, not the steps.** The autopilot drives continuously until it has completed `session_story_limit` stories (default 3), the sprint is finished, or it hits one of five genuine blockers — then it halts cleanly with a handoff report. Steer it mid-flight in plain language ("skip this story, the spec is wrong", "pause", "land before the next story") and it maps that to the right action. Under the hood a deterministic Node state machine (`_Sprintpilot/bin/autopilot.js`) decides what runs next and enforces the sequence; the LLM owns in-skill execution and small-judgment calls. Sprintpilot never invents workflows of its own — it composes BMad's skills. **The benefit:** a planned sprint implements itself overnight with TDD, multi-reviewer code review, and your real git process applied to every story — instead of you hand-running `create-story → readiness → dev → review → patch → commit → PR` dozens of times. You review PRs and answer the occasional genuine question; the autopilot does the mechanical execution faithfully and auditably (every action is logged to an append-only ledger). @@ -122,6 +124,8 @@ Pick at install: `--profile `. Missing profile defaults to `medium`. **Nano safety net:** if `bmad-quick-dev` tests fail or its review classifies a finding as `high` severity, the autopilot escalates that session to the full 7-step cycle (session-scoped, never written back to config). +**Fast lane (opt-in, default OFF):** the full profiles (`small`/`medium`/`large`) can route *individual* low-risk stories — docs, tiny config, small pure-function additions — through one-shot `bmad-quick-dev` while substantial stories keep the full 7-step cycle. Each fast-lane candidate still runs `bmad-create-story` first, then a conservative pre-story gate decides `fast | full` from its real acceptance criteria and declared paths (defaulting to `full` on any doubt, hard-denying auth/migrations/secrets, and honoring an AC-count budget + explicit story/epic tags). Tests stay required; a fast-laned story that **fails** re-runs the full cycle, and one that **completes but flags a problem** gets routed through the adversarial code review it skipped. The installer asks whether to enable it (and for the `max_ac` budget); per-project knobs live under `autopilot.fast_lane.*`, and counts show in `autopilot progress`. See [docs/quick-dev-fast-lane-plan.md](docs/quick-dev-fast-lane-plan.md). + ## Running a session The autopilot scans the host chat for your interjections every turn — you can steer it without learning a command vocabulary: @@ -187,6 +191,11 @@ Most projects only ever change a handful of settings. Pick the change you want, | `complexity_profile` | `autopilot/config.yaml` | `medium` | Per-story flow + which optimization layers are enabled | | `autopilot.session_story_limit` | `autopilot/config.yaml` | `3` (nano: `5`) | Stories per session before checkpoint. `0` = unlimited | | `autopilot.retrospective_mode` | `autopilot/config.yaml` | `auto` | `auto` / `stop` / `skip` | +| `autopilot.fast_lane.enabled` | `autopilot/config.yaml` | `false` | Route low-risk stories through one-shot quick-dev under full profiles (installer prompts) | +| `autopilot.fast_lane.max_ac` | `autopilot/config.yaml` | `3` | Stories with more Acceptance Criteria never fast-lane | +| `autopilot.fast_lane.allow_globs` | `autopilot/config.yaml` | `"docs/**,**/*.md"` | Inference only fast-lanes when every declared path is allow-listed (comma-sep) | +| `autopilot.fast_lane.deny_globs` | `autopilot/config.yaml` | `"**/auth/**,**/migrations/**,**/*secret*,**/*secret*/**"` | Any declared path matching forces `full` — hard safety | +| `autopilot.fast_lane.require_story_tag` | `autopilot/config.yaml` | `false` | Only fast-lane stories explicitly tagged `fast_lane: true` / `risk: low` | | `git.merge_strategy` | `git/config.yaml` | `stacked` | `stacked` / `land_as_you_go` | | `git.push.create_pr` | `git/config.yaml` | `true` | `false` = direct merge to base | | `git.reuse_user_branch` | `git/config.yaml` | `false` | Commit every story onto the current user branch | @@ -425,6 +434,7 @@ Skill internals + output schemas: [docs/USAGE.md](docs/USAGE.md#multi-agent-skil - [Sprint Planning Reference](docs/sprint-planning.md) — focused guide to `/sprintpilot-plan-sprint`: curation, validation, mid-flight commands, companion skills - [Architecture](docs/ARCHITECTURE.md) — state machine, action / signal vocabulary, verify contracts - [Configuration Reference](docs/CONFIGURATION.md) — every setting, default, profile override +- [Quick-Dev Fast Lane](docs/quick-dev-fast-lane-plan.md) — per-story quick-dev routing under full profiles: gate, guardrails, escalation - [Extending (Platforms & Languages)](docs/EXTENDING.md) - [Contributing](docs/CONTRIBUTING.md) - [Changelog](CHANGELOG.md) diff --git a/_Sprintpilot/Sprintpilot.md b/_Sprintpilot/Sprintpilot.md index 9d9f56d..2510e32 100644 --- a/_Sprintpilot/Sprintpilot.md +++ b/_Sprintpilot/Sprintpilot.md @@ -35,6 +35,11 @@ Edit `_Sprintpilot/modules/autopilot/config.yaml`: | `autopilot.retrospective_mode` | `auto` | `auto` / `stop` / `skip` | How epic-end retrospectives are handled (see below). | | `autopilot.auto_infer_dependencies` | `false` (was `true` pre-v2.3.0) | bool | **Legacy flag — superseded by `auto_plan_on_start` in v2.3.0.** See "Sprint Planning + DAG-Aware Execution" below. | | `autopilot.auto_plan_on_start` | `false` | bool | **v2.3.0.** When `true`, `autopilot start` emits `invoke_skill: sprintpilot-plan-sprint` on greenfield projects (no `sprint-plan.yaml`). Default `false`: missing plan → fall back to sprint-status order. Once a plan exists, staleness triggers auto-derive regardless of this knob. | +| `autopilot.fast_lane.enabled` | `false` | bool | **Fast lane.** When `true`, a full profile (`small`/`medium`/`large`) may route *individual* low-risk stories through one-shot `bmad-quick-dev` instead of the 7-step cycle. A conservative pre-story gate decides `fast\|full` (defaults `full` on doubt); any failure bounces the story back to the full cycle and remembers it. Prompted at install. See "Fast lane" below. | +| `autopilot.fast_lane.max_ac` | `3` | integer ≥ 0 | Stories with more Acceptance Criteria than this never fast-lane (a size gate that beats an explicit fast tag). | +| `autopilot.fast_lane.allow_globs` | `"docs/**,**/*.md"` | comma-sep globs | A story only *infers* `fast` when every path it declares is allow-listed here. | +| `autopilot.fast_lane.deny_globs` | `"**/auth/**,**/migrations/**,**/*secret*,**/*secret*/**"` | comma-sep globs | Any declared path matching these forces `full` — hard safety, beats a fast tag. | +| `autopilot.fast_lane.require_story_tag` | `false` | bool | When `true`, only stories explicitly tagged `fast_lane: true` / `risk: low` fast-lane. | | `git.lock.stale_timeout_minutes` | `30` | integer ≥ 0 | `.autopilot.lock` older than this is auto-taken-over by the next session. `0` disables auto-takeover (locks held until released manually). | | `git.worktree.health_check_on_boot` | `true` | bool | At session start, scan `.worktrees/` for orphans from crashed sessions and halt with a prune hint when any are found. | | `git.worktree.cleanup_on_merge` | `true` | bool | After an epic merges, prune worktree metadata and remove `.worktrees//` directories whose branches no longer exist locally or on origin. | @@ -55,6 +60,21 @@ Edit `_Sprintpilot/modules/autopilot/config.yaml`: Both settings are prompted during `sprintpilot install` (interactive mode) with existing values as defaults, so reinstalls preserve your choices. +#### Fast lane (per-story quick-dev under full profiles) + +**Default OFF.** When `autopilot.fast_lane.enabled` is true, a deterministic pre-story gate routes **individual low-risk stories** through `bmad-quick-dev` (one-shot) under a full profile, while substantial stories keep the mandatory 7-step cycle. This is a sanctioned, opt-in relaxation of the RED-first rule — the same kind of exception as `nano`, not a silent skip. The installer asks whether to enable it. + +How a story is routed (the gate is conservative — **any uncertainty → `full`**): + +- more Acceptance Criteria than `max_ac` → `full` (beats a fast tag); +- any declared path matching `deny_globs` (auth / migrations / secrets) → `full`, even against an explicit fast tag; +- inference routes `fast` only when **every** path the story declares is covered by `allow_globs`; +- an explicit tag forces the decision — in the story file (`fast_lane: true` / `risk: low|high`) or on the story's **epic entry** in `sprint-plan.yaml` (story-file tag wins). A `full`-forcing tag always beats a `fast` one. + +**Guardrails.** Tests are still required (`verifyNanoQuickDev` needs `tests_run > 0`, a commit SHA, and sprint-status `done`). If a fast-laned quick-dev run **fails, reports failing tests, or flags a high-severity finding**, the autopilot bounces that story back to the full 7-step cycle (re-running `bmad-create-story` → the 7 steps) and records it in `fast_lane_forced_full` so it never re-fast-lanes. The re-run carries an escalation note telling the dev step it's hardening existing committed code, not doing greenfield RED. + +**Auditing.** Every routing choice is a `fast_lane_decision` ledger entry; `autopilot progress` and the session report show fast-laned and escalated counts. + ### Sprint Planning + DAG-Aware Execution (v2.3.0) `/sprintpilot-plan-sprint` builds the authoritative sprint plan at `_bmad-output/implementation-artifacts/sprint-plan.yaml`. The plan persists per-epic dependencies, cross-epic edges, per-story `plan_status`, priorities, and optional external issue-tracker links. BMad's `sprint-status.yaml` remains the source of truth for *what stories exist*; the plan is the source of truth for *what runs next, in what order, and with what dependencies*. diff --git a/_Sprintpilot/bin/autopilot.js b/_Sprintpilot/bin/autopilot.js index 57ad53a..3eaeecb 100644 --- a/_Sprintpilot/bin/autopilot.js +++ b/_Sprintpilot/bin/autopilot.js @@ -37,6 +37,8 @@ const ledger = require('../lib/orchestrator/action-ledger'); const resumeContext = require('../lib/orchestrator/resume-context'); const backgroundSuite = require('../lib/orchestrator/background-suite'); const changeSizeClassifier = require('../lib/orchestrator/change-size-classifier'); +const fastLaneGate = require('../lib/orchestrator/fast-lane-gate'); +const fastLaneOverrides = require('../lib/orchestrator/fast-lane-overrides'); const flakyQuarantine = require('../lib/orchestrator/flaky-quarantine'); const haltExplainer = require('../lib/orchestrator/halt-explainer'); const sprintHealth = require('../lib/orchestrator/sprint-health'); @@ -57,7 +59,7 @@ const { const { STATES } = stateMachine; -const SUBCOMMANDS = ['start', 'next', 'record', 'state', 'report', 'validate-config', 'status', 'progress', 'heartbeat', 'tasks', 'quarantine', 'watch', 'resume']; +const SUBCOMMANDS = ['start', 'next', 'record', 'state', 'report', 'validate-config', 'status', 'progress', 'heartbeat', 'tasks', 'quarantine', 'watch', 'resume', 'fast-lane']; // v2.3.12 — canonical per-story task list (the BMad 7-step cycle, // collapsed into operator-visible labels). Used by `autopilot tasks` to @@ -844,6 +846,233 @@ function persistState(updates, profile, projectRoot, story) { return stateStore.write(updates, profile, { projectRoot, story }); } +// Story-start phases where a fresh routing decision is meaningful — used to +// throttle fast_lane_decision ledger emission to ~one entry per story. +const FAST_LANE_STORY_START_PHASES = new Set([ + STATES.PREPARE_STORY_BRANCH, + STATES.CREATE_STORY, + STATES.NANO_QUICK_DEV, +]); + +// resolvePlanFastLaneTag(projectRoot, storyKey) → 'fast' | 'full' | null. +// +// Reads sprint-plan.yaml and returns an epic/plan-level routing tag for the +// story: the story's own plan entry wins, then its epic entry. Each may carry +// an optional `fast_lane` (bool/string) and/or `risk` (string) field — +// tolerant, forward-compatible (absent → null; a `full`-forcing tag beats a +// `fast` one). Best-effort: any read/parse error → null (no epic tag). Story- +// FILE tags still take priority over this (the gate resolves that). +function resolvePlanFastLaneTag(projectRoot, storyKey) { + try { + const planRead = sprintPlanScript.read({ projectRoot }); + if (!planRead || (typeof planRead === 'object' && 'error' in planRead)) return null; + const plan = planRead.plan || planRead; + const storyEntry = Array.isArray(plan.stories) + ? plan.stories.find((s) => s && s.key === storyKey) + : null; + const storyTag = fastLaneGate.tagFromFields(storyEntry); + if (storyTag) return storyTag; + const epicId = (storyEntry && storyEntry.epic) || deriveEpicFromStoryKey(storyKey); + const epicEntry = + epicId != null && Array.isArray(plan.epics) + ? plan.epics.find((e) => e && String(e.id) === String(epicId)) + : null; + return fastLaneGate.tagFromFields(epicEntry); + } catch (_e) { + return null; + } +} + +// deriveEffectiveProfile(persisted, profile, projectRoot, opts) → profile. +// +// The per-story fast-lane seam. When `autopilot.fast_lane.enabled` is true, +// a deterministic pre-story gate (fast-lane-gate.js) may route a LOW-RISK +// story through the quick flow (implementation_flow='quick') under a FULL +// profile — otherwise the story keeps the mandatory 7-step cycle. Returns the +// profile UNCHANGED (full behavior, byte-identical to pre-fast-lane) when: +// - the fast lane is disabled, +// - the profile is already quick (nano — whole-profile fast path), +// - no story resolves yet, or +// - the story is in the forced-full ledger (escalated back from a prior +// fast-lane failure — sticky so it never re-fast-lanes). +// +// The gate is re-derived every emission from durable inputs (story file + +// config + forced-full ledger), so a fast story stays quick and a full story +// stays full across the whole cycle without persisting a session profile. +// Best-effort: any error falls back to the base (full) profile — the fast +// lane never wedges the autopilot. +// +// opts.emitLedger (cmdNext only) appends a `fast_lane_decision` audit entry, +// throttled to story-start phases so the ledger gets ~one entry per story. +function deriveEffectiveProfile(persisted, profile, projectRoot, opts = {}) { + try { + if (!profile) return profile; + if (profile.implementation_flow === 'quick') return profile; // nano — already quick + // NB: the fast_lane_enabled gate is applied LATER (after the manual-override + // check) so a per-story `fast` override works even when the lane is off. + + // Resolve the candidate story key cheaply (mirrors composeRuntimeState's + // precedence without re-running the whole pipeline): persisted + // current_story → first valid queue entry → sprint-status resolver. + let storyKey = persisted.current_story || null; + if (storyKey && persistedStoryRejectionReason(storyKey, projectRoot)) storyKey = null; + if (!storyKey && Array.isArray(persisted.story_queue)) { + storyKey = + persisted.story_queue.find( + (k) => typeof k === 'string' && k && !persistedStoryRejectionReason(k, projectRoot), + ) || null; + } + if (!storyKey) { + storyKey = resolveNextStoryKey(projectRoot, { preferEpic: persisted.current_epic || null }); + } + if (!storyKey) return profile; + + const forcedList = Array.isArray(persisted.fast_lane_forced_full) + ? persisted.fast_lane_forced_full + : []; + const forcedFull = forcedList.includes(storyKey); + + // Append a fast_lane_decision audit entry (deduped against the last one for + // this story so re-emitting `next` at the same phase doesn't spam the + // ledger). Best-effort — never blocks the emission. Callers gate WHEN to + // log; this just does the append. + const logDecision = (decision, reasons) => { + try { + const prev = ledger.last({ projectRoot }, 'fast_lane_decision'); + if (prev && prev.story_key === storyKey && prev.decision === decision) return; + ledger.append( + { kind: 'fast_lane_decision', story_key: storyKey, decision, reasons }, + { projectRoot }, + ); + } catch (_e) { + /* audit is best-effort */ + } + }; + + // Manual user override (highest authority). A user marks a story or epic + // `fast` / `full` via the `set_fast_lane` chat command, `autopilot + // fast-lane` CLI, or `/sprintpilot-plan-sprint`; it lives in the durable, + // clobber-resistant fast-lane-overrides.json and is consulted here BEFORE + // the automatic gate — and even when the lane is globally OFF, so "mark + // this one story fast" works without flipping the whole switch. A `fast` + // mark wins over the gate's deny-globs / size budget / tags (the human is + // trusted); `forced_full` (a story the escalation net bounced after it + // actually failed the fast path) still wins over a `fast` mark to prevent a + // fast→fail→fast loop. + const epicKey = deriveEpicFromStoryKey(storyKey); + const override = fastLaneOverrides.resolve(projectRoot, storyKey, epicKey); + const phase = persisted.current_bmad_step || null; + const atStoryStart = phase === null || FAST_LANE_STORY_START_PHASES.has(phase); + + // Decision lock at NANO_QUICK_DEV. A story here was already routed fast (a + // full profile only reaches NANO_QUICK_DEV via the fast lane); it is + // COMMITTED to quick-dev, so keep it `quick + fast_lane_active` and do NOT + // re-derive: + // - re-reading the story file would flip the gate to `full` (quick-dev + // appended a File List) and clear fast_lane_active just when adapt.js's + // escalation guards need it; + // - a `full` override arriving mid-quick-dev must NOT strip + // fast_lane_active either — that would silently defeat the escalation + // net AND still not produce a real full cycle (the phase stays + // nano_quick_dev). A `full` mark instead takes effect at the story's + // NEXT story-start re-derivation. Only `forced_full` (the escalation + // flow itself) downgrades an in-flight fast story. + // Also honors the config kill-switch loosely: turning the lane off does not + // yank a story out of an in-flight quick-dev run. + // + // This is also where the `fast` decision is RECORDED — at CREATE_STORY the + // file didn't exist yet so the gate logged `full`, and this is the first + // emitting phase where `fast` is knowable, so the ledger reads + // `full`(create-story) → `fast`. + if (!forcedFull && phase === STATES.NANO_QUICK_DEV) { + if (opts.emitLedger) { + logDecision( + fastLaneGate.DECISION_FAST, + override === 'fast' ? ['user_override_fast'] : ['locked_at_nano_quick_dev'], + ); + } + return { ...profile, implementation_flow: 'quick', fast_lane_active: true }; + } + + // Manual override at a routing decision point (before the automatic gate). + if (atStoryStart) { + if (!forcedFull && override === 'fast') { + if (opts.emitLedger) logDecision(fastLaneGate.DECISION_FAST, ['user_override_fast']); + return { ...profile, implementation_flow: 'quick', fast_lane_active: true }; + } + if (override === 'full') { + if (opts.emitLedger) logDecision(fastLaneGate.DECISION_FULL, ['user_override_full']); + return profile; + } + } + + // No override in force → fall to the automatic gate, but only when the lane + // is enabled (an override above already handled the lane-off case). + if (profile.fast_lane_enabled !== true) return profile; + + // Read the story file (best-effort). Missing → gate sees empty text → + // conservative `full`. Resolve from THIS story's convention path first; + // only trust persisted.story_file_path when it belongs to this story — it + // can still point at the PREVIOUS story's .md right after a re-resolution + // (e.g. current_story was rejected and storyKey advanced), which would + // classify the new story on stale content and emit a misleading ledger + // entry. + let storyText = ''; + const conventionPath = path.join( + projectRoot, + '_bmad-output', + 'implementation-artifacts', + `${storyKey}.md`, + ); + const persistedMatchesStory = + typeof persisted.story_file_path === 'string' && + path.basename(persisted.story_file_path) === `${storyKey}.md`; + const storyPath = + persistedMatchesStory && safeExistsSync(persisted.story_file_path) + ? persisted.story_file_path + : conventionPath; + if (safeExistsSync(storyPath)) { + try { + storyText = fs.readFileSync(storyPath, 'utf8'); + } catch (_e) { + storyText = ''; + } + } + + // Epic/plan-level fallback tag: honor a `fast_lane` / `risk` field on the + // story's sprint-plan.yaml entry, or on its epic entry, so an epic tagged + // low-risk cascades to its stories without tagging each. Story-FILE tags + // still win (resolved inside the gate). Best-effort; null when absent. + const fallbackTag = resolvePlanFastLaneTag(projectRoot, storyKey); + + const result = fastLaneGate.classifyStory({ + storyKey, + storyText, + config: profile, + forcedFull, + fallbackTag, + }); + + if (opts.emitLedger && atStoryStart) logDecision(result.decision, result.reasons); + + if (result.decision === fastLaneGate.DECISION_FAST) { + // Only flip at a story-start phase (or a fresh session). A story that + // already entered the FULL cycle (create-story wrote a file the gate now + // reads as `fast`) must NOT flip mid-cycle to a quick+fast_lane_active + // profile — the routing is decided once, at the CREATE_STORY → successor + // transition, and stays put. The fast-lane phases (PREPARE_STORY_BRANCH, + // CREATE_STORY, NANO_QUICK_DEV) are exactly where the flip is meaningful. + if (atStoryStart) { + return { ...profile, implementation_flow: 'quick', fast_lane_active: true }; + } + } + return profile; + } catch (e) { + log.warn(`fast-lane gate skipped: ${e.message}`); + return profile; + } +} + // Compose the runtime `state` shape the state machine expects from the // persisted autopilot-state.yaml. Missing fields default to fresh-session // values; the CLI does not assume more than what's on disk. @@ -873,8 +1102,11 @@ function composeRuntimeState(persisted, profile, projectRoot) { profile.enabled !== false && !profile.reuse_user_branch && (profile.granularity === 'story' || profile.granularity === 'epic'); + // nano (whole-profile quick) boots straight at NANO_QUICK_DEV. The per-story + // fast lane (fast_lane_active) boots at CREATE_STORY instead — it must run + // create-story first so the pre-story gate can read a real story file. const flowStart = - profile && profile.implementation_flow === 'quick' + profile && profile.implementation_flow === 'quick' && !profile.fast_lane_active ? STATES.NANO_QUICK_DEV : STATES.CREATE_STORY; const defaultPhase = needsBranchPrep ? STATES.PREPARE_STORY_BRANCH : flowStart; @@ -1185,6 +1417,12 @@ function composeRuntimeState(persisted, profile, projectRoot) { // Head is the current pick; adapt.advanceState pops on story // completion. Empty array means "no override; use resolveNextStoryKey." story_queue: persistedQueue, + // Fast-lane escalation ledger: story keys bounced from the quick-dev + // fast lane back to the full 7-step cycle. deriveEffectiveProfile / + // fast-lane-gate.js consult it so a re-run story never re-fast-lanes. + fast_lane_forced_full: Array.isArray(persisted.fast_lane_forced_full) + ? persisted.fast_lane_forced_full + : [], // Land-as-you-go: pending land state survives rebase-conflict halts. land_pending: persisted.land_pending || null, // Pending alternative (propose_alternative → user_prompt) survives @@ -1251,6 +1489,9 @@ function persistRuntimeState(runtime, profile, projectRoot) { current_epic: runtime.current_epic, ac_summary: runtime.ac_summary, prior_diagnosis: runtime.prior_diagnosis, + // Story-scoped escalation context (fast-lane re-run notice). Surfaced as + // profile_specific_notes every phase; cleared at each new-story boundary. + escalation_note: runtime.escalation_note || null, relevant_decisions: runtime.relevant_decisions, prior_signals_summary: runtime.prior_signals_summary, patch_findings: runtime.patch_findings, @@ -1262,6 +1503,9 @@ function persistRuntimeState(runtime, profile, projectRoot) { consecutive_test_failures: runtime.consecutive_test_failures, user_branch: runtime.user_branch, story_queue: Array.isArray(runtime.story_queue) ? runtime.story_queue : [], + fast_lane_forced_full: Array.isArray(runtime.fast_lane_forced_full) + ? runtime.fast_lane_forced_full + : [], land_pending: runtime.land_pending, pending_alternative: runtime.pending_alternative || null, session_stories_completed: runtime.session_stories_completed || 0, @@ -2602,6 +2846,35 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) { } break; } + case 'set_fast_lane': { + // Persist / clear a fast|full mark in the durable overrides store. An + // `epic-` key routes to the epics bucket regardless of which field + // carried it (defensive: the LLM might place an epic in story_key). The + // audit entry logs the normalized key so it matches what resolve() sees. + try { + const rawKey = eff.epic || eff.story_key; + if (!rawKey) break; + const isEpic = !!eff.epic || /^epic-/i.test(rawKey); + const auditKey = isEpic ? fastLaneOverrides.normalizeEpicKey(rawKey) : rawKey; + const target = isEpic ? 'epic' : 'story'; + if (eff.decision === 'auto') { + const cleared = fastLaneOverrides.clearOverride(projectRoot, rawKey, { isEpic }); + ledger.append( + { kind: 'fast_lane_override_set', target, key: auditKey, decision: 'auto', cleared }, + { projectRoot }, + ); + } else { + const res = fastLaneOverrides.setOverride(projectRoot, rawKey, eff.decision, { isEpic }); + ledger.append( + { kind: 'fast_lane_override_set', target, key: res.key || auditKey, decision: eff.decision, ok: res.ok }, + { projectRoot }, + ); + } + } catch (e) { + log.warn(`set_fast_lane failed: ${e.message}`); + } + break; + } default: // Unknown side-effect kinds are recorded but otherwise ignored. ledger.append({ kind: 'state_transition', detail: eff }, { projectRoot }); @@ -3510,8 +3783,13 @@ function cmdStart(opts) { } // Fresh start or clean resume. `composeRuntimeState` applies the - // profile-aware initial phase when persisted state is empty. - const runtime = composeRuntimeState(persisted, profile, projectRoot); + // profile-aware initial phase when persisted state is empty. Apply the + // per-story fast-lane routing so the FIRST emitted action already matches + // what `next` will re-derive (no ledger emit here — cmdNext owns the + // audit entry; start immediately followed by next would otherwise + // double-log the decision). + const effectiveProfile = deriveEffectiveProfile(persisted, profile, projectRoot); + const runtime = composeRuntimeState(persisted, effectiveProfile, projectRoot); // session_story_limit is per-session — a fresh `autopilot start` // resets the counter so the next batch of N stories can run before @@ -3519,7 +3797,7 @@ function cmdStart(opts) { // increments on STORY_DONE → EPIC_BOUNDARY_CHECK.) runtime.session_stories_completed = 0; - const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot); + const lockResult = lockUserBranchIfNeeded(runtime, effectiveProfile, projectRoot); if (lockResult && lockResult.halt) { const halt = decorateHaltContext(lockResult.halt, runtime, projectRoot); ledger.append( @@ -3537,21 +3815,26 @@ function cmdStart(opts) { decorateTestScope( decorateRunScript( decorateResumeHint( - decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot), + decorateGitOp( + stateMachine.nextAction(runtime, effectiveProfile), + runtime, + effectiveProfile, + projectRoot, + ), runtime, - profile, + effectiveProfile, projectRoot, ), runtime, - profile, + effectiveProfile, projectRoot, ), runtime, - profile, + effectiveProfile, projectRoot, ), runtime, - profile, + effectiveProfile, projectRoot, ), runtime, @@ -3559,11 +3842,17 @@ function cmdStart(opts) { ); // land_as_you_go guard: never start a new story while the previous one // is unpushed/unlanded. Overrides the emitted action with a halt prompt. - const landGuard = guardLandAsYouGoPredecessor({ action, runtime, profile, projectRoot }); + const landGuard = guardLandAsYouGoPredecessor({ + action, + runtime, + profile: effectiveProfile, + projectRoot, + }); if (landGuard) action = decorateHaltContext(landGuard, runtime, projectRoot); ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot }); - persistRuntimeState(runtime, profile, projectRoot); - if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key }); + persistRuntimeState(runtime, effectiveProfile, projectRoot); + if (effectiveProfile.coalesce_state_writes) + stateStore.flush(effectiveProfile, { projectRoot, story: runtime.story_key }); const nextSummary = formatNextStorySummary(runtime, action, persisted.story_queue); process.stdout.write( `${JSON.stringify({ action, phase: runtime.phase, next_summary: nextSummary }, null, 2)}\n`, @@ -3573,7 +3862,7 @@ function cmdStart(opts) { function cmdNext(opts) { const projectRoot = resolveProjectRoot(opts); - const { typed: profile } = resolveProfile(projectRoot, opts.profile); + const { typed: baseProfile } = resolveProfile(projectRoot, opts.profile); const persisted = loadState(projectRoot); // land_as_you_go auto-recovery (also on the `next`-driven path, since the // workflow drives `next` directly without `start`). Rewinds persisted @@ -3581,7 +3870,9 @@ function cmdNext(opts) { // runtime, so this emission finishes that story instead of skipping ahead. // The rewound state is persisted by cmdNext's own persistRuntimeState call // below (it derives from the mutated `persisted` via composeRuntimeState). - const landRecovery = recoverUnlandedPredecessor({ persisted, profile, projectRoot }); + // Uses the base profile: recovery is a git/landing concern, independent of + // the per-story fast-lane flow. + const landRecovery = recoverUnlandedPredecessor({ persisted, profile: baseProfile, projectRoot }); if (landRecovery) { ledger.append( { @@ -3605,6 +3896,13 @@ function cmdNext(opts) { `(${landRecovery.reason}) before starting the next story.\n`, ); } + // Per-story fast-lane routing (no-op unless autopilot.fast_lane.enabled). + // Derived AFTER land recovery so the decision + audit entry key on the + // (possibly rewound) story, not the one we were about to skip to. cmdNext + // owns the audit entry (emitLedger) — recorded ~once per story-start. + const profile = deriveEffectiveProfile(persisted, baseProfile, projectRoot, { + emitLedger: true, + }); const runtime = composeRuntimeState(persisted, profile, projectRoot); // --test-scope override (one-shot, this emission only). Accepted values // match profile.testing_scope. Threaded into the runtime so @@ -3680,8 +3978,12 @@ function cmdNext(opts) { function cmdRecord(opts) { const projectRoot = resolveProjectRoot(opts); - const { typed: profile } = resolveProfile(projectRoot, opts.profile); + const { typed: baseProfile } = resolveProfile(projectRoot, opts.profile); const persisted = loadState(projectRoot); + // Per-story fast-lane routing must match what cmdNext emitted so verify / + // escalation see the same effective flow (fast_lane_active). No ledger + // emission here — cmdNext already recorded the decision. + const profile = deriveEffectiveProfile(persisted, baseProfile, projectRoot); const runtime = composeRuntimeState(persisted, profile, projectRoot); let signalJson; @@ -4088,6 +4390,19 @@ function cmdRecord(opts) { function cmdState(opts) { const projectRoot = resolveProjectRoot(opts); const persisted = loadState(projectRoot); + // Decorate the dumped state with the per-story fast-lane decision so the + // display reflects what the next emission will route to (no ledger emit — + // this is a read-only inspector). Only when the fast lane is enabled. The + // preview lives under a clearly-synthetic `_fast_lane_preview` key (leading + // underscore) so nothing mistakes it for a persisted state field. + const { typed: baseProfile } = resolveProfile(projectRoot, opts.profile); + if (baseProfile && baseProfile.fast_lane_enabled === true) { + const eff = deriveEffectiveProfile(persisted, baseProfile, projectRoot); + persisted._fast_lane_preview = { + effective_flow: eff.implementation_flow, + fast_lane_active: eff.fast_lane_active === true, + }; + } process.stdout.write(`${JSON.stringify(persisted, null, 2)}\n`); return 0; } @@ -4182,6 +4497,57 @@ function cmdResume(opts) { return 0; } +// `autopilot fast-lane [--epic]` — set or +// clear a durable fast|full override for a story or epic. `auto` clears the +// mark (reverts to the automatic gate). Used by users directly and by the +// /sprintpilot-plan-sprint skill. The mark lives in the clobber-resistant +// fast-lane-overrides.json and is the highest-authority routing signal. +function cmdFastLane(opts, args) { + const projectRoot = resolveProjectRoot(opts); + const key = args[0]; + const decision = args[1]; + if (!key || !decision) { + log.error('usage: autopilot fast-lane > '); + return 2; + } + if (!['fast', 'full', 'auto'].includes(decision)) { + log.error(`invalid decision ${JSON.stringify(decision)} (expected fast | full | auto)`); + return 2; + } + // An epic target is the `epic-` key form (unambiguous, no flag needed). + const isEpic = /^epic-/i.test(key); + const label = isEpic ? `epic ${fastLaneOverrides.normalizeEpicKey(key)}` : `story ${key}`; + try { + if (decision === 'auto') { + const cleared = fastLaneOverrides.clearOverride(projectRoot, key, { isEpic }); + ledger.append( + { kind: 'fast_lane_override_set', target: isEpic ? 'epic' : 'story', key, decision: 'auto', cleared }, + { projectRoot }, + ); + process.stdout.write( + cleared + ? `Cleared the fast-lane mark on ${label} — it reverts to the automatic gate.\n` + : `No fast-lane mark set on ${label}.\n`, + ); + return 0; + } + const res = fastLaneOverrides.setOverride(projectRoot, key, decision, { isEpic }); + if (!res.ok) { + log.error(`could not set fast-lane mark: ${res.reason || 'invalid'}`); + return 2; + } + ledger.append( + { kind: 'fast_lane_override_set', target: isEpic ? 'epic' : 'story', key: res.key, decision }, + { projectRoot }, + ); + process.stdout.write(`Marked ${label} as ${decision}.\n`); + return 0; + } catch (e) { + log.error(`fast-lane: ${e.message}`); + return 1; + } +} + function cmdReport(opts) { const projectRoot = resolveProjectRoot(opts); const { typed: profile } = resolveProfile(projectRoot, opts.profile); @@ -4323,6 +4689,42 @@ function buildRichStatus(projectRoot, persisted, opts) { profileName = null; } + // Fast-lane routing summary (null when the lane never fired this sprint — + // the ledger is append-only and sprint-lifetime, not reset per session): + // the current story's latest decision + sprint counts of fast-laned and + // escalated-back stories. Derived from the fast_lane_decision / + // profile_escalated ledger entries. + let fastLane = null; + const flDecisions = ledgerEntries.filter((e) => e.kind === 'fast_lane_decision'); + if (flDecisions.length > 0) { + // "Fast-laned" = ever routed fast (ran quick-dev), not just the latest + // decision — an escalated story still ran quick-dev. `current_decision` + // is the current story's LATEST decision. + const everFast = new Set(); + const latestByStory = new Map(); + for (const e of flDecisions) { + const k = e.story_key || '(unknown)'; + latestByStory.set(k, e.decision); + if (e.decision === 'fast') everFast.add(k); + } + const escalated = new Set( + ledgerEntries + .filter((e) => e.kind === 'profile_escalated' && e.from === 'fast_lane') + .map((e) => e.story_key || '(unknown)'), + ); + const curKey = persisted.current_story || null; + // Current story's decision: show `fast→full` while it's running the + // escalated full cycle (it was fast-laned then bounced), rather than the + // stale `fast` its last fast_lane_decision entry still reads. + let currentDecision = curKey && latestByStory.has(curKey) ? latestByStory.get(curKey) : null; + if (curKey && escalated.has(curKey)) currentDecision = 'fast→full'; + fastLane = { + current_decision: currentDecision, + fast_laned: everFast.size, + escalated: escalated.size, + }; + } + // Single authoritative "what runs next" line — same composer the // start/next envelopes use, so `autopilot progress` agrees with them // byte-for-byte. Built from a minimal runtime view of persisted state. @@ -4358,6 +4760,7 @@ function buildRichStatus(projectRoot, persisted, opts) { recent_events: recent, background_full_suite: backgroundFullSuite, quarantined_test_count: quarantinedCount, + fast_lane: fastLane, }; } @@ -4382,6 +4785,14 @@ function renderStatusHuman(s) { if (s.session_stories_completed > 0) { lines.push(`session stories done: ${s.session_stories_completed}`); } + if (s.fast_lane) { + const cur = s.fast_lane.current_decision + ? ` (this story: ${s.fast_lane.current_decision})` + : ''; + lines.push( + `fast-lane: ${s.fast_lane.fast_laned} fast-laned, ${s.fast_lane.escalated} escalated back${cur}`, + ); + } if (s.halt_active) { lines.push(`HALT: ${s.halt_reason || 'unknown'}`); } @@ -5241,6 +5652,8 @@ function main(argv) { return cmdTasks(opts); case 'quarantine': return cmdQuarantine(opts, positional.slice(1)); + case 'fast-lane': + return cmdFastLane(opts, positional.slice(1)); case 'watch': return cmdWatch(opts); case 'resume': @@ -5277,6 +5690,8 @@ module.exports = { decorateGitOp, decorateRunScript, composeRuntimeState, + // Per-story fast-lane routing seam (exposed for unit/integration tests). + deriveEffectiveProfile, acquireAutopilotLock, runWorktreeHealthCheck, // v2.5.0 observability helpers (exposed for unit tests). diff --git a/_Sprintpilot/lib/orchestrator/action-ledger.js b/_Sprintpilot/lib/orchestrator/action-ledger.js index 369a26b..b77a89b 100644 --- a/_Sprintpilot/lib/orchestrator/action-ledger.js +++ b/_Sprintpilot/lib/orchestrator/action-ledger.js @@ -113,6 +113,13 @@ const VALID_KINDS = [ // checkpoint and the terminal signal lets the next boot replay it // back to the skill as `resume_hint.checkpoint`. 'skill_checkpoint', + // Fast lane — the per-story fast|full routing decision (story_key, decision, + // reasons[]) emitted by deriveEffectiveProfile at story start, deduped so + // there is ~one entry per story. Makes every routing choice inspectable. + 'fast_lane_decision', + // Fast lane — a user MARK (set/clear a fast|full override on a story/epic) + // applied via the set_fast_lane command / `autopilot fast-lane` CLI. + 'fast_lane_override_set', ]; function isPlainObject(v) { diff --git a/_Sprintpilot/lib/orchestrator/adapt.js b/_Sprintpilot/lib/orchestrator/adapt.js index 6dbd75f..a19b7a1 100644 --- a/_Sprintpilot/lib/orchestrator/adapt.js +++ b/_Sprintpilot/lib/orchestrator/adapt.js @@ -56,6 +56,44 @@ const SIGNAL_STATUSES = [ 'verify_override', ]; +// fastLaneRerunNote(reason, rerunPhase) → a skill-facing note surfaced as +// `profile_specific_notes` for every phase of a fast-lane escalation. +// +// Two shapes, because the two escalation origins re-enter at different phases: +// - CREATE_STORY (hard-failure path): quick-dev FAILED, the story is not +// `done`, and we re-run the full 7-step cycle over the committed-but- +// deficient code. DEV_RED must know it's hardening existing code, not +// doing greenfield RED (tests are EXPECTED to fail against current code). +// - CODE_REVIEW (success-but-flagged path): quick-dev completed and marked +// the story `done`, but reported failing tests / a high-severity finding. +// We run the adversarial review the fast lane skipped, over the shipped +// code, and patch what it finds. +const FAST_LANE_REASON_PHRASE = { + tests_failed: 'quick-dev reported failing tests', + high_severity: 'quick-dev flagged a high-severity finding', + quick_dev_failure: 'the quick-dev one-shot failed', +}; +function fastLaneRerunNote(reason, rerunPhase) { + const why = FAST_LANE_REASON_PHRASE[reason] || 'the quick-dev one-shot did not pass'; + if (rerunPhase === STATES.CODE_REVIEW) { + return ( + `⚠ FAST-LANE ESCALATION — this story was fast-laned (quick-dev one-shot) and completed, ` + + `but ${why}. It is being sent through the full adversarial CODE REVIEW that the fast lane ` + + `skipped. Review the committed implementation rigorously; treat any finding as real and ` + + `route fixes through patch. Do not rubber-stamp it — this pass exists because the one-shot ` + + `flagged a problem.` + ); + } + return ( + `⚠ FAST-LANE ESCALATION — this story was routed through quick-dev (one-shot) and bounced ` + + `to the full 7-step cycle because ${why}. Quick-dev has ALREADY committed an implementation ` + + `on the story branch; treat it as KNOWN-DEFICIENT. This is a rigor pass over existing code, ` + + `NOT greenfield: in DEV_RED, write tests that encode the acceptance criteria and the observed ` + + `failure — they are EXPECTED to fail against the current implementation — then DEV_GREEN fixes ` + + `until all pass, followed by full code review. Do not delete the existing work; harden it.` + ); +} + // Pure: given current orchestrator state + the incoming signal, return: // { // newState, // updated runtime state shape @@ -197,18 +235,43 @@ function handleSuccess(state, signal, profile, verifyResult, sideEffects) { }; } - // Verify passed (or wasn't provided). For nano: check escalation triggers. + // Verify passed (or wasn't provided). Quick-dev escalation triggers: a + // nano session escalates its REMAINING stories to full; a per-story + // fast-laned story (fast_lane_active under a FULL profile) bounces THIS + // story back to the full 7-step cycle. let workingProfile = profile; + let fastLaneRerunPhase = null; + const forcedFullAdditions = []; if (state.phase === STATES.NANO_QUICK_DEV) { const escalated = escalateOnFailure(profile, signal.output); if (escalated !== profile) { workingProfile = escalated; - sideEffects.push({ - kind: 'profile_escalated', - from: 'nano', - to: escalated.name, - reason: escalated.escalation_reason, - }); + if (escalated.escalated_from === 'fast_lane') { + // This is the SUCCESS path: quick-dev completed and marked the story + // `done` (verifyNanoQuickDev requires it), but reported failing tests + // or a high-severity finding. Route to CODE_REVIEW — the adversarial + // review the fast lane skipped — NOT to CREATE_STORY: a `done` story + // is rejected+skipped by composeRuntimeState at CREATE_STORY (that + // phase isn't in its done-rejection skip-set), whereas CODE_REVIEW IS, + // so the story survives re-resolution and actually gets the review. + // Record it so the gate keeps it full on re-derivation. + fastLaneRerunPhase = STATES.CODE_REVIEW; + if (state.story_key) forcedFullAdditions.push(state.story_key); + sideEffects.push({ + kind: 'profile_escalated', + from: 'fast_lane', + to: workingProfile.name, + story_key: state.story_key || null, + reason: escalated.escalation_reason, + }); + } else { + sideEffects.push({ + kind: 'profile_escalated', + from: 'nano', + to: escalated.name, + reason: escalated.escalation_reason, + }); + } } } @@ -234,7 +297,7 @@ function handleSuccess(state, signal, profile, verifyResult, sideEffects) { } } - const newPhase = nextStateAfterSuccess(state, workingProfile, signal); + const newPhase = fastLaneRerunPhase || nextStateAfterSuccess(state, workingProfile, signal); if (newPhase === null) { // Defensive: shouldn't normally happen since blocking-findings case is handled. return { @@ -253,6 +316,23 @@ function handleSuccess(state, signal, profile, verifyResult, sideEffects) { // Build the new state: carry forward story-scoped fields; reset retry counters. const newState = advanceState(state, workingProfile, newPhase, signal); + // Append fast-lane escalations to the durable forced-full ledger (carried + // via advanceState's `...state` spread; we union in the new story keys). + if (forcedFullAdditions.length > 0) { + const existing = Array.isArray(newState.fast_lane_forced_full) + ? newState.fast_lane_forced_full + : []; + newState.fast_lane_forced_full = Array.from(new Set([...existing, ...forcedFullAdditions])); + // Re-set the fast-lane escalation note AFTER advanceState (which cleared it + // as a new-story field). The note shape depends on the re-entry phase + // (CODE_REVIEW review-pass vs CREATE_STORY full re-run). + if (fastLaneRerunPhase) { + newState.escalation_note = fastLaneRerunNote( + workingProfile.escalation_reason, + fastLaneRerunPhase, + ); + } + } return { newState, newProfile: workingProfile, @@ -263,6 +343,50 @@ function handleSuccess(state, signal, profile, verifyResult, sideEffects) { } function handleFailure(state, signal, profile, sideEffects) { + // Fast-lane hard-failure fallback. A story fast-laned under a FULL profile + // whose quick-dev run FAILS (any status:failure, recoverable or not) bounces + // to the full 7-step cycle rather than retrying the one-shot: the fast-path + // bet was wrong, and the sanctioned recovery is the rigorous cycle, not a + // blind retry. The story is recorded in fast_lane_forced_full so the + // pre-story gate keeps it full on re-derivation (no loop back to fast). This + // complements the success-with-failing-tests escalation in handleSuccess. + if (state.phase === STATES.NANO_QUICK_DEV && profile.fast_lane_active) { + const fullProfile = { + ...profile, + implementation_flow: 'full', + fast_lane_active: false, + escalated_from: 'fast_lane', + escalation_reason: 'quick_dev_failure', + }; + sideEffects.push({ + kind: 'profile_escalated', + from: 'fast_lane', + to: fullProfile.name, + story_key: state.story_key || null, + reason: 'quick_dev_failure', + }); + const rerunState = advanceState(state, fullProfile, STATES.CREATE_STORY, signal); + const existing = Array.isArray(rerunState.fast_lane_forced_full) + ? rerunState.fast_lane_forced_full + : []; + if (state.story_key && !existing.includes(state.story_key)) { + rerunState.fast_lane_forced_full = [...existing, state.story_key]; + } + // Carry the failure diagnosis into the full re-run's first phase, and set + // the fast-lane re-run note (surfaced every phase) so DEV_RED knows it's + // hardening existing, committed-but-deficient code — not doing greenfield + // RED. Re-set AFTER advanceState, which cleared it as a new-story field. + rerunState.prior_diagnosis = signal.diagnosis || null; + rerunState.escalation_note = fastLaneRerunNote('quick_dev_failure', STATES.CREATE_STORY); + return { + newState: rerunState, + newProfile: fullProfile, + nextAction: nextAction(rerunState, fullProfile), + sideEffects, + verdict: 'advanced', + }; + } + const recoverable = signal.recoverable !== false; const retryCount = (state.retry_count_this_phase || 0) + 1; const exhausted = retryCount > profile.retry_budget_per_action; @@ -706,6 +830,20 @@ function advanceState(state, profile, newPhase, signal) { next.test_files = null; } + // escalation_note is story-scoped context (e.g. the fast-lane re-run notice). + // Clear it at the FIRST phase of a fresh story so it never bleeds into the + // next story. PREPARE_STORY_BRANCH is included because under branch-prep + // profiles it — not CREATE_STORY — is the new-story boundary. A fast-lane + // escalation routes TO CREATE_STORY (branch already exists) and re-sets the + // note AFTER advanceState returns (see handleSuccess / handleFailure). + if ( + newPhase === STATES.PREPARE_STORY_BRANCH || + newPhase === STATES.CREATE_STORY || + newPhase === STATES.NANO_QUICK_DEV + ) { + next.escalation_note = null; + } + // test_scope_hint propagation. dev-story / nano-quick-dev signals may // carry `test_scope_hint: { scope: 'full' } | { include_dirs: [...] }` // when the LLM realizes the change is structural (refactor of a shared @@ -761,9 +899,16 @@ function advanceState(state, profile, newPhase, signal) { // `output.sprint_is_complete: false` if they have additional stories to // run (e.g. a sprint-status.yaml with multiple pending stories was // pre-seeded). + // + // EXCLUDES the per-story fast lane: a fast-laned story under a FULL profile + // also runs NANO_QUICK_DEV with implementation_flow='quick', but the sprint + // has many more stories to go — marking it complete after one fast-laned + // story would wrongly halt the whole run. `fast_lane_active` distinguishes + // the two. if ( state.phase === STATES.NANO_QUICK_DEV && profile.implementation_flow === 'quick' && + !profile.fast_lane_active && !next.sprint_is_complete ) { const explicitOverride = signal && signal.output && signal.output.sprint_is_complete === false; diff --git a/_Sprintpilot/lib/orchestrator/fast-lane-gate.js b/_Sprintpilot/lib/orchestrator/fast-lane-gate.js new file mode 100644 index 0000000..e4b1b9f --- /dev/null +++ b/_Sprintpilot/lib/orchestrator/fast-lane-gate.js @@ -0,0 +1,380 @@ +// fast-lane-gate.js — pre-story risk classifier for the quick-dev fast lane. +// +// The fast lane routes LOW-RISK stories through `bmad-quick-dev` (one-shot) +// under a FULL profile (small / medium / large / legacy), while substantial +// stories keep the mandatory 7-step BMad cycle. This module is the +// deterministic, cheap, pre-implementation gate that decides `fast | full` +// for a single story from signals available BEFORE any code is written: +// +// - Acceptance-Criteria count (the story-size gate). Tasks/Subtasks count +// is also parsed and exposed on the signals, but reserved — no +// `max_tasks` knob drives the decision today; AC count is the size proxy. +// - path allow/deny globs matched against paths the story declares +// - an explicit per-story tag (`fast_lane: true|false` / `risk: low|high`) +// - a persisted escalation marker (a story bounced back to `full`) +// +// Design contract (see docs/quick-dev-fast-lane-plan.md): +// - PURE: no I/O. The CLI reads the story file + config and injects text. +// - CONSERVATIVE: any uncertainty resolves to `full`. The fast lane is a +// sanctioned, opt-in, default-OFF relaxation — never an implicit skip. +// - DENY WINS: a deny-glob match (auth / migrations / secrets / …) forces +// `full` even against an explicit fast tag. Safety is not overridable +// from the story file. +// - ESCALATION IS STICKY: a story recorded in `forcedFull` never fast-lanes +// again, so a misclassified story that bounced to the full cycle stays +// full on re-derivation. +// +// The classifier mirrors change-size-classifier.js in spirit (scale process +// to risk) but runs PRE-diff, so it's a sibling, not a reuse: it reads the +// story spec, not a git diff. + +'use strict'; + +const DECISION_FAST = 'fast'; +const DECISION_FULL = 'full'; + +// Convert a glob to an anchored RegExp. Supports the subset the fast-lane +// config needs: `**` (any run of chars incl. `/`), `*` (any run except +// `/`), `?` (single non-`/`). Everything else is matched literally. Kept +// local (no minimatch dep) — the orchestrator ships zero runtime deps into +// the user's project. +function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i += 1) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + // `**` → a globstar spanning whole path segments. Compile with real + // segment boundaries so `**/auth/**` matches `src/auth/x` but NOT + // `src/oauth/x` (substring `.*auth` would wrongly match the latter). + i += 1; + const followedBySlash = glob[i + 1] === '/'; + if (re.endsWith('/')) { + // `/**` (or `/**/`) → optional descendant: `docs/**` matches both + // `docs/x` AND bare `docs`. + re = `${re.slice(0, -1)}(?:/.*)?`; + if (followedBySlash) i += 1; + } else if (followedBySlash) { + // `**/` (leading, or after a literal) → optional ancestor dirs. + re += '(?:.*/)?'; + i += 1; + } else { + // bare `**` + re += '.*'; + } + } else { + re += '[^/]*'; + } + } else if (c === '?') { + re += '[^/]'; + } else if ('.+^${}()|[]\\'.includes(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +// matchesAnyGlob(path, globs) → boolean. Normalizes a leading `./` and +// backslashes so declared paths compare cleanly against POSIX-style globs. +function matchesAnyGlob(p, globs) { + if (!p || !Array.isArray(globs) || globs.length === 0) return false; + const norm = String(p).replace(/\\/g, '/').replace(/^\.\//, ''); + for (const g of globs) { + if (typeof g !== 'string' || g.length === 0) continue; + if (globToRegExp(g).test(norm)) return true; + } + return false; +} + +// countAcceptanceCriteria(text) → int. Finds the AC section (matching the +// same heading tolerance verify.js uses) and counts the list items that +// immediately follow it, stopping at the next heading. Returns 0 when no +// AC section is present. +function countAcceptanceCriteria(text) { + if (!text) return 0; + const m = text.match(/#{2,4}\s+(?:Acceptance Criteria|Acceptance criteria|AC)\b[^\n]*\n/i); + if (!m) return 0; + const start = m.index + m[0].length; + const rest = text.slice(start); + let count = 0; + for (const line of rest.split(/\r?\n/)) { + if (/^#{1,6}\s/.test(line)) break; // next heading ends the section + if (/^\s*(?:[-*]|\d+[.)])\s+\S/.test(line)) count += 1; + } + return count; +} + +// countTasks(text) → int. Counts task/subtask checkboxes (`[ ]` / `[x]`) +// under a Tasks/Subtasks section. Counts every checkbox in the file after +// the section heading, which is a fine proxy for story size. +function countTasks(text) { + if (!text) return 0; + const m = text.match(/#{2,4}\s+Tasks(?:\s*\/\s*Subtasks)?\b[^\n]*\n/i); + if (!m) return 0; + const rest = text.slice(m.index + m[0].length); + const matches = rest.match(/\[[ xX]\]/g); + return matches ? matches.length : 0; +} + +// extractTag(text) → 'fast' | 'full' | null. Reads an explicit routing +// intent from the story. Recognized (case-insensitive, value-tolerant): +// fast_lane: true|false → fast | full +// risk: low → fast +// risk: high | critical → full +// risk: medium → null (no opinion; fall to inference) +// A `full`-forcing signal always wins over a `fast`-forcing one so a story +// can never be dragged INTO the fast lane by a stray `risk: low` when it +// also declares `risk: high`. +function extractTag(text) { + if (!text) return null; + let sawFast = false; + let sawFull = false; + // Scan ALL occurrences (not just the first) so a later full-forcing tag + // isn't masked by an earlier fast one — `full` always wins. + const fastLaneRe = /(?:^|\n)[ \t]*(?:[-*][ \t]*)?fast[_-]?lane[ \t]*[:=][ \t]*(true|false|yes|no|on|off)/gi; + let m = fastLaneRe.exec(text); + while (m) { + if (/^(true|yes|on)$/i.test(m[1])) sawFast = true; + else sawFull = true; + m = fastLaneRe.exec(text); + } + const riskRe = /(?:^|\n)[ \t]*(?:[-*][ \t]*)?risk[ \t]*[:=][ \t]*(low|medium|high|critical)/gi; + let r = riskRe.exec(text); + while (r) { + if (/^low$/i.test(r[1])) sawFast = true; + else if (/^(high|critical)$/i.test(r[1])) sawFull = true; + r = riskRe.exec(text); + } + if (sawFull) return DECISION_FULL; + if (sawFast) return DECISION_FAST; + return null; +} + +// tagFromFields(obj) → 'fast' | 'full' | null. The structured analogue of +// extractTag, for a sprint-plan.yaml story/epic entry that carries an +// explicit `fast_lane` (bool/string) and/or `risk` (string) field. Used to +// honor epic-level (and plan-level per-story) routing tags — story-FILE tags +// still win over these (resolved in the caller). `full` beats `fast`. +function tagFromFields(obj) { + if (!obj || typeof obj !== 'object') return null; + let sawFast = false; + let sawFull = false; + const fl = obj.fast_lane; + if (fl === true || /^(true|yes|on|fast)$/i.test(String(fl))) sawFast = true; + else if (fl === false || /^(false|no|off|full)$/i.test(String(fl))) sawFull = true; + const risk = typeof obj.risk === 'string' ? obj.risk.trim().toLowerCase() : null; + if (risk === 'low') sawFast = true; + else if (risk === 'high' || risk === 'critical') sawFull = true; + if (sawFull) return DECISION_FULL; + if (sawFast) return DECISION_FAST; + return null; +} + +// extractDeclaredPaths(text) → string[]. Best-effort, conservative harvest +// of file paths the story says it will touch. Two sources: +// 1. inline code spans (`src/foo.ts`) that look like paths +// 2. list items under a "File List" / "Files" / "Affected Files" heading +// A "path-like" token contains a `/` or ends in a common source extension. +// Conservative by design: we only fast-lane when EVERY declared path is +// allow-listed, so under-collecting paths costs a fast-lane opportunity +// (safe) while over-collecting can only surface a deny match (also safe). +function extractDeclaredPaths(text) { + if (!text) return []; + const paths = new Set(); + // Path-like ⇔ contains a `/` OR ends in a real file extension (≥2 chars, + // alpha-led — so `e.g.` / `i.e.` in prose don't get mistaken for files). + const looksLikePath = (t) => + /[\w.-]+\/[\w./-]+/.test(t) || /\.[a-z][a-z0-9]{1,4}$/i.test(t.trim()); + + // 1. inline code spans + const spanRe = /`([^`\n]+)`/g; + let mm = spanRe.exec(text); + while (mm) { + const tok = mm[1].trim(); + if (tok && !/\s/.test(tok) && looksLikePath(tok)) paths.add(tok.replace(/[.,;:]$/, '')); + mm = spanRe.exec(text); + } + + // 2. list items under a File-List-style heading. Broad synonym set so more + // BMad story shapes get their declared paths harvested (still conservative — + // a token must look like a path to count). Accepts `-`/`*`/numbered items. + const headingRe = + /#{2,4}\s+(?:File List|Files|Affected Files|Modified Files|Files Modified|Relevant Files|Source Tree|Files? to (?:touch|change|modify|create))\b[^\n]*\n/i; + const hm = text.match(headingRe); + if (hm) { + const rest = text.slice(hm.index + hm[0].length); + for (const line of rest.split(/\r?\n/)) { + if (/^#{1,6}\s/.test(line)) break; + const li = line.match(/^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$/); + if (li) { + const tok = li[1].replace(/`/g, '').split(/\s+/)[0].replace(/[.,;:]$/, ''); + if (tok && looksLikePath(tok)) paths.add(tok); + } + } + } + return Array.from(paths); +} + +// extractStorySignals(text) → { acCount, taskCount, tag, declaredPaths }. +// Pure parse of a story markdown file into the numeric/categorical signals +// the gate reasons over. Split from the decision so tests can drive the +// decision with hand-built signals and drive the parser independently. +function extractStorySignals(text) { + return { + acCount: countAcceptanceCriteria(text), + taskCount: countTasks(text), + tag: extractTag(text), + declaredPaths: extractDeclaredPaths(text), + }; +} + +// normalizeConfig(config) → resolved fast-lane knobs with defaults. Accepts +// either the flat profile fields (fast_lane_enabled, …) or a nested +// { enabled, max_ac, … } object, so callers can pass a typed Profile +// directly. +function normalizeConfig(config) { + const c = config || {}; + const pick = (flat, nested, fallback) => { + if (c[flat] !== undefined && c[flat] !== null) return c[flat]; + if (c.fast_lane && c.fast_lane[nested] !== undefined && c.fast_lane[nested] !== null) { + return c.fast_lane[nested]; + } + return fallback; + }; + // max_ac: a non-negative integer. A negative / non-finite value (config + // typo) would silently force EVERY story full (acCount > -1 is always true), + // quietly disabling the lane — fall back to the default instead. + const rawMaxAc = pick('fast_lane_max_ac', 'max_ac', 3); + const maxAc = Number.isFinite(rawMaxAc) && rawMaxAc >= 0 ? rawMaxAc : 3; + return { + enabled: pick('fast_lane_enabled', 'enabled', false) === true, + maxAc, + allowGlobs: Array.isArray(pick('fast_lane_allow_globs', 'allow_globs', null)) + ? pick('fast_lane_allow_globs', 'allow_globs', null) + : [], + denyGlobs: Array.isArray(pick('fast_lane_deny_globs', 'deny_globs', null)) + ? pick('fast_lane_deny_globs', 'deny_globs', null) + : [], + requireStoryTag: pick('fast_lane_require_story_tag', 'require_story_tag', false) === true, + }; +} + +// evaluateSignals(signals, config, opts) → { decision, reasons }. +// The pure decision core. Precedence (first match wins): +// 0. forcedFull marker → full (sticky escalation) +// 1. fast lane disabled → full +// 2. explicit full-forcing tag → full +// 3. deny-glob match → full (hard safety; beats fast tag) +// 4. AC count over budget → full (size gate; beats fast tag too — +// a `risk: low` line can't wave a +// large story into the fast lane) +// 5. explicit fast-forcing tag → fast +// 6. require_story_tag & untagged → full +// 7. inferred low-risk → fast (declared paths ⊆ allow globs) +// 8. default → full (uncertainty) +// +// Tag resolution: the story-FILE tag (`s.tag`) wins; when it is null, an +// `opts.fallbackTag` (an epic-level / plan-entry tag, derived by the caller +// from sprint-plan.yaml) applies. This lets an epic tagged `risk: low` / +// `fast_lane: true` cascade to its stories without tagging each one. +function evaluateSignals(signals, config, opts = {}) { + const s = signals || {}; + const cfg = normalizeConfig(config); + const reasons = []; + + if (opts.forcedFull) { + return { decision: DECISION_FULL, reasons: ['escalated_forced_full'] }; + } + if (!cfg.enabled) { + return { decision: DECISION_FULL, reasons: ['fast_lane_disabled'] }; + } + + const paths = Array.isArray(s.declaredPaths) ? s.declaredPaths : []; + // Resolved routing tag: story-file wins, else the epic/plan fallback tag. + const tag = s.tag || opts.fallbackTag || null; + const tagSuffix = s.tag ? '' : opts.fallbackTag ? ':epic' : ''; + + if (tag === DECISION_FULL) { + return { decision: DECISION_FULL, reasons: [`tag_force_full${tagSuffix}`] }; + } + + const denied = paths.filter((p) => matchesAnyGlob(p, cfg.denyGlobs)); + if (denied.length > 0) { + return { decision: DECISION_FULL, reasons: [`deny_glob:${denied.join(',')}`] }; + } + + // Size gate runs BEFORE the fast-tag override so an explicit `fast_lane: + // true` / `risk: low` can only fast-lane a genuinely small story. A story + // with more ACs than the budget is contradictory-if-tagged → keep it full. + const acCount = Number.isFinite(s.acCount) ? s.acCount : 0; + if (acCount > cfg.maxAc) { + return { decision: DECISION_FULL, reasons: [`ac_count_${acCount}>${cfg.maxAc}`] }; + } + + if (tag === DECISION_FAST) { + // An epic/plan FALLBACK fast tag does NOT satisfy `require_story_tag` — + // that knob means "only stories that tag THEMSELVES fast-lane." So when + // required and the fast tag came from the epic (not the story file), fall + // through to the require_story_tag_unset gate below. A story-FILE fast tag + // (and any full-forcing tag, handled above) is unaffected. + const fromEpic = !s.tag && !!opts.fallbackTag; + if (!(fromEpic && cfg.requireStoryTag)) { + return { decision: DECISION_FAST, reasons: [`tag_force_fast${tagSuffix}`] }; + } + } + + if (cfg.requireStoryTag) { + return { decision: DECISION_FULL, reasons: ['require_story_tag_unset'] }; + } + + // Inference: only fast-lane when the story positively declares the files + // it touches AND every one is allow-listed. No declared paths → we can't + // confirm the blast radius → full. + if (paths.length > 0 && cfg.allowGlobs.length > 0) { + const allAllowed = paths.every((p) => matchesAnyGlob(p, cfg.allowGlobs)); + if (allAllowed) { + reasons.push(`inferred_low_risk(ac=${acCount},paths=${paths.length})`); + return { decision: DECISION_FAST, reasons }; + } + const outside = paths.filter((p) => !matchesAnyGlob(p, cfg.allowGlobs)); + return { decision: DECISION_FULL, reasons: [`paths_outside_allow:${outside.join(',')}`] }; + } + + return { decision: DECISION_FULL, reasons: ['default_full_no_allowlisted_paths'] }; +} + +// classifyStory({ storyKey, storyText, config, forcedFull }) → decision. +// The public entry point the CLI calls. Returns: +// { story_key, decision: 'fast'|'full', reasons: string[] } +// opts.fallbackTag ('fast'|'full'|null) is the epic/plan-level routing tag the +// caller resolved from sprint-plan.yaml — applied only when the story file +// carries no tag of its own. +function classifyStory({ + storyKey = null, + storyText = '', + config, + forcedFull = false, + fallbackTag = null, +} = {}) { + const signals = extractStorySignals(storyText || ''); + const { decision, reasons } = evaluateSignals(signals, config, { forcedFull, fallbackTag }); + return { story_key: storyKey, decision, reasons }; +} + +module.exports = { + DECISION_FAST, + DECISION_FULL, + globToRegExp, + matchesAnyGlob, + countAcceptanceCriteria, + countTasks, + extractTag, + tagFromFields, + extractDeclaredPaths, + extractStorySignals, + normalizeConfig, + evaluateSignals, + classifyStory, +}; diff --git a/_Sprintpilot/lib/orchestrator/fast-lane-overrides.js b/_Sprintpilot/lib/orchestrator/fast-lane-overrides.js new file mode 100644 index 0000000..16c63e0 --- /dev/null +++ b/_Sprintpilot/lib/orchestrator/fast-lane-overrides.js @@ -0,0 +1,152 @@ +// fast-lane-overrides.js — Sprintpilot-owned, durable per-story / per-epic +// fast|full overrides for the quick-dev fast lane. +// +// A user can explicitly mark a story (or a whole epic) `fast` or `full`, in +// chat ("fast-lane 4-1", "mark 4-2 full") via the `set_fast_lane` UserCommand, +// or from the CLI (`autopilot fast-lane `), or during +// `/sprintpilot-plan-sprint`. Those marks are the HIGHEST-authority routing +// signal — a `fast` mark wins over the gate's deny-globs / size budget / tags +// (the human is trusted), a `full` mark forces the full cycle. The only thing +// that still overrides a `fast` mark is `fast_lane_forced_full` (a story the +// automatic escalation net bounced after it actually failed the fast path — +// that wins to prevent a fast→fail→fast loop). +// +// Stored as its OWN JSON file — NOT sprint-plan.yaml, NOT autopilot-state.yaml +// — so it is clobber-resistant: a `/sprintpilot-plan-sprint` re-derivation +// regenerates the plan but never touches these marks (same rationale as +// excluded-stories.js). Replace-on-write semantics; a mark cleared to `auto` +// is deleted (deep-merge can't delete a nested key, which is why this isn't in +// autopilot-state.yaml). +// +// File: _bmad-output/implementation-artifacts/fast-lane-overrides.json +// { "fast_lane_overrides": { +// "stories": { "": { "decision": "fast"|"full", "recorded_at": ... } }, +// "epics": { "": { "decision": "fast"|"full", "recorded_at": ... } } } } +// +// All I/O goes through an injected `fs` so tests use tmp dirs. + +'use strict'; + +const nodeFs = require('node:fs'); +const path = require('node:path'); + +const VALID_DECISIONS = new Set(['fast', 'full']); + +function overridesPath(projectRoot) { + return path.join( + projectRoot, + '_bmad-output', + 'implementation-artifacts', + 'fast-lane-overrides.json', + ); +} + +// Epic keys are normalized to a bare id (`epic-5` / `Epic-5` / `5` → `5`) so a +// mark set as `epic-5` resolves against an epic derived as `5` and vice-versa. +function normalizeEpicKey(key) { + return typeof key === 'string' ? key.trim().replace(/^epic-/i, '') : ''; +} + +// Read the raw { stories, epics } maps. Tolerant: missing / unreadable / +// malformed file → empty maps (a mangled artifact must never wedge routing). +function readMap(projectRoot, fsImpl = nodeFs) { + const empty = { stories: {}, epics: {} }; + if (!projectRoot) return empty; + try { + const raw = fsImpl.readFileSync(overridesPath(projectRoot), 'utf8'); + const obj = JSON.parse(raw); + const root = obj && typeof obj === 'object' ? obj.fast_lane_overrides : null; + if (!root || typeof root !== 'object' || Array.isArray(root)) return empty; + const stories = + root.stories && typeof root.stories === 'object' && !Array.isArray(root.stories) + ? root.stories + : {}; + const epics = + root.epics && typeof root.epics === 'object' && !Array.isArray(root.epics) + ? root.epics + : {}; + return { stories, epics }; + } catch (_e) { + return empty; + } +} + +function decisionOf(entry) { + const d = entry && typeof entry === 'object' ? entry.decision : entry; + return VALID_DECISIONS.has(d) ? d : null; +} + +// resolve(projectRoot, storyKey, epicKey) → 'fast' | 'full' | null. +// A story-level mark wins over its epic-level mark; null when neither is set. +function resolve(projectRoot, storyKey, epicKey, fsImpl = nodeFs) { + const { stories, epics } = readMap(projectRoot, fsImpl); + if (typeof storyKey === 'string' && storyKey) { + const s = decisionOf(stories[storyKey]); + if (s) return s; + } + const ek = normalizeEpicKey(epicKey); + if (ek) { + const e = decisionOf(epics[ek]); + if (e) return e; + } + return null; +} + +function writeMap(projectRoot, map, fsImpl = nodeFs) { + const filePath = overridesPath(projectRoot); + fsImpl.mkdirSync(path.dirname(filePath), { recursive: true }); + const body = { + fast_lane_overrides: { stories: map.stories || {}, epics: map.epics || {} }, + }; + const text = `${JSON.stringify(body, null, 2)}\n`; + // Atomic write via tmp sibling + rename (mirrors state-store.js). + const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`; + fsImpl.writeFileSync(tmp, text, 'utf8'); + fsImpl.renameSync(tmp, filePath); +} + +// setOverride(projectRoot, key, decision, { isEpic }) — mark a story or epic +// `fast` or `full`. Idempotent on the decision; preserves recorded_at only when +// the decision is unchanged. Returns { ok, bucket, key } or { ok:false }. +function setOverride(projectRoot, key, decision, opts = {}, fsImpl = nodeFs) { + if (typeof key !== 'string' || !key || !VALID_DECISIONS.has(decision)) { + return { ok: false, reason: 'invalid_args' }; + } + const map = readMap(projectRoot, fsImpl); + const bucket = opts.isEpic ? 'epics' : 'stories'; + const storeKey = opts.isEpic ? normalizeEpicKey(key) : key; + if (!storeKey) return { ok: false, reason: 'invalid_args' }; + const existing = map[bucket][storeKey]; + map[bucket][storeKey] = { + decision, + recorded_at: + existing && existing.decision === decision && existing.recorded_at + ? existing.recorded_at + : new Date().toISOString(), + }; + writeMap(projectRoot, map, fsImpl); + return { ok: true, bucket, key: storeKey, decision }; +} + +// clearOverride(projectRoot, key, { isEpic }) — revert a story/epic to `auto` +// (gate-decided). Returns true iff an entry was removed. +function clearOverride(projectRoot, key, opts = {}, fsImpl = nodeFs) { + if (typeof key !== 'string' || !key) return false; + const map = readMap(projectRoot, fsImpl); + const bucket = opts.isEpic ? 'epics' : 'stories'; + const storeKey = opts.isEpic ? normalizeEpicKey(key) : key; + if (!Object.prototype.hasOwnProperty.call(map[bucket], storeKey)) return false; + delete map[bucket][storeKey]; + writeMap(projectRoot, map, fsImpl); + return true; +} + +module.exports = { + overridesPath, + normalizeEpicKey, + readMap, + resolve, + setOverride, + clearOverride, + VALID_DECISIONS, +}; diff --git a/_Sprintpilot/lib/orchestrator/profile-rules.js b/_Sprintpilot/lib/orchestrator/profile-rules.js index 5beafe4..276186b 100644 --- a/_Sprintpilot/lib/orchestrator/profile-rules.js +++ b/_Sprintpilot/lib/orchestrator/profile-rules.js @@ -64,6 +64,9 @@ const PHASE_TIMEOUT_DEFAULTS_BY_PROFILE = { code_review: 10, patch_apply: 10, patch_retest: 10, + // Fast lane: a low-risk story may run quick-dev (one-shot) under a full + // profile. Budget it like dev_green so a hung fast-lane run still halts. + nano_quick_dev: 20, }, medium: { create_story: 5, @@ -73,6 +76,7 @@ const PHASE_TIMEOUT_DEFAULTS_BY_PROFILE = { code_review: 15, patch_apply: 15, patch_retest: 15, + nano_quick_dev: 30, }, large: { create_story: 10, @@ -82,6 +86,7 @@ const PHASE_TIMEOUT_DEFAULTS_BY_PROFILE = { code_review: 30, patch_apply: 30, patch_retest: 30, + nano_quick_dev: 60, }, legacy: null, }; @@ -115,6 +120,16 @@ function coerceEnum(v, allowed, fallback) { return fallback; } +// Coerce a glob list. resolve-profile.js's narrow YAML parser can't emit +// arrays, so the portable representation is a comma-separated string +// (`"docs/**,**/*.md"`). Also tolerate a real array (when parsed by js-yaml +// in tests / a richer host). Returns a trimmed string[] with empties dropped. +function coerceGlobList(v) { + if (Array.isArray(v)) return v.filter((g) => typeof g === 'string' && g.trim().length > 0).map((g) => g.trim()); + if (typeof v === 'string') return v.split(',').map((g) => g.trim()).filter((g) => g.length > 0); + return []; +} + // Coerce a per-phase timeout map. Accepts: // - null / undefined → use defaults // - explicit null in YAML → disable all timeouts (returns null) @@ -392,27 +407,87 @@ function flatToProfile(resolved, profileName) { typeof get(resolved, 'testing.commands.full') === 'string' ? get(resolved, 'testing.commands.full') : null, + + // autopilot.fast_lane.* — per-story quick-dev fast lane (default OFF). + // + // When enabled, a deterministic pre-story gate (fast-lane-gate.js) may + // route LOW-RISK stories through bmad-quick-dev (one-shot) under a FULL + // profile, while substantial stories keep the mandatory 7-step cycle. + // This is a sanctioned, opt-in relaxation of the RED-first rule — see + // AGENTS.md and docs/quick-dev-fast-lane-plan.md. Every knob is inert + // while `enabled` is false, so full profiles behave exactly as before. + // + // enabled — master switch (installer prompts; default false) + // max_ac — stories with more Acceptance Criteria never + // fast-lane (a size proxy) + // allow_globs — a story only infers `fast` when EVERY path it + // declares is allow-listed here + // deny_globs — any declared path matching these forces `full` + // even against an explicit fast tag (hard safety) + // require_story_tag — when true, only stories explicitly tagged + // `fast_lane: true` / `risk: low` fast-lane + fast_lane_enabled: coerceBool(get(resolved, 'autopilot.fast_lane.enabled'), false), + fast_lane_max_ac: coerceInt(get(resolved, 'autopilot.fast_lane.max_ac'), 3), + fast_lane_allow_globs: coerceGlobList(get(resolved, 'autopilot.fast_lane.allow_globs')), + fast_lane_deny_globs: coerceGlobList(get(resolved, 'autopilot.fast_lane.deny_globs')), + fast_lane_require_story_tag: coerceBool( + get(resolved, 'autopilot.fast_lane.require_story_tag'), + false, + ), }; } -// Session-scoped mid-sprint escalation. Called when a nano `bmad-quick-dev` -// returns failure indicators. Returns a NEW Profile object — never mutates. -// Returns the original profile unchanged when escalation conditions are not met -// or the profile is not nano. +// Session-scoped mid-sprint escalation. Called when a `bmad-quick-dev` +// success signal carries failure indicators (failing tests or a high-severity +// Classify verdict). Returns a NEW Profile object — never mutates. Returns the +// original profile unchanged when escalation conditions are not met. +// +// Two escalation origins share this trigger: +// - nano profile → the whole session escalates to `fallback_target` +// (full flow) so REMAINING stories run the 7-step +// cycle (AGENTS.md nano safety net). +// - fast_lane_active → a single story that was fast-laned under a FULL +// profile bounces to `full` flow. The profile name +// is preserved (it's already a full profile); the +// caller re-runs THAT story through the 7-step cycle +// and records it in `fast_lane_forced_full` so the +// pre-story gate keeps it full on re-derivation. function escalateOnFailure(profile, signalOutput) { - if (!profile || profile.name !== 'nano') return profile; + if (!profile) return profile; if (!signalOutput || typeof signalOutput !== 'object') return profile; + const isNano = profile.name === 'nano'; + const isFastLane = profile.fast_lane_active === true; + if (!isNano && !isFastLane) return profile; + const testsFailed = typeof signalOutput.tests_failed === 'number' && signalOutput.tests_failed > 0; const highSeverity = signalOutput.severity === 'high'; - const shouldEscalate = - (testsFailed && profile.fallback_on_tests_fail) || - (highSeverity && profile.fallback_on_quick_dev_high_severity); + // The fast lane's safety net is core to the design (not an optional knob), + // so a fast-laned story always escalates on these triggers. Nano keeps its + // documented, per-knob gating. + const shouldEscalate = isFastLane + ? testsFailed || highSeverity + : (testsFailed && profile.fallback_on_tests_fail) || + (highSeverity && profile.fallback_on_quick_dev_high_severity); if (!shouldEscalate) return profile; + const escalationReason = testsFailed ? 'tests_failed' : 'high_severity'; + + if (isFastLane) { + // Keep the profile's own name + budgets; just drop back to the full + // flow for this story and clear the fast-lane marker so we don't loop. + return { + ...profile, + implementation_flow: 'full', + fast_lane_active: false, + escalated_from: 'fast_lane', + escalation_reason: escalationReason, + }; + } + const targetName = profile.fallback_target || 'small'; const targetDefaults = ORCHESTRATOR_DEFAULTS_BY_PROFILE[targetName] || ORCHESTRATOR_DEFAULTS_BY_PROFILE.small; @@ -426,7 +501,7 @@ function escalateOnFailure(profile, signalOutput) { fallback_on_tests_fail: false, fallback_on_quick_dev_high_severity: false, escalated_from: 'nano', - escalation_reason: testsFailed ? 'tests_failed' : 'high_severity', + escalation_reason: escalationReason, }; } diff --git a/_Sprintpilot/lib/orchestrator/report.js b/_Sprintpilot/lib/orchestrator/report.js index e81a57f..ae21abe 100644 --- a/_Sprintpilot/lib/orchestrator/report.js +++ b/_Sprintpilot/lib/orchestrator/report.js @@ -67,6 +67,46 @@ function blockers(entries) { return lines.join('\n'); } +// fastLaneSummary(entries) → a metrics block for the quick-dev fast lane, or +// '' when the lane never fired this sprint (the ledger is append-only and +// sprint-lifetime, not reset per session). Counts routing decisions +// (fast_lane_decision) and how many fast-laned stories bounced back to the +// full cycle (profile_escalated from='fast_lane'), so the value/cost of the +// fast lane is visible at a glance. +function fastLaneSummary(entries) { + const decisions = entries.filter((e) => e.kind === 'fast_lane_decision'); + if (decisions.length === 0) return ''; + // A story counts as "fast-laned" if it was EVER routed fast (it ran + // quick-dev), independent of a later forced-full flip. "Kept full" means it + // was never fast-laned. This avoids mislabeling an escalated story — which + // did run quick-dev — as if it stayed on the full cycle the whole time. + const everFast = new Set(); + const seen = new Set(); + for (const e of decisions) { + const k = e.story_key || '(unknown)'; + seen.add(k); + if (e.decision === 'fast') everFast.add(k); + } + const keptFull = [...seen].filter((k) => !everFast.has(k)).length; + const escalatedKeys = new Set( + entries + .filter((e) => e.kind === 'profile_escalated' && e.from === 'fast_lane') + .map((e) => e.story_key || '(unknown)'), + ); + const lines = [ + '', + '## Fast lane', + '', + `- Stories fast-laned (ran quick-dev one-shot): ${everFast.size}`, + `- Stories kept on the full cycle: ${keptFull}`, + `- Fast-laned stories escalated back to full: ${escalatedKeys.size}`, + ]; + if (escalatedKeys.size > 0) { + lines.push(` - ${Array.from(escalatedKeys).join(', ')}`); + } + return lines.join('\n'); +} + function nextActionHint(state, profile) { const phase = state.current_bmad_step; if (state.sprint_is_complete && phase !== STATES.SPRINT_FINALIZE_PENDING) { @@ -83,6 +123,7 @@ function render(state, entries, profile) { return [ header(state || {}), ledgerSummary(safeEntries), + fastLaneSummary(safeEntries), recentActions(safeEntries), recentDecisions(safeEntries), blockers(safeEntries), @@ -92,4 +133,13 @@ function render(state, entries, profile) { .join('\n'); } -module.exports = { render, header, ledgerSummary, recentActions, recentDecisions, blockers, nextActionHint }; +module.exports = { + render, + header, + ledgerSummary, + fastLaneSummary, + recentActions, + recentDecisions, + blockers, + nextActionHint, +}; diff --git a/_Sprintpilot/lib/orchestrator/state-machine.js b/_Sprintpilot/lib/orchestrator/state-machine.js index 823dbbc..95d6334 100644 --- a/_Sprintpilot/lib/orchestrator/state-machine.js +++ b/_Sprintpilot/lib/orchestrator/state-machine.js @@ -87,7 +87,11 @@ const TERMINAL_STATES = new Set([STATES.SPRINT_FINALIZE_PENDING]); // enforced in `nextStateAfterSuccess`. const FULL_FLOW_SUCCESSORS = { [STATES.PREPARE_STORY_BRANCH]: [STATES.CREATE_STORY], - [STATES.CREATE_STORY]: [STATES.CHECK_READINESS], + // NANO_QUICK_DEV is a valid successor when the per-story fast lane is active: + // a fast-laned story runs create-story FIRST (so the gate has a real story + // file), then routes to quick-dev instead of the 7-step cycle. + [STATES.CREATE_STORY]: [STATES.CHECK_READINESS, STATES.NANO_QUICK_DEV], // NANO_QUICK_DEV only when fast_lane_active + [STATES.NANO_QUICK_DEV]: [STATES.STORY_DONE], [STATES.CHECK_READINESS]: [STATES.DEV_RED], [STATES.DEV_RED]: [STATES.DEV_GREEN], [STATES.DEV_GREEN]: [STATES.CODE_REVIEW], @@ -152,7 +156,7 @@ function buildTemplateSlots(state, profile, extra = {}) { patch_findings: state.patch_findings || null, tests_to_rerun: state.tests_to_rerun || null, profile_name: profile.name, - profile_specific_notes: state.escalation_note || profileNotes(profile), + profile_specific_notes: joinNotes(state.escalation_note, profileNotes(profile)), // Filled by autopilot.js#decorateTestScope for test-running phases. test_scope: null, recommended_test_command: null, @@ -194,6 +198,16 @@ function profileNotes(profile) { return null; } +// joinNotes(a, b) → combine two note strings without letting one suppress the +// other. Used so a transient escalation_note (e.g. the fast-lane re-run +// notice) is surfaced ALONGSIDE the normal profile guidance rather than +// overriding it. Drops empties; returns null when both are empty. +function joinNotes(a, b) { + const parts = [a, b].filter((n) => typeof n === 'string' && n.trim().length > 0); + if (parts.length === 0) return null; + return parts.join('\n\n'); +} + // Compute elapsed minutes between two ISO timestamps. Returns null on // any parse failure so callers can treat "unknown" the same as "no budget." function elapsedMinutesSince(startedAtIso, nowIso) { @@ -490,7 +504,12 @@ function nextStateAfterSuccess(currentState, profile, signal) { const output = (signal && signal.output) || {}; // First: hint tiebreaker. If the LLM provided a structurally-valid hint, prefer it. - const successors = (profile.implementation_flow === 'quick' ? NANO_FLOW_SUCCESSORS : FULL_FLOW_SUCCESSORS)[phase] || []; + // Only nano (whole-profile quick) uses the compressed NANO successor table. + // A per-story fast-laned story (fast_lane_active) travels through FULL-flow + // phases (create_story → nano_quick_dev), so it validates against + // FULL_FLOW_SUCCESSORS (which lists NANO_QUICK_DEV as a create_story edge). + const useNanoTable = profile.implementation_flow === 'quick' && !profile.fast_lane_active; + const successors = (useNanoTable ? NANO_FLOW_SUCCESSORS : FULL_FLOW_SUCCESSORS)[phase] || []; const hint = signal && signal.next_skill_hint; // We only consult the hint when the deterministic decision below has more // than one valid successor. Compute the deterministic answer first. @@ -508,13 +527,36 @@ function deterministicNext(state, profile, output) { switch (phase) { case STATES.PREPARE_STORY_BRANCH: { // Branch is on disk → enter the actual story work (flow-dependent). - const next = profile.implementation_flow === 'quick' - ? STATES.NANO_QUICK_DEV - : STATES.CREATE_STORY; + // nano (whole-profile quick) goes straight to quick-dev, skipping + // create-story per AGENTS.md. The per-story FAST LANE (fast_lane_active + // under a full profile) instead runs create-story FIRST — the pre-story + // gate needs a real story file to enforce deny-globs / max_ac / tags — + // and only THEN routes to quick-dev (see the CREATE_STORY case). + const next = + profile.implementation_flow === 'quick' && !profile.fast_lane_active + ? STATES.NANO_QUICK_DEV + : STATES.CREATE_STORY; return { chosen: next, allValid: [next] }; } - case STATES.CREATE_STORY: - return { chosen: STATES.CHECK_READINESS, allValid: [STATES.CHECK_READINESS] }; + case STATES.CREATE_STORY: { + // Fast lane: with the story file now written, a story the gate routes + // `fast` (profile flipped to quick + fast_lane_active) goes to quick-dev + // one-shot instead of the 7-step cycle. Everything else runs readiness. + // + // allValid is flow-scoped: NANO_QUICK_DEV is a structurally-valid + // successor ONLY when fast-laned. Otherwise CHECK_READINESS is the sole + // valid successor, so the LLM's next_skill_hint tiebreaker cannot push a + // NON-fast-laned story (incl. a deny-glob'd or sticky-forced-full one) + // into quick-dev via a stray `bmad-quick-dev` hint. + const fastLane = profile.implementation_flow === 'quick' && profile.fast_lane_active; + const chosen = fastLane ? STATES.NANO_QUICK_DEV : STATES.CHECK_READINESS; + return { + chosen, + allValid: fastLane + ? [STATES.NANO_QUICK_DEV, STATES.CHECK_READINESS] + : [STATES.CHECK_READINESS], + }; + } case STATES.CHECK_READINESS: return { chosen: STATES.DEV_RED, allValid: [STATES.DEV_RED] }; case STATES.DEV_RED: @@ -655,7 +697,11 @@ function nextStoryStart(profile) { !profile.reuse_user_branch && (profile.granularity === 'story' || profile.granularity === 'epic'); if (needsBranchPrep) return STATES.PREPARE_STORY_BRANCH; - return profile.implementation_flow === 'quick' ? STATES.NANO_QUICK_DEV : STATES.CREATE_STORY; + // nano skips create-story; the per-story fast lane runs it first (see the + // PREPARE_STORY_BRANCH / CREATE_STORY cases). + return profile.implementation_flow === 'quick' && !profile.fast_lane_active + ? STATES.NANO_QUICK_DEV + : STATES.CREATE_STORY; } // Best-effort mapping from a next_skill_hint string (e.g. "bmad-code-review") diff --git a/_Sprintpilot/lib/orchestrator/state-store.js b/_Sprintpilot/lib/orchestrator/state-store.js index 2508db2..2ed540f 100644 --- a/_Sprintpilot/lib/orchestrator/state-store.js +++ b/_Sprintpilot/lib/orchestrator/state-store.js @@ -39,6 +39,12 @@ const CRITICAL_KEYS = new Set([ // the loop-hint enriched prompt, defeating the loop-detection UX. 'last_verify_issues_signature', 'consecutive_identical_rejections', + // Fast-lane escalation ledger (story keys bounced from the quick-dev + // fast lane back to the full 7-step cycle). Must write through so a + // crash between the escalation and the story boundary doesn't let the + // pre-story gate re-fast-lane a story that already failed the fast path. + // Array value → replaced wholesale by deepMerge (never partially merged). + 'fast_lane_forced_full', ]); // In-memory pending buffer. Process-scoped — flushed at story boundary or diff --git a/_Sprintpilot/lib/orchestrator/user-command-applier.js b/_Sprintpilot/lib/orchestrator/user-command-applier.js index 6fd8700..05d1a27 100644 --- a/_Sprintpilot/lib/orchestrator/user-command-applier.js +++ b/_Sprintpilot/lib/orchestrator/user-command-applier.js @@ -253,6 +253,18 @@ function applyOne(state, profile, cmd) { }); break; + case 'set_fast_lane': + // Persist a fast|full mark (or clear to `auto`) for a story/epic into the + // durable fast-lane-overrides store. The CLI edge (applySideEffects) does + // the write; the applier stays pure. + effects.push({ + kind: 'set_fast_lane', + story_key: cmd.story_key !== undefined ? cmd.story_key : null, + epic: cmd.epic !== undefined ? cmd.epic : null, + decision: cmd.decision, + }); + break; + case 'replan_sprint': // Set replan_requested in state so the next cmdStart picks it up // and emits the invoke_skill action. Halt now so the autopilot diff --git a/_Sprintpilot/lib/orchestrator/user-commands.js b/_Sprintpilot/lib/orchestrator/user-commands.js index 3a91421..d10df99 100644 --- a/_Sprintpilot/lib/orchestrator/user-commands.js +++ b/_Sprintpilot/lib/orchestrator/user-commands.js @@ -71,8 +71,12 @@ const COMMAND_KINDS = [ 'add_to_sprint', 'remove_from_sprint', 'replan_sprint', + // Fast lane — mark a story/epic fast|full (or `auto` to clear the mark). + 'set_fast_lane', ]; +const FAST_LANE_DECISIONS = ['fast', 'full', 'auto']; + const STORY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/; const EPIC_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; const DECISION_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; @@ -110,6 +114,22 @@ function validateOne(cmd) { errors.push('skip_story.reason must be string when present'); break; } + case 'set_fast_lane': { + // Exactly one target: story_key OR epic. decision ∈ fast|full|auto. + const hasStory = 'story_key' in cmd && cmd.story_key !== undefined; + const hasEpic = 'epic' in cmd && cmd.epic !== undefined; + if (hasStory === hasEpic) { + errors.push('set_fast_lane requires exactly one of story_key or epic'); + } else if (hasStory) { + if (!nonEmptyString(cmd.story_key) || !STORY_KEY_RE.test(cmd.story_key)) + errors.push('set_fast_lane.story_key must match [A-Za-z0-9._-]{1,64}'); + } else if (!nonEmptyString(cmd.epic) || !EPIC_ID_RE.test(cmd.epic)) { + errors.push('set_fast_lane.epic must match [A-Za-z0-9._-]{1,32}'); + } + if (!nonEmptyString(cmd.decision) || !FAST_LANE_DECISIONS.includes(cmd.decision)) + errors.push(`set_fast_lane.decision must be one of ${FAST_LANE_DECISIONS.join(', ')}`); + break; + } case 'abort_sprint': case 'force_continue': case 'pause': diff --git a/_Sprintpilot/modules/autopilot/config.yaml b/_Sprintpilot/modules/autopilot/config.yaml index 64c21f7..87132f8 100644 --- a/_Sprintpilot/modules/autopilot/config.yaml +++ b/_Sprintpilot/modules/autopilot/config.yaml @@ -88,3 +88,26 @@ autopilot: # The halts are deliberately user_prompts (recoverable) so the LLM can # be re-prompted with extra context; the next session continues from # the same phase. Leave the key unset to inherit profile defaults. + + # Quick-dev fast lane — per-story routing of LOW-RISK stories through + # bmad-quick-dev (one-shot) under a FULL profile, while substantial + # stories keep the mandatory 7-step BMad cycle. + # + # DEFAULT OFF. When disabled, small/medium/large/legacy profiles behave + # exactly as before — no story is ever fast-laned. This is a sanctioned, + # opt-in relaxation of the RED-first rule (see AGENTS.md); the installer + # asks whether to enable it. A deterministic pre-story gate decides + # fast|full and defaults to `full` on any uncertainty; a fast-laned story + # that fails its tests or hits a high-severity finding bounces back to the + # full cycle and is remembered for the rest of the sprint. + # + # Only `enabled` is prompted at install; the remaining knobs inherit the + # profile defaults in profiles/_base.yaml. Uncomment to override. NOTE: + # glob lists are comma-separated STRINGS (the portable representation for + # Sprintpilot's narrow YAML reader), e.g. "docs/**,**/*.md". + fast_lane: + enabled: false + # max_ac: 3 # more Acceptance Criteria than this → never fast-lane + # allow_globs: "docs/**,**/*.md" # infer fast only when every declared path is allow-listed + # deny_globs: "**/auth/**,**/migrations/**,**/*secret*,**/*secret*/**" # any match forces full (hard safety) + # require_story_tag: false # true = only stories tagged `fast_lane: true` / `risk: low` diff --git a/_Sprintpilot/modules/autopilot/profiles/_base.yaml b/_Sprintpilot/modules/autopilot/profiles/_base.yaml index 83dec02..1dec4e6 100644 --- a/_Sprintpilot/modules/autopilot/profiles/_base.yaml +++ b/_Sprintpilot/modules/autopilot/profiles/_base.yaml @@ -37,6 +37,30 @@ autopilot: # to false is a one-shot opt-out for net-new projects only. auto_plan_on_start: false + # Quick-dev fast lane — per-story routing of LOW-RISK stories through + # bmad-quick-dev (one-shot) under a FULL profile, while substantial + # stories keep the mandatory 7-step BMad cycle. DEFAULT OFF: when + # disabled, full profiles behave exactly as before. This is a sanctioned, + # opt-in relaxation of the RED-first rule (see AGENTS.md); the installer + # prompts whether to enable it. A pre-story gate (fast-lane-gate.js) + # decides fast|full deterministically and defaults to `full` on any + # uncertainty; a fast-laned story that fails tests or hits high severity + # bounces back to the full cycle and is remembered for the rest of the + # sprint. + # NOTE: glob lists are comma-separated STRINGS, not YAML sequences — + # resolve-profile.js ships a narrow YAML parser (no array support) into the + # user's project, so `allow_globs: "a,b"` is the portable representation. + # flatToProfile splits them into arrays. + fast_lane: + enabled: false # master switch (installer prompts) + max_ac: 3 # more Acceptance Criteria than this → never fast-lane + # A story only *infers* fast when every path it declares is allow-listed. + allow_globs: "docs/**,**/*.md" + # Any declared path matching a deny glob forces full — beats an explicit + # fast tag (hard safety for auth / migrations / secrets / infra). + deny_globs: "**/auth/**,**/migrations/**,**/*secret*,**/*secret*/**" + require_story_tag: false # true = only stories tagged `fast_lane: true` / `risk: low` fast-lane + git: granularity: story # story | epic worktree: diff --git a/_Sprintpilot/scripts/state-shard.js b/_Sprintpilot/scripts/state-shard.js index 9ca45f4..388ff3a 100644 --- a/_Sprintpilot/scripts/state-shard.js +++ b/_Sprintpilot/scripts/state-shard.js @@ -57,6 +57,9 @@ const CRITICAL_KEYS = new Set([ // _Sprintpilot/lib/orchestrator/state-store.js for full rationale. 'last_verify_issues_signature', 'consecutive_identical_rejections', + // Fast-lane escalation ledger — mirror of state-store.js. Story keys + // bounced from the quick-dev fast lane back to the full 7-step cycle. + 'fast_lane_forced_full', ]); // Prototype-pollution guard. State keys are machine-generated field names; diff --git a/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md b/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md index c22cae6..5691267 100644 --- a/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +++ b/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md @@ -41,7 +41,7 @@ orchestrator emits it. | `action.type` | What you do | |-------------------|--------------------------------------------------------------------------------------------------| -| `invoke_skill` | Run the named BMad skill **verbatim from its own body** (e.g. `bmad-create-story`, `bmad-quick-dev`, `bmad-code-review`). `action.template_slots` is a parameter bag (story_key, prior_diagnosis, relevant_decisions, prior_signals_summary, …) — it's input context for BMad's skill, NOT a replacement for the skill's instructions. When `implementation_flow=quick`, you'll receive `invoke_skill: bmad-quick-dev` per story — follow BMad's `step-oneshot.md`. | +| `invoke_skill` | Run the named BMad skill **verbatim from its own body** (e.g. `bmad-create-story`, `bmad-quick-dev`, `bmad-code-review`). `action.template_slots` is a parameter bag (story_key, prior_diagnosis, relevant_decisions, prior_signals_summary, …) — it's input context for BMad's skill, NOT a replacement for the skill's instructions. When `implementation_flow=quick`, you'll receive `invoke_skill: bmad-quick-dev` per story — follow BMad's `step-oneshot.md`. This occurs under `nano` (whole-profile) **and** under a full profile when the opt-in **fast lane** routes an individual low-risk story to quick-dev (see "Fast lane" below) — either way, treat the action identically. | | `run_script` | Execute `action.command` via the host's shell-equivalent. Argv-only — no shell interpolation. | | `git_op` | Execute `action.steps` in order. The orchestrator pre-plans every git op (commit_and_push_story, merge_epic, push, fetch, create_branch) via `git-plan.js` and inlines the resulting argv sequence — each step has `args: [cmd, ...argv]`, a `description`, and optional metadata fields (see below). **Required**: run each step via `_Sprintpilot/scripts/run-step.js` (see "Step metadata" below) so the metadata contract is enforced uniformly. Argv-only — NO shell interpolation. Halt on first non-retryable failure. Never improvise the git commands or skip a step — `git push` lives in `steps`, not in `op`. Empty `steps: []` (e.g. when `git.enabled: false`) means "no work, signal success." | | `parallel_batch` | Dispatch each child action concurrently (M6+ hosts only — fall back to sequential otherwise). | @@ -93,7 +93,7 @@ Wrap everything in `{ "status": "...", ... }` and pass to | `failure` | `reason`, `diagnosis` (first-class — fed back into next retry), `recoverable: boolean` | | `blocked` | `blocker_kind` (one of the 5 TRUE BLOCKERS or recoverable kinds), `details`, `user_input_needed`, `consecutive_count?` | | `propose_alternative` | `reason`, `alternative` (full Action object), `urgency_hint?` (raises impact only). Low impact → auto-accepted; medium / high → orchestrator stores the alternative in `state.pending_alternative` and emits `user_prompt`. The user accepts via `user_input` `{ kind: 'accept_alternative' }` or rejects via `force_continue` (both clear `pending_alternative`). | -| `user_input` | `commands: UserCommand[]` (validated server-side; see user-commands.js). Kinds: `skip_story`, `abort_sprint`, `force_continue`, `override_decision`, `change_profile`, `pause` (cleanly halts THIS session; next `/sprint-autopilot-on` resumes), `accept_alternative` (dispatches the stored `pending_alternative`), `trigger_retrospective` (force-routes to RETROSPECTIVE for the current epic regardless of `remaining_stories_in_epic`; use when the user explicitly says "close out epic N with retro" while non-terminal stories remain). **NEVER send `pause` on your own initiative** — see "Pause is human-only" below. | +| `user_input` | `commands: UserCommand[]` (validated server-side; see user-commands.js). Kinds: `skip_story`, `abort_sprint`, `force_continue`, `override_decision`, `change_profile`, `pause` (cleanly halts THIS session; next `/sprint-autopilot-on` resumes), `accept_alternative` (dispatches the stored `pending_alternative`), `trigger_retrospective` (force-routes to RETROSPECTIVE for the current epic regardless of `remaining_stories_in_epic`; use when the user explicitly says "close out epic N with retro" while non-terminal stories remain), `set_fast_lane` (`{ story_key? \| epic, decision: 'fast'\|'full'\|'auto' }` — the user's explicit fast/full mark for a story or epic; `auto` clears it. Map plain-language marks to this: "fast-lane story 4-1" → `{story_key:'4-1',decision:'fast'}`; "keep 4-2 full" / "don't fast-lane 4-2" → `{story_key:'4-2',decision:'full'}`; "fast-lane epic 5" → `{epic:'epic-5',decision:'fast'}`; "reset 4-1 to auto" → `{story_key:'4-1',decision:'auto'}`. The mark is durable and the highest-authority routing signal — see "Fast lane" above). **NEVER send `pause` on your own initiative** — see "Pause is human-only" below. | | `verify_override` | `evidence: { decision_log_ref?, explanation, expected_paths? }` — used when verify.js is wrong | ## Visibility — show the user a live task list @@ -262,6 +262,33 @@ expectations. After N consecutive verify rejections on the same state (profile- configured budget), the orchestrator escalates to `user_prompt`. +## Fast lane (opt-in, default OFF) + +When `autopilot.fast_lane.enabled` is true, a deterministic pre-story gate may +route an **individual low-risk story** through `bmad-quick-dev` (one-shot) +under a full profile, while every substantial story keeps the mandatory +7-step cycle. You don't decide this — the orchestrator does, and simply emits +`invoke_skill: bmad-quick-dev` for a fast-laned story exactly as it would +under `nano`. Nothing changes in how you dispatch actions. + +Two things to know: + +- **Escalation is automatic.** If a fast-laned quick-dev run fails, reports + failing tests, or flags a high-severity finding, the orchestrator bounces + that story back to the full cycle (re-emitting `bmad-create-story`, then the + 7 steps) and never fast-lanes it again. You just follow the emitted actions. +- **Read `template_slots.profile_specific_notes` on a bounced story.** It + carries a `⚠ FAST-LANE ESCALATION` note explaining that quick-dev already + committed a *known-deficient* implementation: in DEV_RED, write tests that + encode the ACs and the observed failure (they are expected to fail against + the current code), then DEV_GREEN fixes. This is a rigor pass over existing + code — do not delete the work, harden it. The note is surfaced every phase + of the re-run and clears at the next story. + +Auditing: each routing choice is logged as a `fast_lane_decision` ledger +entry, and `autopilot progress` / the session report show fast-laned and +escalated counts. + ## Git workflow knobs These knobs in `_Sprintpilot/modules/git/config.yaml` change what the diff --git a/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md b/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md index 226e14a..008f2c0 100644 --- a/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +++ b/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md @@ -457,6 +457,39 @@ also override individual entries via `[a:KEY]` (re-include something the scheduling default excluded) or `[r:KEY]` (exclude something the default included). +### Step 11c — Fast-lane marking (conditional) + +ONLY when the quick-dev fast lane is enabled — check with: +``` +node ./_Sprintpilot/scripts/resolve-profile.js get autopilot.fast_lane.enabled +``` +If it prints `false` (or the key is absent), SKIP this sub-step entirely. + +When enabled, offer a per-story fast|full pass over the INCLUDED stories. +The fast lane routes genuinely low-risk stories (docs, tiny config, small +pure functions) through one-shot `bmad-quick-dev` while substantial +stories keep the full 7-step cycle. A conservative gate already decides +this per story at run time; this pass lets the user PRE-mark stories they +already know about, so they don't have to intervene mid-sprint. + +> "Fast lane is ON. Want to pre-mark any of the 14 included stories +> `fast` (one-shot quick-dev) or `full` (7-step cycle)? The gate decides +> the rest automatically — this is only for stories you already know. +> +> e.g. 'fast 3-1-docs, 3-2-readme' · 'full 4-2-migration' +> 'fast epic 3' · [Enter] skip — let the gate decide." + +Persist each mark with the CLI (durable + clobber-resistant — it survives +future re-plans): +``` +node ./_Sprintpilot/bin/autopilot.js fast-lane fast # or full / auto +node ./_Sprintpilot/bin/autopilot.js fast-lane epic- fast +``` +A `fast` mark is the highest-authority signal (it overrides the gate's +deny-globs / size budget); reserve it for stories you're confident are +low-risk. `full` guarantees the rigorous cycle. Echo back what you set: +> "Marked fast: 3-1-docs, 3-2-readme, epic-3. Marked full: 4-2-migration." + --- ## Step 12 — Validate Selection diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 79c33ae..7c7e171 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,7 +103,7 @@ The orchestrator is deliberately split so that all *decisions* are pure and test │ (state, signal) │ (newState, action, sideEffects) ▼ │ ┌─────────────────────────────────────────────────────────────────┐ - │ PURE CORE — _Sprintpilot/lib/orchestrator/ (22 modules) │ + │ PURE CORE — _Sprintpilot/lib/orchestrator/ (24 modules) │ │ │ │ state-machine.js phase enum + transition tables + nextAction │ │ adapt.js interpretSignal / advanceState (signal → state)│ @@ -166,6 +166,17 @@ PREPARE_STORY_BRANCH → NANO_QUICK_DEV → STORY_DONE → STORY_LAND? → EPIC_ Quality gates are preserved inside quick-dev's own review step. If quick-dev's tests fail or its severity classification is `high`, the session **escalates** to the full flow (§8.4). +### Fast-lane flow (full profiles, `autopilot.fast_lane.enabled: true`) + +Opt-in (default OFF). Under a *full* profile, a deterministic pre-story gate (`lib/orchestrator/fast-lane-gate.js`, consulted by `deriveEffectiveProfile` in `bin/autopilot.js`) may route an individual LOW-RISK story through quick-dev while substantial stories keep the 7-step cycle. Unlike nano, a fast-laned story runs `bmad-create-story` FIRST — the gate needs a real story file (acceptance criteria + declared paths) to enforce its guardrails, and that file doesn't exist until create-story writes it: + +``` +PREPARE_STORY_BRANCH → CREATE_STORY → NANO_QUICK_DEV → STORY_DONE → … + ↑ gate decides fast|full here (with the real file) +``` + +The effective profile is flipped to `implementation_flow = quick` + `fast_lane_active = true` per story (distinguishing it from nano); the decision is locked at `NANO_QUICK_DEV` so quick-dev's post-implementation file edits can't re-flip it. See §8.4 for the escalation net and `docs/quick-dev-fast-lane-plan.md` for the gate/guardrails. + ### Skill mapping per phase | Phase | BMad skill | Template slot | @@ -347,9 +358,12 @@ A single `complexity_profile` reshapes the whole flow. It lives in `_Sprintpilot | large | 3 | 3 | dev_red 30m, dev_green 60m, review/patch 30m | | legacy | 2 | 3 | disabled (preserves v1.0.5) | -### 9.4 Nano → full escalation (`escalateOnFailure`) +### 9.4 Quick-dev → full escalation (`escalateOnFailure`) + +Two origins share this trigger, both firing at `NANO_QUICK_DEV`: -Only `nano` escalates. When a `NANO_QUICK_DEV` success reports `tests_failed > 0` (and `fallback_on_tests_fail`) or `severity = high` (and `fallback_on_quick_dev_high_severity`), the profile is replaced in-memory with the `fallback_target` (default `small`), switched to `implementation_flow = full`, and budgets upgraded. **Escalation is session-scoped — never written back to config.** The next session starts fresh as `nano`. A `profile_escalated` side effect records the reason. +- **`nano`** (whole-profile): when a `NANO_QUICK_DEV` success reports `tests_failed > 0` (and `fallback_on_tests_fail`) or `severity = high` (and `fallback_on_quick_dev_high_severity`), the profile is replaced in-memory with the `fallback_target` (default `small`), switched to `implementation_flow = full`, and budgets upgraded, so the session's REMAINING stories run the full cycle. **Session-scoped — never written back to config.** A `profile_escalated` side effect records the reason. +- **Fast lane** (`fast_lane_active`, per story): a single fast-laned story that flags a problem bounces to the full cycle for THAT story and is recorded in `fast_lane_forced_full` (a durable, write-through marker) so the gate never re-fast-lanes it. A **hard failure** (`status: failure`, story not `done`) re-runs the full 7-step cycle from `CREATE_STORY`; a **success-but-flagged** story (already marked `done`) routes to `CODE_REVIEW` — the adversarial review the fast lane skipped — because `composeRuntimeState` would skip-reject a `done` story at `CREATE_STORY` but not at `CODE_REVIEW`. An `escalation_note` (surfaced as `profile_specific_notes`) reframes the re-entry for the skill. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 24024f3..b03e1be 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -28,6 +28,79 @@ Profile resolution happens at boot via `_Sprintpilot/scripts/resolve-profile.js` | `autopilot.retrospective_mode` | `auto` | `auto` (deterministic artifact, continue) / `stop` (pause for `/bmad-retrospective`) / `skip` (no artifact). | | `autopilot.implementation_flow` | `full` (nano: `quick`) | `full` runs the 7-step BMad cycle (create-story → check-readiness → dev-RED → dev-GREEN → code-review → patch → retrospective). `quick` routes every story through `bmad-quick-dev` and boots fresh sessions directly at `NANO_QUICK_DEV`. | +### Quick-Dev Fast Lane + +Opt-in (**default OFF**) per-story routing of LOW-RISK stories through `bmad-quick-dev` (one-shot) under a *full* profile, while substantial stories keep the mandatory 7-step cycle. This is a sanctioned relaxation of the RED-first rule (same class as `nano`), not a silent skip — the installer prompts whether to enable it and for `max_ac`. See [quick-dev-fast-lane-plan.md](quick-dev-fast-lane-plan.md) for the full design. + +| Key | Default | Description | +|-----|---------|-------------| +| `autopilot.fast_lane.enabled` | `false` | Master switch. When off, `small`/`medium`/`large`/`legacy` behave exactly as before — no story is ever fast-laned. | +| `autopilot.fast_lane.max_ac` | `3` | Stories with more Acceptance Criteria than this never fast-lane (size gate; beats an explicit fast tag). | +| `autopilot.fast_lane.allow_globs` | `"docs/**,**/*.md"` | Comma-separated globs. A story only *infers* `fast` when **every** path it declares is allow-listed. | +| `autopilot.fast_lane.deny_globs` | `"**/auth/**,**/migrations/**,**/*secret*,**/*secret*/**"` | Comma-separated globs. Any declared path matching forces `full` — hard safety, beats a fast tag. | +| `autopilot.fast_lane.require_story_tag` | `false` | When `true`, only stories that tag *themselves* `fast_lane: true` / `risk: low` fast-lane (an epic-level tag no longer suffices). | + +A fast-lane candidate runs `bmad-create-story` FIRST (so the gate reads real acceptance criteria / declared paths), then routes `CREATE_STORY → NANO_QUICK_DEV` when `fast`, else into the 7-step cycle. `nano` still skips create-story. The gate is conservative — **any uncertainty → `full`**. + +#### Setting fast/full per story or epic + +Three ways, from most to least authoritative. Use whichever fits — you can mix them. + +| Method | How | Scope | Persists in | Survives a re-plan? | +|---|---|---|---|---| +| **1. Explicit mark** *(highest authority)* | Chat, CLI, or the planner (below) | Story **or** epic | `fast-lane-overrides.json` (Sprintpilot-owned) | **Yes** | +| **2. Story-file tag** | A line in the story `.md` | Story | The BMad story file | Yes (the story file isn't regenerated) | +| **3. Epic/plan tag** | A field on the epic/story entry in `sprint-plan.yaml` | Epic (cascades) or story | `sprint-plan.yaml` | **No** — a `/sprintpilot-plan-sprint` re-run may regenerate it | + +**1 — Explicit mark (recommended for one-offs).** You don't edit files; the mark is durable and clobber-resistant. + +```bash +# CLI (also runnable as `! ...` from the IDE chat) +autopilot fast-lane 4-1-docs fast # this story → one-shot quick-dev +autopilot fast-lane 4-2-migration full # this story → full 7-step cycle +autopilot fast-lane epic-3 fast # every story in epic 3 (unless the story says otherwise) +autopilot fast-lane 4-1-docs auto # clear the mark → back to the automatic gate +``` +In chat, just say it — the orchestrator maps plain language to the `set_fast_lane` command: *"fast-lane story 4-1"*, *"keep 4-2 full"*, *"don't fast-lane 4-2"*, *"fast-lane epic 5"*, *"reset 4-1 to auto"*. During `/sprintpilot-plan-sprint`, **Step 11c** offers a fast|full pass over the sprint's stories. A **story mark wins over its epic mark**. A `fast` mark applies **even when `fast_lane.enabled` is `false`** (mark one story fast without flipping the whole switch). + +**2 — Story-file tag** (put it anywhere in `_bmad-output/implementation-artifacts/.md`): + +```markdown +fast_lane: true # this story → fast +fast_lane: false # this story → full +risk: low # → fast +risk: high # → full (also: critical; `medium` = no opinion) +``` + +**3 — Epic/plan tag** (add a `fast_lane` and/or `risk` field to an entry in `sprint-plan.yaml`): + +```yaml +epics: + - id: "3" + fast_lane: true # cascades to every story in epic 3 +stories: + - key: 3-4-migration + epic: "3" + risk: high # overrides the epic for this one story +``` + +#### Precedence (first match wins) + +1. **`fast_lane_forced_full`** — a story the escalation net bounced *after it actually failed the fast path* → `full` (always; prevents a fast→fail→fast loop). +2. **Explicit mark** — a `full` mark → `full`; a `fast` mark → `fast`, overriding deny-globs, `max_ac`, and every tag (you're the human). Story mark beats epic mark. `require_story_tag` does not apply to a mark. +3. **The automatic gate** (only for stories with no mark, and only when `enabled: true`): + 1. explicit **full-forcing** tag (story-file or epic) → `full` + 2. **`deny_globs`** match → `full` (beats a `fast` tag — hard safety) + 3. more ACs than **`max_ac`** → `full` (beats a `fast` tag) + 4. explicit **`fast`** tag (story-file beats epic) → `fast` + 5. `require_story_tag: true` and no story-file `fast` tag → `full` + 6. **inference** — every declared path ∈ `allow_globs` → `fast` + 7. default → `full` + +**Guardrails.** Tests are still required (`verifyNanoQuickDev` enforces `tests_run > 0`, a commit SHA, and sprint-status `done`). If a fast-laned quick-dev **fails**, the autopilot re-runs the full 7-step cycle from `bmad-create-story`; if it **completes but reports failing tests / a high-severity finding**, it routes the story through the adversarial `bmad-code-review` it skipped. Glob lists are comma-separated strings (the portable representation for Sprintpilot's narrow YAML reader), e.g. `"docs/**,**/*.md"`. + +**Auditing.** Every routing choice is a `fast_lane_decision` ledger entry (a mark logs `reasons: ['user_override_fast'|'user_override_full']`); setting a mark logs `fast_lane_override_set`. `autopilot progress` and the session report show fast-laned / kept-full / escalated counts. + ### Per-phase wall-clock budgets (v2.4.0) | Key | Default | Description | diff --git a/docs/USAGE.md b/docs/USAGE.md index e2cff35..940dcf0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -39,6 +39,29 @@ The per-story flow depends on the active `complexity_profile` in `_Sprintpilot/m The autopilot resolves the profile at boot via `_Sprintpilot/scripts/resolve-profile.js`. Missing key falls back to `medium` with a stderr notice. +### Quick-Dev Fast Lane (opt-in, default OFF) + +Independently of the profile, you can let the full profiles fast-lane *individual* low-risk stories through one-shot `bmad-quick-dev` (instead of the 7-step cycle) while substantial stories keep the full cycle. It is **off by default**; the installer asks whether to enable it (and for the `max_ac` budget), or set it directly: + +```yaml +# _Sprintpilot/modules/autopilot/config.yaml +autopilot: + fast_lane: + enabled: true # off by default + max_ac: 3 # stories with more Acceptance Criteria never fast-lane + # allow_globs / deny_globs / require_story_tag — see docs/CONFIGURATION.md +``` + +A conservative pre-story gate decides `fast | full` from each story's real acceptance criteria and declared paths (create-story runs first), defaulting to `full` on any doubt and hard-denying auth / migrations / secrets. Tests stay required; a fast-laned story that fails re-runs the full cycle, and one that completes but flags a problem is routed through the adversarial code review it skipped. + +**Mark a story or epic fast/full** without editing files — the mark is the highest-authority signal and persists in a clobber-resistant store (survives re-planning): + +- In chat: *"fast-lane story 4-1"*, *"keep 4-2 full"*, *"fast-lane epic 5"*, *"reset 4-1 to auto"*. +- CLI: `autopilot fast-lane > `. +- During `/sprintpilot-plan-sprint` (a fast|full pass over the stories). + +A `fast` mark wins over the gate (deny-globs, size budget, tags) and works even when the lane is globally off; only a story that already *failed* the fast path (`fast_lane_forced_full`) resists a `fast` mark. Every choice is logged (`fast_lane_decision`) and the counts show in `autopilot progress`. Full reference: [CONFIGURATION.md](CONFIGURATION.md#quick-dev-fast-lane) and [quick-dev-fast-lane-plan.md](quick-dev-fast-lane-plan.md). + ### Parallel Story Dispatch When `ma.parallel_stories: true` AND the host supports concurrent subagents (Claude Code today; Gemini CLI experimentally) AND the active layer of the inferred DAG has ≥2 independent stories, step 3 of the autopilot: diff --git a/docs/quick-dev-fast-lane-plan.md b/docs/quick-dev-fast-lane-plan.md index 3da99db..49df574 100644 --- a/docs/quick-dev-fast-lane-plan.md +++ b/docs/quick-dev-fast-lane-plan.md @@ -1,6 +1,6 @@ # Design Plan — Quick-Dev Fast Lane (per-story, under full profiles) -**Status:** APPROVED 2026-07-10 — build tracked as a follow-up. Decisions recorded under "Open decisions" below. +**Status:** IMPLEMENTED 2026-07-10 (default OFF). Shipped as `fast-lane-gate.js` + profile/state wiring + installer prompt; see AGENTS.md "Fast lane" section. Decisions recorded under "Decisions" below. **Goal:** cut tokens/time on real `small`/`medium`/`large` sprints by routing **low-risk stories** through `bmad-quick-dev` (one-shot) while keeping the full 7-step cycle for substantial stories. ## Why (and why it isn't already covered) @@ -15,9 +15,9 @@ Today quick-dev is **all-or-nothing at the profile level**: `nano` runs every st Reuse the machinery that already exists; add one new pre-story gate. -1. **Routing (reuse).** The state machine already has the quick path (`NANO_QUICK_DEV` → `bmad-quick-dev`) selected by `implementation_flow === 'quick'` (`state-machine.js:512`). The fast lane makes that a **per-story** decision under a full profile instead of a whole-profile setting: for a story the gate marks `fast`, route it through `NANO_QUICK_DEV`; otherwise the normal 7-step successors. No new skill, no new BMad contract. +1. **Routing (reuse, per-story).** The fast lane makes the quick path (`NANO_QUICK_DEV` → `bmad-quick-dev`) a **per-story** decision under a full profile instead of a whole-profile setting. **Crucially, a fast-laned story runs `bmad-create-story` FIRST, then routes to quick-dev** — `PREPARE_STORY_BRANCH → CREATE_STORY → NANO_QUICK_DEV` — not straight to quick-dev like nano. This is not cosmetic: the pre-story gate's guardrails (deny-globs, `max_ac`, story-file tags, path inference) can only read the story's Acceptance Criteria / declared paths from the story `.md`, **which does not exist until `bmad-create-story` writes it** (sprint-status carries only statuses, and quick-dev reads AC from there). So the routing decision that actually enforces the safety gates is taken at the `CREATE_STORY → successor` transition, with the real file on disk. nano (whole-profile quick, `fast_lane_active` false) still skips create-story per AGENTS.md; the two are distinguished by the `fast_lane_active` flag on the effective profile. No new skill, no new BMad contract — just conditional `PREPARE_STORY_BRANCH` / `CREATE_STORY` successors keyed on `fast_lane_active`. -2. **Pre-story risk gate (new — the only substantive addition).** A conservative, deterministic classifier that decides `fast | full` **before** implementation, from cheap signals in the story file + plan: +2. **Pre-story risk gate (new — the only substantive addition).** A conservative, deterministic classifier that decides `fast | full` from cheap signals in the story file + plan (evaluated once the story file exists, at the `CREATE_STORY → successor` transition; at earlier phases with no file it defaults `full`, which is why fast-lane candidates route through create-story first): - AC count, task/subtask count, story-size hint; - path allow/deny globs (e.g. allow `docs/**`, `**/*.md`, config; deny `**/auth/**`, `**/migrations/**`, security-tagged epics); - explicit per-story/epic tags (`risk: low` / `fast_lane: true` in the plan); @@ -48,13 +48,35 @@ Reuse the machinery that already exists; add one new pre-story gate. - `AGENTS.md` — document the sanctioned exception (as nano is documented). ## Risks & mitigations -- **Misclassification ships unreviewed code** → conservative default-`full`, deny-globs, tests-required gate, and the escalation net (failure re-runs full cycle). +- **Misclassification ships unreviewed code** → conservative default-`full`, deny-globs, tests-required gate, and the escalation net (any quick-dev failure OR success-with-failing-tests/high-severity re-runs the full cycle). - **Policy drift** → default OFF; opt-in per project; documented as a first-class sanctioned mode, not a silent skip. - **Classifier scope creep** → keep it deterministic + cheap (no LLM call); an optional LLM urgency hint can only *downgrade* to full, never upgrade to fast. +- **Inference under-triggers (known limitation)** → allow-glob *inference* only fires when a story positively declares the files it touches (inline `code-span` paths or a "File List / Modified Files / Source Tree / …" section). BMad story specs don't always list paths pre-implementation, so in practice most fast-laning comes from explicit story tags (`fast_lane: true` / `risk: low`). This is intentional (safe under-triggering beats unsafe over-triggering), but means the token savings scale with how well stories are tagged / list their files. -## Verification (when built) -- Unit: `fast-lane-gate.js` truth table (AC count, globs, tags, uncertainty→full); state-machine routes `fast` story to `NANO_QUICK_DEV` and `full` story to the 7-step successors; escalation re-runs full on fast-lane failure. -- E2e (opt-in): a mixed sprint where a `docs/**` story fast-lanes and a security story stays full. +## Configuring fast/full per story or epic + +Three ways to set the routing for a specific story or epic (full examples + precedence table in [CONFIGURATION.md → Quick-Dev Fast Lane](CONFIGURATION.md#quick-dev-fast-lane)): + +1. **Explicit mark** (highest authority, durable) — `autopilot fast-lane > `, the plain-language `set_fast_lane` chat command, or `/sprintpilot-plan-sprint` Step 11c. Persists in the Sprintpilot-owned, clobber-resistant `fast-lane-overrides.json` (module: `fast-lane-overrides.js`; mirrors `excluded-stories.js`), which **survives a plan re-derivation** unlike tags placed in `sprint-plan.yaml`. A `fast` mark beats the gate's deny-globs / `max_ac` / tags and applies even when the lane is globally off; a story mark beats its epic mark; only `fast_lane_forced_full` (a story the escalation net bounced after it actually failed) still overrides a `fast` mark, preventing a fast→fail→fast loop. +2. **Story-file tag** — `fast_lane: true|false` / `risk: low|high` in the story `.md`. Subject to the gate's deny-globs + `max_ac` (a `fast` tag can't wave in a large or auth-touching story — unlike a mark). +3. **Epic/plan tag** — a `fast_lane` / `risk` field on the epic (cascades) or story entry in `sprint-plan.yaml`. Same gate guardrails; may be regenerated by a re-plan. + +Precedence: `fast_lane_forced_full` → **explicit mark** → (gate) full-tag → deny-glob → `max_ac` → fast-tag (story-file beats epic) → `require_story_tag` → inference → default `full`. + +## Implementation notes (as built) +- Routing is per-story via `deriveEffectiveProfile` (`autopilot.js`) flipping `implementation_flow → quick` + `fast_lane_active` for a `fast` story (flip gated to story-start phases so it can't happen mid-full-cycle). The state machine routes a `fast_lane_active` story `PREPARE_STORY_BRANCH → CREATE_STORY → NANO_QUICK_DEV` (create-story first — see Design §1), whereas nano goes straight to `NANO_QUICK_DEV`. `FULL_FLOW_SUCCESSORS[CREATE_STORY]` lists `NANO_QUICK_DEV` as the fast-lane edge. +- The escalation net covers **both** failure shapes, but re-enters at different phases because they leave the story in different states: + - **Hard failure** (`status: failure`, `adapt.handleFailure`): quick-dev failed, the story is **not** `done`, so re-run the full 7-step cycle from `CREATE_STORY` over the committed-but-deficient code. + - **Success-but-flagged** (`status: success` with failing tests / high severity, `adapt.handleSuccess` via `escalateOnFailure`): quick-dev completed and marked the story `done` (verifyNanoQuickDev requires it), so re-enter at `CODE_REVIEW` — the adversarial review the fast lane skipped. Routing to `CREATE_STORY` here would be a no-op: `composeRuntimeState` rejects+skips a `done` story at `CREATE_STORY` (that phase isn't in its done-rejection skip-set), whereas `CODE_REVIEW` **is**, so the story survives re-resolution and actually gets reviewed. + - Both record the story in `fast_lane_forced_full` so the gate keeps it full on re-derivation. +- **Not-done-with-failing-tests halts (doesn't escalate).** The success-but-flagged escalation only fires after `verifyNanoQuickDev` passes, which requires the story marked `done`. A quick-dev signal that claims success with `tests_failed > 0` but has NOT marked the story `done` fails verify → retries → exhausts `verify_reject_budget` → a generic `user_prompt` halt for a human. That's safe (it never ships), but the tailored fast-lane escalation messaging doesn't fire for that specific shape — the story stops for human review instead. +- **Decision lock at `NANO_QUICK_DEV`.** `deriveEffectiveProfile` does NOT re-read the story file once the story is at `NANO_QUICK_DEV`: quick-dev appends a "File List" of the code it wrote (paths outside `allow_globs`), and re-classifying would flip the gate to `full` and clear `fast_lane_active` at the exact moment the escalation guards need it. A full profile can only reach `NANO_QUICK_DEV` via the fast lane, so the decision is locked to quick there (forced-full still wins). +- The bounce re-enters at `CREATE_STORY` even though quick-dev already committed code. To keep `DEV_RED` coherent (it would otherwise read as greenfield "tests-first" against existing code), the escalation sets `state.escalation_note` — surfaced as `profile_specific_notes` in **every** phase's skill template — reframing the re-run as a *rigor pass over known-deficient committed code*: DEV_RED tests encode the ACs + the observed failure and are expected to fail against the current implementation, then DEV_GREEN fixes. The note is story-scoped and cleared at the next new-story boundary. +- A fast-laned `nano_quick_dev` phase is wall-clock budgeted under full profiles (`PHASE_TIMEOUT_DEFAULTS_BY_PROFILE`: small 20 / medium 30 / large 60 min). + +## Verification (built) +- Unit: `fast-lane-gate.js` truth table (AC count, globs, tags, uncertainty→full); `escalateOnFailure` fast-lane origin; `adapt` routing (success-flagged AND hard-failure both re-run full + record forced-full; clean success advances to STORY_DONE without marking the sprint complete); `flatToProfile` fast_lane config threading; installer `applyFastLaneEnabled` + patch/read round-trip. +- Integration: `deriveEffectiveProfile` against a tmp project — a `docs/**` story routes quick, a security story stays full, forced-full is sticky, and the `fast_lane_decision` ledger entry is emitted (deduped per story). ## Decisions (signed off 2026-07-10) 1. **RED waiver — APPROVED.** Gate-approved low-risk stories may run quick-dev one-shot (RED-first waived) provided tests still exist and the escalation net re-runs the full cycle on failure. Fast lane is default-OFF, opt-in. diff --git a/lib/commands/install.js b/lib/commands/install.js index 4f25045..060a687 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -874,6 +874,11 @@ async function readExistingAutopilotConfig(projectRoot, v1Snapshot) { // notice on upgrade — we never write it back. autoPlanOnStart: null, autoInferDependencies: null, + // Fast-lane master switch (autopilot.fast_lane.enabled). null = not set + // in user config → inherit the bundled default (false). + fastLaneEnabled: null, + // Fast-lane AC-count budget. null = inherit the bundled default (3). + fastLaneMaxAc: null, }; let raw = null; @@ -951,6 +956,28 @@ async function readExistingAutopilotConfig(projectRoot, v1Snapshot) { if (inferMatch) { out.autoInferDependencies = inferMatch[1] === 'true'; } + // `fast_lane:` block → `enabled: true|false`. Scoped to WITHIN the block + // (header, then any indented lines — comments OR other knobs the user may + // have reordered above it — then the active `enabled:` line). Without the + // tolerance, a hand-reordered block would read null and a `--yes` reinstall + // would silently flip a user's `enabled: true` back to false. + const fastLaneMatch = raw.match( + /^[ \t]*fast_lane:[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+enabled:[ \t]*(true|false)/m, + ); + if (fastLaneMatch) { + out.fastLaneEnabled = fastLaneMatch[1] === 'true'; + } + // `fast_lane.max_ac` — an active (non-commented) line, scoped to WITHIN the + // fast_lane block (after its header, past any comment/enabled lines) so a + // stray `max_ac:` elsewhere can't be misread. Null when only the commented + // default is present. + const maxAcMatch = raw.match( + /^[ \t]*fast_lane:[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+max_ac:[ \t]*(\d+)[ \t]*(?:#.*)?$/m, + ); + if (maxAcMatch) { + const n = Number.parseInt(maxAcMatch[1], 10); + if (Number.isFinite(n) && n >= 0) out.fastLaneMaxAc = n; + } return out; } @@ -1004,9 +1031,67 @@ function applyScalar(source, key, value) { return `${trimmed} ${key}: ${value}\n`; } +// Rewrite the `enabled:` line inside the nested `fast_lane:` block. Kept +// separate from applyScalar because applyScalar targets top-level (2-space) +// scalars under `autopilot:`, whereas fast_lane.enabled is nested one level +// deeper. Three shapes: +// 1. block present with `enabled:` line → in-place replace +// 2. block present without an `enabled:` line → insert one under the header +// 3. block absent → append a fast_lane block +function applyFastLaneEnabled(source, enabled) { + const literal = enabled ? 'true' : 'false'; + // (1) block header immediately followed (allowing comment lines) by an + // enabled: line — replace the value in place. + const enabledRe = + /^([ \t]*fast_lane:[ \t]*(?:#.*)?\n(?:[ \t]*#.*\n)*[ \t]*enabled:[ \t]*)(?:true|false)([ \t]*(?:#.*)?)$/m; + if (enabledRe.test(source)) { + return source.replace(enabledRe, (_m, head, tail) => `${head}${literal}${tail || ''}`); + } + // (2) block header present but no enabled: line — insert one right after. + const headerRe = /^([ \t]*)fast_lane:[ \t]*(?:#.*)?$/m; + const headerMatch = source.match(headerRe); + if (headerMatch) { + const indent = headerMatch[1]; + return source.replace(headerRe, (m) => `${m}\n${indent} enabled: ${literal}`); + } + // (3) no fast_lane block — append one under the autopilot: block. + if (!/^autopilot:\s*$/m.test(source)) return source; + const trimmed = source.endsWith('\n') ? source : `${source}\n`; + return `${trimmed} fast_lane:\n enabled: ${literal}\n`; +} + +// Write `max_ac` inside the nested `fast_lane:` block. Replaces an existing +// active `max_ac:` line, else inserts one right after the block's `enabled:` +// line (which always exists once the block is written). No-op on a bad value +// or when the block is absent. +function applyFastLaneMaxAc(source, value) { + const n = Number.parseInt(String(value), 10); + if (!Number.isFinite(n) || n < 0) return source; + // Replace an existing active `max_ac:` — scoped to the fast_lane block (like + // the insert path below) so a stray indented `max_ac:` elsewhere can't be + // rewritten. The prefix (header + intervening lines) is preserved verbatim. + const scopedActiveRe = + /^([ \t]*fast_lane:[^\n]*\n(?:[ \t]+[^\n]*\n)*?)([ \t]+)max_ac:[ \t]*\d+([ \t]*(?:#.*)?)$/m; + if (scopedActiveRe.test(source)) { + return source.replace( + scopedActiveRe, + (_m, prefix, indent, tail) => `${prefix}${indent}max_ac: ${n}${tail || ''}`, + ); + } + // Insert after the fast_lane block's `enabled:` line — scoped to the block + // (header, any indented lines, then enabled) so a future indented `enabled:` + // key above fast_lane can't misdirect the insertion. + const scopedEnabledRe = + /^([ \t]*fast_lane:[^\n]*\n(?:[ \t]+[^\n]*\n)*?)([ \t]+)enabled:[ \t]*(?:true|false)[^\n]*\n/m; + if (scopedEnabledRe.test(source)) { + return source.replace(scopedEnabledRe, (m, _prefix, indent) => `${m}${indent}max_ac: ${n}\n`); + } + return source; +} + async function patchAutopilotConfig( projectRoot, - { sessionStoryLimit, retrospectiveMode, autoPlanOnStart }, + { sessionStoryLimit, retrospectiveMode, autoPlanOnStart, fastLaneEnabled, fastLaneMaxAc }, ) { const file = path.join( projectRoot, @@ -1025,6 +1110,14 @@ async function patchAutopilotConfig( if (autoPlanOnStart !== undefined && autoPlanOnStart !== null) { updated = applyScalar(updated, 'auto_plan_on_start', autoPlanOnStart ? 'true' : 'false'); } + // Fast lane — nested keys, own writers. + if (fastLaneEnabled !== undefined && fastLaneEnabled !== null) { + updated = applyFastLaneEnabled(updated, fastLaneEnabled === true); + // Only persist max_ac when the lane is ON (writing it while OFF is noise). + if (fastLaneEnabled === true && fastLaneMaxAc !== undefined && fastLaneMaxAc !== null) { + updated = applyFastLaneMaxAc(updated, fastLaneMaxAc); + } + } if (updated !== original) { await writeAtomic(file, updated); } @@ -1211,16 +1304,20 @@ async function resolveAutopilotSettings({ projectRoot, yes, dryRun, v1Snapshot } const defaultMode = existing.retrospectiveMode ?? DEFAULT_RETROSPECTIVE_MODE; // v2.3.0 — opt-in default false; preserve existing user choice on upgrade. const defaultAutoPlan = existing.autoPlanOnStart ?? false; + // Fast lane — opt-in default false; preserve existing user choice on upgrade. + const defaultFastLane = existing.fastLaneEnabled ?? false; + const defaultFastLaneMaxAc = existing.fastLaneMaxAc ?? 3; if (yes) { if ( existing.sessionStoryLimit != null || existing.retrospectiveMode != null || - existing.autoPlanOnStart != null + existing.autoPlanOnStart != null || + existing.fastLaneEnabled != null ) { console.log( pc.dim( - `Preserving autopilot config: session_story_limit=${defaultLimit}, retrospective_mode=${defaultMode}, auto_plan_on_start=${defaultAutoPlan}`, + `Preserving autopilot config: session_story_limit=${defaultLimit}, retrospective_mode=${defaultMode}, auto_plan_on_start=${defaultAutoPlan}, fast_lane=${defaultFastLane}`, ), ); } @@ -1228,19 +1325,23 @@ async function resolveAutopilotSettings({ projectRoot, yes, dryRun, v1Snapshot } sessionStoryLimit: defaultLimit, retrospectiveMode: defaultMode, autoPlanOnStart: defaultAutoPlan, + fastLaneEnabled: defaultFastLane, + fastLaneMaxAc: defaultFastLaneMaxAc, }; } if (dryRun) { console.log( pc.dim( - `[DRY RUN] Would prompt for autopilot config (current: session_story_limit=${defaultLimit}, retrospective_mode=${defaultMode}, auto_plan_on_start=${defaultAutoPlan})`, + `[DRY RUN] Would prompt for autopilot config (current: session_story_limit=${defaultLimit}, retrospective_mode=${defaultMode}, auto_plan_on_start=${defaultAutoPlan}, fast_lane=${defaultFastLane})`, ), ); return { sessionStoryLimit: defaultLimit, retrospectiveMode: defaultMode, autoPlanOnStart: defaultAutoPlan, + fastLaneEnabled: defaultFastLane, + fastLaneMaxAc: defaultFastLaneMaxAc, }; } @@ -1290,7 +1391,43 @@ async function resolveAutopilotSettings({ projectRoot, yes, dryRun, v1Snapshot } initialValue: defaultAutoPlan, }); - return { sessionStoryLimit, retrospectiveMode, autoPlanOnStart }; + // Fast lane — an opt-in relaxation of the RED-first rule for LOW-RISK + // stories only. Default OFF. When on, a conservative pre-story gate routes + // trivial stories (docs, small config, tiny pure functions) through + // bmad-quick-dev one-shot to save tokens/time, while substantial stories + // keep the full 7-step cycle. Tests are still required and a misclassified + // story falls back to the full cycle, so the downside is bounded. + const fastLaneEnabled = await prompts.confirm({ + message: + 'Enable the quick-dev FAST LANE? (Routes only LOW-RISK stories through one-shot quick-dev under full profiles — saves tokens/time. A conservative gate defaults to the full cycle on any doubt, tests stay required, and a failed fast-lane story re-runs the full cycle. Default OFF.)', + initialValue: defaultFastLane, + }); + + // Only tune the size gate when the lane is actually on. The other knobs + // (allow/deny globs, require_story_tag) stay config-file edits under + // autopilot.fast_lane.* — documented in Sprintpilot.md. + let fastLaneMaxAc = defaultFastLaneMaxAc; + if (fastLaneEnabled) { + const maxAcRaw = await prompts.text({ + message: + 'Fast lane: max Acceptance Criteria for a story to qualify (more ACs → always full cycle)', + initialValue: String(defaultFastLaneMaxAc), + validate(value) { + if (value == null || value === '') return undefined; + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || String(n) !== String(value).trim() || n < 0) { + return 'Enter a non-negative integer'; + } + return undefined; + }, + }); + fastLaneMaxAc = + maxAcRaw == null || String(maxAcRaw).trim() === '' + ? defaultFastLaneMaxAc + : Number.parseInt(String(maxAcRaw).trim(), 10); + } + + return { sessionStoryLimit, retrospectiveMode, autoPlanOnStart, fastLaneEnabled, fastLaneMaxAc }; } async function runInteractiveToolPicker(detected) { @@ -1428,12 +1565,13 @@ async function runInstall(options = {}) { // runtime copy — they're NOT threaded through `renderString`, because // workflow.md's `{{session_story_limit}}` / `{{retrospective_mode}}` // variable references would collide with single-brace token matching. - const { sessionStoryLimit, retrospectiveMode, autoPlanOnStart } = await resolveAutopilotSettings({ - projectRoot, - yes, - dryRun, - v1Snapshot: v1ConfigSnapshot, - }); + const { sessionStoryLimit, retrospectiveMode, autoPlanOnStart, fastLaneEnabled, fastLaneMaxAc } = + await resolveAutopilotSettings({ + projectRoot, + yes, + dryRun, + v1Snapshot: v1ConfigSnapshot, + }); const ctx = buildContext({ outputFolder }); // 3. Detect + select tools @@ -1774,6 +1912,8 @@ async function runInstall(options = {}) { sessionStoryLimit, retrospectiveMode, autoPlanOnStart, + fastLaneEnabled, + fastLaneMaxAc, }); // 6c. Persist the complexity_profile. Separate from patchAutopilotConfig @@ -1941,6 +2081,9 @@ async function runInstall(options = {}) { console.log( ` ${apKey('autopilot.auto_plan_on_start')}${apVal(String(autoPlanOnStart))} Auto-build sprint plan on first start (v2.3.0; default off)`, ); + console.log( + ` ${apKey('autopilot.fast_lane.enabled')}${apVal(String(fastLaneEnabled))} Route low-risk stories through one-shot quick-dev under full profiles (default off)`, + ); console.log(''); console.log('Sprint planning + progress (v2.3.0):'); console.log(' /sprintpilot-plan-sprint Build dependency-aware sprint plan'); @@ -1986,6 +2129,8 @@ module.exports = { readExistingAutopilotConfig, patchAutopilotConfig, applyScalar, + applyFastLaneEnabled, + applyFastLaneMaxAc, readExistingComplexityProfile, patchComplexityProfile, resolveComplexityProfile, diff --git a/tests/e2e/fast-lane.test.ts b/tests/e2e/fast-lane.test.ts new file mode 100644 index 0000000..d3a7282 --- /dev/null +++ b/tests/e2e/fast-lane.test.ts @@ -0,0 +1,210 @@ +/** + * E2E Fast-Lane Test: per-story quick-dev under a FULL profile. + * + * Verifies the opt-in fast lane end to end: + * - complexity_profile=medium (a FULL profile) with autopilot.fast_lane.enabled=true. + * - The pre-story gate emits a `fast_lane_decision` ledger entry per story. + * - A low-risk story (docs) is routed through bmad-quick-dev; a substantial + * story keeps the 7-step cycle (bmad-dev-story runs). + * - The run completes with commits, proving the mixed routing works. + * + * This is the deterministic-where-possible complement to the pure unit + + * integration coverage (fast-lane-gate / fast-lane-escalation / + * fast-lane-derive-profile). It drives a real autopilot session, so it is + * gated on RUN_LLM_E2E=1 + a usable Claude CLI, like the other e2e suites. + * + * Run: RUN_LLM_E2E=1 ANTHROPIC_API_KEY=... npx vitest run tests/e2e/fast-lane.test.ts + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { assertDirectoryExists, assertFileContains } from './harness/assertions.js'; +import { runClaude } from './harness/claude-runner.js'; +import { costTracker } from './harness/cost-tracker.js'; +import { createTempProject, placeFixture, type TempProject } from './harness/temp-project.js'; + +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures/greenfield'); +const ADDON_SOURCE = join(import.meta.dirname, '../../_Sprintpilot'); + +const MAX_SESSIONS = 3; +const BUDGET_PER_SESSION = 12; +const TIMEOUT_PER_SESSION = 1_800_000; // 30 min +const MODEL = process.env.BMAD_TEST_MODEL ?? 'sonnet'; +const REMOTE_URL = process.env.BMAD_TEST_REMOTE_URL ?? ''; +const HAS_CLAUDE = (() => { + try { + execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return !!process.env.ANTHROPIC_API_KEY; + } +})(); +const RUN_LLM_E2E = process.env.RUN_LLM_E2E === '1'; + +let project: TempProject; + +function gitSafe(args: string[], dir: string): string { + try { + return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', timeout: 30_000 }).trim(); + } catch { + return ''; + } +} + +// Read every `fast_lane_decision` / `profile_escalated` ledger entry. +function readLedger(dir: string): Array> { + const p = join(dir, '_bmad-output/implementation-artifacts/ledger.jsonl'); + if (!existsSync(p)) return []; + return readFileSync(p, 'utf-8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l) as Record; + } catch { + return null; + } + }) + .filter((e): e is Record => e !== null); +} + +// Set complexity_profile=medium AND enable the fast lane, verifying both stuck. +function configureFastLane(dir: string) { + const cfg = join(dir, '_Sprintpilot/modules/autopilot/config.yaml'); + let body = existsSync(cfg) ? readFileSync(cfg, 'utf-8') : 'autopilot:\n'; + if (/^[ \t]*complexity_profile:/m.test(body)) { + body = body.replace( + /^([ \t]*)complexity_profile:[ \t]*[^\n]+$/m, + '$1complexity_profile: medium', + ); + } else { + body = body.replace(/^autopilot:/m, 'autopilot:\n complexity_profile: medium'); + } + // Add / replace the fast_lane block with enabled: true + generous docs allow. + if (/^[ \t]*fast_lane:/m.test(body)) { + body = body.replace( + /^([ \t]*fast_lane:[ \t]*(?:#.*)?\n(?:[ \t]*#.*\n)*[ \t]*enabled:[ \t]*)(?:true|false)/m, + '$1true', + ); + } else { + body = body.replace( + /^autopilot:/m, + 'autopilot:\n fast_lane:\n enabled: true\n allow_globs: "docs/**,**/*.md,**/README*"', + ); + } + writeFileSync(cfg, body); + const verify = readFileSync(cfg, 'utf-8'); + if (!/^[ \t]*complexity_profile:[ \t]*medium\b/m.test(verify)) { + throw new Error(`[Fast-lane setup] complexity_profile: medium did not stick.\n${verify}`); + } + if (!/^[ \t]*enabled:[ \t]*true\b/m.test(verify)) { + throw new Error(`[Fast-lane setup] fast_lane.enabled: true did not stick.\n${verify}`); + } +} + +describe.skipIf(!HAS_CLAUDE || !RUN_LLM_E2E)('Fast lane (medium profile, Claude Code)', () => { + beforeAll(() => { + project = createTempProject({ + remoteUrl: REMOTE_URL, + installBmadCore: true, + installAddon: true, + platform: 'github', + }); + placeFixture( + project.dir, + '_bmad-output/planning-artifacts/prd.md', + readFileSync(join(FIXTURES_DIR, 'prd.md'), 'utf-8'), + ); + configureFastLane(project.dir); + console.log(`[Fast-lane] Temp project: ${project.dir}`); + }); + + afterAll(async () => { + const lockPath = join(project.dir, '.autopilot.lock'); + if (project && existsSync(lockPath)) { + try { + await runClaude('/sprint-autopilot-off', { + cwd: project.dir, + maxBudget: 2, + model: MODEL, + addDirs: [ADDON_SOURCE], + timeout: 60_000, + }); + } catch { + try { + rmSync(lockPath, { force: true }); + } catch { + /* ignore */ + } + } + } + console.log(costTracker.report()); + project?.cleanup(); + }); + + it('setup is valid — medium profile with the fast lane enabled', () => { + assertDirectoryExists(join(project.dir, '_Sprintpilot')); + assertFileContains( + join(project.dir, '_Sprintpilot/modules/autopilot/config.yaml'), + /complexity_profile:\s*medium/, + ); + assertFileContains( + join(project.dir, '_Sprintpilot/modules/autopilot/config.yaml'), + /enabled:\s*true/, + ); + }); + + it( + 'autopilot runs a mixed sprint with per-story fast-lane routing', + async () => { + let session = 0; + let totalCost = 0; + while (session < MAX_SESSIONS) { + session++; + const systemPrompt = [ + 'You are running inside an automated e2e test.', + session === 1 + ? 'Follow the BMAD autopilot workflow exactly. PRD is at _bmad-output/planning-artifacts/prd.md.' + : 'Resume the BMAD autopilot from saved state.', + 'complexity_profile=medium with the quick-dev fast lane enabled. Follow the orchestrator actions verbatim — a low-risk story may be routed to bmad-quick-dev, a substantial one to the full 7-step cycle.', + 'Do NOT ask the user any questions.', + 'Use TypeScript with Vitest for testing.', + ].join(' '); + console.log(`\n[Session ${session}/${MAX_SESSIONS}] Starting autopilot (fast lane)...`); + const result = await runClaude('/sprint-autopilot-on', { + cwd: project.dir, + maxBudget: BUDGET_PER_SESSION, + model: MODEL, + addDirs: [ADDON_SOURCE], + timeout: TIMEOUT_PER_SESSION, + appendSystemPrompt: systemPrompt, + }); + const cost = result.json?.total_cost_usd ?? 0; + totalCost += cost; + costTracker.record('fast-lane', `session-${session}`, cost, result.json?.duration_ms ?? 0); + if (!existsSync(join(project.dir, '.autopilot.lock'))) break; + } + console.log(`[Result] Fast-lane sprint finished, $${totalCost.toFixed(4)}`); + }, + MAX_SESSIONS * (TIMEOUT_PER_SESSION + 120_000), + ); + + it('the fast-lane gate evaluated a routing decision per story', () => { + const decisions = readLedger(project.dir).filter((e) => e.kind === 'fast_lane_decision'); + console.log( + `[Fast-lane] decisions: ${JSON.stringify(decisions.map((d) => [d.story_key, d.decision]))}`, + ); + // With the lane enabled and at least one story run, the gate must have + // emitted at least one routing decision. + expect(decisions.length).toBeGreaterThan(0); + }); + + it('produced commits and left no dangling lock', () => { + const commits = gitSafe(['log', '--all', '--oneline'], project.dir).split('\n').filter(Boolean); + expect(commits.length).toBeGreaterThan(2); // beyond the 2 setup commits + expect(existsSync(join(project.dir, '.autopilot.lock'))).toBe(false); + }); +}); diff --git a/tests/package.json b/tests/package.json index ff6b317..2b2cb30 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,6 +8,7 @@ "test:fast": "vitest run unit/ scripts/", "test": "vitest run unit/ scripts/", "test:e2e:nano": "RUN_LLM_E2E=1 vitest run e2e/nano.test.ts", + "test:e2e:fast-lane": "RUN_LLM_E2E=1 vitest run e2e/fast-lane.test.ts", "test:e2e:live": "RUN_LLM_E2E=1 vitest run e2e/nano.test.ts", "test:e2e:greenfield": "RUN_LLM_E2E=1 RUN_LLM_E2E_FULL=1 vitest run e2e/greenfield.test.ts", "test:e2e:brownfield": "RUN_LLM_E2E=1 RUN_LLM_E2E_FULL=1 vitest run e2e/brownfield.test.ts", diff --git a/tests/scripts/autopilot-cli.test.ts b/tests/scripts/autopilot-cli.test.ts index 2c5e534..2f26404 100644 --- a/tests/scripts/autopilot-cli.test.ts +++ b/tests/scripts/autopilot-cli.test.ts @@ -713,3 +713,36 @@ describe('autopilot start --stories / --epic', () => { expect(state).not.toMatch(/story_queue:.*4-1-foo/); }); }); + +describe('autopilot fast-lane', () => { + const readOverrides = () => + JSON.parse( + readFileSync( + join(projectRoot, '_bmad-output', 'implementation-artifacts', 'fast-lane-overrides.json'), + 'utf8', + ), + ).fast_lane_overrides; + + it('marks a story fast, an epic full, and clears to auto', () => { + let r = runCli(['fast-lane', '4-1-x', 'fast']); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Marked story 4-1-x as fast/); + r = runCli(['fast-lane', 'epic-5', 'full']); + expect(r.stdout).toMatch(/Marked epic 5 as full/); + + let ov = readOverrides(); + expect(ov.stories['4-1-x'].decision).toBe('fast'); + expect(ov.epics['5'].decision).toBe('full'); + + r = runCli(['fast-lane', '4-1-x', 'auto']); + expect(r.stdout).toMatch(/Cleared the fast-lane mark on story 4-1-x/); + ov = readOverrides(); + expect(ov.stories['4-1-x']).toBeUndefined(); + expect(ov.epics['5'].decision).toBe('full'); // epic mark untouched + }); + + it('rejects a bad decision and missing args', () => { + expect(runCli(['fast-lane', '4-1-x', 'bogus']).status).not.toBe(0); + expect(runCli(['fast-lane', '4-1-x']).status).not.toBe(0); + }); +}); diff --git a/tests/unit/autopilot-config.test.ts b/tests/unit/autopilot-config.test.ts index 837ae62..921c19f 100644 --- a/tests/unit/autopilot-config.test.ts +++ b/tests/unit/autopilot-config.test.ts @@ -17,6 +17,8 @@ const { _internals } = installMod as { retrospectiveMode: string | null; autoPlanOnStart: boolean | null; autoInferDependencies: boolean | null; + fastLaneEnabled: boolean | null; + fastLaneMaxAc: number | null; }>; patchAutopilotConfig: ( root: string, @@ -24,9 +26,13 @@ const { _internals } = installMod as { sessionStoryLimit?: number; retrospectiveMode?: string; autoPlanOnStart?: boolean; + fastLaneEnabled?: boolean; + fastLaneMaxAc?: number; }, ) => Promise; applyScalar: (source: string, key: string, value: string | number) => string; + applyFastLaneEnabled: (source: string, enabled: boolean) => string; + applyFastLaneMaxAc: (source: string, value: number) => string; verifySkillManifest: ( projectRoot: string, bundleDir?: string, @@ -47,6 +53,8 @@ const { readExistingAutopilotConfig, patchAutopilotConfig, applyScalar, + applyFastLaneEnabled, + applyFastLaneMaxAc, verifySkillManifest, pruneOrphanSkillsFromToolDir, SPRINTPILOT_SKILL_PREFIXES, @@ -80,6 +88,8 @@ describe('readExistingAutopilotConfig', () => { retrospectiveMode: null, autoPlanOnStart: null, autoInferDependencies: null, + fastLaneEnabled: null, + fastLaneMaxAc: null, }); }); @@ -91,6 +101,8 @@ describe('readExistingAutopilotConfig', () => { retrospectiveMode: 'stop', autoPlanOnStart: null, autoInferDependencies: null, + fastLaneEnabled: null, + fastLaneMaxAc: null, }); }); @@ -104,6 +116,8 @@ describe('readExistingAutopilotConfig', () => { retrospectiveMode: 'skip', autoPlanOnStart: null, autoInferDependencies: null, + fastLaneEnabled: null, + fastLaneMaxAc: null, }); }); @@ -127,6 +141,8 @@ describe('readExistingAutopilotConfig', () => { retrospectiveMode: null, autoPlanOnStart: null, autoInferDependencies: null, + fastLaneEnabled: null, + fastLaneMaxAc: null, }); }); @@ -241,6 +257,135 @@ describe('patchAutopilotConfig', () => { }); }); +// ────────────────────────────────────────────────────────────────── +// Fast lane — autopilot.fast_lane.enabled (nested key) +// ────────────────────────────────────────────────────────────────── + +describe('applyFastLaneEnabled', () => { + it('rewrites the enabled: line inside an existing fast_lane block', () => { + const src = 'autopilot:\n fast_lane:\n enabled: false\n'; + expect(applyFastLaneEnabled(src, true)).toContain(' enabled: true'); + }); + + it('preserves a trailing comment on the enabled line', () => { + const src = 'autopilot:\n fast_lane:\n enabled: false # off by default\n'; + expect(applyFastLaneEnabled(src, true)).toContain(' enabled: true # off by default'); + }); + + it('tolerates comment lines between the header and enabled:', () => { + const src = 'autopilot:\n fast_lane:\n # master switch\n enabled: false\n'; + expect(applyFastLaneEnabled(src, true)).toContain(' enabled: true'); + }); + + it('inserts enabled: under a headerless fast_lane block', () => { + const src = 'autopilot:\n fast_lane:\n'; + expect(applyFastLaneEnabled(src, true)).toContain(' enabled: true'); + }); + + it('appends a fast_lane block when none exists', () => { + const src = 'autopilot:\n session_story_limit: 3\n'; + const out = applyFastLaneEnabled(src, true); + expect(out).toContain(' fast_lane:\n enabled: true'); + }); + + it('bails (returns input) with no autopilot: header', () => { + const src = '# hand-edited\nsome_other: thing\n'; + expect(applyFastLaneEnabled(src, true)).toBe(src); + }); +}); + +describe('fast_lane block robustness (hand-reordered keys)', () => { + it('reads enabled even when max_ac was reordered above it (F1)', async () => { + // A hand-reordered block must not read null → a --yes reinstall must not + // silently flip enabled:true back to false. + writeConfig('autopilot:\n fast_lane:\n max_ac: 4\n enabled: true\n'); + const out = await readExistingAutopilotConfig(root); + expect(out.fastLaneEnabled).toBe(true); + expect(out.fastLaneMaxAc).toBe(4); + }); + + it('applyFastLaneMaxAc inserts under the fast_lane block, not a foreign enabled (F2)', () => { + // A nested `enabled:` under a different block above fast_lane must not + // capture the max_ac insertion. + const src = 'autopilot:\n other:\n enabled: true\n fast_lane:\n enabled: true\n'; + const out = applyFastLaneMaxAc(src, 5); + // max_ac lands after the fast_lane enabled, not the `other` enabled. + expect(out).toBe( + 'autopilot:\n other:\n enabled: true\n fast_lane:\n enabled: true\n max_ac: 5\n', + ); + }); +}); + +describe('applyFastLaneMaxAc', () => { + it('inserts max_ac right after the enabled line when absent', () => { + const src = 'autopilot:\n fast_lane:\n enabled: true\n'; + expect(applyFastLaneMaxAc(src, 5)).toBe( + 'autopilot:\n fast_lane:\n enabled: true\n max_ac: 5\n', + ); + }); + + it('replaces an existing active max_ac line', () => { + const src = 'autopilot:\n fast_lane:\n enabled: true\n max_ac: 3\n'; + expect(applyFastLaneMaxAc(src, 7)).toContain('max_ac: 7'); + }); + + it('ignores commented-out max_ac and inserts an active one', () => { + const src = 'autopilot:\n fast_lane:\n enabled: true\n # max_ac: 3\n'; + const out = applyFastLaneMaxAc(src, 2); + expect(out).toContain(' max_ac: 2'); + expect(out).toContain(' # max_ac: 3'); + }); + + it('no-ops on a negative / non-numeric value', () => { + const src = 'autopilot:\n fast_lane:\n enabled: true\n'; + expect(applyFastLaneMaxAc(src, -1 as unknown as number)).toBe(src); + }); +}); + +describe('fast_lane round-trips through patch + read', () => { + it('patchAutopilotConfig writes enabled and readExistingAutopilotConfig parses it', async () => { + writeConfig('autopilot:\n fast_lane:\n enabled: false\n'); + await patchAutopilotConfig(root, { + sessionStoryLimit: 3, + retrospectiveMode: 'auto', + fastLaneEnabled: true, + }); + const out = await readExistingAutopilotConfig(root); + expect(out.fastLaneEnabled).toBe(true); + expect(readConfig()).toContain(' enabled: true'); + }); + + it('leaves fastLaneEnabled null when the block is absent', async () => { + writeConfig('autopilot:\n session_story_limit: 3\n'); + const out = await readExistingAutopilotConfig(root); + expect(out.fastLaneEnabled).toBeNull(); + }); + + it('writes + reads back max_ac when the lane is enabled', async () => { + writeConfig('autopilot:\n fast_lane:\n enabled: false\n'); + await patchAutopilotConfig(root, { + sessionStoryLimit: 3, + retrospectiveMode: 'auto', + fastLaneEnabled: true, + fastLaneMaxAc: 5, + }); + const out = await readExistingAutopilotConfig(root); + expect(out.fastLaneEnabled).toBe(true); + expect(out.fastLaneMaxAc).toBe(5); + }); + + it('does not write max_ac when the lane is disabled', async () => { + writeConfig('autopilot:\n fast_lane:\n enabled: false\n'); + await patchAutopilotConfig(root, { + sessionStoryLimit: 3, + retrospectiveMode: 'auto', + fastLaneEnabled: false, + fastLaneMaxAc: 5, + }); + expect(readConfig()).not.toContain('max_ac:'); + }); +}); + // ────────────────────────────────────────────────────────────────── // v2.3.0 — auto_plan_on_start + auto_infer_dependencies parsing // ────────────────────────────────────────────────────────────────── diff --git a/tests/unit/orchestrator/fast-lane-derive-profile.test.ts b/tests/unit/orchestrator/fast-lane-derive-profile.test.ts new file mode 100644 index 0000000..ced7b5a --- /dev/null +++ b/tests/unit/orchestrator/fast-lane-derive-profile.test.ts @@ -0,0 +1,478 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// @ts-expect-error — CommonJS module +import autopilot from '../../../_Sprintpilot/bin/autopilot.js'; +// @ts-expect-error — CommonJS module +import ledger from '../../../_Sprintpilot/lib/orchestrator/action-ledger.js'; +// @ts-expect-error — CommonJS module +import fastLaneOverrides from '../../../_Sprintpilot/lib/orchestrator/fast-lane-overrides.js'; +// @ts-expect-error — CommonJS module +import profileRules from '../../../_Sprintpilot/lib/orchestrator/profile-rules.js'; + +const setOverride = ( + fastLaneOverrides as { + setOverride: (r: string, k: string, d: string, o?: { isEpic?: boolean }) => unknown; + } +).setOverride; + +const { deriveEffectiveProfile, buildRichStatus } = autopilot as { + deriveEffectiveProfile: ( + persisted: Record, + profile: Record, + projectRoot: string, + opts?: { emitLedger?: boolean }, + ) => Record; + buildRichStatus: ( + projectRoot: string, + persisted: Record, + opts?: Record, + ) => Record; +}; +const appendLedger = ( + ledger as { + append: (entry: Record, ctx: { projectRoot: string }) => void; + } +).append; +const { flatToProfile } = profileRules as { + flatToProfile: (resolved: unknown, name: string) => Record; +}; +const readLedger = (ledger as { read: (ctx: { projectRoot: string }) => Record[] }) + .read; + +let root: string; + +const ART = ['_bmad-output', 'implementation-artifacts']; + +function artDir() { + const dir = join(root, ...ART); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeStory(key: string, content: string) { + writeFileSync(join(artDir(), `${key}.md`), content, 'utf8'); +} + +function writeSprintStatus(yaml: string) { + writeFileSync(join(artDir(), 'sprint-status.yaml'), yaml, 'utf8'); +} + +function writeSprintPlan(plan: Record) { + writeFileSync(join(artDir(), 'sprint-plan.yaml'), JSON.stringify(plan), 'utf8'); +} + +// A minimal valid plan (validatePlan requires these top-level keys). +function planWith(epics: unknown[], stories: unknown[]) { + return { + schema_version: 1, + status: { last_run_outcome: 'success' }, + epics, + stories, + dependencies: { version: 1, stories: {} }, + cross_epic_deps: [], + overrides: [], + }; +} + +// A medium profile with the fast lane enabled (glob strings mirror the shipped +// config, exercising coerceGlobList inside flatToProfile). +function fastLaneProfile() { + return flatToProfile( + { + autopilot: { + fast_lane: { + enabled: true, + max_ac: 3, + allow_globs: 'docs/**,**/*.md', + deny_globs: '**/auth/**,**/migrations/**', + }, + }, + }, + 'medium', + ); +} + +const DOCS_STORY = + '## Acceptance Criteria\n- update the guide\n\n## File List\n- `docs/guide.md`\n'; +const AUTH_STORY = + '## Acceptance Criteria\n- harden login\n\n## File List\n- `src/auth/login.ts`\n'; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'sp-fastlane-derive-')); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('deriveEffectiveProfile', () => { + it('returns the base profile unchanged when the fast lane is disabled', () => { + writeStory('1-1-docs', DOCS_STORY); + const base = flatToProfile({}, 'medium'); // fast_lane_enabled = false + const eff = deriveEffectiveProfile({ current_story: '1-1-docs' }, base, root); + expect(eff).toBe(base); + }); + + it('routes a low-risk docs story to the quick flow (pinned current_story)', () => { + writeStory('1-1-docs', DOCS_STORY); + const eff = deriveEffectiveProfile({ current_story: '1-1-docs' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('quick'); + expect(eff.fast_lane_active).toBe(true); + }); + + it('keeps a security story on the full cycle (deny glob)', () => { + writeStory('2-1-auth', AUTH_STORY); + const eff = deriveEffectiveProfile({ current_story: '2-1-auth' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('full'); + expect(eff.fast_lane_active).toBeUndefined(); + }); + + it('resolves the next story from sprint-status when none is pinned', () => { + writeStory('1-1-docs', DOCS_STORY); + writeSprintStatus('development_status:\n epic-1: in-progress\n 1-1-docs: backlog\n'); + const eff = deriveEffectiveProfile({}, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('quick'); + }); + + it('never fast-lanes a story recorded in fast_lane_forced_full (sticky escalation)', () => { + writeStory('1-1-docs', DOCS_STORY); + const eff = deriveEffectiveProfile( + { current_story: '1-1-docs', fast_lane_forced_full: ['1-1-docs'] }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('full'); + }); + + it('honors an epic-level fast_lane tag from sprint-plan.yaml (cascades to the story)', () => { + // A neutral story (no story-file tag, no allow-listed paths) would default + // to FULL — but its epic is tagged fast_lane:true in the plan. + writeStory('4-1-neutral', '## Acceptance Criteria\n- do a small thing\n'); + writeSprintPlan( + planWith( + [{ id: '4', fast_lane: true }], + [{ key: '4-1-neutral', epic: '4', plan_status: 'pending' }], + ), + ); + const eff = deriveEffectiveProfile({ current_story: '4-1-neutral' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('quick'); + }); + + it('a story-file tag overrides the epic plan tag (risk:high beats epic fast_lane:true)', () => { + writeStory('4-2-risky', 'risk: high\n## Acceptance Criteria\n- do a thing\n'); + writeSprintPlan( + planWith( + [{ id: '4', fast_lane: true }], + [{ key: '4-2-risky', epic: '4', plan_status: 'pending' }], + ), + ); + const eff = deriveEffectiveProfile({ current_story: '4-2-risky' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('full'); + }); + + it('LOCKS the decision at NANO_QUICK_DEV — quick-dev File-List drift cannot flip it off', () => { + // At NANO_QUICK_DEV, quick-dev has already appended a File List of the code + // it wrote (out-of-allowlist paths). Re-reading it would classify `full` + // and drop fast_lane_active — defeating the escalation guards. A full + // profile only reaches NANO_QUICK_DEV via the fast lane, so the decision is + // locked to quick regardless of the file contents. + writeStory( + '4-1-docs', + '## Acceptance Criteria\n- x\n\n## File List\n- `src/core/engine.ts`\n- `src/db/migrations/001.sql`\n', + ); + const eff = deriveEffectiveProfile( + { current_story: '4-1-docs', current_bmad_step: 'nano_quick_dev' }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('quick'); + expect(eff.fast_lane_active).toBe(true); + }); + + it('forced_full still wins over the NANO_QUICK_DEV lock', () => { + writeStory('4-1-docs', DOCS_STORY); + const eff = deriveEffectiveProfile( + { + current_story: '4-1-docs', + current_bmad_step: 'nano_quick_dev', + fast_lane_forced_full: ['4-1-docs'], + }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('full'); + }); + + it('classifies THIS story, not a stale persisted.story_file_path from a prior story (F3)', () => { + // A docs story on disk, but persisted.story_file_path still points at a + // previous, quick-lane-ineligible story's .md. The gate must read the + // current story's convention path, not the stale one. + writeStory('5-1-docs', DOCS_STORY); + writeStory('4-9-prev', AUTH_STORY); // stale target with a deny path + const eff = deriveEffectiveProfile( + { + current_story: '5-1-docs', + story_file_path: join(artDir(), '4-9-prev.md'), + }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('quick'); // read 5-1-docs, not 4-9-prev + }); + + it('is conservatively full when the story file is missing', () => { + // No story file on disk → gate sees empty text → full. + const eff = deriveEffectiveProfile({ current_story: '9-9-ghost' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('full'); + }); + + it('never throws — falls back to the base profile on a bad projectRoot', () => { + const base = fastLaneProfile(); + const eff = deriveEffectiveProfile({ current_story: '1-1-docs' }, base, '/nonexistent/xyz'); + // Missing story file / no sprint-status → conservative full, base returned. + expect(eff.implementation_flow).toBe('full'); + }); +}); + +describe('deriveEffectiveProfile — user overrides (highest authority)', () => { + const AUTH_BIG = // 5 ACs + an auth path: the gate would force this FULL + '## Acceptance Criteria\n- a\n- b\n- c\n- d\n- e\n\n## File List\n- `src/auth/login.ts`\n'; + + it('a fast override wins over deny-globs AND the size budget', () => { + writeStory('4-1-auth', AUTH_BIG); + setOverride(root, '4-1-auth', 'fast'); + const eff = deriveEffectiveProfile({ current_story: '4-1-auth' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('quick'); + expect(eff.fast_lane_active).toBe(true); + }); + + it('a fast override works even when the lane is globally OFF', () => { + writeStory('4-1-x', '## Acceptance Criteria\n- x\n'); + setOverride(root, '4-1-x', 'fast'); + const base = flatToProfile({}, 'medium'); // fast_lane_enabled = false + const eff = deriveEffectiveProfile({ current_story: '4-1-x' }, base, root); + expect(eff.implementation_flow).toBe('quick'); + }); + + it('a full override forces the full cycle even for a fast-classifiable story', () => { + writeStory('4-1-docs', DOCS_STORY); // gate would say fast + setOverride(root, '4-1-docs', 'full'); + const eff = deriveEffectiveProfile({ current_story: '4-1-docs' }, fastLaneProfile(), root); + expect(eff.implementation_flow).toBe('full'); + }); + + it('forced_full (post-failure escalation) still beats a fast override — no loop', () => { + writeStory('4-1-docs', DOCS_STORY); + setOverride(root, '4-1-docs', 'fast'); + const eff = deriveEffectiveProfile( + { current_story: '4-1-docs', fast_lane_forced_full: ['4-1-docs'] }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('full'); + }); + + it('a story override wins over an epic override', () => { + writeStory('4-1-x', '## Acceptance Criteria\n- x\n'); + setOverride(root, 'epic-4', 'full', { isEpic: true }); + setOverride(root, '4-1-x', 'fast'); + const base = flatToProfile({}, 'medium'); + const eff = deriveEffectiveProfile({ current_story: '4-1-x' }, base, root); + expect(eff.implementation_flow).toBe('quick'); // story fast beats epic full + }); + + it('a full override arriving at NANO_QUICK_DEV does NOT strip fast_lane_active (escalation net preserved)', () => { + // A story already at quick-dev is committed; a mid-flight `full` mark must + // not silently defeat the escalation guards (which need fast_lane_active) — + // it takes effect at the next story-start instead. + writeStory('4-1-docs', DOCS_STORY); + setOverride(root, '4-1-docs', 'full'); + const eff = deriveEffectiveProfile( + { current_story: '4-1-docs', current_bmad_step: 'nano_quick_dev' }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('quick'); + expect(eff.fast_lane_active).toBe(true); + }); + + it('a fast override at a mid-full-cycle phase does NOT flip the profile', () => { + // The story already entered the full cycle; a mark only takes effect at a + // story-start phase, never mid-cycle. + writeStory('4-1-docs', DOCS_STORY); + setOverride(root, '4-1-docs', 'fast'); + const eff = deriveEffectiveProfile( + { current_story: '4-1-docs', current_bmad_step: 'check_readiness' }, + fastLaneProfile(), + root, + ); + expect(eff.implementation_flow).toBe('full'); + expect(eff.fast_lane_active).toBeUndefined(); + }); +}); + +describe('deriveEffectiveProfile — ledger audit', () => { + it('emits one fast_lane_decision per story-start and dedups identical repeats', () => { + writeStory('1-1-docs', DOCS_STORY); + const persisted = { current_story: '1-1-docs', current_bmad_step: null }; + deriveEffectiveProfile(persisted, fastLaneProfile(), root, { emitLedger: true }); + deriveEffectiveProfile(persisted, fastLaneProfile(), root, { emitLedger: true }); + const decisions = readLedger({ projectRoot: root }).filter( + (e) => e.kind === 'fast_lane_decision', + ); + expect(decisions).toHaveLength(1); + expect(decisions[0]).toMatchObject({ story_key: '1-1-docs', decision: 'fast' }); + }); + + it('logs a new entry when the decision flips (fast → forced full)', () => { + writeStory('1-1-docs', DOCS_STORY); + deriveEffectiveProfile( + { current_story: '1-1-docs', current_bmad_step: null }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + deriveEffectiveProfile( + { current_story: '1-1-docs', current_bmad_step: null, fast_lane_forced_full: ['1-1-docs'] }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + const decisions = readLedger({ projectRoot: root }).filter( + (e) => e.kind === 'fast_lane_decision', + ); + expect(decisions.map((d) => d.decision)).toEqual(['fast', 'full']); + }); + + it('RECORDS the fast decision at the NANO_QUICK_DEV lock (the real routing point)', () => { + // At CREATE_STORY the story file doesn't exist yet so the gate logs `full`; + // the actual `fast` routing is only knowable once the file exists, and the + // NANO_QUICK_DEV lock is the first emitting phase where that holds. Without + // logging here a fast-laned story would only ever record the misleading + // `full`, and every fast-lane metric would read zero. + writeStory( + '3-1-docs', + '## Acceptance Criteria\n- x\n\n## File List\n- `src/core/engine.ts`\n', // out-of-allowlist (drift) + ); + deriveEffectiveProfile( + { current_story: '3-1-docs', current_bmad_step: 'nano_quick_dev' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + const decisions = readLedger({ projectRoot: root }).filter( + (e) => e.kind === 'fast_lane_decision', + ); + expect(decisions).toHaveLength(1); + expect(decisions[0]).toMatchObject({ story_key: '3-1-docs', decision: 'fast' }); + }); + + it('a full→fast sequence makes buildRichStatus count the story as fast-laned', () => { + writeStory('3-2-docs', DOCS_STORY); + // Emission 1: create-story phase, file not yet classifiable as this test + // simulates via an explicit full entry, then the lock records fast. + deriveEffectiveProfile( + { current_story: '3-2-docs', current_bmad_step: 'create_story' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + deriveEffectiveProfile( + { current_story: '3-2-docs', current_bmad_step: 'nano_quick_dev' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + const s = buildRichStatus(root, { current_story: '3-2-docs' }, {}) as { + fast_lane: { fast_laned: number; current_decision: string }; + }; + expect(s.fast_lane.fast_laned).toBe(1); + expect(s.fast_lane.current_decision).toBe('fast'); + }); + + it('logs two different stories with the SAME decision (dedup is per-story, not global)', () => { + // The dedup keys on (story_key, decision) — a new story must always log, + // even if its decision matches the previous story's. + writeStory('7-1-a', '## Acceptance Criteria\n- do a thing\n'); // no paths → full + writeStory('7-2-b', '## Acceptance Criteria\n- do another\n'); // no paths → full + deriveEffectiveProfile( + { current_story: '7-1-a', current_bmad_step: 'create_story' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + deriveEffectiveProfile( + { current_story: '7-2-b', current_bmad_step: 'create_story' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + const decisions = readLedger({ projectRoot: root }).filter( + (e) => e.kind === 'fast_lane_decision', + ); + expect(decisions.map((d) => [d.story_key, d.decision])).toEqual([ + ['7-1-a', 'full'], + ['7-2-b', 'full'], + ]); + }); + + it('does not emit a ledger entry mid-story (non-story-start phase)', () => { + writeStory('1-1-docs', DOCS_STORY); + deriveEffectiveProfile( + { current_story: '1-1-docs', current_bmad_step: 'code_review' }, + fastLaneProfile(), + root, + { emitLedger: true }, + ); + const decisions = readLedger({ projectRoot: root }).filter( + (e) => e.kind === 'fast_lane_decision', + ); + expect(decisions).toHaveLength(0); + }); +}); + +describe('buildRichStatus — fast-lane field', () => { + it('is null when the lane never fired', () => { + const s = buildRichStatus(root, { current_story: '1-1' }, {}); + expect(s.fast_lane).toBeNull(); + }); + + it('surfaces fast-laned / escalated counts and the current story decision', () => { + appendLedger( + { kind: 'fast_lane_decision', story_key: '1-1', decision: 'fast', reasons: [] }, + { projectRoot: root }, + ); + appendLedger( + { kind: 'fast_lane_decision', story_key: '1-2', decision: 'full', reasons: [] }, + { projectRoot: root }, + ); + appendLedger( + { kind: 'profile_escalated', from: 'fast_lane', story_key: '1-3', reason: 'tests_failed' }, + { projectRoot: root }, + ); + const s = buildRichStatus(root, { current_story: '1-1' }, {}) as { + fast_lane: { current_decision: string; fast_laned: number; escalated: number }; + }; + expect(s.fast_lane.fast_laned).toBe(1); + expect(s.fast_lane.escalated).toBe(1); + expect(s.fast_lane.current_decision).toBe('fast'); + }); + + it('shows fast→full for a story currently running its escalated full cycle (F6)', () => { + appendLedger( + { kind: 'fast_lane_decision', story_key: '2-1', decision: 'fast', reasons: [] }, + { projectRoot: root }, + ); + appendLedger( + { kind: 'profile_escalated', from: 'fast_lane', story_key: '2-1', reason: 'high_severity' }, + { projectRoot: root }, + ); + const s = buildRichStatus(root, { current_story: '2-1' }, {}) as { + fast_lane: { current_decision: string }; + }; + expect(s.fast_lane.current_decision).toBe('fast→full'); + }); +}); diff --git a/tests/unit/orchestrator/fast-lane-escalation.test.ts b/tests/unit/orchestrator/fast-lane-escalation.test.ts new file mode 100644 index 0000000..ca9deca --- /dev/null +++ b/tests/unit/orchestrator/fast-lane-escalation.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; + +// @ts-expect-error — CommonJS module +import adapt from '../../../_Sprintpilot/lib/orchestrator/adapt.js'; +// @ts-expect-error — CommonJS module +import profileRules from '../../../_Sprintpilot/lib/orchestrator/profile-rules.js'; +// @ts-expect-error — CommonJS module +import sm from '../../../_Sprintpilot/lib/orchestrator/state-machine.js'; + +type Result = { + newState: Record; + newProfile: Record; + nextAction: Record; + sideEffects: Record[]; + verdict: string; +}; + +const { interpretSignal } = adapt as { + interpretSignal: ( + state: Record, + signal: Record, + profile: Record, + verifyResult?: { ok: boolean; issues?: string[] }, + ) => Result; +}; +const { STATES } = sm as { STATES: Record }; +const { flatToProfile, escalateOnFailure } = profileRules as { + flatToProfile: (resolved: unknown, name: string) => Record; + escalateOnFailure: ( + profile: Record, + output: Record, + ) => Record; +}; + +// A medium profile with the fast lane active for the current story — mirrors +// what deriveEffectiveProfile produces in the CLI when the gate says `fast`. +const fastLaneMedium = () => ({ + ...flatToProfile({}, 'medium'), + implementation_flow: 'quick', + fast_lane_active: true, +}); + +function st(phase: string, extra: Record = {}) { + return { + phase, + story_key: '4-2-copy-tweak', + story_file_path: '/r/_bmad-output/implementation-artifacts/4-2-copy-tweak.md', + current_epic: '4', + remaining_stories_in_epic: 1, + sprint_is_complete: false, + retry_count_this_phase: 0, + verify_reject_count: 0, + consecutive_test_failures: 0, + ...extra, + }; +} + +describe('escalateOnFailure — fast_lane origin', () => { + it('bounces a fast-laned story to full on failing tests, keeping the profile name', () => { + const p = fastLaneMedium(); + const out = escalateOnFailure(p, { tests_failed: 2 }); + expect(out).not.toBe(p); + expect(out.name).toBe('medium'); // name preserved (already a full profile) + expect(out.implementation_flow).toBe('full'); + expect(out.fast_lane_active).toBe(false); + expect(out.escalated_from).toBe('fast_lane'); + expect(out.escalation_reason).toBe('tests_failed'); + }); + + it('bounces on a high-severity finding', () => { + const out = escalateOnFailure(fastLaneMedium(), { severity: 'high' }); + expect(out.implementation_flow).toBe('full'); + expect(out.escalation_reason).toBe('high_severity'); + }); + + it('does not escalate a clean fast-laned success', () => { + const p = fastLaneMedium(); + expect(escalateOnFailure(p, { tests_failed: 0, severity: 'low' })).toBe(p); + }); + + it('leaves a plain full profile (no fast_lane_active) untouched', () => { + const p = flatToProfile({}, 'medium'); + expect(escalateOnFailure(p, { tests_failed: 3 })).toBe(p); + }); +}); + +describe('fast-lane routing — create-story runs first', () => { + // The gate needs a real story file to enforce deny-globs / max_ac / tags, and + // that file only exists after bmad-create-story. So a fast-laned story goes + // PREPARE_STORY_BRANCH → CREATE_STORY → NANO_QUICK_DEV — NOT straight to + // quick-dev. nano (whole-profile quick) still skips create-story. + it('a fast-laned story branches PREPARE_STORY_BRANCH → CREATE_STORY (not NANO_QUICK_DEV)', () => { + const r = interpretSignal( + st(STATES.PREPARE_STORY_BRANCH), + { status: 'success' }, + fastLaneMedium(), + ); + expect(r.newState.phase).toBe(STATES.CREATE_STORY); + expect(r.nextAction.skill).toBe('bmad-create-story'); + }); + + it('a fast-laned story routes CREATE_STORY → NANO_QUICK_DEV (skips the 7-step cycle)', () => { + const r = interpretSignal( + st(STATES.CREATE_STORY), + { status: 'success', output: { story_key: '4-2-copy-tweak' } }, + fastLaneMedium(), + ); + expect(r.newState.phase).toBe(STATES.NANO_QUICK_DEV); + expect(r.nextAction.skill).toBe('bmad-quick-dev'); + }); + + it('nano (whole-profile quick, not fast_lane_active) still skips create-story', () => { + const nano = { ...flatToProfile({}, 'medium'), implementation_flow: 'quick' }; + const r = interpretSignal(st(STATES.PREPARE_STORY_BRANCH), { status: 'success' }, nano); + expect(r.newState.phase).toBe(STATES.NANO_QUICK_DEV); + }); + + it('a non-fast-laned full story routes CREATE_STORY → CHECK_READINESS as before', () => { + const r = interpretSignal( + st(STATES.CREATE_STORY), + { status: 'success' }, + flatToProfile({}, 'medium'), + ); + expect(r.newState.phase).toBe(STATES.CHECK_READINESS); + }); + + it('a bmad-quick-dev HINT cannot hijack a non-fast-laned CREATE_STORY into quick-dev', () => { + // Safety: NANO_QUICK_DEV is not a structurally-valid CREATE_STORY successor + // unless fast_lane_active, so the LLM hint tiebreaker can't push a deny- + // glob'd / forced-full / plain-full story into unreviewed one-shot. + const r = interpretSignal( + st(STATES.CREATE_STORY), + { status: 'success', next_skill_hint: 'bmad-quick-dev' }, + flatToProfile({}, 'medium'), + ); + expect(r.newState.phase).toBe(STATES.CHECK_READINESS); + }); +}); + +describe('adapt routing — fast-lane re-run', () => { + it('a success-but-flagged quick-dev routes to CODE_REVIEW (the review the fast lane skipped) and records forced-full', () => { + // The SUCCESS path: quick-dev marked the story done (verify requires it) + // but reported failing tests / high severity. Routing to CODE_REVIEW — not + // CREATE_STORY — because composeRuntimeState skips the done-rejection at + // CODE_REVIEW (a done story survives re-resolution and gets reviewed), + // whereas CREATE_STORY would let the done story be skipped entirely. + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV), + { status: 'success', output: { tests_failed: 1, story_key: '4-2-copy-tweak' } }, + fastLaneMedium(), + ); + expect(r.newState.phase).toBe(STATES.CODE_REVIEW); + expect(r.newProfile.implementation_flow).toBe('full'); + expect(r.newState.fast_lane_forced_full).toContain('4-2-copy-tweak'); + const esc = r.sideEffects.find((e) => e.kind === 'profile_escalated'); + expect(esc).toMatchObject({ from: 'fast_lane', story_key: '4-2-copy-tweak' }); + // next action runs the adversarial review over the committed code + expect(r.nextAction.skill).toBe('bmad-code-review'); + // the escalation context is surfaced as a review-pass note + expect(r.newState.escalation_note).toMatch(/FAST-LANE ESCALATION/); + expect(r.newState.escalation_note).toMatch(/CODE REVIEW/); + const slots = r.nextAction.template_slots as Record; + expect(slots.profile_specific_notes).toBe(r.newState.escalation_note); + }); + + it('a clean fast-laned quick-dev advances to STORY_DONE (no escalation)', () => { + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV), + { + status: 'success', + output: { tests_failed: 0, commit_sha: 'abc', story_key: '4-2-copy-tweak' }, + }, + fastLaneMedium(), + ); + expect(r.newState.phase).toBe(STATES.STORY_DONE); + expect(r.newState.fast_lane_forced_full ?? []).not.toContain('4-2-copy-tweak'); + }); + + it('does NOT mark the sprint complete after a clean fast-laned story (multi-story sprint)', () => { + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV), + { + status: 'success', + output: { tests_failed: 0, commit_sha: 'abc', story_key: '4-2-copy-tweak' }, + }, + fastLaneMedium(), + ); + // The nano one-shot "sprint complete" shortcut must not fire for the + // per-story fast lane — the medium sprint has more stories to run. + expect(r.newState.sprint_is_complete).toBe(false); + }); + + it('unions repeated escalations without dropping earlier entries', () => { + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV, { fast_lane_forced_full: ['1-1-prior'] }), + { status: 'success', output: { severity: 'high', story_key: '4-2-copy-tweak' } }, + fastLaneMedium(), + ); + expect(r.newState.fast_lane_forced_full).toEqual( + expect.arrayContaining(['1-1-prior', '4-2-copy-tweak']), + ); + }); +}); + +describe('adapt routing — fast-lane hard failure', () => { + it('a status:failure quick-dev bounces to the full cycle instead of retrying', () => { + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV, { retry_count_this_phase: 0 }), + { status: 'failure', recoverable: true, reason: 'impl broke', story_key: '4-2-copy-tweak' }, + fastLaneMedium(), + ); + expect(r.verdict).toBe('advanced'); + expect(r.newState.phase).toBe(STATES.CREATE_STORY); + expect(r.newProfile.implementation_flow).toBe('full'); + expect(r.newState.fast_lane_forced_full).toContain('4-2-copy-tweak'); + expect(r.nextAction.skill).toBe('bmad-create-story'); + expect(r.newState.escalation_note).toMatch(/FAST-LANE ESCALATION/); + expect(r.newState.escalation_note).toMatch(/known-deficient/i); + const esc = r.sideEffects.find((e) => e.kind === 'profile_escalated'); + expect(esc).toMatchObject({ from: 'fast_lane', reason: 'quick_dev_failure' }); + }); + + it('a non-recoverable failure also bounces to full (the full cycle is the recovery)', () => { + const r = interpretSignal( + st(STATES.NANO_QUICK_DEV), + { status: 'failure', recoverable: false, story_key: '4-2-copy-tweak' }, + fastLaneMedium(), + ); + expect(r.newState.phase).toBe(STATES.CREATE_STORY); + expect(r.newState.fast_lane_forced_full).toContain('4-2-copy-tweak'); + }); + + it('a plain full-profile dev failure is unaffected (normal retry path)', () => { + const r = interpretSignal( + st(STATES.DEV_GREEN, { retry_count_this_phase: 0 }), + { status: 'failure', recoverable: true }, + flatToProfile({}, 'medium'), + ); + // No fast lane → normal retry, not a bounce. + expect(r.verdict).toBe('retry'); + expect(r.newState.phase).toBe(STATES.DEV_GREEN); + }); + + it('the escalation note does not bleed into the NEXT story', () => { + // A stale escalation_note on state must be cleared when a fresh story + // starts (advanceState new-story reset), so story N+1 gets normal notes. + const r = interpretSignal( + st(STATES.RETROSPECTIVE, { + escalation_note: 'stale note from a prior escalated story', + remaining_stories_in_epic: 1, + sprint_is_complete: false, + }), + { status: 'success' }, + flatToProfile({}, 'medium'), + ); + // RETROSPECTIVE → next story start (PREPARE_STORY_BRANCH under a + // branch-prep full profile) — the note clears at that boundary. + expect(r.newState.phase).toBe(STATES.PREPARE_STORY_BRANCH); + expect(r.newState.escalation_note).toBeNull(); + }); +}); + +describe('nano_quick_dev phase timeout is budgeted under full profiles', () => { + for (const [name, expected] of [ + ['small', 20], + ['medium', 30], + ['large', 60], + ] as const) { + it(`${name} budgets nano_quick_dev at ${expected}m (fast-lane hang protection)`, () => { + const p = flatToProfile({}, name); + expect((p.phase_timeout_minutes as Record).nano_quick_dev).toBe(expected); + }); + } +}); diff --git a/tests/unit/orchestrator/fast-lane-gate.test.ts b/tests/unit/orchestrator/fast-lane-gate.test.ts new file mode 100644 index 0000000..4c112e1 --- /dev/null +++ b/tests/unit/orchestrator/fast-lane-gate.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from 'vitest'; + +// @ts-expect-error — CommonJS module +import gate from '../../../_Sprintpilot/lib/orchestrator/fast-lane-gate.js'; + +const { + globToRegExp, + matchesAnyGlob, + countAcceptanceCriteria, + countTasks, + extractTag, + tagFromFields, + extractDeclaredPaths, + extractStorySignals, + normalizeConfig, + evaluateSignals, + classifyStory, +} = gate as { + globToRegExp: (g: string) => RegExp; + matchesAnyGlob: (p: string, globs: string[]) => boolean; + countAcceptanceCriteria: (t: string) => number; + countTasks: (t: string) => number; + extractTag: (t: string) => 'fast' | 'full' | null; + tagFromFields: (obj: unknown) => 'fast' | 'full' | null; + extractDeclaredPaths: (t: string) => string[]; + extractStorySignals: (t: string) => { + acCount: number; + taskCount: number; + tag: string | null; + declaredPaths: string[]; + }; + normalizeConfig: (c: Record) => { + enabled: boolean; + maxAc: number; + allowGlobs: string[]; + denyGlobs: string[]; + requireStoryTag: boolean; + }; + evaluateSignals: ( + s: Record, + c: Record, + opts?: { forcedFull?: boolean; fallbackTag?: 'fast' | 'full' | null }, + ) => { decision: 'fast' | 'full'; reasons: string[] }; + classifyStory: (a: { + storyKey?: string; + storyText?: string; + config?: Record; + forcedFull?: boolean; + fallbackTag?: 'fast' | 'full' | null; + }) => { story_key: string | null; decision: 'fast' | 'full'; reasons: string[] }; +}; + +const ENABLED = { + fast_lane_enabled: true, + fast_lane_max_ac: 3, + fast_lane_allow_globs: ['docs/**', '**/*.md'], + fast_lane_deny_globs: ['**/auth/**', '**/migrations/**', '**/*secret*'], + fast_lane_require_story_tag: false, +}; + +describe('globToRegExp / matchesAnyGlob', () => { + it('** matches across path separators, including the bare prefix', () => { + expect(globToRegExp('docs/**').test('docs/a/b/c.md')).toBe(true); + expect(globToRegExp('docs/**').test('docs')).toBe(true); + expect(globToRegExp('docs/**').test('src/docs/a.md')).toBe(false); + }); + + it('* stops at a path separator', () => { + expect(globToRegExp('*.md').test('README.md')).toBe(true); + expect(globToRegExp('*.md').test('docs/README.md')).toBe(false); + expect(globToRegExp('**/*.md').test('docs/a/README.md')).toBe(true); + expect(globToRegExp('**/*.md').test('README.md')).toBe(true); + }); + + it('**/segment/** matches whole path segments, not substrings', () => { + // `**/auth/**` must match a real `auth` dir but NOT `oauth`. + expect(globToRegExp('**/auth/**').test('src/auth/login.ts')).toBe(true); + expect(globToRegExp('**/auth/**').test('auth/x')).toBe(true); + expect(globToRegExp('**/auth/**').test('src/oauth/token.ts')).toBe(false); + expect(globToRegExp('**/auth/**').test('src/oauthorize.ts')).toBe(false); + }); + + it('default secret deny covers both secret-named files and secret dirs (F4)', () => { + // `**/*secret*` catches secret-named files; `**/*secret*/**` catches files + // UNDER a secret-named directory whose basename lacks "secret". + const deny = ['**/*secret*', '**/*secret*/**']; + expect(matchesAnyGlob('config/secrets.yaml', deny)).toBe(true); + expect(matchesAnyGlob('src/secrets/key.ts', deny)).toBe(true); + expect(matchesAnyGlob('src/util/helper.ts', deny)).toBe(false); + }); + + it('escapes regex metacharacters in the literal parts', () => { + expect(globToRegExp('a.b+c').test('a.b+c')).toBe(true); + expect(globToRegExp('a.b+c').test('axbxc')).toBe(false); + }); + + it('normalizes ./ prefixes and backslashes before matching', () => { + expect(matchesAnyGlob('./docs/x.md', ['docs/**'])).toBe(true); + expect(matchesAnyGlob('docs\\x.md', ['docs/**'])).toBe(true); + expect(matchesAnyGlob('src/x.ts', [])).toBe(false); + }); +}); + +describe('story parsers', () => { + it('counts acceptance criteria list items until the next heading', () => { + const t = '## Acceptance Criteria\n- one\n- two\n1. three\n\n## Tasks\n- [ ] nope\n'; + expect(countAcceptanceCriteria(t)).toBe(3); + }); + + it('returns 0 AC when the section is absent', () => { + expect(countAcceptanceCriteria('## Notes\n- x\n')).toBe(0); + }); + + it('counts task checkboxes under the Tasks section', () => { + const t = '## Tasks / Subtasks\n- [ ] a\n - [x] b\n- [ ] c\n'; + expect(countTasks(t)).toBe(3); + }); + + it('extracts explicit tags with full winning over fast', () => { + expect(extractTag('fast_lane: true\n')).toBe('fast'); + expect(extractTag('risk: low\n')).toBe('fast'); + expect(extractTag('risk: high\n')).toBe('full'); + expect(extractTag('fast_lane: false\n')).toBe('full'); + // full-forcing signal wins even when a fast signal is also present + expect(extractTag('risk: low\nrisk: high\n')).toBe('full'); + expect(extractTag('risk: medium\n')).toBe(null); + expect(extractTag('no tags here')).toBe(null); + }); + + it('harvests declared paths from code spans and File List sections', () => { + const t = + 'Touches `src/foo.ts` and the docs.\n\n## File List\n- `docs/guide.md`\n- README.md\n'; + const paths = extractDeclaredPaths(t); + expect(paths).toContain('src/foo.ts'); + expect(paths).toContain('docs/guide.md'); + expect(paths).toContain('README.md'); + }); + + it('extractStorySignals bundles the parsed fields', () => { + const sig = extractStorySignals('risk: low\n## Acceptance Criteria\n- x\n'); + expect(sig).toMatchObject({ acCount: 1, tag: 'fast' }); + }); +}); + +describe('tagFromFields (plan/epic entry tags)', () => { + it('reads fast_lane + risk fields with full winning over fast', () => { + expect(tagFromFields({ fast_lane: true })).toBe('fast'); + expect(tagFromFields({ fast_lane: false })).toBe('full'); + expect(tagFromFields({ fast_lane: 'yes' })).toBe('fast'); + expect(tagFromFields({ risk: 'low' })).toBe('fast'); + expect(tagFromFields({ risk: 'High' })).toBe('full'); + expect(tagFromFields({ risk: 'critical' })).toBe('full'); + expect(tagFromFields({ fast_lane: true, risk: 'high' })).toBe('full'); + expect(tagFromFields({ risk: 'medium' })).toBeNull(); + expect(tagFromFields({})).toBeNull(); + expect(tagFromFields(null)).toBeNull(); + }); +}); + +describe('evaluateSignals — epic/plan fallback tag', () => { + it('applies fallbackTag when the story file has no tag of its own', () => { + const r = evaluateSignals({ tag: null, declaredPaths: [], acCount: 1 }, ENABLED, { + fallbackTag: 'fast', + }); + expect(r.decision).toBe('fast'); + expect(r.reasons).toContain('tag_force_fast:epic'); + }); + + it('a story-file tag OVERRIDES the epic fallback tag', () => { + const r = evaluateSignals({ tag: 'full', declaredPaths: [], acCount: 1 }, ENABLED, { + fallbackTag: 'fast', + }); + expect(r.decision).toBe('full'); + expect(r.reasons).toContain('tag_force_full'); + }); + + it('an epic fallback fast tag does NOT satisfy require_story_tag', () => { + // The knob means "only stories that tag THEMSELVES" — an epic tag can't + // stand in for a per-story tag. + const epicOnly = evaluateSignals( + { tag: null, declaredPaths: [], acCount: 1 }, + { + ...ENABLED, + fast_lane_require_story_tag: true, + }, + { fallbackTag: 'fast' }, + ); + expect(epicOnly.decision).toBe('full'); + expect(epicOnly.reasons).toContain('require_story_tag_unset'); + // but a STORY-FILE fast tag still qualifies under require_story_tag + const storyTagged = evaluateSignals( + { tag: 'fast', declaredPaths: [], acCount: 1 }, + { + ...ENABLED, + fast_lane_require_story_tag: true, + }, + ); + expect(storyTagged.decision).toBe('fast'); + }); + + it('an epic fallback tag is still subject to the deny-glob + AC gates', () => { + const denied = evaluateSignals( + { tag: null, declaredPaths: ['src/auth/x.ts'], acCount: 1 }, + ENABLED, + { fallbackTag: 'fast' }, + ); + expect(denied.decision).toBe('full'); + const big = evaluateSignals({ tag: null, declaredPaths: [], acCount: 9 }, ENABLED, { + fallbackTag: 'fast', + }); + expect(big.decision).toBe('full'); + }); +}); + +describe('normalizeConfig', () => { + it('reads flat profile fields', () => { + const c = normalizeConfig(ENABLED); + expect(c).toMatchObject({ enabled: true, maxAc: 3, requireStoryTag: false }); + expect(c.allowGlobs).toEqual(['docs/**', '**/*.md']); + }); + + it('reads a nested { fast_lane: {...} } object', () => { + const c = normalizeConfig({ fast_lane: { enabled: true, max_ac: 5, allow_globs: ['a/**'] } }); + expect(c).toMatchObject({ enabled: true, maxAc: 5 }); + expect(c.allowGlobs).toEqual(['a/**']); + }); + + it('defaults to disabled + empty globs on an empty config', () => { + const c = normalizeConfig({}); + expect(c).toMatchObject({ enabled: false, maxAc: 3, allowGlobs: [], denyGlobs: [] }); + }); + + it('falls back to the default max_ac on a negative / non-finite value (typo guard)', () => { + expect(normalizeConfig({ fast_lane_max_ac: -1 }).maxAc).toBe(3); + expect(normalizeConfig({ fast_lane_max_ac: Number.NaN }).maxAc).toBe(3); + expect(normalizeConfig({ fast_lane_max_ac: 0 }).maxAc).toBe(0); // 0 is a valid (strict) budget + expect(normalizeConfig({ fast_lane_max_ac: 5 }).maxAc).toBe(5); + }); +}); + +describe('evaluateSignals precedence', () => { + it('forcedFull short-circuits everything', () => { + const r = evaluateSignals({ tag: 'fast', declaredPaths: ['docs/x.md'], acCount: 1 }, ENABLED, { + forcedFull: true, + }); + expect(r.decision).toBe('full'); + expect(r.reasons).toContain('escalated_forced_full'); + }); + + it('disabled config is always full', () => { + const r = evaluateSignals( + { tag: 'fast', declaredPaths: ['docs/x.md'] }, + { + ...ENABLED, + fast_lane_enabled: false, + }, + ); + expect(r.decision).toBe('full'); + }); + + it('deny glob beats an explicit fast tag (hard safety)', () => { + const r = evaluateSignals( + { tag: 'fast', declaredPaths: ['src/auth/login.ts'], acCount: 1 }, + ENABLED, + ); + expect(r.decision).toBe('full'); + expect(r.reasons[0]).toMatch(/^deny_glob:/); + }); + + it('explicit full tag forces full', () => { + const r = evaluateSignals({ tag: 'full', declaredPaths: ['docs/x.md'] }, ENABLED); + expect(r.decision).toBe('full'); + expect(r.reasons).toContain('tag_force_full'); + }); + + it('explicit fast tag routes fast even without allow-listed paths', () => { + const r = evaluateSignals({ tag: 'fast', declaredPaths: [], acCount: 1 }, ENABLED); + expect(r.decision).toBe('fast'); + expect(r.reasons).toContain('tag_force_fast'); + }); + + it('an explicit fast tag CANNOT override the AC budget (size gate wins)', () => { + // A large story with an incidental `risk: low` line must not fast-lane. + const r = evaluateSignals({ tag: 'fast', declaredPaths: [], acCount: 9 }, ENABLED); + expect(r.decision).toBe('full'); + expect(r.reasons[0]).toMatch(/^ac_count_9>3/); + }); + + it('require_story_tag forces full when untagged', () => { + const r = evaluateSignals( + { tag: null, declaredPaths: ['docs/x.md'], acCount: 1 }, + { + ...ENABLED, + fast_lane_require_story_tag: true, + }, + ); + expect(r.decision).toBe('full'); + expect(r.reasons).toContain('require_story_tag_unset'); + }); + + it('AC over budget forces full', () => { + const r = evaluateSignals({ tag: null, declaredPaths: ['docs/x.md'], acCount: 4 }, ENABLED); + expect(r.decision).toBe('full'); + expect(r.reasons[0]).toMatch(/^ac_count_4>3/); + }); + + it('infers fast when every declared path is allow-listed and AC in budget', () => { + const r = evaluateSignals( + { tag: null, declaredPaths: ['docs/a.md', 'README.md'], acCount: 2 }, + ENABLED, + ); + expect(r.decision).toBe('fast'); + expect(r.reasons[0]).toMatch(/^inferred_low_risk/); + }); + + it('forces full when a declared path is outside the allow list', () => { + const r = evaluateSignals( + { tag: null, declaredPaths: ['docs/a.md', 'src/core.ts'], acCount: 1 }, + ENABLED, + ); + expect(r.decision).toBe('full'); + expect(r.reasons[0]).toMatch(/^paths_outside_allow:/); + }); + + it('defaults full when no allow-listed paths are declared', () => { + const r = evaluateSignals({ tag: null, declaredPaths: [], acCount: 1 }, ENABLED); + expect(r.decision).toBe('full'); + expect(r.reasons).toContain('default_full_no_allowlisted_paths'); + }); +}); + +describe('classifyStory end-to-end', () => { + it('fast-lanes a small docs-only story', () => { + const md = '## Acceptance Criteria\n- update guide\n\n## File List\n- `docs/guide.md`\n'; + const r = classifyStory({ storyKey: '1-1-docs', storyText: md, config: ENABLED }); + expect(r).toMatchObject({ story_key: '1-1-docs', decision: 'fast' }); + }); + + it('keeps a security story on the full cycle even if tagged fast', () => { + const md = + 'fast_lane: true\n## Acceptance Criteria\n- x\n## File List\n- `src/auth/login.ts`\n'; + const r = classifyStory({ storyKey: '2-1-auth', storyText: md, config: ENABLED }); + expect(r.decision).toBe('full'); + }); + + it('empty story text (missing file) is conservatively full', () => { + const r = classifyStory({ storyKey: '3-1', storyText: '', config: ENABLED }); + expect(r.decision).toBe('full'); + }); +}); diff --git a/tests/unit/orchestrator/fast-lane-overrides.test.ts b/tests/unit/orchestrator/fast-lane-overrides.test.ts new file mode 100644 index 0000000..b15605a --- /dev/null +++ b/tests/unit/orchestrator/fast-lane-overrides.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// @ts-expect-error — CommonJS module +import overrides from '../../../_Sprintpilot/lib/orchestrator/fast-lane-overrides.js'; + +const { overridesPath, normalizeEpicKey, readMap, resolve, setOverride, clearOverride } = + overrides as { + overridesPath: (root: string) => string; + normalizeEpicKey: (k: string) => string; + readMap: (root: string) => { stories: Record; epics: Record }; + resolve: ( + root: string, + storyKey: string | null, + epicKey: string | null, + ) => 'fast' | 'full' | null; + setOverride: ( + root: string, + key: string, + decision: string, + opts?: { isEpic?: boolean }, + ) => { ok: boolean; bucket?: string; key?: string; reason?: string }; + clearOverride: (root: string, key: string, opts?: { isEpic?: boolean }) => boolean; + }; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'sp-fl-overrides-')); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('fast-lane-overrides store', () => { + it('is empty and tolerant when the file is absent', () => { + expect(readMap(root)).toEqual({ stories: {}, epics: {} }); + expect(resolve(root, '1-1', '1')).toBeNull(); + }); + + it('sets and resolves a story mark', () => { + expect(setOverride(root, '4-1-docs', 'fast').ok).toBe(true); + expect(resolve(root, '4-1-docs', '4')).toBe('fast'); + }); + + it('sets and resolves an epic mark, normalizing the key', () => { + setOverride(root, 'epic-5', 'full', { isEpic: true }); + // stored under bare id 5; resolves whether the caller passes 5 or epic-5 + expect(resolve(root, '5-2-x', '5')).toBe('full'); + expect(resolve(root, '5-2-x', 'epic-5')).toBe('full'); + expect(normalizeEpicKey('Epic-5')).toBe('5'); + }); + + it('a story mark WINS over its epic mark', () => { + setOverride(root, 'epic-4', 'full', { isEpic: true }); + setOverride(root, '4-1-docs', 'fast'); + expect(resolve(root, '4-1-docs', '4')).toBe('fast'); // story wins + expect(resolve(root, '4-2-other', '4')).toBe('full'); // sibling falls to epic + }); + + it('clear reverts to auto (null)', () => { + setOverride(root, '4-1', 'fast'); + expect(clearOverride(root, '4-1')).toBe(true); + expect(resolve(root, '4-1', '4')).toBeNull(); + expect(clearOverride(root, '4-1')).toBe(false); // idempotent + }); + + it('rejects an invalid decision', () => { + expect(setOverride(root, '4-1', 'maybe').ok).toBe(false); + }); + + it('persists JSON under the conventional path with replace semantics', () => { + setOverride(root, '4-1', 'fast'); + setOverride(root, '4-1', 'full'); // replace, not append + const raw = JSON.parse(readFileSync(overridesPath(root), 'utf8')); + expect(raw.fast_lane_overrides.stories['4-1'].decision).toBe('full'); + expect(Object.keys(raw.fast_lane_overrides.stories)).toEqual(['4-1']); + }); + + it('tolerates a mangled file (empty maps, never throws)', () => { + setOverride(root, '4-1', 'fast'); + // corrupt it + rmSync(overridesPath(root)); + require('node:fs').writeFileSync(overridesPath(root), '{ not json', 'utf8'); + expect(readMap(root)).toEqual({ stories: {}, epics: {} }); + expect(resolve(root, '4-1', '4')).toBeNull(); + }); +}); diff --git a/tests/unit/orchestrator/profile-rules.test.ts b/tests/unit/orchestrator/profile-rules.test.ts index 4c101e4..112ecd8 100644 --- a/tests/unit/orchestrator/profile-rules.test.ts +++ b/tests/unit/orchestrator/profile-rules.test.ts @@ -15,6 +15,47 @@ const { flatToProfile, escalateOnFailure, ORCHESTRATOR_DEFAULTS_BY_PROFILE } = p >; }; +describe('flatToProfile — fast_lane config', () => { + it('defaults to disabled with empty glob lists on an empty tree', () => { + const p = flatToProfile({}, 'medium'); + expect(p.fast_lane_enabled).toBe(false); + expect(p.fast_lane_max_ac).toBe(3); + expect(p.fast_lane_allow_globs).toEqual([]); + expect(p.fast_lane_deny_globs).toEqual([]); + expect(p.fast_lane_require_story_tag).toBe(false); + }); + + it('parses comma-separated glob strings into arrays (narrow-parser shape)', () => { + const p = flatToProfile( + { + autopilot: { + fast_lane: { + enabled: true, + max_ac: 5, + allow_globs: 'docs/**, **/*.md', + deny_globs: '**/auth/**', + require_story_tag: true, + }, + }, + }, + 'medium', + ); + expect(p.fast_lane_enabled).toBe(true); + expect(p.fast_lane_max_ac).toBe(5); + expect(p.fast_lane_allow_globs).toEqual(['docs/**', '**/*.md']); + expect(p.fast_lane_deny_globs).toEqual(['**/auth/**']); + expect(p.fast_lane_require_story_tag).toBe(true); + }); + + it('also tolerates real arrays (js-yaml host)', () => { + const p = flatToProfile( + { autopilot: { fast_lane: { allow_globs: ['a/**', 'b/**'] } } }, + 'medium', + ); + expect(p.fast_lane_allow_globs).toEqual(['a/**', 'b/**']); + }); +}); + describe('flatToProfile', () => { it('produces a typed Profile from an empty resolved tree (medium defaults)', () => { const p = flatToProfile({}, 'medium'); diff --git a/tests/unit/orchestrator/report.test.ts b/tests/unit/orchestrator/report.test.ts index 70d7410..2053058 100644 --- a/tests/unit/orchestrator/report.test.ts +++ b/tests/unit/orchestrator/report.test.ts @@ -3,17 +3,19 @@ import { describe, expect, it } from 'vitest'; // @ts-expect-error — CommonJS module import report from '../../../_Sprintpilot/lib/orchestrator/report.js'; -const { render, ledgerSummary, recentActions, blockers, nextActionHint } = report as { - render: ( - state: Record, - entries: Record[], - profile: Record, - ) => string; - ledgerSummary: (entries: Record[]) => string; - recentActions: (entries: Record[], limit?: number) => string; - blockers: (entries: Record[]) => string; - nextActionHint: (state: Record, profile: Record) => string; -}; +const { render, ledgerSummary, fastLaneSummary, recentActions, blockers, nextActionHint } = + report as { + render: ( + state: Record, + entries: Record[], + profile: Record, + ) => string; + ledgerSummary: (entries: Record[]) => string; + fastLaneSummary: (entries: Record[]) => string; + recentActions: (entries: Record[], limit?: number) => string; + blockers: (entries: Record[]) => string; + nextActionHint: (state: Record, profile: Record) => string; + }; const entry = (kind: string, extra: Record = {}) => ({ seq: 1, @@ -22,6 +24,29 @@ const entry = (kind: string, extra: Record = {}) => ({ ...extra, }); +describe('fastLaneSummary', () => { + it('returns empty when the fast lane never fired', () => { + expect(fastLaneSummary([entry('action_emitted')])).toBe(''); + }); + + it('counts a fast-then-escalated story as fast-laned, not kept-full', () => { + const entries = [ + entry('fast_lane_decision', { story_key: '1-1', decision: 'fast' }), + entry('fast_lane_decision', { story_key: '1-2', decision: 'full' }), + entry('fast_lane_decision', { story_key: '1-3', decision: 'fast' }), + // 1-3 later bounced to full → it STILL ran quick-dev, so it stays counted + // as fast-laned AND is reported as escalated. + entry('fast_lane_decision', { story_key: '1-3', decision: 'full' }), + entry('profile_escalated', { from: 'fast_lane', story_key: '1-3' }), + ]; + const out = fastLaneSummary(entries); + expect(out).toContain('Stories fast-laned (ran quick-dev one-shot): 2'); // 1-1 + 1-3 + expect(out).toContain('Stories kept on the full cycle: 1'); // only 1-2 + expect(out).toContain('Fast-laned stories escalated back to full: 1'); // 1-3 + expect(out).toContain('1-3'); + }); +}); + describe('render', () => { it('renders header + ledger summary + recent actions', () => { const state = { diff --git a/tests/unit/orchestrator/state-store.test.ts b/tests/unit/orchestrator/state-store.test.ts index c596b8c..b206779 100644 --- a/tests/unit/orchestrator/state-store.test.ts +++ b/tests/unit/orchestrator/state-store.test.ts @@ -148,6 +148,10 @@ describe('write (coalesce path)', () => { // across rejections. 'last_verify_issues_signature', 'consecutive_identical_rejections', + // Fast-lane escalation ledger — story keys bounced from the + // quick-dev fast lane back to the full cycle. Write-through so a + // crash can't let the gate re-fast-lane an already-failed story. + 'fast_lane_forced_full', ].sort(), ); }); diff --git a/tests/unit/orchestrator/user-command-applier.test.ts b/tests/unit/orchestrator/user-command-applier.test.ts index b1952de..156358b 100644 --- a/tests/unit/orchestrator/user-command-applier.test.ts +++ b/tests/unit/orchestrator/user-command-applier.test.ts @@ -71,6 +71,38 @@ describe('applyOne', () => { expect(r.effects[0].reason).toBe('user_skip_story'); }); + it('set_fast_lane → emits a set_fast_lane side-effect (story + epic + auto)', () => { + const story = applyOne(st(STATES.DEV_RED), medium(), { + kind: 'set_fast_lane', + story_key: '4-1', + decision: 'fast', + }); + expect(story.effects[0]).toMatchObject({ + kind: 'set_fast_lane', + story_key: '4-1', + epic: null, + decision: 'fast', + }); + const epic = applyOne(st(STATES.DEV_RED), medium(), { + kind: 'set_fast_lane', + epic: 'epic-5', + decision: 'full', + }); + expect(epic.effects[0]).toMatchObject({ + kind: 'set_fast_lane', + epic: 'epic-5', + decision: 'full', + }); + const auto = applyOne(st(STATES.DEV_RED), medium(), { + kind: 'set_fast_lane', + story_key: '4-1', + decision: 'auto', + }); + expect(auto.effects[0]).toMatchObject({ decision: 'auto' }); + // pure: state/profile unchanged + expect(story.newState).toEqual(st(STATES.DEV_RED)); + }); + it('skip_story → resets to NANO_QUICK_DEV for quick flow', () => { const r = applyOne(st(STATES.NANO_QUICK_DEV), nano(), { kind: 'skip_story', diff --git a/tests/unit/orchestrator/user-commands.test.ts b/tests/unit/orchestrator/user-commands.test.ts index a1a5ed8..04337f5 100644 --- a/tests/unit/orchestrator/user-commands.test.ts +++ b/tests/unit/orchestrator/user-commands.test.ts @@ -18,6 +18,29 @@ describe('validateOne', () => { expect(r.ok).toBe(true); }); + it('accepts set_fast_lane with a story_key or epic + decision', () => { + expect(validateOne({ kind: 'set_fast_lane', story_key: '4-1', decision: 'fast' }).ok).toBe( + true, + ); + expect(validateOne({ kind: 'set_fast_lane', story_key: '4-1', decision: 'full' }).ok).toBe( + true, + ); + expect(validateOne({ kind: 'set_fast_lane', story_key: '4-1', decision: 'auto' }).ok).toBe( + true, + ); + expect(validateOne({ kind: 'set_fast_lane', epic: 'epic-5', decision: 'fast' }).ok).toBe(true); + }); + + it('rejects set_fast_lane without a target, with both, or with a bad decision', () => { + expect(validateOne({ kind: 'set_fast_lane', decision: 'fast' }).ok).toBe(false); + expect( + validateOne({ kind: 'set_fast_lane', story_key: '4-1', epic: '5', decision: 'fast' }).ok, + ).toBe(false); + expect(validateOne({ kind: 'set_fast_lane', story_key: '4-1', decision: 'maybe' }).ok).toBe( + false, + ); + }); + it('rejects skip_story without story_key', () => { const r = validateOne({ kind: 'skip_story' }); expect(r.ok).toBe(false);