From 6a80034caa44feb252a7c0cf57633251a326d053 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Mon, 13 Jul 2026 12:36:26 +0800 Subject: [PATCH] feat(man): harden planning and verification --- README.md | 60 +- README.zh-CN.md | 45 +- docs/12-lifecycle.md | 23 +- package-lock.json | 4 +- package.json | 2 +- src/cli.ts | 24 +- src/commands/init.ts | 3 + src/commands/status.ts | 8 + src/commands/workflow.ts | 913 ++++++++++++++++++++++++++++- src/installers/mode-skills.ts | 18 +- src/installers/shared-content.ts | 6 + src/system/requirements-ledger.ts | 266 +++++++++ src/system/review-ledger.ts | 49 ++ src/system/verification-ledger.ts | 386 ++++++++++++ src/system/workflow.ts | 339 ++++++++++- src/templates/agents/plan-coach.ts | 19 +- src/templates/inline.ts | 48 +- src/templates/skills/man.ts | 46 +- src/templates/skills/manteam.ts | 10 +- tests/agents.test.ts | 3 + tests/hook-budget.test.ts | 24 + tests/requirements-ledger.test.ts | 125 ++++ tests/review-ledger.test.ts | 12 + tests/skills.test.ts | 21 +- tests/status.test.ts | 21 + tests/verification-ledger.test.ts | 125 ++++ tests/workflow-command.test.ts | 610 ++++++++++++++++++- website/docs.html | 10 +- website/docs.zh-CN.html | 12 +- website/index.html | 18 +- website/index.zh-CN.html | 6 +- 31 files changed, 3150 insertions(+), 106 deletions(-) create mode 100644 src/system/requirements-ledger.ts create mode 100644 src/system/verification-ledger.ts create mode 100644 tests/requirements-ledger.test.ts create mode 100644 tests/verification-ledger.test.ts diff --git a/README.md b/README.md index 3edba41..4675765 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

License: AGPL-3.0 npm version - Status: stable v0.3.7 + Status: stable v0.3.8 Platforms: Claude Code, Cursor, Codex in ChatGPT desktop and CLI, GitHub Copilot, ZCode Tests: 381 passed

@@ -125,9 +125,12 @@ coding agent reads. - **Match an existing UI system when present**: inspect project UI dependencies, Tailwind configuration, CSS variables, and components so the agent reuses established colors, fonts, and interaction patterns. -- **Add bounded AI code review**: use `/man` for a 9-step workflow with - research, plan approval, implementation, tests, and risk-based review that - cannot repeat the same review domain indefinitely. +- **Align requirements before planning**: `/man` investigates the project, + clarifies decisions that would change the solution, recommends viable + options, and produces a durable plan without automatically starting full execution. +- **Choose the delivery depth**: after plan approval, keep the plan, hand it to + default `solo` for lightweight implementation, or continue the full `/man` + validation and bounded risk-review workflow. - **Keep workflow artifacts on disk**: save research, plans, review reports, and summaries under `.mancode/workflows//`. - **Support team memory**: use `/manteam` to read and update shared project @@ -203,20 +206,28 @@ slash commands in your AI coding agent's conversation: |---|---|---| | `solo` | Daily coding · practice day | Lightweight hooks, style awareness, YAGNI checks, and one bounded diff self-check | | `/manba` | Diagnosis and real validation · Mamba mentality | Reproduces defects, finds root causes, drives real user flows, and runs regression checks | -| `/man` | Production or high-risk changes · playoffs | Full 9-step workflow with targeted or full risk-based review | +| `/man` | Work needing requirement alignment or a formal plan · playoffs | Research, recommendations, and a durable plan; then choose lightweight solo delivery or the full 9-step workflow | | `/manteam` | Team projects · five on the floor, one mind | Shared memory, decisions, coordination, and Conventional Commits | | `/manps` | Cleanup and maintenance · preseason | Project health scan with Markdown and JSON reports | | `/mansolo` | Returning to default mode | Resets current mode back to `solo` | ## How `/man` Works: Playoffs Mode -`/man` is playoffs mode for production work. It creates a durable workflow under -`.mancode/workflows//` and moves through nine steps: +`/man` is both the formal planning entry point and playoffs mode for production +work. A planning or research request made from default `solo` routes into `/man`. +It inspects the project, asks only questions that can change scope, architecture, +cost, or acceptance, and recommends 2–3 options when a decision benefits from +guidance. It writes `plan.md` only after the requirements are ready. + +Finishing the plan does not automatically start the full workflow. At the plan +gate, choose lightweight `solo` implementation, full `/man` execution, plan-only, +or plan revision. Only full execution continues through implementation, validation, +and risk review: 1. **Scout report**: maps existing code, risks, and unknowns. -2. **Clarification**: resolves requirements in up to two rounds. -3. **Plan**: Plan Coach creates a durable, verifiable plan. -4. **Plan gate**: choose plan-only, execution, or plan revision. +2. **Clarification**: asks every unresolved decision-changing question, across as many batches as needed, without repeating confirmed answers; it makes a clear recommendation when a suitable approach exists. +3. **Plan**: Plan Coach checks input readiness, then creates a durable plan with technical choices, boundaries, and acceptance criteria. +4. **Plan gate**: choose lightweight solo delivery, full `/man`, plan-only, or plan revision. 5. **Implementation**: Head Coach applies the confirmed plan. 6. **Validation and review scope**: run build, lint, tests, smoke checks, then select targeted or full review from the actual diff and hard-risk triggers. 7. **Film session 1**: evidence-backed quality review, limited to the changed behavior. @@ -293,7 +304,7 @@ it should behave, and why previous decisions were made. ## Installation -**Status**: stable v0.3.7. Claude Code, Cursor, Codex in the ChatGPT desktop app +**Status**: stable v0.3.8. Claude Code, Cursor, Codex in the ChatGPT desktop app and CLI, and GitHub Copilot are supported. ZCode adapter support is included, with project skill discovery kept behind a verification gate before release. @@ -366,10 +377,20 @@ mancode status --json mancode install mancode list-platforms mancode workflow create "" [--parent-task ] -mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--blocking-reason ""] [--outcome fixed|verified|no_repro|manual_test_required] [--plan-version N] [--skipped a,b] +mancode workflow requirements finalize --file +mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--requirements-status ready|needs_clarification] [--blocking-reason ""] [--outcome fixed|verified|no_repro|manual_test_required] [--plan-version N] [--skipped clarification] +mancode workflow decide --plan-decision plan_only|governed_execution +mancode workflow handoff --to solo +mancode workflow handoff --complete +mancode workflow verify init +mancode workflow verify record --acceptance AC-1 --method automated --result passed|failed --evidence "" --command "" --exit-code [--evidence-file ] +mancode workflow verify require-manual --acceptance AC-1 --evidence "" +mancode workflow verify confirm-manual --acceptance AC-1 --evidence "" +mancode workflow verify show [--json] mancode workflow review init --review-depth [--review-domain ] mancode workflow review complete --review-domain --report [--blockers Q1,Q2] mancode workflow review remediate --resolved Q1,Q2 +mancode workflow review skip --reason "" mancode workflow review show [--json] mancode workflow list [--json] mancode workflow show [--json] @@ -386,7 +407,7 @@ mancode version Example output for a UI project (not a default stack): ```text -mancode v0.3.7 +mancode v0.3.8 Project: my-app (React + TypeScript + Tailwind) Mode: solo (default) @@ -449,13 +470,20 @@ mancode status --json ### `mancode workflow` Creates and manages validated workflow metadata used by `/manba`, `/man`, and -`/manteam`. A linked `/manba` child can only be created while its parent is -active at Step 6. Governed review state records required domains, blocker IDs, -and the single remediation round. +`/manteam`. New governed workflows finalize seven explicit coverage dimensions, +structured requirements, and stable acceptance IDs. Automated verification +records the command and exit code. Remediation invalidates earlier evidence, so +all required checks must be re-recorded at Step 9. Manual checks block until +explicit user confirmation is recorded. Review can only be skipped through the +Step 6 review skip command with a reason; targeted review treats its second +domain as not applicable. ```bash mancode workflow create man "refactor auth module" +mancode workflow requirements finalize --file requirements-input.json mancode workflow update --step 4 --plan-version 2 +mancode workflow verify init +mancode workflow verify record --acceptance AC-1 --method automated --result passed --evidence "tests passed" --command "npm test" --exit-code 0 mancode workflow review init --review-depth full mancode workflow review complete --review-domain quality --report film-report-1.md --blockers Q1 mancode workflow review remediate --resolved Q1 diff --git a/README.zh-CN.md b/README.zh-CN.md index 76a6d4a..f1bb0d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,7 @@

许可证:AGPL-3.0 npm 版本 - 状态:稳定版 v0.3.7 + 状态:稳定版 v0.3.8 平台:Claude Code、Cursor、ChatGPT 桌面端 Codex、Codex CLI、GitHub Copilot、ZCode 测试:381 通过

@@ -112,7 +112,8 @@ AGENTS.md # Codex(ChatGPT 桌面端/CLI):托管 ins - **减少 AI 过度设计**:先复用已有代码、标准库、已安装依赖和一行修复,再考虑新增抽象。 - **在存在 UI 时匹配现有设计系统**:检查项目 UI 依赖、Tailwind 配置、CSS 变量和已有组件,让 agent 复用现有颜色、字体和交互模式。 -- **加入有界 AI 代码审查**:`/man` 提供 9 步流程,包括调研、计划审批、实现、测试和按风险选择的审查,同一领域不会无限重复。 +- **先把需求和计划对齐**:`/man` 会调研项目、引导澄清会改变方案的需求、推荐可行选项并生成可确认的持久计划;计划完成后不会自动进入完整实施。 +- **自由选择执行强度**:计划确认后,可只保留计划、交给默认 `solo` 轻量开发,或继续完整 `/man` 的验证与有界风险审查。 - **保留工作流产物**:调研、计划、审查报告和总结会保存到 `.mancode/workflows//`。 - **支持团队记忆**:`/manteam` 读写 `.mancode/memory/` 下的共享项目上下文。 - **扫描项目健康度**:`mancode manps` 检测陈旧 TODO、未使用依赖、风险依赖和硬编码设计值。 @@ -174,20 +175,27 @@ mancode 不是 Claude Code、Cursor、Codex 或 Copilot 的替代品。它是在 |---|---|---| | `solo` | 日常编码 · 日常训练 | 轻量 hooks、风格感知、YAGNI 检查和一次受限 diff 自检 | | `/manba` | 诊断与真实验证 · 曼巴心态 | 复现缺陷、定位根因、驱动真实用户路径并执行回归检查 | -| `/man` | 生产级或高风险改动 · 季后赛 | 完整 9 步工作流和定向/完整风险审查 | +| `/man` | 需要需求对齐或正式计划的改动 · 季后赛 | 调研、方案推荐和持久计划;确认后选择 solo 轻量开发或完整 9 步治理 | | `/manteam` | 团队项目 · 上场五人,一条心 | 共享记忆、决策记录、协作和 Conventional Commits | | `/manps` | 清理和维护 · 季前赛 | 输出 Markdown 和 JSON 项目健康报告 | | `/mansolo` | 回到默认模式 | 将当前模式重置为 `solo` | ## `/man` 如何工作:季后赛模式 -`/man` 是面向关键任务的季后赛模式。它会在 `.mancode/workflows//` -下创建可追溯工作流,并推进九个步骤: +`/man` 既是正式计划入口,也是面向关键任务的季后赛模式。即使当前处于默认 +`solo`,当用户要求先调研、给方案或出计划时,也会进入 `/man`。它会先了解项目, +只追问会改变范围、架构、成本或验收的问题;适合由系统推荐的决策会给出 2–3 个 +方案、优缺点和明确建议。需求足够清楚后,计划才会写入 +`.mancode/workflows//plan.md`。 + +计划完成不会自动开始完整开发。用户在计划关卡选择:交给 `solo` 按已确认计划 +轻量开发、继续完整 `/man`、只保留计划,或修改计划。只有选择完整 `/man` 才继续 +后续实施、验证和风险审查: 1. **球探报告**:梳理既有代码、风险和未知项。 -2. **需求澄清**:最多两轮确认需求。 -3. **计划**:Plan Coach 输出可验证的持久计划。 -4. **计划关卡**:选择只要计划、继续执行或修改计划。 +2. **需求澄清**:按需求就绪程度引导对齐;问出所有会改变方案且无法从项目查清的疑问,可按需分多批,不限制数量、不重复已确认内容,有合适方案时直接给出推荐。 +3. **计划**:Plan Coach 先检查输入是否完整,再输出包含技术选择、边界和验收标准的持久计划。 +4. **计划关卡**:选择 solo 轻量执行、完整 `/man`、只保留计划或修改计划。 5. **实施**:Head Coach 按确认计划实现。 6. **验证与审查范围**:运行 build、lint、test、smoke test,再根据实际 diff 和硬风险选择定向或完整审查。 7. **录像分析 1**:只对改动行为做有证据的质量审查。 @@ -254,7 +262,7 @@ src/components/ ## 安装 -**状态**:稳定版 v0.3.7。Claude Code、Cursor、ChatGPT 桌面端中的 Codex、 +**状态**:稳定版 v0.3.8。Claude Code、Cursor、ChatGPT 桌面端中的 Codex、 Codex CLI 和 GitHub Copilot 均已支持。ZCode adapter 已接入,但项目级 skill 发现路径在发布前仍作为验证门禁。 @@ -325,10 +333,20 @@ mancode status --json mancode install mancode list-platforms mancode workflow create "" [--parent-task ] -mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--blocking-reason ""] [--outcome fixed|verified|no_repro|manual_test_required] [--plan-version N] [--skipped a,b] +mancode workflow requirements finalize --file +mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--requirements-status ready|needs_clarification] [--blocking-reason ""] [--outcome fixed|verified|no_repro|manual_test_required] [--plan-version N] [--skipped clarification] +mancode workflow decide --plan-decision plan_only|governed_execution +mancode workflow handoff --to solo +mancode workflow handoff --complete +mancode workflow verify init +mancode workflow verify record --acceptance AC-1 --method automated --result passed|failed --evidence "" --command "" --exit-code [--evidence-file ] +mancode workflow verify require-manual --acceptance AC-1 --evidence "" +mancode workflow verify confirm-manual --acceptance AC-1 --evidence "" +mancode workflow verify show [--json] mancode workflow review init --review-depth [--review-domain ] mancode workflow review complete --review-domain --report [--blockers Q1,Q2] mancode workflow review remediate --resolved Q1,Q2 +mancode workflow review skip --reason "<用户明确理由>" mancode workflow review show [--json] mancode workflow list [--json] mancode workflow show [--json] @@ -345,7 +363,7 @@ mancode version 以下是 UI 项目的输出示例,并非默认技术栈: ```text -mancode v0.3.7 +mancode v0.3.8 Project: my-app (React + TypeScript + Tailwind) Mode: solo (default) @@ -405,11 +423,14 @@ mancode status --json ### `mancode workflow` -创建和管理 `/manba`、`/man` 和 `/manteam` 使用的受校验 workflow 元数据。关联 `/manba` 子任务只能在父任务处于 Step 6 且正在进行时创建;严格模式的 review 状态会记录所需领域、blocker ID 和唯一一轮修复。 +创建和管理 `/manba`、`/man` 和 `/manteam` 使用的受校验 workflow 元数据。新 `/man`/`/manteam` 任务通过七项 coverage、结构化需求和验收 ID 固化范围;自动验证记录命令与退出码。remediation 会让旧证据失效,必须在 Step 9 重新登记全部 required 验收。人工验收会阻塞流程,直到记录用户明确确认。跳过 review 只能在 Step 6 使用专用 skip 命令并记录理由;targeted 的第二领域是不适用。 ```bash mancode workflow create man "refactor auth module" +mancode workflow requirements finalize --file requirements-input.json mancode workflow update --step 4 --plan-version 2 +mancode workflow verify init +mancode workflow verify record --acceptance AC-1 --method automated --result passed --evidence "tests passed" --command "npm test" --exit-code 0 mancode workflow review init --review-depth full mancode workflow review complete --review-domain quality --report film-report-1.md --blockers Q1 mancode workflow review remediate --resolved Q1 diff --git a/docs/12-lifecycle.md b/docs/12-lifecycle.md index f717fe3..63d8b5f 100644 --- a/docs/12-lifecycle.md +++ b/docs/12-lifecycle.md @@ -107,7 +107,7 @@ Node 直接解析 JSON。不存在 jq fallback,也不会调用任何外部进 ```json { - "version": "0.3.7", + "version": "0.3.8", "currentMode": "solo", "lastMode": "solo", "platform": "claude-code", @@ -117,6 +117,7 @@ Node 直接解析 JSON。不存在 jq fallback,也不会调用任何外部进 "currentTask": null, "currentWorkflowMode": null, "skippedSteps": [], + "activeSoloPlan": null, "teamModeAutoDetected": false, "contributors": 1 } @@ -129,6 +130,7 @@ Node 直接解析 JSON。不存在 jq fallback,也不会调用任何外部进 | `techStack` / `uiLibrary` | 从 project profile 得到的摘要 | | `currentTask` / `currentWorkflowMode` | 当前持久化工作流信息 | | `skippedSteps` | 用户明确跳过的工作流步骤 | +| `activeSoloPlan` | `/man` 计划交给 solo 后保留的 taskId 与 planVersion;solo 验证完成后由 `workflow handoff --complete` 原子清理 | | `teamModeAutoDetected` | 自动检测或显式配置后的团队状态 | | `contributors` | 检测到的贡献者数量;降级时为 1 | @@ -185,7 +187,22 @@ shell 中语义一致。 --- -## 6. 验证矩阵 +## 6. `/man` 受管验证门禁 + +新创建的 `/man` 与 `/manteam` workflow 使用 policy v2: + +1. Step 2 把目标、非空范围、排除项、技术决策、默认值、阻塞未知项、七个 coverage 维度和带稳定 ID 的验收标准写入结构化输入。 +2. `workflow requirements finalize --file ` 校验输入,并确定性生成 `requirements.json` 和 `requirements.md`;readiness 由 `blockingUnknowns` 是否为空推导。 +3. Step 6 初始化 `verification-ledger.json`。每个 required 验收项按 `automated`、`manual` 或 `hybrid` 记录证据;自动 passed/failed 包含命令和退出码。 +4. `manual_required` 会把主 workflow 标为 blocked;普通 `workflow update --status in_progress` 不能绕过,必须在取得用户明确确认后通过 `verify ... confirm-manual` 恢复。 +5. 台账绑定 requirements digest、planVersion 与 remediation round。任一发生变化,旧验证自动失效;remediation 后在 Step 9 重新登记全部 required 验收。 +6. 验证未全部通过时,CLI 同时拒绝 Step 7、review 初始化和 completed。完成还要求 `summary.md`。review 只能在 Step 6 通过专用 skip 命令和理由跳过。 + +review policy v2 只允许 `clarification` 和用户明确要求跳过的整个 `review` 出现在 `skippedSteps`。targeted review 的第二领域是不适用,不写入 skippedSteps。旧 policy v1 workflow 保持兼容读取。 + +--- + +## 7. 验证矩阵 本地测试覆盖: @@ -201,7 +218,7 @@ Bash 执行 smoke test。测试清空子进程 PATH 来模拟 Git/Bash 不可用 --- -## 7. 实施状态 +## 8. 实施状态 | 阶段 | 内容 | |---|---| diff --git a/package-lock.json b/package-lock.json index 26f9bc5..9539eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mancode", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mancode", - "version": "0.3.7", + "version": "0.3.8", "license": "AGPL-3.0-only", "dependencies": { "commander": "^12.1.0" diff --git a/package.json b/package.json index b3a98cf..b9e1302 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mancode", - "version": "0.3.7", + "version": "0.3.8", "description": "AI coding agent workflow harness. Five modes from practice to playoffs: stop over-engineering, reuse project context, and add multi-agent code review.", "type": "module", "license": "AGPL-3.0-only", diff --git a/src/cli.ts b/src/cli.ts index 1a7f751..6ee431f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -95,7 +95,20 @@ program .option('--blocking-reason ', 'Explain why a workflow is blocked') .option('--outcome ', 'Set manba outcome') .option('--plan-version ', 'Set the next man/manteam plan revision') - .option('--skipped ', 'Update skipped steps as comma-separated values') + .option( + '--requirements-status ', + 'Planning readiness: ready or needs_clarification', + ) + .option( + '--plan-decision ', + 'Plan gate choice: plan_only or governed_execution', + ) + .option('--to ', 'Workflow handoff target (solo)') + .option('--complete', 'Complete an active solo handoff') + .option( + '--skipped ', + 'Policy v2: clarification only; use workflow review skip for review', + ) .option('--review-depth ', 'Review depth: targeted or full') .option('--review-domain ', 'Review domain: quality or security') .option( @@ -107,6 +120,15 @@ program '--resolved ', 'Comma-separated blocker ids resolved in remediation', ) + .option('--file ', 'Structured requirements JSON input file') + .option('--acceptance ', 'Acceptance criterion id (for example AC-1)') + .option('--method ', 'Verification method: automated or manual') + .option('--result ', 'Verification result') + .option('--evidence ', 'Verification evidence or user confirmation') + .option('--command ', 'Command used for automated verification') + .option('--exit-code ', 'Exit code from automated verification') + .option('--evidence-file ', 'Existing verification report or artifact') + .option('--reason ', 'Reason for an explicit review skip') .option('--json', 'Output as JSON (for scripts)') .action(async (subcommand, args, options) => { const code = await workflow(process.cwd(), subcommand, args ?? [], options); diff --git a/src/commands/init.ts b/src/commands/init.ts index 85edc78..b7d74db 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -65,6 +65,8 @@ export interface MancodeState { currentTask: string | null; currentWorkflowMode: 'man' | 'mamba' | 'manteam' | null; skippedSteps: string[]; + /** Confirmed /man plan currently being implemented in lightweight solo mode. */ + activeSoloPlan: { taskId: string; planVersion: number } | null; // MVP-2: 团队检测 teamModeAutoDetected: boolean; contributors: number; @@ -396,6 +398,7 @@ export async function init( currentTask: null, currentWorkflowMode: null, skippedSteps: [], + activeSoloPlan: null, teamModeAutoDetected: teamModeEnabled, contributors: team.contributors, projectMode: isGenericProject ? 'generic' : 'detected', diff --git a/src/commands/status.ts b/src/commands/status.ts index f3da97e..bc0e4e3 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -46,6 +46,7 @@ export interface StatusState { uiLibrary: string; currentTask?: string | null; currentWorkflowMode?: string | null; + activeSoloPlan?: { taskId: string; planVersion: number } | null; teamModeAutoDetected?: boolean; contributors?: number; projectMode?: 'generic' | 'detected'; @@ -97,6 +98,7 @@ export interface StatusResult { status: WorkflowStatus; }>; } | null; + activeSoloPlan: { taskId: string; planVersion: number } | null; platformStatus: Record; projectRefreshRecommended: boolean; } @@ -190,6 +192,7 @@ export async function status( hookInjection, team: effectiveTeam, currentWorkflow, + activeSoloPlan: state.activeSoloPlan ?? null, platformStatus: {}, projectRefreshRecommended, }; @@ -533,6 +536,11 @@ function printText(r: StatusResult): void { ); } } + if (r.activeSoloPlan) { + console.log( + `Solo plan: ${r.activeSoloPlan.taskId} (plan v${r.activeSoloPlan.planVersion})`, + ); + } console.log(''); console.log('Installed platforms:'); for (const p of r.platforms) { diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index e28b2c2..28c26e4 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -1,20 +1,42 @@ -import { access } from 'node:fs/promises'; +import { access, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { + parseRequirementsLedger, + readRequirementsLedger, + requirementsAreReady, + requirementsDigest, + writeRequirementsArtifacts, +} from '../system/requirements-ledger.js'; import { type ReviewLedger, completeReviewDomain, initializeReview, + initializeSkippedReview, isReviewDepth, isReviewDomain, readReviewLedger, remediateReviewBlockers, + reviewLedgerPath, } from '../system/review-ledger.js'; import { upsertActivePlan } from '../system/team-memory.js'; +import { + type VerificationLedger, + confirmManualVerification, + initializeVerificationLedger, + readVerificationLedger, + recordVerification, + resetVerificationForRemediation, + verificationCanAdvance, + verificationLedgerPath, + writeVerificationLedger, +} from '../system/verification-ledger.js'; import { type WorkflowMeta, type WorkflowMode, createWorkflow, deleteWorkflow, + isPlanDecision, + isRequirementsStatus, isTerminalWorkflowStatus, isValidWorkflowTaskId, isWorkflowOutcome, @@ -45,6 +67,19 @@ export interface WorkflowOptions { report?: string; blockers?: string; resolved?: string; + requirementsStatus?: string; + planDecision?: string; + to?: string; + complete?: boolean; + file?: string; + acceptance?: string; + method?: string; + result?: string; + evidence?: string; + command?: string; + exitCode?: string; + evidenceFile?: string; + reason?: string; } interface WorkflowView extends WorkflowMeta { @@ -85,8 +120,16 @@ export async function workflow( return workflowList(rootDir, options); case 'show': return workflowShow(rootDir, args[0], options); + case 'handoff': + return workflowHandoff(rootDir, args[0], options); + case 'decide': + return workflowDecide(rootDir, args[0], options); case 'review': return workflowReview(rootDir, args, options); + case 'requirements': + return workflowRequirements(rootDir, args, options); + case 'verify': + return workflowVerify(rootDir, args, options); case 'clean': return workflowClean(rootDir, options); default: @@ -101,13 +144,349 @@ export async function workflow( } else { console.error(`✗ Invalid workflow subcommand: ${subcommand}`); console.error( - ' Use: create | update | review | list | show | clean', + ' Use: create | requirements finalize --file | verify | decide --plan-decision | handoff --to solo | review | list | show | clean', ); } return EXIT_INVALID_ARG; } } +async function workflowRequirements( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const taskId = args[0]; + const action = args[1]; + if (!taskId || !isValidWorkflowTaskId(taskId)) { + return invalidArg(options, `invalid taskId: ${taskId ?? ''}`); + } + if (action !== 'finalize' || !options.file) { + return invalidArg(options, 'requirements requires finalize --file '); + } + const meta = await readWorkflow(rootDir, taskId); + if ( + !meta || + (meta.mode !== 'man' && meta.mode !== 'manteam') || + meta.status !== 'in_progress' || + meta.currentStep > 2 + ) { + return invalidArg( + options, + 'requirements can only be finalized for an in-progress man or manteam workflow at step 1 or 2', + ); + } + + const workflowPath = path.join(rootDir, '.mancode', 'workflows', taskId); + const jsonPath = path.join(workflowPath, 'requirements.json'); + const markdownPath = path.join(workflowPath, 'requirements.md'); + const metadataPath = path.join(workflowPath, 'metadata.json'); + let originalJson: string | null | undefined; + let originalMarkdown: string | null | undefined; + let originalMetadata: string | undefined; + try { + const inputPath = path.isAbsolute(options.file) + ? options.file + : path.resolve(rootDir, options.file); + const input = await readFile(inputPath, 'utf-8'); + const requirements = parseRequirementsLedger(input); + [originalJson, originalMarkdown, originalMetadata] = await Promise.all([ + readOptionalText(jsonPath), + readOptionalText(markdownPath), + readFile(metadataPath, 'utf-8'), + ]); + await writeRequirementsArtifacts(rootDir, taskId, requirements); + await updateWorkflow(rootDir, taskId, { + requirementsStatus: requirementsAreReady(requirements) + ? 'ready' + : 'needs_clarification', + requirementsDigest: requirementsDigest(requirements), + }); + if (options.json) { + console.log( + JSON.stringify( + { + requirements, + requirementsStatus: requirementsAreReady(requirements) + ? 'ready' + : 'needs_clarification', + }, + null, + 2, + ), + ); + } else { + console.log( + `Finalized requirements: ${taskId} (${requirementsAreReady(requirements) ? 'ready' : 'needs_clarification'})`, + ); + } + return EXIT_OK; + } catch (error) { + let rollbackIncomplete = false; + if ( + originalJson !== undefined && + originalMarkdown !== undefined && + originalMetadata !== undefined + ) { + const rollback = await Promise.allSettled([ + restoreOptionalText(jsonPath, originalJson), + restoreOptionalText(markdownPath, originalMarkdown), + writeFile(metadataPath, originalMetadata, 'utf-8'), + ]); + rollbackIncomplete = rollback.some( + (result) => result.status === 'rejected', + ); + } + const message = + error instanceof Error + ? error.message + : 'unable to finalize requirements'; + return invalidArg( + options, + rollbackIncomplete ? `${message}; rollback was incomplete` : message, + ); + } +} + +async function workflowVerify( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const taskId = args[0]; + const action = args[1]; + if (!taskId || !isValidWorkflowTaskId(taskId)) { + return invalidArg(options, `invalid taskId: ${taskId ?? ''}`); + } + const meta = await readWorkflow(rootDir, taskId); + if (!meta || (meta.mode !== 'man' && meta.mode !== 'manteam')) { + return invalidArg(options, `verification is not valid for: ${taskId}`); + } + if (action === 'show') { + const ledger = await readVerificationLedger(rootDir, taskId); + if (!ledger) { + return invalidArg(options, `verification not initialized: ${taskId}`); + } + outputVerificationLedger(ledger, options); + return EXIT_OK; + } + if ( + meta.verificationPolicyVersion !== 1 || + (action === 'init' + ? meta.currentStep !== 6 + : meta.currentStep !== 6 && meta.currentStep !== 9) || + meta.planDecision !== 'governed_execution' || + (meta.status !== 'in_progress' && meta.status !== 'blocked') + ) { + return invalidArg( + options, + 'verification requires a governed policy-v2 workflow at step 6, or step 9 when refreshing evidence', + ); + } + + try { + let ledger: VerificationLedger; + if (action === 'init') { + if (await readVerificationLedger(rootDir, taskId)) { + return invalidArg( + options, + `verification already initialized: ${taskId}`, + ); + } + const requirements = await readRequirementsLedger(rootDir, taskId); + if (!requirements || !requirementsAreReady(requirements)) { + return invalidArg(options, 'ready requirements.json is required'); + } + ledger = initializeVerificationLedger( + requirements, + meta.planVersion ?? 1, + 0, + ); + } else { + let existing = await readVerificationLedger(rootDir, taskId); + if (!existing) { + return invalidArg(options, `verification not initialized: ${taskId}`); + } + const review = await readReviewLedger(rootDir, taskId); + existing = resetVerificationForRemediation( + existing, + review?.remediationRounds ?? 0, + ); + if (!options.acceptance || !options.evidence) { + return invalidArg( + options, + `${action ?? 'verification'} requires --acceptance and --evidence`, + ); + } + if (action === 'record') { + if ( + options.method !== 'automated' || + (options.result !== 'passed' && + options.result !== 'failed' && + options.result !== 'blocked') + ) { + return invalidArg( + options, + 'record requires --method automated and --result passed|failed|blocked', + ); + } + let automatedDetails: + | { command: string; exitCode: number; evidenceFile?: string } + | undefined; + if (options.result === 'passed' || options.result === 'failed') { + const exitCode = parseExactInteger(options.exitCode); + if (!options.command?.trim() || exitCode === null) { + return invalidArg( + options, + 'automated passed/failed verification requires --command and --exit-code', + ); + } + if (options.evidenceFile) { + const evidencePath = path.isAbsolute(options.evidenceFile) + ? options.evidenceFile + : path.resolve(rootDir, options.evidenceFile); + if (!(await pathExists(evidencePath))) { + return invalidArg( + options, + `verification evidence file not found: ${options.evidenceFile}`, + ); + } + } + automatedDetails = { + command: options.command, + exitCode, + ...(options.evidenceFile + ? { evidenceFile: options.evidenceFile } + : {}), + }; + } + ledger = recordVerification( + existing, + options.acceptance, + 'automated', + options.result, + options.evidence, + automatedDetails, + ); + } else if (action === 'require-manual') { + ledger = recordVerification( + existing, + options.acceptance, + 'manual', + 'manual_required', + options.evidence, + ); + } else if (action === 'confirm-manual') { + ledger = confirmManualVerification( + existing, + options.acceptance, + options.evidence, + ); + } else { + return invalidArg( + options, + `invalid verification action: ${action ?? ''}`, + ); + } + } + + const transitionError = await commitVerificationTransition( + rootDir, + meta, + ledger, + ); + if (transitionError) return invalidArg(options, transitionError); + outputVerificationLedger(ledger, options); + return EXIT_OK; + } catch (error) { + return invalidArg( + options, + error instanceof Error ? error.message : 'unable to update verification', + ); + } +} + +async function commitVerificationTransition( + rootDir: string, + meta: WorkflowMeta, + ledger: VerificationLedger, +): Promise { + const ledgerPath = verificationLedgerPath(rootDir, meta.taskId); + const metadataPath = path.join( + rootDir, + '.mancode', + 'workflows', + meta.taskId, + 'metadata.json', + ); + const specPath = path.join(rootDir, '.mancode', 'memory', 'spec.md'); + const [originalLedger, originalMetadata, originalSpec] = await Promise.all([ + readOptionalText(ledgerPath), + readFile(metadataPath, 'utf-8'), + readOptionalText(specPath), + ]); + const wasVerificationBlocked = + meta.status === 'blocked' && + meta.blockingReason?.startsWith('[verification]'); + const shouldBlock = + ledger.status === 'manual_required' || ledger.status === 'blocked'; + const workflowPatch: Partial = { + verificationStatus: ledger.status, + ...(shouldBlock + ? { + status: 'blocked', + blockingReason: `[verification] ${ledger.status === 'manual_required' ? 'manual confirmation required' : 'verification blocked'}`, + } + : wasVerificationBlocked + ? { status: 'in_progress', blockingReason: undefined } + : {}), + }; + try { + await writeVerificationLedger(rootDir, meta.taskId, ledger); + await updateWorkflow(rootDir, meta.taskId, workflowPatch, { + allowIncompleteVerification: true, + }); + const updated = await readWorkflow(rootDir, meta.taskId); + if (updated) { + await upsertActivePlan(rootDir, { + taskId: updated.taskId, + status: updated.status, + planVersion: updated.planVersion ?? 1, + }); + } + return null; + } catch (error) { + const rollback = await Promise.allSettled([ + restoreOptionalText(ledgerPath, originalLedger), + writeFile(metadataPath, originalMetadata, 'utf-8'), + restoreOptionalText(specPath, originalSpec), + ]); + const message = + error instanceof Error ? error.message : 'verification transition failed'; + return rollback.some((result) => result.status === 'rejected') + ? `${message}; rollback was incomplete` + : message; + } +} + +function outputVerificationLedger( + ledger: VerificationLedger, + options: WorkflowOptions, +): void { + if (options.json) { + console.log(JSON.stringify(ledger, null, 2)); + return; + } + const passed = ledger.checks.filter((check) => + [check.automated, check.manual] + .filter(Boolean) + .every((component) => component?.status === 'passed'), + ).length; + console.log( + `Verification: ${ledger.status}; criteria ${passed}/${ledger.checks.length}; plan v${ledger.planVersion}`, + ); +} + async function workflowReview( rootDir: string, args: string[], @@ -136,6 +515,46 @@ async function workflowReview( 'review requires an in_progress workflow at step 6 or later', ); } + if ( + (action === 'init' || action === 'skip') && + meta.verificationPolicyVersion === 1 && + !(await verificationCanAdvance(rootDir, taskId, meta.planVersion ?? 1)) + ) { + return invalidArg( + options, + 'workflow verification must pass before review can start', + ); + } + + if (action === 'skip') { + if ( + meta.reviewPolicyVersion !== 2 || + meta.currentStep !== 6 || + meta.planDecision !== 'governed_execution' || + !options.reason?.trim() + ) { + return invalidArg( + options, + 'review skip requires a governed policy-v2 workflow at step 6 and --reason', + ); + } + try { + const error = await commitReviewSkip( + rootDir, + meta, + options.reason.trim(), + ); + if (error) return invalidArg(options, error); + const ledger = await readReviewLedger(rootDir, taskId); + outputReviewLedger(ledger, options); + return EXIT_OK; + } catch (error) { + return invalidArg( + options, + error instanceof Error ? error.message : 'unable to skip review', + ); + } + } try { let ledger: ReviewLedger; @@ -185,7 +604,7 @@ async function workflowReview( return invalidArg( options, `invalid review action: ${action ?? ''}`, - 'Use: mancode workflow review ', + 'Use: mancode workflow review ', ); } outputReviewLedger(ledger, options); @@ -198,6 +617,42 @@ async function workflowReview( } } +async function commitReviewSkip( + rootDir: string, + meta: WorkflowMeta, + reason: string, +): Promise { + const ledgerPath = reviewLedgerPath(rootDir, meta.taskId); + const metadataPath = path.join( + rootDir, + '.mancode', + 'workflows', + meta.taskId, + 'metadata.json', + ); + const [originalLedger, originalMetadata] = await Promise.all([ + readOptionalText(ledgerPath), + readFile(metadataPath, 'utf-8'), + ]); + try { + await initializeSkippedReview(rootDir, meta.taskId, reason); + await updateWorkflow(rootDir, meta.taskId, { + skippedSteps: [...new Set([...meta.skippedSteps, 'review'])], + }); + return null; + } catch (error) { + const rollback = await Promise.allSettled([ + restoreOptionalText(ledgerPath, originalLedger), + writeFile(metadataPath, originalMetadata, 'utf-8'), + ]); + const message = + error instanceof Error ? error.message : 'review skip failed'; + return rollback.some((result) => result.status === 'rejected') + ? `${message}; rollback was incomplete` + : message; + } +} + function outputReviewLedger( ledger: Awaited>, options: WorkflowOptions, @@ -207,6 +662,10 @@ function outputReviewLedger( return; } if (!ledger) return; + if (ledger.skipped) { + console.log(`Review: skipped; reason ${ledger.skipped.reason}`); + return; + } const openBlockers = ledger.blockers.filter( (blocker) => blocker.status === 'open', ); @@ -248,6 +707,8 @@ async function workflowCreate( try { const meta = await createWorkflow(rootDir, task, mode, { parentTaskId: options.parentTask, + planningPolicyVersion: + mode === 'man' || mode === 'manteam' ? 2 : undefined, }); if (options.json) { console.log(JSON.stringify(meta, null, 2)); @@ -272,7 +733,7 @@ async function workflowUpdate( return invalidArg( options, 'missing taskId', - 'Use: mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--blocking-reason ] [--outcome ] [--plan-version N] [--skipped a,b]', + 'Use: mancode workflow update [--step N] [--status in_progress|planned|completed|blocked|abandoned] [--requirements-status ready|needs_clarification] [--blocking-reason ] [--outcome ] [--plan-version N] [--skipped clarification]', ); } if (!isValidWorkflowTaskId(taskId)) { @@ -347,11 +808,59 @@ async function workflowUpdate( patch.planVersion = planVersion; } + if (options.requirementsStatus !== undefined) { + if ( + !isRequirementsStatus(options.requirementsStatus) || + (existing.mode !== 'man' && existing.mode !== 'manteam') + ) { + return invalidArg( + options, + `invalid --requirements-status: ${options.requirementsStatus}`, + ); + } + patch.requirementsStatus = options.requirementsStatus; + } + + if (options.planDecision !== undefined) { + if ( + options.planDecision !== 'governed_execution' || + (existing.mode !== 'man' && existing.mode !== 'manteam') + ) { + return invalidArg( + options, + `invalid --plan-decision: ${options.planDecision}`, + ); + } + patch.planDecision = options.planDecision; + } + if (options.skipped !== undefined) { - patch.skippedSteps = options.skipped + const requestedSkipped = options.skipped .split(',') .map((item) => item.trim()) .filter(Boolean); + if (existing.reviewPolicyVersion === 2) { + if (requestedSkipped.includes('review')) { + return invalidArg( + options, + 'policy-v2 review skips require workflow review skip --reason ', + ); + } + if ( + existing.currentStep > 2 || + requestedSkipped.some((item) => item !== 'clarification') + ) { + return invalidArg( + options, + 'policy-v2 clarification can only be skipped at step 1 or 2', + ); + } + patch.skippedSteps = [ + ...new Set([...existing.skippedSteps, ...requestedSkipped]), + ]; + } else { + patch.skippedSteps = requestedSkipped; + } } if (Object.keys(patch).length === 0) { @@ -387,6 +896,389 @@ async function workflowUpdate( return EXIT_OK; } +async function workflowHandoff( + rootDir: string, + taskId: string | undefined, + options: WorkflowOptions, +): Promise { + if (!taskId || !isValidWorkflowTaskId(taskId)) { + return invalidArg(options, `invalid taskId: ${taskId ?? ''}`); + } + if (options.complete) { + if (options.to !== undefined) { + return invalidArg(options, '--complete cannot be combined with --to'); + } + return workflowCompleteHandoff(rootDir, taskId, options); + } + if (options.to !== 'solo') { + return invalidArg(options, 'handoff requires --to solo'); + } + + const meta = await readWorkflow(rootDir, taskId); + if (!meta || (meta.mode !== 'man' && meta.mode !== 'manteam')) { + return invalidArg(options, `workflow cannot be handed off: ${taskId}`); + } + if ( + meta.status !== 'in_progress' || + meta.currentStep !== 4 || + meta.requirementsStatus !== 'ready' || + meta.planDecision !== undefined + ) { + return invalidArg( + options, + 'solo handoff requires an undecided in-progress workflow at step 4 with ready requirements', + ); + } + const workflowPath = path.join(rootDir, '.mancode', 'workflows', taskId); + if ( + !(await pathExists(path.join(workflowPath, 'requirements.md'))) || + !(await pathExists(path.join(workflowPath, 'plan.md'))) + ) { + return invalidArg( + options, + 'solo handoff requires requirements.md and plan.md', + ); + } + + const statePath = path.join(rootDir, '.mancode', 'state.json'); + let originalState: string; + let state: Record; + try { + originalState = await readFile(statePath, 'utf-8'); + state = JSON.parse(originalState) as Record; + } catch { + return invalidArg( + options, + 'unable to read .mancode/state.json for handoff', + ); + } + if (state.currentTask !== taskId) { + return invalidArg( + options, + 'current workflow state does not match the handoff task', + ); + } + const activeSoloPlan = readActiveSoloPlan(state.activeSoloPlan); + if (activeSoloPlan && activeSoloPlan.taskId !== taskId) { + return invalidArg( + options, + `another solo plan is active: ${activeSoloPlan.taskId}; complete or abandon it before handoff`, + ); + } + + const nextState = { + ...state, + currentMode: 'solo', + lastMode: + typeof state.currentMode === 'string' ? state.currentMode : meta.mode, + currentTask: null, + currentWorkflowMode: null, + skippedSteps: [], + activeSoloPlan: { + taskId, + planVersion: meta.planVersion ?? 1, + }, + }; + + const transitionError = await commitPlanningTransition({ + rootDir, + taskId, + statePath, + originalState, + nextState, + workflowPatch: { + status: 'planned', + planDecision: 'solo_handoff', + }, + activePlanStatus: 'planned', + planVersion: meta.planVersion ?? 1, + }); + if (transitionError) { + return invalidArg(options, transitionError); + } + + const result = await readWorkflow(rootDir, taskId); + if (options.json) { + console.log( + JSON.stringify( + { workflow: result, activeSoloPlan: nextState.activeSoloPlan }, + null, + 2, + ), + ); + } else { + console.log(`Handed off plan to solo: ${taskId}`); + } + return EXIT_OK; +} + +async function workflowCompleteHandoff( + rootDir: string, + taskId: string, + options: WorkflowOptions, +): Promise { + const meta = await readWorkflow(rootDir, taskId); + if ( + !meta || + (meta.mode !== 'man' && meta.mode !== 'manteam') || + meta.status !== 'planned' || + meta.currentStep !== 4 || + meta.planDecision !== 'solo_handoff' + ) { + return invalidArg( + options, + `workflow is not an active solo handoff: ${taskId}`, + ); + } + + const statePath = path.join(rootDir, '.mancode', 'state.json'); + let originalState: string; + let state: Record; + try { + originalState = await readFile(statePath, 'utf-8'); + state = JSON.parse(originalState) as Record; + } catch { + return invalidArg( + options, + 'unable to read .mancode/state.json for handoff completion', + ); + } + const activeSoloPlan = readActiveSoloPlan(state.activeSoloPlan); + if (activeSoloPlan?.taskId !== taskId) { + return invalidArg( + options, + 'active solo plan does not match the handoff task', + ); + } + + const nextState = { + ...state, + activeSoloPlan: null, + }; + const transitionError = await commitPlanningTransition({ + rootDir, + taskId, + statePath, + originalState, + nextState, + workflowPatch: { status: 'completed' }, + activePlanStatus: 'completed', + planVersion: meta.planVersion ?? 1, + }); + if (transitionError) return invalidArg(options, transitionError); + + const result = await readWorkflow(rootDir, taskId); + if (options.json) { + console.log( + JSON.stringify({ workflow: result, activeSoloPlan: null }, null, 2), + ); + } else { + console.log(`Completed solo handoff: ${taskId}`); + } + return EXIT_OK; +} + +async function workflowDecide( + rootDir: string, + taskId: string | undefined, + options: WorkflowOptions, +): Promise { + if (!taskId || !isValidWorkflowTaskId(taskId)) { + return invalidArg(options, `invalid taskId: ${taskId ?? ''}`); + } + if ( + !isPlanDecision(options.planDecision) || + options.planDecision === 'solo_handoff' + ) { + return invalidArg( + options, + 'decide requires --plan-decision plan_only or governed_execution', + ); + } + if (options.planDecision === 'governed_execution') { + return workflowUpdate(rootDir, taskId, { + ...options, + planDecision: 'governed_execution', + }); + } + + const meta = await readWorkflow(rootDir, taskId); + if ( + !meta || + (meta.mode !== 'man' && meta.mode !== 'manteam') || + meta.status !== 'in_progress' || + meta.currentStep !== 4 || + meta.requirementsStatus !== 'ready' || + meta.planDecision !== undefined + ) { + return invalidArg( + options, + `workflow is not ready for a plan-only decision: ${taskId}`, + ); + } + const workflowPath = path.join(rootDir, '.mancode', 'workflows', taskId); + if ( + !(await pathExists(path.join(workflowPath, 'requirements.md'))) || + !(await pathExists(path.join(workflowPath, 'plan.md'))) + ) { + return invalidArg( + options, + 'plan-only requires requirements.md and plan.md', + ); + } + + const statePath = path.join(rootDir, '.mancode', 'state.json'); + let originalState: string; + let state: Record; + try { + originalState = await readFile(statePath, 'utf-8'); + state = JSON.parse(originalState) as Record; + } catch { + return invalidArg( + options, + 'unable to read .mancode/state.json for decision', + ); + } + if (state.currentTask !== taskId) { + return invalidArg( + options, + 'current workflow state does not match the decision task', + ); + } + const activeSoloPlan = readActiveSoloPlan(state.activeSoloPlan); + if (activeSoloPlan) { + return invalidArg( + options, + `another solo plan is active: ${activeSoloPlan.taskId}; resolve it before plan-only`, + ); + } + + const nextState = { + ...state, + currentMode: 'solo', + lastMode: + typeof state.currentMode === 'string' ? state.currentMode : meta.mode, + currentTask: null, + currentWorkflowMode: null, + skippedSteps: [], + activeSoloPlan: null, + }; + const transitionError = await commitPlanningTransition({ + rootDir, + taskId, + statePath, + originalState, + nextState, + workflowPatch: { status: 'planned', planDecision: 'plan_only' }, + activePlanStatus: 'planned', + planVersion: meta.planVersion ?? 1, + }); + if (transitionError) return invalidArg(options, transitionError); + + const result = await readWorkflow(rootDir, taskId); + if (options.json) { + console.log( + JSON.stringify({ workflow: result, state: nextState }, null, 2), + ); + } else { + console.log(`Saved plan without execution: ${taskId}`); + } + return EXIT_OK; +} + +async function commitPlanningTransition(args: { + rootDir: string; + taskId: string; + statePath: string; + originalState: string; + nextState: Record; + workflowPatch: Partial; + activePlanStatus: string; + planVersion: number; +}): Promise { + const metadataPath = path.join( + args.rootDir, + '.mancode', + 'workflows', + args.taskId, + 'metadata.json', + ); + const specPath = path.join(args.rootDir, '.mancode', 'memory', 'spec.md'); + let originalMetadata: string; + let originalSpec: string | null; + try { + originalMetadata = await readFile(metadataPath, 'utf-8'); + originalSpec = await readOptionalText(specPath); + } catch (error) { + return error instanceof Error + ? `unable to prepare planning transition: ${error.message}` + : 'unable to prepare planning transition'; + } + + try { + await updateWorkflow(args.rootDir, args.taskId, args.workflowPatch); + await writeFile( + args.statePath, + `${JSON.stringify(args.nextState, null, 2)}\n`, + 'utf-8', + ); + await upsertActivePlan(args.rootDir, { + taskId: args.taskId, + status: args.activePlanStatus, + planVersion: args.planVersion, + }); + return null; + } catch (error) { + const rollback = await Promise.allSettled([ + writeFile(metadataPath, originalMetadata, 'utf-8'), + writeFile(args.statePath, args.originalState, 'utf-8'), + originalSpec === null + ? rm(specPath, { force: true }) + : writeFile(specPath, originalSpec, 'utf-8'), + ]); + const message = + error instanceof Error ? error.message : 'planning transition failed'; + return rollback.some((result) => result.status === 'rejected') + ? `${message}; rollback was incomplete` + : message; + } +} + +async function readOptionalText(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } +} + +async function restoreOptionalText( + filePath: string, + content: string | null, +): Promise { + if (content === null) { + await rm(filePath, { force: true }); + return; + } + await writeFile(filePath, content, 'utf-8'); +} + +function readActiveSoloPlan( + value: unknown, +): { taskId: string; planVersion: number } | null { + if (!value || typeof value !== 'object') return null; + const plan = value as Record; + if (typeof plan.taskId !== 'string' || !Number.isInteger(plan.planVersion)) { + return null; + } + return { taskId: plan.taskId, planVersion: plan.planVersion as number }; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + async function workflowList( rootDir: string, options: WorkflowOptions, @@ -475,6 +1367,11 @@ async function workflowShow( if (meta.parentTaskId) console.log(`Parent: ${meta.parentTaskId}`); if (meta.planVersion !== undefined) console.log(`Plan version: ${meta.planVersion}`); + if (meta.requirementsStatus) + console.log(`Requirements: ${meta.requirementsStatus}`); + if (meta.verificationStatus) + console.log(`Verification: ${meta.verificationStatus}`); + if (meta.planDecision) console.log(`Plan choice: ${meta.planDecision}`); if (meta.outcome) console.log(`Outcome: ${meta.outcome}`); if (meta.blockingReason) console.log(`Blocked: ${meta.blockingReason}`); @@ -625,6 +1522,12 @@ function parseExactPositiveInteger(value: string): number | null { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } +function parseExactInteger(value: string | undefined): number | null { + if (!value || !/^-?\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + function parseOlderThan(value: string | undefined): Date | undefined | null { if (!value) return undefined; const match = /^(\d+)([dhm])$/.exec(value); diff --git a/src/installers/mode-skills.ts b/src/installers/mode-skills.ts index 378615c..870f971 100644 --- a/src/installers/mode-skills.ts +++ b/src/installers/mode-skills.ts @@ -496,8 +496,8 @@ const MODE_META: Record = { }, man: { description: - 'Full high-risk workflow with plan, implementation, verification, and review. Use when the user asks for man or a high-risk production change.', - intro: '# mancode man — Full Review Workflow', + 'Planning and progressive workflow that aligns requirements, recommends options, produces a confirmed plan, and then offers plan-only, solo handoff, or full governed execution.', + intro: '# mancode man — Planning and Progressive Delivery', workflow: [ '## Progressive nine-step workflow (simulate roles in one conversation)', '', @@ -509,16 +509,16 @@ const MODE_META: Record = { 'never advance state merely because a report was requested or an agent was called.', '', 'Step 1 — Scout: inspect the profile, existing implementation, dependencies, risks, and unknowns; write `scout-report.md`.', - 'Step 2 — Clarify: ask at most two rounds; write answers, conservative defaults, and unresolved risks to `requirements.md`.', - 'Step 3 — Plan: write `plan.md` with scope, reuse, exclusions, risks, completion criteria, and real validation. First plan is v1; revisions use `--plan-version ` and Active Plans is updated by the CLI.', - 'Step 4 — Plan gate: the user chooses plan-only (`status planned`), continue to execution, or revise the plan. Do not implement before this choice.', + 'Step 2 — Clarify by readiness, not a fixed round or question count. Ask every decision-changing question that cannot be resolved from project facts; use as many batches as needed without repeating confirmed answers. Classify unknowns as blocking, recommendable, or defaultable. When a good approach exists, present 2–3 options and make a clear recommendation. Create structured input with non-empty scope, exclusions, technical decisions, defaults, blocking unknowns, stable acceptance IDs, and explicit confirmed/defaulted/not-applicable coverage for platform, core scope, stack, data, performance, compatibility, and security. Run `mancode workflow requirements finalize --file `; only advance to Step 3 when the CLI returns ready.', + 'Step 3 — Plan Coach first returns READY_FOR_PLAN or NEEDS_CLARIFICATION. Never hide blocking gaps inside assumptions. When ready, write `plan.md` with confirmed scope, technical choices and reasons, reuse, exclusions, behavior, risks/rollback, completion criteria, defaults, and real validation. First plan is v1; revisions use `--plan-version `.', + 'Step 4 — Plan gate: recommend based on risk and let the user choose (1) hand the confirmed plan to lightweight solo via `mancode workflow handoff --to solo`, (2) continue full governed execution via `mancode workflow decide --plan-decision governed_execution` before Step 5, (3) keep the plan only via `mancode workflow decide --plan-decision plan_only`, or (4) revise. Do not implement before this choice. Before either execution path, echo the plan version, delivery, stack, inclusions, exclusions, validation, and residual assumptions.', 'Step 5 — Implement the confirmed minimum change.', - 'Step 6 — Run detected build/lint/typecheck/test and smoke checks. A real or complex diagnosis creates a linked manba child while the parent remains at Step 6. After a resolved child, restore a parent blocked by that child with `workflow update --status in_progress` before Step 7; never auto-resume manual_test_required. Then write `review-scope.md` from the actual diff, verification evidence, and hard-risk triggers. Initialize a targeted review for routine governed changes or a full review for auth, payment, sensitive data, migrations/deletion, public APIs, untrusted input, concurrency, cross-service, or infrastructure changes using `mancode workflow review init ...`.', + 'Step 6 — Run `workflow verify init`, execute detected build/lint/typecheck/test and smoke checks, and record every required acceptance ID with reproducible evidence; automated passed/failed records include the command and exit code. Use require-manual when a foreground browser, device, or human judgment is necessary; stop for explicit user confirmation before confirm-manual. The CLI blocks Step 7 and review until all checks pass. Then write `review-scope.md` and initialize targeted or full review. An explicit user review skip uses `workflow review skip --reason ` at Step 6, never generic skipped metadata.', 'Step 7 — Run one quality review limited to the changed diff and direct impact. Findings require changed-line evidence and user impact, with at most three new findings. Record stable blocker IDs through `workflow review ... complete`; do not fix yet.', - 'Step 8 — Only full review runs the security/boundary reviewer. It must read Film #1, mark the same root cause duplicate, and stay within security, permissions, recovery, resources, and boundaries. A targeted review ends after Film #1.', - 'Step 9 — If open blockers exist, fix them in one remediation round and record resolved IDs through `workflow review ... remediate`; with no blockers, do not create a remediation round. Re-run affected verification without re-running completed reviewers. Write `summary.md` and set completed only when required domains are complete and blockers are zero; otherwise set blocked+blockingReason.', + 'Step 8 — Only full review runs the security/boundary reviewer. It must read Film #1, mark the same root cause duplicate, and stay within security, permissions, recovery, resources, and boundaries. A targeted review treats the second domain as not applicable; it is never recorded as a skipped step.', + 'Step 9 — If open blockers exist, fix them in one remediation round and record resolved IDs through `workflow review ... remediate`; remediation invalidates all earlier acceptance evidence, so re-run and re-record every required check at Step 9 without re-running completed reviewers. Write `summary.md` and set completed only when verification and required review domains are complete and blockers are zero.', '', - 'After planned/completed/abandoned, return state to solo. On blocked, preserve enough state to resume. A CLI rejection is a hard stop, never a reason to bypass metadata validation.', + 'A solo handoff keeps the workflow planned and stores activeSoloPlan in state; solo reads requirements.md and plan.md, implements the confirmed scope with narrow validation and one bounded self-check, and does not run the /man reviewers. After successful solo verification, run `mancode workflow handoff --complete` to atomically clear the pointer, complete the workflow at Step 4, and remove it from Active Plans. After plan-only/completed/abandoned, return state to solo. On blocked, preserve enough state to resume. A CLI rejection is a hard stop, never a reason to bypass metadata validation.', ].join('\n'), }, manteam: { diff --git a/src/installers/shared-content.ts b/src/installers/shared-content.ts index 94c3f50..5ac2b16 100644 --- a/src/installers/shared-content.ts +++ b/src/installers/shared-content.ts @@ -14,6 +14,7 @@ export interface SharedContentOptions { interface MancodeStateOnDisk { currentMode?: string; + activeSoloPlan?: { taskId?: string; planVersion?: number } | null; techStack?: string; uiLibrary?: string; } @@ -91,6 +92,11 @@ function renderProjectContext( `- UI library: ${uiLibrary}`, `- Project profile: ${currentProfile?.projectKind || 'unknown'}; validation: ${currentProfile?.availableValidation?.join(', ') || 'inspect project'}`, '- At the start of each session, read `.mancode/state.json` to check the current mode and project context.', + ...(state?.activeSoloPlan?.taskId + ? [ + `- Active solo plan: ${state.activeSoloPlan.taskId} (plan v${state.activeSoloPlan.planVersion ?? 1}); read its requirements.md and plan.md before implementation.`, + ] + : []), '- Read `.mancode/project-profile.json` before choosing tools or validation. Only for a UI task in a profile with detected UI assets, read `.mancode/aesthetics/style-tokens.json`.', ].join('\n'); } diff --git a/src/system/requirements-ledger.ts b/src/system/requirements-ledger.ts new file mode 100644 index 0000000..9561c82 --- /dev/null +++ b/src/system/requirements-ledger.ts @@ -0,0 +1,266 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export type VerificationMethod = 'automated' | 'manual' | 'hybrid'; +export type RequirementCoverageStatus = + | 'confirmed' + | 'defaulted' + | 'not_applicable'; +export type RequirementDimension = + | 'platform' + | 'core_scope' + | 'technical_stack' + | 'data_and_persistence' + | 'performance' + | 'compatibility' + | 'security'; + +export interface RequirementCoverage { + dimension: RequirementDimension; + status: RequirementCoverageStatus; + rationale: string; +} + +export interface AcceptanceCriterion { + id: string; + description: string; + required: boolean; + method: VerificationMethod; +} + +export interface RequirementsLedger { + version: 1; + goal: string; + confirmedScope: string[]; + excludedScope: string[]; + technicalDecisions: string[]; + defaults: string[]; + blockingUnknowns: string[]; + coverage: RequirementCoverage[]; + acceptanceCriteria: AcceptanceCriterion[]; +} + +const REQUIREMENTS_FILE = 'requirements.json'; +const REQUIREMENTS_MARKDOWN_FILE = 'requirements.md'; +const ACCEPTANCE_ID_PATTERN = /^AC-[A-Z0-9][A-Z0-9-]{0,27}$/; +export const REQUIREMENT_DIMENSIONS: RequirementDimension[] = [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', +]; + +export function parseRequirementsLedger(raw: string): RequirementsLedger { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error('requirements input must be valid JSON'); + } + if (!isRecord(value) || value.version !== 1) { + throw new Error('requirements version must be 1'); + } + const stringFields = [ + 'confirmedScope', + 'excludedScope', + 'technicalDecisions', + 'defaults', + 'blockingUnknowns', + ] as const; + if (typeof value.goal !== 'string' || !value.goal.trim()) { + throw new Error('requirements goal is required'); + } + for (const field of stringFields) { + if (!isNonEmptyStringArray(value[field])) { + if (!Array.isArray(value[field]) || value[field].length > 0) { + throw new Error(`requirements ${field} must contain non-empty strings`); + } + } + } + if (!Array.isArray(value.coverage)) { + throw new Error('requirements coverage is required'); + } + const coverageDimensions = new Set(); + const coverage = value.coverage.map((item) => { + if ( + !isRecord(item) || + !isRequirementDimension(item.dimension) || + !isCoverageStatus(item.status) || + typeof item.rationale !== 'string' || + !item.rationale.trim() + ) { + throw new Error('invalid requirements coverage item'); + } + if (coverageDimensions.has(item.dimension)) { + throw new Error(`duplicate requirements coverage: ${item.dimension}`); + } + coverageDimensions.add(item.dimension); + return { + dimension: item.dimension, + status: item.status, + rationale: item.rationale.trim(), + }; + }); + const missingDimensions = REQUIREMENT_DIMENSIONS.filter( + (dimension) => !coverageDimensions.has(dimension), + ); + if (missingDimensions.length > 0) { + throw new Error( + `requirements coverage is missing: ${missingDimensions.join(', ')}`, + ); + } + if ((value.confirmedScope as string[]).length === 0) { + throw new Error('requirements confirmedScope must not be empty'); + } + const stackCoverage = coverage.find( + (item) => item.dimension === 'technical_stack', + ); + if ( + stackCoverage?.status !== 'not_applicable' && + (value.technicalDecisions as string[]).length === 0 + ) { + throw new Error( + 'requirements technicalDecisions are required when technical_stack applies', + ); + } + if ( + !Array.isArray(value.acceptanceCriteria) || + value.acceptanceCriteria.length === 0 + ) { + throw new Error('requirements need at least one acceptance criterion'); + } + const ids = new Set(); + const acceptanceCriteria = value.acceptanceCriteria.map((item) => { + if ( + !isRecord(item) || + typeof item.id !== 'string' || + !ACCEPTANCE_ID_PATTERN.test(item.id) || + typeof item.description !== 'string' || + !item.description.trim() || + typeof item.required !== 'boolean' || + !isVerificationMethod(item.method) + ) { + throw new Error('invalid acceptance criterion'); + } + if (ids.has(item.id)) { + throw new Error(`duplicate acceptance criterion: ${item.id}`); + } + ids.add(item.id); + return { + id: item.id, + description: item.description.trim(), + required: item.required, + method: item.method, + }; + }); + if (!acceptanceCriteria.some((item) => item.required)) { + throw new Error( + 'requirements need at least one required acceptance criterion', + ); + } + return { + version: 1, + goal: value.goal.trim(), + confirmedScope: normalizeStrings(value.confirmedScope), + excludedScope: normalizeStrings(value.excludedScope), + technicalDecisions: normalizeStrings(value.technicalDecisions), + defaults: normalizeStrings(value.defaults), + blockingUnknowns: normalizeStrings(value.blockingUnknowns), + coverage, + acceptanceCriteria, + }; +} + +export function requirementsAreReady(ledger: RequirementsLedger): boolean { + return ledger.blockingUnknowns.length === 0; +} + +export function requirementsDigest(ledger: RequirementsLedger): string { + return createHash('sha256').update(JSON.stringify(ledger)).digest('hex'); +} + +export async function readRequirementsLedger( + projectRoot: string, + taskId: string, +): Promise { + try { + const raw = await readFile(requirementsPath(projectRoot, taskId), 'utf-8'); + return parseRequirementsLedger(raw); + } catch { + return null; + } +} + +export async function writeRequirementsArtifacts( + projectRoot: string, + taskId: string, + ledger: RequirementsLedger, +): Promise { + const dir = workflowDir(projectRoot, taskId); + await writeFile( + path.join(dir, REQUIREMENTS_FILE), + `${JSON.stringify(ledger, null, 2)}\n`, + 'utf-8', + ); + await writeFile( + path.join(dir, REQUIREMENTS_MARKDOWN_FILE), + renderRequirementsMarkdown(ledger), + 'utf-8', + ); +} + +export function renderRequirementsMarkdown(ledger: RequirementsLedger): string { + const section = (title: string, items: string[]) => + `## ${title}\n\n${items.length > 0 ? items.map((item) => `- ${item}`).join('\n') : '- 无'}\n`; + const criteria = ledger.acceptanceCriteria + .map( + (item) => + `- **${item.id}** [${item.required ? '必需' : '可选'} / ${item.method}] ${item.description}`, + ) + .join('\n'); + const coverage = ledger.coverage + .map((item) => `- **${item.dimension}** [${item.status}] ${item.rationale}`) + .join('\n'); + return `# Requirements\n\n## 用户目标\n\n${ledger.goal}\n\n${section('已确认范围', ledger.confirmedScope)}\n${section('明确排除项', ledger.excludedScope)}\n${section('技术决策', ledger.technicalDecisions)}\n${section('默认值与理由', ledger.defaults)}\n## 需求覆盖\n\n${coverage}\n\n## 验收标准\n\n${criteria}\n\n${section('阻塞性未决问题', ledger.blockingUnknowns)}\n## Readiness\n\n${requirementsAreReady(ledger) ? 'READY' : 'NEEDS_CLARIFICATION'}\n`; +} + +function requirementsPath(projectRoot: string, taskId: string): string { + return path.join(workflowDir(projectRoot, taskId), REQUIREMENTS_FILE); +} + +function workflowDir(projectRoot: string, taskId: string): string { + return path.join(projectRoot, '.mancode', 'workflows', taskId); +} + +function normalizeStrings(value: unknown): string[] { + return (value as string[]).map((item) => item.trim()); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every((item) => typeof item === 'string' && item.trim().length > 0) + ); +} + +function isVerificationMethod(value: unknown): value is VerificationMethod { + return value === 'automated' || value === 'manual' || value === 'hybrid'; +} + +function isRequirementDimension(value: unknown): value is RequirementDimension { + return REQUIREMENT_DIMENSIONS.some((dimension) => dimension === value); +} + +function isCoverageStatus(value: unknown): value is RequirementCoverageStatus { + return ( + value === 'confirmed' || value === 'defaulted' || value === 'not_applicable' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/system/review-ledger.ts b/src/system/review-ledger.ts index a2a762d..dbd135a 100644 --- a/src/system/review-ledger.ts +++ b/src/system/review-ledger.ts @@ -18,6 +18,10 @@ export interface ReviewLedger { reports: Partial>; blockers: ReviewBlocker[]; remediationRounds: number; + skipped?: { + reason: string; + recordedAt: string; + }; } const REVIEW_FILE = 'review-ledger.json'; @@ -61,6 +65,32 @@ export async function initializeReview( return ledger; } +export async function initializeSkippedReview( + projectRoot: string, + taskId: string, + reason: string, +): Promise { + assertValidTaskId(taskId); + if (!reason.trim()) throw new Error('review skip reason is required'); + const existing = await readReviewLedger(projectRoot, taskId); + if (existing) throw new Error(`review already initialized: ${taskId}`); + const ledger: ReviewLedger = { + version: '1.0', + depth: 'targeted', + requiredDomains: [], + completedDomains: [], + reports: {}, + blockers: [], + remediationRounds: 0, + skipped: { + reason: reason.trim(), + recordedAt: new Date().toISOString(), + }, + }; + await writeReviewLedger(projectRoot, taskId, ledger); + return ledger; +} + export async function completeReviewDomain( projectRoot: string, taskId: string, @@ -150,6 +180,7 @@ export async function reviewCanComplete( ): Promise { const ledger = await readReviewLedger(projectRoot, taskId); if (!ledger) return false; + if (ledger.skipped) return true; return ( ledger.requiredDomains.every((domain) => ledger.completedDomains.includes(domain), @@ -190,6 +221,19 @@ function isReviewLedger(value: unknown): value is ReviewLedger { const blockers = value.blockers as unknown[]; const expectedDomains: ReviewDomain[] = value.depth === 'full' ? ['quality', 'security'] : requiredDomains; + if (value.skipped !== undefined) { + return ( + isRecord(value.skipped) && + typeof value.skipped.reason === 'string' && + value.skipped.reason.trim().length > 0 && + typeof value.skipped.recordedAt === 'string' && + requiredDomains.length === 0 && + completedDomains.length === 0 && + Object.keys(reports).length === 0 && + blockers.length === 0 && + value.remediationRounds === 0 + ); + } if ( (value.depth === 'targeted' && requiredDomains.length !== 1) || requiredDomains.length !== expectedDomains.length || @@ -249,6 +293,11 @@ async function writeReviewLedger( ); } +export function reviewLedgerPath(projectRoot: string, taskId: string): string { + assertValidTaskId(taskId); + return reviewPath(projectRoot, taskId); +} + function reviewPath(projectRoot: string, taskId: string): string { return path.join(projectRoot, '.mancode', 'workflows', taskId, REVIEW_FILE); } diff --git a/src/system/verification-ledger.ts b/src/system/verification-ledger.ts new file mode 100644 index 0000000..e410aff --- /dev/null +++ b/src/system/verification-ledger.ts @@ -0,0 +1,386 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + type RequirementsLedger, + readRequirementsLedger, + requirementsDigest, +} from './requirements-ledger.js'; +import { readReviewLedger } from './review-ledger.js'; + +export type VerificationComponentStatus = + | 'pending' + | 'passed' + | 'failed' + | 'manual_required' + | 'blocked'; +export type VerificationOverallStatus = VerificationComponentStatus; +export type VerificationComponent = 'automated' | 'manual'; + +export interface VerificationEvidence { + status: VerificationComponentStatus; + evidence?: string; + updatedAt?: string; + command?: string; + exitCode?: number; + evidenceFile?: string; +} + +export interface VerificationCheck { + acceptanceId: string; + required: boolean; + automated?: VerificationEvidence; + manual?: VerificationEvidence; +} + +export interface VerificationLedger { + version: 1; + planVersion: number; + requirementsDigest: string; + remediationRound: number; + status: VerificationOverallStatus; + checks: VerificationCheck[]; +} + +const VERIFICATION_FILE = 'verification-ledger.json'; + +export function initializeVerificationLedger( + requirements: RequirementsLedger, + planVersion: number, + remediationRound = 0, +): VerificationLedger { + const checks = requirements.acceptanceCriteria.map((criterion) => ({ + acceptanceId: criterion.id, + required: criterion.required, + ...(criterion.method === 'automated' || criterion.method === 'hybrid' + ? { automated: { status: 'pending' as const } } + : {}), + ...(criterion.method === 'manual' || criterion.method === 'hybrid' + ? { manual: { status: 'pending' as const } } + : {}), + })); + return { + version: 1, + planVersion, + requirementsDigest: requirementsDigest(requirements), + remediationRound, + status: deriveVerificationStatus(checks), + checks, + }; +} + +export function recordVerification( + ledger: VerificationLedger, + acceptanceId: string, + component: VerificationComponent, + result: VerificationComponentStatus, + evidence: string, + automatedDetails?: { + command: string; + exitCode: number; + evidenceFile?: string; + }, +): VerificationLedger { + if (!evidence.trim()) throw new Error('verification evidence is required'); + if (component === 'automated' && result === 'manual_required') { + throw new Error('automated verification cannot be manual_required'); + } + if ( + component === 'automated' && + (result === 'passed' || result === 'failed') + ) { + if ( + !automatedDetails?.command.trim() || + !Number.isInteger(automatedDetails.exitCode) + ) { + throw new Error( + 'automated passed/failed verification requires command and exit code', + ); + } + if (result === 'passed' && automatedDetails.exitCode !== 0) { + throw new Error('passed verification requires exit code 0'); + } + if (result === 'failed' && automatedDetails.exitCode === 0) { + throw new Error('failed verification requires a non-zero exit code'); + } + } + if (component === 'manual' && result !== 'manual_required') { + throw new Error( + 'manual verification must use require-manual or confirm-manual', + ); + } + const index = ledger.checks.findIndex( + (check) => check.acceptanceId === acceptanceId, + ); + if (index < 0) + throw new Error(`unknown acceptance criterion: ${acceptanceId}`); + const check = ledger.checks[index]; + if (!check) throw new Error(`unknown acceptance criterion: ${acceptanceId}`); + if (!check[component]) { + throw new Error( + `${acceptanceId} does not require ${component} verification`, + ); + } + const checks = [...ledger.checks]; + checks[index] = { + ...check, + [component]: { + status: result, + evidence: evidence.trim(), + updatedAt: new Date().toISOString(), + ...(component === 'automated' && automatedDetails + ? { + command: automatedDetails.command.trim(), + exitCode: automatedDetails.exitCode, + ...(automatedDetails.evidenceFile + ? { evidenceFile: automatedDetails.evidenceFile } + : {}), + } + : {}), + }, + }; + return { ...ledger, status: deriveVerificationStatus(checks), checks }; +} + +export function resetVerificationForRemediation( + ledger: VerificationLedger, + remediationRound: number, +): VerificationLedger { + if ( + !Number.isInteger(remediationRound) || + remediationRound < ledger.remediationRound + ) { + throw new Error('invalid verification remediation round'); + } + if (remediationRound === ledger.remediationRound) return ledger; + const checks = ledger.checks.map((check) => ({ + acceptanceId: check.acceptanceId, + required: check.required, + ...(check.automated ? { automated: { status: 'pending' as const } } : {}), + ...(check.manual ? { manual: { status: 'pending' as const } } : {}), + })); + return { + ...ledger, + remediationRound, + status: deriveVerificationStatus(checks), + checks, + }; +} + +export function confirmManualVerification( + ledger: VerificationLedger, + acceptanceId: string, + evidence: string, +): VerificationLedger { + if (!evidence.trim()) + throw new Error('manual confirmation evidence is required'); + const index = ledger.checks.findIndex( + (check) => check.acceptanceId === acceptanceId, + ); + if (index < 0) + throw new Error(`unknown acceptance criterion: ${acceptanceId}`); + const check = ledger.checks[index]; + if (!check) throw new Error(`unknown acceptance criterion: ${acceptanceId}`); + if (!check.manual) { + throw new Error(`${acceptanceId} does not require manual verification`); + } + if (check.manual.status !== 'manual_required') { + throw new Error(`${acceptanceId} is not awaiting manual confirmation`); + } + const checks = [...ledger.checks]; + checks[index] = { + ...check, + manual: { + status: 'passed', + evidence: evidence.trim(), + updatedAt: new Date().toISOString(), + }, + }; + return { ...ledger, status: deriveVerificationStatus(checks), checks }; +} + +export function deriveVerificationStatus( + checks: VerificationCheck[], +): VerificationOverallStatus { + const statuses = checks + .filter((check) => check.required) + .flatMap((check) => [check.automated?.status, check.manual?.status]) + .filter((status): status is VerificationComponentStatus => Boolean(status)); + if (statuses.includes('blocked')) return 'blocked'; + if (statuses.includes('manual_required')) return 'manual_required'; + if (statuses.includes('failed')) return 'failed'; + if (statuses.every((status) => status === 'passed')) return 'passed'; + return 'pending'; +} + +export async function readVerificationLedger( + projectRoot: string, + taskId: string, +): Promise { + try { + const raw = await readFile(verificationPath(projectRoot, taskId), 'utf-8'); + const value = JSON.parse(raw) as VerificationLedger; + return isVerificationLedger(value) ? value : null; + } catch { + return null; + } +} + +export async function writeVerificationLedger( + projectRoot: string, + taskId: string, + ledger: VerificationLedger, +): Promise { + await writeFile( + verificationPath(projectRoot, taskId), + `${JSON.stringify(ledger, null, 2)}\n`, + 'utf-8', + ); +} + +export async function verificationCanAdvance( + projectRoot: string, + taskId: string, + planVersion: number, +): Promise { + const [ledger, requirements, review] = await Promise.all([ + readVerificationLedger(projectRoot, taskId), + readRequirementsLedger(projectRoot, taskId), + readReviewLedger(projectRoot, taskId), + ]); + return Boolean( + ledger && + requirements && + ledger.status === 'passed' && + ledger.planVersion === planVersion && + ledger.remediationRound === (review?.remediationRounds ?? 0) && + ledger.requirementsDigest === requirementsDigest(requirements) && + ledgerMatchesRequirements(ledger, requirements), + ); +} + +export function verificationLedgerPath( + projectRoot: string, + taskId: string, +): string { + return verificationPath(projectRoot, taskId); +} + +function verificationPath(projectRoot: string, taskId: string): string { + return path.join( + projectRoot, + '.mancode', + 'workflows', + taskId, + VERIFICATION_FILE, + ); +} + +function isVerificationLedger(value: unknown): value is VerificationLedger { + if ( + typeof value !== 'object' || + value === null || + !('version' in value) || + value.version !== 1 || + !('planVersion' in value) || + !Number.isInteger(value.planVersion) || + !('requirementsDigest' in value) || + typeof value.requirementsDigest !== 'string' || + !('remediationRound' in value) || + typeof value.remediationRound !== 'number' || + !Number.isInteger(value.remediationRound) || + value.remediationRound < 0 || + !('status' in value) || + !isComponentStatus(value.status) || + !('checks' in value) || + !Array.isArray(value.checks) + ) { + return false; + } + const ids = new Set(); + const validChecks = value.checks.every((check) => { + if ( + typeof check !== 'object' || + check === null || + !('acceptanceId' in check) || + typeof check.acceptanceId !== 'string' || + !('required' in check) || + typeof check.required !== 'boolean' + ) { + return false; + } + if (ids.has(check.acceptanceId)) return false; + ids.add(check.acceptanceId); + return ['automated', 'manual'].every((component) => { + const evidence = check[component as keyof typeof check]; + if (evidence === undefined) return true; + const valid = + typeof evidence === 'object' && + evidence !== null && + 'status' in evidence && + isComponentStatus(evidence.status); + if (!valid) return false; + if (evidence.status === 'pending') { + return !('evidence' in evidence) && !('updatedAt' in evidence); + } + const baseEvidenceIsValid = + 'evidence' in evidence && + typeof evidence.evidence === 'string' && + evidence.evidence.trim().length > 0 && + 'updatedAt' in evidence && + typeof evidence.updatedAt === 'string'; + if (!baseEvidenceIsValid) return false; + if ( + component === 'automated' && + (evidence.status === 'passed' || evidence.status === 'failed') + ) { + return ( + 'command' in evidence && + typeof evidence.command === 'string' && + evidence.command.trim().length > 0 && + 'exitCode' in evidence && + Number.isInteger(evidence.exitCode) && + (evidence.status === 'passed' + ? evidence.exitCode === 0 + : evidence.exitCode !== 0) + ); + } + return true; + }); + }); + return validChecks && value.status === deriveVerificationStatus(value.checks); +} + +function ledgerMatchesRequirements( + ledger: VerificationLedger, + requirements: RequirementsLedger, +): boolean { + if (ledger.checks.length !== requirements.acceptanceCriteria.length) { + return false; + } + return requirements.acceptanceCriteria.every((criterion) => { + const check = ledger.checks.find( + (candidate) => candidate.acceptanceId === criterion.id, + ); + if (!check || check.required !== criterion.required) return false; + const needsAutomated = + criterion.method === 'automated' || criterion.method === 'hybrid'; + const needsManual = + criterion.method === 'manual' || criterion.method === 'hybrid'; + return ( + Boolean(check.automated) === needsAutomated && + Boolean(check.manual) === needsManual + ); + }); +} + +function isComponentStatus( + value: unknown, +): value is VerificationComponentStatus { + return ( + value === 'pending' || + value === 'passed' || + value === 'failed' || + value === 'manual_required' || + value === 'blocked' + ); +} diff --git a/src/system/workflow.ts b/src/system/workflow.ts index c8262f3..caeba7e 100644 --- a/src/system/workflow.ts +++ b/src/system/workflow.ts @@ -7,8 +7,17 @@ import { writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { + requirementsDigest as calculateRequirementsDigest, + readRequirementsLedger, + requirementsAreReady, +} from './requirements-ledger.js'; import { reviewCanComplete } from './review-ledger.js'; import { upsertActivePlan } from './team-memory.js'; +import { + type VerificationOverallStatus, + verificationCanAdvance, +} from './verification-ledger.js'; /** * Workflow 元数据。 @@ -25,7 +34,7 @@ export interface WorkflowMeta { mode: WorkflowMode; /** 当前进行到第几步(mode-dependent)*/ currentStep: number; - /** 被跳过的步骤名(如 ['warmup-drill', 'film-1'])*/ + /** Policy v2 only allows clarification or the whole review. */ skippedSteps: string[]; /** ISO timestamp */ startedAt: string; @@ -42,7 +51,19 @@ export interface WorkflowMeta { /** Monotonically increasing plan revision for /man workflows. */ planVersion?: number; /** Enables the bounded review completion gate for newly created governed workflows. */ - reviewPolicyVersion?: 1; + reviewPolicyVersion?: 1 | 2; + /** Enables requirements and plan gates for newly created governed workflows. */ + planningPolicyVersion?: 1 | 2; + /** Enables acceptance-linked verification gates for governed execution. */ + verificationPolicyVersion?: 1; + /** Whether clarification has resolved every implementation-blocking unknown. */ + requirementsStatus?: RequirementsStatus; + /** Digest of the structured requirements accepted before planning. */ + requirementsDigest?: string; + /** User choice made at the plan gate. */ + planDecision?: PlanDecision; + /** Cached verification state for status display and guarded unblock transitions. */ + verificationStatus?: VerificationOverallStatus; } export type WorkflowStatus = @@ -57,9 +78,17 @@ export type WorkflowOutcome = | 'verified' | 'no_repro' | 'manual_test_required'; +export type RequirementsStatus = 'ready' | 'needs_clarification'; +export type PlanDecision = 'plan_only' | 'solo_handoff' | 'governed_execution'; export interface CreateWorkflowOptions { parentTaskId?: string; + planningPolicyVersion?: 1 | 2; +} + +export interface UpdateWorkflowOptions { + /** Internal verification recording at Step 6/9 may be incomplete between checks. */ + allowIncompleteVerification?: boolean; } const METADATA_FILE = 'metadata.json'; @@ -126,7 +155,16 @@ export async function createWorkflow( status: 'in_progress', ...(options.parentTaskId ? { parentTaskId: options.parentTaskId } : {}), ...(mode === 'man' || mode === 'manteam' - ? { planVersion: 1, reviewPolicyVersion: 1 as const } + ? { + planVersion: 1, + reviewPolicyVersion: options.planningPolicyVersion === 2 ? 2 : 1, + ...(options.planningPolicyVersion === 2 + ? { verificationPolicyVersion: 1 as const } + : {}), + ...(options.planningPolicyVersion + ? { planningPolicyVersion: options.planningPolicyVersion } + : {}), + } : {}), }; @@ -179,6 +217,7 @@ export async function updateWorkflow( projectRoot: string, taskId: string, patch: Partial, + options: UpdateWorkflowOptions = {}, ): Promise { assertValidTaskId(taskId); const existing = await readWorkflow(projectRoot, taskId); @@ -200,7 +239,7 @@ export async function updateWorkflow( if (updated.status !== 'blocked' && !explicitlyPatchedBlockingReason) { updated.blockingReason = undefined; } - await validateWorkflowMeta(projectRoot, updated, existing); + await validateWorkflowMeta(projectRoot, updated, existing, options); await writeMetadata(workflowDir(projectRoot, taskId), updated); // Propagate blocked / manual_test_required to parent workflow. @@ -325,6 +364,20 @@ export function isWorkflowOutcome(value: unknown): value is WorkflowOutcome { ); } +export function isRequirementsStatus( + value: unknown, +): value is RequirementsStatus { + return value === 'ready' || value === 'needs_clarification'; +} + +export function isPlanDecision(value: unknown): value is PlanDecision { + return ( + value === 'plan_only' || + value === 'solo_handoff' || + value === 'governed_execution' + ); +} + async function validateParentTask( projectRoot: string, mode: WorkflowMode, @@ -354,6 +407,7 @@ async function validateWorkflowMeta( projectRoot: string, updated: WorkflowMeta, existing: WorkflowMeta, + options: UpdateWorkflowOptions, ): Promise { if (updated.mode !== existing.mode) { throw new Error('workflow mode cannot be changed'); @@ -367,6 +421,22 @@ async function validateWorkflowMeta( if (updated.reviewPolicyVersion !== existing.reviewPolicyVersion) { throw new Error('workflow review policy version cannot be changed'); } + if (updated.planningPolicyVersion !== existing.planningPolicyVersion) { + throw new Error('workflow planning policy version cannot be changed'); + } + if ( + updated.requirementsDigest !== existing.requirementsDigest && + existing.currentStep > 2 + ) { + throw new Error( + 'workflow requirements cannot change after planning starts', + ); + } + if ( + updated.verificationPolicyVersion !== existing.verificationPolicyVersion + ) { + throw new Error('workflow verification policy version cannot be changed'); + } if ( !Number.isInteger(updated.currentStep) || updated.currentStep < 1 || @@ -383,6 +453,16 @@ async function validateWorkflowMeta( ) { throw new Error('workflow skipped steps must be strings'); } + if ( + updated.reviewPolicyVersion === 2 && + updated.skippedSteps.some( + (step) => step !== 'clarification' && step !== 'review', + ) + ) { + throw new Error( + 'workflow policy v2 only allows skipping clarification or review', + ); + } if (!canTransition(existing.status, updated.status)) { throw new Error( `invalid workflow status transition: ${existing.status} -> ${updated.status}`, @@ -396,7 +476,8 @@ async function validateWorkflowMeta( } if ( updated.status === 'completed' && - updated.currentStep !== maxWorkflowStep(updated.mode) + updated.currentStep !== maxWorkflowStep(updated.mode) && + !(updated.currentStep === 4 && updated.planDecision === 'solo_handoff') ) { throw new Error( `completed ${updated.mode === 'mamba' ? 'manba' : updated.mode} workflows must be at step ${maxWorkflowStep(updated.mode)}`, @@ -436,13 +517,169 @@ async function validateWorkflowMeta( } if ( updated.reviewPolicyVersion !== undefined && - updated.reviewPolicyVersion !== 1 + updated.reviewPolicyVersion !== 1 && + updated.reviewPolicyVersion !== 2 ) { throw new Error('invalid workflow review policy version'); } if (updated.mode === 'mamba' && updated.reviewPolicyVersion !== undefined) { throw new Error('manba workflows cannot have a review policy version'); } + if ( + updated.planningPolicyVersion !== undefined && + updated.planningPolicyVersion !== 1 && + updated.planningPolicyVersion !== 2 + ) { + throw new Error('invalid workflow planning policy version'); + } + if (updated.mode === 'mamba' && updated.planningPolicyVersion !== undefined) { + throw new Error('manba workflows cannot have a planning policy version'); + } + if ( + updated.verificationPolicyVersion !== undefined && + updated.verificationPolicyVersion !== 1 + ) { + throw new Error('invalid workflow verification policy version'); + } + if ( + updated.mode === 'mamba' && + updated.verificationPolicyVersion !== undefined + ) { + throw new Error( + 'manba workflows cannot have a verification policy version', + ); + } + if ( + updated.verificationStatus !== undefined && + !isVerificationStatus(updated.verificationStatus) + ) { + throw new Error('invalid workflow verification status'); + } + if (updated.mode === 'mamba' && updated.verificationStatus !== undefined) { + throw new Error('manba workflows cannot have a verification status'); + } + if ( + updated.requirementsStatus !== undefined && + !isRequirementsStatus(updated.requirementsStatus) + ) { + throw new Error('invalid workflow requirements status'); + } + if (updated.mode === 'mamba' && updated.requirementsStatus !== undefined) { + throw new Error('manba workflows cannot have a requirements status'); + } + if ( + updated.requirementsDigest !== undefined && + typeof updated.requirementsDigest !== 'string' + ) { + throw new Error('workflow requirements digest must be a string'); + } + if (updated.mode === 'mamba' && updated.requirementsDigest !== undefined) { + throw new Error('manba workflows cannot have a requirements digest'); + } + if ( + updated.planDecision !== undefined && + !isPlanDecision(updated.planDecision) + ) { + throw new Error('invalid workflow plan decision'); + } + if (updated.mode === 'mamba' && updated.planDecision !== undefined) { + throw new Error('manba workflows cannot have a plan decision'); + } + if ( + updated.planDecision !== existing.planDecision && + existing.planDecision !== undefined + ) { + throw new Error('workflow plan decision cannot be changed'); + } + if ( + updated.planDecision !== existing.planDecision && + existing.currentStep !== 4 + ) { + throw new Error('workflow plan decision can only be set at step 4'); + } + if ( + updated.planningPolicyVersion === 1 || + updated.planningPolicyVersion === 2 + ) { + if (updated.currentStep >= 3) { + if (updated.requirementsStatus !== 'ready') { + throw new Error('requirements must be ready before planning'); + } + if ( + !(await workflowArtifactExists( + projectRoot, + updated.taskId, + 'requirements.md', + )) + ) { + throw new Error('requirements.md is required before planning'); + } + if (updated.planningPolicyVersion === 2) { + const requirements = await readRequirementsLedger( + projectRoot, + updated.taskId, + ); + if ( + !requirements || + !requirementsAreReady(requirements) || + updated.requirementsDigest !== + calculateRequirementsDigest(requirements) + ) { + throw new Error( + 'finalized requirements.json with no blocking unknowns is required before planning', + ); + } + } + } + if ( + updated.currentStep >= 4 && + !(await workflowArtifactExists(projectRoot, updated.taskId, 'plan.md')) + ) { + throw new Error('plan.md is required before the plan gate'); + } + if ( + updated.status === 'planned' && + updated.planDecision !== 'plan_only' && + updated.planDecision !== 'solo_handoff' + ) { + throw new Error( + 'planned workflows require a plan-only or solo-handoff decision', + ); + } + if ( + updated.currentStep >= 5 && + updated.status === 'in_progress' && + updated.planDecision !== 'governed_execution' + ) { + throw new Error('governed execution must be confirmed before step 5'); + } + } + if ( + updated.verificationPolicyVersion === 1 && + updated.currentStep >= 7 && + updated.planDecision !== 'solo_handoff' && + !options.allowIncompleteVerification && + !(await verificationCanAdvance( + projectRoot, + updated.taskId, + updated.planVersion ?? 1, + )) + ) { + throw new Error( + 'workflow verification is incomplete, stale, or requires manual confirmation', + ); + } + if ( + existing.status === 'blocked' && + existing.blockingReason?.startsWith('[verification]') && + updated.status === 'in_progress' && + (updated.verificationStatus === 'manual_required' || + updated.verificationStatus === 'blocked') + ) { + throw new Error( + 'verification-blocked workflows must resume through verify', + ); + } if ( updated.planVersion !== undefined && (!Number.isInteger(updated.planVersion) || updated.planVersion < 1) @@ -473,19 +710,36 @@ async function validateWorkflowMeta( if ( updated.status === 'completed' && updated.reviewPolicyVersion === 1 && + updated.planDecision !== 'solo_handoff' && !reviewWasExplicitlySkipped(updated) && !(await reviewCanComplete(projectRoot, updated.taskId)) ) { throw new Error('workflow review is incomplete or still has open blockers'); } + if ( + updated.status === 'completed' && + updated.reviewPolicyVersion === 2 && + updated.planDecision !== 'solo_handoff' && + !(await reviewCanComplete(projectRoot, updated.taskId)) + ) { + throw new Error('workflow review is incomplete or still has open blockers'); + } + if ( + updated.status === 'completed' && + updated.verificationPolicyVersion === 1 && + updated.planDecision !== 'solo_handoff' && + !(await workflowArtifactExists(projectRoot, updated.taskId, 'summary.md')) + ) { + throw new Error('summary.md is required before workflow completion'); + } } function canTransition(from: WorkflowStatus, to: WorkflowStatus): boolean { if (from === to) return true; if (from === 'in_progress') return true; - if (from === 'planned' || from === 'blocked') { - return to === 'in_progress' || to === 'abandoned'; - } + if (from === 'planned') + return to === 'in_progress' || to === 'completed' || to === 'abandoned'; + if (from === 'blocked') return to === 'in_progress' || to === 'abandoned'; return false; } @@ -520,7 +774,39 @@ function isWorkflowShape(obj: Partial): boolean { (!Number.isInteger(obj.planVersion) || obj.planVersion < 1) ) return false; - if (obj.reviewPolicyVersion !== undefined && obj.reviewPolicyVersion !== 1) + if ( + obj.reviewPolicyVersion !== undefined && + obj.reviewPolicyVersion !== 1 && + obj.reviewPolicyVersion !== 2 + ) + return false; + if ( + obj.planningPolicyVersion !== undefined && + obj.planningPolicyVersion !== 1 && + obj.planningPolicyVersion !== 2 + ) + return false; + if ( + obj.verificationPolicyVersion !== undefined && + obj.verificationPolicyVersion !== 1 + ) + return false; + if ( + obj.requirementsStatus !== undefined && + !isRequirementsStatus(obj.requirementsStatus) + ) + return false; + if ( + obj.requirementsDigest !== undefined && + typeof obj.requirementsDigest !== 'string' + ) + return false; + if (obj.planDecision !== undefined && !isPlanDecision(obj.planDecision)) + return false; + if ( + obj.verificationStatus !== undefined && + !isVerificationStatus(obj.verificationStatus) + ) return false; if (status === 'blocked') { if (!obj.blockingReason?.trim()) return false; @@ -531,7 +817,13 @@ function isWorkflowShape(obj: Partial): boolean { if ( status === 'planned' || obj.planVersion !== undefined || - obj.reviewPolicyVersion !== undefined + obj.reviewPolicyVersion !== undefined || + obj.planningPolicyVersion !== undefined || + obj.verificationPolicyVersion !== undefined || + obj.requirementsStatus !== undefined || + obj.requirementsDigest !== undefined || + obj.planDecision !== undefined || + obj.verificationStatus !== undefined ) return false; if ( @@ -547,6 +839,19 @@ function isWorkflowShape(obj: Partial): boolean { return true; } +async function workflowArtifactExists( + projectRoot: string, + taskId: string, + filename: string, +): Promise { + try { + await stat(path.join(workflowDir(projectRoot, taskId), filename)); + return true; + } catch { + return false; + } +} + function reviewWasExplicitlySkipped(meta: WorkflowMeta): boolean { return ( meta.skippedSteps.includes('review') || @@ -555,6 +860,18 @@ function reviewWasExplicitlySkipped(meta: WorkflowMeta): boolean { ); } +function isVerificationStatus( + value: unknown, +): value is VerificationOverallStatus { + return ( + value === 'pending' || + value === 'passed' || + value === 'failed' || + value === 'manual_required' || + value === 'blocked' + ); +} + export async function listActiveMambaChildren( projectRoot: string, parentTaskId: string, diff --git a/src/templates/agents/plan-coach.ts b/src/templates/agents/plan-coach.ts index 8aab65b..f48499a 100644 --- a/src/templates/agents/plan-coach.ts +++ b/src/templates/agents/plan-coach.ts @@ -13,7 +13,7 @@ export const PLAN_COACH_AGENT: AgentSpec = { tools: ['Read', 'Grep', 'Glob'], body: `你是 mancode 教练组的 Plan Coach(计划教练)。 -你的职责:在用户确认前,把 Scout Report 和团队上下文整理成可执行 plan。 +你的职责:先检查需求是否足够支撑计划,再在用户确认前把 Scout Report、结构化 requirements 和团队上下文整理成可执行 plan。 ## 硬约束 @@ -21,6 +21,16 @@ export const PLAN_COACH_AGENT: AgentSpec = { - 不创建 README、源码、配置、测试或团队 memory。 - 不把 proposal 写成 decision。团队决策只能在用户确认并完成实现后进入 \`.mancode/memory/decisions.md\`。 - 只在最终响应里返回计划文本;调用方会负责写入 \`.mancode/workflows//plan.md\`。 +- 不用自己的假设填补会改变范围、架构、成本或验收的 blocking 决策。 + +## 输入就绪检查 + +先读取 \`requirements.json\`,以其中的 confirmedScope、excludedScope、technicalDecisions、defaults、blockingUnknowns、coverage 和 acceptanceCriteria 为权威输入;\`requirements.md\` 只用于阅读。逐项检查 platform、core_scope、technical_stack、data_and_persistence、performance、compatibility、security 的状态和理由是否与事实一致。检查需求是否覆盖任务实际适用的用户目标、核心流程、首期范围、排除项、技术与运行约束、数据/状态/集成、关键性能/兼容性/安全要求和验收标准。blockingUnknowns 非空、coverage 用无根据的 not_applicable 掩盖决策、核心行为缺少验收 ID,或文档与结构化输入矛盾时必须返回 NEEDS_CLARIFICATION。 + +只能返回两种结果: + +1. \`NEEDS_CLARIFICATION\`:存在 blocking 缺口时,不生成计划。列出缺失决策、对计划的影响、2–3 个可行选项、明确推荐和建议问题。 +2. \`READY_FOR_PLAN\`:没有 blocking 缺口时,输出标记后再生成完整计划。defaultable 细节必须作为显式默认值写入计划。 ## 输出格式 @@ -32,7 +42,7 @@ export const PLAN_COACH_AGENT: AgentSpec = { - 简单 / 中等 / 复杂;说明理由 ## 模块索引 ## 技术与交付约束(仅新项目或技术选择未定时) -- 候选方案(2–3 个)、优缺点、推荐与确认点;已有项目写“沿用检测到的项目约定” +- 已确认方案、选择理由和取舍;尚未确认会改变架构的技术选择时返回 NEEDS_CLARIFICATION;已有项目写“沿用检测到的项目约定” ## 模块:<名称> ### 改动文件 / 新建文件 ### 复用资源(引用 scout-report.md 行号) @@ -40,11 +50,14 @@ export const PLAN_COACH_AGENT: AgentSpec = { ### 不做什么 ### 实施步骤 ### 风险点 +### 回退方式 ### 完成定义 +## 非阻塞默认值 ## 验证计划 +- 把每个 required 验收 ID 映射到 automated / manual / hybrid 验证,不得用页面加载或代码阅读代替核心交互 - build / lint / typecheck / test / smoke test ## 预估 \`\`\` -如果任务不应继续实施,明确写在 Risks 或 Questions 中。`, +如果任务不应继续实施,返回 NEEDS_CLARIFICATION,不要用一份看似完整的计划掩盖缺失需求。`, }; diff --git a/src/templates/inline.ts b/src/templates/inline.ts index 2a63a8f..88ef0ce 100644 --- a/src/templates/inline.ts +++ b/src/templates/inline.ts @@ -75,6 +75,21 @@ if (state.teamModeAutoDetected === true && mode === 'solo') { ); } +if ( + mode === 'solo' && + state.activeSoloPlan && + typeof state.activeSoloPlan.taskId === 'string' +) { + output.push( + '', + '### 已确认的 solo 实施计划', + 'Task: ' + sanitize(state.activeSoloPlan.taskId), + 'Plan version: ' + sanitize(state.activeSoloPlan.planVersion || 1), + '- 收到继续实施指令时,先读对应 workflow 的 requirements.md 和 plan.md。', + '- 按已确认范围轻量开发,不重新进入 /man,不运行独立 reviewer。', + ); +} + console.log(output.join('\n')); function readJson(filePath) { @@ -138,8 +153,31 @@ if (mode === 'solo') { const rawInput = readFileSync(0, 'utf8'); const userPrompt = readPrompt(rawInput); const planningPattern = /先(?:别|不要|看|看看|调研|分析|评估)|给.*方案|给.*计划|怎么.*做|如何.*做|怎么.*实现|如何.*实现|应该怎么|怎么.*拆|拆分|只给.*计划|不要.*改代码|别.*改代码|不要.*动代码|别.*动代码|评估.*风险|风险.*评估|设计.*方案|架构|迁移|集成|\b(?:plan|planning|research|investigate|approach|proposal|architecture|risk|migration|integration)\b|how (?:should|would|to)|do not (?:edit|modify|change)|don.t (?:edit|modify|change)|no code changes|without changing code/iu; +const approvedPlanExecutionPattern = /按.*计划|继续.*计划|执行.*计划|实现.*计划|(?:implement|execute|continue|resume).*(?:approved )?plan/iu; +const hasActiveSoloPlan = + mode === 'solo' && + state.activeSoloPlan && + typeof state.activeSoloPlan.taskId === 'string'; -if (mode === 'solo' && planningPattern.test(userPrompt)) { +if (hasActiveSoloPlan) { + output.push( + '## mancode 已确认计划', + '', + '当前 solo 计划:.mancode/workflows/' + + sanitize(state.activeSoloPlan.taskId) + + '/plan.md(v' + + sanitize(state.activeSoloPlan.planVersion || 1) + + ')。', + '实施前同时读取 requirements.md;只执行确认范围,完成后运行 workflow handoff --complete。', + '', + ); +} + +if ( + mode === 'solo' && + planningPattern.test(userPrompt) && + !(hasActiveSoloPlan && approvedPlanExecutionPattern.test(userPrompt)) +) { output.push( '## mancode 自动路由', '', @@ -264,6 +302,14 @@ export const SOLO_SKILL = `# mancode · solo mode 3. 搜索相似实现("这个项目里有没有?") 4. 检查可复用资源 +### 执行已确认的 /man 计划 +当 \`.mancode/state.json\` 的 \`activeSoloPlan\` 非空且用户要求继续该计划时: +1. 读取对应 workflow 的 \`requirements.md\` 和 \`plan.md\`,确认 taskId 与 planVersion。 +2. 开工前回执目标、技术方案、包含范围、排除范围、验证方式和残余假设。 +3. 只按计划轻量实施;不重新规划、不调用 Film Reviewer、不创建新的 /man workflow。 +4. 发现会改变架构、范围或验收的缺口时暂停并询问,不自行扩大计划。 +5. 完成最窄有效验证和一次受限 diff 自检后,运行 \`mancode workflow handoff --complete\`;由 CLI 原子清理 \`activeSoloPlan\`、把 workflow 标记 completed 并移出 Active Plans,保留计划文件。 + ### UI 任务(条件执行) 仅当 project-profile 确认有 UI 资产且任务确实涉及界面时: 1. UserPromptSubmit hook 会注入项目审美 token(如有) diff --git a/src/templates/skills/man.ts b/src/templates/skills/man.ts index 35645da..1538ada 100644 --- a/src/templates/skills/man.ts +++ b/src/templates/skills/man.ts @@ -4,16 +4,18 @@ import { CORE_CODING_PRINCIPLES } from './principles.js'; export const MAN_SKILL: SkillSpec = { name: 'man', description: - 'Progressive 9-step engineering workflow: research, clarify, plan, choose plan-only or execution, implement, validate, review, and wrap up. Use for complex or high-risk changes.', + 'Planning and progressive engineering workflow: research, align requirements, recommend options, produce a confirmed plan, then choose plan-only, lightweight solo implementation, or the full governed workflow. Use for planning requests and complex or high-risk changes.', body: `# mancode · /man (Progressive Governance) -用户用 \`/man \` 触发你。立即读 \`.mancode/state.json\`。若已有不同的 active workflow,不得直接清空指针;先让用户选择恢复原流程或用 \`/mansolo\` 放弃。task 为空时询问任务,等待期间仍是 \`man\` 模式。 +用户用 \`/man \` 触发你。立即读 \`.mancode/state.json\`。若已有不同的 active workflow,不得直接清空指针;先让用户选择恢复原流程或用 \`/mansolo\` 放弃。若 \`activeSoloPlan\` 非空,先让用户选择继续该计划;已完成时运行 \`mancode workflow handoff <旧taskId> --complete\`,放弃时取得确认后通过 CLI 标记旧 workflow abandoned 并清理指针,然后才能创建新计划。handoff CLI 会拒绝静默覆盖。task 为空时询问任务,等待期间仍是 \`man\` 模式。 -用 \`mancode workflow create man "" --json\` 创建 workflow 并读取返回的 taskId;不得直接创建或改写 metadata.json。随后只用 \`mancode workflow update\` 更新 step/status/planVersion/skippedSteps。state 的 \`currentMode\`、\`currentTask\` 和 \`currentWorkflowMode\` 指向本 task。 +用 \`mancode workflow create man "" --json\` 创建 workflow 并读取返回的 taskId;不得直接创建或改写 metadata.json。随后只用 workflow CLI 更新需求、验证、step/status/planVersion/skippedSteps。通用 \`--skipped\` 只用于 Step 1–2 的 \`clarification\`;用户明确跳过整个 review 时必须在 Step 6 使用专用 \`workflow review ... skip --reason\`,不能写数字、\`film-1\` 或 \`film-2\`。state 的 \`currentMode\`、\`currentTask\` 和 \`currentWorkflowMode\` 指向本 task。 -## 新项目技术选择关卡(仅在适用时) +## 计划职责与技术选择关卡 -先读 \`.mancode/project-profile.json\`。如果用户要新建项目、profile 为 unknown,且用户没有指定技术栈:先收集目标平台、用户规模、离线/部署、团队熟悉度、预算与集成约束;列出 2–3 个可行方案的优缺点和推荐理由,取得用户确认后才创建脚手架。不要把任何语言、框架、UI 库或浏览器自动化当作默认。已有项目则以检测到的事实和仓库约定为准。 +把 \`/man\` 作为正式计划入口:先对齐需求并产出可确认的计划,不因计划完成而自动进入完整实施。计划关卡必须让用户选择只保留计划、交给默认 \`solo\` 轻量实施、继续完整 \`/man\` 或修改计划。 + +先读 \`.mancode/project-profile.json\`。如果用户要新建项目、profile 为 unknown,且用户没有指定技术栈:收集适用的目标平台、规模、离线/部署、团队熟悉度、预算与集成约束;列出 2–3 个可行方案的优缺点、明确推荐和推荐理由,取得用户确认。不要把语言、框架、UI 库、数据存储或浏览器自动化当作静默默认。已有项目以检测到的事实和仓库约定为准。 ### Step 1: Scout 调研 @@ -21,18 +23,34 @@ export const MAN_SKILL: SkillSpec = { ### Step 2: 需求澄清 -主 skill 直接基于 Scout 报告和任务歧义提问:客观题用 AskUserQuestion(每次最多 4 个),主观题开放提问。最多两轮,回答写 \`requirements.md\`;无问题则通过 CLI 将 \`clarify\` 写入累计 skippedSteps。第二轮后将假设、未决风险和保守默认值写入 requirements,不无限追问。完成后更新至 Step 3。 +基于 Scout 报告做需求就绪判断,不用固定轮数或问题数量代替理解质量。把所有会改变范围、架构、成本或验收且无法从项目事实中查清的疑问问出来;允许按需要分多批澄清,不限制每批数量。每次回答后重新判断,只追问仍阻塞计划的未知项,不重复已确认内容。 + +把未知项分为: +- **blocking**:技术栈、核心范围、持久化、主要交互、关键性能/兼容性/安全目标等会改变方案的决策,必须确认。 +- **recommendable**:有合适方案时直接给 2–3 个方案、优缺点和明确推荐,让用户接受或调整;不要只把选择题抛给用户。 +- **defaultable**:命名、微小样式等低影响细节,可采用默认值,但写明默认值和理由。 + +按任务实际适用范围检查:用户目标与平台、核心流程、首期范围、排除项、技术与运行约束、数据/状态/集成、性能/兼容性/安全、可验证完成标准。连续澄清没有减少 blocking 项时,不散问;让用户选择缩小首期范围、接受列明的推荐默认值或暂停。 + +把结论写入结构化需求输入,至少包含 \`goal\`、非空 \`confirmedScope\`、\`excludedScope\`、\`technicalDecisions\`、\`defaults\`、\`blockingUnknowns\`、\`coverage\` 和 \`acceptanceCriteria\`。coverage 必须逐项说明 platform、core_scope、technical_stack、data_and_persistence、performance、compatibility、security 是 \`confirmed\`、\`defaulted\` 或带理由的 \`not_applicable\`,不能用空数组假装已经考虑。每个核心用户行为必须有稳定验收 ID(\`AC-1\` 等)、描述、\`required\` 和验证方式 \`automated\` / \`manual\` / \`hybrid\`;至少一个验收项必需。运行 \`mancode workflow requirements finalize --file \`,由 CLI 校验并生成权威 \`requirements.json\` 与 \`requirements.md\`,不要手工制造两份可能冲突的结论。无须提问时可把 \`clarification\` 记入累计 skippedSteps,但不能跳过需求摘要与就绪判断。仍有 blocking 项时停在 Step 2;只有 CLI 返回 ready 才运行 \`mancode workflow update --step 3\`。 ### Step 3: Plan Coach 出计划 -调用只读 \`plan-coach\`,输入 task、scout-report 和 requirements;Plan Coach 只返回计划文本,由主 skill 写入 \`plan.md\`。计划必须含任务分级、模块索引、复用资源与 scout 行号、最小策略、不做什么、完成定义、验证与 smoke test、预估。首次计划写入成功后运行 \`mancode workflow update --step 4\`,由 CLI upsert Active Plans 并进入计划关卡;重写计划时保持在 Step 4,运行 \`--plan-version <当前版本+1>\`,不得直接编辑 planVersion。 +调用只读 \`plan-coach\`,输入 task、scout-report、\`requirements.json\` 和渲染后的 \`requirements.md\`。Plan Coach 先返回 \`READY_FOR_PLAN\` 或 \`NEEDS_CLARIFICATION\`。后者只列缺失决策、影响、推荐和问题;主 skill 将 workflow 退回 Step 2 并重新 finalize requirements,不得强行补全计划。前者返回计划文本,由主 skill 写入 \`plan.md\`。 + +计划必须含需求摘要、任务分级、技术选择及理由、模块索引、复用资源与 scout 行号、核心行为、最小策略、不做什么、步骤、风险/回退、完成定义、真实验证与 smoke test、预估和非阻塞默认值。首次计划写入成功后运行 \`mancode workflow update --step 4\`;重写计划时保持 Step 4,运行 \`--plan-version <当前版本+1>\`,不得直接编辑 planVersion。 ### Step 4: 计划关卡 用 AskUserQuestion 让用户选择: -1. **只要计划**:CLI 更新 \`status: planned\`,state 回 solo 并清空 workflow 指针,结束。 -2. **继续执行(推荐)**:CLI 更新至 Step 5。 -3. **修改计划**:收集意见并重跑 Step 3 的 Plan Coach,workflow 指针保持 Step 4;计划重写完成后递增 planVersion。 +1. **交给 solo 轻量执行**:常规低风险任务推荐。运行 \`mancode workflow handoff --to solo\`;保留已确认计划和 Active Plans,由 solo 按计划做最窄验证和一次受限自检,不进入 Step 5–9。 +2. **继续完整 /man**:高风险或需要独立审查时推荐。先运行 \`mancode workflow decide --plan-decision governed_execution\`,输出开工回执,再更新至 Step 5。 +3. **只要计划(仅保留)**:运行 \`mancode workflow decide --plan-decision plan_only\`,由 CLI 原子保存 planned 状态、切回 solo 并清空受管 workflow 指针,结束。 +4. **修改计划**:收集意见;若改变需求则回 Step 2,否则重跑 Plan Coach。重写完成后递增 planVersion,保持计划决策为空。 + +根据风险明确推荐理由,不把完整 \`/man\` 永远标为推荐。鉴权、支付、敏感数据、迁移/删除、公开 API、未可信输入、并发、跨服务或基础设施默认推荐完整 \`/man\`;普通原型、内部工具和低风险功能默认推荐 solo。 + +确认执行后、修改业务文件前输出开工回执:计划版本、执行方式、目标与交付物、技术方案、包含范围、排除范围、验证方式和残余假设。仍有 blocking 未知项时不得声称开始实施。 ### Step 5: 实施 @@ -40,9 +58,11 @@ export const MAN_SKILL: SkillSpec = { ### Step 6: 自测、诊断与回归 -运行实际 build/lint/typecheck/test 和 smoke test。相同代码、环境、命令下相同错误签名失败两次,停止盲试并诊断根因。需要真实浏览器、复杂复现或回归时,用 \`mancode workflow create manba "<问题>" --parent-task --json\` 创建子 workflow;父任务保持 Step 6。子任务 fixed/verified/no_repro 后恢复本任务;若父曾因该子任务 blocked,先通过 \`workflow update --status in_progress\` 恢复,再更新至 Step 7。blocked 或 manual_test_required 会由 CLI 自动阻塞父任务,不得手改父 metadata,也不得自动越过人工验证要求。 +先运行 \`mancode workflow verify init\`,再按 \`requirements.json\` 的每个验收 ID 记录真实结果。自动 passed/failed 必须使用 \`mancode workflow verify record --acceptance AC-N --method automated --result passed|failed --evidence "<摘要>" --command "<实际命令>" --exit-code <退出码> [--evidence-file <报告>]\`;CLI 校验 passed 的退出码为 0、failed 为非 0。需要真实浏览器、设备或人的判断时,使用 \`mancode workflow verify require-manual --acceptance AC-N --evidence "<自动化不能覆盖的原因>"\`;CLI 会阻塞主任务。明确告诉用户具体实测步骤并停下,只有收到用户明确确认后才使用 \`mancode workflow verify confirm-manual --acceptance AC-N --evidence "<用户确认原文>"\`。此证据用于审计,不代表 CLI 能认证操作者身份。不得用页面加载、控制提示、截图、代码阅读或 reviewer 代替核心交互验收。 + +运行实际 build/lint/typecheck/test 和 smoke test。相同代码、环境、命令下相同错误签名失败两次,停止盲试并诊断根因。需要复杂复现或回归时,用 \`mancode workflow create manba "<问题>" --parent-task --json\` 创建子 workflow;父任务保持 Step 6。子任务 fixed/verified/no_repro 后恢复本任务;\`manual_test_required\` 仍必须走上述人工确认。所有 required 验收及 hybrid 的两个部分都 passed 后,CLI 才允许进入 Step 7 或启动 review;计划版本或结构化需求变化会使旧验证失效。 -验证后基于**实际 diff**写 \`review-scope.md\`:base、改动文件、需求、已跑验证、硬风险和审查深度。鉴权、支付、敏感数据、迁移/删除、公开 API、未可信输入、并发、跨服务或基础设施命中任一项时用完整审查 \`full\`;否则用定向审查 \`targeted\`。运行 \`mancode workflow review init --review-depth targeted --review-domain quality\` 或 \`--review-depth full\`。用户明确跳过审查时才把 \`review\` 写入累计 skippedSteps,并记录残余风险。 +验证后基于**实际 diff**写 \`review-scope.md\`:base、改动文件、需求、已跑验证、硬风险和审查深度。鉴权、支付、敏感数据、迁移/删除、公开 API、未可信输入、并发、跨服务或基础设施命中任一项时用完整审查 \`full\`;否则用定向审查 \`targeted\`。运行 \`mancode workflow review init --review-depth targeted --review-domain quality\` 或 \`--review-depth full\`。只有用户明确要求跳过审查时才运行 \`mancode workflow review skip --reason "<用户理由>"\`;CLI 会记录原因并累计 \`review\`,不得通过通用 skipped 参数绕过。 ### Step 7: Film #1 代码质量审查与修复 @@ -55,7 +75,7 @@ export const MAN_SKILL: SkillSpec = { ### Step 9: 增强收尾 1. 存在 open blocker 时,Head Coach 一次性修复全部 blocker,并用 \`mancode workflow review remediate --resolved Q1,D1\` 记录唯一一轮修复;没有 blocker 时不运行 remediate。不要为 🟡/🟢 扩大改动。 -2. 重跑受影响验证,不重新运行已完成的 reviewer。修复若引入新的高风险面则标记 blocked,不能开启无界 review 循环。 +2. remediation 会使旧验证整批失效。在 Step 9 重跑全部 required 验收,并通过 verify record/require-manual/confirm-manual 重新登记证据;未重新全部通过不能 completed。不重新运行已完成的 reviewer。修复若引入新的高风险面则标记 blocked,不能开启无界 review 循环。 3. 写 \`summary.md\`:改动、新建、复用、验证、审查深度、findings 处置、跳过步骤和残余风险。 4. CLI 确认所需审查领域完成且 blocker 清零后才写 \`completed\`;否则用 \`--status blocked --blocking-reason "<原因>"\`。 5. 关键决策 appendTeamDecision 到 \`decisions.md\`,更新 Active Plans。 diff --git a/src/templates/skills/manteam.ts b/src/templates/skills/manteam.ts index 37c1167..f3582db 100644 --- a/src/templates/skills/manteam.ts +++ b/src/templates/skills/manteam.ts @@ -51,13 +51,13 @@ export const MANTEAM_SKILL: SkillSpec = { 按 \`/man\` 的 9 步流程执行(含澄清、计划关卡和增强收尾),但每一步增加团队约束: 1. Scout Report:必须列出共享文件、近期相关提交、潜在冲突文件。 -2. 澄清:最多两轮;把已确认约束和保守默认值写入 requirements.md。 -3. Game Plan:计划里必须包含变更边界、兼容性风险、回滚方式;Plan Coach 只返回计划文本,禁止提前修改业务文件或团队 memory。 -4. 计划关卡:用户可只保留计划、继续执行或重写计划;Active Plans 按 taskId 更新。 +2. 澄清:沿用 /man 的需求就绪门槛,不设固定轮数或每批问题数量;问出所有会改变决策且无法从项目事实查清的疑问,不重复已确认内容。有合适方案时直接给出选项、优缺点和明确推荐。通过 \`workflow requirements ... finalize\` 固化七个 coverage 维度、结构化需求和带验证方式的验收 ID,只有 CLI 判定 ready 才进入计划。 +3. Game Plan:Plan Coach 先做输入就绪检查;计划里必须包含变更边界、技术选择理由、兼容性风险、回滚方式,禁止提前修改业务文件或团队 memory。 +4. 计划关卡:用户可只保留计划、继续完整团队执行、明确交给 solo 轻量执行或重写计划;Active Plans 按 taskId 更新。团队共享文件或交接风险存在时推荐完整团队执行。 5. 实施:改动前再次检查 \`git status --short\`,避免踩用户或队友改动。 -6. Self-test:优先跑项目已有验证命令;失败两次停下诊断根因。需要真实验证时创建带 parentTaskId 的 /manba 子任务。子任务修复后若父任务曾因此 blocked,先用 workflow CLI 恢复父任务为 in_progress;manual_test_required 不得自动恢复。 +6. Self-test:在 Step 6 初始化 verification ledger,把每个 required 验收 ID 的自动、人工或 hybrid 结果和证据通过 CLI 记录;自动结果包含命令与退出码,未全部通过不得进入 review。优先跑项目已有验证命令;失败两次停下诊断根因。需要人工验证时标记 require-manual 并等待用户明确确认,不得自动恢复。remediation 后在 Step 9 重新登记全部验收。 7. Review scope + Film #1:基于实际 diff 写 \`review-scope.md\`,用 \`workflow review ... init\` 选择 targeted 或 full;重点审查行为、可维护性、团队风格与测试,finding 必须有证据和稳定 ID。 -8. Film #2:仅 full 执行,先读 Film #1 报告并去重,只审查边界、安全、性能、并发和兼容性。用户明确要求才可跳过全部 review,并写入 \`skippedSteps\` 和残余风险。 +8. Film #2:仅 full 执行,先读 Film #1 报告并去重,只审查边界、安全、性能、并发和兼容性。targeted 的第二审是不适用,不能记为 skipped;只有用户明确要求才可跳过全部 review,并写入 \`review\` 和残余风险。 9. Post-game:汇总 blocker,只做一轮修复并用 \`workflow review ... remediate\` 记录;复验后写 summary 与 hand-off。验证失败、审查不完整或仍有 blocker 时标记 blocked,不得标 completed。 如果用户在确认阶段选择"退出"或放弃团队 workflow:用 \`mancode workflow update --status abandoned\` 更新并清理 Active Plans;若有活跃 manba 子任务,先取得确认并逐个 abandoned。成功后再用 Edit 更新 state 回 solo。不要直接改 metadata,也不要把 abandoned workflow 留在 active state。 diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 3457545..eabfc66 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -52,6 +52,9 @@ describe('coaching staff agents', () => { it('plan coach is read-only and owns pre-confirmation plans', () => { expect(PLAN_COACH_AGENT.body).toMatch(/只读/); expect(PLAN_COACH_AGENT.body).toMatch(/plan\.md/); + expect(PLAN_COACH_AGENT.body).toMatch(/READY_FOR_PLAN/); + expect(PLAN_COACH_AGENT.body).toMatch(/NEEDS_CLARIFICATION/); + expect(PLAN_COACH_AGENT.body).toMatch(/blocking/); expect(PLAN_COACH_AGENT.tools).toEqual(['Read', 'Grep', 'Glob']); expect(PLAN_COACH_AGENT.tools).not.toContain('Edit'); expect(PLAN_COACH_AGENT.tools).not.toContain('Write'); diff --git a/tests/hook-budget.test.ts b/tests/hook-budget.test.ts index 5edc16b..8889bcc 100644 --- a/tests/hook-budget.test.ts +++ b/tests/hook-budget.test.ts @@ -164,6 +164,28 @@ describe('UserPromptSubmit hook context budget', () => { expect(output).toContain("skill='man'"); }); + it('keeps approved-plan execution in solo instead of routing back to man', async () => { + await writeState(dir, { + currentMode: 'solo', + teamModeAutoDetected: false, + contributors: 1, + activeSoloPlan: { + taskId: '20260713-040000-prototype', + planVersion: 2, + }, + }); + + const output = await runHook( + dir, + {}, + { prompt: 'Implement the approved plan now' }, + ); + + expect(output).toContain('## mancode 已确认计划'); + expect(output).toContain('20260713-040000-prototype'); + expect(output).not.toContain('## mancode 自动路由'); + }); + it('does not route small direct edits to man', async () => { await writeState(dir, { currentMode: 'solo', @@ -409,6 +431,7 @@ async function writeState( currentMode: string; teamModeAutoDetected: boolean; contributors: number; + activeSoloPlan?: { taskId: string; planVersion: number }; }, ): Promise { await writeFile( @@ -425,6 +448,7 @@ async function writeState( currentTask: null, currentWorkflowMode: null, skippedSteps: [], + activeSoloPlan: patch.activeSoloPlan ?? null, teamModeAutoDetected: patch.teamModeAutoDetected, contributors: patch.contributors, }, diff --git a/tests/requirements-ledger.test.ts b/tests/requirements-ledger.test.ts new file mode 100644 index 0000000..fd3719f --- /dev/null +++ b/tests/requirements-ledger.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + parseRequirementsLedger, + renderRequirementsMarkdown, + requirementsAreReady, +} from '../src/system/requirements-ledger.js'; + +describe('requirements ledger', () => { + it('derives readiness from blocking unknowns and renders one authoritative view', () => { + const ledger = parseRequirementsLedger( + JSON.stringify({ + version: 1, + goal: 'Build a voxel sandbox', + confirmedScope: ['Desktop creative mode'], + excludedScope: ['Survival mode'], + technicalDecisions: ['Use the existing Vite stack'], + defaults: ['Use a bounded world'], + blockingUnknowns: [], + coverage: completeCoverage(), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Pointer lock movement works', + required: true, + method: 'manual', + }, + ], + }), + ); + + expect(requirementsAreReady(ledger)).toBe(true); + expect(renderRequirementsMarkdown(ledger)).toContain('READY'); + expect(renderRequirementsMarkdown(ledger)).toContain('AC-1'); + }); + + it('rejects duplicate ids and manifests with no required acceptance', () => { + const base = { + version: 1, + goal: 'Build it', + confirmedScope: ['Confirmed first release'], + excludedScope: [], + technicalDecisions: ['Use the existing stack'], + defaults: [], + blockingUnknowns: [], + coverage: completeCoverage(), + }; + expect(() => + parseRequirementsLedger( + JSON.stringify({ + ...base, + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'First', + required: true, + method: 'automated', + }, + { + id: 'AC-1', + description: 'Duplicate', + required: true, + method: 'manual', + }, + ], + }), + ), + ).toThrow(/duplicate acceptance criterion/); + expect(() => + parseRequirementsLedger( + JSON.stringify({ + ...base, + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Optional only', + required: false, + method: 'automated', + }, + ], + }), + ), + ).toThrow(/at least one required/); + }); + + it('rejects missing requirement coverage dimensions', () => { + expect(() => + parseRequirementsLedger( + JSON.stringify({ + version: 1, + goal: 'Build it', + confirmedScope: ['First release'], + excludedScope: [], + technicalDecisions: ['Use the existing stack'], + defaults: [], + blockingUnknowns: [], + coverage: completeCoverage().slice(0, -1), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'The confirmed behavior works', + required: true, + method: 'automated', + }, + ], + }), + ), + ).toThrow(/coverage is missing/); + }); +}); + +function completeCoverage() { + return [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension) => ({ + dimension, + status: 'confirmed', + rationale: `${dimension} was explicitly considered`, + })); +} diff --git a/tests/review-ledger.test.ts b/tests/review-ledger.test.ts index a9d0cdb..6678cdf 100644 --- a/tests/review-ledger.test.ts +++ b/tests/review-ledger.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { completeReviewDomain, initializeReview, + initializeSkippedReview, readReviewLedger, remediateReviewBlockers, reviewCanComplete, @@ -50,6 +51,17 @@ describe('review ledger', () => { await expect(reviewCanComplete(dir, taskId)).resolves.toBe(true); }); + it('records an explicit review skip as a completed review decision', async () => { + const ledger = await initializeSkippedReview( + dir, + taskId, + '用户明确要求跳过独立审查', + ); + + expect(ledger.skipped?.reason).toContain('用户明确要求'); + await expect(reviewCanComplete(dir, taskId)).resolves.toBe(true); + }); + it('allows one remediation round and keeps unresolved blockers open', async () => { await initializeReview(dir, taskId, 'targeted', 'quality'); await writeReviewReport('film-report-1.md'); diff --git a/tests/skills.test.ts b/tests/skills.test.ts index 1519373..50781fe 100644 --- a/tests/skills.test.ts +++ b/tests/skills.test.ts @@ -44,14 +44,28 @@ describe('mvp-2 skills', () => { expect(MAN_SKILL.body).toMatch(/workflow create man/); expect(MAN_SKILL.body).toMatch(/workflow update/); expect(MAN_SKILL.body).toMatch(/--plan-version/); - expect(MAN_SKILL.body).toMatch(/--status in_progress/); + expect(MAN_SKILL.body).toMatch(/confirm-manual/); expect(MAN_SKILL.body).toMatch(/workflow update --step 4/); - expect(MAN_SKILL.body).toMatch(/Plan Coach 只返回计划文本/); + expect(MAN_SKILL.body).toMatch(/Plan Coach 先返回/); expect(MAN_SKILL.body).toMatch(/不得直接.*metadata\.json/); expect(MAN_SKILL.body).toMatch(/review-scope\.md/); expect(MAN_SKILL.body).toMatch(/定向审查/); expect(MAN_SKILL.body).toMatch(/一轮修复/); expect(MAN_SKILL.body).toMatch(/workflow review/); + expect(MAN_SKILL.body).toMatch(/不用固定轮数/); + expect(MAN_SKILL.body).toMatch(/不限制每批数量/); + expect(MAN_SKILL.body).not.toMatch(/最多询问 4 个/); + expect(MAN_SKILL.body).toMatch(/workflow requirements/); + expect(MAN_SKILL.body).toMatch(/requirements\.json/); + expect(MAN_SKILL.body).toMatch(/hybrid/); + expect(MAN_SKILL.body).toMatch(/coverage/); + expect(MAN_SKILL.body).toMatch(/--exit-code/); + expect(MAN_SKILL.body).toMatch(/review skip --reason/); + expect(MAN_SKILL.body).toMatch(/Step 9 重跑全部 required/); + expect(MAN_SKILL.body).toMatch(/workflow handoff/); + expect(MAN_SKILL.body).toMatch(/workflow decide/); + expect(MAN_SKILL.body).toMatch(/solo 轻量执行/); + expect(MAN_SKILL.body).toMatch(/开工回执/); }); it('keeps solo review bounded and lightweight', () => { @@ -60,6 +74,9 @@ describe('mvp-2 skills', () => { expect(SOLO_SKILL).toMatch(/不调用.*reviewer/); expect(SOLO_SKILL).toMatch(/最窄/); expect(SOLO_SKILL).not.toMatch(/自审发现 3 个以上问题/); + expect(SOLO_SKILL).toMatch(/activeSoloPlan/); + expect(SOLO_SKILL).toMatch(/不重新规划/); + expect(SOLO_SKILL).toMatch(/handoff --complete/); }); it('defines manba diagnosis and real browser validation boundaries', () => { diff --git a/tests/status.test.ts b/tests/status.test.ts index fa89a56..434f5b3 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -58,6 +58,27 @@ describe('mancode status', () => { expect(result.team.isTeam).toBe(false); expect(result.team.contributors).toBeGreaterThanOrEqual(1); expect(result.currentWorkflow).toBeNull(); + expect(result.activeSoloPlan).toBeNull(); + }); + + it('shows a confirmed plan handed to solo', async () => { + await silentInit(dir); + const statePath = path.join(dir, '.mancode', 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')); + state.activeSoloPlan = { + taskId: '20260713-040000-prototype', + planVersion: 2, + }; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); + + const jsonLogs = await captureLog(() => status(dir, { json: true })); + const result: StatusResult = JSON.parse(jsonLogs.join('\n')); + expect(result.activeSoloPlan).toEqual(state.activeSoloPlan); + + const textLogs = await captureLog(() => status(dir)); + expect(textLogs.join('\n')).toContain( + 'Solo plan: 20260713-040000-prototype (plan v2)', + ); }); it('project name comes from package.json name field', async () => { diff --git a/tests/verification-ledger.test.ts b/tests/verification-ledger.test.ts new file mode 100644 index 0000000..80754ac --- /dev/null +++ b/tests/verification-ledger.test.ts @@ -0,0 +1,125 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + type RequirementsLedger, + writeRequirementsArtifacts, +} from '../src/system/requirements-ledger.js'; +import { + confirmManualVerification, + initializeVerificationLedger, + recordVerification, + verificationCanAdvance, + writeVerificationLedger, +} from '../src/system/verification-ledger.js'; + +describe('verification ledger', () => { + let dir: string; + const taskId = '20260713-120000-verification'; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'mancode-verification-')); + await mkdir(path.join(dir, '.mancode', 'workflows', taskId), { + recursive: true, + }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('requires both components of a hybrid acceptance criterion', () => { + const requirements = hybridRequirements(); + const initialized = initializeVerificationLedger(requirements, 1); + const automated = recordVerification( + initialized, + 'AC-1', + 'automated', + 'passed', + 'browser test exited 0', + { command: 'npm test', exitCode: 0 }, + ); + const waiting = recordVerification( + automated, + 'AC-1', + 'manual', + 'manual_required', + 'Pointer Lock needs a foreground browser', + ); + const confirmed = confirmManualVerification( + waiting, + 'AC-1', + '用户回复:交互正常', + ); + + expect(automated.status).toBe('pending'); + expect(waiting.status).toBe('manual_required'); + expect(confirmed.status).toBe('passed'); + }); + + it('invalidates verification when requirements or plan version changes', async () => { + const requirements = hybridRequirements(); + await writeRequirementsArtifacts(dir, taskId, requirements); + let ledger = initializeVerificationLedger(requirements, 1); + ledger = recordVerification( + ledger, + 'AC-1', + 'automated', + 'passed', + 'browser test exited 0', + { command: 'npm test', exitCode: 0 }, + ); + ledger = recordVerification( + ledger, + 'AC-1', + 'manual', + 'manual_required', + 'needs foreground confirmation', + ); + ledger = confirmManualVerification(ledger, 'AC-1', '用户回复:已通过'); + await writeVerificationLedger(dir, taskId, ledger); + + await expect(verificationCanAdvance(dir, taskId, 1)).resolves.toBe(true); + await expect(verificationCanAdvance(dir, taskId, 2)).resolves.toBe(false); + await writeRequirementsArtifacts(dir, taskId, { + ...requirements, + confirmedScope: ['Changed scope'], + }); + await expect(verificationCanAdvance(dir, taskId, 1)).resolves.toBe(false); + }); +}); + +function hybridRequirements(): RequirementsLedger { + return { + version: 1, + goal: 'Verify browser interaction', + confirmedScope: ['Desktop browser'], + excludedScope: [], + technicalDecisions: ['Use browser automation plus manual confirmation'], + defaults: [], + blockingUnknowns: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension) => ({ + dimension: + dimension as RequirementsLedger['coverage'][number]['dimension'], + status: 'confirmed' as const, + rationale: `${dimension} was explicitly considered`, + })), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Pointer lock movement works', + required: true, + method: 'hybrid', + }, + ], + }; +} diff --git a/tests/workflow-command.test.ts b/tests/workflow-command.test.ts index 258d661..d29bd62 100644 --- a/tests/workflow-command.test.ts +++ b/tests/workflow-command.test.ts @@ -43,6 +43,494 @@ describe('mancode workflow command', () => { await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ task: 'add oauth login', currentStep: 1, + planningPolicyVersion: 2, + reviewPolicyVersion: 2, + verificationPolicyVersion: 1, + }); + }); + + it('requires ready requirements and a plan before the plan gate', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'plan', 'a', 'feature'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + + const missingRequirements = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '3' }), + ); + expect(missingRequirements.code).toBe(EXIT_INVALID_ARG); + expect(missingRequirements.stderr.join('\n')).toContain( + 'requirements must be ready', + ); + + const requirementsInput = await writeRequirementsInput(dir, meta.taskId); + const finalized = await captureLog(() => + workflow(dir, 'requirements', [meta.taskId, 'finalize'], { + file: requirementsInput, + }), + ); + expect(finalized.code).toBe(EXIT_OK); + const ready = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { + step: '3', + }), + ); + expect(ready.code).toBe(EXIT_OK); + + const missingPlan = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '4' }), + ); + expect(missingPlan.code).toBe(EXIT_INVALID_ARG); + expect(missingPlan.stderr.join('\n')).toContain('plan.md is required'); + }); + + it('hands a confirmed plan to solo without abandoning the workflow', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'build', 'a', 'prototype'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await preparePlanGate(dir, meta.taskId); + const statePath = path.join(dir, '.mancode', 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')); + await writeFile( + statePath, + `${JSON.stringify( + { + ...state, + currentMode: 'man', + currentTask: meta.taskId, + currentWorkflowMode: 'man', + }, + null, + 2, + )}\n`, + 'utf-8', + ); + + const handedOff = await captureLog(() => + workflow(dir, 'handoff', [meta.taskId], { to: 'solo', json: true }), + ); + const nextState = JSON.parse(await readFile(statePath, 'utf-8')); + + expect(handedOff.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + status: 'planned', + planDecision: 'solo_handoff', + currentStep: 4, + }); + expect(nextState).toMatchObject({ + currentMode: 'solo', + currentTask: null, + currentWorkflowMode: null, + activeSoloPlan: { taskId: meta.taskId, planVersion: 1 }, + }); + }); + + it('completes a solo handoff and removes it from Active Plans', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'deliver', 'a', 'prototype'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await preparePlanGate(dir, meta.taskId); + await pointStateAtWorkflow(dir, meta.taskId); + expect( + ( + await captureLog(() => + workflow(dir, 'handoff', [meta.taskId], { + to: 'solo', + json: true, + }), + ) + ).code, + ).toBe(EXIT_OK); + + const completed = await captureLog(() => + workflow(dir, 'handoff', [meta.taskId], { + complete: true, + json: true, + }), + ); + const state = JSON.parse( + await readFile(path.join(dir, '.mancode', 'state.json'), 'utf-8'), + ); + const spec = await readFile( + path.join(dir, '.mancode', 'memory', 'spec.md'), + 'utf-8', + ); + + expect(completed.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + status: 'completed', + currentStep: 4, + planDecision: 'solo_handoff', + }); + expect(state.activeSoloPlan).toBeNull(); + expect(spec).not.toContain(meta.taskId); + }); + + it('refuses to overwrite another active solo plan', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'replacement', 'plan'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await preparePlanGate(dir, meta.taskId); + await pointStateAtWorkflow(dir, meta.taskId, { + taskId: '20260713-030000-existing-plan', + planVersion: 3, + }); + + const handedOff = await captureLog(() => + workflow(dir, 'handoff', [meta.taskId], { to: 'solo' }), + ); + const state = JSON.parse( + await readFile(path.join(dir, '.mancode', 'state.json'), 'utf-8'), + ); + + expect(handedOff.code).toBe(EXIT_INVALID_ARG); + expect(handedOff.stderr.join('\n')).toContain( + 'another solo plan is active', + ); + expect(state.activeSoloPlan.taskId).toBe('20260713-030000-existing-plan'); + const unchanged = await readWorkflow(dir, meta.taskId); + expect(unchanged?.status).toBe('in_progress'); + expect(unchanged?.planDecision).toBeUndefined(); + }); + + it('applies the plan-only decision and returns state to solo together', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'plan', 'only'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await preparePlanGate(dir, meta.taskId); + await pointStateAtWorkflow(dir, meta.taskId); + + const decided = await captureLog(() => + workflow(dir, 'decide', [meta.taskId], { + planDecision: 'plan_only', + json: true, + }), + ); + const state = JSON.parse( + await readFile(path.join(dir, '.mancode', 'state.json'), 'utf-8'), + ); + + expect(decided.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + status: 'planned', + planDecision: 'plan_only', + currentStep: 4, + }); + expect(state).toMatchObject({ + currentMode: 'solo', + currentTask: null, + currentWorkflowMode: null, + activeSoloPlan: null, + }); + }); + + it('keeps blocking requirements at step 2', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'clarify', 'scope'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + const input = await writeRequirementsInput(dir, meta.taskId, { + blockingUnknowns: ['Choose the deployment target'], + }); + + const finalized = await captureLog(() => + workflow(dir, 'requirements', [meta.taskId, 'finalize'], { + file: input, + json: true, + }), + ); + const advanced = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '3' }), + ); + + expect(finalized.code).toBe(EXIT_OK); + expect(JSON.parse(finalized.stdout.join('\n')).requirementsStatus).toBe( + 'needs_clarification', + ); + expect(advanced.code).toBe(EXIT_INVALID_ARG); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + currentStep: 1, + requirementsStatus: 'needs_clarification', + }); + }); + + it('blocks review and later steps until required verification passes', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'verify', 'behavior'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await prepareGovernedWorkflow(dir, meta.taskId, 6); + + const initialized = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'init'], { json: true }), + ); + const earlyStep = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '7' }), + ); + const earlyReview = await captureLog(() => + workflow(dir, 'review', [meta.taskId, 'init'], { + reviewDepth: 'targeted', + reviewDomain: 'quality', + }), + ); + const recorded = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'npm test exited 0', + command: 'npm test', + exitCode: '0', + json: true, + }), + ); + const advanced = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '7' }), + ); + + expect(initialized.code).toBe(EXIT_OK); + expect(earlyStep.code).toBe(EXIT_INVALID_ARG); + expect(earlyReview.code).toBe(EXIT_INVALID_ARG); + expect(recorded.code).toBe(EXIT_OK); + expect(JSON.parse(recorded.stdout.join('\n')).status).toBe('passed'); + expect(advanced.code).toBe(EXIT_OK); + }); + + it('requires explicit manual confirmation and guards the unblock path', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'verify', 'pointer', 'lock'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await prepareGovernedWorkflow(dir, meta.taskId, 6, { + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Pointer lock movement works in a foreground browser', + required: true, + method: 'manual', + }, + ], + }); + await captureLog(() => workflow(dir, 'verify', [meta.taskId, 'init'])); + + const required = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'require-manual'], { + acceptance: 'AC-1', + evidence: 'Automation cannot acquire Pointer Lock', + }), + ); + expect(required.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + status: 'blocked', + verificationStatus: 'manual_required', + }); + const bypass = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { status: 'in_progress' }), + ); + const confirmed = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'confirm-manual'], { + acceptance: 'AC-1', + evidence: '用户回复:移动、视角、破坏和放置均正常', + json: true, + }), + ); + + expect(bypass.code).toBe(EXIT_INVALID_ARG); + expect(confirmed.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + status: 'in_progress', + currentStep: 6, + verificationStatus: 'passed', + }); + }); + + it('rejects legacy step names in policy-v2 skipped steps', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'strict', 'review'], { json: true }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + + const numeric = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { skipped: '8' }), + ); + const film = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { skipped: 'film-2' }), + ); + const allowed = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { skipped: 'clarification' }), + ); + const earlyReview = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { skipped: 'review' }), + ); + + expect(numeric.code).toBe(EXIT_INVALID_ARG); + expect(film.code).toBe(EXIT_INVALID_ARG); + expect(allowed.code).toBe(EXIT_OK); + expect(earlyReview.code).toBe(EXIT_INVALID_ARG); + }); + + it('requires structured automated verification evidence', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'structured', 'evidence'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await prepareGovernedWorkflow(dir, meta.taskId, 6); + await captureLog(() => workflow(dir, 'verify', [meta.taskId, 'init'])); + + const missingCommand = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'ok', + }), + ); + const wrongExit = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'command failed', + command: 'npm test', + exitCode: '1', + }), + ); + + expect(missingCommand.code).toBe(EXIT_INVALID_ARG); + expect(wrongExit.code).toBe(EXIT_INVALID_ARG); + }); + + it('invalidates pre-remediation evidence and permits Step 9 re-verification', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'remediate', 'and', 'reverify'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await prepareGovernedWorkflow(dir, meta.taskId, 6); + await captureLog(() => workflow(dir, 'verify', [meta.taskId, 'init'])); + await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'initial test passed', + command: 'npm test', + exitCode: '0', + }), + ); + await captureLog(() => + workflow(dir, 'review', [meta.taskId, 'init'], { + reviewDepth: 'targeted', + reviewDomain: 'quality', + }), + ); + await writeWorkflowArtifact( + dir, + meta.taskId, + 'film-report-1.md', + '# Review\n', + ); + await captureLog(() => + workflow(dir, 'review', [meta.taskId, 'complete'], { + reviewDomain: 'quality', + report: 'film-report-1.md', + blockers: 'Q1', + }), + ); + await captureLog(() => + workflow(dir, 'update', [meta.taskId], { step: '9' }), + ); + await captureLog(() => + workflow(dir, 'review', [meta.taskId, 'remediate'], { + resolved: 'Q1', + }), + ); + await writeWorkflowArtifact(dir, meta.taskId, 'summary.md', '# Summary\n'); + + const stale = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { status: 'completed' }), + ); + const refreshed = await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'post-remediation test passed', + command: 'npm test', + exitCode: '0', + json: true, + }), + ); + const completed = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { status: 'completed' }), + ); + + expect(stale.code).toBe(EXIT_INVALID_ARG); + expect(stale.stderr.join('\n')).toContain('verification'); + expect(refreshed.code).toBe(EXIT_OK); + expect(JSON.parse(refreshed.stdout.join('\n')).remediationRound).toBe(1); + expect(completed.code).toBe(EXIT_OK); + }); + + it('requires summary.md even when the user explicitly skips review', async () => { + const created = await captureLog(() => + workflow(dir, 'create', ['man', 'finish', 'verified', 'work'], { + json: true, + }), + ); + const meta = JSON.parse(created.stdout.join('\n')); + await captureLog(() => + workflow(dir, 'update', [meta.taskId], { skipped: 'clarification' }), + ); + await prepareGovernedWorkflow(dir, meta.taskId, 6); + await captureLog(() => workflow(dir, 'verify', [meta.taskId, 'init'])); + await captureLog(() => + workflow(dir, 'verify', [meta.taskId, 'record'], { + acceptance: 'AC-1', + method: 'automated', + result: 'passed', + evidence: 'npm test exited 0', + command: 'npm test', + exitCode: '0', + }), + ); + await captureLog(() => + workflow(dir, 'review', [meta.taskId, 'skip'], { + reason: '用户明确要求跳过独立审查', + }), + ); + await captureLog(() => + workflow(dir, 'update', [meta.taskId], { + step: '9', + }), + ); + + const missingSummary = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { status: 'completed' }), + ); + await writeWorkflowArtifact(dir, meta.taskId, 'summary.md', '# Summary\n'); + const completed = await captureLog(() => + workflow(dir, 'update', [meta.taskId], { status: 'completed' }), + ); + + expect(missingSummary.code).toBe(EXIT_INVALID_ARG); + expect(missingSummary.stderr.join('\n')).toContain('summary.md'); + expect(completed.code).toBe(EXIT_OK); + await expect(readWorkflow(dir, meta.taskId)).resolves.toMatchObject({ + skippedSteps: ['clarification', 'review'], }); }); @@ -312,9 +800,7 @@ describe('mancode workflow command', () => { }), ); const parent = JSON.parse(parentLogs.stdout.join('\n')); - await captureLog(() => - workflow(dir, 'update', [parent.taskId], { step: '6' }), - ); + await prepareGovernedWorkflow(dir, parent.taskId, 6); const childLogs = await captureLog(() => workflow(dir, 'create', ['manba', 'verify', 'regression'], { @@ -579,6 +1065,124 @@ async function silentInit(dir: string): Promise { } } +async function preparePlanGate( + dir: string, + taskId: string, + requirementsOverrides: Record = {}, +): Promise { + const requirementsInput = await writeRequirementsInput( + dir, + taskId, + requirementsOverrides, + ); + await workflow(dir, 'requirements', [taskId, 'finalize'], { + file: requirementsInput, + }); + await workflow(dir, 'update', [taskId], { + step: '3', + }); + await writeWorkflowArtifact(dir, taskId, 'plan.md', '# Plan\n'); + await workflow(dir, 'update', [taskId], { step: '4' }); +} + +async function writeRequirementsInput( + dir: string, + taskId: string, + overrides: Record = {}, +): Promise { + const inputPath = path.join(dir, `${taskId}-requirements-input.json`); + await writeFile( + inputPath, + `${JSON.stringify( + { + version: 1, + goal: 'Deliver the confirmed change', + confirmedScope: ['Implement the requested behavior'], + excludedScope: [], + technicalDecisions: ['Follow the existing project stack'], + defaults: [], + blockingUnknowns: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension) => ({ + dimension, + status: 'confirmed', + rationale: `${dimension} was explicitly considered`, + })), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'The requested behavior works', + required: true, + method: 'automated', + }, + ], + ...overrides, + }, + null, + 2, + )}\n`, + 'utf-8', + ); + return inputPath; +} + +async function pointStateAtWorkflow( + dir: string, + taskId: string, + activeSoloPlan: { taskId: string; planVersion: number } | null = null, +): Promise { + const statePath = path.join(dir, '.mancode', 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')); + await writeFile( + statePath, + `${JSON.stringify( + { + ...state, + currentMode: 'man', + currentTask: taskId, + currentWorkflowMode: 'man', + activeSoloPlan, + }, + null, + 2, + )}\n`, + 'utf-8', + ); +} + +async function prepareGovernedWorkflow( + dir: string, + taskId: string, + step: number, + requirementsOverrides: Record = {}, +): Promise { + await preparePlanGate(dir, taskId, requirementsOverrides); + await workflow(dir, 'decide', [taskId], { + planDecision: 'governed_execution', + }); + await workflow(dir, 'update', [taskId], { step: String(step) }); +} + +async function writeWorkflowArtifact( + dir: string, + taskId: string, + filename: string, + content: string, +): Promise { + await writeFile( + path.join(dir, '.mancode', 'workflows', taskId, filename), + content, + 'utf-8', + ); +} + async function rewriteMetadata( dir: string, meta: Awaited>, diff --git a/website/docs.html b/website/docs.html index d760eb8..e43786e 100644 --- a/website/docs.html +++ b/website/docs.html @@ -24,7 +24,7 @@
mancode - Documentation / v0.3.7 + Documentation / v0.3.8
复用优先阶梯
  1. 01已有代码
  2. 02标准库
  3. 03原生能力
  4. 04已安装依赖
  5. 05最小新实现
-
02 / 五档强度按风险匹配流程。

不是每个任务
都是抢七。

同一个编码 Agent,日常任务轻装上阵;高风险任务才加计划、验证和审查。针对最新模型自带审查的特点,mancode 会限制 review 轮次,同时给不主动审查的模型保留质量门槛。

日常训练

solo

一次受限 diff 自检、最窄验证,不调用额外 reviewer。

真实诊断

/manba

复现、定位根因、驱动真实路径并回归验证。

团队协作

/manteam

共享项目记忆、决策记录与协作约定。

季前赛

/manps

检测陈旧 TODO、依赖风险和硬编码设计值。

+
02 / 五档强度按风险匹配流程。

不是每个任务
都是抢七。

同一个编码 Agent,日常任务轻装上阵;需要正式方案时先用 /man 对齐需求和计划,再选择 solo 轻量开发或完整治理。针对最新模型自带审查的特点,mancode 会限制 review 轮次,同时给不主动审查的模型保留质量门槛。

日常训练

solo

一次受限 diff 自检、最窄验证,不调用额外 reviewer。

真实诊断

/manba

复现、定位根因、驱动真实路径并回归验证。

团队协作

/manteam

共享项目记忆、决策记录与协作约定。

季前赛

/manps

检测陈旧 TODO、依赖风险和硬编码设计值。

-
03 / 季后赛模式九步、有界审查、可追溯。

/man 工作流

从球探报告
到终场哨响。

每次高风险任务都会在 .mancode/workflows/<taskId>/ 产生可读记录,让计划、决策、审查和结果都留在项目里。

  1. 01球探报告梳理代码、风险与未知项
  2. 02需求澄清确认真正要解决的问题
  3. 03计划输出可验证步骤
  4. 04计划关卡批准、修改或停止
  5. 06验证与范围验证后选择审查深度
  6. 07定向审查有证据的质量 finding
  7. 08风险防守高风险时审查安全边界
  8. 09收尾一轮修复、复验、停止
+
03 / 先计划再选择solo 交付或完整治理。

/man 工作流

从需求对齐
到确认计划。

每次规划都会在 .mancode/workflows/<taskId>/ 产生可读记录。计划确认后,可交给 solo 轻量开发,也可继续完整九步治理。

  1. 01球探报告梳理代码、风险与未知项
  2. 02需求澄清确认真正要解决的问题
  3. 03计划输出可验证步骤
  4. 04计划关卡solo、完整治理、仅计划或修改
  5. 06验证与范围验证后选择审查深度
  6. 07定向审查有证据的质量 finding
  7. 08风险防守高风险时审查安全边界
  8. 09收尾一轮修复、复验、停止
04 / 适配器保留你的 Agent,加入纪律。

一套战术。
你的工具。

mancode 是工作流层,不是新的编码 Agent。Claude Code 获得最完整的原生集成;其他工具获得持久 rules、skills 或 instruction 文件。

平台适配方式级别
Claude CodeHooks · skills · subagents完整集成
Cursor项目 rules · modes适配器
CodexAGENTS.md · 项目 skills适配器
GitHub Copilot仓库 instructions适配器
ZCodeAGENTS.md · 项目 skills适配器
@@ -46,6 +46,6 @@

简单任务少一点仪式。
关键任务多一点纪律。

别再替 AI
收拾臃肿。

> npm install -g mancode
GitHub 上查看 ↗
- +