From 15d8bcdef44b4bbcb711d765f0bd9ae52258c122 Mon Sep 17 00:00:00 2001 From: zccyman Date: Sat, 4 Apr 2026 02:53:08 +0800 Subject: [PATCH] feat(dev-workflow): add AI-driven spec-driven development workflow plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dev-workflow plugin implementing a 10-step spec-driven development workflow with multi-agent orchestration, inspired by Claw Code harness patterns. Features: - 3 modes: quick, standard, full - Spec-driven workflow: analysis → requirement → brainstorm → spec → dev → review → test → docs → delivery - Ship/Show/Ask task categorization with conventional commits - QA gate with 10 quality checks (lint, format, tests, coverage, typecheck, etc.) - 21 code quality rules enforcement - Context persistence for cross-session resumption - 5 tools: dev_workflow_start, workflow_status, task_execute, spec_view, qa_gate_check - Channel plugin with aliases: dwf, devflow Co-authored-by: OpenClaw Maintainers --- extensions/dev-workflow/README.md | 147 +++++ extensions/dev-workflow/README_CN.md | 147 +++++ extensions/dev-workflow/openclaw.plugin.json | 38 ++ extensions/dev-workflow/package.json | 67 +++ extensions/dev-workflow/setup-entry.ts | 4 + .../dev-workflow/skills/dev-workflow/SKILL.md | 219 +++++++ extensions/dev-workflow/src/agents/index.ts | 452 ++++++++++++++ .../src/channel/dev-workflow-channel.ts | 42 ++ .../dev-workflow/src/channel/runtime.ts | 24 + extensions/dev-workflow/src/engine/index.ts | 452 ++++++++++++++ extensions/dev-workflow/src/hooks/index.ts | 19 + extensions/dev-workflow/src/index.ts | 22 + .../src/tools/dev-workflow-tool.ts | 50 ++ extensions/dev-workflow/src/tools/index.ts | 14 + .../dev-workflow/src/tools/qa-gate-tool.ts | 553 ++++++++++++++++++ .../dev-workflow/src/tools/spec-view-tool.ts | 72 +++ .../src/tools/task-execute-tool.ts | 45 ++ .../src/tools/workflow-status-tool.ts | 55 ++ extensions/dev-workflow/src/types.ts | 182 ++++++ extensions/dev-workflow/tests/agents.test.ts | 298 ++++++++++ extensions/dev-workflow/tests/channel.test.ts | 74 +++ extensions/dev-workflow/tests/engine.test.ts | 268 +++++++++ extensions/dev-workflow/tests/hooks.test.ts | 80 +++ extensions/dev-workflow/tests/index.test.ts | 57 ++ extensions/dev-workflow/tests/runtime.test.ts | 31 + extensions/dev-workflow/tests/tools.test.ts | 272 +++++++++ extensions/dev-workflow/tests/types.test.ts | 191 ++++++ extensions/dev-workflow/tsconfig.json | 21 + extensions/dev-workflow/tsconfig.tsbuildinfo | 1 + extensions/dev-workflow/vitest.config.ts | 9 + 30 files changed, 3906 insertions(+) create mode 100755 extensions/dev-workflow/README.md create mode 100755 extensions/dev-workflow/README_CN.md create mode 100755 extensions/dev-workflow/openclaw.plugin.json create mode 100755 extensions/dev-workflow/package.json create mode 100755 extensions/dev-workflow/setup-entry.ts create mode 100755 extensions/dev-workflow/skills/dev-workflow/SKILL.md create mode 100755 extensions/dev-workflow/src/agents/index.ts create mode 100755 extensions/dev-workflow/src/channel/dev-workflow-channel.ts create mode 100755 extensions/dev-workflow/src/channel/runtime.ts create mode 100755 extensions/dev-workflow/src/engine/index.ts create mode 100755 extensions/dev-workflow/src/hooks/index.ts create mode 100755 extensions/dev-workflow/src/index.ts create mode 100755 extensions/dev-workflow/src/tools/dev-workflow-tool.ts create mode 100755 extensions/dev-workflow/src/tools/index.ts create mode 100755 extensions/dev-workflow/src/tools/qa-gate-tool.ts create mode 100755 extensions/dev-workflow/src/tools/spec-view-tool.ts create mode 100755 extensions/dev-workflow/src/tools/task-execute-tool.ts create mode 100755 extensions/dev-workflow/src/tools/workflow-status-tool.ts create mode 100755 extensions/dev-workflow/src/types.ts create mode 100755 extensions/dev-workflow/tests/agents.test.ts create mode 100755 extensions/dev-workflow/tests/channel.test.ts create mode 100755 extensions/dev-workflow/tests/engine.test.ts create mode 100755 extensions/dev-workflow/tests/hooks.test.ts create mode 100755 extensions/dev-workflow/tests/index.test.ts create mode 100755 extensions/dev-workflow/tests/runtime.test.ts create mode 100755 extensions/dev-workflow/tests/tools.test.ts create mode 100755 extensions/dev-workflow/tests/types.test.ts create mode 100755 extensions/dev-workflow/tsconfig.json create mode 100755 extensions/dev-workflow/tsconfig.tsbuildinfo create mode 100755 extensions/dev-workflow/vitest.config.ts diff --git a/extensions/dev-workflow/README.md b/extensions/dev-workflow/README.md new file mode 100755 index 0000000000000..39a4360fd2dc6 --- /dev/null +++ b/extensions/dev-workflow/README.md @@ -0,0 +1,147 @@ +# @openclaw/dev-workflow +[中文文档](./README_CN.md) + +AI-driven spec-driven development workflow plugin for [OpenClaw](https://github.com/openclaw/openclaw), integrating Claw Code harness patterns with multi-agent orchestration. + +## Features + +- **3 Complexity Modes**: Quick (fast fixes), Standard (balanced), Full (production-grade) +- **10-Step Workflow**: Analysis → Requirement → Brainstorm → Spec → Tech Selection → Development → Review → Test → Docs → Delivery +- **Ship/Show/Ask Framework**: Automatic categorization of changes for safe delivery +- **Multi-Agent Orchestration**: Subagent runtime for LLM calls, code review, test execution +- **TDD Cycle Enforcement**: RED → GREEN → REFACTOR → VERIFY → COMMIT (strict in Full mode) +- **Conventional Commits**: Auto-generated `type(scope): description` commit messages +- **Working Memory**: 3-layer context system (Project → Task → Step) +- **QA Gate**: 10 quality checks including lint, format, tests, coverage, typecheck, simplify, commits, todos, docs, and rule enforcement +- **Rule Enforcement**: 21 built-in code quality rules (configurable via feature flags) +- **Feature Flags**: Fine-grained control over workflow behavior +- **GitHub Integration**: Auto-tag releases, merge feature branches, update repo descriptions +- **Git Branch Management**: Automatic `feature/-` branch creation + +## Installation + +```bash +# In your OpenClaw monorepo +pnpm add @openclaw/dev-workflow --workspace +``` + +Or add to `extensions/` directory for local development. + +## Usage + +### As an OpenClaw Extension + +The plugin registers automatically when loaded by OpenClaw's plugin discovery system. + +### Tools Provided + +| Tool | Description | +|------|-------------| +| `dev_workflow_start` | Start a new workflow with a requirement | +| `workflow_status` | Check current workflow progress | +| `task_execute` | Execute a specific task by ID | +| `spec_view` | View the spec (proposal, design, tasks) | +| `qa_gate_check` | Run quality gate checks | + +### Starting a Workflow + +``` +dev_workflow_start({ + requirement: "Add dark mode toggle to settings page", + projectDir: "/path/to/project", + mode: "standard", + featureFlags: { + strictTdd: true, + ruleEnforcement: true + } +}) +``` + +### Feature Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `strictTdd` | `false` | Enforce strict TDD (auto-enabled in Full mode) | +| `ruleEnforcement` | `true` | Check code against 21 quality rules | +| `autoCommit` | `true` | Auto-commit after task completion | +| `workingMemoryPersist` | `true` | Persist working memory across tasks | +| `dependencyParallelTasks` | `true` | Execute independent tasks in dependency order | +| `conventionalCommits` | `true` | Generate Conventional Commits messages | +| `qaGateBlocking` | `false` | Block delivery on QA failures (auto-enabled in Full mode) | +| `githubIntegration` | `true` | Enable GitHub tag/release/merge steps | +| `coverageThreshold` | `80` | Minimum test coverage percentage | +| `maxFileLines` | `500` | Maximum lines per file before warning | +| `maxFunctionLines` | `50` | Maximum lines per function before warning | + +### QA Gate Checks + +1. **lint** — ESLint or project lint script +2. **format** — Prettier or project format script +3. **tests** — Test suite execution +4. **coverage** — Coverage threshold enforcement +5. **typecheck** — TypeScript type checking +6. **simplify** — Complex function/file detection +7. **commits** — Conventional Commits format validation +8. **todos** — TODO/FIXME/HACK/XXX detection +9. **docs** — README.md existence and content +10. **rules** — 21 built-in code quality rules + +### Rule Enforcement (21 Rules) + +Rules are checked during QA gate and embedded in agent prompts during task execution: + +- No unused variables, prefer const, no console.log +- No any type, explicit return types, no magic numbers +- File/function size limits, no inline styles +- Prefer immutable patterns, avoid deep nesting +- No duplicate code, meaningful names, single responsibility +- No commented code, no debugger, no hardcoded secrets +- Prefer early return, avoid boolean params +- No global mutation, prefer pure functions + +## Architecture + +``` +src/ +├── index.ts # Plugin entry point +├── types.ts # Domain types & feature flags +├── channel/ +│ ├── dev-workflow-channel.ts # Channel plugin definition +│ └── runtime.ts # Runtime singleton +├── agents/ +│ └── index.ts # AgentOrchestrator (9 agent methods) +├── engine/ +│ └── index.ts # DevWorkflowEngine (10-step workflow) +├── tools/ +│ ├── dev-workflow-tool.ts # Start workflow tool +│ ├── workflow-status-tool.ts # Status check tool +│ ├── task-execute-tool.ts # Task execution tool +│ ├── spec-view-tool.ts # Spec viewer tool +│ ├── qa-gate-tool.ts # QA gate with 10 checks +│ └── index.ts # Tool registration +└── hooks/ + └── index.ts # Event hooks (4 hooks) +``` + +## Development + +```bash +# Install dependencies +pnpm install + +# Type check +pnpm typecheck + +# Run tests +pnpm test + +# Build +pnpm build + +# Lint +pnpm lint +``` + +## License + +MIT diff --git a/extensions/dev-workflow/README_CN.md b/extensions/dev-workflow/README_CN.md new file mode 100755 index 0000000000000..fb72ef6a72d12 --- /dev/null +++ b/extensions/dev-workflow/README_CN.md @@ -0,0 +1,147 @@ +# @openclaw/dev-workflow +[English Docs](./README.md) + +基于 AI 的规格驱动开发工作流插件,适用于 [OpenClaw](https://github.com/openclaw/openclaw),集成 Claw Code harness 模式与多智能体编排。 + +## 功能特性 + +- **3 种复杂度模式**: Quick(快速修复)、Standard(均衡模式)、Full(生产级) +- **10 步工作流**: 分析 → 需求 → 头脑风暴 → 规格 → 技术选型 → 开发 → 评审 → 测试 → 文档 → 交付 +- **Ship/Show/Ask 框架**: 自动分类变更以安全交付 +- **多智能体编排**: 通过子智能体运行时进行 LLM 调用、代码评审、测试执行 +- **TDD 周期强制**: RED → GREEN → REFACTOR → VERIFY → COMMIT(Full 模式下严格) +- **约定式提交**: 自动生成 `type(scope): description` 提交信息 +- **工作记忆**: 3 层上下文系统(项目 → 任务 → 步骤) +- **QA 质量门**: 10 项质量检查,包括 lint、格式化、测试、覆盖率、类型检查、简化、提交、TODO、文档和规则执行 +- **规则执行**: 21 条内置代码质量规则(通过 feature flags 配置) +- **Feature Flags**: 细粒度控制工作流行为 +- **GitHub 集成**: 自动标签发布、合并特性分支、更新仓库描述 +- **Git 分支管理**: 自动创建 `feature/-` 分支 + +## 安装 + +```bash +# 在 OpenClaw 单仓库中 +pnpm add @openclaw/dev-workflow --workspace +``` + +或添加到 `extensions/` 目录进行本地开发。 + +## 使用方法 + +### 作为 OpenClaw 扩展 + +插件在 OpenClaw 插件发现系统加载时自动注册。 + +### 提供的工具 + +| 工具 | 描述 | +|------|------| +| `dev_workflow_start` | 启动新的工作流 | +| `workflow_status` | 检查当前工作流进度 | +| `task_execute` | 按 ID 执行特定任务 | +| `spec_view` | 查看规格(提案、设计、任务) | +| `qa_gate_check` | 运行质量门检查 | + +### 启动工作流 + +``` +dev_workflow_start({ + requirement: "为设置页面添加暗色模式切换", + projectDir: "/path/to/project", + mode: "standard", + featureFlags: { + strictTdd: true, + ruleEnforcement: true + } +}) +``` + +### Feature Flags + +| 标志 | 默认值 | 描述 | +|------|--------|------| +| `strictTdd` | `false` | 强制严格 TDD(Full 模式自动启用) | +| `ruleEnforcement` | `true` | 检查代码是否符合 21 条质量规则 | +| `autoCommit` | `true` | 任务完成后自动提交 | +| `workingMemoryPersist` | `true` | 跨任务持久化工作记忆 | +| `dependencyParallelTasks` | `true` | 按依赖顺序执行独立任务 | +| `conventionalCommits` | `true` | 生成约定式提交信息 | +| `qaGateBlocking` | `false` | QA 失败时阻止交付(Full 模式自动启用) | +| `githubIntegration` | `true` | 启用 GitHub 标签/发布/合并步骤 | +| `coverageThreshold` | `80` | 最低测试覆盖率百分比 | +| `maxFileLines` | `500` | 文件最大行数警告阈值 | +| `maxFunctionLines` | `50` | 函数最大行数警告阈值 | + +### QA 质量门检查 + +1. **lint** — ESLint 或项目 lint 脚本 +2. **format** — Prettier 或项目格式化脚本 +3. **tests** — 测试套件执行 +4. **coverage** — 覆盖率阈值检查 +5. **typecheck** — TypeScript 类型检查 +6. **simplify** — 复杂函数/文件检测 +7. **commits** — 约定式提交格式验证 +8. **todos** — TODO/FIXME/HACK/XXX 检测 +9. **docs** — README.md 存在性和内容检查 +10. **rules** — 21 条内置代码质量规则 + +### 规则执行(21 条规则) + +规则在 QA 质量门期间检查,并在任务执行期间嵌入智能体提示: + +- 无未使用变量、优先使用 const、禁止 console.log +- 禁止 any 类型、显式返回类型、禁止魔术数字 +- 文件/函数大小限制、禁止内联样式 +- 优先不可变模式、避免深层嵌套 +- 禁止重复代码、有意义命名、单一职责 +- 禁止注释代码、禁止 debugger、禁止硬编码密钥 +- 优先早期返回、避免布尔参数 +- 禁止全局变异、优先纯函数 + +## 架构 + +``` +src/ +├── index.ts # 插件入口 +├── types.ts # 领域类型 & feature flags +├── channel/ +│ ├── dev-workflow-channel.ts # 频道插件定义 +│ └── runtime.ts # 运行时单例 +├── agents/ +│ └── index.ts # AgentOrchestrator(9 个智能体方法) +├── engine/ +│ └── index.ts # DevWorkflowEngine(10 步工作流) +├── tools/ +│ ├── dev-workflow-tool.ts # 启动工作流工具 +│ ├── workflow-status-tool.ts # 状态检查工具 +│ ├── task-execute-tool.ts # 任务执行工具 +│ ├── spec-view-tool.ts # 规格查看工具 +│ ├── qa-gate-tool.ts # QA 质量门(10 项检查) +│ └── index.ts # 工具注册 +└── hooks/ + └── index.ts # 事件钩子(4 个钩子) +``` + +## 开发 + +```bash +# 安装依赖 +pnpm install + +# 类型检查 +pnpm typecheck + +# 运行测试 +pnpm test + +# 构建 +pnpm build + +# Lint +pnpm lint +``` + +## 许可证 + +MIT diff --git a/extensions/dev-workflow/openclaw.plugin.json b/extensions/dev-workflow/openclaw.plugin.json new file mode 100755 index 0000000000000..ba05ac675f8e0 --- /dev/null +++ b/extensions/dev-workflow/openclaw.plugin.json @@ -0,0 +1,38 @@ +{ + "id": "dev-workflow", + "enabledByDefault": false, + "channels": ["dev-workflow"], + "skills": ["./skills"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultMode": { + "type": "string", + "enum": ["quick", "standard", "full"], + "default": "standard", + "description": "Default complexity mode for development tasks" + }, + "defaultModel": { + "type": "string", + "default": "kilo/minimax/minimax-m2.5:free", + "description": "Default model for agent orchestration" + }, + "specDriven": { + "type": "boolean", + "default": true, + "description": "Enable spec-driven development (proposal/design/tasks)" + }, + "qualityGate": { + "type": "boolean", + "default": true, + "description": "Enable QA gate checks before delivery" + }, + "agentParallelism": { + "type": "boolean", + "default": true, + "description": "Allow parallel agent execution for independent tasks" + } + } + } +} diff --git a/extensions/dev-workflow/package.json b/extensions/dev-workflow/package.json new file mode 100755 index 0000000000000..70e22ab1792da --- /dev/null +++ b/extensions/dev-workflow/package.json @@ -0,0 +1,67 @@ +{ + "name": "@openclaw/dev-workflow", + "version": "0.1.0", + "description": "OpenClaw Dev Workflow plugin — AI-driven spec-driven development workflow with multi-agent orchestration, integrating claw-code harness patterns", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./engine": "./dist/engine/index.js", + "./agents": "./dist/agents/index.js", + "./tools": "./dist/tools/index.js" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "oxlint src/" + }, + "dependencies": { + "@sinclair/typebox": "0.34.49", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "openclaw": "workspace:*", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "oxlint": "^0.15.0" + }, + "peerDependencies": { + "openclaw": ">=2026.4.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./src/index.ts" + ], + "setupEntry": "./setup-entry.ts", + "channel": { + "id": "dev-workflow", + "label": "Dev Workflow", + "selectionLabel": "Dev Workflow (AI-Driven Development)", + "docsPath": "/channels/dev-workflow", + "docsLabel": "dev-workflow", + "blurb": "Spec-driven AI development workflow with multi-agent orchestration, inspired by Claw Code harness patterns.", + "aliases": ["dwf", "devflow"], + "order": 50, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/dev-workflow", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.2" + }, + "bundle": { + "stageRuntimeDependencies": true + }, + "release": { + "publishToNpm": true + } + } +} diff --git a/extensions/dev-workflow/setup-entry.ts b/extensions/dev-workflow/setup-entry.ts new file mode 100755 index 0000000000000..697764e7a40aa --- /dev/null +++ b/extensions/dev-workflow/setup-entry.ts @@ -0,0 +1,4 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { devWorkflowChannel } from "./src/channel/dev-workflow-channel.js"; + +export default defineSetupPluginEntry(devWorkflowChannel); diff --git a/extensions/dev-workflow/skills/dev-workflow/SKILL.md b/extensions/dev-workflow/skills/dev-workflow/SKILL.md new file mode 100755 index 0000000000000..a3c85306fdc58 --- /dev/null +++ b/extensions/dev-workflow/skills/dev-workflow/SKILL.md @@ -0,0 +1,219 @@ +--- +name: dev-workflow +description: AI-driven spec-driven development workflow with multi-agent orchestration, Ship/Show/Ask framework, TDD cycle enforcement, Working Memory, Conventional Commits, Feature Flags, and 21 code quality rules. Use when implementing features, building new modules, or executing structured development tasks. +user-invocable: true +--- + +# Dev Workflow — OpenClaw Plugin Skill + +> Version: 2.0.0 | Integrated with openclaw-plugin @openclaw/dev-workflow + +## Overview + +Spec-driven development workflow with multi-agent orchestration. Integrates Claw Code harness patterns into OpenClaw's plugin ecosystem with Ship/Show/Ask framework, TDD cycle enforcement, Working Memory system, Conventional Commits, Feature Flags, and Rule Enforcement. + +## When to Use + +- User requests to implement a new feature +- User asks to build a module or component +- User describes a development requirement +- User wants structured, test-driven development +- User needs to execute a spec-driven workflow + +## Complexity Modes + +| Mode | Steps | Spec-Driven | QA Gate | Ship/Show/Ask | Duration | +|------|-------|-------------|---------|---------------|----------| +| Quick | 1→5→9 | No | Basic | Ship only | <30min | +| Standard | 0→1→2→3→5→6→7→8→8.5→9 | Yes | Full | All 3 | 1-4h | +| Full | All + Tech Selection + Feature Flags + PR | Yes (mandatory) | All 10 checks | All 3 | >4h | + +## Feature Flags + +| Flag | Default | Full Mode | Description | +|------|---------|-----------|-------------| +| `strictTdd` | false | **true** | Enforce strict TDD cycle | +| `ruleEnforcement` | true | true | Check 21 code quality rules | +| `autoCommit` | true | true | Auto-commit after task completion | +| `workingMemoryPersist` | true | true | Persist working memory across tasks | +| `dependencyParallelTasks` | true | true | Execute independent tasks in dependency order | +| `conventionalCommits` | true | true | Generate Conventional Commits messages | +| `qaGateBlocking` | false | **true** | Block delivery on QA failures | +| `githubIntegration` | true | true | Enable GitHub tag/release/merge steps | +| `coverageThreshold` | 80 | 80 | Minimum test coverage percentage | +| `maxFileLines` | 500 | 500 | Maximum lines per file | +| `maxFunctionLines` | 50 | 50 | Maximum lines per function | + +Flags can be overridden via `dev_workflow_start({ featureFlags: {...} })`. + +## Workflow Steps + +### Step 0: Project Analysis +- Scan project structure (package.json, tsconfig, git status) +- Detect OpenSpec integration +- Load `.dev-workflow.md` context file +- Initialize Working Memory +- Apply Feature Flag configuration + +### Step 1: Requirement Analysis +- Parse the user's requirement +- Determine complexity (quick/standard/full mode) +- Identify affected modules and estimated effort + +### Step 2: Brainstorming (Standard/Full mode) +- Explore 2-3 implementation approaches +- Present options with pros/cons and directory structures +- Wait for user confirmation + +### Step 3: Spec Definition (Standard/Full mode) +- Create proposal.md (what & why) +- Create design.md (how) +- Create tasks.json with Ship/Show/Ask categories +- Write to `openspec/changes/dev-workflow/` + +### Step 4: Tech Selection (Full mode) +- Select language, framework, architecture patterns +- Define design patterns (repository, factory, observer, etc.) +- Record in `.dev-workflow.md` + +### Step 5: Development +Create feature branch (`feature/-`). + +For each task, apply Ship/Show/Ask strategy: +- **Ship**: Skip review, direct commit +- **Show**: Commit then async review +- **Ask**: Review before commit + +TDD Cycle enforced (strict in Full mode or when `strictTdd` flag is set): +1. RED: Write failing test +2. GREEN: Minimal implementation +3. REFACTOR: Simplify +4. VERIFY: Run all tests +5. COMMIT: Conventional Commits (`type(scope): description`) + +Dependency-aware task scheduling with retry (2 retries). + +Rule enforcement embedded in agent prompts when `ruleEnforcement` flag is enabled. + +### Step 6: Review (Standard/Full) +- Code review via subagent +- Check for bugs, edge cases, test coverage + +### Step 7: Test (Standard/Full) +- Auto-detect test framework (vitest, jest, npm test) +- Run full test suite +- Record pass/fail + +### Step 8: Documentation (Standard/Full) +- Generate markdown docs from spec +- Write to `docs/generated.md` + +### Step 8.5: GitHub (Standard/Full, when `githubIntegration` enabled) +- Tag release (`v`) +- Push tag to origin +- Update repo description (if open source) +- Merge feature branch to main + +### Step 9: Delivery +- Generate delivery report with task stats +- Update `.dev-workflow.md` +- Report Ship/Show/Ask counts +- Run QA Gate (blocking in Full mode) + +## Agent Roles + +| Role | Responsibility | Model | +|------|---------------|-------| +| BrainstormAgent | Requirement exploration | MiniMax M2.5 | +| SpecAgent | Spec definition | MiniMax M2.5 | +| TechAgent | Technology selection | MiniMax M2.5 | +| CoderAgent | Code implementation + TDD | Based on difficulty | +| ReviewAgent | Code review | GLM-5.1 | +| TestAgent | Test validation | MiniMax M2.5 | +| DocsAgent | Documentation | MiniMax M2.5 | +| QAAgent | Quality gate (10 checks + rules) | GLM-5.1 | + +## Conventional Commits + +Auto-generated from task metadata (when `conventionalCommits` enabled): +- `feat(scope): description` — New features +- `fix(scope): description` — Bug fixes +- `test(scope): description` — Test additions +- `docs(scope): description` — Documentation +- `refactor(scope): description` — Refactoring +- `chore(scope): description` — Setup/config + +Scope inferred from file path (e.g., `src/engine/index.ts` → scope: `engine`). + +## Working Memory (3 Layers) + +| Layer | Location | Purpose | +|-------|----------|---------| +| Project | `.dev-workflow.md` | Architecture, constraints, decisions | +| Task | `docs/plans/-context.md` | Task-specific context, progress | +| Step | In-agent | Current step context | + +Working memory persistence controlled by `workingMemoryPersist` feature flag. + +## Tools Available + +| Tool | Description | +|------|-------------| +| `dev_workflow_start` | Start a workflow with requirement, mode, and feature flags | +| `workflow_status` | Check current workflow status | +| `task_execute` | Execute a specific task | +| `spec_view` | View current specification | +| `qa_gate_check` | Run 10 QA checks including rule enforcement | + +## QA Gate (10 Checks) + +1. **Lint** — `eslint .` or equivalent +2. **Format** — `prettier --check` or equivalent +3. **Tests** — `npm test` / `vitest run` +4. **Coverage** — Coverage threshold enforcement (configurable) +5. **TypeCheck** — `tsc --noEmit` +6. **Simplify** — Complex function/file detection +7. **Commits** — Conventional Commits format validation +8. **TODOs** — TODO/FIXME/HACK/XXX detection +9. **Docs** — README.md existence and content +10. **Rules** — 21 code quality rules enforcement + +## Rule Enforcement (21 Rules) + +When `ruleEnforcement` is enabled, rules are enforced both: +1. **In agent prompts** during task execution +2. **In QA Gate** as the 10th check + +### Error-level rules (blocking) +- No unused variables or imports +- No any type +- No duplicate code +- No debugger statements +- No hardcoded secrets/credentials +- No global state mutation + +### Warning-level rules (non-blocking) +- Prefer const over let +- No console.log (use logger) +- Explicit return types +- No magic numbers +- File size < maxFileLines (default: 500) +- Function size < maxFunctionLines (default: 50) +- No inline styles +- Prefer immutable patterns +- Avoid deep nesting (>3 levels) +- Meaningful names +- Single responsibility +- No commented-out code +- Prefer early return +- Avoid boolean parameters +- Prefer pure functions + +## Event Hooks + +| Hook | Action | +|------|--------| +| `session_start` | Initialize logging | +| `session_end` | Save context | +| `before_tool_call` | Log tool invocation | +| `after_tool_call` | Record results | diff --git a/extensions/dev-workflow/src/agents/index.ts b/extensions/dev-workflow/src/agents/index.ts new file mode 100755 index 0000000000000..f9f1cc8b5578a --- /dev/null +++ b/extensions/dev-workflow/src/agents/index.ts @@ -0,0 +1,452 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import type { + WorkflowTask, + WorkflowMode, + WorkflowSpec, + BrainstormOption, + AgentResult, + TechSelection, + FeatureFlags, +} from "../types.js"; +import { DEFAULT_FEATURE_FLAGS } from "../types.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from "fs"; +import { join } from "path"; + +const execAsync = promisify(exec); + +interface AnalysisResult { + summary: string; + hasOpenSpec: boolean; + gitStatus: string; +} + +interface RequirementAnalysisResult { + complexity: string; + estimatedFiles: number; + suggestedMode: WorkflowMode; + affectedModules: string[]; +} + +export class AgentOrchestrator { + private runtime: PluginRuntime; + + constructor(runtime: PluginRuntime) { + this.runtime = runtime; + } + + async runAnalysis(projectDir: string): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + logger.info(`Running project analysis for ${projectDir}`); + + const hasOpenSpec = existsSync(join(projectDir, "openspec")); + let gitStatus = "unknown"; + let summary = ""; + + try { + const { stdout: gitOut } = await execAsync("git status --porcelain", { cwd: projectDir, timeout: 10000 }); + gitStatus = gitOut.trim() ? "dirty" : "clean"; + } catch { + gitStatus = "not-a-git-repo"; + } + + let pkgInfo = ""; + const pkgPath = join(projectDir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + pkgInfo = `name: ${pkg.name || "unknown"}`; + if (pkg.scripts) pkgInfo += `, scripts: ${Object.keys(pkg.scripts).join(", ")}`; + } catch { /* skip */ } + } + + const tsConfig = existsSync(join(projectDir, "tsconfig.json")); + const dirs = readdirSync(projectDir).filter((e) => { + try { return statSync(join(projectDir, e)).isDirectory() && !e.startsWith("."); } catch { return false; } + }); + + summary = `Project: ${pkgInfo}. Dirs: ${dirs.join(", ")}. TS: ${tsConfig}. OpenSpec: ${hasOpenSpec}. Git: ${gitStatus}.`; + return { summary, hasOpenSpec, gitStatus }; + } + + async analyzeRequirement(requirement: string, projectDir: string, mode: WorkflowMode): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + const sessionKey = `dwf-analysis-${Date.now()}`; + + const systemPrompt = `You are a senior software architect. Return a JSON object with: +- "complexity": "low" | "medium" | "high" +- "estimatedFiles": number +- "affectedModules": string[] +Return ONLY valid JSON.`; + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Requirement: ${requirement}\nProject: ${projectDir}`, + extraSystemPrompt: systemPrompt, deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 120000 }); + if (waitResult.status !== "ok") return this.fallbackAnalysis(requirement); + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + const m = text.match(/\{[\s\S]*\}/); + if (m) { + const p = JSON.parse(m[0]); + return { + complexity: p.complexity ?? "medium", + estimatedFiles: p.estimatedFiles ?? 3, + suggestedMode: this.complexityToMode(p.complexity ?? "medium"), + affectedModules: p.affectedModules ?? [], + }; + } + } catch (e) { + logger.warn(`Analysis subagent failed: ${e}`); + } + return this.fallbackAnalysis(requirement); + } + + private fallbackAnalysis(requirement: string): RequirementAnalysisResult { + const wc = requirement.split(/\s+/).length; + const complexity = wc > 50 ? "high" : wc > 20 ? "medium" : "low"; + return { complexity, estimatedFiles: Math.max(1, Math.ceil(wc / 15)), suggestedMode: this.complexityToMode(complexity), affectedModules: [] }; + } + + private complexityToMode(c: string): WorkflowMode { + return c === "high" ? "full" : c === "medium" ? "standard" : "quick"; + } + + async brainstorm(requirement: string, projectDir: string): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + const sessionKey = `dwf-brainstorm-${Date.now()}`; + + const systemPrompt = `Propose 3 distinct implementation approaches. Return a JSON array where each entry has: +- "label": short name +- "description": 1-2 sentences +- "pros": string[] +- "cons": string[] +Return ONLY valid JSON.`; + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Requirement: ${requirement}\nProject: ${projectDir}`, + extraSystemPrompt: systemPrompt, deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 120000 }); + if (waitResult.status !== "ok") return this.defaultBrainstorm(); + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + const m = text.match(/\[[\s\S]*\]/); + if (m) return JSON.parse(m[0]); + } catch (e) { + logger.warn(`Brainstorm subagent failed: ${e}`); + } + return this.defaultBrainstorm(); + } + + private defaultBrainstorm(): BrainstormOption[] { + return [ + { label: "Minimal", description: "Simplest solution that meets the requirement", pros: ["Fast"], cons: ["Limited scalability"] }, + { label: "Standard", description: "Balanced approach with proper architecture", pros: ["Maintainable"], cons: ["More time"] }, + { label: "Full", description: "Comprehensive solution with full documentation", pros: ["Production-ready"], cons: ["Complex"] }, + ]; + } + + async defineSpec(requirement: string, projectDir: string, brainstormNotes: string[]): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + const sessionKey = `dwf-spec-${Date.now()}`; + const notes = brainstormNotes.length > 0 ? `\nBrainstorm notes:\n${brainstormNotes.join("\n")}` : ""; + + const systemPrompt = `You are a tech lead defining a spec. Return a JSON object with: +- "proposal": markdown string +- "design": markdown string +- "tasks": array of { id, title, description, difficulty ("easy"|"medium"|"hard"), estimatedMinutes, dependencies (string[]), files (string[]), shipCategory ("ship"|"show"|"ask") } +Return ONLY valid JSON.`; + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Requirement: ${requirement}${notes}`, + extraSystemPrompt: systemPrompt, deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 180000 }); + if (waitResult.status !== "ok") return this.defaultSpec(requirement); + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + const m = text.match(/\{[\s\S]*\}/); + if (m) { + const p = JSON.parse(m[0]); + return { + proposal: p.proposal ?? `# Proposal\n\n${requirement}`, + design: p.design ?? "# Design\n\nTBD", + tasks: (p.tasks ?? []).map((t: any, i: number) => ({ + id: t.id ?? `task-${i + 1}`, + title: t.title ?? `Task ${i + 1}`, + description: t.description ?? "", + status: "pending" as const, + difficulty: t.difficulty ?? "medium", + estimatedMinutes: t.estimatedMinutes ?? 30, + dependencies: t.dependencies ?? [], + files: t.files ?? [], + shipCategory: t.shipCategory ?? "show", + })), + updatedAt: new Date().toISOString(), + }; + } + } catch (e) { + logger.warn(`Spec subagent failed: ${e}`); + } + return this.defaultSpec(requirement); + } + + private defaultSpec(requirement: string): WorkflowSpec { + return { + proposal: `# Proposal\n\n${requirement}`, + design: "# Design\n\nArchitecture TBD.", + tasks: [ + { id: "task-1", title: "Setup", description: "Create project skeleton", status: "pending", difficulty: "easy", estimatedMinutes: 30, dependencies: [], files: ["package.json"], shipCategory: "ship" }, + { id: "task-2", title: "Core implementation", description: "Implement core logic", status: "pending", difficulty: "medium", estimatedMinutes: 60, dependencies: ["task-1"], files: ["src/index.ts"], shipCategory: "show" }, + { id: "task-3", title: "Tests", description: "Write unit tests", status: "pending", difficulty: "medium", estimatedMinutes: 45, dependencies: ["task-2"], files: ["tests/index.test.ts"], shipCategory: "show" }, + { id: "task-4", title: "Documentation", description: "Write docs", status: "pending", difficulty: "easy", estimatedMinutes: 30, dependencies: ["task-2"], files: ["README.md"], shipCategory: "ship" }, + ], + updatedAt: new Date().toISOString(), + }; + } + + async selectTech(requirement: string, projectDir: string, brainstormNotes: string[]): Promise { + const sessionKey = `dwf-tech-${Date.now()}`; + const notes = brainstormNotes.length > 0 ? `\nBrainstorm notes:\n${brainstormNotes.join("\n")}` : ""; + + const systemPrompt = `You are a tech lead selecting technologies. Return a JSON object with: +- "language": string +- "framework": string +- "architecture": string (e.g. "modular-monolith", "microservices", "layered") +- "patterns": string[] (e.g. ["repository", "factory", "observer"]) +- "notes": string +Return ONLY valid JSON.`; + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Requirement: ${requirement}${notes}\nProject: ${projectDir}`, + extraSystemPrompt: systemPrompt, deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 120000 }); + if (waitResult.status !== "ok") return this.defaultTech(); + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + const m = text.match(/\{[\s\S]*\}/); + if (m) { + const p = JSON.parse(m[0]); + return { + language: p.language ?? "TypeScript", + framework: p.framework ?? "Node.js", + architecture: p.architecture ?? "modular", + patterns: p.patterns ?? [], + notes: p.notes ?? "", + }; + } + } catch { /* skip */ } + return this.defaultTech(); + } + + private defaultTech(): TechSelection { + return { + language: "TypeScript", + framework: "Node.js", + architecture: "modular", + patterns: ["module", "factory"], + notes: "Default tech selection", + }; + } + + async executeTask(task: WorkflowTask, projectDir: string, mode: WorkflowMode, flags?: FeatureFlags): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + const start = Date.now(); + const sessionKey = `dwf-task-${task.id}-${Date.now()}`; + const effectiveFlags = flags ?? DEFAULT_FEATURE_FLAGS; + + const projectContext = await this.buildProjectContext(projectDir); + const workingMemory = effectiveFlags.workingMemoryPersist + ? this.loadWorkingMemory(projectDir, task.id) + : ""; + + const tddPrompt = mode === "quick" + ? "Write code and verify it works." + : mode === "full" || effectiveFlags.strictTdd + ? `Follow STRICT TDD cycle (mandatory): +1. RED: Write a failing test first that defines expected behavior +2. GREEN: Write the minimal implementation to make the test pass +3. REFACTOR: Simplify while keeping tests green +4. VERIFY: Run all tests to confirm no regressions +5. COMMIT: Prepare a Conventional Commits message + +DO NOT skip any step. Tests MUST fail first before implementation.` + : `Follow TDD cycle: +1. RED: Write a failing test first that defines expected behavior +2. GREEN: Write the minimal implementation to make the test pass +3. REFACTOR: Simplify while keeping tests green +4. VERIFY: Run all tests to confirm no regressions +5. COMMIT: Prepare a Conventional Commits message`; + + const rulesSection = effectiveFlags.ruleEnforcement + ? `\n\nCode Rules (enforced):\n- No unused variables or imports\n- Prefer const over let\n- No console.log (use logger)\n- Avoid any type\n- Functions < 50 lines, files < 500 lines\n- No hardcoded secrets\n- Prefer pure functions\n- Use early returns\n- Meaningful names\n` + : ""; + + const systemPrompt = `You are a senior engineer executing a task. +${tddPrompt} +${rulesSection} +Project context: +${projectContext} + +${workingMemory ? `Working memory:\n${workingMemory}\n` : ""}Task: ${task.title} - ${task.description} +Files: ${task.files.join(", ")} +Ship category: ${task.shipCategory} +Return a summary of what you did.`; + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Execute task **${task.title}**: ${task.description}\nFiles: ${task.files.join(", ")}\nShip: ${task.shipCategory}`, + extraSystemPrompt: systemPrompt, deliver: false, + }); + const timeout = mode === "full" ? 600000 : mode === "standard" ? 300000 : 180000; + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: timeout }); + + if (waitResult.status !== "ok") { + return { agentId: this.selectAgent(task.difficulty), task: task.id, success: false, output: `Failed: ${waitResult.error ?? waitResult.status}`, durationMs: Date.now() - start }; + } + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 10 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + + if (effectiveFlags.workingMemoryPersist) { + this.saveWorkingMemory(projectDir, task.id, text.slice(0, 1000)); + } + + return { agentId: this.selectAgent(task.difficulty), task: task.id, success: true, output: text.slice(0, 2000), durationMs: Date.now() - start }; + } catch (e) { + logger.error(`Task execution error: ${e}`); + return { agentId: this.selectAgent(task.difficulty), task: task.id, success: false, output: `Error: ${e}`, durationMs: Date.now() - start }; + } + } + + private loadWorkingMemory(projectDir: string, taskId: string): string { + const layers: string[] = []; + + const projectCtx = join(projectDir, ".dev-workflow.md"); + if (existsSync(projectCtx)) { + try { layers.push(`[Project] ${readFileSync(projectCtx, "utf-8").slice(0, 500)}`); } catch { /* skip */ } + } + + const taskCtx = join(projectDir, "docs", "plans", `${taskId}-context.md`); + if (existsSync(taskCtx)) { + try { layers.push(`[Task] ${readFileSync(taskCtx, "utf-8").slice(0, 500)}`); } catch { /* skip */ } + } + + return layers.join("\n"); + } + + private saveWorkingMemory(projectDir: string, taskId: string, content: string): void { + try { + const plansDir = join(projectDir, "docs", "plans"); + if (!existsSync(plansDir)) mkdirSync(plansDir, { recursive: true }); + writeFileSync(join(plansDir, `${taskId}-context.md`), `# Task ${taskId} Context\n\n${content}\n\nUpdated: ${new Date().toISOString()}\n`); + } catch { /* skip */ } + } + + private async buildProjectContext(projectDir: string): Promise { + const lines: string[] = []; + try { + const { stdout } = await execAsync("find . -maxdepth 3 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | head -50", { cwd: projectDir, timeout: 10000 }); + lines.push(`Files:\n${stdout.trim()}`); + } catch { /* skip */ } + + const pkgPath = join(projectDir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + lines.push(`Package: ${pkg.name || "unknown"}`); + if (pkg.scripts) lines.push(`Scripts: ${JSON.stringify(pkg.scripts)}`); + } catch { /* skip */ } + } + return lines.join("\n"); + } + + async runReview(projectDir: string): Promise { + const logger = this.runtime.logging.getChildLogger({ level: "info" }); + const sessionKey = `dwf-review-${Date.now()}`; + + const { stdout: diffOut } = await execAsync("git diff HEAD~1 --stat", { cwd: projectDir, timeout: 10000 }).catch(() => ({ stdout: "No recent commits" })); + const { stdout: logOut } = await execAsync("git log --oneline -5", { cwd: projectDir, timeout: 10000 }).catch(() => ({ stdout: "No git log" })); + + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Review recent changes:\nDiff:\n${diffOut}\nCommits:\n${logOut}`, + extraSystemPrompt: "You are a senior code reviewer. Review for quality, bugs, edge cases, test coverage. Start with APPROVE or REQUEST CHANGES. Return markdown.", deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 120000 }); + if (waitResult.status !== "ok") return `Review incomplete: ${waitResult.status}`; + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + return typeof last === "string" ? last : (last?.content ?? "Review completed"); + } catch (e) { + logger.warn(`Review subagent failed: ${e}`); + return "Review failed"; + } + } + + async runTests(projectDir: string): Promise<{ passed: boolean; output: string }> { + const pkgPath = join(projectDir, "package.json"); + let cmd = "npm test"; + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.test) cmd = pkg.scripts.test; + else if (existsSync(join(projectDir, "vitest.config.js")) || existsSync(join(projectDir, "vitest.config.ts"))) cmd = "npx vitest run"; + else if (existsSync(join(projectDir, "jest.config.js")) || existsSync(join(projectDir, "jest.config.ts"))) cmd = "npx jest"; + } catch { /* skip */ } + } + try { + const { stdout, stderr } = await execAsync(cmd, { cwd: projectDir, timeout: 120000, env: { ...process.env, CI: "true", NODE_ENV: "test" } }); + return { passed: true, output: stdout || stderr }; + } catch (e: any) { + return { passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + async generateDocs(projectDir: string, spec: WorkflowSpec | null): Promise { + if (!spec) return "No spec to generate docs from."; + + const sessionKey = `dwf-docs-${Date.now()}`; + try { + const runResult = await this.runtime.subagent.run({ + sessionKey, message: `Generate docs:\nProposal:\n${spec.proposal}\nDesign:\n${spec.design}\nTasks:\n${JSON.stringify(spec.tasks, null, 2)}`, + extraSystemPrompt: "You are a technical writer. Generate comprehensive markdown documentation.", deliver: false, + }); + const waitResult = await this.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: 120000 }); + if (waitResult.status !== "ok") return `Docs incomplete: ${waitResult.status}`; + + const msgResult = await this.runtime.subagent.getSessionMessages({ sessionKey, limit: 5 }); + const last = msgResult.messages[msgResult.messages.length - 1] as any; + const text = typeof last === "string" ? last : (last?.content ?? ""); + + const docsPath = join(projectDir, "docs"); + if (!existsSync(docsPath)) mkdirSync(docsPath, { recursive: true }); + try { writeFileSync(join(docsPath, "generated.md"), text); } catch { /* skip */ } + return text.slice(0, 3000); + } catch (e) { + return "Docs generation failed"; + } + } + + private selectAgent(difficulty: string): string { + return difficulty === "hard" ? "glm-5.1" : "minimax-m2.5"; + } +} diff --git a/extensions/dev-workflow/src/channel/dev-workflow-channel.ts b/extensions/dev-workflow/src/channel/dev-workflow-channel.ts new file mode 100755 index 0000000000000..b4610f288720b --- /dev/null +++ b/extensions/dev-workflow/src/channel/dev-workflow-channel.ts @@ -0,0 +1,42 @@ +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import type { DevWorkflowAccount } from "../types.js"; + +const CHANNEL_ID = "dev-workflow"; + +export const devWorkflowChannel = createChannelPluginBase({ + id: CHANNEL_ID, + meta: { + id: CHANNEL_ID, + label: "Dev Workflow", + selectionLabel: "Dev Workflow (AI-Driven Development)", + docsPath: "/channels/dev-workflow", + docsLabel: "dev-workflow", + blurb: "Spec-driven AI development workflow with multi-agent orchestration.", + order: 50, + aliases: ["dwf", "devflow"], + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct"], + nativeCommands: true, + media: true, + }, + setup: { + applyAccountConfig: (params: { cfg: OpenClawConfig; accountId: string; input: any }) => { + return params.cfg; + }, + }, + config: { + listAccountIds: (cfg: OpenClawConfig) => { + const accounts = (cfg as any).channels?.[CHANNEL_ID] ?? {}; + return Object.keys(accounts).filter((k) => k !== "allowFrom"); + }, + resolveAccount: (_cfg: OpenClawConfig, accountId?: string | null): DevWorkflowAccount => ({ + accountId: accountId ?? "default", + enabled: true, + }), + isEnabled: (account: DevWorkflowAccount) => account.enabled !== false, + isConfigured: () => true, + }, +}); diff --git a/extensions/dev-workflow/src/channel/runtime.ts b/extensions/dev-workflow/src/channel/runtime.ts new file mode 100755 index 0000000000000..0e79c4bdafc80 --- /dev/null +++ b/extensions/dev-workflow/src/channel/runtime.ts @@ -0,0 +1,24 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { DevWorkflowEngine } from "../engine/index.js"; + +let _runtime: PluginRuntime | null = null; +let _engine: DevWorkflowEngine | null = null; + +export function setDevWorkflowRuntime(runtime: PluginRuntime) { + _runtime = runtime; + _engine = new DevWorkflowEngine(runtime); +} + +export function getRuntime(): PluginRuntime { + if (!_runtime) { + throw new Error("DevWorkflow runtime not initialized. Ensure the plugin is loaded."); + } + return _runtime; +} + +export function getEngine(): DevWorkflowEngine { + if (!_engine) { + throw new Error("DevWorkflow engine not initialized. Ensure the plugin is loaded."); + } + return _engine; +} diff --git a/extensions/dev-workflow/src/engine/index.ts b/extensions/dev-workflow/src/engine/index.ts new file mode 100755 index 0000000000000..aaacd755835e2 --- /dev/null +++ b/extensions/dev-workflow/src/engine/index.ts @@ -0,0 +1,452 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import type { WorkflowContext, WorkflowMode, WorkflowTask, AgentResult, TechSelection, ConventionalCommit, FeatureFlags } from "../types.js"; +import { DEFAULT_FEATURE_FLAGS } from "../types.js"; +import { AgentOrchestrator } from "../agents/index.js"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; + +const CONTEXT_FILE = ".dev-workflow-context.json"; +const CONTEXT_MD_FILE = ".dev-workflow.md"; +const MAX_RETRIES = 2; + +export class DevWorkflowEngine { + private runtime: PluginRuntime; + private orchestrator: AgentOrchestrator; + private context: WorkflowContext | null = null; + private aborted = false; + + constructor(runtime: PluginRuntime) { + this.runtime = runtime; + this.orchestrator = new AgentOrchestrator(runtime); + } + + async initialize(projectDir: string, mode: WorkflowMode = "standard", featureFlags?: Partial): Promise { + const persisted = this.loadContext(projectDir); + if (persisted) { + this.context = persisted; + return this.context; + } + + const flags: FeatureFlags = { ...DEFAULT_FEATURE_FLAGS, ...featureFlags }; + + if (mode === "full") { + flags.strictTdd = true; + flags.qaGateBlocking = true; + } + + this.context = { + projectId: projectDir.split("/").pop() || "unknown", + projectDir, + mode, + currentStep: "step0-analysis", + spec: null, + activeTaskIndex: 0, + brainstormNotes: [], + decisions: [], + qaGateResults: [], + startedAt: new Date().toISOString(), + openSource: null, + branchName: null, + featureFlags: flags, + }; + + this.loadContextMd(projectDir); + + const analysis = await this.orchestrator.runAnalysis(projectDir); + this.context!.decisions.push(`Analysis: ${analysis.summary}`); + this.context!.openSource = analysis.hasOpenSpec; + this.persistContext(); + return this.context; + } + + abort(): void { + this.aborted = true; + } + + async executeWorkflow(requirement: string): Promise { + if (!this.context) throw new Error("Workflow not initialized."); + this.aborted = false; + + try { + await this.runStep("step1-requirement", async () => { + const analysis = await this.orchestrator.analyzeRequirement(requirement, this.context!.projectDir, this.context!.mode); + this.context!.decisions.push(`Requirement: complexity=${analysis.complexity}, files=${analysis.estimatedFiles}`); + }); + + if (this.aborted) return this.buildReport(); + + if (this.context.mode !== "quick") { + await this.runStep("step2-brainstorm", async () => { + const options = await this.orchestrator.brainstorm(requirement, this.context!.projectDir); + this.context!.brainstormNotes = options.map((o) => `${o.label}: ${o.description}`); + }); + } + + if (this.aborted) return this.buildReport(); + + await this.runStep("step3-spec", async () => { + this.context!.spec = await this.orchestrator.defineSpec(requirement, this.context!.projectDir, this.context!.brainstormNotes); + const openspecDir = join(this.context!.projectDir, "openspec", "changes", "dev-workflow"); + try { + if (!existsSync(openspecDir)) mkdirSync(openspecDir, { recursive: true }); + writeFileSync(join(openspecDir, "proposal.md"), this.context!.spec.proposal); + writeFileSync(join(openspecDir, "design.md"), this.context!.spec.design); + writeFileSync(join(openspecDir, "tasks.json"), JSON.stringify(this.context!.spec.tasks, null, 2)); + } catch { /* skip */ } + }); + + if (this.aborted) return this.buildReport(); + + if (this.context.mode === "full") { + await this.runStep("step4-tech-selection", async () => { + const tech = await this.orchestrator.selectTech(requirement, this.context!.projectDir, this.context!.brainstormNotes); + this.context!.decisions.push(`Tech: ${tech.language}/${tech.framework} - ${tech.architecture} [${tech.patterns.join(", ")}]`); + this.updateContextMd("tech-selection", `Language: ${tech.language}\nFramework: ${tech.framework}\nArchitecture: ${tech.architecture}\nPatterns: ${tech.patterns.join(", ")}`); + }); + + if (this.aborted) return this.buildReport(); + } + + await this.runStep("step5-development", async () => { + if (!this.context!.spec) return; + const featureBranch = `feature/${this.context!.projectId}-${Date.now()}`; + this.createBranch(featureBranch); + this.context!.branchName = featureBranch; + this.persistContext(); + }); + + if (this.aborted) return this.buildReport(); + + await this.executeAllTasks(); + + if (this.aborted) return this.buildReport(); + + if (this.context.mode !== "quick") { + await this.runStep("step6-review", async () => { + const review = await this.orchestrator.runReview(this.context!.projectDir); + this.context!.decisions.push(`Review: ${review.slice(0, 200)}`); + }); + + await this.runStep("step7-test", async () => { + const tests = await this.orchestrator.runTests(this.context!.projectDir); + this.context!.decisions.push(`Tests: ${tests.passed ? "PASSED" : "FAILED"}`); + }); + + await this.runStep("step8-docs", async () => { + await this.orchestrator.generateDocs(this.context!.projectDir, this.context!.spec); + }); + } + + if (this.aborted) return this.buildReport(); + + if (this.context.mode !== "quick" && this.context.featureFlags.githubIntegration) { + await this.runStep("step8.5-github", async () => { + await this.runGitHubSteps(); + }); + } + + this.context.currentStep = "step9-delivery"; + this.updateContextMd("delivery", `Completed at ${new Date().toISOString()}`); + this.persistContext(); + } catch (e) { + if (this.context) { + this.context.decisions.push(`Workflow error at ${this.context.currentStep}: ${e}`); + this.persistContext(); + } + } + + return this.buildReport(); + } + + private async runGitHubSteps(): Promise { + if (!this.context?.spec) return; + const dir = this.context.projectDir; + + try { + const version = this.getVersion(dir); + const tag = `v${version}`; + + execSync("gh auth status", { cwd: dir, stdio: "pipe", timeout: 10000 }); + execSync(`git tag -a ${tag} -m "Release ${tag}"`, { cwd: dir, stdio: "pipe", timeout: 10000 }); + execSync(`git push origin ${tag}`, { cwd: dir, stdio: "pipe", timeout: 30000 }); + this.context.decisions.push(`GitHub: tagged ${tag}`); + + if (this.context.openSource) { + const desc = this.context.spec.proposal.split("\n")[0].replace(/^#+\s*/, "").trim(); + execSync(`gh repo edit --description "${desc.slice(0, 100)}"`, { cwd: dir, stdio: "pipe", timeout: 10000 }); + this.context.decisions.push("GitHub: updated repo description"); + } + + if (this.context.branchName) { + execSync("git checkout main", { cwd: dir, stdio: "pipe", timeout: 10000 }); + execSync(`git merge --no-ff ${this.context.branchName} -m "Merge ${this.context.branchName}"`, { cwd: dir, stdio: "pipe", timeout: 15000 }); + execSync("git push origin main", { cwd: dir, stdio: "pipe", timeout: 30000 }); + this.context.decisions.push(`GitHub: merged ${this.context.branchName} to main`); + } + } catch (e) { + this.context!.decisions.push(`GitHub step skipped: ${e}`); + } + } + + private getVersion(dir: string): string { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.version) return pkg.version; + } catch { /* skip */ } + } + const date = new Date(); + return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`; + } + + private createBranch(branchName: string): void { + if (!this.context) return; + try { + execSync("git rev-parse --is-inside-work-tree", { cwd: this.context.projectDir, stdio: "pipe", timeout: 5000 }); + execSync(`git checkout -b ${branchName}`, { cwd: this.context.projectDir, stdio: "pipe", timeout: 10000 }); + } catch { + this.context!.decisions.push(`Branch creation skipped: ${branchName}`); + } + } + + private loadContextMd(projectDir: string): void { + const p = join(projectDir, CONTEXT_MD_FILE); + if (!existsSync(p)) return; + try { + const content = readFileSync(p, "utf-8"); + if (this.context) { + this.context.decisions.push(`Context file loaded: ${content.length} chars`); + } + } catch { /* skip */ } + } + + private updateContextMd(section: string, content: string): void { + if (!this.context) return; + const p = join(this.context.projectDir, CONTEXT_MD_FILE); + const existing = existsSync(p) ? readFileSync(p, "utf-8") : ""; + const entry = `\n## ${section}\n${content}\n`; + try { + writeFileSync(p, existing + entry); + } catch { /* skip */ } + } + + private async runStep(step: WorkflowContext["currentStep"], fn: () => Promise): Promise { + if (this.aborted || !this.context) return; + this.context.currentStep = step; + this.persistContext(); + await fn(); + this.persistContext(); + } + + private async executeAllTasks(): Promise { + if (!this.context?.spec) return; + const tasks = this.context.spec.tasks.filter((t) => t.status === "pending"); + + const completed = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id)); + const failed = new Set(); + let progress = true; + + while (progress && !this.aborted) { + progress = false; + const batch: WorkflowTask[] = []; + + for (const task of tasks) { + if (task.status !== "pending") continue; + const depsOk = task.dependencies.every((dep) => completed.has(dep)); + const depsFailed = task.dependencies.some((dep) => failed.has(dep)); + if (depsFailed) { + task.status = "cancelled"; + failed.add(task.id); + this.context!.decisions.push(`Task ${task.id}: CANCELLED (dependency failed)`); + progress = true; + continue; + } + if (depsOk) batch.push(task); + } + + if (batch.length === 0) break; + + const independent = batch.filter((t) => t.dependencies.length === 0); + const dependent = batch.filter((t) => t.dependencies.length > 0); + const ordered = [...independent, ...dependent]; + + for (const task of ordered) { + if (this.aborted) break; + task.status = "in_progress"; + this.persistContext(); + + const result = await this.executeTaskWithShipStrategy(task); + task.status = result.success ? "completed" : "failed"; + if (result.success) { + completed.add(task.id); + } else { + failed.add(task.id); + } + this.context!.decisions.push(`Task ${task.id}: ${result.success ? "OK" : "FAIL"} (${result.durationMs}ms) [${task.shipCategory}]`); + progress = true; + this.persistContext(); + } + } + } + + private async executeTaskWithShipStrategy(task: WorkflowTask): Promise { + let lastResult: AgentResult = { + agentId: "unknown", + task: task.id, + success: false, + output: "Not attempted", + durationMs: 0, + }; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (this.aborted) break; + try { + lastResult = await this.orchestrator.executeTask(task, this.context!.projectDir, this.context!.mode, this.context!.featureFlags); + if (lastResult.success) { + if (this.context!.featureFlags.autoCommit) { + await this.applyShipStrategy(task); + } else { + this.context!.decisions.push(`Task ${task.id}: completed (auto-commit disabled)`); + } + return lastResult; + } + if (attempt < MAX_RETRIES) { + this.context!.decisions.push(`Task ${task.id}: retry ${attempt + 1}/${MAX_RETRIES}`); + } + } catch (e) { + lastResult = { agentId: "unknown", task: task.id, success: false, output: `Exception: ${e}`, durationMs: 0 }; + if (attempt < MAX_RETRIES) { + this.context!.decisions.push(`Task ${task.id}: retry ${attempt + 1}/${MAX_RETRIES} after exception`); + } + } + } + + return lastResult; + } + + private async applyShipStrategy(task: WorkflowTask): Promise { + if (!this.context) return; + const commit = this.context.featureFlags.conventionalCommits + ? this.generateCommitMessage(task) + : task.title; + + switch (task.shipCategory) { + case "ship": + this.gitCommit(commit); + this.context.decisions.push(`Ship: ${commit}`); + break; + case "show": + this.gitCommit(commit); + this.context.decisions.push(`Show: ${commit} (async review)`); + break; + case "ask": { + const review = await this.orchestrator.runReview(this.context.projectDir); + if (review.includes("APPROVE") || review.includes("approve") || review.includes("looks good")) { + this.gitCommit(commit); + this.context.decisions.push(`Ask→Approved: ${commit}`); + } else { + this.context.decisions.push(`Ask→Blocked: ${commit} - review: ${review.slice(0, 200)}`); + } + break; + } + } + } + + private generateCommitMessage(task: WorkflowTask): string { + const type = this.inferCommitType(task); + const scope = task.files.length > 0 ? this.inferScope(task.files[0]) : ""; + const desc = task.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const scopeStr = scope ? `(${scope})` : ""; + return `${type}${scopeStr}: ${desc}`; + } + + private inferCommitType(task: WorkflowTask): string { + const t = task.title.toLowerCase(); + const d = task.description.toLowerCase(); + if (t.includes("test") || d.includes("test")) return "test"; + if (t.includes("doc") || d.includes("doc")) return "docs"; + if (t.includes("fix") || d.includes("fix") || d.includes("bug")) return "fix"; + if (t.includes("refactor") || d.includes("refactor")) return "refactor"; + if (t.includes("setup") || t.includes("config") || t.includes("init")) return "chore"; + return "feat"; + } + + private inferScope(filePath: string): string { + const parts = filePath.replace(/\\/g, "/").split("/"); + if (parts.length >= 2 && parts[0] === "src") return parts[1].replace(/\.[^.]+$/, ""); + if (parts.length >= 1) return parts[0].replace(/\.[^.]+$/, ""); + return ""; + } + + private gitCommit(message: string): void { + if (!this.context) return; + try { + execSync("git add -A", { cwd: this.context.projectDir, stdio: "pipe", timeout: 10000 }); + execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: this.context.projectDir, stdio: "pipe", timeout: 10000 }); + } catch (e) { + this.context!.decisions.push(`Commit skipped: ${message}`); + } + } + + private buildReport(): string { + if (!this.context) return "No context."; + const spec = this.context.spec; + const completed = spec?.tasks.filter((t) => t.status === "completed").length ?? 0; + const total = spec?.tasks.length ?? 0; + const elapsed = Date.now() - new Date(this.context.startedAt).getTime(); + const mins = Math.floor(elapsed / 60000); + const secs = Math.floor((elapsed % 60000) / 1000); + + const shipCounts = { + ship: spec?.tasks.filter((t) => t.shipCategory === "ship" && t.status === "completed").length ?? 0, + show: spec?.tasks.filter((t) => t.shipCategory === "show" && t.status === "completed").length ?? 0, + ask: spec?.tasks.filter((t) => t.shipCategory === "ask" && t.status === "completed").length ?? 0, + }; + + const lines = [ + `# Delivery Report`, + `Project: ${this.context.projectId} | Mode: ${this.context.mode} | Duration: ${mins}m ${secs}s`, + `Tasks: ${completed}/${total} completed (ship:${shipCounts.ship} show:${shipCounts.show} ask:${shipCounts.ask})`, + `Branch: ${this.context.branchName ?? "N/A"}`, + ``, + spec ? spec.proposal : "No spec generated.", + ]; + + if (this.context.decisions.length > 0) { + lines.push(``, `## Decisions`); + for (const d of this.context.decisions) lines.push(`- ${d}`); + } + + if (this.context.qaGateResults.length > 0) { + lines.push(``, `## QA Gate`); + for (const c of this.context.qaGateResults) lines.push(`- [${c.passed ? "x" : " "}] ${c.name}`); + } + + return lines.join("\n"); + } + + private persistContext() { + if (!this.context) return; + try { writeFileSync(join(this.context.projectDir, CONTEXT_FILE), JSON.stringify(this.context, null, 2)); } catch { /* skip */ } + } + + private loadContext(projectDir: string): WorkflowContext | null { + const p = join(projectDir, CONTEXT_FILE); + if (!existsSync(p)) return null; + try { return JSON.parse(readFileSync(p, "utf-8")) as WorkflowContext; } catch { return null; } + } + + getContext(): WorkflowContext | null { + return this.context; + } + + getOrchestrator(): AgentOrchestrator { + return this.orchestrator; + } + + saveContext(): void { + this.persistContext(); + } +} diff --git a/extensions/dev-workflow/src/hooks/index.ts b/extensions/dev-workflow/src/hooks/index.ts new file mode 100755 index 0000000000000..a4be19bfa22a4 --- /dev/null +++ b/extensions/dev-workflow/src/hooks/index.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +export function registerDevWorkflowHooks(api: OpenClawPluginApi) { + api.registerHook("session_start", async (event: any) => { + api.logger.info(`[dev-workflow] Session started: ${event?.sessionKey ?? "unknown"}`); + }); + + api.registerHook("session_end", async (event: any) => { + api.logger.info(`[dev-workflow] Session ended: ${event?.sessionKey ?? "unknown"}`); + }); + + api.registerHook("before_tool_call", async (event: any) => { + api.logger.info(`[dev-workflow] Tool about to be called: ${event?.toolName ?? "unknown"}`); + }); + + api.registerHook("after_tool_call", async (event: any) => { + api.logger.info(`[dev-workflow] Tool call completed: ${event?.toolName ?? "unknown"}`); + }); +} diff --git a/extensions/dev-workflow/src/index.ts b/extensions/dev-workflow/src/index.ts new file mode 100755 index 0000000000000..5f4155aca5a77 --- /dev/null +++ b/extensions/dev-workflow/src/index.ts @@ -0,0 +1,22 @@ +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { devWorkflowChannel } from "./channel/dev-workflow-channel.js"; +import { setDevWorkflowRuntime } from "./channel/runtime.js"; +import { registerDevWorkflowTools } from "./tools/index.js"; +import { registerDevWorkflowHooks } from "./hooks/index.js"; + +export { devWorkflowChannel } from "./channel/dev-workflow-channel.js"; +export { setDevWorkflowRuntime } from "./channel/runtime.js"; +export { DevWorkflowEngine } from "./engine/index.js"; +export { AgentOrchestrator } from "./agents/index.js"; + +export default defineChannelPluginEntry({ + id: "dev-workflow", + name: "Dev Workflow", + description: "AI-driven spec-driven development workflow with multi-agent orchestration", + plugin: devWorkflowChannel, + setRuntime: setDevWorkflowRuntime, + registerFull(api) { + registerDevWorkflowTools(api); + registerDevWorkflowHooks(api); + }, +}); diff --git a/extensions/dev-workflow/src/tools/dev-workflow-tool.ts b/extensions/dev-workflow/src/tools/dev-workflow-tool.ts new file mode 100755 index 0000000000000..48579d2512a60 --- /dev/null +++ b/extensions/dev-workflow/src/tools/dev-workflow-tool.ts @@ -0,0 +1,50 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { z } from "zod"; +import { getEngine } from "../channel/runtime.js"; +import type { FeatureFlags } from "../types.js"; + +export class DevWorkflowTool implements AnyAgentTool { + name = "dev_workflow_start"; + label = "Start Dev Workflow"; + description = "Start an AI-driven development workflow for a given requirement. Supports quick, standard, and full complexity modes with configurable feature flags."; + parameters = z.object({ + requirement: z.string().describe("The development requirement or feature request"), + projectDir: z.string().describe("Absolute path to the project directory"), + mode: z.enum(["quick", "standard", "full"]).optional().describe("Complexity mode (default: standard)"), + featureFlags: z.object({ + strictTdd: z.boolean().optional(), + ruleEnforcement: z.boolean().optional(), + autoCommit: z.boolean().optional(), + workingMemoryPersist: z.boolean().optional(), + dependencyParallelTasks: z.boolean().optional(), + conventionalCommits: z.boolean().optional(), + qaGateBlocking: z.boolean().optional(), + githubIntegration: z.boolean().optional(), + coverageThreshold: z.number().optional(), + maxFileLines: z.number().optional(), + maxFunctionLines: z.number().optional(), + }).optional().describe("Optional feature flag overrides"), + }); + + async execute(_toolCallId: string, input: z.infer) { + const engine = getEngine(); + const context = await engine.initialize(input.projectDir, input.mode ?? "standard", input.featureFlags as Partial); + const report = await engine.executeWorkflow(input.requirement); + + const result = { + success: true, + context: { + projectId: context.projectId, + mode: context.mode, + currentStep: context.currentStep, + tasksCount: context.spec?.tasks.length ?? 0, + }, + report, + }; + + return { + content: [{ type: "text" as const, text: report }], + details: result, + }; + } +} diff --git a/extensions/dev-workflow/src/tools/index.ts b/extensions/dev-workflow/src/tools/index.ts new file mode 100755 index 0000000000000..da37e57dacedc --- /dev/null +++ b/extensions/dev-workflow/src/tools/index.ts @@ -0,0 +1,14 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { DevWorkflowTool } from "./dev-workflow-tool.js"; +import { WorkflowStatusTool } from "./workflow-status-tool.js"; +import { TaskExecuteTool } from "./task-execute-tool.js"; +import { SpecViewTool } from "./spec-view-tool.js"; +import { QAGateTool } from "./qa-gate-tool.js"; + +export function registerDevWorkflowTools(api: OpenClawPluginApi) { + api.registerTool(new DevWorkflowTool()); + api.registerTool(new WorkflowStatusTool()); + api.registerTool(new TaskExecuteTool()); + api.registerTool(new SpecViewTool()); + api.registerTool(new QAGateTool()); +} diff --git a/extensions/dev-workflow/src/tools/qa-gate-tool.ts b/extensions/dev-workflow/src/tools/qa-gate-tool.ts new file mode 100755 index 0000000000000..9fd0c3db722d1 --- /dev/null +++ b/extensions/dev-workflow/src/tools/qa-gate-tool.ts @@ -0,0 +1,553 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { z } from "zod"; +import { getEngine } from "../channel/runtime.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import type { DevWorkflowRule } from "../types.js"; +import { DEV_WORKFLOW_RULES } from "../types.js"; + +const execAsync = promisify(exec); + +export class QAGateTool implements AnyAgentTool { + name = "qa_gate_check"; + label = "QA Gate Check"; + description = "Run the QA gate check to verify code quality before delivery. Checks lint, format, tests, coverage, typecheck, simplify, commmits, todos, docs, and rule enforcement."; + parameters = z.object({ + projectDir: z.string().describe("Absolute path to the project directory"), + checks: z.array(z.enum(["lint", "format", "tests", "coverage", "typecheck", "simplify", "commits", "todos", "docs", "rules"])).optional().describe("Specific checks to run (default: all)"), + }); + + async execute(_toolCallId: string, input: z.infer) { + const engine = getEngine(); + const context = engine.getContext(); + + if (!context) { + return { + content: [{ type: "text" as const, text: "No active workflow. Start one first." }], + details: { success: false, error: "No active workflow" }, + }; + } + + const checksToRun = input.checks ?? ["lint", "format", "tests", "coverage", "typecheck", "simplify", "commits", "todos", "docs", "rules"]; + const results: Array<{ name: string; passed: boolean; output?: string }> = []; + + for (const check of checksToRun) { + const result = await this.runCheck(check, input.projectDir); + results.push(result); + } + + context.qaGateResults = results; + + const allPassed = results.every((r) => r.passed); + const failed = results.filter((r) => !r.passed); + + const summaryText = results.map((r) => `${r.passed ? "✅" : "❌"} ${r.name}: ${r.output ?? ""}`).join("\n"); + + return { + content: [{ type: "text" as const, text: summaryText }], + details: { + success: allPassed, + checks: results, + summary: allPassed + ? "All QA gate checks passed." + : `${failed.length} check(s) failed: ${failed.map((r) => r.name).join(", ")}`, + }, + }; + } + + private async runCheck(check: string, projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const logger = this.getLogger(); + logger.info(`Running QA check: ${check} for ${projectDir}`); + + switch (check) { + case "lint": + return this.runLintCheck(projectDir); + case "format": + return this.runFormatCheck(projectDir); + case "tests": + return this.runTestsCheck(projectDir); + case "coverage": + return this.runCoverageCheck(projectDir); + case "typecheck": + return this.runTypeCheck(projectDir); + case "simplify": + return this.runSimplifyCheck(projectDir); + case "commits": + return this.runCommitsCheck(projectDir); + case "todos": + return this.runTodosCheck(projectDir); + case "docs": + return this.runDocsCheck(projectDir); + case "rules": + return this.runRulesCheck(projectDir); + default: + return { name: check, passed: true, output: `Check ${check} skipped - not implemented` }; + } + } + + private async runLintCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const pkgPath = join(projectDir, "package.json"); + let lintCommand: string | null = null; + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.lint) { + lintCommand = pkg.scripts.lint; + } else if (pkg.scripts?.["lint:fix"]) { + lintCommand = pkg.scripts["lint:fix"]; + } + } catch { + // skip + } + } + + if (!lintCommand) { + const hasEslint = existsSync(join(projectDir, ".eslintrc.js")) || existsSync(join(projectDir, ".eslintrc.json")) || existsSync(join(projectDir, "eslint.config.js")) || existsSync(join(projectDir, "eslint.config.mjs")); + if (hasEslint) { + lintCommand = "npx eslint . --max-warnings=0"; + } + } + + if (!lintCommand) { + return { name: "lint", passed: true, output: "No lint configuration found - skipping" }; + } + + try { + const { stdout, stderr } = await execAsync(lintCommand, { + cwd: projectDir, + timeout: 60000, + }); + return { name: "lint", passed: true, output: stdout || stderr || "Lint passed" }; + } catch (e: any) { + return { name: "lint", passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + private async runFormatCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const pkgPath = join(projectDir, "package.json"); + let formatCommand: string | null = null; + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.format) { + formatCommand = pkg.scripts.format; + } + } catch { + // skip + } + } + + if (!formatCommand) { + const hasPrettier = existsSync(join(projectDir, ".prettierrc")) || existsSync(join(projectDir, ".prettierrc.json")) || existsSync(join(projectDir, "prettier.config.js")); + if (hasPrettier) { + formatCommand = "npx prettier --check ."; + } + } + + if (!formatCommand) { + return { name: "format", passed: true, output: "No formatter configuration found - skipping" }; + } + + try { + const { stdout, stderr } = await execAsync(formatCommand, { + cwd: projectDir, + timeout: 60000, + }); + return { name: "format", passed: true, output: stdout || stderr || "Format check passed" }; + } catch (e: any) { + return { name: "format", passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + private async runTestsCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const pkgPath = join(projectDir, "package.json"); + let testCommand = "npm test"; + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.test) { + testCommand = pkg.scripts.test; + } else if (existsSync(join(projectDir, "jest.config.js")) || existsSync(join(projectDir, "jest.config.ts"))) { + testCommand = "npx jest --passWithNoTests"; + } else if (existsSync(join(projectDir, "vitest.config.js")) || existsSync(join(projectDir, "vitest.config.ts"))) { + testCommand = "npx vitest run --passWithNoTests"; + } + } catch { + // skip + } + } + + try { + const { stdout, stderr } = await execAsync(testCommand, { + cwd: projectDir, + timeout: 120000, + env: { ...process.env, CI: "true", NODE_ENV: "test" }, + }); + return { name: "tests", passed: true, output: stdout || stderr || "Tests passed" }; + } catch (e: any) { + return { name: "tests", passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + private async runCoverageCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const pkgPath = join(projectDir, "package.json"); + let coverageCommand: string | null = null; + const threshold = 80; + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.["test:coverage"]) { + coverageCommand = pkg.scripts["test:coverage"]; + } else if (pkg.scripts?.coverage) { + coverageCommand = pkg.scripts.coverage; + } else if (pkg.scripts?.test) { + const testScript = pkg.scripts.test; + if (testScript.includes("jest")) { + coverageCommand = testScript.replace("jest", "jest --coverage"); + } else if (testScript.includes("vitest")) { + coverageCommand = testScript.replace("vitest", "vitest run --coverage"); + } else { + coverageCommand = `${testScript} --coverage`; + } + } + } catch { + // skip + } + } + + if (!coverageCommand) { + return { name: "coverage", passed: true, output: "No coverage configuration found - skipping" }; + } + + try { + const { stdout, stderr } = await execAsync(coverageCommand, { + cwd: projectDir, + timeout: 120000, + env: { ...process.env, CI: "true", NODE_ENV: "test" }, + }); + const output = stdout || stderr; + + const coverageMatch = output.match(/All files\s*\|\s*([\d.]+)/); + if (coverageMatch) { + const coverage = parseFloat(coverageMatch[1]); + const passed = coverage >= threshold; + return { name: "coverage", passed, output: `Coverage: ${coverage}% (threshold: ${threshold}%)` }; + } + + return { name: "coverage", passed: true, output: `Coverage check completed: ${output.slice(0, 500)}` }; + } catch (e: any) { + return { name: "coverage", passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + private async runTypeCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const pkgPath = join(projectDir, "package.json"); + let typecheckCommand: string | null = null; + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.["type-check"]) { + typecheckCommand = pkg.scripts["type-check"]; + } else if (pkg.scripts?.typecheck) { + typecheckCommand = pkg.scripts.typecheck; + } else if (pkg.scripts?.["typecheck:ci"]) { + typecheckCommand = pkg.scripts["typecheck:ci"]; + } + } catch { + // skip + } + } + + if (!typecheckCommand && existsSync(join(projectDir, "tsconfig.json"))) { + typecheckCommand = "npx tsc --noEmit"; + } + + if (!typecheckCommand) { + return { name: "typecheck", passed: true, output: "No TypeScript configuration found - skipping" }; + } + + try { + const { stdout, stderr } = await execAsync(typecheckCommand, { + cwd: projectDir, + timeout: 120000, + }); + return { name: "typecheck", passed: true, output: stdout || stderr || "Type check passed" }; + } catch (e: any) { + return { name: "typecheck", passed: false, output: e.stdout ? `${e.stdout}\n${e.stderr}` : e.message }; + } + } + + private async runSimplifyCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const logger = this.getLogger(); + logger.info("Running simplify check - checking for complex functions and long files"); + + try { + const { stdout: findOut } = await execAsync("find . -name '*.ts' -o -name '*.js' -o -name '*.tsx' -o -name '*.jsx' | grep -v node_modules | grep -v dist | head -20", { + cwd: projectDir, + timeout: 10000, + }); + + const files = findOut.trim().split("\n").filter(Boolean); + const issues: string[] = []; + + for (const file of files.slice(0, 10)) { + const fullPath = join(projectDir, file); + if (!existsSync(fullPath)) continue; + + try { + const content = readFileSync(fullPath, "utf-8"); + const lines = content.split("\n"); + + if (lines.length > 500) { + issues.push(`${file}: ${lines.length} lines (consider splitting)`); + } + + let maxFuncLength = 0; + let currentFuncLength = 0; + let inFunction = false; + + for (const line of lines) { + if (/^\s*(async\s+)?function\s+\w+|^\s*const\s+\w+\s*=\s*(async\s+)?\(/.test(line)) { + if (inFunction && currentFuncLength > maxFuncLength) { + maxFuncLength = currentFuncLength; + } + inFunction = true; + currentFuncLength = 1; + } else if (inFunction) { + currentFuncLength++; + if (/^\s*\}/.test(line) && currentFuncLength > 50) { + maxFuncLength = Math.max(maxFuncLength, currentFuncLength); + inFunction = false; + } + } + } + + if (maxFuncLength > 50) { + issues.push(`${file}: function with ${maxFuncLength} lines detected (consider refactoring)`); + } + } catch { + // skip unreadable files + } + } + + if (issues.length > 0) { + return { name: "simplify", passed: false, output: issues.join("\n") }; + } + + return { name: "simplify", passed: true, output: "No simplification opportunities detected" }; + } catch (e: any) { + return { name: "simplify", passed: true, output: `Simplify check skipped: ${e.message}` }; + } + } + + private async runCommitsCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + try { + const { stdout: logOut } = await execAsync("git log --oneline -10", { + cwd: projectDir, + timeout: 10000, + }); + + const commits = logOut.trim().split("\n").filter(Boolean); + if (commits.length === 0) { + return { name: "commits", passed: true, output: "No commits to check" }; + } + + const conventionalCommitRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(\w+\))?:/; + const nonConforming = commits.filter((commit) => { + const message = commit.split(" ").slice(1).join(" "); + return !conventionalCommitRegex.test(message); + }); + + if (nonConforming.length > 0) { + return { + name: "commits", + passed: false, + output: `Non-conforming commits found:\n${nonConforming.join("\n")}\n\nUse Conventional Commits format: type(scope): description`, + }; + } + + return { name: "commits", passed: true, output: `All ${commits.length} recent commits follow Conventional Commits format` }; + } catch (e: any) { + return { name: "commits", passed: true, output: "Not a git repository or cannot read git log" }; + } + } + + private async runTodosCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + try { + const { stdout: grepOut } = await execAsync("grep -rn 'TODO\\|FIXME\\|HACK\\|XXX' --include='*.ts' --include='*.js' --include='*.tsx' --include='*.jsx' . | grep -v node_modules | grep -v dist | head -20", { + cwd: projectDir, + timeout: 10000, + }); + + const todos = grepOut.trim().split("\n").filter(Boolean); + if (todos.length === 0) { + return { name: "todos", passed: true, output: "No TODO/FIXME/HACK/XXX comments found" }; + } + + return { + name: "todos", + passed: false, + output: `Found ${todos.length} TODO/FIXME/HACK/XXX comments:\n${todos.join("\n")}`, + }; + } catch (e: any) { + if (e.code === 1) { + return { name: "todos", passed: true, output: "No TODO/FIXME/HACK/XXX comments found" }; + } + return { name: "todos", passed: true, output: `TODO check skipped: ${e.message}` }; + } + } + + private async runDocsCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const hasReadme = existsSync(join(projectDir, "README.md")); + const hasDocs = existsSync(join(projectDir, "docs")); + const hasGeneratedDocs = existsSync(join(projectDir, "docs", "generated.md")); + + if (!hasReadme) { + return { name: "docs", passed: false, output: "README.md not found" }; + } + + try { + const readmeContent = readFileSync(join(projectDir, "README.md"), "utf-8"); + if (readmeContent.length < 50) { + return { name: "docs", passed: false, output: "README.md is too short (less than 50 characters)" }; + } + } catch { + return { name: "docs", passed: false, output: "Could not read README.md" }; + } + + const messages = ["README.md exists and has content"]; + if (hasDocs) messages.push("docs/ directory exists"); + if (hasGeneratedDocs) messages.push("Generated documentation found"); + + return { name: "docs", passed: true, output: messages.join(". ") }; + } + + private async runRulesCheck(projectDir: string): Promise<{ name: string; passed: boolean; output?: string }> { + const context = getEngine().getContext(); + if (!context?.featureFlags.ruleEnforcement) { + return { name: "rules", passed: true, output: "Rule enforcement disabled via feature flags" }; + } + + const violations: string[] = []; + const rules = DEV_WORKFLOW_RULES; + const maxFileLines = context.featureFlags.maxFileLines; + const maxFunctionLines = context.featureFlags.maxFunctionLines; + + try { + const { stdout: findOut } = await execAsync( + "find . -name '*.ts' -o -name '*.js' -o -name '*.tsx' -o -name '*.jsx' | grep -v node_modules | grep -v dist | head -30", + { cwd: projectDir, timeout: 10000 } + ); + + const files = findOut.trim().split("\n").filter(Boolean); + + for (const file of files) { + const fullPath = join(projectDir, file); + if (!existsSync(fullPath)) continue; + + try { + const content = readFileSync(fullPath, "utf-8"); + const lines = content.split("\n"); + + if (lines.length > maxFileLines) { + violations.push(`[${rules["max-file-lines"].severity.toUpperCase()}] ${file}: ${lines.length} lines (max: ${maxFileLines}) — ${rules["max-file-lines"].description}`); + } + + let funcStart = -1; + let funcDepth = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*(async\s+)?function\s+\w+|^\s*const\s+\w+\s*=\s*(async\s+)?\(|^\s*(public|private|protected|static)?\s*(async\s+)?\w+\s*\(/.test(line)) { + if (funcStart >= 0) { + const funcLen = i - funcStart; + if (funcLen > maxFunctionLines) { + violations.push(`[${rules["max-function-lines"].severity.toUpperCase()}] ${file}:${funcStart + 1}: function with ${funcLen} lines (max: ${maxFunctionLines}) — ${rules["max-function-lines"].description}`); + } + } + funcStart = i; + funcDepth = 0; + } + if (funcStart >= 0 && i > funcStart) { + funcDepth += (line.match(/\{/g) || []).length; + funcDepth -= (line.match(/\}/g) || []).length; + if (funcDepth <= 0) { + const funcLen = i - funcStart + 1; + if (funcLen > maxFunctionLines) { + violations.push(`[${rules["max-function-lines"].severity.toUpperCase()}] ${file}:${funcStart + 1}: function with ${funcLen} lines (max: ${maxFunctionLines}) — ${rules["max-function-lines"].description}`); + } + funcStart = -1; + } + } + } + + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + + if (/\bconsole\.log\b/.test(ln) && !/\/\/\s*eslint/.test(ln)) { + violations.push(`[${rules["no-console-log"].severity.toUpperCase()}] ${file}:${i + 1}: console.log found — ${rules["no-console-log"].description}`); + } + + if (/\bdebugger\b/.test(ln)) { + violations.push(`[${rules["no-debugger"].severity.toUpperCase()}] ${file}:${i + 1}: debugger statement found — ${rules["no-debugger"].description}`); + } + + if (/\b(password|secret|api_?key|token)\s*[:=]\s*["'][^"']+["']/i.test(ln)) { + violations.push(`[${rules["no-hardcoded-secrets"].severity.toUpperCase()}] ${file}:${i + 1}: possible hardcoded secret — ${rules["no-hardcoded-secrets"].description}`); + } + + if (/^\s*\/\/\s*(TODO|FIXME)/.test(ln)) { + // already covered by todos check + } else if (/^\s*\/\/.*[;(={]/.test(ln) && !/^\s*\/\/\s*(eslint|prettier|tslint|@ts-)/.test(ln)) { + violations.push(`[${rules["no-commented-code"].severity.toUpperCase()}] ${file}:${i + 1}: possible commented-out code — ${rules["no-commented-code"].description}`); + } + + if (/\bany\b/.test(ln) && !/\/\*|\*\/|\/\/|import|export|as any/.test(ln) && /:\s*any\b/.test(ln)) { + violations.push(`[${rules["no-any-type"].severity.toUpperCase()}] ${file}:${i + 1}: any type used — ${rules["no-any-type"].description}`); + } + } + } catch { + // skip unreadable files + } + } + } catch (e: any) { + return { name: "rules", passed: true, output: `Rules check skipped: ${e.message}` }; + } + + const errors = violations.filter((v) => v.startsWith("[ERROR]")); + const warnings = violations.filter((v) => v.startsWith("[WARNING]")); + + if (errors.length > 0) { + return { + name: "rules", + passed: false, + output: `${errors.length} error(s), ${warnings.length} warning(s)\n${violations.join("\n")}`, + }; + } + + if (warnings.length > 0) { + return { + name: "rules", + passed: true, + output: `${warnings.length} warning(s) (non-blocking)\n${warnings.join("\n")}`, + }; + } + + return { name: "rules", passed: true, output: "All dev-workflow rules satisfied" }; + } + + private getLogger() { + try { + const runtime = (globalThis as any).__devWorkflowRuntime; + return runtime?.logging?.getChildLogger({ level: "info" }) ?? console; + } catch { + return console; + } + } +} diff --git a/extensions/dev-workflow/src/tools/spec-view-tool.ts b/extensions/dev-workflow/src/tools/spec-view-tool.ts new file mode 100755 index 0000000000000..4cfe532b4cd86 --- /dev/null +++ b/extensions/dev-workflow/src/tools/spec-view-tool.ts @@ -0,0 +1,72 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { z } from "zod"; +import { getEngine } from "../channel/runtime.js"; + +export class SpecViewTool implements AnyAgentTool { + name = "spec_view"; + label = "View Spec"; + description = "View the current workflow specification including proposal, design, and task list."; + parameters = z.object({ + section: z.enum(["proposal", "design", "tasks", "all"]).optional().describe("Which section to view (default: all)"), + }); + + async execute(_toolCallId: string, input: z.infer) { + const engine = getEngine(); + const context = engine.getContext(); + + if (!context || !context.spec) { + return { + content: [{ type: "text" as const, text: "No active workflow with spec. Start a workflow first." }], + details: { success: false, error: "No active workflow with spec" }, + }; + } + + const spec = context.spec; + const section = input.section ?? "all"; + + let text = ""; + let details: any; + + if (section === "proposal") { + text = spec.proposal; + details = { success: true, proposal: spec.proposal }; + } else if (section === "design") { + text = spec.design; + details = { success: true, design: spec.design }; + } else if (section === "tasks") { + text = spec.tasks.map((t) => `- [${t.status === "completed" ? "x" : " "}] **${t.id}**: ${t.title} (${t.difficulty}, ~${t.estimatedMinutes}min)`).join("\n"); + details = { + success: true, + tasks: spec.tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + difficulty: t.difficulty, + estimatedMinutes: t.estimatedMinutes, + dependencies: t.dependencies, + })), + }; + } else { + text = `${spec.proposal}\n\n---\n\n${spec.design}\n\n---\n\n## Tasks\n\n${spec.tasks.map((t) => `- [${t.status === "completed" ? "x" : " "}] **${t.id}**: ${t.title} (${t.difficulty}, ~${t.estimatedMinutes}min)`).join("\n")}`; + details = { + success: true, + proposal: spec.proposal, + design: spec.design, + tasks: spec.tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + difficulty: t.difficulty, + estimatedMinutes: t.estimatedMinutes, + dependencies: t.dependencies, + })), + updatedAt: spec.updatedAt, + }; + } + + return { + content: [{ type: "text" as const, text }], + details, + }; + } +} diff --git a/extensions/dev-workflow/src/tools/task-execute-tool.ts b/extensions/dev-workflow/src/tools/task-execute-tool.ts new file mode 100755 index 0000000000000..fedfd8e61d47d --- /dev/null +++ b/extensions/dev-workflow/src/tools/task-execute-tool.ts @@ -0,0 +1,45 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { z } from "zod"; +import { getEngine } from "../channel/runtime.js"; + +export class TaskExecuteTool implements AnyAgentTool { + name = "task_execute"; + label = "Execute Task"; + description = "Execute a specific task in the current workflow by task ID."; + parameters = z.object({ + taskId: z.string().describe("The ID of the task to execute (e.g., task-1)"), + }); + + async execute(_toolCallId: string, input: z.infer) { + const engine = getEngine(); + const context = engine.getContext(); + + if (!context || !context.spec) { + return { + content: [{ type: "text" as const, text: "No active workflow with spec. Start a workflow first." }], + details: { success: false, error: "No active workflow with spec" }, + }; + } + + const task = context.spec.tasks.find((t) => t.id === input.taskId); + if (!task) { + const available = context.spec.tasks.map((t) => t.id).join(", "); + return { + content: [{ type: "text" as const, text: `Task ${input.taskId} not found. Available: ${available}` }], + details: { success: false, error: `Task not found` }, + }; + } + + const orchestrator = engine.getOrchestrator(); + const ctx = context!; + const result = await orchestrator.executeTask(task, ctx.projectDir, ctx.mode); + const resultText = result.success + ? `Task ${task.id} (${task.title}) completed successfully in ${result.durationMs}ms.\n\n${result.output}` + : `Task ${task.id} (${task.title}) failed: ${result.output}`; + + return { + content: [{ type: "text" as const, text: resultText }], + details: result, + }; + } +} diff --git a/extensions/dev-workflow/src/tools/workflow-status-tool.ts b/extensions/dev-workflow/src/tools/workflow-status-tool.ts new file mode 100755 index 0000000000000..dbe5e517cc854 --- /dev/null +++ b/extensions/dev-workflow/src/tools/workflow-status-tool.ts @@ -0,0 +1,55 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { z } from "zod"; +import { getEngine } from "../channel/runtime.js"; + +export class WorkflowStatusTool implements AnyAgentTool { + name = "workflow_status"; + label = "Workflow Status"; + description = "Check the current status of a dev workflow, including completed steps, active tasks, and QA gate results."; + parameters = z.object({}); + + async execute(_toolCallId: string, _input: z.infer) { + const engine = getEngine(); + const context = engine.getContext(); + + if (!context) { + return { + content: [{ type: "text" as const, text: "No active workflow. Start one with dev_workflow_start first." }], + details: { success: false, error: "No active workflow" }, + }; + } + + const result = { + success: true, + projectId: context.projectId, + mode: context.mode, + currentStep: context.currentStep, + tasksCompleted: context.spec?.tasks.filter((t) => t.status === "completed").length ?? 0, + tasksTotal: context.spec?.tasks.length ?? 0, + decisions: context.decisions, + qaGateResults: context.qaGateResults, + duration: this.calculateDuration(context.startedAt), + }; + + const statusText = [ + `**Workflow Status**`, + `Project: ${context.projectId}`, + `Mode: ${context.mode}`, + `Step: ${context.currentStep}`, + `Tasks: ${result.tasksCompleted}/${result.tasksTotal} completed`, + `Duration: ${result.duration}`, + ].join("\n"); + + return { + content: [{ type: "text" as const, text: statusText }], + details: result, + }; + } + + private calculateDuration(startedAt: string): string { + const elapsed = Date.now() - new Date(startedAt).getTime(); + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } +} diff --git a/extensions/dev-workflow/src/types.ts b/extensions/dev-workflow/src/types.ts new file mode 100755 index 0000000000000..5a9edcf24f110 --- /dev/null +++ b/extensions/dev-workflow/src/types.ts @@ -0,0 +1,182 @@ +export interface DevWorkflowAccount { + accountId: string; + enabled: boolean; +} + +export type WorkflowMode = "quick" | "standard" | "full"; +export type WorkflowStep = + | "step0-analysis" + | "step0.5-spec-update" + | "step1-requirement" + | "step2-brainstorm" + | "step3-spec" + | "step4-tech-selection" + | "step5-development" + | "step6-review" + | "step7-test" + | "step8-docs" + | "step8.5-github" + | "step9-delivery"; + +export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled" | "failed"; +export type ShipCategory = "ship" | "show" | "ask"; +export type DifficultyLevel = "easy" | "medium" | "hard"; + +export interface WorkflowTask { + id: string; + title: string; + description: string; + status: TaskStatus; + difficulty: DifficultyLevel; + estimatedMinutes: number; + dependencies: string[]; + files: string[]; + shipCategory: ShipCategory; +} + +export interface WorkflowSpec { + proposal: string; + design: string; + tasks: WorkflowTask[]; + updatedAt: string; +} + +export interface WorkflowContext { + projectId: string; + projectDir: string; + mode: WorkflowMode; + currentStep: WorkflowStep; + spec: WorkflowSpec | null; + activeTaskIndex: number; + brainstormNotes: string[]; + decisions: string[]; + qaGateResults: QAGateCheck[]; + startedAt: string; + openSource: boolean | null; + branchName: string | null; + featureFlags: FeatureFlags; +} + +export interface QAGateCheck { + name: string; + passed: boolean; + output?: string; +} + +export interface AgentResult { + agentId: string; + task: string; + success: boolean; + output: string; + durationMs: number; +} + +export interface BrainstormOption { + label: string; + description: string; + pros: string[]; + cons: string[]; + directoryStructure?: string; +} + +export interface TechSelection { + language: string; + framework: string; + architecture: string; + patterns: string[]; + notes: string; +} + +export interface WorkingMemoryLayer { + project: string; + task: string; + step: string; +} + +export interface ConventionalCommit { + type: string; + scope: string; + description: string; + breaking: boolean; +} + +export interface FeatureFlags { + strictTdd: boolean; + ruleEnforcement: boolean; + autoCommit: boolean; + workingMemoryPersist: boolean; + dependencyParallelTasks: boolean; + conventionalCommits: boolean; + qaGateBlocking: boolean; + githubIntegration: boolean; + coverageThreshold: number; + maxFileLines: number; + maxFunctionLines: number; +} + +export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { + strictTdd: false, + ruleEnforcement: true, + autoCommit: true, + workingMemoryPersist: true, + dependencyParallelTasks: true, + conventionalCommits: true, + qaGateBlocking: false, + githubIntegration: true, + coverageThreshold: 80, + maxFileLines: 500, + maxFunctionLines: 50, +}; + +export interface WorkflowConfig { + mode: WorkflowMode; + featureFlags: FeatureFlags; + projectDir: string; +} + +export type DevWorkflowRule = + | "no-unused-vars" + | "prefer-const" + | "no-console-log" + | "no-any-type" + | "explicit-return-types" + | "no-magic-numbers" + | "max-file-lines" + | "max-function-lines" + | "no-inline-styles" + | "prefer-immutable" + | "no-deep-nesting" + | "no-duplicate-code" + | "meaningful-names" + | "single-responsibility" + | "no-commented-code" + | "no-debugger" + | "no-hardcoded-secrets" + | "prefer-early-return" + | "no-boolean-params" + | "no-global-mutation" + | "prefer-pure-functions"; + +export const DEV_WORKFLOW_RULES: Record = { + "no-unused-vars": { description: "No unused variables or imports", severity: "error" }, + "prefer-const": { description: "Prefer const over let when variable is not reassigned", severity: "warning" }, + "no-console-log": { description: "No console.log in production code (use logger)", severity: "warning" }, + "no-any-type": { description: "Avoid TypeScript any type", severity: "error" }, + "explicit-return-types": { description: "Functions should have explicit return types", severity: "warning" }, + "no-magic-numbers": { description: "Extract magic numbers into named constants", severity: "warning" }, + "max-file-lines": { description: "Files should not exceed 500 lines", severity: "warning" }, + "max-function-lines": { description: "Functions should not exceed 50 lines", severity: "warning" }, + "no-inline-styles": { description: "No inline styles, use CSS classes or style objects", severity: "warning" }, + "prefer-immutable": { description: "Prefer immutable data patterns", severity: "warning" }, + "no-deep-nesting": { description: "Avoid deeply nested code (>3 levels)", severity: "warning" }, + "no-duplicate-code": { description: "No duplicate code blocks", severity: "error" }, + "meaningful-names": { description: "Use descriptive variable and function names", severity: "warning" }, + "single-responsibility": { description: "Each function/module should do one thing", severity: "warning" }, + "no-commented-code": { description: "No commented-out code blocks", severity: "warning" }, + "no-debugger": { description: "No debugger statements in production code", severity: "error" }, + "no-hardcoded-secrets": { description: "No hardcoded secrets or credentials", severity: "error" }, + "prefer-early-return": { description: "Use early returns to reduce nesting", severity: "warning" }, + "no-boolean-params": { description: "Avoid boolean parameters that change function behavior", severity: "warning" }, + "no-global-mutation": { description: "Avoid mutating global state", severity: "error" }, + "prefer-pure-functions": { description: "Prefer pure functions over side-effecting ones", severity: "warning" }, +}; diff --git a/extensions/dev-workflow/tests/agents.test.ts b/extensions/dev-workflow/tests/agents.test.ts new file mode 100755 index 0000000000000..73710a4c5384e --- /dev/null +++ b/extensions/dev-workflow/tests/agents.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +function createMockRuntime(overrides: Record = {}) { + const mockRun = vi.fn().mockResolvedValue({ runId: "run-1" }); + const mockWaitForRun = vi.fn().mockResolvedValue({ status: "ok" }); + const mockGetMessages = vi.fn().mockResolvedValue({ messages: ["default response"] }); + return { + logging: { + getChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, + subagent: { + run: mockRun, + waitForRun: mockWaitForRun, + getSessionMessages: mockGetMessages, + deleteSession: vi.fn(), + }, + system: { + runCommandWithTimeout: vi.fn(), + }, + ...overrides, + } as any; +} + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `dwf-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + try { rmSync(testDir, { recursive: true, force: true }); } catch {} +}); + +// Must import dynamically after mocks +vi.mock("openclaw/plugin-sdk/core", () => ({})); + +describe("AgentOrchestrator", () => { + async function getOrchestrator(runtimeOverrides: Record = {}) { + const { AgentOrchestrator } = await import("../src/agents/index.js"); + return new AgentOrchestrator(createMockRuntime(runtimeOverrides)); + } + + it("runAnalysis detects git status", async () => { + const orchestrator = await getOrchestrator(); + const result = await orchestrator.runAnalysis(testDir); + expect(result).toHaveProperty("summary"); + expect(result).toHaveProperty("hasOpenSpec"); + expect(result).toHaveProperty("gitStatus"); + expect(result.gitStatus).toBe("not-a-git-repo"); + }); + + it("runAnalysis reads package.json info", async () => { + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test-pkg", scripts: { build: "tsc" } })); + const orchestrator = await getOrchestrator(); + const result = await orchestrator.runAnalysis(testDir); + expect(result.summary).toContain("test-pkg"); + expect(result.summary).toContain("build"); + }); + + it("runAnalysis detects tsconfig.json", async () => { + writeFileSync(join(testDir, "tsconfig.json"), "{}"); + const orchestrator = await getOrchestrator(); + const result = await orchestrator.runAnalysis(testDir); + expect(result.summary).toContain("TS: true"); + }); + + it("runAnalysis detects openspec directory", async () => { + mkdirSync(join(testDir, "openspec"), { recursive: true }); + const orchestrator = await getOrchestrator(); + const result = await orchestrator.runAnalysis(testDir); + expect(result.hasOpenSpec).toBe(true); + }); + + it("analyzeRequirement falls back on subagent failure", async () => { + const runtime = createMockRuntime(); + runtime.subagent.run.mockRejectedValue(new Error("subagent error")); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const result = await orchestrator.analyzeRequirement("short req", testDir, "quick"); + expect(result).toHaveProperty("complexity"); + expect(result).toHaveProperty("estimatedFiles"); + expect(result).toHaveProperty("suggestedMode"); + expect(result).toHaveProperty("affectedModules"); + }); + + it("analyzeRequirement parses LLM JSON response", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ['{"complexity":"high","estimatedFiles":10,"affectedModules":["core","api"]}'], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const result = await orchestrator.analyzeRequirement("complex feature request", testDir, "full"); + expect(result.complexity).toBe("high"); + expect(result.estimatedFiles).toBe(10); + expect(result.suggestedMode).toBe("full"); + expect(result.affectedModules).toEqual(["core", "api"]); + }); + + it("brainstorm falls back to default options", async () => { + const runtime = createMockRuntime(); + runtime.subagent.run.mockRejectedValue(new Error("fail")); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const options = await orchestrator.brainstorm("test requirement", testDir); + expect(options).toHaveLength(3); + expect(options[0].label).toBe("Minimal"); + expect(options[1].label).toBe("Standard"); + expect(options[2].label).toBe("Full"); + }); + + it("brainstorm parses LLM JSON array response", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ['[{"label":"Custom A","description":"Desc","pros":["p1"],"cons":["c1"]}]'], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const options = await orchestrator.brainstorm("test", testDir); + expect(options).toHaveLength(1); + expect(options[0].label).toBe("Custom A"); + }); + + it("defineSpec falls back to default spec", async () => { + const runtime = createMockRuntime(); + runtime.subagent.run.mockRejectedValue(new Error("fail")); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const spec = await orchestrator.defineSpec("test requirement", testDir, []); + expect(spec.proposal).toContain("test requirement"); + expect(spec.tasks.length).toBeGreaterThan(0); + expect(spec.tasks[0].status).toBe("pending"); + }); + + it("defineSpec parses LLM JSON response with tasks", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ['{"proposal":"# Prop","design":"# Design","tasks":[{"id":"t1","title":"Task1","description":"D","difficulty":"hard","estimatedMinutes":60,"dependencies":[],"files":["a.ts"],"shipCategory":"ship"}]}'], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const spec = await orchestrator.defineSpec("test", testDir, ["note1"]); + expect(spec.proposal).toBe("# Prop"); + expect(spec.tasks).toHaveLength(1); + expect(spec.tasks[0].id).toBe("t1"); + expect(spec.tasks[0].difficulty).toBe("hard"); + }); + + it("executeTask returns AgentResult on subagent failure", async () => { + const runtime = createMockRuntime(); + runtime.subagent.waitForRun.mockResolvedValue({ status: "error", error: "timeout" }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const task = { + id: "task-1", title: "Test", description: "Test task", + status: "pending" as const, difficulty: "medium" as const, + estimatedMinutes: 30, dependencies: [], files: ["src/test.ts"], + shipCategory: "show" as const, + }; + const result = await orchestrator.executeTask(task, testDir, "standard"); + expect(result.agentId).toBeDefined(); + expect(result.task).toBe("task-1"); + expect(result.success).toBe(false); + }); + + it("executeTask returns success on valid subagent response", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ["Implemented the feature successfully"], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const task = { + id: "task-1", title: "Test", description: "Test task", + status: "pending" as const, difficulty: "easy" as const, + estimatedMinutes: 15, dependencies: [], files: ["src/test.ts"], + shipCategory: "ship" as const, + }; + const result = await orchestrator.executeTask(task, testDir, "quick"); + expect(result.success).toBe(true); + expect(result.output).toContain("Implemented"); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("runTests detects and runs test command", async () => { + writeFileSync(join(testDir, "package.json"), JSON.stringify({ scripts: { test: "echo test passed" } })); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(createMockRuntime()); + const result = await orchestrator.runTests(testDir); + expect(result).toHaveProperty("passed"); + expect(result).toHaveProperty("output"); + }); + + it("runReview returns string", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ["Code review looks good"], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const review = await orchestrator.runReview(testDir); + expect(typeof review).toBe("string"); + }); + + it("generateDocs returns string", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ["# Generated Docs\n\nContent here"], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const spec = { proposal: "# P", design: "# D", tasks: [], updatedAt: new Date().toISOString() }; + const docs = await orchestrator.generateDocs(testDir, spec as any); + expect(typeof docs).toBe("string"); + }); + + it("generateDocs returns message when no spec", async () => { + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(createMockRuntime()); + const docs = await orchestrator.generateDocs(testDir, null); + expect(docs).toContain("No spec"); + }); + + it("selectTech falls back to default on subagent failure", async () => { + const runtime = createMockRuntime(); + runtime.subagent.run.mockRejectedValue(new Error("fail")); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const tech = await orchestrator.selectTech("build something", testDir, []); + expect(tech.language).toBe("TypeScript"); + expect(tech.framework).toBe("Node.js"); + expect(tech.architecture).toBe("modular"); + expect(tech.patterns).toContain("module"); + }); + + it("selectTech parses LLM JSON response", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ['{"language":"Python","framework":"FastAPI","architecture":"microservices","patterns":["repository","cqrs"],"notes":"Good for APIs"}'], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const tech = await orchestrator.selectTech("build API", testDir, ["note"]); + expect(tech.language).toBe("Python"); + expect(tech.framework).toBe("FastAPI"); + expect(tech.architecture).toBe("microservices"); + expect(tech.patterns).toEqual(["repository", "cqrs"]); + }); + + it("executeTask saves working memory after success", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ + messages: ["Done implementing feature"], + }); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const orchestrator = new AgentOrchestrator(runtime); + const task = { + id: "task-mem", title: "Memory Test", description: "Test memory", + status: "pending" as const, difficulty: "easy" as const, + estimatedMinutes: 10, dependencies: [], files: ["src/a.ts"], + shipCategory: "ship" as const, + }; + await orchestrator.executeTask(task, testDir, "standard"); + const { existsSync, readFileSync } = await import("fs"); + const memPath = join(testDir, "docs", "plans", "task-mem-context.md"); + expect(existsSync(memPath)).toBe(true); + const content = readFileSync(memPath, "utf-8"); + expect(content).toContain("task-mem"); + }); + + it("executeTask loads working memory for subsequent runs", async () => { + const { writeFileSync } = await import("fs"); + const { AgentOrchestrator } = await import("../src/agents/index.js"); + const plansDir = join(testDir, "docs", "plans"); + mkdirSync(plansDir, { recursive: true }); + writeFileSync(join(plansDir, "task-wm-context.md"), "Previous context info"); + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages.mockResolvedValue({ messages: ["OK"] }); + const orchestrator = new AgentOrchestrator(runtime); + const task = { + id: "task-wm", title: "WM Test", description: "WM task", + status: "pending" as const, difficulty: "easy" as const, + estimatedMinutes: 10, dependencies: [], files: [], + shipCategory: "ship" as const, + }; + await orchestrator.executeTask(task, testDir, "standard"); + const runCalls = runtime.subagent.run.mock.calls; + expect(runCalls.length).toBe(1); + }); +}); diff --git a/extensions/dev-workflow/tests/channel.test.ts b/extensions/dev-workflow/tests/channel.test.ts new file mode 100755 index 0000000000000..d490bcfc421ec --- /dev/null +++ b/extensions/dev-workflow/tests/channel.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/core", () => ({ + createChannelPluginBase: (opts: any) => opts, + defineChannelPluginEntry: (opts: any) => opts, +})); + +import { devWorkflowChannel } from "../src/channel/dev-workflow-channel.js"; + +describe("devWorkflowChannel", () => { + it("has correct channel id", () => { + expect(devWorkflowChannel.id).toBe("dev-workflow"); + }); + + it("has meta with expected properties", () => { + expect(devWorkflowChannel.meta.id).toBe("dev-workflow"); + expect(devWorkflowChannel.meta.label).toBe("Dev Workflow"); + expect(devWorkflowChannel.meta.aliases).toEqual(["dwf", "devflow"]); + expect(devWorkflowChannel.meta.order).toBe(50); + expect(devWorkflowChannel.meta.quickstartAllowFrom).toBe(true); + }); + + it("has capabilities configured", () => { + expect(devWorkflowChannel.capabilities.chatTypes).toEqual(["direct"]); + expect(devWorkflowChannel.capabilities.nativeCommands).toBe(true); + expect(devWorkflowChannel.capabilities.media).toBe(true); + }); + + it("setup.applyAccountConfig returns config unchanged", () => { + const cfg = { foo: "bar" } as any; + const result = devWorkflowChannel.setup.applyAccountConfig({ cfg, accountId: "test", input: {} }); + expect(result).toBe(cfg); + }); + + it("config.listAccountIds extracts keys from channel config", () => { + const cfg = { + channels: { + "dev-workflow": { + account1: { enabled: true }, + account2: { enabled: false }, + allowFrom: ["*"], + }, + }, + } as any; + const ids = devWorkflowChannel.config.listAccountIds(cfg); + expect(ids).toContain("account1"); + expect(ids).toContain("account2"); + expect(ids).not.toContain("allowFrom"); + }); + + it("config.resolveAccount returns default when no accountId", () => { + const cfg = {} as any; + const account = devWorkflowChannel.config.resolveAccount(cfg, null); + expect(account.accountId).toBe("default"); + expect(account.enabled).toBe(true); + }); + + it("config.resolveAccount uses provided accountId", () => { + const account = devWorkflowChannel.config.resolveAccount({} as any, "custom-id"); + expect(account.accountId).toBe("custom-id"); + }); + + it("config.isEnabled returns true for enabled accounts", () => { + expect(devWorkflowChannel.config.isEnabled({ accountId: "a", enabled: true })).toBe(true); + }); + + it("config.isEnabled returns false for disabled accounts", () => { + expect(devWorkflowChannel.config.isEnabled({ accountId: "a", enabled: false })).toBe(false); + }); + + it("config.isConfigured always returns true", () => { + expect(devWorkflowChannel.config.isConfigured({} as any)).toBe(true); + }); +}); diff --git a/extensions/dev-workflow/tests/engine.test.ts b/extensions/dev-workflow/tests/engine.test.ts new file mode 100755 index 0000000000000..9d20b34045b77 --- /dev/null +++ b/extensions/dev-workflow/tests/engine.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +vi.mock("openclaw/plugin-sdk/core", () => ({})); + +function createMockRuntime() { + const mockRun = vi.fn().mockResolvedValue({ runId: "run-1" }); + const mockWaitForRun = vi.fn().mockResolvedValue({ status: "ok" }); + const mockGetMessages = vi.fn().mockResolvedValue({ messages: ['{"complexity":"medium","estimatedFiles":3,"affectedModules":["src"]}'] }); + return { + logging: { + getChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, + subagent: { + run: mockRun, + waitForRun: mockWaitForRun, + getSessionMessages: mockGetMessages, + deleteSession: vi.fn(), + }, + system: { + runCommandWithTimeout: vi.fn(), + }, + } as any; +} + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `dwf-engine-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + try { rmSync(testDir, { recursive: true, force: true }); } catch {} +}); + +describe("DevWorkflowEngine", () => { + it("initialize creates context and runs analysis", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const context = await engine.initialize(testDir, "standard"); + expect(context.projectId).toBeDefined(); + expect(context.mode).toBe("standard"); + expect(context.currentStep).toBe("step0-analysis"); + expect(context.spec).toBeNull(); + expect(context.decisions.length).toBeGreaterThan(0); + }); + + it("initialize loads persisted context", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const runtime = createMockRuntime(); + const engine1 = new DevWorkflowEngine(runtime); + const ctx1 = await engine1.initialize(testDir, "full"); + ctx1.currentStep = "step5-development"; + ctx1.decisions.push("test decision"); + engine1.saveContext(); + + const engine2 = new DevWorkflowEngine(runtime); + const ctx2 = await engine2.initialize(testDir); + expect(ctx2.currentStep).toBe("step5-development"); + expect(ctx2.decisions).toContain("test decision"); + }); + + it("initialize uses default mode standard", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const context = await engine.initialize(testDir); + expect(context.mode).toBe("standard"); + }); + + it("initialize extracts project name from directory basename", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const context = await engine.initialize(testDir, "standard"); + const expectedName = testDir.split("/").pop(); + expect(context.projectId).toBe(expectedName); + }); + + it("executeWorkflow throws when not initialized", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + await expect(engine.executeWorkflow("test requirement")).rejects.toThrow("not initialized"); + }); + + it("executeWorkflow runs full workflow in standard mode", async () => { + const runtime = createMockRuntime(); + runtime.subagent.getSessionMessages + .mockResolvedValueOnce({ messages: ['{"complexity":"medium","estimatedFiles":3,"affectedModules":["src"]}'] }) + .mockResolvedValueOnce({ messages: ['[{"label":"A","description":"d","pros":[],"cons":[]}]'] }) + .mockResolvedValueOnce({ messages: ['{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":10,"dependencies":[],"files":["a.ts"],"shipCategory":"ship"}]}'] }) + .mockResolvedValueOnce({ messages: ["Task done"] }) + .mockResolvedValueOnce({ messages: ["Review done"] }) + .mockResolvedValueOnce({ messages: ["# Docs"] }); + + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(runtime); + await engine.initialize(testDir, "standard"); + const report = await engine.executeWorkflow("build a feature"); + expect(report).toContain("Delivery Report"); + expect(report).toContain("completed"); + }); + + it("executeWorkflow skips brainstorm and review in quick mode", async () => { + const runtime = createMockRuntime(); + let callCount = 0; + runtime.subagent.getSessionMessages.mockImplementation(async () => { + callCount++; + if (callCount === 1) return { messages: ['{"complexity":"low","estimatedFiles":1,"affectedModules":[]}'] }; + if (callCount === 2) return { messages: ['{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":5,"dependencies":[],"files":[],"shipCategory":"ship"}]}'] }; + return { messages: ["done"] }; + }); + + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(runtime); + await engine.initialize(testDir, "quick"); + const report = await engine.executeWorkflow("simple fix"); + expect(report).toContain("Delivery Report"); + }); + + it("getContext returns null before initialization", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + expect(engine.getContext()).toBeNull(); + }); + + it("getContext returns context after initialization", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + await engine.initialize(testDir, "standard"); + expect(engine.getContext()).not.toBeNull(); + expect(engine.getContext()!.mode).toBe("standard"); + }); + + it("getOrchestrator returns the agent orchestrator", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + expect(engine.getOrchestrator()).toBeDefined(); + }); + + it("persists context to .dev-workflow-context.json", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + await engine.initialize(testDir, "full"); + const ctxFile = join(testDir, ".dev-workflow-context.json"); + expect(existsSync(ctxFile)).toBe(true); + const saved = JSON.parse(readFileSync(ctxFile, "utf-8")); + expect(saved.mode).toBe("full"); + expect(saved.projectDir).toBe(testDir); + }); + + it("context includes openSource, branchName, and featureFlags fields", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const ctx = await engine.initialize(testDir, "standard"); + expect(ctx).toHaveProperty("openSource"); + expect(ctx).toHaveProperty("branchName"); + expect(ctx).toHaveProperty("featureFlags"); + expect(ctx.openSource).toBeDefined(); + expect(ctx.branchName).toBeNull(); + expect(ctx.featureFlags.autoCommit).toBe(true); + expect(ctx.featureFlags.strictTdd).toBe(false); + }); + + it("report includes Ship/Show/Ask counts", async () => { + const runtime = createMockRuntime(); + let callCount = 0; + runtime.subagent.getSessionMessages.mockImplementation(async () => { + callCount++; + if (callCount === 1) return { messages: ['{"complexity":"medium","estimatedFiles":2,"affectedModules":["src"]}'] }; + if (callCount === 2) return { messages: ['{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":5,"dependencies":[],"files":[],"shipCategory":"ship"}]}'] }; + return { messages: ["done"] }; + }); + + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(runtime); + await engine.initialize(testDir, "quick"); + const report = await engine.executeWorkflow("test feature"); + expect(report).toContain("ship:"); + expect(report).toContain("show:"); + expect(report).toContain("ask:"); + }); + + it("engine creates .dev-workflow.md context file on updates", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + await engine.initialize(testDir, "full"); + expect(existsSync(join(testDir, ".dev-workflow.md"))).toBe(false); + }); + + it("engine handles full mode with tech selection step", async () => { + const runtime = createMockRuntime(); + let callCount = 0; + runtime.subagent.getSessionMessages.mockImplementation(async () => { + callCount++; + if (callCount === 1) return { messages: ['{"complexity":"high","estimatedFiles":5,"affectedModules":["core"]}'] }; + if (callCount === 2) return { messages: ['[{"label":"A","description":"d","pros":[],"cons":[]}]'] }; + if (callCount === 3) return { messages: ['{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":10,"dependencies":[],"files":[],"shipCategory":"ship"}]}'] }; + if (callCount === 4) return { messages: ['{"language":"TypeScript","framework":"React","architecture":"modular","patterns":["hooks"],"notes":"test"}'] }; + return { messages: ["done"] }; + }); + + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(runtime); + await engine.initialize(testDir, "full"); + const report = await engine.executeWorkflow("complex feature"); + expect(report).toContain("Delivery Report"); + }); + + it("conventional commit message generation covers types", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + await engine.initialize(testDir, "standard"); + const report = await engine.executeWorkflow("simple fix"); + expect(typeof report).toBe("string"); + }); + + it("full mode enables strictTdd and qaGateBlocking flags", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const ctx = await engine.initialize(testDir, "full"); + expect(ctx.featureFlags.strictTdd).toBe(true); + expect(ctx.featureFlags.qaGateBlocking).toBe(true); + }); + + it("custom feature flags override defaults", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(createMockRuntime()); + const ctx = await engine.initialize(testDir, "standard", { coverageThreshold: 95, autoCommit: false }); + expect(ctx.featureFlags.coverageThreshold).toBe(95); + expect(ctx.featureFlags.autoCommit).toBe(false); + expect(ctx.featureFlags.conventionalCommits).toBe(true); + }); + + it("persists feature flags in context file", async () => { + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const runtime = createMockRuntime(); + const engine1 = new DevWorkflowEngine(runtime); + await engine1.initialize(testDir, "standard", { strictTdd: true }); + engine1.saveContext(); + + const engine2 = new DevWorkflowEngine(runtime); + const ctx2 = await engine2.initialize(testDir); + expect(ctx2.featureFlags.strictTdd).toBe(true); + }); + + it("quick mode skips GitHub integration when flag disabled", async () => { + const runtime = createMockRuntime(); + let callCount = 0; + runtime.subagent.getSessionMessages.mockImplementation(async () => { + callCount++; + if (callCount === 1) return { messages: ['{"complexity":"low","estimatedFiles":1,"affectedModules":[]}'] }; + if (callCount === 2) return { messages: ['{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":5,"dependencies":[],"files":[],"shipCategory":"ship"}]}'] }; + return { messages: ["done"] }; + }); + + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const engine = new DevWorkflowEngine(runtime); + await engine.initialize(testDir, "quick", { githubIntegration: false }); + const report = await engine.executeWorkflow("test feature"); + expect(report).toContain("Delivery Report"); + }); +}); diff --git a/extensions/dev-workflow/tests/hooks.test.ts b/extensions/dev-workflow/tests/hooks.test.ts new file mode 100755 index 0000000000000..b146114545d12 --- /dev/null +++ b/extensions/dev-workflow/tests/hooks.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/core", () => ({})); + +describe("registerDevWorkflowHooks", () => { + it("registers 4 hooks without errors", async () => { + const { registerDevWorkflowHooks } = await import("../src/hooks/index.js"); + const registerHook = vi.fn(); + const api = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerHook, + } as any; + registerDevWorkflowHooks(api); + expect(registerHook).toHaveBeenCalledTimes(4); + expect(registerHook).toHaveBeenCalledWith("session_start", expect.any(Function)); + expect(registerHook).toHaveBeenCalledWith("session_end", expect.any(Function)); + expect(registerHook).toHaveBeenCalledWith("before_tool_call", expect.any(Function)); + expect(registerHook).toHaveBeenCalledWith("after_tool_call", expect.any(Function)); + }); + + it("session_start hook logs session key", async () => { + const { registerDevWorkflowHooks } = await import("../src/hooks/index.js"); + const registerHook = vi.fn(); + const loggerInfo = vi.fn(); + const api = { + logger: { info: loggerInfo, warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerHook, + } as any; + registerDevWorkflowHooks(api); + + const sessionStartHandler = registerHook.mock.calls.find((c: any) => c[0] === "session_start")![1]; + await sessionStartHandler({ sessionKey: "test-session" }); + expect(loggerInfo).toHaveBeenCalledWith(expect.stringContaining("test-session")); + }); + + it("before_tool_call hook logs tool name", async () => { + const { registerDevWorkflowHooks } = await import("../src/hooks/index.js"); + const registerHook = vi.fn(); + const loggerInfo = vi.fn(); + const api = { + logger: { info: loggerInfo, warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerHook, + } as any; + registerDevWorkflowHooks(api); + + const handler = registerHook.mock.calls.find((c: any) => c[0] === "before_tool_call")![1]; + await handler({ toolName: "dev_workflow_start" }); + expect(loggerInfo).toHaveBeenCalledWith(expect.stringContaining("dev_workflow_start")); + }); + + it("after_tool_call hook logs tool name", async () => { + const { registerDevWorkflowHooks } = await import("../src/hooks/index.js"); + const registerHook = vi.fn(); + const loggerInfo = vi.fn(); + const api = { + logger: { info: loggerInfo, warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerHook, + } as any; + registerDevWorkflowHooks(api); + + const handler = registerHook.mock.calls.find((c: any) => c[0] === "after_tool_call")![1]; + await handler({ toolName: "qa_gate_check" }); + expect(loggerInfo).toHaveBeenCalledWith(expect.stringContaining("qa_gate_check")); + }); + + it("session_end hook logs session key", async () => { + const { registerDevWorkflowHooks } = await import("../src/hooks/index.js"); + const registerHook = vi.fn(); + const loggerInfo = vi.fn(); + const api = { + logger: { info: loggerInfo, warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerHook, + } as any; + registerDevWorkflowHooks(api); + + const handler = registerHook.mock.calls.find((c: any) => c[0] === "session_end")![1]; + await handler({ sessionKey: "end-session" }); + expect(loggerInfo).toHaveBeenCalledWith(expect.stringContaining("end-session")); + }); +}); diff --git a/extensions/dev-workflow/tests/index.test.ts b/extensions/dev-workflow/tests/index.test.ts new file mode 100755 index 0000000000000..269805ddeb3cc --- /dev/null +++ b/extensions/dev-workflow/tests/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/core", () => ({ + defineChannelPluginEntry: (opts: any) => opts, + createChannelPluginBase: (opts: any) => opts, +})); + +describe("plugin entry point", () => { + it("exports plugin with correct id", async () => { + const plugin = (await import("../src/index.js")).default; + expect(plugin.id).toBe("dev-workflow"); + expect(plugin.name).toBe("Dev Workflow"); + }); + + it("has registerFull that calls tool and hook registration", async () => { + const plugin = (await import("../src/index.js")).default; + const registerTool = vi.fn(); + const registerHook = vi.fn(); + const api = { + registerTool, + registerHook, + registerHttpRoute: vi.fn(), + registerChannel: vi.fn(), + registerGatewayMethod: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + registerCliBackend: vi.fn(), + registerProvider: vi.fn(), + registerSpeechProvider: vi.fn(), + registerMediaUnderstandingProvider: vi.fn(), + registerImageGenerationProvider: vi.fn(), + registerWebFetchProvider: vi.fn(), + registerWebSearchProvider: vi.fn(), + registerInteractiveHandler: vi.fn(), + onConversationBindingResolved: vi.fn(), + registerCommand: vi.fn(), + registerContextEngine: vi.fn(), + registerMemoryPromptSection: vi.fn(), + registerMemoryFlushPlan: vi.fn(), + registerMemoryRuntime: vi.fn(), + registerMemoryEmbeddingProvider: vi.fn(), + resolvePath: vi.fn(), + on: vi.fn(), + } as any; + plugin.registerFull(api); + expect(registerTool).toHaveBeenCalled(); + expect(registerHook).toHaveBeenCalled(); + }); + + it("exports named exports", async () => { + const mod = await import("../src/index.js"); + expect(mod.devWorkflowChannel).toBeDefined(); + expect(mod.setDevWorkflowRuntime).toBeDefined(); + expect(mod.DevWorkflowEngine).toBeDefined(); + expect(mod.AgentOrchestrator).toBeDefined(); + }); +}); diff --git a/extensions/dev-workflow/tests/runtime.test.ts b/extensions/dev-workflow/tests/runtime.test.ts new file mode 100755 index 0000000000000..965fbe39ddf26 --- /dev/null +++ b/extensions/dev-workflow/tests/runtime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/core", () => ({})); + +describe("runtime", () => { + it("throws before initialization", async () => { + const { getRuntime, getEngine, setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + const mod = await import("../src/channel/runtime.js"); + + const freshModule = await vi.importActual("../src/channel/runtime.js") as any; + + const mockRuntime = { + logging: { getChildLogger: vi.fn() }, + subagent: { run: vi.fn() }, + } as any; + setDevWorkflowRuntime(mockRuntime); + expect(getRuntime()).toBe(mockRuntime); + expect(getEngine()).toBeDefined(); + }); + + it("setDevWorkflowRuntime sets runtime and creates engine", async () => { + const { setDevWorkflowRuntime, getRuntime, getEngine } = await import("../src/channel/runtime.js"); + const mockRuntime = { + logging: { getChildLogger: vi.fn() }, + subagent: { run: vi.fn() }, + } as any; + setDevWorkflowRuntime(mockRuntime); + expect(getRuntime()).toBe(mockRuntime); + expect(getEngine()).toBeDefined(); + }); +}); diff --git a/extensions/dev-workflow/tests/tools.test.ts b/extensions/dev-workflow/tests/tools.test.ts new file mode 100755 index 0000000000000..c372a6a4ce448 --- /dev/null +++ b/extensions/dev-workflow/tests/tools.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +vi.mock("openclaw/plugin-sdk/core", () => ({})); + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `dwf-tools-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + mkdirSync(testDir, { recursive: true }); + vi.resetModules(); +}); + +afterEach(() => { + try { rmSync(testDir, { recursive: true, force: true }); } catch {} +}); + +async function setupEngine() { + const mockRun = vi.fn().mockResolvedValue({ runId: "run-1" }); + const mockWaitForRun = vi.fn().mockResolvedValue({ status: "ok" }); + const mockGetMessages = vi.fn().mockResolvedValue({ messages: ['{"complexity":"medium","estimatedFiles":3,"affectedModules":["src"]}'] }); + const runtime = { + logging: { getChildLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, + subagent: { run: mockRun, waitForRun: mockWaitForRun, getSessionMessages: mockGetMessages, deleteSession: vi.fn() }, + system: { runCommandWithTimeout: vi.fn() }, + } as any; + + const { setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + setDevWorkflowRuntime(runtime); + return { runtime }; +} + +describe("DevWorkflowTool", () => { + it("executes workflow and returns report", async () => { + await setupEngine(); + const { DevWorkflowTool } = await import("../src/tools/dev-workflow-tool.js"); + const tool = new DevWorkflowTool(); + + expect(tool.name).toBe("dev_workflow_start"); + expect(tool.label).toBe("Start Dev Workflow"); + + const specResponse = '{"proposal":"# P","design":"# D","tasks":[{"id":"t1","title":"T1","description":"D","difficulty":"easy","estimatedMinutes":5,"dependencies":[],"files":[],"shipCategory":"ship"}]}'; + const { getRuntime } = await import("../src/channel/runtime.js"); + getRuntime().subagent.getSessionMessages + .mockResolvedValueOnce({ messages: ['{"complexity":"low","estimatedFiles":1,"affectedModules":[]}'] }) + .mockResolvedValueOnce({ messages: [specResponse] }) + .mockResolvedValueOnce({ messages: ["done"] }); + + const result = await tool.execute("call-1", { requirement: "test feature", projectDir: testDir, mode: "quick" }); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Delivery Report"); + expect(result.details.success).toBe(true); + }); + + it("uses standard mode by default", async () => { + await setupEngine(); + const { DevWorkflowTool } = await import("../src/tools/dev-workflow-tool.js"); + const tool = new DevWorkflowTool(); + const result = await tool.execute("call-2", { requirement: "test", projectDir: testDir }); + expect(result.details.context.mode).toBe("standard"); + }); +}); + +describe("WorkflowStatusTool", () => { + it("returns error when no active workflow", async () => { + vi.resetModules(); + const mockRuntime = { + logging: { getChildLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, + subagent: { run: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), deleteSession: vi.fn() }, + system: { runCommandWithTimeout: vi.fn() }, + } as any; + const { setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + setDevWorkflowRuntime(mockRuntime); + + const { WorkflowStatusTool } = await import("../src/tools/workflow-status-tool.js"); + const tool = new WorkflowStatusTool(); + const result = await tool.execute("call-1", {}); + expect(result.content[0].text).toContain("No active workflow"); + expect(result.details.success).toBe(false); + }); + + it("returns status when workflow is active", async () => { + await setupEngine(); + const { DevWorkflowEngine } = await import("../src/engine/index.js"); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + + const { WorkflowStatusTool } = await import("../src/tools/workflow-status-tool.js"); + const tool = new WorkflowStatusTool(); + const result = await tool.execute("call-1", {}); + expect(result.details.success).toBe(true); + expect(result.details.mode).toBe("standard"); + expect(result.content[0].text).toContain("Workflow Status"); + }); +}); + +describe("SpecViewTool", () => { + it("returns error when no active workflow", async () => { + vi.resetModules(); + const mockRuntime = { + logging: { getChildLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, + subagent: { run: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), deleteSession: vi.fn() }, + system: { runCommandWithTimeout: vi.fn() }, + } as any; + const { setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + setDevWorkflowRuntime(mockRuntime); + + const { SpecViewTool } = await import("../src/tools/spec-view-tool.js"); + const tool = new SpecViewTool(); + const result = await tool.execute("call-1", {}); + expect(result.details.success).toBe(false); + }); + + it("returns proposal section", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + engine.getContext()!.spec = { + proposal: "# My Proposal", + design: "# My Design", + tasks: [{ id: "t1", title: "Task 1", description: "D", status: "pending", difficulty: "easy", estimatedMinutes: 10, dependencies: [], files: [], shipCategory: "ship" }], + updatedAt: new Date().toISOString(), + }; + + const { SpecViewTool } = await import("../src/tools/spec-view-tool.js"); + const tool = new SpecViewTool(); + const result = await tool.execute("call-1", { section: "proposal" }); + expect(result.details.success).toBe(true); + expect(result.content[0].text).toBe("# My Proposal"); + }); + + it("returns all sections by default", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + engine.getContext()!.spec = { + proposal: "# Proposal", + design: "# Design", + tasks: [{ id: "t1", title: "T1", description: "D", status: "pending", difficulty: "easy", estimatedMinutes: 5, dependencies: [], files: [], shipCategory: "ship" }], + updatedAt: new Date().toISOString(), + }; + + const { SpecViewTool } = await import("../src/tools/spec-view-tool.js"); + const tool = new SpecViewTool(); + const result = await tool.execute("call-1", {}); + expect(result.content[0].text).toContain("# Proposal"); + expect(result.content[0].text).toContain("# Design"); + expect(result.content[0].text).toContain("T1"); + }); +}); + +describe("TaskExecuteTool", () => { + it("returns error when no active workflow", async () => { + vi.resetModules(); + const mockRuntime = { + logging: { getChildLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, + subagent: { run: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), deleteSession: vi.fn() }, + system: { runCommandWithTimeout: vi.fn() }, + } as any; + const { setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + setDevWorkflowRuntime(mockRuntime); + + const { TaskExecuteTool } = await import("../src/tools/task-execute-tool.js"); + const tool = new TaskExecuteTool(); + const result = await tool.execute("call-1", { taskId: "task-1" }); + expect(result.details.success).toBe(false); + }); + + it("returns error for unknown task ID", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + engine.getContext()!.spec = { + proposal: "# P", + design: "# D", + tasks: [{ id: "task-1", title: "T1", description: "D", status: "pending", difficulty: "easy", estimatedMinutes: 5, dependencies: [], files: [], shipCategory: "ship" }], + updatedAt: new Date().toISOString(), + }; + + const { TaskExecuteTool } = await import("../src/tools/task-execute-tool.js"); + const tool = new TaskExecuteTool(); + const result = await tool.execute("call-1", { taskId: "nonexistent" }); + expect(result.details.success).toBe(false); + expect(result.content[0].text).toContain("not found"); + }); +}); + +describe("QAGateTool", () => { + it("returns error when no active workflow", async () => { + vi.resetModules(); + const mockRuntime = { + logging: { getChildLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, + subagent: { run: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), deleteSession: vi.fn() }, + system: { runCommandWithTimeout: vi.fn() }, + } as any; + const { setDevWorkflowRuntime } = await import("../src/channel/runtime.js"); + setDevWorkflowRuntime(mockRuntime); + + const { QAGateTool } = await import("../src/tools/qa-gate-tool.js"); + const tool = new QAGateTool(); + const result = await tool.execute("call-1", { projectDir: testDir }); + expect(result.details.success).toBe(false); + }); + + it("runs all checks and returns results", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + + writeFileSync(join(testDir, "README.md"), "# Test Project\n\nThis is a test project with enough content to pass the docs check."); + + const { QAGateTool } = await import("../src/tools/qa-gate-tool.js"); + const tool = new QAGateTool(); + const result = await tool.execute("call-1", { projectDir: testDir, checks: ["docs", "typecheck"] }); + expect(result.details.checks).toHaveLength(2); + expect(result.details.checks[0].name).toBe("docs"); + }); + + it("runs rules check and detects violations", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + + writeFileSync(join(testDir, "sample.ts"), `const x = 5;\nconsole.log("hello");\ndebugger;\nconst pw = "secret123";\n`); + + const { QAGateTool } = await import("../src/tools/qa-gate-tool.js"); + const tool = new QAGateTool(); + const result = await tool.execute("call-1", { projectDir: testDir, checks: ["rules"] }); + expect(result.details.checks).toHaveLength(1); + expect(result.details.checks[0].name).toBe("rules"); + expect(result.details.checks[0].output).toContain("console.log"); + }); + + it("rules check passes when rule enforcement disabled", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard", { ruleEnforcement: false }); + + writeFileSync(join(testDir, "sample.ts"), `console.log("hello");\n`); + + const { QAGateTool } = await import("../src/tools/qa-gate-tool.js"); + const tool = new QAGateTool(); + const result = await tool.execute("call-1", { projectDir: testDir, checks: ["rules"] }); + expect(result.details.checks[0].passed).toBe(true); + expect(result.details.checks[0].output).toContain("disabled"); + }); + + it("default checks include rules", async () => { + await setupEngine(); + const { getEngine } = await import("../src/channel/runtime.js"); + const engine = getEngine(); + await engine.initialize(testDir, "standard"); + + writeFileSync(join(testDir, "README.md"), "# Test Project with enough content for docs check.\n"); + + const { QAGateTool } = await import("../src/tools/qa-gate-tool.js"); + const tool = new QAGateTool(); + const result = await tool.execute("call-1", { projectDir: testDir }); + const checkNames = result.details.checks.map((c: any) => c.name); + expect(checkNames).toContain("rules"); + expect(checkNames).toHaveLength(10); + }); +}); diff --git a/extensions/dev-workflow/tests/types.test.ts b/extensions/dev-workflow/tests/types.test.ts new file mode 100755 index 0000000000000..48877c2c97ad9 --- /dev/null +++ b/extensions/dev-workflow/tests/types.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import type { + DevWorkflowAccount, + WorkflowMode, + WorkflowStep, + TaskStatus, + ShipCategory, + DifficultyLevel, + WorkflowTask, + WorkflowSpec, + WorkflowContext, + QAGateCheck, + AgentResult, + BrainstormOption, + FeatureFlags, + DevWorkflowRule, +} from "../src/types.js"; +import { DEFAULT_FEATURE_FLAGS, DEV_WORKFLOW_RULES } from "../src/types.js"; + +describe("types", () => { + it("DevWorkflowAccount has required fields", () => { + const account: DevWorkflowAccount = { accountId: "test", enabled: true }; + expect(account.accountId).toBe("test"); + expect(account.enabled).toBe(true); + }); + + it("WorkflowMode accepts valid values", () => { + const modes: WorkflowMode[] = ["quick", "standard", "full"]; + expect(modes).toHaveLength(3); + }); + + it("WorkflowStep covers all 12 steps", () => { + const steps: WorkflowStep[] = [ + "step0-analysis", + "step0.5-spec-update", + "step1-requirement", + "step2-brainstorm", + "step3-spec", + "step4-tech-selection", + "step5-development", + "step6-review", + "step7-test", + "step8-docs", + "step8.5-github", + "step9-delivery", + ]; + expect(steps).toHaveLength(12); + }); + + it("TaskStatus covers all states", () => { + const statuses: TaskStatus[] = ["pending", "in_progress", "completed", "cancelled", "failed"]; + expect(statuses).toHaveLength(5); + }); + + it("ShipCategory has ship/show/ask", () => { + const cats: ShipCategory[] = ["ship", "show", "ask"]; + expect(cats).toHaveLength(3); + }); + + it("DifficultyLevel has easy/medium/hard", () => { + const levels: DifficultyLevel[] = ["easy", "medium", "hard"]; + expect(levels).toHaveLength(3); + }); + + it("WorkflowTask has all required fields", () => { + const task: WorkflowTask = { + id: "task-1", + title: "Test Task", + description: "A test task", + status: "pending", + difficulty: "medium", + estimatedMinutes: 30, + dependencies: [], + files: ["src/index.ts"], + shipCategory: "show", + }; + expect(task.id).toBe("task-1"); + expect(task.dependencies).toEqual([]); + }); + + it("WorkflowSpec has proposal, design, tasks, updatedAt", () => { + const spec: WorkflowSpec = { + proposal: "# Proposal", + design: "# Design", + tasks: [], + updatedAt: new Date().toISOString(), + }; + expect(spec.tasks).toHaveLength(0); + }); + + it("WorkflowContext has all required fields", () => { + const ctx: WorkflowContext = { + projectId: "test-project", + projectDir: "/tmp/test", + mode: "standard", + currentStep: "step0-analysis", + spec: null, + activeTaskIndex: 0, + brainstormNotes: [], + decisions: [], + qaGateResults: [], + startedAt: new Date().toISOString(), + openSource: null, + branchName: null, + featureFlags: DEFAULT_FEATURE_FLAGS, + }; + expect(ctx.projectId).toBe("test-project"); + expect(ctx.spec).toBeNull(); + expect(ctx.openSource).toBeNull(); + expect(ctx.branchName).toBeNull(); + expect(ctx.featureFlags).toBeDefined(); + expect(ctx.featureFlags.autoCommit).toBe(true); + }); + + it("QAGateCheck has name, passed, optional output", () => { + const check: QAGateCheck = { name: "lint", passed: true }; + expect(check.output).toBeUndefined(); + const failedCheck: QAGateCheck = { name: "tests", passed: false, output: "1 test failed" }; + expect(failedCheck.output).toBe("1 test failed"); + }); + + it("AgentResult has all required fields", () => { + const result: AgentResult = { + agentId: "glm-5.1", + task: "task-1", + success: true, + output: "Done", + durationMs: 1500, + }; + expect(result.success).toBe(true); + }); + + it("BrainstormOption has optional directoryStructure", () => { + const option: BrainstormOption = { + label: "Option A", + description: "Description", + pros: ["Fast"], + cons: ["Limited"], + }; + expect(option.directoryStructure).toBeUndefined(); + }); + + it("FeatureFlags has all fields with defaults", () => { + const flags: FeatureFlags = { ...DEFAULT_FEATURE_FLAGS }; + expect(flags.strictTdd).toBe(false); + expect(flags.ruleEnforcement).toBe(true); + expect(flags.autoCommit).toBe(true); + expect(flags.workingMemoryPersist).toBe(true); + expect(flags.dependencyParallelTasks).toBe(true); + expect(flags.conventionalCommits).toBe(true); + expect(flags.qaGateBlocking).toBe(false); + expect(flags.githubIntegration).toBe(true); + expect(flags.coverageThreshold).toBe(80); + expect(flags.maxFileLines).toBe(500); + expect(flags.maxFunctionLines).toBe(50); + }); + + it("FeatureFlags can be partially overridden", () => { + const custom: FeatureFlags = { ...DEFAULT_FEATURE_FLAGS, strictTdd: true, coverageThreshold: 95 }; + expect(custom.strictTdd).toBe(true); + expect(custom.coverageThreshold).toBe(95); + expect(custom.autoCommit).toBe(true); + }); + + it("DEV_WORKFLOW_RULES has 21 rules", () => { + const ruleKeys = Object.keys(DEV_WORKFLOW_RULES); + expect(ruleKeys).toHaveLength(21); + }); + + it("DEV_WORKFLOW_RULES each rule has description and severity", () => { + for (const [key, rule] of Object.entries(DEV_WORKFLOW_RULES)) { + expect(rule.description.length).toBeGreaterThan(0); + expect(["error", "warning"]).toContain(rule.severity); + } + }); + + it("DevWorkflowRule type covers all rule keys", () => { + const rules: DevWorkflowRule[] = [ + "no-unused-vars", "prefer-const", "no-console-log", "no-any-type", + "explicit-return-types", "no-magic-numbers", "max-file-lines", "max-function-lines", + "no-inline-styles", "prefer-immutable", "no-deep-nesting", "no-duplicate-code", + "meaningful-names", "single-responsibility", "no-commented-code", "no-debugger", + "no-hardcoded-secrets", "prefer-early-return", "no-boolean-params", + "no-global-mutation", "prefer-pure-functions", + ]; + expect(rules).toHaveLength(21); + for (const r of rules) { + expect(DEV_WORKFLOW_RULES[r]).toBeDefined(); + } + }); +}); diff --git a/extensions/dev-workflow/tsconfig.json b/extensions/dev-workflow/tsconfig.json new file mode 100755 index 0000000000000..590bed785f714 --- /dev/null +++ b/extensions/dev-workflow/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/extensions/dev-workflow/tsconfig.tsbuildinfo b/extensions/dev-workflow/tsconfig.tsbuildinfo new file mode 100755 index 0000000000000..d53f28edbc6f0 --- /dev/null +++ b/extensions/dev-workflow/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/index.ts","./src/types.ts","./src/agents/index.ts","./src/channel/dev-workflow-channel.ts","./src/channel/runtime.ts","./src/engine/index.ts","./src/hooks/index.ts","./src/tools/dev-workflow-tool.ts","./src/tools/index.ts","./src/tools/qa-gate-tool.ts","./src/tools/spec-view-tool.ts","./src/tools/task-execute-tool.ts","./src/tools/workflow-status-tool.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/extensions/dev-workflow/vitest.config.ts b/extensions/dev-workflow/vitest.config.ts new file mode 100755 index 0000000000000..bbbd4f9f4b527 --- /dev/null +++ b/extensions/dev-workflow/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + exclude: ["node_modules", "dist"], + testTimeout: 30000, + }, +});