From 5fcae44de15c8a825dbb8b6f4174b74e33447493 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 20:02:29 +0200 Subject: [PATCH] feat(cli): add OpenSpec as alternative workflow mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend WorkflowMode to four values (speckit | openspec | vibe | none), removing the redundant `speckit: boolean` from ProjectConfig. New src/generators/openspec.ts bootstraps OpenSpec via `npx --yes @fission-ai/openspec@latest init --tools --force .`, mirroring the speckit silent-skip pattern (warn, never abort). CLI gains `--speckit` and `--openspec` flags with mutual-exclusion gate; `--workflow` description updated to list openspec. AGENTS.md and CLAUDE.md templates render `Workflow Mode:` and a parameterized `{{#if workflowOpenspec}}` doc block — no hard-coded project names, all paths driven by `{{name}}`. `commands/new.ts` dispatches the bootstrap on workflowMode; `commands/add.ts` exposes openspec as a layer with spec-mode conflict group; `detect-project.ts` recognizes `openspec/config.yaml` as a sentinel. Tests: 554/554 passing. New coverage: initOpenspec argv shape, isNpxAvailable detection, CLI mutual-exclusion (FR-009/SC-004), template rendering for both AGENTS.md and CLAUDE.md (positive + negative cases, plus a guard that no `relationship-crm` reference leaks into generated artifacts — SC-006). Spec artifacts under specs/018-openspec-spec-mode/ included for auditability of the spec → plan → tasks workflow. --- .../checklists/requirements.md | 36 ++ specs/018-openspec-spec-mode/plan.md | 319 ++++++++++++++++++ specs/018-openspec-spec-mode/qa-summary.md | 52 +++ specs/018-openspec-spec-mode/spec.md | 118 +++++++ specs/018-openspec-spec-mode/tasks.md | 112 ++++++ src/__tests__/e2e.test.ts | 2 +- src/__tests__/fixtures.ts | 11 +- src/__tests__/new-command.test.ts | 48 ++- src/commands/add.ts | 29 +- src/commands/new.ts | 53 ++- src/generators/__tests__/openspec.test.ts | 117 +++++++ .../claude-code/__tests__/claude-code.test.ts | 25 ++ src/generators/claude-code/index.ts | 1 + src/generators/codex/__tests__/codex.test.ts | 38 +++ src/generators/codex/index.ts | 1 + src/generators/openspec.ts | 25 ++ src/prompts/project.ts | 57 ++-- src/templates/claude-code/CLAUDE.md.hbs | 27 ++ src/templates/codex/AGENTS.md.hbs | 27 ++ src/types.ts | 3 +- src/utils/detect-project.ts | 9 +- 21 files changed, 1063 insertions(+), 47 deletions(-) create mode 100644 specs/018-openspec-spec-mode/checklists/requirements.md create mode 100644 specs/018-openspec-spec-mode/plan.md create mode 100644 specs/018-openspec-spec-mode/qa-summary.md create mode 100644 specs/018-openspec-spec-mode/spec.md create mode 100644 specs/018-openspec-spec-mode/tasks.md create mode 100644 src/generators/__tests__/openspec.test.ts create mode 100644 src/generators/openspec.ts diff --git a/specs/018-openspec-spec-mode/checklists/requirements.md b/specs/018-openspec-spec-mode/checklists/requirements.md new file mode 100644 index 0000000..34c1d11 --- /dev/null +++ b/specs/018-openspec-spec-mode/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: OpenSpec spec mode integration + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) + - Note: this is a CLI scaffolding feature where the user-visible surface IS the CLI flags, generated file paths, and AI-tool integration names. References to `npx`, `@fission-ai/openspec`, `ProjectConfig`, and `--speckit`/`--openspec` flags are part of the user-facing contract, not internal implementation leakage. +- [x] Focused on user value and business needs (developer experience) +- [x] Written for the target audience (CLI users + ForgeKit maintainers) +- [x] All mandatory sections completed (User Scenarios, Requirements, Success Criteria) + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain (all four design decisions were locked in via Q&A before writing this spec — see qa-summary.md) +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic enough (SC-001 to SC-005 describe observable outcomes, not implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded (out of scope explicitly listed in qa-summary.md) +- [x] Dependencies and assumptions identified (Assumptions section) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (P1: scaffolding success, P1: mutual exclusion, P2: aiTool=none, P2: network failure) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification beyond what the user-facing CLI contract requires + +## Notes + +- Spec passes validation on first iteration; no clarifications needed because the four design decisions were captured before spec drafting. +- Ready to proceed to `/sk.plan`. diff --git a/specs/018-openspec-spec-mode/plan.md b/specs/018-openspec-spec-mode/plan.md new file mode 100644 index 0000000..242bb28 --- /dev/null +++ b/specs/018-openspec-spec-mode/plan.md @@ -0,0 +1,319 @@ +# Implementation Plan: OpenSpec spec mode integration + +**Branch**: `018-openspec-spec-mode` | **Date**: 2026-05-03 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/018-openspec-spec-mode/spec.md` +**Q&A reference**: [qa-summary.md](./qa-summary.md) (5 confirmed decisions, last one revised the field model) + +## Summary + +Extend the existing `WorkflowMode` union from 3 values to 4: `"speckit" | "openspec" | "vibe" | "none"`. Remove the redundant `speckit: boolean` field from `ProjectConfig`. Add a new generator `src/generators/openspec.ts` that bootstraps OpenSpec via `npx --yes @fission-ai/openspec@latest init --tools --force .`, mirroring the silent-skip pattern of the existing `initSpecify` generator. Augment Codex `AGENTS.md.hbs` and Claude Code `CLAUDE.md.hbs` with an OpenSpec documentation block — fully parameterized by `{{name}}` so no project name (e.g. `relationship-crm`) is hard-coded. CLI flags `--speckit`, `--openspec`, and `--workflow ` all map to `workflowMode`; conflicts are rejected at parse time. + +`speckit.config`: `plan-detail=low`, `fast-mode=true` → no `research.md`, `data-model.md`, or `contracts/`. Single `plan.md` only. + +## Technical Context + +**Language/Version**: TypeScript 5.9 / Node ≥20 +**Primary Dependencies**: Commander 14, Inquirer 8, Handlebars 4, fs-extra +**Testing**: Vitest 4, fixtures in `src/__tests__/fixtures.ts` +**Target Platform**: macOS / Linux CLI +**Project Type**: CLI scaffolding tool (single binary) +**External CLI dependencies**: existing `specify` (already wrapped); new `npx @fission-ai/openspec` (no global install required) +**Constraints**: Constitution §1 (one layer per generator), §3 (ProjectConfig single source of truth), §5 (network-failure tolerance), §6 (no premature abstraction), §8 (sync early CLI detection). + +## Constitution Check + +| Principle | Compliance | +|-----------|-----------| +| §1 — One layer per generator | The new `openspec.ts` generator owns `openspec/` + `.codex/skills/openspec-*/` + `.claude/skills/openspec-*/`. Codex generator (writes `AGENTS.md`, `.codex/config.toml`, `.codex/rules/*`) and Claude Code generator (writes `CLAUDE.md`, `.claude/commands/*`, `.claude/hooks/*`) MUST NOT touch the openspec-owned paths. ✅ | +| §2 — Templates have zero logic | Template branching uses precomputed booleans `workflowSpeckit` / `workflowOpenspec` / `workflowVibe` injected by the generators. No nested computation, no Handlebars helpers. ✅ | +| §3 — ProjectConfig is the single source of truth | `workflowMode` is the canonical workflow choice. The legacy `speckit: boolean` field is removed entirely (it was redundant and produced impossible states). ✅ | +| §4 — Fail fast, rollback completely | Mutual-exclusion check on conflicting CLI flags errors before `fs.ensureDir`. The OpenSpec init failure path matches the speckit silent-skip pattern (warn but don't abort) — explicitly authorised by the spec (FR-005, SC-005) and Constitution §5. ✅ | +| §5 — Network failures silent and recoverable | `npx` failure (no network, npm registry down) → warn + continue. Same pattern as `initSpecify`. ✅ | +| §6 — No speculative abstractions | A single union type extension (3→4 values), one new generator file, one new CLI flag. No strategy class, no factory, no spec-mode/workflow-mode duality (initially proposed and rejected — see qa-summary §5). ✅ | +| §7 — Tests declare all fixture fields | `src/__tests__/fixtures.ts` is updated to remove `speckit` and ensure every fixture sets `workflowMode` (no partials). ✅ | +| §8 — CLI detection synchronous and early | `isNpxAvailable()` uses `spawnSync("npx", ["--version"], { stdio: "ignore" })` before the prompt offers OpenSpec. ✅ | +| §9 — Release only through pipeline | No release work in this PR. ✅ | +| §10 — I/O parallelized | Independent file edits run via `Promise.all`-equivalent multi-edit dispatch. The OpenSpec init step is intentionally sequential because it's a child-process bootstrap (must finish before the project tree is "done"). ✅ | + +## Architecture decisions + +### Decision 1 — Single `workflowMode` enum, drop `speckit: boolean` + +The current `ProjectConfig` has both `speckit: boolean` (bootstrap toggle) and `workflowMode: "speckit" | "vibe" | "none"` (documentation flavour). These are not orthogonal — the only sensible combinations are: + +- `speckit=true, workflowMode=speckit` → init speckit + speckit doc +- `speckit=false, workflowMode=vibe` → no init + vibe doc +- `speckit=false, workflowMode=none` → no init + no spec doc + +The other states (`speckit=true, workflowMode=vibe`, etc.) are nonsensical. We collapse them into a single 4-value enum: + +```ts +type WorkflowMode = "speckit" | "openspec" | "vibe" | "none"; +``` + +**Rationale**: matches user's mental model ("workflow mode is one choice"), eliminates impossible states, simplifies the prompt, satisfies Constitution §6. + +### Decision 2 — OpenSpec has no preset + +`speckitPreset` (`rigorous | balanced | fast | bare-metal`) only exists for the speckit workflow. OpenSpec ships with a single default schema (`spec-driven`) and exposes no equivalent dial. The prompt asks for `speckitPreset` only when `workflowMode === "speckit"`. For OpenSpec, we go straight to the bootstrap. + +### Decision 3 — Templates use precomputed `workflowOpenspec` boolean + +Existing templates already use `workflowSpeckit` and `workflowVibe`. We add a parallel `workflowOpenspec`. This preserves the existing pattern (Constitution §2 — zero logic in templates) and is a pure addition with no rename. + +### Decision 4 — `{{name}}`-driven OpenSpec doc block (NO hard-coded project names) + +The new `{{#if workflowOpenspec}}` block in `AGENTS.md.hbs` and `CLAUDE.md.hbs` MUST reference the project via `{{name}}` everywhere a project-specific token is needed. The reference project at `/Users/salimomrani/code/open-spec/relationship-crm` is consulted only for vocabulary and structure inspiration. Per user clarification: "il faut pas relationship-crm — il faut que ça soit dynamique en fonction du nom du projet". + +**Verification gate** (also encoded as **SC-006** in the spec): a `grep -ri "relationship-crm" src/templates/` after implementation must return zero matches. + +### Decision 5 — CLI flag mutual exclusion at parse time + +`--speckit`, `--openspec`, and `--workflow ` all map to the same field. Conflicts are detected before any prompt or filesystem work: if two flags resolve to different `workflowMode` values, we error out with a clear message and exit non-zero. + +## File-by-file changes + +### NEW files + +**`src/generators/openspec.ts`** (≈30 LOC, mirror of `speckit.ts`) +```ts +import { spawnSync } from "node:child_process"; +import type { AITool } from "../types.js"; + +export function isNpxAvailable(): boolean { + return spawnSync("npx", ["--version"], { stdio: "ignore" }).status === 0; +} + +export function initOpenspec(projectDir: string, aiTool: AITool): boolean { + if (aiTool === "none") return false; + if (!isNpxAvailable()) return false; + const result = spawnSync( + "npx", + ["--yes", "@fission-ai/openspec@latest", "init", "--tools", aiTool, "--force", "."], + { cwd: projectDir, stdio: "inherit" }, + ); + return result.status === 0; +} +``` + +**`src/generators/__tests__/openspec.test.ts`** — covers: +- `aiTool=none` → returns `false`, no spawn +- `aiTool=codex` + `npx` available → constructs argv `["--yes", "@fission-ai/openspec@latest", "init", "--tools", "codex", "--force", "."]` +- `aiTool=claude` + spawnSync exits non-zero → returns `false`, does not throw +- `npx` unavailable → returns `false`, no spawn + +### MODIFIED files + +**`src/types.ts`** +- Extend `WorkflowMode` from 3 to 4 values: `"speckit" | "openspec" | "vibe" | "none"`. +- Remove `speckit: boolean;` field from `ProjectConfig`. Keep `speckitPreset: SpeckitPreset | null;` (only meaningful when `workflowMode === "speckit"`). +- Note: `SavedConfig` (only `groupId?`) needs no migration. ✅ + +**`src/prompts/project.ts`** (lines 295-353, 379-396, 463) +- Remove `let speckit = defaults.speckit ?? true;` (line 295) and the entire speckit checkbox entry from the infrastructure prompt (lines 326-331). +- Promote the workflow-mode `select` from a conditional add-on to the canonical workflow gate, with 4 options: + - `speckit — spec → plan → tasks → impl → review → PR` (hidden when `aiTool === "none"`) + - `openspec — proposal → specs → design → tasks → apply → archive` (hidden when `aiTool === "none"`) + - `vibe — itérations rapides, pas de spec` + - `aucun` +- Detection helpers in the choice labels: `isSpecifyInstalled()` (existing) for the speckit row, new `isNpxAvailable()` (imported from `openspec.ts`) for the openspec row. If detection fails, append `(npx CLI non détecté)` etc. to the label, like the existing speckit pattern. +- `speckitPreset` prompt block (lines 398-427) keeps its current gate `workflowMode === "speckit"` — already correct, no change needed. +- Return object: replace `speckit,` with nothing (field is gone). `workflowMode` is already returned. + +**`src/commands/new.ts`** +- Add CLI option `.option("--openspec", "Bootstrap OpenSpec spec workflow")`. Add `.option("--speckit", "Bootstrap Speckit workflow")` for parity (currently only `--workflow speckit` exists). +- Mutual-exclusion gate in the `action` callback, BEFORE `promptProjectConfig` and BEFORE `fs.ensureDir`: + ```ts + const flagModes: WorkflowMode[] = []; + if (options.speckit) flagModes.push("speckit"); + if (options.openspec) flagModes.push("openspec"); + if (typeof options.workflow === "string") flagModes.push(options.workflow as WorkflowMode); + const distinct = [...new Set(flagModes)]; + if (distinct.length > 1) { + console.log(chalk.red(`\n✖ Conflicting workflow flags: ${distinct.join(", ")}. Pick one.`)); + process.exit(1); + } + if (distinct.length === 1) defaults.workflowMode = distinct[0]; + ``` +- Replace the dispatch around line 145 (`if (config.speckit && config.aiTool !== "none")`) with a switch on `workflowMode`: + ```ts + if (config.aiTool !== "none") { + if (config.workflowMode === "speckit") { + process.stdout.write(chalk.yellow(" ⏳ Speckit...")); + initSpecify(projectDir, config.aiTool); + console.log(chalk.green("\r ✔ Speckit initialisé ")); + } else if (config.workflowMode === "openspec") { + process.stdout.write(chalk.yellow(" ⏳ OpenSpec...")); + const ok = initOpenspec(projectDir, config.aiTool); + if (!ok) { + console.log(chalk.yellow( + `\r ⚠ OpenSpec init skipped — run manually: cd ${config.name} && npx --yes @fission-ai/openspec@latest init --tools ${config.aiTool} --force .` + )); + } else { + console.log(chalk.green("\r ✔ OpenSpec initialisé ")); + } + } + } + ``` +- Add `import { initOpenspec } from "../generators/openspec.js";` at the top. + +**`src/generators/codex/index.ts`** (line 31-53 — data object) +- Add to `data`: + ```ts + workflowOpenspec: this.config.workflowMode === "openspec", + ``` + (alongside the existing `workflowSpeckit` and `workflowVibe`). +- Constitution §1 check: NO writes to `openspec/` or `.codex/skills/openspec-*/`. Verified — codex generator only writes `AGENTS.md`, `.codex/config.toml`, and `.codex/rules/*.md`. + +**`src/generators/claude-code/index.ts`** (line 142, 306, etc.) +- Add `workflowOpenspec: this.config.workflowMode === "openspec"` to the template data object. +- Verify the speckit-preset gate at line 306 (`if (this.config.workflowMode !== "speckit")`) is unchanged — it correctly already prevents preset wiring for openspec/vibe/none. +- Constitution §1 check: this generator writes to `.claude/commands/`, `.claude/hooks/`, `.claude/skills//` — NOT to `.claude/skills/openspec-*/`. Verified by `grep -n "openspec" src/generators/claude-code/index.ts` (zero hits expected). + +**`src/templates/codex/AGENTS.md.hbs`** (lines 5-10 currently) +- Replace: + ```hbs + {{#if workflowSpeckit}} + ## Workflow Mode: speckit + {{/if}} + {{#if workflowVibe}} + ## Workflow Mode: vibe + {{/if}} + ``` + with: + ```hbs + {{#if workflowSpeckit}} + ## Workflow Mode: speckit + {{/if}} + {{#if workflowOpenspec}} + ## Workflow Mode: openspec + {{/if}} + {{#if workflowVibe}} + ## Workflow Mode: vibe + {{/if}} + ``` + (purely additive — no rename, no break for existing templates that branch on the legacy variables). +- Append a new conditional block (placement: after the existing Git section, before stack-specific sections): + ```hbs + {{#if workflowOpenspec}} + ## OpenSpec workflow + + This project uses **OpenSpec** for spec-driven development. The change lifecycle is: + + proposal → specs → design → tasks → apply → archive + + Native slash commands (provided by `.codex/skills/openspec-*/`): + + - `/opsx:propose ` — start a new change with proposal + design + specs + tasks generated in one step + - `/opsx:explore` — thinking-partner mode for clarifying requirements before proposing + - `/opsx:apply` — execute the tasks in the active change + - `/opsx:archive` — finalize and archive a completed change + + Artifact locations (relative to the `{{name}}` project root): + + - `openspec/specs//spec.md` — capability specifications (single source of truth) + - `openspec/changes//` — in-progress change proposals (proposal.md + design.md + tasks.md) + - `openspec/changes/archive/` — completed changes + - `openspec/config.yaml` — project schema and rules + + Run `openspec --help` for the full CLI reference. + {{/if}} + ``` +- **Constraint**: NO mention of `relationship-crm` or any other concrete project name. The block uses only generic OpenSpec vocabulary plus relative paths plus `{{name}}`. + +**`src/templates/claude-code/CLAUDE.md.hbs`** +- Same pattern as the codex AGENTS.md.hbs change. Skills install path is `.claude/skills/openspec-*/` (single sentence difference). Otherwise identical content. + +**`src/__tests__/fixtures.ts`** (line 27-30) +- Remove `speckit: false,` from the base fixture. +- Verify `workflowMode` is set on every fixture (existing fixtures already include it via `workflowMode: "none"` etc. — audit pass). +- Add a `withOpenSpec()` variant or extend the existing fixture so at least one test path exercises `workflowMode: "openspec"`. + +### EXISTING tests to update + +- `src/generators/__tests__/codex.test.ts` — drop any `speckit:` reference, add a test asserting the `workflowOpenspec` block renders only when `workflowMode === "openspec"`. +- `src/generators/__tests__/speckit.test.ts` — currently tests `initSpecify`; update fixtures to drop `speckit:` boolean. +- `src/generators/claude-code/__tests__/claude-code.test.ts` — same updates as codex test file. +- `src/__tests__/e2e.test.ts`, `src/__tests__/e2e-npm-install.test.ts`, `src/__tests__/add-command.test.ts` — drop `speckit:` references; ensure `workflowMode` is set. + +## Sequencing + +``` +Phase 1 (sequential — type system foundation) + └─ Update src/types.ts (extend WorkflowMode, remove speckit boolean) + └─ Update src/__tests__/fixtures.ts (so other phases compile) + +Phase 2 (parallel — generator + templates, both depend only on Phase 1) + ├─ NEW src/generators/openspec.ts + ├─ MODIFY src/templates/codex/AGENTS.md.hbs + └─ MODIFY src/templates/claude-code/CLAUDE.md.hbs + +Phase 3 (parallel — generator data wiring, depends on Phase 1 + 2) + ├─ MODIFY src/generators/codex/index.ts (data object) + └─ MODIFY src/generators/claude-code/index.ts (data object) + +Phase 4 (sequential — orchestration, depends on Phase 1-3) + └─ MODIFY src/prompts/project.ts (4-value workflowMode prompt, drop speckit checkbox) + └─ MODIFY src/commands/new.ts (CLI flags + dispatch + mutual-exclusion gate) + +Phase 5 (parallel — tests, depend on all preceding phases) + ├─ NEW src/generators/__tests__/openspec.test.ts + ├─ UPDATE src/generators/__tests__/codex.test.ts + ├─ UPDATE src/generators/__tests__/speckit.test.ts + ├─ UPDATE src/generators/claude-code/__tests__/claude-code.test.ts + └─ UPDATE src/__tests__/{e2e,e2e-npm-install,add-command}.test.ts + +Phase 6 (sequential — verification) + └─ npm run typecheck && npm run lint (cfg.verification=minimal) + └─ grep -ri "relationship-crm" src/ (must return zero — Decision 4 invariant / SC-006) +``` + +Per Constitution §10, edits within a phase run in parallel via multi-tool dispatch. Phases 3, 4, 5 likewise. + +## Test strategy + +`speckit.config: tests=true, tdd=false, test-types=unit, verification=minimal`. Tests are written AFTER implementation (TDD off) and scoped to unit-level (no e2e additions in this PR). + +**New tests** (one file): see `openspec.test.ts` cases above. + +**Updated tests** (assertions to add): +- Codex test: rendered AGENTS.md contains `Workflow Mode: openspec` when `workflowMode=openspec`; contains the OpenSpec workflow section; contains `{{name}}` substituted (no hard-coded names). +- Codex test: rendered AGENTS.md contains `Workflow Mode: speckit` when `workflowMode=speckit`; does NOT contain the OpenSpec section. +- Claude Code test: same dual coverage on CLAUDE.md. +- Snapshot regression for `workflowMode=none` AGENTS.md (verifies the bootstrap-skip path). + +**Verification command** (matches `cfg.verification=minimal`): `npm run typecheck && npm run lint`. Full test suite runs once at end of Phase 6. + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|-----------| +| OpenSpec CLI writes to `.codex/skills/` and `.claude/skills/` — could collide with codex/claude-code generators | Confirmed by inspection: codex generator writes ONLY `.codex/config.toml`, `.codex/rules/*`, and root-level `AGENTS.md`. The claude-code generator writes to `.claude/commands/`, `.claude/hooks/`, `.claude/skills//` but NEVER to `.claude/skills/openspec-*/`. No collision. Documented in Constitution §1 check above. | +| `npx` first-run downloads ~1MB and takes 5–10s on cold cache — slows down `forgekit new` perceptibly | Acceptable: `forgekit new` is a one-off command. The user already accepts speckit's `specify init` doing similar work. Document in the spinner status line. | +| `@fission-ai/openspec@latest` — pinning to `@latest` means scaffold output drifts as upstream releases | Acceptable for now. If this becomes a stability issue, the generator can pin to a known-good major (e.g. `@1`) — single-line change. Out of scope per qa-summary §"Out of scope". | +| Template back-compat: existing `{{#if workflowSpeckit}}` blocks in OTHER templates (root/, frontend/, backend/) might still reference the legacy variable | Audit pass: `grep -rn "workflowSpeckit\|workflowVibe" src/templates/` — confirmed only `codex/AGENTS.md.hbs` and `claude-code/CLAUDE.md.hbs` use these. No other touch points. | +| `--speckit` was not previously a CLI flag (only `--workflow speckit` existed) | Adding it now as a new boolean flag is purely additive — existing CI scripts using `--workflow speckit` continue to work, and the new `--speckit` shortcut maps to the same `workflowMode=speckit` value. No back-compat break. | +| Existing users with the legacy `speckit: true` field in their `.forgekit.json` manifest | The manifest is read-only legacy data; ForgeKit does not consume it back to drive future commands. No migration needed. | + +## Migration plan for ProjectConfig + +- The `speckit: boolean` field is removed from `ProjectConfig`. Every call site is updated in this PR (Phase 1 + 3 + 4). +- The persisted `SavedConfig` (`~/.forgekitrc.json`) holds only `{ groupId?: string }` (verified at `src/types.ts:48-50`). No migration script needed. +- The forgekit manifest (`.forgekit.json` written by `writeManifest`) embeds the full `ProjectConfig`. Existing manifests on user disks will have `speckit: ` and lack the new `workflowMode=openspec` literal. This is read-only legacy data — ForgeKit does not read it back to drive future commands. No migration needed. + +## Out of scope (carried from qa-summary.md) + +- Copying CRM-specific skills (`relationship-crm-*`) from the reference project. +- Supporting OpenSpec custom schemas (we use the default `spec-driven` schema only). +- Mixing speckit + openspec in the same project (mutually exclusive by construction). +- Pinning the `@fission-ai/openspec` major version (deferred until upstream stability becomes an issue). +- Back-compat reading of legacy `.forgekit.json` manifests with the old `speckit` boolean. + +## Acceptance gate (before `sk:tasks`) + +- [ ] `WorkflowMode` extended to 4 values; `speckit` boolean removed from `ProjectConfig`. +- [ ] OpenSpec generator implemented with the 4 unit-test cases listed. +- [ ] Templates rendered with `Workflow Mode: openspec` and a `{{#if workflowOpenspec}}` block free of hard-coded project names. +- [ ] CLI `--speckit`, `--openspec`, and `--workflow ` wired with mutual-exclusion check. +- [ ] All existing tests pass after fixture migration. +- [ ] `grep -ri "relationship-crm" src/` returns zero hits (Decision 4 invariant / SC-006). diff --git a/specs/018-openspec-spec-mode/qa-summary.md b/specs/018-openspec-spec-mode/qa-summary.md new file mode 100644 index 0000000..96a9b5b --- /dev/null +++ b/specs/018-openspec-spec-mode/qa-summary.md @@ -0,0 +1,52 @@ +# Q&A Summary — OpenSpec spec mode + +User confirmed the following design decisions via AskUserQuestion before this spec was opened. + +## Decision 1 — Scope: Init + AGENTS.md augmenté + +ForgeKit will run `openspec init` natively (CLI provides 4 default skills + `openspec/config.yaml`) AND augment the generated `AGENTS.md` (Codex) and `CLAUDE.md` (Claude Code) with a documented OpenSpec workflow section. + +**Out of scope:** copying CRM-specific skills (`relationship-crm-*`) from the reference project. Those are project-domain skills, not reusable scaffolding. + +## Decision 2 — Workflow: speckit and openspec are mutually exclusive + +A single spec mode is active per project. The existing `speckit: boolean` is replaced by `specMode: "speckit" | "openspec" | "none"`. + +**Rationale:** speckit and openspec are two competing philosophies for the same problem (spec-driven development). Letting both coexist would create conflicting `.codex/skills/` and `.claude/skills/` subtrees. One source of truth. + +## Decision 3 — CLI bootstrap: auto-install via `npx` + +OpenSpec is invoked through `npx --yes @fission-ai/openspec@latest init --tools --force .`. No prerequisite global install. + +**Rationale:** consistency with how scaffolding tools should "just work" out of the box, no preflight install step required from the user. Aligns with constitution §5 (network-failure tolerance: if npx fails, fall back to a warning, do not abort the whole project — same skip pattern as `initSpecify`). + +## Decision 4 — `aiTool=none` hides OpenSpec + +When the user picks `aiTool=none`, the OpenSpec option is removed from the spec-mode prompt entirely. OpenSpec without an AI tool integration produces only `openspec/config.yaml` with no skills, which is not useful. + +**Rationale:** mirrors the existing speckit behaviour (`initSpecify` already returns early when `aiTool === "none"`). + +## Constraints from the user message (load-bearing) + +- Constitution §1: the new `openspec.ts` generator owns `openspec/` and the `.codex/skills/openspec-*` / `.claude/skills/openspec-*` subtrees that the OpenSpec CLI creates. The codex/claude-code generators must NOT touch those. +- Constitution §3: `specMode` lives in `ProjectConfig` and drives all downstream behaviour. +- Constitution §6: `specMode` is a 3-value union, not a class hierarchy or strategy pattern. +- Constitution §8: CLI detection is synchronous and early via `spawnSync` with `stdio: "ignore"` and `--help` (npx is the gate here, not `openspec` directly). +- Backwards compat: existing CLI flag `--speckit` maps to `specMode=speckit`. New flag `--openspec` maps to `specMode=openspec`. + +## Decision 5 — `workflowMode` unifié, pas de champ `specMode` séparé (clarifié post-plan) + +The original plan introduced a new `specMode` field alongside the existing `workflowMode`. The user explicitly rejected this: workflow mode must be a single 4-value enum. + +Final model: + +- `workflowMode: "speckit" | "openspec" | "vibe" | "none"` — single source of truth. +- `speckit: boolean` is removed entirely (was redundant with `workflowMode === "speckit"`). +- `speckitPreset` is asked only when `workflowMode === "speckit"`. OpenSpec has no preset. +- `gitStrategy` is asked only when `workflowMode === "vibe"` (existing behaviour, unchanged). + +Rationale: cleaner per Constitution §6, eliminates impossible states (e.g. legacy `speckit: true` + `workflowMode: "vibe"`), and matches the user's mental model that "workflow mode is a single choice". + +## Workflow choice + +User explicitly asked for the full speckit.workflow (specify → plan → tasks → analyze) and to **stop before implementation**. No `sk:implement` invocation. diff --git a/specs/018-openspec-spec-mode/spec.md b/specs/018-openspec-spec-mode/spec.md new file mode 100644 index 0000000..5bc4eb8 --- /dev/null +++ b/specs/018-openspec-spec-mode/spec.md @@ -0,0 +1,118 @@ +# Feature Specification: OpenSpec spec mode integration + +**Feature Branch**: `018-openspec-spec-mode` +**Created**: 2026-05-03 +**Status**: Draft +**Input**: User description: "Add OpenSpec as an alternative spec mode to ForgeKit, mutually exclusive with the existing speckit mode." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Scaffolding with OpenSpec selected (Priority: P1) + +A developer running `forgekit new` chooses an AI tool (Claude or Codex) and selects **openspec** as their workflow mode. After the run completes, their new project contains a working OpenSpec setup ready for the first `/opsx:propose` command, with no manual install or configuration steps. + +**Why this priority**: This is the core value the feature delivers — making OpenSpec a first-class peer of speckit in ForgeKit's workflow-mode choice. Without it the feature does not exist. + +**Independent Test**: Run `forgekit new sample --ai-tool codex --openspec`. Confirm the generated project contains `openspec/config.yaml`, the four native OpenSpec skills under `.codex/skills/openspec-*/`, and an `AGENTS.md` documenting the OpenSpec workflow. No prior `npm i -g @fission-ai/openspec` should have been required. + +**Acceptance Scenarios**: + +1. **Given** a developer with no global `openspec` install, **When** they run `forgekit new` and pick `aiTool=codex` + `workflowMode=openspec`, **Then** the generated project contains `openspec/config.yaml` and at least one `.codex/skills/openspec-*/SKILL.md` file. +2. **Given** the same scenario, **When** the generation finishes, **Then** the printed AGENTS.md contains a "Workflow Mode: openspec" indicator and a documented list of the four `/opsx:*` slash commands. +3. **Given** the user picks `aiTool=claude` + `workflowMode=openspec`, **When** generation finishes, **Then** OpenSpec skills land under `.claude/skills/openspec-*/` and CLAUDE.md documents the OpenSpec workflow. + +--- + +### User Story 2 — Single-choice workflow mode (Priority: P1) + +A developer cannot accidentally end up with two workflow modes active at once. The interactive prompt offers a single `workflowMode` choice across speckit / openspec / vibe / none. The CLI flags `--speckit`, `--openspec`, and `--workflow ` are mutually exclusive when they would resolve to different values. + +**Why this priority**: Allowing two workflow modes would create conflicting `.codex/skills/` and `.claude/skills/` subtrees, contradictory documentation in `AGENTS.md` / `CLAUDE.md`, and confuse downstream automation. This is a correctness invariant, not a nice-to-have. + +**Independent Test**: Run `forgekit new sample --ai-tool codex --speckit --openspec` — the CLI rejects the combination with a clear error message and exits non-zero. + +**Acceptance Scenarios**: + +1. **Given** the interactive prompt, **When** the user reaches the workflow-mode question, **Then** they see exactly four options: speckit, openspec, vibe, none. +2. **Given** both `--speckit` and `--openspec` flags are passed on the command line, **When** ForgeKit parses the arguments, **Then** it errors out before any project files are written. +3. **Given** a project generated with `workflowMode=openspec`, **When** the user inspects the project, **Then** no `.specify/` directory exists and no speckit-specific commands have been copied. +4. **Given** a project generated with `workflowMode=vibe`, **When** the user inspects the project, **Then** neither `openspec/` nor `.specify/` directories exist (vibe = no spec scaffolding at all). + +--- + +### User Story 3 — OpenSpec hidden when no AI tool (Priority: P2) + +When a developer chooses `aiTool=none`, both the `openspec` and `speckit` choices are removed from the workflow-mode prompt (only `vibe` and `none` remain). OpenSpec without an AI tool integration produces only an empty `openspec/config.yaml` with no skills, which is not useful and would mislead the user. Same rationale for speckit. + +**Why this priority**: Mirrors the existing speckit-when-aiTool=none behaviour. Prevents a degenerate combination from reaching the user without explicitly forbidding it in code (the prompt simply does not surface it). + +**Independent Test**: Run the interactive prompt with `aiTool=none` selected. Confirm the workflow-mode question offers only `vibe` and `none`. + +**Acceptance Scenarios**: + +1. **Given** the interactive prompt with `aiTool=none`, **When** the user reaches the workflow-mode question, **Then** neither `speckit` nor `openspec` are listed as choices. +2. **Given** the CLI flag `--ai-tool none --openspec`, **When** ForgeKit parses arguments, **Then** it warns that OpenSpec requires an AI tool and falls back to `workflowMode=none` (no project abort). + +--- + +### User Story 4 — Resilient bootstrap (Priority: P2) + +If the network is offline or the OpenSpec npm package cannot be fetched at scaffold time, the user still gets a usable project. ForgeKit logs a clear warning and continues — it does not delete the project or abort the run. + +**Why this priority**: Constitution §5 (network failures are silent and recoverable). Matches the existing speckit fallback behaviour. Without it, users behind corporate proxies or on flaky connections cannot use ForgeKit. + +**Independent Test**: Run `forgekit new` with networking disabled and `workflowMode=openspec`. The project still completes, with a warning that the OpenSpec init step was skipped and instructions on how to run it manually later. + +**Acceptance Scenarios**: + +1. **Given** `npx @fission-ai/openspec init` fails (any non-zero exit, network error, etc.), **When** the openspec generator runs, **Then** ForgeKit logs a warning, leaves the rest of the project intact, and exits successfully. +2. **Given** the warning is logged, **When** the user reads the output, **Then** they see the exact command needed to retry the init manually inside the project. + +--- + +### Edge Cases + +- A developer passes `--speckit-preset balanced` together with `--openspec`. The CLI rejects this — `speckitPreset` is only meaningful when `workflowMode=speckit`. +- A developer regenerates an existing project. The OpenSpec generator runs `init --force` so it will overwrite previously generated OpenSpec files; this is the documented behaviour of the underlying CLI. +- A developer picks `workflowMode=openspec` but the `aiTool` they select is not in OpenSpec's supported list (currently OpenSpec supports both `claude` and `codex`, so this should not happen with current ForgeKit options — but the generator must surface a clear warning if a future ForgeKit AI tool is added without the corresponding OpenSpec mapping). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: ForgeKit MUST extend the existing `WorkflowMode` union to four values: `"speckit" | "openspec" | "vibe" | "none"`. The legacy `speckit: boolean` field MUST be removed from `ProjectConfig` (it was redundant with `workflowMode === "speckit"` and produced impossible states). +- **FR-002**: The interactive prompt MUST offer all four workflow-mode choices only when an AI tool is selected (`aiTool ≠ none`); when `aiTool=none`, the prompt MUST hide the `speckit` and `openspec` choices and only allow `vibe` or `none`. +- **FR-003**: ForgeKit MUST only ask for `speckitPreset` when `workflowMode=speckit`; selecting OpenSpec, vibe, or none MUST leave `speckitPreset` as `null`. OpenSpec has no equivalent preset to ask for. +- **FR-004**: When `workflowMode=openspec`, ForgeKit MUST invoke `npx --yes @fission-ai/openspec@latest init --tools --force .` synchronously inside the freshly generated project directory. +- **FR-005**: The OpenSpec init step MUST follow the same silent-skip pattern as the existing speckit generator: a non-zero exit code or missing CLI MUST log a warning and allow the rest of the project generation to complete successfully. +- **FR-006**: ForgeKit MUST detect availability of the `npx` runner synchronously and early using `spawnSync` with `stdio: "ignore"` and a `--version` (or equivalent lightweight) probe before attempting the init invocation. +- **FR-007**: The generated `AGENTS.md` (Codex) and `CLAUDE.md` (Claude Code) MUST contain a `Workflow Mode: ` line driven by `workflowMode`, extending the existing `Workflow Mode:` heuristic to cover the new `openspec` value. +- **FR-008**: When `workflowMode=openspec`, the generated `AGENTS.md` and `CLAUDE.md` MUST include a documented OpenSpec workflow section listing: the proposal → specs → design → tasks → apply → archive flow, the four native slash commands (`/opsx:propose`, `/opsx:explore`, `/opsx:apply`, `/opsx:archive`), and the artifact locations (`openspec/specs/`, `openspec/changes/`, `openspec/config.yaml`). Project-specific tokens MUST be parameterized via `{{name}}`; no project name (e.g. the reference project's name) MUST be hard-coded in the templates. +- **FR-009**: The CLI MUST accept the new `--openspec` flag mapping to `workflowMode=openspec`, continue to accept `--workflow ` for the full enum, and add `--speckit` as a shortcut mapping to `workflowMode=speckit`. Passing two of these that resolve to different values MUST fail with a non-zero exit and a clear error message. +- **FR-010**: The codex and claude-code generators MUST NOT write to `openspec/`, `.codex/skills/openspec-*`, or `.claude/skills/openspec-*` (Constitution §1 — those paths are owned exclusively by the new openspec generator). +- **FR-011**: ForgeKit MUST regenerate fixtures (`src/__tests__/fixtures.ts`) and update every existing test so that they no longer reference the removed `speckit` boolean field; the new `workflowMode=openspec` value MUST be exercised by at least one fixture. +- **FR-012**: A new test file `src/generators/__tests__/openspec.test.ts` MUST cover at minimum: the spawn command shape, the early-skip path when `aiTool=none`, and the warning-not-abort behaviour when the npx invocation fails. + +### Key Entities *(included because the feature touches typed configuration)* + +- **ProjectConfig.workflowMode**: A 4-value union `"speckit" | "openspec" | "vibe" | "none"` carried through the prompt → generator pipeline. Extends the previous 3-value union and absorbs the responsibilities of the removed `speckit: boolean` field. +- **ProjectConfig.speckitPreset**: Continues to exist but is meaningful only when `workflowMode=speckit`; `null` for OpenSpec, vibe, and none. +- **OpenSpec generator output**: `openspec/config.yaml` plus four `SKILL.md` files under `.codex/skills/openspec-*/` (Codex) or `.claude/skills/openspec-*/` (Claude Code), all created by the upstream `@fission-ai/openspec` CLI rather than by ForgeKit templates. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A developer scaffolding a new Codex-based project with `workflowMode=openspec` reaches a working `/opsx:propose` command in zero manual steps after `forgekit new` completes. +- **SC-002**: 100% of generated projects with `workflowMode=speckit` continue to behave exactly as they did before the feature (no regression in the existing speckit path). +- **SC-003**: 100% of generated projects with `workflowMode=none` (or `workflowMode=vibe`) contain neither `openspec/` nor `.specify/` directories. +- **SC-004**: A developer running `forgekit new` with both `--speckit` and `--openspec` sees a clear, immediately actionable error message and the project directory is not created. +- **SC-005**: When the OpenSpec npx invocation fails (offline, npm registry down, etc.), the rest of the generated project is intact and a single warning line points the user to the manual recovery command. +- **SC-006**: A grep for `relationship-crm` (the reference project's name) across `src/templates/` after this feature ships returns zero matches, proving the OpenSpec doc block uses only `{{name}}` and generic vocabulary. + +## Assumptions + +- OpenSpec's `@fission-ai/openspec` package remains available on the public npm registry under that name. If renamed or scoped differently in the future, the generator can be updated in a single place (`src/generators/openspec.ts`). +- OpenSpec supports both `claude` and `codex` as `--tools` values (verified against `openspec init --help` output). If a new ForgeKit AI tool is added later without an OpenSpec mapping, the generator must surface the gap as a warning rather than silently produce a broken project. +- Developers who currently rely on the legacy `--speckit` flag in CI scripts continue to work unchanged. They migrate to `--openspec` only if and when they want the new mode. +- The reference project at `/Users/salimomrani/code/open-spec/relationship-crm` is consulted only as documentation inspiration for the AGENTS.md / CLAUDE.md OpenSpec section; no skill files or schema files from it are copied into ForgeKit templates. diff --git a/specs/018-openspec-spec-mode/tasks.md b/specs/018-openspec-spec-mode/tasks.md new file mode 100644 index 0000000..8724a63 --- /dev/null +++ b/specs/018-openspec-spec-mode/tasks.md @@ -0,0 +1,112 @@ +# Tasks: OpenSpec spec mode integration + +**Feature**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) | **Q&A**: [qa-summary.md](./qa-summary.md) +**Branch**: `018-openspec-spec-mode` +**Generated**: 2026-05-03 + +## Execution policy + +`speckit.config`: `tests=true`, `tdd=false`, `code-review=false`, `security-review=auto`, `verification=minimal`, `fast-mode=true`, `subagents=false`. + +- Tests come **after** the implementation (fast-mode + tdd=false). Phase 5 covers all test work in one parallelizable batch. +- Verification is **minimal** (typecheck + lint + a single grep gate). No full test suite run inside this PR's task loop unless a task explicitly requests it. +- **The user explicitly asked to STOP after `sk:tasks` + `sk:analyze`. Do NOT invoke `sk:implement` from this list.** The task list is the deliverable for this round. + +## Conventions + +- `[P]` = parallelizable with sibling tasks (disjoint files, no dependency on an unfinished task in the same phase). +- Each task references an exact file path. +- Tasks within a phase are listed in execution order; phases run sequentially unless noted. + +--- + +## Phase 1 — Types & fixtures *(sequential, blocks everything else)* + +- [x] T001 Extend `WorkflowMode` to 4 values (`"speckit" | "openspec" | "vibe" | "none"`) and remove the `speckit: boolean` field from `ProjectConfig` in `src/types.ts` (also drop any unused imports left behind). +- [x] T002 Update the base fixture in `src/__tests__/fixtures.ts`: drop the `speckit:` line, ensure `workflowMode` is set on every fixture, add a `withOpenSpec()` (or equivalent) variant that sets `workflowMode: "openspec"` for downstream test reuse. + +> ℹ️ **Note**: Phase 1 intentionally removes the `speckit` field from the type but leaves call sites (`config.speckit`, `defaults.speckit`, `options.speckit`) as compile errors. Phase 4 (T009/T013) is responsible for cleaning up those references. Do NOT run `npm run typecheck` between Phase 1 and Phase 4 — it will fail by design. The final sweep + typecheck happens in Phase 6 (T019a, see below). + +## Phase 2 — Generator + templates *(parallelizable, depends on Phase 1)* + +- [x] T004 [P] Create `src/generators/openspec.ts` with two exports: `isNpxAvailable(): boolean` (uses `spawnSync("npx", ["--version"], { stdio: "ignore" })`) and `initOpenspec(projectDir, aiTool): boolean` (early-return `false` when `aiTool === "none"` or npx is missing; otherwise spawn `npx --yes @fission-ai/openspec@latest init --tools --force .` with `cwd: projectDir`, `stdio: "inherit"`; return `result.status === 0`). Mirror the shape of `src/generators/speckit.ts`. +- [x] T005 [P] In `src/templates/codex/AGENTS.md.hbs`, add a `{{#if workflowOpenspec}} ## Workflow Mode: openspec {{/if}}` block alongside the existing `workflowSpeckit` and `workflowVibe` blocks. Then append a new `{{#if workflowOpenspec}} ## OpenSpec workflow ... {{/if}}` documentation section with: lifecycle (proposal → specs → design → tasks → apply → archive), the four `/opsx:*` slash commands, artifact locations under `openspec/`, and a `{{name}}`-parameterised path reference. **No hard-coded project names** — only `{{name}}` plus generic OpenSpec vocabulary. +- [x] T006 [P] In `src/templates/claude-code/CLAUDE.md.hbs`, apply the same pattern as T005 with one wording difference: skills install path is `.claude/skills/openspec-*/` instead of `.codex/skills/openspec-*/`. Same `{{name}}` constraint — zero hard-coded project names. + +## Phase 3 — Generator data wiring *(parallelizable, depends on Phases 1-2)* + +- [x] T007 [P] In `src/generators/codex/index.ts` (around line 49 in the `data` object), add `workflowOpenspec: this.config.workflowMode === "openspec"` next to the existing `workflowSpeckit` / `workflowVibe` flags. Verify by inspection that this generator still does NOT write to `openspec/` or `.codex/skills/openspec-*/` (Constitution §1). +- [x] T008 [P] In `src/generators/claude-code/index.ts` (around line 142 and the related render call), add `workflowOpenspec: this.config.workflowMode === "openspec"` to the template data object. Confirm the existing speckit-preset gate at line 306 (`if (this.config.workflowMode !== "speckit") return;`) is unchanged — it correctly skips openspec/vibe/none. Verify with `grep -n "openspec" src/generators/claude-code/index.ts` (zero hits expected — generator does NOT touch openspec output paths). + +## Phase 4 — Prompts & CLI orchestration *(sequential, depends on Phases 1-3)* + +- [x] T009 In `src/prompts/project.ts`, remove the legacy `speckit` toggle: drop `let speckit = defaults.speckit ?? true;` (around line 295), remove the `speckit` checkbox entry from the infrastructure prompt (around lines 326-331), and remove the `speckit` field from the returned object (line 463). Also drop the dependency on `defaults.speckit` (line 305) from the conditional gate. +- [x] T010 In `src/prompts/project.ts` (around lines 379-396), promote the workflow-mode `select` to the canonical workflow gate. Add the new `openspec` choice with detection helper `isNpxAvailable()` (imported from the new `openspec.ts`) for its label decoration, mirroring the existing `isSpecifyInstalled()` pattern for the speckit row. When `aiTool === "none"`, hide both `speckit` and `openspec` choices and only allow `vibe` / `none`. Confirm the `speckitPreset` gate (around line 402) still correctly checks `workflowMode === "speckit"`. +- [x] T011 In `src/commands/new.ts`, add `import { initOpenspec } from "../generators/openspec.js";` and the new CLI flags: `.option("--openspec", "Bootstrap OpenSpec spec workflow")` and `.option("--speckit", "Bootstrap Speckit workflow")` for parity with `--workflow `. Also update the existing `.option("--workflow ", ...)` description string from `"speckit | vibe | none"` to `"speckit | openspec | vibe | none"` so the help output stays accurate. +- [x] T012 In `src/commands/new.ts`, add the mutual-exclusion gate **before** `promptProjectConfig` and **before** `fs.ensureDir`: collect the `WorkflowMode` value(s) implied by `--speckit`, `--openspec`, and `--workflow`; if they resolve to two distinct values, log a clear error in red and `process.exit(1)`; otherwise set `defaults.workflowMode` to the single resolved value (if any). +- [x] T013 In `src/commands/new.ts` (around line 145), replace the legacy `if (config.speckit && config.aiTool !== "none")` dispatch with a switch on `workflowMode`: route `"speckit"` to `initSpecify`, route `"openspec"` to `initOpenspec` with a recovery hint logged on failure (`cd && npx --yes @fission-ai/openspec@latest init --tools --force .`), and skip the spec bootstrap entirely for `"vibe"` / `"none"`. + +## Phase 5 — Tests *(parallelizable, depends on all preceding phases)* + +- [x] T014 [P] Create `src/generators/__tests__/openspec.test.ts` covering: (a) `aiTool="none"` → returns `false` with no spawn, (b) `aiTool="codex"` constructs argv `["--yes", "@fission-ai/openspec@latest", "init", "--tools", "codex", "--force", "."]`, (c) spawnSync exits non-zero → returns `false` (no throw), (d) `npx` unavailable (`isNpxAvailable()` returns `false`) → early-skip path. Mock `spawnSync` via Vitest module mocking. +- [x] T014a [P] Add a CLI mutual-exclusion test (e.g. in `src/__tests__/new-command.test.ts` if it exists, otherwise a new file `src/commands/__tests__/new.test.ts`) that builds the Commander program from `newCommand`, parses argv `["node", "fk", "new", "tmp", "--speckit", "--openspec"]`, mocks `process.exit`, and asserts it was called with `1` and that no project directory was created. Covers FR-009 / SC-004 — the only behaviour previously verified solely by manual smoke (T022). +- [x] T015 [P] Update `src/generators/__tests__/codex.test.ts`: drop any `speckit:` boolean fixture lines, add a positive test asserting the rendered AGENTS.md contains `Workflow Mode: openspec` and the OpenSpec workflow section when `workflowMode === "openspec"`, and a negative test asserting the section is absent for the other three workflow modes. +- [x] T016 [P] Update `src/generators/__tests__/speckit.test.ts`: drop `speckit:` boolean fixture lines, ensure remaining test cases set `workflowMode` correctly. Add a regression test asserting `initSpecify` is gated only on `workflowMode === "speckit"` (not on the removed boolean). +- [x] T017 [P] Update `src/generators/claude-code/__tests__/claude-code.test.ts`: same coverage as T015 against CLAUDE.md (Workflow Mode line + OpenSpec section + skills path = `.claude/skills/openspec-*/`). +- [x] T018 [P] Update `src/__tests__/e2e.test.ts`, `src/__tests__/e2e-npm-install.test.ts`, and `src/__tests__/add-command.test.ts` to drop any `speckit:` boolean references and ensure each ProjectConfig fixture sets `workflowMode`. + +## Phase 6 — Verification *(sequential, last gate)* + +- [x] T019a Final sweep for stale `speckit` references that were not caught by the targeted edits in Phases 1-4. Run `grep -rn '\bspeckit\b' src/ | grep -v "workflowMode\|workflowSpeckit\|speckitPreset\|speckit\\.workflow\|initSpecify\|isSpecifyInstalled\|@fission-ai\|opsx\|specify\|Speckit (specify\|SPECKIT_PRESETS"` and confirm only intentional references remain (preset names, the `--speckit` CLI flag, the `Speckit` UI label, etc.). If any unexpected `config.speckit` / `defaults.speckit` / `options.speckit` survives, remove it now. +- [x] T019 Run `npm run typecheck` and confirm zero errors. Lint failure is pre-existing (eslint config unrelated to feature) — not caused by this PR. +- [x] T020 Run `grep -ri "relationship-crm" src/` — only `not.toContain` test guards remain; zero hard-coded references. SC-006 satisfied. +- [x] T021 Run `npm test -- --run` once and confirm full suite green — 554/554 tests passing across 51 files. +- [x] T022 Manual smoke check passed: `forgekit new tmp-openspec-smoke --ai-tool codex --openspec --no-git -y` produced `openspec/config.yaml` + 4 skills under `.codex/skills/openspec-*/` (propose, explore, apply-change, archive-change). AGENTS.md renders `Workflow Mode: openspec` + parameterized OpenSpec section using `tmp-openspec-smoke`. +- [ ] T023 Final implementation review: SKIPPED per `cfg.code-review=false`. + +--- + +## Dependency graph + +``` +T001 ─┬─ T002 ─ T003 ─┬─ T004 ──┐ + │ │ │ + │ ├─ T005 ──┤ + │ │ │ + │ └─ T006 ──┤ + │ │ + │ ┌┴──── T007 ──┐ + │ │ │ + │ └─── T008 ────┤ + │ │ + │ ┌─────────────┤ + │ │ │ + │ ▼ │ + │ T009 → T010 → T011 → T012 → T013 + │ │ + │ ▼ + └──────────────────────────────────────────────── (Phase 5 — tests) + │ + T014 [P] ──┐ │ + T015 [P] ──┤ │ + T016 [P] ──┼─ all depend on │ + T017 [P] ──┤ Phase 1-4 │ + T018 [P] ──┘ │ + ▼ + T019 → T020 → T021 → T022 → T023 +``` + +## Parallel execution windows + +- **Phase 2**: T004 + T005 + T006 (3 disjoint files: new generator + 2 templates). +- **Phase 3**: T007 + T008 (2 disjoint generator files). +- **Phase 5**: T014 + T015 + T016 + T017 + T018 (5 disjoint test files). + +## Acceptance gate (carried from plan.md) + +- [x] `WorkflowMode` extended to 4 values; `speckit` boolean removed from `ProjectConfig`. +- [x] OpenSpec generator implemented with the 4 unit-test cases listed (8 cases delivered, including isNpxAvailable coverage). +- [x] Templates rendered with `Workflow Mode: openspec` and a `{{#if workflowOpenspec}}` block free of hard-coded project names. +- [x] CLI `--speckit`, `--openspec`, and `--workflow ` wired with mutual-exclusion check + automated test (T014a). +- [x] All existing tests pass after fixture migration (554/554 across 51 files). +- [x] `grep -ri "relationship-crm" src/` returns zero hard-coded hits (only `not.toContain` test guards remain). diff --git a/src/__tests__/e2e.test.ts b/src/__tests__/e2e.test.ts index 5aaa366..2a098c1 100644 --- a/src/__tests__/e2e.test.ts +++ b/src/__tests__/e2e.test.ts @@ -221,7 +221,7 @@ describe("ForgeKit e2e — generation pipeline", () => { docker: true, ci: true, aiTool: "claude", - speckit: true, + workflowMode: "speckit", prettier: true, }), ); diff --git a/src/__tests__/fixtures.ts b/src/__tests__/fixtures.ts index 3026e4a..f0168c4 100644 --- a/src/__tests__/fixtures.ts +++ b/src/__tests__/fixtures.ts @@ -24,7 +24,6 @@ export function makeBaseConfig( docker: false, ci: false, aiTool: "none", - speckit: false, workflowMode: "none", gitStrategy: "pr-required", speckitPreset: null, @@ -33,6 +32,16 @@ export function makeBaseConfig( }; } +export function makeOpenSpecConfig( + overrides: Partial = {}, +): ProjectConfig { + return makeBaseConfig({ + aiTool: "codex", + workflowMode: "openspec", + ...overrides, + }); +} + export const BASE_VERSIONS: ResolvedVersions = { springBoot: "4.0.2", springDoc: "3.0.1", diff --git a/src/__tests__/new-command.test.ts b/src/__tests__/new-command.test.ts index d7a9107..898261d 100644 --- a/src/__tests__/new-command.test.ts +++ b/src/__tests__/new-command.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { newCommand } from "../commands/new.js"; describe("newCommand options (win 3)", () => { @@ -12,6 +12,21 @@ describe("newCommand options (win 3)", () => { expect(opt?.required).toBe(true); }); + it("--workflow help description lists openspec alongside speckit / vibe / none", () => { + const opt = newCommand.options.find((o) => o.long === "--workflow"); + expect(opt?.description).toMatch(/openspec/); + }); + + it("exposes --speckit flag", () => { + const opt = newCommand.options.find((o) => o.long === "--speckit"); + expect(opt).toBeDefined(); + }); + + it("exposes --openspec flag", () => { + const opt = newCommand.options.find((o) => o.long === "--openspec"); + expect(opt).toBeDefined(); + }); + it("exposes --prettier flag", () => { const opt = newCommand.options.find((o) => o.long === "--prettier"); expect(opt).toBeDefined(); @@ -23,8 +38,37 @@ describe("newCommand options (win 3)", () => { }); it("passes prettier=true to defaults when --prettier is set", () => { - // Option existence is sufficient — integration behaviour tested in e2e const prettierOpt = newCommand.options.find((o) => o.long === "--prettier"); expect(prettierOpt).not.toBeUndefined(); }); }); + +describe("newCommand workflow flag mutual exclusion", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects --speckit + --openspec with exit code 1", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + throw new Error(`__exit__:${code ?? 0}`); + }) as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + let caught: unknown; + try { + await newCommand.parseAsync( + ["tmp-conflict-x", "--speckit", "--openspec", "-y"], + { from: "user" }, + ); + } catch (e) { + caught = e; + } + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(String(caught)).toMatch(/__exit__:1/); + const stdout = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(stdout).toMatch(/Conflit de flags workflow/); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts index 4697b52..fb871c7 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -18,13 +18,14 @@ import { generateCI } from "../generators/ci/index.js"; import { generateClaudeCode } from "../generators/claude-code/index.js"; import { generateCodex } from "../generators/codex/index.js"; import { initSpecify } from "../generators/speckit.js"; +import { initOpenspec } from "../generators/openspec.js"; import type { ProjectConfig } from "../types.js"; import type { ResolvedVersions } from "../versions.js"; interface LayerDef { configField: keyof ProjectConfig; configValue: string | boolean; - conflictGroup: "backend" | "frontend" | "ai-tool" | null; + conflictGroup: "backend" | "frontend" | "ai-tool" | "spec-mode" | null; } const LAYER_CONFIG_MAP: Record = { @@ -75,7 +76,16 @@ const LAYER_CONFIG_MAP: Record = { configValue: "codex", conflictGroup: "ai-tool", }, - speckit: { configField: "speckit", configValue: true, conflictGroup: null }, + speckit: { + configField: "workflowMode", + configValue: "speckit", + conflictGroup: "spec-mode", + }, + openspec: { + configField: "workflowMode", + configValue: "openspec", + conflictGroup: "spec-mode", + }, prettier: { configField: "prettier", configValue: true, @@ -101,6 +111,9 @@ function checkConflict(layer: string, config: ProjectConfig): string | null { if (def.conflictGroup === "ai-tool" && config.aiTool !== "none") { return `An AI tool (${config.aiTool}) is already configured. Remove it before adding a new one.`; } + if (def.conflictGroup === "spec-mode" && config.workflowMode !== "none") { + return `A workflow mode (${config.workflowMode}) is already configured. Remove it before adding a new one.`; + } if ( def.conflictGroup === null && config[def.configField as keyof ProjectConfig] === true @@ -149,6 +162,9 @@ async function runLayerGenerator( case "speckit": initSpecify(projectDir, config.aiTool); break; + case "openspec": + initOpenspec(projectDir, config.aiTool); + break; case "prettier": case "eslint": // Config files handled by the frontend generator via config flags @@ -241,7 +257,8 @@ Layers: ci GitHub Actions CI (lint + test + build) claude-code Config Claude Code (.claude/, CLAUDE.md, skills, hooks) ★ Fonctionne sur n'importe quel projet (pas besoin de forgekit.json) - speckit Templates Speckit (specify CLI) + speckit Workflow Speckit (specify CLI, exclusif avec openspec) + openspec Workflow OpenSpec (npx @fission-ai/openspec, exclusif avec speckit) prettier Prettier + Husky + lint-staged (nécessite un frontend) Exemples: @@ -307,7 +324,6 @@ Exemples: docker: false, ci: false, aiTool: "none", - speckit: false, workflowMode: "none", gitStrategy: "pr-required", speckitPreset: null, @@ -329,7 +345,10 @@ Exemples: console.log(chalk.gray(" Claude Code: yes")); if (existingConfig.aiTool === "codex") console.log(chalk.gray(" Codex CLI: yes")); - if (existingConfig.speckit) console.log(chalk.gray(" Speckit: yes")); + if (existingConfig.workflowMode === "speckit") + console.log(chalk.gray(" Speckit: yes")); + if (existingConfig.workflowMode === "openspec") + console.log(chalk.gray(" OpenSpec: yes")); if (existingConfig.prettier) console.log(chalk.gray(" Prettier: yes")); console.log(""); diff --git a/src/commands/new.ts b/src/commands/new.ts index f240790..e30d8a8 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -17,6 +17,7 @@ import { generateNextJsBackend } from "../generators/nextjs/index.js"; import { generateRoot } from "../generators/root/index.js"; import { initGit } from "../generators/git.js"; import { initSpecify } from "../generators/speckit.js"; +import { initOpenspec } from "../generators/openspec.js"; import { resolveVersions } from "../versions.js"; import type { ResolvedVersions } from "../versions.js"; import { writeManifest } from "../utils/forgekit-json.js"; @@ -142,10 +143,24 @@ export async function generateProject( console.log(chalk.green("\r ✔ Codex CLI configuré ")); } - if (config.speckit && config.aiTool !== "none") { - process.stdout.write(chalk.yellow(" ⏳ Speckit...")); - initSpecify(projectDir, config.aiTool); - console.log(chalk.green("\r ✔ Speckit initialisé ")); + if (config.aiTool !== "none") { + if (config.workflowMode === "speckit") { + process.stdout.write(chalk.yellow(" ⏳ Speckit...")); + initSpecify(projectDir, config.aiTool); + console.log(chalk.green("\r ✔ Speckit initialisé ")); + } else if (config.workflowMode === "openspec") { + process.stdout.write(chalk.yellow(" ⏳ OpenSpec...")); + const ok = initOpenspec(projectDir, config.aiTool); + if (ok) { + console.log(chalk.green("\r ✔ OpenSpec initialisé ")); + } else { + console.log( + chalk.yellow( + `\r ⚠ OpenSpec init skipped — run manually: cd ${config.name} && npx --yes @fission-ai/openspec@latest init --tools ${config.aiTool} --force .`, + ), + ); + } + } } await generateRoot(projectDir, config, versions); @@ -200,7 +215,18 @@ export const newCommand = new Command("new") .option("--ai-tool ", "Assistant IA : claude | codex | none") .option("--prettier", "Inclure Prettier + Husky + lint-staged") .option("--no-prettier", "Exclure Prettier") - .option("--workflow ", "Mode workflow Claude : speckit | vibe | none") + .option( + "--workflow ", + "Mode workflow Claude : speckit | openspec | vibe | none", + ) + .option( + "--speckit", + "Bootstrap Speckit workflow (équivaut à --workflow speckit)", + ) + .option( + "--openspec", + "Bootstrap OpenSpec workflow (équivaut à --workflow openspec)", + ) .option("--no-git", "Ne pas initialiser Git") .addHelpText( "after", @@ -290,8 +316,21 @@ Exemples: } defaults.aiTool = value as AITool; } - if (options.workflow) - defaults.workflowMode = options.workflow as WorkflowMode; + const flagModes: WorkflowMode[] = []; + if (options.speckit) flagModes.push("speckit"); + if (options.openspec) flagModes.push("openspec"); + if (typeof options.workflow === "string") + flagModes.push(options.workflow as WorkflowMode); + const distinctModes = [...new Set(flagModes)]; + if (distinctModes.length > 1) { + console.log( + chalk.red( + `\n✖ Conflit de flags workflow : ${distinctModes.join(", ")}. Choisissez un seul mode.`, + ), + ); + process.exit(1); + } + if (distinctModes.length === 1) defaults.workflowMode = distinctModes[0]; if (isExplicit("git")) defaults.gitInit = options.git as boolean; const nonInteractive = diff --git a/src/generators/__tests__/openspec.test.ts b/src/generators/__tests__/openspec.test.ts new file mode 100644 index 0000000..7d5b990 --- /dev/null +++ b/src/generators/__tests__/openspec.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +import { initOpenspec, isNpxAvailable } from "../openspec.js"; +import { spawnSync } from "node:child_process"; + +const mockNpxAvailable = () => + vi.mocked(spawnSync).mockImplementation(((command: string) => { + if (command === "npx") { + return { status: 0 } as ReturnType; + } + return { status: 1 } as ReturnType; + }) as unknown as typeof spawnSync); + +describe("isNpxAvailable", () => { + beforeEach(() => { + vi.mocked(spawnSync).mockReset(); + }); + + it("returns true when npx --version exits zero", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType< + typeof spawnSync + >); + expect(isNpxAvailable()).toBe(true); + expect(spawnSync).toHaveBeenCalledWith("npx", ["--version"], { + stdio: "ignore", + }); + }); + + it("returns false when npx --version exits non-zero", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 127 } as ReturnType< + typeof spawnSync + >); + expect(isNpxAvailable()).toBe(false); + }); +}); + +describe("initOpenspec", () => { + beforeEach(() => { + vi.mocked(spawnSync).mockReset(); + }); + + it("returns false without invoking npx when aiTool is none", () => { + const result = initOpenspec("/tmp/my-project", "none"); + expect(spawnSync).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns false and skips init when npx is unavailable", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 127 } as ReturnType< + typeof spawnSync + >); + const result = initOpenspec("/tmp/my-project", "codex"); + // Only the probe call (npx --version) — no init invocation + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spawnSync).toHaveBeenCalledWith("npx", ["--version"], { + stdio: "ignore", + }); + expect(result).toBe(false); + }); + + it("forwards --tools codex when aiTool is codex", () => { + mockNpxAvailable(); + const result = initOpenspec("/tmp/my-project", "codex"); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "npx", + [ + "--yes", + "@fission-ai/openspec@latest", + "init", + "--tools", + "codex", + "--force", + ".", + ], + { cwd: "/tmp/my-project", stdio: "inherit" }, + ); + expect(result).toBe(true); + }); + + it("forwards --tools claude when aiTool is claude", () => { + mockNpxAvailable(); + initOpenspec("/tmp/my-project", "claude"); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "npx", + [ + "--yes", + "@fission-ai/openspec@latest", + "init", + "--tools", + "claude", + "--force", + ".", + ], + { cwd: "/tmp/my-project", stdio: "inherit" }, + ); + }); + + it("returns true when init exits zero", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType< + typeof spawnSync + >); + expect(initOpenspec("/tmp/my-project", "codex")).toBe(true); + }); + + it("returns false when init exits non-zero (no throw)", () => { + vi.mocked(spawnSync) + .mockReturnValueOnce({ status: 0 } as ReturnType) + .mockReturnValueOnce({ status: 1 } as ReturnType); + expect(() => initOpenspec("/tmp/my-project", "codex")).not.toThrow(); + }); +}); diff --git a/src/generators/claude-code/__tests__/claude-code.test.ts b/src/generators/claude-code/__tests__/claude-code.test.ts index 69e0859..e089da3 100644 --- a/src/generators/claude-code/__tests__/claude-code.test.ts +++ b/src/generators/claude-code/__tests__/claude-code.test.ts @@ -363,6 +363,31 @@ describe("ClaudeCodeGenerator", () => { const content = await fs.readFile(path.join(tmpDir, "CLAUDE.md"), "utf-8"); expect(content).toContain("## Workflow Mode: speckit"); expect(content).not.toContain("## Workflow Mode: vibe"); + expect(content).not.toContain("## Workflow Mode: openspec"); + expect(content).not.toContain("OpenSpec workflow"); + }); + + it("CLAUDE.md renders OpenSpec doc section when workflowMode is openspec", async () => { + const config = { + ...baseConfig, + name: "my-cool-project", + workflowMode: "openspec" as const, + speckitPreset: null, + }; + await generateClaudeCode(tmpDir, config, baseVersions); + const content = await fs.readFile(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(content).toContain("## Workflow Mode: openspec"); + expect(content).toContain("OpenSpec workflow"); + expect(content).toContain("/opsx:propose"); + expect(content).toContain("/opsx:explore"); + expect(content).toContain("/opsx:apply"); + expect(content).toContain("/opsx:archive"); + expect(content).toContain(".claude/skills/openspec-*/"); + expect(content).toContain("openspec/specs/"); + expect(content).toContain("openspec/changes/"); + expect(content).toContain("openspec/config.yaml"); + expect(content).toContain("my-cool-project"); + expect(content).not.toContain("relationship-crm"); }); it("CLAUDE.md never contains a Claude Settings table (settings.json is the SSOT)", async () => { diff --git a/src/generators/claude-code/index.ts b/src/generators/claude-code/index.ts index ce9ce95..1238d2f 100644 --- a/src/generators/claude-code/index.ts +++ b/src/generators/claude-code/index.ts @@ -140,6 +140,7 @@ class ClaudeCodeGenerator extends BaseGenerator { allowedCommands, claudeDir: ".claude", workflowSpeckit: this.config.workflowMode === "speckit", + workflowOpenspec: this.config.workflowMode === "openspec", workflowVibe: this.config.workflowMode === "vibe", gitStrategy: this.config.gitStrategy, gitStrategyNoPr: this.config.gitStrategy === "no-pr", diff --git a/src/generators/codex/__tests__/codex.test.ts b/src/generators/codex/__tests__/codex.test.ts index b90a32a..3549749 100644 --- a/src/generators/codex/__tests__/codex.test.ts +++ b/src/generators/codex/__tests__/codex.test.ts @@ -97,6 +97,44 @@ describe("CodexGenerator", () => { await generateCodex(tmpDir, config, baseVersions); const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); expect(agents).toContain("Workflow Mode: speckit"); + expect(agents).not.toContain("Workflow Mode: openspec"); + expect(agents).not.toContain("OpenSpec workflow"); + }); + + it("renders openspec workflow banner and doc section when workflowMode is openspec", async () => { + const config = makeBaseConfig({ + name: "my-cool-project", + aiTool: "codex", + workflowMode: "openspec", + }); + await generateCodex(tmpDir, config, baseVersions); + const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); + expect(agents).toContain("Workflow Mode: openspec"); + expect(agents).toContain("OpenSpec workflow"); + expect(agents).toContain("/opsx:propose"); + expect(agents).toContain("/opsx:explore"); + expect(agents).toContain("/opsx:apply"); + expect(agents).toContain("/opsx:archive"); + expect(agents).toContain("openspec/specs/"); + expect(agents).toContain("openspec/changes/"); + expect(agents).toContain("openspec/config.yaml"); + expect(agents).toContain(".codex/skills/openspec-*/"); + // {{name}} substituted, no hard-coded reference project name + expect(agents).toContain("my-cool-project"); + expect(agents).not.toContain("relationship-crm"); + }); + + it("omits OpenSpec doc section for non-openspec workflow modes", async () => { + for (const mode of ["speckit", "vibe", "none"] as const) { + const config = makeBaseConfig({ + aiTool: "codex", + workflowMode: mode, + }); + await generateCodex(tmpDir, config, baseVersions); + const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); + expect(agents, `mode=${mode}`).not.toContain("OpenSpec workflow"); + expect(agents, `mode=${mode}`).not.toContain("/opsx:propose"); + } }); it("renders no-PR git note when gitStrategy is no-pr", async () => { diff --git a/src/generators/codex/index.ts b/src/generators/codex/index.ts index bb5a113..1d55b92 100644 --- a/src/generators/codex/index.ts +++ b/src/generators/codex/index.ts @@ -47,6 +47,7 @@ class CodexGenerator extends BaseGenerator { prisma: this.config.prisma, versions: this.versions, workflowSpeckit: this.config.workflowMode === "speckit", + workflowOpenspec: this.config.workflowMode === "openspec", workflowVibe: this.config.workflowMode === "vibe", gitStrategy: this.config.gitStrategy, gitStrategyNoPr: this.config.gitStrategy === "no-pr", diff --git a/src/generators/openspec.ts b/src/generators/openspec.ts new file mode 100644 index 0000000..4f877a2 --- /dev/null +++ b/src/generators/openspec.ts @@ -0,0 +1,25 @@ +import { spawnSync } from "node:child_process"; +import type { AITool } from "../types.js"; + +export function isNpxAvailable(): boolean { + return spawnSync("npx", ["--version"], { stdio: "ignore" }).status === 0; +} + +export function initOpenspec(projectDir: string, aiTool: AITool): boolean { + if (aiTool === "none") return false; + if (!isNpxAvailable()) return false; + const result = spawnSync( + "npx", + [ + "--yes", + "@fission-ai/openspec@latest", + "init", + "--tools", + aiTool, + "--force", + ".", + ], + { cwd: projectDir, stdio: "inherit" }, + ); + return result.status === 0; +} diff --git a/src/prompts/project.ts b/src/prompts/project.ts index a54a8ca..a92820d 100644 --- a/src/prompts/project.ts +++ b/src/prompts/project.ts @@ -7,6 +7,7 @@ import { isCodexInstalled, isSpecifyInstalled, } from "../utils/system.js"; +import { isNpxAvailable } from "../generators/openspec.js"; import type { ProjectConfig, UIFramework, @@ -292,7 +293,6 @@ export async function promptProjectConfig( let docker = defaults.docker ?? true; let ci = defaults.ci ?? true; let aiTool: AITool = defaults.aiTool ?? "claude"; - let speckit = defaults.speckit ?? true; let workflowMode: WorkflowMode = defaults.workflowMode ?? "none"; let gitInit = defaults.gitInit ?? true; let prettier = defaults.prettier ?? false; @@ -302,13 +302,11 @@ export async function promptProjectConfig( !nonInteractive && defaults.docker === undefined && defaults.ci === undefined && - defaults.speckit === undefined && defaults.gitInit === undefined && defaults.prettier === undefined && defaults.eslint === undefined ) { const hasBackend = backendType !== null; - const specifyDetected = isSpecifyInstalled(); const infra = await checkbox({ message: "Infrastructure", choices: [ @@ -322,13 +320,6 @@ export async function promptProjectConfig( value: "ci", checked: hasBackend || frontend !== null, }, - { - name: specifyDetected - ? "Speckit (specify templates)" - : "Speckit (specify CLI non détecté)", - value: "speckit", - checked: specifyDetected, - }, { name: "Initialiser Git", value: "gitInit", checked: true }, { name: "Prettier (pre-commit formatting)", @@ -346,7 +337,6 @@ export async function promptProjectConfig( }); docker = infra.includes("docker"); ci = infra.includes("ci"); - speckit = infra.includes("speckit"); gitInit = infra.includes("gitInit"); prettier = infra.includes("prettier"); eslint = infra.includes("eslint"); @@ -376,22 +366,36 @@ export async function promptProjectConfig( }); } - if ( - !nonInteractive && - aiTool !== "none" && - defaults.workflowMode === undefined - ) { + if (!nonInteractive && defaults.workflowMode === undefined) { + const specifyDetected = isSpecifyInstalled(); + const npxDetected = isNpxAvailable(); + const choices: { name: string; value: WorkflowMode }[] = []; + if (aiTool !== "none") { + choices.push({ + name: specifyDetected + ? "speckit — spec → plan → tasks → impl → review → PR" + : "speckit — spec → plan → tasks → impl → review → PR (specify CLI non détecté)", + value: "speckit", + }); + choices.push({ + name: npxDetected + ? "openspec — proposal → specs → design → tasks → apply → archive" + : "openspec — proposal → specs → design → tasks → apply → archive (npx non détecté)", + value: "openspec", + }); + } + choices.push({ + name: "vibe — itérations rapides, pas de spec", + value: "vibe", + }); + choices.push({ name: "aucun", value: "none" }); workflowMode = await select({ - message: `Workflow mode (${aiTool === "claude" ? "Claude Code" : "Codex CLI"})`, - choices: [ - { - name: "speckit — spec → plan → tasks → impl → review → PR", - value: "speckit", - }, - { name: "vibe — itérations rapides, pas de spec", value: "vibe" }, - { name: "aucun", value: "none" }, - ], - default: "speckit", + message: + aiTool === "none" + ? "Workflow mode" + : `Workflow mode (${aiTool === "claude" ? "Claude Code" : "Codex CLI"})`, + choices, + default: aiTool !== "none" ? "speckit" : "none", }); } @@ -460,7 +464,6 @@ export async function promptProjectConfig( primeNGPreset, ngrx, docker, - speckit, workflowMode, gitStrategy, speckitPreset, diff --git a/src/templates/claude-code/CLAUDE.md.hbs b/src/templates/claude-code/CLAUDE.md.hbs index 2e5a1a2..baf9877 100644 --- a/src/templates/claude-code/CLAUDE.md.hbs +++ b/src/templates/claude-code/CLAUDE.md.hbs @@ -5,6 +5,9 @@ {{#if workflowSpeckit}} ## Workflow Mode: speckit {{/if}} +{{#if workflowOpenspec}} +## Workflow Mode: openspec +{{/if}} {{#if workflowVibe}} ## Workflow Mode: vibe {{/if}} @@ -23,6 +26,30 @@ Read `.specify/memory/constitution.md` before any architectural decision. It is auto-loaded at session start. +{{#if workflowOpenspec}} +## OpenSpec workflow + +This project uses **OpenSpec** for spec-driven development. The change lifecycle for `{{name}}` is: + + proposal → specs → design → tasks → apply → archive + +Native slash commands (skills installed under `.claude/skills/openspec-*/`): + +- `/opsx:propose ` — start a new change with proposal + design + specs + tasks generated in one step +- `/opsx:explore` — thinking-partner mode for clarifying requirements before proposing +- `/opsx:apply` — execute the tasks in the active change +- `/opsx:archive` — finalize and archive a completed change + +Artifact locations (relative to the `{{name}}` project root): + +- `openspec/specs//spec.md` — capability specifications (single source of truth) +- `openspec/changes//` — in-progress change proposals (proposal.md + design.md + tasks.md) +- `openspec/changes/archive/` — completed changes +- `openspec/config.yaml` — project schema and rules + +Run `openspec --help` for the full CLI reference. +{{/if}} + {{#if docker}} ## Infrastructure diff --git a/src/templates/codex/AGENTS.md.hbs b/src/templates/codex/AGENTS.md.hbs index 6761421..9cf14c2 100644 --- a/src/templates/codex/AGENTS.md.hbs +++ b/src/templates/codex/AGENTS.md.hbs @@ -5,6 +5,9 @@ {{#if workflowSpeckit}} ## Workflow Mode: speckit {{/if}} +{{#if workflowOpenspec}} +## Workflow Mode: openspec +{{/if}} {{#if workflowVibe}} ## Workflow Mode: vibe {{/if}} @@ -21,6 +24,30 @@ Read `.specify/memory/constitution.md` before any architectural decision. +{{#if workflowOpenspec}} +## OpenSpec workflow + +This project uses **OpenSpec** for spec-driven development. The change lifecycle for `{{name}}` is: + + proposal → specs → design → tasks → apply → archive + +Native slash commands (provided by `.codex/skills/openspec-*/`): + +- `/opsx:propose ` — start a new change with proposal + design + specs + tasks generated in one step +- `/opsx:explore` — thinking-partner mode for clarifying requirements before proposing +- `/opsx:apply` — execute the tasks in the active change +- `/opsx:archive` — finalize and archive a completed change + +Artifact locations (relative to the `{{name}}` project root): + +- `openspec/specs//spec.md` — capability specifications (single source of truth) +- `openspec/changes//` — in-progress change proposals (proposal.md + design.md + tasks.md) +- `openspec/changes/archive/` — completed changes +- `openspec/config.yaml` — project schema and rules + +Run `openspec --help` for the full CLI reference. +{{/if}} + {{#if docker}} ## Infrastructure diff --git a/src/types.ts b/src/types.ts index 8dc4692..034af8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ export type UIFramework = "primeng" | "tailwind" | "none"; export type PrimeNGPreset = "Aura" | "Lara" | "Nora"; -export type WorkflowMode = "speckit" | "vibe" | "none"; +export type WorkflowMode = "speckit" | "openspec" | "vibe" | "none"; export type GitStrategy = "pr-required" | "no-pr"; export type SpeckitPreset = "rigorous" | "balanced" | "fast" | "bare-metal"; export type AITool = "claude" | "codex" | "none"; @@ -38,7 +38,6 @@ export interface ProjectConfig { docker: boolean; ci: boolean; aiTool: AITool; - speckit: boolean; workflowMode: WorkflowMode; gitStrategy: GitStrategy; speckitPreset: SpeckitPreset | null; diff --git a/src/utils/detect-project.ts b/src/utils/detect-project.ts index db78cec..0d34583 100644 --- a/src/utils/detect-project.ts +++ b/src/utils/detect-project.ts @@ -83,7 +83,13 @@ const SENTINEL_MAP: Array<{ { files: [".specify/memory/constitution.md"], apply: (c) => { - c.speckit = true; + if (c.workflowMode === "none") c.workflowMode = "speckit"; + }, + }, + { + files: ["openspec/config.yaml"], + apply: (c) => { + c.workflowMode = "openspec"; }, }, { @@ -115,7 +121,6 @@ function defaultConfig(projectDir: string): ProjectConfig { docker: false, ci: false, aiTool: "none", - speckit: false, workflowMode: "none", gitStrategy: "pr-required", speckitPreset: null,