From ba81a4a7045180e9bbfad74293a870bbfe3b7e19 Mon Sep 17 00:00:00 2001 From: MageByte Date: Wed, 15 Jul 2026 11:55:48 +0800 Subject: [PATCH] feat: recommend execution modes at DP-4 --- CHANGELOG.md | 4 +- INSTALL.md | 18 +- README.md | 23 ++- docs/README_en.md | 35 +++- docs/artifact-contract.md | 17 +- docs/state-machine.md | 16 +- scripts/install-cursor.mjs | 2 +- scripts/install-zcode.mjs | 2 +- scripts/lib/cmd-execution.mjs | 56 +++++- scripts/lib/cmd-inject.mjs | 7 +- scripts/lib/cmd-install-workbuddy.mjs | 2 +- scripts/lib/execution-plan.mjs | 83 +++++++- scripts/lib/execution-recommendation.mjs | 221 +++++++++++++++++++++ scripts/lib/install.mjs | 2 +- scripts/lib/sdd-overlay.mjs | 1 + scripts/spec-superflow.mjs | 8 +- skills/build-executor/SKILL.md | 23 ++- skills/workflow-start/SKILL.md | 4 +- templates/execution-contract.md | 17 +- tests/lib/cmd-execution.test.mjs | 164 ++++++++++++++- tests/lib/execution-control-plane.test.mjs | 15 +- tests/lib/execution-plan.test.mjs | 138 ++++++++++++- tests/lib/guard-specs-merged.test.mjs | 5 +- tests/lib/guard-tests-passing.test.mjs | 5 +- tests/lib/guard.test.mjs | 12 +- 25 files changed, 776 insertions(+), 104 deletions(-) create mode 100644 scripts/lib/execution-recommendation.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7f3cc..43c57ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ The format loosely follows Keep a Changelog. ## [Unreleased] -### Fixed +### Changed -- **#45 — Guarded execution control plane**: full/hotfix now default to SDD through a persisted execution plan at `/.superpowers/sdd/execution-plan.json`; inline and Batch Inline require an explicit override, named waves record dependencies and strategy, and closing requires current `pass` review receipts for every wave. +- **#45 — Execution-mode recommendation**: DP-4 now runs `ssf execution recommend` to list Inline, Batch Inline, and SDD from task and wave evidence, with a recommendation. Every persisted plan at `/.superpowers/sdd/execution-plan.json` requires explicit `--confirm`; a non-recommended selection records `--acknowledge-recommendation` instead of treating Inline as an override. Named waves, dependencies, and review receipts remain mandatory. ## [0.9.0] - 2026-07-11 diff --git a/INSTALL.md b/INSTALL.md index b63bef5..dab13dc 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -688,17 +688,27 @@ changes// full/hotfix 在 DP-4 必须保存 current execution plan 到 `/.superpowers/sdd/execution-plan.json`;它不属于 `execution-contract.md`。 -SDD 是 default。只有用户明确选择 explicit override,才可改为 `inline` 或 -`batch-inline`;Batch Inline 始终串行,不会自动成为默认模式或表示并行。`tweak` +先运行 `ssf execution recommend`:它按任务量和 wave 策略列出 `inline`、 +`batch-inline`、`sdd` 并给出推荐,并保存当前 wave 的推荐凭据到 +`/.superpowers/sdd/execution-recommendation.json`。Agent 展示候选项和理由后, +`plan` 与 `revise` 必须消费匹配当前 artifact、contract 和 wave 的凭据;用户用 `--confirm` +确认;若选择非推荐方式,必须用 `--acknowledge-recommendation` 记录风险确认。Batch +Inline 始终串行,不会表示并行。`tweak` 免除 execution plan 与 review receipt gate。 ```bash -ssf execution plan changes/my-change --mode sdd --reason "independent work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution plan changes/my-change --mode sdd --confirm --reason "independent work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation ssf execution show changes/my-change --json # 可将已有 inline/batch-inline 计划升级为 sdd,或重规划已有 sdd 的 wave/依赖;不能降级。 -ssf execution revise changes/my-change --mode sdd --reason "need parallel work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution revise changes/my-change --mode sdd --confirm --reason "need parallel work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation ssf execution review changes/my-change --wave foundation --base --head \ diff --git a/README.md b/README.md index eaaa45c..127a19f 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ npx spec-superflow list # 或通过 npx 使用 | `ssf handoff list ` | 列出 handoff 生命周期状态 | | `ssf handoff finish ` | 校验 handoff 结果 | | `ssf handoff resolve --decision ` | 记录显式 handoff 决策 | -| `ssf execution plan ...` | 为 full/hotfix 在 `/.superpowers/sdd/execution-plan.json` 记录受 guard 保护的执行计划(SDD 默认) | +| `ssf execution recommend ...` | 基于任务量、wave 和工作流列出可用执行方式并给出推荐 | +| `ssf execution plan ...` | 在用户确认选择后,为 full/hotfix 保存受 guard 保护的执行计划 | | `ssf execution show [--json]` | 查看并校验当前执行计划、wave 与 receipt | | `ssf execution revise ...` | 将已有计划保留/升级为 SDD,并生成新 revision;不允许降级 | | `ssf execution review ...` | 为一个计划 wave 记录 review receipt | @@ -191,18 +192,28 @@ Delta spec 的规范路径是 `specs//spec.md`;扁平的 `specs//.superpowers/sdd/execution-plan.json`,不写入 -`execution-contract.md`。SDD 是 default;只有用户给出 explicit override 时,才能选择 -`inline` 或 `batch-inline`。后者始终是串行模式,绝不自动成为默认或冒充并行。 +`execution-contract.md`。先运行 `ssf execution recommend`,它会根据任务量和 wave +策略列出 `inline`、`batch-inline`、`sdd`,并给出可审计的推荐理由,同时把当前 wave 的 +推荐凭据保存为 `/.superpowers/sdd/execution-recommendation.json`;Agent 必须将这些 +候选项和推荐展示给用户。`plan` 或 `revise` 只接受匹配当前 artifact、contract 和 wave 的 +凭据。用户用 `--confirm` 明确确认选择;若选择与推荐不同,必须额外 +传入 `--acknowledge-recommendation` 记录已知风险。Batch Inline 始终串行,绝不冒充并行。 `tweak` 保持轻量例外,不要求 execution plan 或 wave receipt。 ```bash -ssf execution plan changes/my-change --mode sdd --reason "independent work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution plan changes/my-change --mode sdd --confirm --reason "independent work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation ssf execution show changes/my-change --json # 将已有 inline/batch-inline 计划升级为 sdd,或重规划已有 sdd 计划;不能降级。 # 每次修订都会生成新 revision 并清除旧 review receipt。 -ssf execution revise changes/my-change --mode sdd --reason "need parallel work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution revise changes/my-change --mode sdd --confirm --reason "need parallel work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation # 每个 wave 都先写入非空 review report,再记录 receipt。 @@ -361,7 +372,7 @@ ssf config --resolve-model mechanical
SDD (Subagent-Driven Development) 怎么工作的? -full/hotfix 默认 SDD:先在 `/.superpowers/sdd/execution-plan.json` 保存带依赖和策略的 execution plan,再按可执行 wave 派实施子代理;每个 wave 先有 review report,再写 `pass`/`fail` review receipt。只有用户 explicit override 才能选择 inline 或 Batch Inline;Batch Inline 仍是串行。进度台账防止会话压缩后丢失进度。 +full/hotfix 先由 `ssf execution recommend` 根据任务量和 wave 策略列出 Inline、Batch Inline、SDD 并推荐一种;Agent 展示候选项和理由,用户以 `--confirm` 确认后才保存 plan。若选择非推荐方式,`--acknowledge-recommendation` 会记录风险确认。SDD 按可执行 wave 派实施子代理;每个 wave 先有 review report,再写 `pass`/`fail` review receipt。Batch Inline 仍是串行。进度台账防止会话压缩后丢失进度。
diff --git a/docs/README_en.md b/docs/README_en.md index 62f88cd..4771e91 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -234,19 +234,30 @@ You: "add authorization to the API" For full/hotfix, DP-4 is a persisted, current execution plan at `/.superpowers/sdd/execution-plan.json`, rather than an arbitrary text -field or content stored in `execution-contract.md`. SDD is the default. -`inline` and `batch-inline` require an explicit user override; Batch Inline -remains serial and is never an automatic default or a claim of parallel work. +field or content stored in `execution-contract.md`. Run `ssf execution recommend` +first: it lists `inline`, `batch-inline`, and `sdd` from task count and wave +strategy, with auditable reasons, and saves a recommendation receipt at +`/.superpowers/sdd/execution-recommendation.json`. The agent presents that recommendation and the +user records a choice with `--confirm`; `plan` and `revise` require a receipt matching the current +artifacts, contract, and waves. A non-recommended choice also requires +`--acknowledge-recommendation`. Batch Inline remains serial and never claims +parallel work. `tweak` is exempt from this execution-plan and review-receipt gate. ```bash -ssf execution plan changes/my-change --mode sdd --reason "independent work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution plan changes/my-change --mode sdd --confirm --reason "independent work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation ssf execution show changes/my-change --json # Retains/upgrades an existing plan as sdd; it can replan waves and dependencies, # creates a new revision, and clears old review receipts. Downgrades are rejected. -ssf execution revise changes/my-change --mode sdd --reason "need parallel work" \ +ssf execution recommend changes/my-change \ + --wave foundation:parallel:1.1,1.2 \ + --wave integration:serial:2.1:foundation --json +ssf execution revise changes/my-change --mode sdd --confirm --reason "need parallel work" \ --wave foundation:parallel:1.1,1.2 \ --wave integration:serial:2.1:foundation ssf execution review changes/my-change --wave foundation --base --head \ @@ -330,12 +341,14 @@ Content-level detection, not timestamps: proposal scope changed, approved spec b
How does SDD (Subagent-Driven Development) work? -For full/hotfix, the default SDD loop persists an execution plan at -`/.superpowers/sdd/execution-plan.json` with named waves, dependencies, -and strategies before dispatching implementers. Each wave gets a review report -and a `pass`/`fail` review receipt. Inline and Batch Inline require an explicit -user override; Batch Inline remains serial. The progress ledger prevents -session-compression loss. +For full/hotfix, `ssf execution recommend` first presents Inline, Batch Inline, +and SDD with evidence from the change, then recommends one. The user confirms a +selection with `--confirm`; a different selection requires +`--acknowledge-recommendation`. The saved execution plan at +`/.superpowers/sdd/execution-plan.json` names waves, dependencies, and +strategies before dispatching implementers. Each wave gets a review report and a +`pass`/`fail` receipt. Batch Inline remains serial, and the progress ledger +prevents session-compression loss.
diff --git a/docs/artifact-contract.md b/docs/artifact-contract.md index 13fa4e9..69ece26 100644 --- a/docs/artifact-contract.md +++ b/docs/artifact-contract.md @@ -57,15 +57,20 @@ Defines: - review gates and their review receipts - escalation rules -For full/hotfix, SDD is the default execution mode. `inline` and -`batch-inline` require an explicit user override; Batch Inline remains serial -and is never an automatic default. After approval, `ssf execution plan` writes +For full/hotfix, `ssf execution recommend` lists applicable execution modes and +recommends one from task count and wave strategy, and persists a recommendation +receipt at `/.superpowers/sdd/execution-recommendation.json`. `plan` +and `revise` require the receipt to match the current artifacts, contract, and +waves. The user confirms the selected mode with `--confirm`; a non-recommended mode additionally requires +`--acknowledge-recommendation`. Batch Inline remains serial. After approval, +`ssf execution plan` writes the persisted execution plan to `/.superpowers/sdd/execution-plan.json`. That JSON records each wave's dependencies and parallel/serial strategy; it is not stored in `execution-contract.md`. A current `pass` review receipt is required for every wave before dependent work or closing proceeds. `tweak` is exempt from execution-plan and review-receipt gates. `ssf execution revise` -retains or upgrades an existing plan as `sdd`, creates a new revision, and +retains or upgrades an existing plan as `sdd`, requires fresh confirmation, +creates a new revision, and clears prior review receipts; it never permits a downgrade. #47 slash commands for recovery, switching, and manual save are not implemented, so `/ssf:*` commands must not be claimed. @@ -86,6 +91,6 @@ Implementation starts only after: - planning artifacts exist - `execution-contract.md` exists - the user approves the execution contract -- full/hotfix have a current `ssf execution plan` with SDD as the default or an - explicit override +- full/hotfix have a current `ssf execution plan` with a user-confirmed mode and + persisted recommendation evidence - every completed wave records a current `pass` review receipt before closing diff --git a/docs/state-machine.md b/docs/state-machine.md index 16c9551..1dc4cc7 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -43,9 +43,14 @@ For full/hotfix, DP-4 is the persisted execution plan created by `ssf execution plan` at `/.superpowers/sdd/execution-plan.json`, not an arbitrary -state value or content stored in `execution-contract.md`. SDD is the default. `inline` and -`batch-inline` require an explicit user override; Batch Inline remains serial -and is never an automatic default or a substitute for parallel execution. +state value or content stored in `execution-contract.md`. Before planning, run +`ssf execution recommend`; it lists applicable `inline`, `batch-inline`, and +`sdd` modes with evidence and one recommendation, and persists a receipt at +`/.superpowers/sdd/execution-recommendation.json`. `plan` and `revise` +accept only a receipt whose artifacts, contract, and waves still match. The user must record the +selected mode with `--confirm`; a non-recommended selection also requires +`--acknowledge-recommendation`. Batch Inline remains serial and is never a +substitute for parallel execution. `tweak` is exempt from execution-plan and review-receipt requirements. The plan names ordered execution waves, dependencies, and parallel/serial @@ -53,8 +58,9 @@ strategy. `ssf execution show --json` reports which current waves are eligible. Each completed wave must have a current `pass` review receipt, recorded with `ssf execution review`, before a dependent wave or `closing` can proceed. `ssf execution revise` retains or upgrades an -existing plan as `sdd`; that new revision invalidates old review receipts and -does not permit a downgrade. #47 recovery/switch/save +existing plan as `sdd`; that new revision requires a fresh confirmation (and +acknowledgement when it differs from the new recommendation), invalidates old +review receipts, and does not permit a downgrade. #47 recovery/switch/save slash commands are not implemented; do not assume `/ssf:*` commands exist. ### `debugging` diff --git a/scripts/install-cursor.mjs b/scripts/install-cursor.mjs index ffa6bdc..0bb6736 100644 --- a/scripts/install-cursor.mjs +++ b/scripts/install-cursor.mjs @@ -149,7 +149,7 @@ alwaysApply: true - DP-1:需求确认 - DP-2:工件审查 - DP-3:是否批准 execution contract? -- DP-4:full/hotfix 默认 SDD;Inline / Batch Inline 只允许用户明确 override +- DP-4:先运行 ssf execution recommend,展示可用模式与推荐;用户用 --confirm 确认,非推荐选择额外使用 --acknowledge-recommendation - DP-5:调试升级 - DP-6:验证失败 - DP-7:是否收口归档? diff --git a/scripts/install-zcode.mjs b/scripts/install-zcode.mjs index 85d6dc7..68167a0 100644 --- a/scripts/install-zcode.mjs +++ b/scripts/install-zcode.mjs @@ -149,7 +149,7 @@ alwaysApply: true - DP-1:需求确认 - DP-2:工件审查 - DP-3:是否批准 execution contract? -- DP-4:full/hotfix 默认 SDD;Inline / Batch Inline 只允许用户明确 override +- DP-4:先运行 ssf execution recommend,展示可用模式与推荐;用户用 --confirm 确认,非推荐选择额外使用 --acknowledge-recommendation - DP-5:调试升级 - DP-6:验证失败 - DP-7:是否收口归档? diff --git a/scripts/lib/cmd-execution.mjs b/scripts/lib/cmd-execution.mjs index 2e87982..f769b71 100644 --- a/scripts/lib/cmd-execution.mjs +++ b/scripts/lib/cmd-execution.mjs @@ -1,8 +1,13 @@ import { parseArgs } from 'node:util'; import { createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, validatePlan, writePlan } from './execution-plan.mjs'; +import { + createRecommendationReceipt, + readCurrentRecommendationReceipt, + writeRecommendationReceipt, +} from './execution-recommendation.mjs'; import { readState, writeState } from './state-loader.mjs'; -const SUBCOMMANDS = ['plan', 'show', 'revise', 'review']; +const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review']; export async function run(args) { const { positionals, values } = parseArgs({ @@ -11,7 +16,8 @@ export async function run(args) { mode: { type: 'string' }, reason: { type: 'string' }, wave: { type: 'string', multiple: true }, - override: { type: 'boolean', default: false }, + confirm: { type: 'boolean', default: false }, + 'acknowledge-recommendation': { type: 'boolean', default: false }, base: { type: 'string' }, head: { type: 'string' }, report: { type: 'string' }, @@ -32,6 +38,8 @@ export async function run(args) { if (!changeDir) usage('Usage: ssf execution [options]'); switch (subcommand) { + case 'recommend': + return recommendAndPrint(changeDir, values); case 'plan': return createAndPrintPlan(changeDir, values, false); case 'show': @@ -57,19 +65,35 @@ function createAndPrintPlan(changeDir, values, revise) { } else if (existing) { throw new Error('An execution plan already exists; use "ssf execution revise" to create a new SDD revision'); } + const recommendationReceipt = readCurrentRecommendationReceipt(changeDir, waves, revise ? existing.revision : null); + const recommendation = recommendationReceipt.recommendation; + if (!recommendation.available_modes.includes(values.mode)) { + throw new Error(`${values.mode} is not an available execution mode for this change; choose one of: ${recommendation.available_modes.join(', ')}`); + } - if (values.override && values.mode === 'sdd') { - throw new Error('--override is only valid for inline or batch-inline execution'); + if (!values.confirm) { + throw new Error('Execution mode selection requires --confirm after reviewing "ssf execution recommend" output'); + } + const followedRecommendation = values.mode === recommendation.recommendation.mode; + if (!followedRecommendation && !values['acknowledge-recommendation']) { + throw new Error(`${values.mode} differs from the ${recommendation.recommendation.mode} recommendation; pass --acknowledge-recommendation to record the informed choice`); } - if (!values.override && values.mode !== 'sdd') { - throw new Error(`${values.mode} requires --override to record an explicit user override`); + if (followedRecommendation && values['acknowledge-recommendation']) { + throw new Error('--acknowledge-recommendation is only valid when selecting a non-recommended mode'); } const plan = createPlan(changeDir, { mode: values.mode, - source: values.override ? 'user-override' : 'default', + source: revise ? 'user-confirmed-revision' : 'user-confirmed', rationale: values.reason, waves, + recommendation, + recommendationReceipt, + selection: { + confirmed: true, + followed_recommendation: followedRecommendation, + acknowledged_non_recommendation: !followedRecommendation, + }, revision: revise ? existing.revision + 1 : undefined, }); const saved = writePlan(changeDir, plan); @@ -77,6 +101,19 @@ function createAndPrintPlan(changeDir, values, revise) { print(values.json, { ok: true, plan: saved }, `Execution plan revision ${saved.revision} recorded (${saved.mode}).`); } +function recommendAndPrint(changeDir, values) { + const waves = values.wave?.length ? parseWaves(values.wave) : []; + const receipt = writeRecommendationReceipt(changeDir, createRecommendationReceipt(changeDir, waves)); + const recommendation = receipt.recommendation; + const lines = [ + 'Available execution modes:', + ...recommendation.available_modes.map(mode => `- ${mode}`), + `Recommended: ${recommendation.recommendation.mode}`, + ...recommendation.recommendation.reasons.map(reason => `- ${reason}`), + ]; + print(values.json, { ok: true, recommendation, receipt }, lines.join('\n')); +} + function showPlan(changeDir, json) { const plan = readPlan(changeDir); if (!plan) throw new Error('No execution plan has been recorded'); @@ -161,8 +198,9 @@ function print(json, value, message) { function printHelp() { console.log(`Usage: - ssf execution plan --mode --reason --wave ::[:] [--override] + ssf execution recommend [--wave ::[:]] [--json] + ssf execution plan --mode --confirm --reason --wave ::[:] [--acknowledge-recommendation] ssf execution show [--json] - ssf execution revise --mode sdd --reason --wave ::[:] + ssf execution revise --mode sdd --confirm --reason --wave ::[:] [--acknowledge-recommendation] ssf execution review --wave --base --head --report --verdict pass|fail`); } diff --git a/scripts/lib/cmd-inject.mjs b/scripts/lib/cmd-inject.mjs index c7ea95f..bc478b0 100644 --- a/scripts/lib/cmd-inject.mjs +++ b/scripts/lib/cmd-inject.mjs @@ -59,8 +59,9 @@ const PHASE_TEMPLATES = { **当前阶段**: {{state}} | **工作流**: {{workflow}} ## ✅ 允许操作 -- 选择执行模式(默认 SDD 或用户明确 override) -- 运行 ssf execution plan ... 生成 execution plan +- 运行 ssf execution recommend [--wave ...],完成 DP-4 执行模式选择,列出可用模式与推荐并写入当前推荐凭据 +- 向用户展示候选项、项目事实与推荐,取得明确选择 +- 运行 ssf execution plan --mode --confirm ... 生成 execution plan;它必须使用匹配当前 artifact、contract 和 wave 的推荐凭据,非推荐选择需 --acknowledge-recommendation - 运行 ssf execution show --json 核对 current、revision、mode、waves[].eligible 和 receipts - 准备执行环境 @@ -70,7 +71,7 @@ const PHASE_TEMPLATES = { - 修改 proposal.md, specs/, design.md, tasks.md ## 🔔 决策点 -- DP-4: full/hotfix 默认 SDD;Inline/Batch Inline 仅限用户明确 override。仅当 execution show 报告 current: true 后才可转换到 executing;tweak 免除此 plan/receipt gate`, +- DP-4 执行模式选择:先运行 execution recommend 并展示可用模式和推荐;用户用 --confirm 确认,非推荐选择额外使用 --acknowledge-recommendation。plan/revise 需要当前匹配的推荐凭据。仅当 execution show 报告 current: true 后才可转换到 executing;tweak 免除此 plan/receipt gate`, 'executing': `# Phase Guard: {{change_name}} diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index b0c72d5..918aeac 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -136,7 +136,7 @@ function phaseGuardContent() { - DP-1:需求确认 - DP-2:工件审查 - DP-3:是否批准 execution contract? -- DP-4:full/hotfix 默认 SDD;Inline / Batch Inline 只允许用户明确 override +- DP-4:先运行 ssf execution recommend,展示可用模式与推荐;用户用 --confirm 确认,非推荐选择额外使用 --acknowledge-recommendation - DP-5:调试升级 - DP-6:验证失败 - DP-7:是否收口归档? diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index b8958f9..6d6b9b2 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -23,6 +23,9 @@ export function createPlan(changeDir, input) { workflow: state.workflow, revision: input?.revision ?? state.revision ?? 1, }; + if (input?.recommendation !== undefined) plan.recommendation = input.recommendation; + if (input?.recommendationReceipt !== undefined) plan.recommendation_receipt = input.recommendationReceipt; + if (input?.selection !== undefined) plan.selection = input.selection; const failures = validateStructure(plan); if (failures.length > 0) throw new Error(`Invalid execution plan: ${failures.join('; ')}`); plan.hash = hashPlan(plan); @@ -87,6 +90,29 @@ export function validatePlan(changeDir, plan) { if (state.revision != null && plan?.revision !== state.revision) { failures.push('execution plan revision does not match state'); } + if (plan?.workflow !== 'tweak') { + if (plan?.recommendation === undefined) failures.push('execution plan recommendation is required for full/hotfix'); + if (plan?.recommendation_receipt === undefined) failures.push('execution plan recommendation receipt is required for full/hotfix'); + if (plan?.selection === undefined) failures.push('execution plan selection is required for full/hotfix'); + } + if (plan?.recommendation_receipt !== undefined) { + if (plan.recommendation_receipt.artifacts_hash !== plan.artifacts_hash) failures.push('execution plan recommendation receipt artifacts hash does not match plan'); + if (plan.recommendation_receipt.contract_hash !== plan.contract_hash) failures.push('execution plan recommendation receipt contract hash does not match plan'); + if (plan.recommendation_receipt.workflow !== plan.workflow) failures.push('execution plan recommendation receipt workflow does not match plan'); + if (stableJson(plan.recommendation_receipt.waves) !== stableJson(plan.waves)) failures.push('execution plan recommendation receipt waves do not match plan'); + if (stableJson(plan.recommendation_receipt.recommendation) !== stableJson(plan.recommendation)) failures.push('execution plan recommendation receipt does not match plan recommendation'); + const expectedRecommendationPlanRevision = plan.source === 'user-confirmed-revision' + ? plan.revision - 1 + : null; + if (plan.recommendation_receipt.execution_plan_revision_at_recommendation !== expectedRecommendationPlanRevision) { + failures.push('execution plan recommendation receipt does not match the immediately prior plan revision'); + } + } + if (plan?.selection !== undefined && plan?.recommendation?.recommendation?.mode) { + const followedRecommendation = plan.mode === plan.recommendation.recommendation.mode; + if (plan.selection.followed_recommendation !== followedRecommendation) failures.push('execution plan selection does not match recommended mode'); + if (plan.selection.acknowledged_non_recommendation !== !followedRecommendation) failures.push('execution plan selection acknowledgement does not match recommended mode'); + } return { valid: failures.length === 0, failures, plan }; } @@ -269,8 +295,51 @@ function validateStructure(plan) { if (!EXECUTION_MODES.includes(plan.mode)) failures.push('execution plan mode is invalid'); if (typeof plan.source !== 'string' || !plan.source.trim()) failures.push('execution plan source is required'); if (!isNonEmptyText(plan.rationale)) failures.push('execution plan rationale is required'); - if (plan.mode !== 'sdd' && plan.source !== 'user-override') { - failures.push(`${plan.mode || 'execution plan'} requires an explicit user override`); + if (plan.recommendation !== undefined) { + if (!isObject(plan.recommendation)) { + failures.push('execution plan recommendation must be an object'); + } else { + if (!isObject(plan.recommendation.recommendation) || !EXECUTION_MODES.includes(plan.recommendation.recommendation.mode)) { + failures.push('execution plan recommendation mode is invalid'); + } + if (!Array.isArray(plan.recommendation.recommendation?.reasons) || plan.recommendation.recommendation.reasons.some(reason => !isNonEmptyText(reason))) { + failures.push('execution plan recommendation reasons must be non-empty strings'); + } + if (!isObject(plan.recommendation.facts)) failures.push('execution plan recommendation facts must be an object'); + if (!Array.isArray(plan.recommendation.available_modes) || plan.recommendation.available_modes.some(mode => !EXECUTION_MODES.includes(mode))) { + failures.push('execution plan recommendation available_modes are invalid'); + } + } + } + if (plan.recommendation_receipt !== undefined) { + if (!isObject(plan.recommendation_receipt)) { + failures.push('execution plan recommendation receipt must be an object'); + } else { + if (!isObject(plan.recommendation_receipt.recommendation)) failures.push('execution plan recommendation receipt payload is required'); + if (!Array.isArray(plan.recommendation_receipt.waves)) failures.push('execution plan recommendation receipt waves must be an array'); + if (!isNullableHash(plan.recommendation_receipt.artifacts_hash)) failures.push('execution plan recommendation receipt artifacts hash is invalid'); + if (!isNullableHash(plan.recommendation_receipt.contract_hash)) failures.push('execution plan recommendation receipt contract hash is invalid'); + if (!isNonEmptyText(plan.recommendation_receipt.workflow)) failures.push('execution plan recommendation receipt workflow is invalid'); + if (plan.recommendation_receipt.execution_plan_revision_at_recommendation !== null + && (!Number.isInteger(plan.recommendation_receipt.execution_plan_revision_at_recommendation) + || plan.recommendation_receipt.execution_plan_revision_at_recommendation < 1)) { + failures.push('execution plan recommendation receipt plan revision is invalid'); + } + if (!isNonEmptyText(plan.recommendation_receipt.created_at)) failures.push('execution plan recommendation receipt timestamp is invalid'); + if (typeof plan.recommendation_receipt.hash !== 'string' || !plan.recommendation_receipt.hash.startsWith('sha256:')) failures.push('execution plan recommendation receipt hash is invalid'); + } + } + if (plan.selection !== undefined) { + if (!isObject(plan.selection)) { + failures.push('execution plan selection must be an object'); + } else { + if (plan.selection.confirmed !== true) failures.push('execution plan selection must be confirmed'); + if (typeof plan.selection.followed_recommendation !== 'boolean') failures.push('execution plan selection must record recommendation alignment'); + if (typeof plan.selection.acknowledged_non_recommendation !== 'boolean') failures.push('execution plan selection must record non-recommendation acknowledgement'); + if (plan.selection.followed_recommendation === plan.selection.acknowledged_non_recommendation) { + failures.push('execution plan selection acknowledgement conflicts with recommendation alignment'); + } + } } if (!Array.isArray(plan.waves)) { failures.push('execution plan waves must be an array'); @@ -363,7 +432,11 @@ function stableJson(value, seen = new WeakSet()) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (seen.has(value)) throw new Error('circular plan data'); seen.add(value); - if (Array.isArray(value)) return `[${value.map(item => stableJson(item, seen)).join(',')}]`; + if (Array.isArray(value)) { + const result = `[${value.map(item => stableJson(item, seen)).join(',')}]`; + seen.delete(value); + return result; + } const result = `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`; seen.delete(value); return result; @@ -406,6 +479,10 @@ function isNonEmptyText(value) { return typeof value === 'string' && value.trim().length > 0; } +function isNullableHash(value) { + return value === null || (typeof value === 'string' && value.startsWith('sha256:')); +} + function requireText(value, field) { if (!isNonEmptyText(value)) throw new Error(`${field} is required`); } diff --git a/scripts/lib/execution-recommendation.mjs b/scripts/lib/execution-recommendation.mjs new file mode 100644 index 0000000..a12962e --- /dev/null +++ b/scripts/lib/execution-recommendation.mjs @@ -0,0 +1,221 @@ +// Evidence-based execution-mode recommendation for DP-4. + +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from './config-loader.mjs'; +import { computeArtifactsHash, computeContractHash } from './hash.mjs'; +import { getOverlayPaths } from './sdd-overlay.mjs'; +import { readState } from './state-loader.mjs'; + +export function recommendExecutionModes({ workflow, taskCount, inlineThreshold, waves = [] }) { + const normalizedWorkflow = typeof workflow === 'string' ? workflow : null; + const normalizedTaskCount = Number.isInteger(taskCount) && taskCount >= 0 ? taskCount : null; + const threshold = Number.isInteger(inlineThreshold) && inlineThreshold > 0 ? inlineThreshold : 3; + const normalizedWaves = Array.isArray(waves) ? waves : []; + const plannedTaskCount = normalizedWaves.reduce((count, wave) => ( + count + (Array.isArray(wave?.tasks) ? wave.tasks.length : 0) + ), 0); + const hasParallelWave = normalizedWaves.some(wave => wave?.strategy === 'parallel'); + const hasMultipleWaves = normalizedWaves.length > 1; + const facts = { + workflow: normalizedWorkflow, + documented_task_count: normalizedTaskCount, + planned_task_count: plannedTaskCount, + planned_wave_count: normalizedWaves.length, + has_parallel_wave: hasParallelWave, + inline_threshold: threshold, + }; + + if (normalizedWorkflow === 'tweak') { + return result(['inline'], 'inline', [ + 'Tweak uses direct inline execution and is exempt from execution-plan and review-receipt gates.', + ], facts); + } + + if (hasParallelWave) { + return result(['inline', 'batch-inline', 'sdd'], 'sdd', [ + 'The declared execution waves include parallel work, which SDD can dispatch and review independently.', + ], facts); + } + + if (hasMultipleWaves) { + return result(['inline', 'batch-inline', 'sdd'], 'sdd', [ + 'The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit.', + ], facts); + } + + const effectiveTaskCount = plannedTaskCount || normalizedTaskCount; + if (effectiveTaskCount === 1) { + return result(['inline', 'batch-inline', 'sdd'], 'inline', [ + 'The change has a single sequential task, so inline execution keeps the work focused without dispatch overhead.', + ], facts); + } + + if (effectiveTaskCount !== null && effectiveTaskCount > 1 && effectiveTaskCount <= threshold) { + return result(['inline', 'batch-inline', 'sdd'], 'batch-inline', [ + `The ${effectiveTaskCount} sequential tasks are within the configured inline threshold of ${threshold}.`, + ], facts); + } + + return result(['inline', 'batch-inline', 'sdd'], 'sdd', [ + effectiveTaskCount === null + ? 'The task count cannot be determined, so SDD is recommended until the execution shape is made explicit.' + : `The ${effectiveTaskCount} tasks exceed the configured inline threshold of ${threshold}.`, + ], facts); +} + +export function recommendExecutionModesForChange(changeDir, waves = []) { + const state = readState(changeDir); + const config = loadConfig(changeDir); + return recommendExecutionModes({ + workflow: state.workflow, + taskCount: countDocumentedTasks(changeDir), + inlineThreshold: config.execution.inlineThreshold, + waves, + }); +} + +export function createRecommendationReceipt(changeDir, waves = []) { + const state = readState(changeDir); + const receipt = { + recommendation: recommendExecutionModesForChange(changeDir, waves), + waves: normalizeWaves(waves), + artifacts_hash: computeArtifactsHash(changeDir), + contract_hash: computeContractHash(changeDir), + workflow: state.workflow, + execution_plan_revision_at_recommendation: state.execution_plan_revision ?? null, + created_at: new Date().toISOString(), + }; + receipt.hash = hashReceipt(receipt); + return receipt; +} + +export function writeRecommendationReceipt(changeDir, receipt) { + const failures = validateRecommendationReceiptStructure(receipt); + if (failures.length > 0) throw new Error(`Invalid execution recommendation: ${failures.join('; ')}`); + const paths = getOverlayPaths(changeDir); + mkdirSync(paths.root, { recursive: true }); + atomicWrite(paths.executionRecommendation, `${JSON.stringify(receipt, null, 2)}\n`); + return readRecommendationReceipt(changeDir); +} + +export function readRecommendationReceipt(changeDir) { + const filePath = getOverlayPaths(changeDir).executionRecommendation; + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Unable to read execution recommendation: ${error.message}`); + } +} + +export function readCurrentRecommendationReceipt(changeDir, waves = [], expectedPlanRevision = null) { + const receipt = readRecommendationReceipt(changeDir); + if (!receipt) { + throw new Error('Run "ssf execution recommend" for these waves before confirming an execution plan'); + } + const failures = validateRecommendationReceipt(changeDir, receipt, waves, expectedPlanRevision); + if (failures.length > 0) { + throw new Error(`Run "ssf execution recommend" again before confirming an execution plan: ${failures.join('; ')}`); + } + return receipt; +} + +export function validateRecommendationReceipt(changeDir, receipt, waves = [], expectedPlanRevision = null) { + const failures = validateRecommendationReceiptStructure(receipt); + if (receipt?.artifacts_hash !== computeArtifactsHash(changeDir)) failures.push('recommendation artifacts hash is stale'); + if (receipt?.contract_hash !== computeContractHash(changeDir)) failures.push('recommendation contract hash is stale'); + if (receipt?.workflow !== readState(changeDir).workflow) failures.push('recommendation workflow does not match state'); + if (stableJson(receipt?.waves) !== stableJson(normalizeWaves(waves))) failures.push('recommendation waves do not match the proposed execution plan'); + if (receipt?.execution_plan_revision_at_recommendation !== expectedPlanRevision) { + failures.push('recommendation was not generated after the current execution plan revision'); + } + return failures; +} + +function countDocumentedTasks(changeDir) { + const tasksPath = join(changeDir, 'tasks.md'); + if (!existsSync(tasksPath)) return null; + return (readFileSync(tasksPath, 'utf8').match(/^- \[[ xX]\] /gm) || []).length; +} + +function result(availableModes, mode, reasons, facts) { + return { + available_modes: availableModes, + recommendation: { mode, reasons }, + facts, + }; +} + +function validateRecommendationReceiptStructure(receipt) { + const failures = []; + if (!isObject(receipt)) return ['execution recommendation must be an object']; + if (!isObject(receipt.recommendation)) failures.push('execution recommendation payload is required'); + else { + const recommendation = receipt.recommendation; + if (!Array.isArray(recommendation.available_modes) || recommendation.available_modes.some(mode => !['inline', 'batch-inline', 'sdd'].includes(mode))) { + failures.push('execution recommendation available modes are invalid'); + } + if (!isObject(recommendation.recommendation) || !['inline', 'batch-inline', 'sdd'].includes(recommendation.recommendation.mode)) { + failures.push('execution recommendation mode is invalid'); + } + if (!Array.isArray(recommendation.recommendation?.reasons) || recommendation.recommendation.reasons.some(reason => typeof reason !== 'string' || !reason.trim())) { + failures.push('execution recommendation reasons are invalid'); + } + if (!isObject(recommendation.facts)) failures.push('execution recommendation facts are invalid'); + } + if (!Array.isArray(receipt.waves)) failures.push('execution recommendation waves are invalid'); + if (!isNullableHash(receipt.artifacts_hash)) failures.push('execution recommendation artifacts hash is invalid'); + if (!isNullableHash(receipt.contract_hash)) failures.push('execution recommendation contract hash is invalid'); + if (typeof receipt.workflow !== 'string' || !receipt.workflow.trim()) failures.push('execution recommendation workflow is invalid'); + if (receipt.execution_plan_revision_at_recommendation !== null + && (!Number.isInteger(receipt.execution_plan_revision_at_recommendation) || receipt.execution_plan_revision_at_recommendation < 1)) { + failures.push('execution recommendation plan revision is invalid'); + } + if (typeof receipt.created_at !== 'string' || !receipt.created_at.trim()) failures.push('execution recommendation timestamp is invalid'); + if (receipt.hash !== hashReceipt(receipt)) failures.push('execution recommendation hash mismatch'); + return failures; +} + +function normalizeWaves(waves) { + return Array.isArray(waves) ? waves.map(wave => ({ + id: wave?.id, + strategy: wave?.strategy, + tasks: Array.isArray(wave?.tasks) ? [...wave.tasks] : wave?.tasks, + depends_on: Array.isArray(wave?.depends_on) ? [...wave.depends_on] : wave?.depends_on, + })) : []; +} + +function hashReceipt(receipt) { + const { hash, ...content } = receipt || {}; + return `sha256:${createHash('sha256').update(stableJson(content)).digest('hex')}`; +} + +function stableJson(value, seen = new WeakSet()) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (seen.has(value)) throw new Error('circular recommendation data'); + seen.add(value); + if (Array.isArray(value)) { + const result = `[${value.map(item => stableJson(item, seen)).join(',')}]`; + seen.delete(value); + return result; + } + const result = `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`; + seen.delete(value); + return result; +} + +function atomicWrite(targetPath, content) { + const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(tempPath, content, 'utf8'); + renameSync(tempPath, targetPath); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNullableHash(value) { + return value === null || (typeof value === 'string' && value.startsWith('sha256:')); +} diff --git a/scripts/lib/install.mjs b/scripts/lib/install.mjs index ffb9dd6..8683f5f 100644 --- a/scripts/lib/install.mjs +++ b/scripts/lib/install.mjs @@ -142,7 +142,7 @@ function phaseGuardContent(rulesFormat, platformId) { - DP-1:需求确认 - DP-2:工件审查 - DP-3:是否批准 execution contract? -- DP-4:full/hotfix 默认 SDD;Inline / Batch Inline 只允许用户明确 override +- DP-4:先运行 ssf execution recommend,展示可用模式与推荐;用户用 --confirm 确认,非推荐选择额外使用 --acknowledge-recommendation - DP-5:调试升级 - DP-6:验证失败 - DP-7:是否收口归档? diff --git a/scripts/lib/sdd-overlay.mjs b/scripts/lib/sdd-overlay.mjs index 11b87c4..5541ba1 100644 --- a/scripts/lib/sdd-overlay.mjs +++ b/scripts/lib/sdd-overlay.mjs @@ -20,6 +20,7 @@ export function getOverlayPaths(changeDir) { checkpoints: join(root, 'checkpoints'), handoffs: join(root, 'handoffs'), executionPlan: join(root, 'execution-plan.json'), + executionRecommendation: join(root, 'execution-recommendation.json'), reviews: join(root, 'reviews'), }; } diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index 3fd4103..5e02f41 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -60,11 +60,13 @@ Commands: Validate a handoff result handoff resolve --decision Record the explicit handoff decision - execution plan --mode --reason --wave :: [--override] - Record a guarded execution plan (SDD is the default) + execution recommend [--wave ::] + List execution modes and an evidence-based recommendation + execution plan --mode --confirm --reason --wave :: [--acknowledge-recommendation] + Record a user-confirmed guarded execution plan execution show [--json] Show and validate the current execution plan - execution revise --mode sdd --reason --wave :: + execution revise --mode sdd --confirm --reason --wave :: [--acknowledge-recommendation] Upgrade inline/batch to SDD, or replan existing SDD waves, as a new revision execution review --wave --base --head --report --verdict pass|fail Record one review receipt for a planned wave diff --git a/skills/build-executor/SKILL.md b/skills/build-executor/SKILL.md index 558da03..c55808e 100644 --- a/skills/build-executor/SKILL.md +++ b/skills/build-executor/SKILL.md @@ -45,30 +45,35 @@ Return to `specifying` or `bridging` if: new behavior appears, interfaces change ## Execution Mode Selection -For `full`/`hotfix`, **SDD is the default execution mode**. Generate a machine-backed plan before deciding who edits code: +For `full`/`hotfix`, generate proposed waves from the approved contract, then use the recommendation as a decision aid rather than silently defaulting a mode: ```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/spec-superflow.mjs" execution recommend \ + --wave ::[:] --json +# Show every available mode, the observed facts, and the recommendation to the user. +# The command writes a receipt tied to the artifacts, contract, and waves. After the user chooses, record that explicit confirmation: node "${CLAUDE_PLUGIN_ROOT}/scripts/spec-superflow.mjs" execution plan \ - --mode sdd --reason "full/hotfix default SDD" \ + --mode --confirm --reason "user-selected execution mode" \ --wave ::[:] +# Add --acknowledge-recommendation when the selection differs from the recommendation. node "${CLAUDE_PLUGIN_ROOT}/scripts/spec-superflow.mjs" execution show --json ``` The optional fourth `--wave` segment names prerequisite wave IDs. `execution show --json` reports `current`, plus each wave's `depends_on`, `receipt`, `blockers`, `retryable`, and `eligible` status. A wave with `retryable: true` has a current `fail` receipt and is eligible only for its focused repair and re-review; its dependents remain blocked until its replacement `pass` receipt. Report the saved plan revision, selected mode, ordered waves, dependencies, and whether every `parallel` wave can actually be dispatched concurrently on the current platform. If concurrency is unavailable, state the capability and reason plainly; retain the planned `parallel` strategy and do not silently execute it as a serial or Batch Inline plan. -`inline` and `batch-inline` are available only through an **explicit user override**. Record that request with `--override`; task count, module locality, estimated effort, or `execution.inlineThreshold` never auto-selects either mode. +The recommendation uses task count, configured `execution.inlineThreshold`, and declared wave strategy. It never auto-selects: present every available mode and the recommendation to the user. `--confirm` records any user-selected mode; a choice that differs from the recommendation requires `--acknowledge-recommendation` so the plan captures an informed risk decision. | Mode | Criteria | |------|----------| -| **SDD** (default for full/hotfix) | Generated unless the user explicitly overrides it | -| **Inline** | Explicit user override for a small, sequential task set | -| **Batch Inline** | Explicit user override for a bounded batch; it remains serial and is never presented as parallel | +| **SDD** | Recommended for parallel waves, multiple waves, or work beyond the inline threshold | +| **Inline** | Recommended for a single sequential task; always available for a user-confirmed choice | +| **Batch Inline** | Recommended for a bounded sequential batch; it remains serial and is never presented as parallel | -Do not transition to `executing` until `execution show` reports `current: true` and the phase guard passes. A revised plan must use `ssf execution revise`; it creates a new revision and invalidates receipts from the prior revision. +Do not transition to `executing` until `execution show` reports `current: true` and the phase guard passes. A revised plan must repeat `execution recommend` and use `ssf execution revise --confirm`; it creates a new revision and invalidates receipts from the prior revision. ## Batch Inline Execution -Only when the user explicitly selects `batch-inline`. Current agent executes directly and serially. TDD Iron Law still applies. +Only when the user explicitly confirms `batch-inline` after seeing the recommendation. Current agent executes directly and serially. TDD Iron Law still applies. Procedure: announce mode → write failing test → confirm failure → implement → run suite → refactor → lightweight checkpoint (files exist, no placeholders, test passed, no unintended changes) → report. @@ -118,7 +123,7 @@ Track in `.superpowers/sdd/progress.md`. Check for existing ledger — completed ## Inline Execution Mode -Only after an explicit user override recorded by `ssf execution plan --override`. Executes in the current session and still writes one review receipt per planned wave. +Only after a user-confirmed `inline` selection is recorded by `ssf execution plan --confirm`; a non-recommended selection also records `--acknowledge-recommendation`. Executes in the current session and still writes one review receipt per planned wave. Per-task: extract brief → write failing test → confirm failure → implement → confirm green → checkpoint review (done-when criteria, SHALL/MUST verification) → commit → save a task-level recovery checkpoint when another task remains → append to progress ledger. diff --git a/skills/workflow-start/SKILL.md b/skills/workflow-start/SKILL.md index c64944a..edc5911 100644 --- a/skills/workflow-start/SKILL.md +++ b/skills/workflow-start/SKILL.md @@ -58,7 +58,7 @@ Guard: `node "${CLAUDE_PLUGIN_ROOT}/scripts/guard/guard.mjs" check explori Guard: `... check specifying bridging --json` → fail = BLOCK. Artifacts exist, implementation requested, contract missing/stale. Include `DP-3: 契约批准`. ### Route to build-executor -Contract exists and approved, contract matches artifacts. Include `DP-4: 执行模式选择`. Before the first implementation edit, `build-executor` must run `ssf execution plan ...`, then `ssf execution show --json`; report the saved revision, selected mode, ordered waves, and actual concurrent-dispatch capability. Do not transition to `executing` until `show` reports `current: true`; then run `... check approved-for-build executing --json` → fail = BLOCK. +Contract exists and approved, contract matches artifacts. Include `DP-4: 执行模式选择`: propose waves, run `ssf execution recommend [--wave ...]`, show the user every available mode plus evidence and the recommendation, then obtain a clear selection. The command saves a current receipt; before the first implementation edit, `build-executor` must run `ssf execution plan --mode --confirm ...` (and `--acknowledge-recommendation` when the selected mode differs from the recommendation) using matching artifacts, contract, and waves, then `ssf execution show --json`; report the saved revision, selected mode, recommendation alignment, ordered waves, and actual concurrent-dispatch capability. A revision must repeat recommend and confirmation. Do not transition to `executing` until `show` reports `current: true`; then run `... check approved-for-build executing --json` → fail = BLOCK. ### Route to bug-investigator Execution hit blockage: test failure, unexpected behavior, build error, task cannot proceed. After debugging, route back to build-executor. @@ -94,7 +94,7 @@ or internal-refactor work. Never pass `--force` to `ssf isolate` for prototype work. ### Fast-Path Routing -- **Hotfix**: Route to contract-builder (minimal), skip need-explorer + spec-writer, guard check `exploring bridging --workflow hotfix`, then `bridging -> approved-for-build`, after DP-3 → build-executor (default SDD plan), after → release-archivist (lightweight). Hotfix may skip `proposal.md`, `design.md`, `tasks.md`, and `specs/`, but it still requires a fresh minimal `execution-contract.md`, DP-3 approval, and a current execution plan before build +- **Hotfix**: Route to contract-builder (minimal), skip need-explorer + spec-writer, guard check `exploring bridging --workflow hotfix`, then `bridging -> approved-for-build`, after DP-3 → build-executor (recommend, show, and confirm an execution mode), after → release-archivist (lightweight). Hotfix may skip `proposal.md`, `design.md`, `tasks.md`, and `specs/`, but it still requires a fresh minimal `execution-contract.md`, DP-3 approval, and a current execution plan before build - **Tweak**: Route to build-executor (direct edit), skip need-explorer + spec-writer + contract-builder, guard check `exploring approved-for-build --workflow tweak`, after → release-archivist (lightweight) Post-transition: 💡 `node "${CLAUDE_PLUGIN_ROOT}/scripts/spec-superflow.mjs" inject ` to update phase-guard artifacts. diff --git a/templates/execution-contract.md b/templates/execution-contract.md index fb48946..0683607 100644 --- a/templates/execution-contract.md +++ b/templates/execution-contract.md @@ -22,8 +22,10 @@ ## Execution Plan -full/hotfix 默认使用 SDD。`inline` 或 `batch-inline` 只能由用户明确的 -explicit override 选择;Batch Inline 是串行模式,不得描述为自动默认或并行。批准后, +full/hotfix 先运行 `ssf execution recommend`,按任务量和 wave 策略列出可用方式并 +推荐一种,同时保存匹配当前 wave 的 recommendation receipt。Agent 展示候选项和理由, +`plan` 和 `revise` 均只接受仍匹配 artifact、contract 和 wave 的凭据;用户通过 `--confirm` 明确确认;选择非推荐方式时 +还必须记录 `--acknowledge-recommendation`。Batch Inline 是串行模式,不得描述为并行。批准后, `ssf execution plan` 会把当前执行计划保存到 `/.superpowers/sdd/execution-plan.json`;该 JSON 是计划的持久化控制面, 不是本 execution contract 的一部分。 @@ -66,11 +68,12 @@ wave 才可以开始。`parallel` 只表示允许在宿主支持并发派发时 ## Execution Mode -- **模式**:`sdd`(full/hotfix default) | `inline` | `batch-inline` -- **选择理由**: -- **用户 explicit override(仅 inline/batch-inline)**: -- **执行计划命令**:`ssf execution plan --mode --reason --wave ::[:]` -- **允许的修订**:将已有计划保留/升级为 `sdd`,生成新 revision 并清除旧 receipt;不允许降级:`ssf execution revise --mode sdd --reason --wave ::[:]` +- **可用方式与推荐**:`ssf execution recommend [--wave ::[:]]` +- **用户确认的模式**:`sdd` | `inline` | `batch-inline` +- **推荐理由 / 项目事实**: +- **非推荐选择的风险确认**:`--acknowledge-recommendation`(若适用) +- **执行计划命令**:`ssf execution plan --mode --confirm --reason --wave ::[:] [--acknowledge-recommendation]` +- **允许的修订**:将已有计划保留/升级为 `sdd`;先重新 recommend,并以 `--confirm` 生成新 revision 和清除旧 receipt;不允许降级:`ssf execution revise --mode sdd --confirm --reason --wave ::[:] [--acknowledge-recommendation]` - **计划 revision / artifact hash**: ## Verification Dimensions diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 24f68b2..cc31479 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -9,9 +9,28 @@ const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); let changeDir; let gitRefs; -function runSsf(args, cwd = process.cwd()) { +function runSsf(args, cwd = process.cwd(), { confirmPlan = true, acknowledgePlan = true, prepareRecommendation = true } = {}) { + const isPlan = args[0] === 'execution' && ['plan', 'revise'].includes(args[1]); + let effectiveArgs = args; + if (confirmPlan && isPlan && !effectiveArgs.includes('--confirm')) effectiveArgs = [...effectiveArgs, '--confirm']; + if (confirmPlan && acknowledgePlan && isPlan && requiresAcknowledgement(effectiveArgs) && !effectiveArgs.includes('--acknowledge-recommendation')) { + effectiveArgs = [...effectiveArgs, '--acknowledge-recommendation']; + } + if (prepareRecommendation && isPlan) { + const changePath = effectiveArgs[2]; + const waves = effectiveArgs.flatMap((value, index) => value === '--wave' ? ['--wave', effectiveArgs[index + 1]] : []).filter(Boolean); + try { + execFileSync(process.execPath, [CLI, 'execution', 'recommend', changePath, ...waves], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch { + // Let the requested command report malformed arguments through the usual test helper. + } + } try { - const stdout = execFileSync(process.execPath, [CLI, ...args], { + const stdout = execFileSync(process.execPath, [CLI, ...effectiveArgs], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], @@ -27,6 +46,16 @@ function runSsf(args, cwd = process.cwd()) { } } +function requiresAcknowledgement(args) { + const mode = args[args.indexOf('--mode') + 1]; + const waves = args.flatMap((value, index) => value === '--wave' ? [args[index + 1]] : []).filter(Boolean); + const hasParallelWave = waves.some(wave => wave.split(':')[1] === 'parallel'); + const plannedTaskCount = waves.reduce((count, wave) => count + (wave.split(':')[2]?.split(',').filter(Boolean).length || 0), 0); + const isSddRecommendation = hasParallelWave || waves.length > 1 || plannedTaskCount > 3; + const recommendedMode = isSddRecommendation ? 'sdd' : plannedTaskCount === 1 ? 'inline' : 'batch-inline'; + return mode !== recommendedMode; +} + function runGit(directory, args) { return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); } @@ -85,7 +114,7 @@ afterEach(() => { }); describe('ssf execution', () => { - it('records DP-4 and state summary only after writing a valid default SDD plan', () => { + it('records DP-4 and state summary after a user-confirmed recommended SDD plan', () => { const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', '--wave', 'wave-1:parallel:1.1,1.2', '--json']); @@ -98,12 +127,80 @@ describe('ssf execution', () => { assert.match(runSsf(['state', 'get', changeDir, 'dp_4_result', '--json']).json.value, /plan revision 1/); }); - it('rejects batch-inline without an explicit user override', () => { + it('lists applicable execution modes and recommends one from the change evidence', () => { + const result = runSsf(['execution', 'recommend', changeDir, '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(result.json.recommendation.available_modes, ['inline', 'batch-inline', 'sdd']); + assert.equal(result.json.recommendation.recommendation.mode, 'batch-inline'); + assert.equal(result.json.recommendation.facts.documented_task_count, 2); + }); + + it('requires a current persisted recommendation before a plan can be confirmed', () => { + const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--confirm', + '--reason', 'independent implementation', '--wave', 'wave-1:parallel:1.1,1.2', '--json'], process.cwd(), { + prepareRecommendation: false, + }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /recommend/i); + }); + + it('rejects a mode that is not available for the current workflow', () => { + const workflow = runSsf(['state', 'set', changeDir, 'workflow', 'tweak']); + assert.equal(workflow.exitCode, 0, workflow.stderr); + const recommended = runSsf(['execution', 'recommend', changeDir, + '--wave', 'wave-1:serial:1.1']); + assert.equal(recommended.exitCode, 0, recommended.stderr); + + const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--confirm', + '--acknowledge-recommendation', '--reason', 'operator wants delegated review', + '--wave', 'wave-1:serial:1.1'], process.cwd(), { prepareRecommendation: false }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /available|mode/i); + }); + + it('rejects a plan when the saved recommendation was for different waves', () => { + const recommended = runSsf(['execution', 'recommend', changeDir, + '--wave', 'wave-1:serial:1.1,1.2']); + assert.equal(recommended.exitCode, 0, recommended.stderr); + + const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--confirm', + '--reason', 'independent implementation', '--wave', 'wave-1:parallel:1.1,1.2', '--json'], process.cwd(), { + prepareRecommendation: false, + }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /recommend/i); + }); + + it('requires a user confirmation before recording any execution plan', () => { + const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', + '--reason', 'independent implementation', '--wave', 'wave-1:parallel:1.1,1.2', '--json'], process.cwd(), { confirmPlan: false }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /confirm/i); + }); + + it('records an acknowledged non-recommended selection instead of an override', () => { + const result = runSsf(['execution', 'plan', changeDir, '--mode', 'inline', '--confirm', + '--acknowledge-recommendation', '--reason', 'operator will keep this focused', + '--wave', 'wave-1:serial:1.1,1.2', '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.json.plan.mode, 'inline'); + assert.equal(result.json.plan.source, 'user-confirmed'); + assert.equal(result.json.plan.selection.followed_recommendation, false); + assert.equal(result.json.plan.selection.acknowledged_non_recommendation, true); + }); + + it('requires acknowledgement for a non-recommended selection', () => { const result = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', - '--reason', 'operator wants a batch', '--wave', 'wave-1:serial:1.1', '--json']); + '--reason', 'operator wants a batch', '--wave', 'wave-1:serial:1.1', '--json'], process.cwd(), { acknowledgePlan: false }); assert.notEqual(result.exitCode, 0); - assert.match(result.stderr, /override/i); + assert.match(result.stderr, /acknowledge/i); }); it('rejects multiline and control-character reasons before mutating the plan or state', () => { @@ -341,7 +438,7 @@ describe('ssf execution', () => { }); it('increments revision when a batch-inline plan is revised to SDD', () => { - const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--override', + const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--confirm', '--acknowledge-recommendation', '--reason', 'operator requested a batch', '--wave', 'wave-1:serial:1.1']); assert.equal(initial.exitCode, 0, initial.stderr); @@ -353,8 +450,55 @@ describe('ssf execution', () => { assert.equal(runSsf(['state', 'get', changeDir, 'execution_plan_revision', '--json']).json.value, 2); }); + it('requires confirmation and acknowledgement when a revision differs from its recommendation', () => { + const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', + '--reason', 'parallel work needs review', '--wave', 'wave-1:parallel:1.1,1.2']); + assert.equal(initial.exitCode, 0, initial.stderr); + + const recommended = runSsf(['execution', 'recommend', changeDir, + '--wave', 'wave-1:serial:1.1']); + assert.equal(recommended.exitCode, 0, recommended.stderr); + + const missingConfirm = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', + '--reason', 'retain SDD for the revised work', '--wave', 'wave-1:serial:1.1'], process.cwd(), { + confirmPlan: false, + prepareRecommendation: false, + }); + assert.notEqual(missingConfirm.exitCode, 0); + assert.match(missingConfirm.stderr, /confirm/i); + + const missingAcknowledgement = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', '--confirm', + '--reason', 'retain SDD for the revised work', '--wave', 'wave-1:serial:1.1'], process.cwd(), { + acknowledgePlan: false, + prepareRecommendation: false, + }); + assert.notEqual(missingAcknowledgement.exitCode, 0); + assert.match(missingAcknowledgement.stderr, /acknowledge/i); + + const revised = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', '--confirm', + '--acknowledge-recommendation', '--reason', 'retain SDD for the revised work', + '--wave', 'wave-1:serial:1.1', '--json'], process.cwd(), { prepareRecommendation: false }); + assert.equal(revised.exitCode, 0, revised.stderr); + assert.equal(revised.json.plan.selection.confirmed, true); + assert.equal(revised.json.plan.selection.followed_recommendation, false); + }); + + it('requires a fresh recommendation after the prior plan before recording a revision', () => { + const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', + '--reason', 'parallel work needs review', '--wave', 'wave-1:parallel:1.1,1.2']); + assert.equal(initial.exitCode, 0, initial.stderr); + + const result = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', '--confirm', + '--reason', 'reconfirm the same work as a new revision', '--wave', 'wave-1:parallel:1.1,1.2'], process.cwd(), { + prepareRecommendation: false, + }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /recommend/i); + }); + it('invalidates receipts from the replaced plan revision', () => { - const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--override', + const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--confirm', '--acknowledge-recommendation', '--reason', 'operator requested a batch', '--wave', 'wave-1:serial:1.1']); assert.equal(initial.exitCode, 0, initial.stderr); const reportPath = writeReviewReport('wave-1.md'); @@ -450,7 +594,7 @@ describe('ssf execution', () => { it('keeps the Task 1 state revision aligned through plan, show, revise, and show', () => { writeChangeDirectory(changeDir, 'full', 2); - const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--override', + const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--confirm', '--acknowledge-recommendation', '--reason', 'operator requested a batch', '--wave', 'wave-1:serial:1.1', '--json']); assert.equal(initial.exitCode, 0, initial.stderr); assert.equal(initial.json.plan.revision, 2); @@ -496,7 +640,7 @@ describe('ssf execution', () => { assert.match(malformed.stderr, /wave/i); runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', '--wave', 'wave-1:serial:1.1']); - const invalidRevision = runSsf(['execution', 'revise', changeDir, '--mode', 'inline', '--override', '--reason', 'downgrade', '--wave', 'wave-1:serial:1.1']); + const invalidRevision = runSsf(['execution', 'revise', changeDir, '--mode', 'inline', '--reason', 'downgrade', '--wave', 'wave-1:serial:1.1']); assert.notEqual(invalidRevision.exitCode, 0); assert.match(invalidRevision.stderr, /sdd|downgrade|upgrade/i); }); diff --git a/tests/lib/execution-control-plane.test.mjs b/tests/lib/execution-control-plane.test.mjs index 1e36352..d7dea99 100644 --- a/tests/lib/execution-control-plane.test.mjs +++ b/tests/lib/execution-control-plane.test.mjs @@ -20,8 +20,10 @@ describe('execution control plane instructions', () => { for (const path of documents) { const content = read(path); assert.match(content, /execution[ -]plan/i, `${path} documents execution plans`); - assert.match(content, /SDD.*default|default.*SDD/is, `${path} documents the SDD default`); - assert.match(content, /explicit.*override|override.*explicit/is, `${path} documents explicit overrides`); + assert.match(content, /execution recommend|execution-recommendation|执行模式推荐/i, `${path} documents execution-mode recommendations`); + assert.match(content, /execution-recommendation\.json|recommendation receipt|推荐凭据/i, `${path} documents persisted recommendation evidence`); + assert.match(content, /--confirm|用户.*确认|user.*confirm/is, `${path} documents user confirmation`); + assert.match(content, /acknowledge-recommendation|确认.*风险|acknowledg/is, `${path} documents acknowledgement for a non-recommended choice`); assert.match(content, /review receipt/i, `${path} documents review receipts`); } @@ -117,8 +119,9 @@ describe('execution control plane instructions', () => { assert.match(workflowStart, /execution show --json/); assert.match(workflowStart, /execution plan /); - assert.match(buildExecutor, /full\/hotfix.*SDD.*default/is); - assert.match(buildExecutor, /explicit user override/i); + assert.match(buildExecutor, /execution recommend/i); + assert.match(buildExecutor, /user.*confirm|用户.*确认/is); + assert.match(buildExecutor, /acknowledge-recommendation/i); assert.match(buildExecutor, /parallel.*wave/is); assert.match(buildExecutor, /concurren(?:cy|t).*unavailable/i); assert.match(buildExecutor, /retryable.*replacement.*pass/is); @@ -138,7 +141,9 @@ describe('execution control plane instructions', () => { 'scripts/install-zcode.mjs', ]) { const content = read(path); - assert.match(content, /execution plan/); + assert.match(content, /execution recommend/); + assert.match(content, /--confirm/); + assert.match(content, /acknowledge-recommendation/); assert.match(content, /all.*pass.*review receipt.*closing/is); assert.match(content, /full\/hotfix.*tweak.*exempt/is); } diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 695d097..43b7950 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -5,8 +5,9 @@ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, s import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { - createPlan, readPlan, recordReview, validatePlan, writePlan, + createPlan as createRawPlan, readPlan, recordReview, validatePlan, writePlan, } from '../../scripts/lib/execution-plan.mjs'; +import { createRecommendationReceipt, recommendExecutionModes } from '../../scripts/lib/execution-recommendation.mjs'; import { readState } from '../../scripts/lib/state-loader.mjs'; let changeDir; @@ -49,7 +50,68 @@ function initializeGitRepository(directory) { return { base, head: runGit(directory, ['rev-parse', 'HEAD']) }; } +function createPlan(directory, input) { + const receipt = input.recommendationReceipt ?? createRecommendationReceipt(directory, input.waves); + const recommendation = input.recommendation ?? receipt.recommendation; + const followedRecommendation = input.mode === recommendation.recommendation.mode; + return createRawPlan(directory, { + ...input, + source: input.source === 'default' ? 'user-confirmed' : input.source, + recommendation, + recommendationReceipt: receipt, + selection: input.selection ?? { + confirmed: true, + followed_recommendation: followedRecommendation, + acknowledged_non_recommendation: !followedRecommendation, + }, + }); +} + describe('execution plan data contract', () => { + it('recommends inline for one small sequential task', () => { + const result = recommendExecutionModes({ + workflow: 'full', + taskCount: 1, + inlineThreshold: 3, + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + + assert.deepEqual(result.available_modes, ['inline', 'batch-inline', 'sdd']); + assert.equal(result.recommendation.mode, 'inline'); + assert.match(result.recommendation.reasons.join('\n'), /single sequential task/i); + }); + + it('recommends batch-inline for a bounded sequential batch', () => { + const result = recommendExecutionModes({ + workflow: 'hotfix', + taskCount: 3, + inlineThreshold: 3, + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1', '1.2', '1.3'], depends_on: [] }], + }); + + assert.equal(result.recommendation.mode, 'batch-inline'); + assert.match(result.recommendation.reasons.join('\n'), /within.*threshold/i); + }); + + it('recommends SDD for independent parallel work', () => { + const result = recommendExecutionModes({ + workflow: 'full', + taskCount: 2, + inlineThreshold: 3, + waves: [{ id: 'foundation', strategy: 'parallel', tasks: ['1.1', '1.2'], depends_on: [] }], + }); + + assert.equal(result.recommendation.mode, 'sdd'); + assert.match(result.recommendation.reasons.join('\n'), /parallel/i); + }); + + it('limits tweak recommendations to direct inline execution', () => { + const result = recommendExecutionModes({ workflow: 'tweak', taskCount: 2, inlineThreshold: 3, waves: [] }); + + assert.deepEqual(result.available_modes, ['inline']); + assert.equal(result.recommendation.mode, 'inline'); + }); + it('creates a current SDD plan with an auditable parallel wave', () => { const plan = createPlan(changeDir, { mode: 'sdd', @@ -66,16 +128,74 @@ describe('execution plan data contract', () => { assert.equal(readState(changeDir).execution_plan_hash, plan.hash); }); - it('rejects batch-inline without an explicit user override', () => { - assert.throws(() => createPlan(changeDir, { - mode: 'batch-inline', source: 'default', rationale: 'fast', waves: [], - }), /explicit user override/); + it('preserves a user-confirmed non-recommended selection in the plan hash', () => { + const plan = createPlan(changeDir, { + mode: 'inline', + source: 'user-confirmed', + rationale: 'operator accepts the serial execution risk', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1', '1.2'], depends_on: [] }], + recommendation: { + available_modes: ['inline', 'batch-inline', 'sdd'], + recommendation: { mode: 'batch-inline', reasons: ['Two tasks are within the threshold.'] }, + facts: { workflow: 'full' }, + }, + selection: { + confirmed: true, + followed_recommendation: false, + acknowledged_non_recommendation: true, + }, + }); + + assert.equal(plan.selection.acknowledged_non_recommendation, true); + assert.equal(plan.recommendation.recommendation.mode, 'batch-inline'); }); - it('rejects an inline plan without an explicit user override', () => { - assert.throws(() => createPlan(changeDir, { - mode: 'inline', source: 'automatic', rationale: 'fast', waves: [], - }), /explicit user override/); + it('invalidates a legacy full plan without recommendation and confirmation evidence', () => { + const plan = createRawPlan(changeDir, { + mode: 'sdd', + source: 'legacy', + rationale: 'legacy execution plan', + waves: [{ id: 'wave-1', strategy: 'parallel', tasks: ['1.1', '1.2'], depends_on: [] }], + }); + writePlan(changeDir, plan); + + const result = validatePlan(changeDir, readPlan(changeDir)); + + assert.equal(result.valid, false); + assert.match(result.failures.join('\n'), /recommendation.*required|selection.*required/i); + }); + + it('invalidates a revision whose recommendation skips the immediately prior plan', () => { + const first = createPlan(changeDir, { + mode: 'sdd', + source: 'user-confirmed', + rationale: 'initial execution plan', + waves: [{ id: 'wave-1', strategy: 'parallel', tasks: ['1.1', '1.2'], depends_on: [] }], + }); + writePlan(changeDir, first); + writeFileSync(join(changeDir, '.spec-superflow.yaml'), readFileSync(join(changeDir, '.spec-superflow.yaml'), 'utf8') + .replace(/^revision:.*$/m, 'revision: 4')); + const receipt = createRecommendationReceipt(changeDir, first.waves); + const revised = createRawPlan(changeDir, { + mode: 'sdd', + source: 'user-confirmed-revision', + rationale: 'skips an execution plan revision', + waves: first.waves, + revision: 4, + recommendation: receipt.recommendation, + recommendationReceipt: receipt, + selection: { + confirmed: true, + followed_recommendation: true, + acknowledged_non_recommendation: false, + }, + }); + writePlan(changeDir, revised); + + const result = validatePlan(changeDir, readPlan(changeDir)); + + assert.equal(result.valid, false); + assert.match(result.failures.join('\n'), /recommendation receipt.*prior|recommendation.*revision/i); }); it('rejects parallel waves with self and unknown dependencies', () => { diff --git a/tests/lib/guard-specs-merged.test.mjs b/tests/lib/guard-specs-merged.test.mjs index 0fe1a1c..dc2c6f2 100644 --- a/tests/lib/guard-specs-merged.test.mjs +++ b/tests/lib/guard-specs-merged.test.mjs @@ -59,8 +59,11 @@ function runClosingGuard(dir, extraState = '') { join(dir, '.spec-superflow.yaml'), `state: executing\nworkflow: full\nchange_name: test\ndp_6_result: pass: ok\n${extraState}`, ); + execFileSync('node', [CLI, 'execution', 'recommend', dir, + '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); execFileSync('node', [CLI, 'execution', 'plan', dir, '--mode', 'sdd', - '--reason', 'closing guard regression fixture', '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); + '--confirm', '--acknowledge-recommendation', '--reason', 'closing guard regression fixture', + '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); const report = join(dir, '.superpowers', 'sdd', 'reviews', 'close-review.md'); mkdirSync(join(dir, '.superpowers', 'sdd', 'reviews'), { recursive: true }); writeFileSync(report, 'review passed\n'); diff --git a/tests/lib/guard-tests-passing.test.mjs b/tests/lib/guard-tests-passing.test.mjs index 3c14802..9bf2e3b 100644 --- a/tests/lib/guard-tests-passing.test.mjs +++ b/tests/lib/guard-tests-passing.test.mjs @@ -64,8 +64,11 @@ function runClosingGuard(dir, extraState = '') { `state: executing\nworkflow: full\nchange_name: test\nspec_merged: true\n${extraState}`, ); rmSync(join(dir, '.superpowers'), { recursive: true, force: true }); + execFileSync('node', [CLI, 'execution', 'recommend', dir, + '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); execFileSync('node', [CLI, 'execution', 'plan', dir, '--mode', 'sdd', - '--reason', 'closing guard regression fixture', '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); + '--confirm', '--acknowledge-recommendation', '--reason', 'closing guard regression fixture', + '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); const report = join(dir, '.superpowers', 'sdd', 'reviews', 'close-review.md'); mkdirSync(join(dir, '.superpowers', 'sdd', 'reviews'), { recursive: true }); writeFileSync(report, 'review passed\n'); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index 21c876d..157d9be 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -246,8 +246,9 @@ describe('guard: hotfix minimal contract', () => { it('allows approved-for-build to executing with fresh contract and approved DP-3', () => { prepareFreshHotfixState(); runNodeScript(CLI_PATH, ['state', 'set', dir, 'dp_3_result', 'approved: user confirmed minimal contract']); - runNodeScript(CLI_PATH, ['execution', 'plan', dir, '--mode', 'sdd', - '--reason', 'hotfix default execution plan', '--wave', 'wave-1:serial:1.1']); + runNodeScript(CLI_PATH, ['execution', 'recommend', dir, '--wave', 'wave-1:serial:1.1']); + runNodeScript(CLI_PATH, ['execution', 'plan', dir, '--mode', 'sdd', '--confirm', '--acknowledge-recommendation', + '--reason', 'hotfix user-selected execution plan', '--wave', 'wave-1:serial:1.1']); const result = run('approved-for-build', 'executing'); assert.equal(result.exitCode, 0, JSON.stringify(result.output)); const dims = result.output.checks.map(c => c.dimension); @@ -293,8 +294,11 @@ describe('guard: execution control records', () => { } function createCurrentPlan() { - runNodeScript(CLI_PATH, ['execution', 'plan', dir, '--mode', 'sdd', - '--reason', 'full workflow default execution plan', + runNodeScript(CLI_PATH, ['execution', 'recommend', dir, + '--wave', 'wave-1:parallel:1.1,1.2', + '--wave', 'wave-2:serial:2.1']); + runNodeScript(CLI_PATH, ['execution', 'plan', dir, '--mode', 'sdd', '--confirm', + '--reason', 'full workflow user-selected execution plan', '--wave', 'wave-1:parallel:1.1,1.2', '--wave', 'wave-2:serial:2.1']); }