From acb10b580791de96bdc71fc88b6de027bb927093 Mon Sep 17 00:00:00 2001 From: CYJiang Date: Wed, 29 Jul 2026 14:38:29 +0800 Subject: [PATCH 1/3] feat: recommend lightweight comet workflows --- CHANGELOG.md | 6 + README-zh.md | 1 + README.md | 1 + assets/skills-zh/comet-open/SKILL.md | 23 +- .../scripts/comet-native-runtime.mjs | 2 + assets/skills/comet-open/SKILL.md | 23 +- assets/skills/comet/scripts/comet-runtime.mjs | 113 +++++++- .../comet-classic/classic-intent-command.ts | 28 +- domains/comet-classic/classic-intent.ts | 92 +++++- .../comet-classic/classic-project-config.ts | 34 ++- domains/skill/platform-install.ts | 5 + domains/workflow-contract/project-config.ts | 5 + .../comet-classic/classic-intent.test.ts | 273 +++++++++++++++++- .../classic-project-config.test.ts | 29 +- test/domains/skill/skills.test.ts | 12 +- .../workflow-optimization-contract.test.ts | 28 +- 16 files changed, 628 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5896cbf7..4b35903b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.12] - 2026-07-30 + +### Added + +- **Lightweight workflow recommendations**: Classic Comet now exposes `classic.workflow_intensity` and typed route recommendation metadata so evidence-backed tweak or hotfix candidates can present confirmed lightweight workflow options before continuing full open, while risk signals keep the conservative full workflow path. + ## What's Changed [0.4.0-beta.11] - 2026-07-29 ### Changed diff --git a/README-zh.md b/README-zh.md index 7e3294a8..39c4ba3b 100644 --- a/README-zh.md +++ b/README-zh.md @@ -143,6 +143,7 @@ classic: artifact_layout: docs language: zh-CN context_compression: off + workflow_intensity: standard review_mode: standard auto_transition: true ``` diff --git a/README.md b/README.md index 95a30571..ac4e827b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ classic: artifact_layout: docs language: en context_compression: off + workflow_intensity: standard review_mode: standard auto_transition: true ``` diff --git a/assets/skills-zh/comet-open/SKILL.md b/assets/skills-zh/comet-open/SKILL.md index 0208eaf1..75a80c83 100644 --- a/assets/skills-zh/comet-open/SKILL.md +++ b/assets/skills-zh/comet-open/SKILL.md @@ -57,7 +57,18 @@ comet classic openspec -- --version 澄清摘要必须包含:目标、非目标、范围边界、关键未知项、验收场景草案。 -### 1a. PRD 拆分预检(阻塞点) +### 1a. 轻量路径推荐确认(阻塞点) + +当 Comet intent runtime 返回轻量路径推荐 metadata 时,必须在创建 OpenSpec artifacts 前展示推荐理由,并按用户决策点协议暂停等待用户选择。 + +选项必须互斥: +- 「切换到 `/comet-tweak`」— 由 tweak workflow 创建并拥有 change +- 「切换到 `/comet-hotfix`」— 仅当请求是已有行为修复或回归时可选 +- 「继续 full `/comet-open`」— 保持完整 OpenSpec + Design 流程 + +不得在轻量路径推荐确认前运行 `openspec new change`,不得创建 proposal/design/tasks,也不得自动把 full 请求降级为 tweak/hotfix。 + +### 1b. PRD 拆分预检(阻塞点) 当用户输入是大型 PRD、路线图、完整产品方案,或澄清摘要显示包含多个独立能力、模块、用户路径或里程碑时,必须在创建 OpenSpec artifacts 前评估是否需要拆分为多个 change。 @@ -112,7 +123,7 @@ comet state check design 断点恢复时先读取 `.comet/batches/.json`,再对清单中已创建的 active changes 运行上述 CLI 检查;已完整通过的拆分项不得重复创建,未通过的拆分项从 OpenSpec 返回的第一个 `ready` artifact 继续。未创建项按持久清单继续创建。清单缺失或损坏时停止并请求用户重建/确认,不能从目录列表猜测原始批次边界。 -### 1b. 需求与 Change 名称解析(默认不阻塞) +### 1c. 需求与 Change 名称解析(默认不阻塞) 创建 OpenSpec artifacts 前,把 Step 1 的澄清结果整理为 resolved brief:目标、非目标、范围边界、关键未知项和验收场景草案,并基于它派生一个能准确表达范围的 kebab-case 英文 change 名称。 @@ -137,9 +148,9 @@ resolved brief 或 change 名称仍不明确时不得运行 `comet classic opens **外部 OpenSpec Skill 覆写:** 对 `openspec-propose` 同样不得采用其直接官方 CLI、固定 cwd 或固定物理 OpenSpec 路径;命令必须通过 adapter,产物必须写入 resolver 返回的 `` 逻辑根。 -技能加载后,按其指引创建 change 骨架;当 Step 1b 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为,避免重复询问。 +技能加载后,按其指引创建 change 骨架;当 Step 1c 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为,避免重复询问。 -直接使用 Step 1b 的 resolved brief 填充产物内容。只有 brief 仍有会改变范围的歧义时,才回退到技能的提问流程。 +直接使用 Step 1c 的 resolved brief 填充产物内容。只有 brief 仍有会改变范围的歧义时,才回退到技能的提问流程。 change 骨架创建后立即初始化可恢复状态,不能等 artifacts 全部生成后再写 `.comet.yaml`: @@ -180,7 +191,7 @@ comet state check open **阻塞与失败处理**:`applyRequires` 尚未全部完成但没有任何可推进其依赖闭包的 ready artifact 时,必须报告相关 `blocked` artifact 的 `missingDeps` 并停止,不得猜测顺序或跳过依赖。如果 adapter 的 `status` / `instructions` 调用失败、返回无效 JSON、路径逃逸仓库、或未提供可用的 `resolvedOutputPath`,也必须立即停止并报告 OpenSpec 错误。不得回退为硬编码文档结构。 -**命名与范围守卫**:change name 必须使用 Step 1b 解析出的 kebab-case 英文名,不得使用非 kebab-case(如中文)名称。变更范围必须与 resolved brief 和用户描述一致,不得自行扩大或缩小。 +**命名与范围守卫**:change name 必须使用 Step 1c 解析出的 kebab-case 英文名,不得使用非 kebab-case(如中文)名称。变更范围必须与 resolved brief 和用户描述一致,不得自行扩大或缩小。 确认以下产物已创建: @@ -224,7 +235,7 @@ comet state check open 全部 OpenSpec artifacts 完成且内容完整性检查通过后,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认**。不得在用户确认前执行阶段守卫或自动流转。 -最终审视同时确认 change 名称、范围和产物内容;不得因 Step 1b 已完成解析而省略,也不得在此之前再增加一次常规摘要/命名确认。 +最终审视同时确认 change 名称、范围和产物内容;不得因 Step 1c 已完成解析而省略,也不得在此之前再增加一次常规摘要/命名确认。 用户确认问题必须以单选题形式呈现,包含以下摘要和选项: diff --git a/assets/skills/comet-native/scripts/comet-native-runtime.mjs b/assets/skills/comet-native/scripts/comet-native-runtime.mjs index 37ea7e21..cde63e80 100644 --- a/assets/skills/comet-native/scripts/comet-native-runtime.mjs +++ b/assets/skills/comet-native/scripts/comet-native-runtime.mjs @@ -8049,6 +8049,7 @@ var COMMENTS = { "classic.artifact_layout": "# Selects the Classic artifact layout. The default is docs; update preserves detected root-level legacy artifacts.\n# artifact_layout: legacy | docs", "classic.language": "# Artifact language used by Classic workflow documents.\n# language: en | zh-CN", "classic.context_compression": "# Controls beta context compression for new Classic changes.\n# context_compression: off | beta", + "classic.workflow_intensity": "# Tunes how aggressively Classic recommends lightweight workflows.\n# workflow_intensity: light | standard | thorough", "classic.review_mode": "# Sets the default review depth for new Classic changes.\n# review_mode: off | standard | thorough", "classic.auto_transition": "# Automatically enters the next Classic phase after a phase passes.\n# auto_transition: true | false" }, @@ -8073,6 +8074,7 @@ var COMMENTS = { "classic.artifact_layout": "# Classic 产物布局;默认使用 docs,update 检测到根目录 legacy 产物时予以保留。\n# 可选值:legacy | docs", "classic.language": "# Classic 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "classic.context_compression": "# 新建 Classic change 是否启用 beta 上下文压缩。\n# 可选值:off | beta", + "classic.workflow_intensity": "# 调节 Classic 推荐轻量工作流的积极程度。\n# 可选值:light | standard | thorough", "classic.review_mode": "# 新建 Classic change 默认使用的审查深度。\n# 可选值:off | standard | thorough", "classic.auto_transition": "# Classic 阶段通过后是否自动进入下一阶段。\n# 可选值:true | false" } diff --git a/assets/skills/comet-open/SKILL.md b/assets/skills/comet-open/SKILL.md index d360efe8..619ebcef 100644 --- a/assets/skills/comet-open/SKILL.md +++ b/assets/skills/comet-open/SKILL.md @@ -57,7 +57,18 @@ After the skill loads, explore the problem space following its guidance, but do The clarification summary must include: goals, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios. -### 1a. PRD Split Preflight (Blocking Point) +### 1a. Lightweight Path Recommendation Confirmation (Blocking Point) + +When the Comet intent runtime returns lightweight path recommendation metadata, present the recommendation reasons before creating OpenSpec artifacts, then pause under the user decision-point protocol. + +Options must be mutually exclusive: +- "Switch to `/comet-tweak`" — the tweak workflow creates and owns the change +- "Switch to `/comet-hotfix`" — only available for existing behavior fixes or regressions +- "Continue full `/comet-open`" — keep the full OpenSpec + Design workflow + +The agent must not run `openspec new change` before lightweight path recommendation confirmation, must not create proposal/design/tasks, and must not automatically downgrade a full request to tweak/hotfix. + +### 1b. PRD Split Preflight (Blocking Point) When the user input is a large PRD, roadmap, complete product plan, or the clarification summary shows multiple independent capabilities, modules, user journeys, or milestones, must evaluate whether it should be split into multiple changes before creating OpenSpec artifacts. @@ -112,7 +123,7 @@ Only after every split item passes both CLI checks may you pause and ask which c On resume, read `.comet/batches/.json` first, then run the CLI checks above for already-created active changes. Do not recreate items that fully pass; resume incomplete items from the first `ready` artifact returned by OpenSpec. Create missing items from the persisted manifest. If the manifest is missing or damaged, stop and ask the user to rebuild/confirm it instead of inferring the original batch boundary from directory names. -### 1b. Resolve Requirements and Change Name (Non-blocking by Default) +### 1c. Resolve Requirements and Change Name (Non-blocking by Default) Before creating OpenSpec artifacts, turn Step 1 clarification into a resolved brief containing the goal, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios. Derive one kebab-case English change name that accurately represents that scope. @@ -137,9 +148,9 @@ Full `/comet-classic` workflow must not use the Skill tool to load the `openspec **External OpenSpec Skill override:** Apply the same rule to `openspec-propose`: ignore direct official CLI, fixed-cwd, and fixed physical OpenSpec path instructions; use the adapter and resolver-returned `` logical roots. -After the skill loads, follow its guidance to create the change skeleton. When Step 1b has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior to avoid a duplicate question. +After the skill loads, follow its guidance to create the change skeleton. When Step 1c has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior to avoid a duplicate question. -Use the Step 1b resolved brief directly to populate artifact content. Fall back to the skill's question flow only when ambiguity remains that would change scope. +Use the Step 1c resolved brief directly to populate artifact content. Fall back to the skill's question flow only when ambiguity remains that would change scope. Immediately after creating the change skeleton, initialize recoverable state instead of waiting until every artifact is generated: @@ -180,7 +191,7 @@ After preflight, generate the implementation-required artifacts from the OpenSpe **Blocking and failure handling**: if `applyRequires` is incomplete and no ready artifact can advance its dependency closure, report `missingDeps` for the relevant `blocked` artifacts and stop. Do not guess order or skip dependencies. Also stop if status/instructions fails, returns invalid JSON, escapes the repository, or provides no usable `resolvedOutputPath`. Must not fall back to hard-coded artifact prose. -**Naming and scope guard**: Use the kebab-case English name resolved in Step 1b; never use a non-kebab-case name. Change scope must match the resolved brief and user request; do not expand or narrow it independently. +**Naming and scope guard**: Use the kebab-case English name resolved in Step 1c; never use a non-kebab-case name. Change scope must match the resolved brief and user request; do not expand or narrow it independently. Confirm the following artifacts have been created: @@ -224,7 +235,7 @@ Then check key artifact content: proposal covers problem, goals, scope, and non- After all OpenSpec artifacts are complete and the content check passes, **must follow the `comet/reference/decision-point.md` protocol to pause and wait for user confirmation**. Must not execute the phase guard or auto-transition before user confirmation. -The final review confirms the change name, scope, and artifact content together. Do not skip it because Step 1b resolved the brief, and do not add another routine summary/name confirmation before it. +The final review confirms the change name, scope, and artifact content together. Do not skip it because Step 1c resolved the brief, and do not add another routine summary/name confirmation before it. The user confirmation question must be presented as a single-select question with the following summary and options: diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index cda2f2f2..8717b104 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7748,6 +7748,7 @@ var init_project_config = __esm({ "classic.artifact_layout": "# Selects the Classic artifact layout. The default is docs; update preserves detected root-level legacy artifacts.\n# artifact_layout: legacy | docs", "classic.language": "# Artifact language used by Classic workflow documents.\n# language: en | zh-CN", "classic.context_compression": "# Controls beta context compression for new Classic changes.\n# context_compression: off | beta", + "classic.workflow_intensity": "# Tunes how aggressively Classic recommends lightweight workflows.\n# workflow_intensity: light | standard | thorough", "classic.review_mode": "# Sets the default review depth for new Classic changes.\n# review_mode: off | standard | thorough", "classic.auto_transition": "# Automatically enters the next Classic phase after a phase passes.\n# auto_transition: true | false" }, @@ -7772,6 +7773,7 @@ var init_project_config = __esm({ "classic.artifact_layout": "# Classic 产物布局;默认使用 docs,update 检测到根目录 legacy 产物时予以保留。\n# 可选值:legacy | docs", "classic.language": "# Classic 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "classic.context_compression": "# 新建 Classic change 是否启用 beta 上下文压缩。\n# 可选值:off | beta", + "classic.workflow_intensity": "# 调节 Classic 推荐轻量工作流的积极程度。\n# 可选值:light | standard | thorough", "classic.review_mode": "# 新建 Classic change 默认使用的审查深度。\n# 可选值:off | standard | thorough", "classic.auto_transition": "# Classic 阶段通过后是否自动进入下一阶段。\n# 可选值:true | false" } @@ -12940,6 +12942,7 @@ var classicValidateCommand = async (args, options) => withClassicCommandContext( init_project_config_reader(); import os from "os"; import path26 from "path"; +var WORKFLOW_INTENSITIES = ["light", "standard", "thorough"]; function configCandidates(options = {}) { const cwd = options.cwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); @@ -12971,6 +12974,19 @@ async function readClassicConfigValue(field2, options = {}) { } return null; } +async function readClassicWorkflowIntensity(options = {}) { + const configured = await readClassicConfigValue("workflow_intensity", options); + if (!configured) return { value: "standard", source: "default" }; + if (!WORKFLOW_INTENSITIES.includes(configured.value)) { + throw new Error( + `classic.workflow_intensity must be light, standard, or thorough, got '${configured.value}' from ${configured.source}` + ); + } + return { + value: configured.value, + source: configured.source + }; +} // domains/comet-classic/classic-guard.ts init_project_config_reader(); @@ -15100,6 +15116,7 @@ var REQUESTED_ACTIONS = [ "unknown" ]; var WORKFLOWS = ["full", "hotfix", "tweak"]; +var WORKFLOW_INTENSITIES2 = ["light", "standard", "thorough"]; var SCOPES = ["small", "medium", "large", "unknown"]; var ROUTES = ["full", "hotfix", "tweak", "resume", "ask_user", "out_of_scope"]; var NEXT_SKILLS = [ @@ -15266,7 +15283,13 @@ function validateFrame(input) { context.dirty_worktree, "context.dirty_worktree", issues - ) + ), + workflow_intensity: context.workflow_intensity === void 0 || context.workflow_intensity === null ? "standard" : enumValue2( + context.workflow_intensity, + WORKFLOW_INTENSITIES2, + "context.workflow_intensity", + issues + ) ?? "standard" }, evidence: evidence.map((item, index) => { const record2 = isRecord2(item) ? item : {}; @@ -15295,7 +15318,8 @@ function validateFrame(input) { proposedRouteInput.fallback_reason, "proposed_route.fallback_reason", issues - ) + ), + recommendation: null } }; if (issues.length > 0) throw new CometIntentValidationError(issues); @@ -15304,10 +15328,47 @@ function validateFrame(input) { function hasEvidence(frame, field2) { return frame.evidence.some((item) => item.field === field2 && item.quote.trim() !== ""); } +function hasSmallScopeEvidence(frame) { + if (frame.slots.scope === "small") return true; + return frame.evidence.some( + (item) => item.field === "slots.scope" && /\b(small|tiny|minor)\b|轻量|小型|较小|很小|微小|小改动|小修改/iu.test(item.quote) + ); +} function hasRiskSignal(frame) { return frame.slots.new_capability === true || frame.slots.public_api_change === true || frame.slots.schema_change === true || frame.slots.cross_module_change === true; } -function route(name, confidence, fallback_reason = null) { +function lowRiskEvidence(frame) { + return hasSmallScopeEvidence(frame) || /docs?|prompt|wording|config|readme|small|tiny|minor/iu.test(frame.utterance); +} +function recommendationFor(frame) { + if (hasRiskSignal(frame)) return null; + const intensity = frame.context.workflow_intensity; + const evidenceIsStrong = lowRiskEvidence(frame); + if (intensity === "thorough" && !evidenceIsStrong) return null; + if (frame.intent.name === "fix_bug" && frame.slots.existing_behavior === true) { + return { + workflow: "hotfix", + next_skill: "comet-hotfix", + intensity, + reasons: ["existing behavior fix", `workflow_intensity=${intensity}`], + options: ["hotfix", "full"] + }; + } + if ((evidenceIsStrong || intensity === "light") && (frame.intent.name === "start_change" || frame.intent.name === "make_tweak" || frame.slots.requested_action === "modify" || frame.slots.requested_action === "create")) { + return { + workflow: "tweak", + next_skill: "comet-tweak", + intensity, + reasons: [ + evidenceIsStrong ? "small low-risk request" : "low-risk request", + `workflow_intensity=${intensity}` + ], + options: ["tweak", "full"] + }; + } + return null; +} +function route(name, confidence, fallback_reason = null, recommendation = null) { const nextSkill = { full: "comet-open", hotfix: "comet-hotfix", @@ -15320,8 +15381,9 @@ function route(name, confidence, fallback_reason = null) { name, next_skill: nextSkill[name], confidence, - requires_confirmation: name === "ask_user" || name === "out_of_scope", - fallback_reason + requires_confirmation: Boolean(recommendation) || name === "ask_user" || name === "out_of_scope", + fallback_reason, + recommendation }; } function askUser(reason) { @@ -15334,6 +15396,7 @@ function resolveCometIntentRoute(input) { const frame = validateFrame(input); const diagnostics = []; const confidence = frame.intent.confidence; + const recommendation = frame.slots.user_explicit_workflow ? null : recommendationFor(frame); let resolved; if (frame.intent.confidence < COMET_INTENT_CONFIDENCE_THRESHOLD) { resolved = askUser( @@ -15357,12 +15420,16 @@ function resolveCometIntentRoute(input) { resolved = workflowRoute(frame.slots.user_explicit_workflow, confidence); } else if (hasRiskSignal(frame)) { resolved = route("full", confidence); + } else if (recommendation) { + resolved = route("full", confidence, null, recommendation); } else if (frame.intent.name === "fix_bug" && frame.slots.existing_behavior === true && hasEvidence(frame, "slots.workflow_candidate")) { resolved = route("hotfix", confidence); } else if (frame.intent.name === "make_tweak" && frame.slots.workflow_candidate === "tweak" && hasEvidence(frame, "slots.workflow_candidate")) { resolved = route("tweak", confidence); } else if (frame.slots.workflow_candidate && hasEvidence(frame, "slots.workflow_candidate")) { resolved = workflowRoute(frame.slots.workflow_candidate, confidence); + } else if (frame.slots.workflow_candidate === "full") { + resolved = route("full", confidence); } else { resolved = askUser("workflow_candidate evidence is missing or route is ambiguous"); } @@ -15386,6 +15453,11 @@ function resolveCometIntentRoute(input) { `agent proposed_route fallback_reason '${frame.proposed_route.fallback_reason}' normalized to '${resolved.fallback_reason}'` ); } + if (resolved.recommendation?.workflow !== frame.proposed_route.recommendation?.workflow) { + diagnostics.push( + `agent proposed_route recommendation '${frame.proposed_route.recommendation?.workflow ?? null}' normalized to '${resolved.recommendation?.workflow ?? null}'` + ); + } return { route: resolved, diagnostics, @@ -15415,13 +15487,33 @@ async function readStdin() { } return Buffer.concat(chunks).toString("utf8"); } +function isRecord3(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +async function withConfiguredWorkflowIntensity(input) { + if (!isRecord3(input)) return input; + const context = isRecord3(input.context) ? { ...input.context } : {}; + if (context.workflow_intensity !== void 0 && context.workflow_intensity !== null) { + return input; + } + const configured = await readClassicWorkflowIntensity(); + return { + ...input, + context: { + ...context, + workflow_intensity: configured.value + } + }; +} var classicIntentCommand = async (args, _options) => { const [subcommand, input] = args; if (subcommand !== "route") return usage(); const source = input === "--stdin" ? await readStdin() : input; if (!source) return usage(); try { - const resolution = resolveCometIntentRoute(JSON.parse(source)); + const resolution = resolveCometIntentRoute( + await withConfiguredWorkflowIntensity(JSON.parse(source)) + ); return result2(0, `${JSON.stringify(resolution, null, 2)} `); } catch (error) { @@ -15431,6 +15523,9 @@ var classicIntentCommand = async (args, _options) => { if (error instanceof CometIntentValidationError) { return result2(1, void 0, error.message); } + if (error instanceof Error) { + return result2(1, void 0, error.message); + } throw error; } }; @@ -15438,11 +15533,11 @@ var classicIntentCommand = async (args, _options) => { // domains/comet-classic/classic-resume-probe.ts import { spawn } from "child_process"; var COMET_RESUME_PROBE_SCHEMA_VERSION = "comet.resume_probe.v1"; -function isRecord3(value) { +function isRecord4(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeInput(input) { - if (!isRecord3(input)) { + if (!isRecord4(input)) { throw new Error("Invalid CometResumeProbeInput: input must be an object"); } if (input.schema_version !== COMET_RESUME_PROBE_SCHEMA_VERSION) { @@ -15453,7 +15548,7 @@ function normalizeInput(input) { if (typeof input.utterance !== "string") { throw new Error("Invalid CometResumeProbeInput: utterance must be a string"); } - const context = isRecord3(input.agent_context) ? input.agent_context : {}; + const context = isRecord4(input.agent_context) ? input.agent_context : {}; return { schema_version: COMET_RESUME_PROBE_SCHEMA_VERSION, utterance: input.utterance, diff --git a/domains/comet-classic/classic-intent-command.ts b/domains/comet-classic/classic-intent-command.ts index 6ba64cd9..0fb557d7 100644 --- a/domains/comet-classic/classic-intent-command.ts +++ b/domains/comet-classic/classic-intent-command.ts @@ -1,5 +1,6 @@ import type { ClassicCommandHandler, ClassicCommandResult } from './classic-cli.js'; import { CometIntentValidationError, resolveCometIntentRoute } from './classic-intent.js'; +import { readClassicWorkflowIntensity } from './classic-project-config.js'; function result(exitCode: number, stdout?: string, stderr?: string): ClassicCommandResult { return { @@ -25,6 +26,26 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString('utf8'); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function withConfiguredWorkflowIntensity(input: unknown): Promise { + if (!isRecord(input)) return input; + const context = isRecord(input.context) ? { ...input.context } : {}; + if (context.workflow_intensity !== undefined && context.workflow_intensity !== null) { + return input; + } + const configured = await readClassicWorkflowIntensity(); + return { + ...input, + context: { + ...context, + workflow_intensity: configured.value, + }, + }; +} + export const classicIntentCommand: ClassicCommandHandler = async (args, _options) => { const [subcommand, input] = args; if (subcommand !== 'route') return usage(); @@ -33,7 +54,9 @@ export const classicIntentCommand: ClassicCommandHandler = async (args, _options if (!source) return usage(); try { - const resolution = resolveCometIntentRoute(JSON.parse(source)); + const resolution = resolveCometIntentRoute( + await withConfiguredWorkflowIntensity(JSON.parse(source)), + ); return result(0, `${JSON.stringify(resolution, null, 2)}\n`); } catch (error) { if (error instanceof SyntaxError) { @@ -42,6 +65,9 @@ export const classicIntentCommand: ClassicCommandHandler = async (args, _options if (error instanceof CometIntentValidationError) { return result(1, undefined, error.message); } + if (error instanceof Error) { + return result(1, undefined, error.message); + } throw error; } }; diff --git a/domains/comet-classic/classic-intent.ts b/domains/comet-classic/classic-intent.ts index 1ff0d8ce..89c4994c 100644 --- a/domains/comet-classic/classic-intent.ts +++ b/domains/comet-classic/classic-intent.ts @@ -31,6 +31,7 @@ const REQUESTED_ACTIONS = [ 'unknown', ] as const; const WORKFLOWS = ['full', 'hotfix', 'tweak'] as const; +const WORKFLOW_INTENSITIES = ['light', 'standard', 'thorough'] as const; const SCOPES = ['small', 'medium', 'large', 'unknown'] as const; const ROUTES = ['full', 'hotfix', 'tweak', 'resume', 'ask_user', 'out_of_scope'] as const; const NEXT_SKILLS = [ @@ -50,6 +51,7 @@ export type CometIntentName = ValueOf; export type CometIntentEntityType = ValueOf; export type CometIntentRequestedAction = ValueOf; export type CometIntentWorkflow = ValueOf; +export type CometIntentWorkflowIntensity = ValueOf; export type CometIntentScope = ValueOf; export type CometIntentRouteName = ValueOf; export type CometIntentNextSkill = ValueOf; @@ -78,6 +80,7 @@ export interface CometIntentFrame { active_changes_count: number; active_change_names: string[]; dirty_worktree: boolean | null; + workflow_intensity: CometIntentWorkflowIntensity; }; evidence: Array<{ field: string; quote: string; source: CometIntentEvidenceSource }>; proposed_route: CometIntentRoute; @@ -93,6 +96,15 @@ export interface CometIntentRoute { confidence: number; requires_confirmation: boolean; fallback_reason: string | null; + recommendation: CometIntentRecommendation | null; +} + +export interface CometIntentRecommendation { + workflow: Exclude; + next_skill: Extract; + intensity: CometIntentWorkflowIntensity; + reasons: string[]; + options: CometIntentWorkflow[]; } export interface CometIntentRouteResolution { @@ -287,6 +299,15 @@ function validateFrame(input: unknown): CometIntentFrame { 'context.dirty_worktree', issues, ), + workflow_intensity: + context.workflow_intensity === undefined || context.workflow_intensity === null + ? 'standard' + : (enumValue( + context.workflow_intensity, + WORKFLOW_INTENSITIES, + 'context.workflow_intensity', + issues, + ) ?? 'standard'), }, evidence: evidence.map((item, index) => { const record = isRecord(item) ? item : {}; @@ -320,6 +341,7 @@ function validateFrame(input: unknown): CometIntentFrame { 'proposed_route.fallback_reason', issues, ), + recommendation: null, }, }; @@ -331,6 +353,16 @@ function hasEvidence(frame: CometIntentFrame, field: string): boolean { return frame.evidence.some((item) => item.field === field && item.quote.trim() !== ''); } +function hasSmallScopeEvidence(frame: CometIntentFrame): boolean { + if (frame.slots.scope === 'small') return true; + + return frame.evidence.some( + (item) => + item.field === 'slots.scope' && + /\b(small|tiny|minor)\b|轻量|小型|较小|很小|微小|小改动|小修改/iu.test(item.quote), + ); +} + function hasRiskSignal(frame: CometIntentFrame): boolean { return ( frame.slots.new_capability === true || @@ -340,10 +372,56 @@ function hasRiskSignal(frame: CometIntentFrame): boolean { ); } +function lowRiskEvidence(frame: CometIntentFrame): boolean { + return ( + hasSmallScopeEvidence(frame) || + /docs?|prompt|wording|config|readme|small|tiny|minor/iu.test(frame.utterance) + ); +} + +function recommendationFor(frame: CometIntentFrame): CometIntentRecommendation | null { + if (hasRiskSignal(frame)) return null; + const intensity = frame.context.workflow_intensity; + const evidenceIsStrong = lowRiskEvidence(frame); + if (intensity === 'thorough' && !evidenceIsStrong) return null; + + if (frame.intent.name === 'fix_bug' && frame.slots.existing_behavior === true) { + return { + workflow: 'hotfix', + next_skill: 'comet-hotfix', + intensity, + reasons: ['existing behavior fix', `workflow_intensity=${intensity}`], + options: ['hotfix', 'full'], + }; + } + + if ( + (evidenceIsStrong || intensity === 'light') && + (frame.intent.name === 'start_change' || + frame.intent.name === 'make_tweak' || + frame.slots.requested_action === 'modify' || + frame.slots.requested_action === 'create') + ) { + return { + workflow: 'tweak', + next_skill: 'comet-tweak', + intensity, + reasons: [ + evidenceIsStrong ? 'small low-risk request' : 'low-risk request', + `workflow_intensity=${intensity}`, + ], + options: ['tweak', 'full'], + }; + } + + return null; +} + function route( name: CometIntentRouteName, confidence: number, fallback_reason: string | null = null, + recommendation: CometIntentRecommendation | null = null, ): CometIntentRoute { const nextSkill: Record = { full: 'comet-open', @@ -357,8 +435,10 @@ function route( name, next_skill: nextSkill[name], confidence, - requires_confirmation: name === 'ask_user' || name === 'out_of_scope', + requires_confirmation: + Boolean(recommendation) || name === 'ask_user' || name === 'out_of_scope', fallback_reason, + recommendation, }; } @@ -374,6 +454,7 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut const frame = validateFrame(input); const diagnostics: string[] = []; const confidence = frame.intent.confidence; + const recommendation = frame.slots.user_explicit_workflow ? null : recommendationFor(frame); let resolved: CometIntentRoute; if (frame.intent.confidence < COMET_INTENT_CONFIDENCE_THRESHOLD) { @@ -415,6 +496,8 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut resolved = workflowRoute(frame.slots.user_explicit_workflow, confidence); } else if (hasRiskSignal(frame)) { resolved = route('full', confidence); + } else if (recommendation) { + resolved = route('full', confidence, null, recommendation); } else if ( frame.intent.name === 'fix_bug' && frame.slots.existing_behavior === true && @@ -429,6 +512,8 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut resolved = route('tweak', confidence); } else if (frame.slots.workflow_candidate && hasEvidence(frame, 'slots.workflow_candidate')) { resolved = workflowRoute(frame.slots.workflow_candidate, confidence); + } else if (frame.slots.workflow_candidate === 'full') { + resolved = route('full', confidence); } else { resolved = askUser('workflow_candidate evidence is missing or route is ambiguous'); } @@ -453,6 +538,11 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut `agent proposed_route fallback_reason '${frame.proposed_route.fallback_reason}' normalized to '${resolved.fallback_reason}'`, ); } + if (resolved.recommendation?.workflow !== frame.proposed_route.recommendation?.workflow) { + diagnostics.push( + `agent proposed_route recommendation '${frame.proposed_route.recommendation?.workflow ?? null}' normalized to '${resolved.recommendation?.workflow ?? null}'`, + ); + } return { route: resolved, diff --git a/domains/comet-classic/classic-project-config.ts b/domains/comet-classic/classic-project-config.ts index cd6e2380..85c3a56d 100644 --- a/domains/comet-classic/classic-project-config.ts +++ b/domains/comet-classic/classic-project-config.ts @@ -12,6 +12,15 @@ type ClassicConfigOptions = { homeDir?: string; }; +const WORKFLOW_INTENSITIES = ['light', 'standard', 'thorough'] as const; + +type ClassicWorkflowIntensity = (typeof WORKFLOW_INTENSITIES)[number]; + +type ClassicWorkflowIntensityValue = { + value: ClassicWorkflowIntensity; + source: string; +}; + function configCandidates(options: ClassicConfigOptions = {}): Array<{ file: string; source: string; @@ -54,5 +63,26 @@ async function readClassicConfigValue( return null; } -export { configCandidates, readClassicConfigValue }; -export type { ClassicConfigOptions, ClassicConfigValue }; +async function readClassicWorkflowIntensity( + options: ClassicConfigOptions = {}, +): Promise { + const configured = await readClassicConfigValue('workflow_intensity', options); + if (!configured) return { value: 'standard', source: 'default' }; + if (!WORKFLOW_INTENSITIES.includes(configured.value as ClassicWorkflowIntensity)) { + throw new Error( + `classic.workflow_intensity must be light, standard, or thorough, got '${configured.value}' from ${configured.source}`, + ); + } + return { + value: configured.value as ClassicWorkflowIntensity, + source: configured.source, + }; +} + +export { configCandidates, readClassicConfigValue, readClassicWorkflowIntensity }; +export type { + ClassicConfigOptions, + ClassicConfigValue, + ClassicWorkflowIntensity, + ClassicWorkflowIntensityValue, +}; diff --git a/domains/skill/platform-install.ts b/domains/skill/platform-install.ts index 4b6bacb3..41497fed 100644 --- a/domains/skill/platform-install.ts +++ b/domains/skill/platform-install.ts @@ -1601,6 +1601,11 @@ function managedConfigFields(language: string = 'en'): ManagedConfigFields { def: 'off', comment: projectConfigComment('classic.context_compression', commentLanguage), }, + { + key: 'workflow_intensity', + def: 'standard', + comment: projectConfigComment('classic.workflow_intensity', commentLanguage), + }, { key: 'review_mode', def: 'standard', diff --git a/domains/workflow-contract/project-config.ts b/domains/workflow-contract/project-config.ts index 7a9744ef..07c7b776 100644 --- a/domains/workflow-contract/project-config.ts +++ b/domains/workflow-contract/project-config.ts @@ -48,6 +48,7 @@ type ProjectConfigCommentKey = | 'classic.artifact_layout' | 'classic.language' | 'classic.context_compression' + | 'classic.workflow_intensity' | 'classic.review_mode' | 'classic.auto_transition'; @@ -88,6 +89,8 @@ const COMMENTS: Record = {}): CometIntentFrame { confidence: 0.9, requires_confirmation: false, fallback_reason: null, + recommendation: null, }, }; return { @@ -53,14 +58,20 @@ function frame(overrides: Partial = {}): CometIntentFrame { } describe('resolveCometIntentRoute', () => { - it('routes existing bug fixes to hotfix', () => { + it('recommends hotfix confirmation for existing bug fixes', () => { const result = resolveCometIntentRoute(frame()); expect(result.route).toMatchObject({ - name: 'hotfix', - next_skill: 'comet-hotfix', - requires_confirmation: false, + name: 'full', + next_skill: 'comet-open', + requires_confirmation: true, + recommendation: { + workflow: 'hotfix', + next_skill: 'comet-hotfix', + intensity: 'standard', + }, }); + expect(result.route.recommendation?.reasons.join(' ')).toContain('existing behavior'); }); it('accepts compact frames and normalizes omitted optional fields', () => { @@ -90,7 +101,11 @@ describe('resolveCometIntentRoute', () => { proposed_route: { name: 'hotfix', confidence: 0.9 }, }); - expect(result.route).toMatchObject({ name: 'hotfix', next_skill: 'comet-hotfix' }); + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + recommendation: { workflow: 'hotfix', next_skill: 'comet-hotfix' }, + }); expect(result.normalizedFrame).toMatchObject({ locale: 'unknown', entities: [], @@ -104,7 +119,7 @@ describe('resolveCometIntentRoute', () => { }); }); - it('routes doc, config, and prompt changes to tweak', () => { + it('recommends tweak confirmation for doc, config, and prompt changes', () => { const result = resolveCometIntentRoute( frame({ utterance: 'tweak the comet prompt wording', @@ -124,7 +139,184 @@ describe('resolveCometIntentRoute', () => { }), ); - expect(result.route).toMatchObject({ name: 'tweak', next_skill: 'comet-tweak' }); + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: true, + recommendation: { + workflow: 'tweak', + next_skill: 'comet-tweak', + intensity: 'standard', + }, + }); + expect(result.route.recommendation?.reasons.join(' ')).toContain('small'); + }); + + it('does not offer hotfix for ordinary tweak recommendations', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'tweak the README wording', + intent: { name: 'make_tweak', confidence: 0.89 }, + entities: [{ type: 'file_path', value: 'README.md', text: 'README' }], + slots: { + requested_action: 'modify', + workflow_candidate: 'tweak', + existing_behavior: null, + target_area: 'README wording', + }, + evidence: [ + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'README wording', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.88 }, + }), + ); + + expect(result.route.recommendation).toMatchObject({ + workflow: 'tweak', + options: ['tweak', 'full'], + }); + }); + + it('routes explicit hotfix directly when no risk signals conflict', () => { + const result = resolveCometIntentRoute( + frame({ + slots: { user_explicit_workflow: 'hotfix' }, + evidence: [ + { field: 'slots.user_explicit_workflow', quote: 'hotfix', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'regression', source: 'user' }, + ], + }), + ); + + expect(result.route).toMatchObject({ + name: 'hotfix', + next_skill: 'comet-hotfix', + requires_confirmation: false, + recommendation: null, + }); + }); + + it('recommends tweak confirmation for small low-risk work under standard intensity', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'add a small docs note', + intent: { name: 'start_change', confidence: 0.92 }, + entities: [{ type: 'file_path', value: 'README.md', text: 'docs' }], + slots: { + requested_action: 'modify', + workflow_candidate: 'full', + scope: 'small', + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + evidence: [ + { field: 'intent.name', quote: 'add', source: 'user' }, + { field: 'slots.scope', quote: 'small docs note', source: 'user' }, + ], + proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + }), + ); + + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: true, + recommendation: { + workflow: 'tweak', + next_skill: 'comet-tweak', + intensity: 'standard', + }, + }); + expect(result.route.recommendation?.reasons.join(' ')).toContain('small'); + }); + + it('does not treat medium or large scope evidence as low-risk evidence', () => { + for (const scope of ['medium', 'large'] as const) { + const result = resolveCometIntentRoute( + frame({ + utterance: `${scope} workflow update`, + intent: { name: 'start_change', confidence: 0.92 }, + slots: { + requested_action: 'modify', + workflow_candidate: 'full', + scope, + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + evidence: [ + { field: 'intent.name', quote: 'update', source: 'user' }, + { field: 'slots.scope', quote: `${scope} workflow update`, source: 'user' }, + ], + proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + }), + ); + + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: false, + recommendation: null, + }); + } + }); + + it('keeps thorough intensity conservative for unclear small work', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'modify the workflow', + intent: { name: 'start_change', confidence: 0.92 }, + slots: { + requested_action: 'modify', + workflow_candidate: 'full', + scope: 'unknown', + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + context: { workflow_intensity: 'thorough' } as never, + evidence: [{ field: 'intent.name', quote: 'modify', source: 'user' }], + proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + }), + ); + + expect(result.route.name).toBe('full'); + expect(result.route.requires_confirmation).toBe(false); + expect(result.route.recommendation).toBeNull(); + }); + + it('does not recommend lightweight paths when risk signals are present', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'add a small public API', + intent: { name: 'start_change', confidence: 0.92 }, + slots: { + requested_action: 'create', + workflow_candidate: 'full', + scope: 'small', + existing_behavior: false, + new_capability: true, + public_api_change: true, + }, + context: { workflow_intensity: 'light' } as never, + evidence: [ + { field: 'slots.scope', quote: 'small', source: 'user' }, + { field: 'slots.public_api_change', quote: 'public API', source: 'user' }, + ], + proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + }), + ); + + expect(result.route).toMatchObject({ name: 'full', next_skill: 'comet-open' }); + expect(result.route.recommendation).toBeNull(); }); it('routes new capability and public API risk signals to full', () => { @@ -165,9 +357,10 @@ describe('resolveCometIntentRoute', () => { }), ); - expect(result.route.name).toBe('hotfix'); + expect(result.route.name).toBe('full'); expect(result.route.fallback_reason).toBeNull(); - expect(result.route.next_skill).toBe('comet-hotfix'); + expect(result.route.next_skill).toBe('comet-open'); + expect(result.route.recommendation?.workflow).toBe('hotfix'); expect(result.diagnostics.some((diagnostic) => diagnostic.includes('ask_user'))).toBe(true); }); @@ -278,7 +471,7 @@ describe('resolveCometIntentRoute', () => { expect(action).toThrow(/active_change_names must only contain strings/); }); - it('records diagnostics when next_skill/requires_confirmation/fallback_reason are normalized', () => { + it('records diagnostics when route metadata is normalized', () => { const result = resolveCometIntentRoute( frame({ proposed_route: { @@ -291,13 +484,67 @@ describe('resolveCometIntentRoute', () => { }), ); - expect(result.route.name).toBe('hotfix'); + expect(result.route.name).toBe('full'); + expect(result.route.recommendation?.workflow).toBe('hotfix'); expect(result.diagnostics).toEqual( expect.arrayContaining([ - "agent proposed_route next_skill 'comet-open' normalized to 'comet-hotfix'", - "agent proposed_route requires_confirmation 'true' normalized to 'false'", + "agent proposed_route 'hotfix' normalized to 'full'", "agent proposed_route fallback_reason 'manual set' normalized to 'null'", + "agent proposed_route recommendation 'null' normalized to 'hotfix'", ]), ); }); + + it('intent command applies workflow_intensity from Classic project config', async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-intent-command-')); + const previousCwd = process.cwd(); + try { + await fs.mkdir(path.join(projectRoot, '.comet'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, '.comet', 'config.yaml'), + 'classic:\n workflow_intensity: thorough\n', + 'utf8', + ); + process.chdir(projectRoot); + + const result = await classicIntentCommand( + [ + 'route', + JSON.stringify( + frame({ + utterance: 'modify the workflow', + intent: { name: 'start_change', confidence: 0.92 }, + slots: { + requested_action: 'modify', + workflow_candidate: 'full', + scope: 'unknown', + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + evidence: [{ field: 'intent.name', quote: 'modify', source: 'user' }], + proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + }), + ), + ], + { json: false }, + ); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout ?? '{}') as ReturnType< + typeof resolveCometIntentRoute + >; + expect(payload.normalizedFrame.context.workflow_intensity).toBe('thorough'); + expect(payload.route).toMatchObject({ + name: 'full', + requires_confirmation: false, + recommendation: null, + }); + } finally { + process.chdir(previousCwd); + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); }); diff --git a/test/domains/comet-classic/classic-project-config.test.ts b/test/domains/comet-classic/classic-project-config.test.ts index 0d552969..24cd50db 100644 --- a/test/domains/comet-classic/classic-project-config.test.ts +++ b/test/domains/comet-classic/classic-project-config.test.ts @@ -3,7 +3,10 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; -import { readClassicConfigValue } from '../../../domains/comet-classic/classic-project-config.js'; +import { + readClassicConfigValue, + readClassicWorkflowIntensity, +} from '../../../domains/comet-classic/classic-project-config.js'; describe('Classic project config', () => { let projectRoot: string; @@ -91,4 +94,28 @@ describe('Classic project config', () => { await fs.rm(outsideRoot, { recursive: true, force: true }); } }); + + it('reads workflow_intensity from the nested Classic block', async () => { + await writeConfig(projectRoot, 'classic:\n workflow_intensity: light\n'); + + await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ + value: 'light', + source: '.comet/config.yaml', + }); + }); + + it('defaults workflow_intensity to standard when absent', async () => { + await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ + value: 'standard', + source: 'default', + }); + }); + + it('rejects invalid workflow_intensity values', async () => { + await writeConfig(projectRoot, 'classic:\n workflow_intensity: fast\n'); + + await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).rejects.toThrow( + /workflow_intensity must be light, standard, or thorough/u, + ); + }); }); diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index 8a67a672..b94e04b8 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1584,14 +1584,14 @@ describe('skills', () => { '若当前平台没有结构化提问工具,则必须在对话中提出明确选项并停止流程', ); expect(zhDecisionPoint).toContain('不得用推荐规则、默认值、历史偏好'); - expect(zhOpen).toContain('### 1b. 需求与 Change 名称解析(默认不阻塞)'); + expect(zhOpen).toContain('### 1c. 需求与 Change 名称解析(默认不阻塞)'); expect(zhOpen).toContain('范围与命名都明确时直接继续'); expect(zhOpen).toContain('`comet/reference/decision-point.md`'); expect(zhOpen).toContain( '完整 `/comet-classic` 流程默认不得使用 Skill 工具加载 `openspec-propose` 技能', ); expect(zhOpen).toContain( - '当 Step 1b 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为', + '当 Step 1c 已形成范围明确的 resolved brief 时,覆盖其"STOP and wait for user direction"行为', ); expect(zhOpen).not.toContain('OpenSpec artifact 指令'); expect(zhOpen).not.toContain('fast-forward'); @@ -1754,7 +1754,7 @@ describe('skills', () => { expect(zhBuild).not.toContain('`/opsx:new` 创建独立 change'); // CRITICAL: open phase PRD split must happen before OpenSpec artifacts are created - expect(zhOpen).toContain('### 1a. PRD 拆分预检(阻塞点)'); + expect(zhOpen).toContain('### 1b. PRD 拆分预检(阻塞点)'); expect(zhOpen).toContain('创建多个 OpenSpec changes'); expect(zhOpen).toContain('保持为一个 change'); expect(zhOpen).toContain('调整拆分方案后继续'); @@ -1970,7 +1970,7 @@ describe('skills', () => { 'Never substitute recommendation rules, defaults, historical preferences', ); expect(enOpen).toContain( - '### 1b. Resolve Requirements and Change Name (Non-blocking by Default)', + '### 1c. Resolve Requirements and Change Name (Non-blocking by Default)', ); expect(enOpen).toContain( 'Do not run `comet classic openspec -- new change` or create proposal/design/tasks while the resolved brief or name remains ambiguous', @@ -1980,7 +1980,7 @@ describe('skills', () => { ); expect(enOpen).toContain('`comet/reference/decision-point.md`'); expect(enOpen).toContain( - 'When Step 1b has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior', + 'When Step 1c has produced an unambiguous resolved brief, override its "STOP and wait for user direction" behavior', ); expect(enOpen).toContain( 'The clarification summary must include: goals, non-goals, scope boundaries, key unknowns, and draft acceptance scenarios', @@ -2147,7 +2147,7 @@ describe('skills', () => { ); expect(enBuild).toContain('create independent change through `/comet-open`'); expect(enBuild).not.toContain('create independent change through `/opsx:new`'); - expect(enOpen).toContain('### 1a. PRD Split Preflight (Blocking Point)'); + expect(enOpen).toContain('### 1b. PRD Split Preflight (Blocking Point)'); expect(enOpen).toContain('Create multiple OpenSpec changes'); expect(enOpen).toContain('Keep everything as one change'); expect(enOpen).toContain('Adjust the split plan before continuing'); diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts index 2353f222..004af63c 100644 --- a/test/domains/skill/workflow-optimization-contract.test.ts +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -35,6 +35,30 @@ describe('Comet workflow optimization contracts', () => { }, ); + it.each([ + [ + '中文', + zhSkillRoot, + '### 1a. 轻量路径推荐确认(阻塞点)', + '不得在轻量路径推荐确认前运行 `openspec new change`', + ], + [ + 'English', + skillRoot, + '### 1a. Lightweight Path Recommendation Confirmation (Blocking Point)', + 'must not run `openspec new change` before lightweight path recommendation confirmation', + ], + ])( + '%s open flow confirms lightweight recommendation before artifact creation', + async (_language, root, heading, guard) => { + const skill = await readSkill(root, 'comet-open'); + + expect(skill).toContain(heading); + expect(skill).toContain(guard); + expect(skill.indexOf(heading)).toBeLessThan(skill.indexOf('openspec new change')); + }, + ); + it.each([ ['中文', zhSkillRoot, 'Design Doc 和状态证据落盘后', '无法程序化触发时不得阻塞'], [ @@ -252,14 +276,14 @@ describe('Comet workflow optimization contracts', () => { [ '中文', zhSkillRoot, - '### 1b. 需求与 Change 名称解析(默认不阻塞)', + '### 1c. 需求与 Change 名称解析(默认不阻塞)', '范围与命名都明确时直接继续', '最终审视同时确认 change 名称、范围和产物内容', ], [ 'English', skillRoot, - '### 1b. Resolve Requirements and Change Name (Non-blocking by Default)', + '### 1c. Resolve Requirements and Change Name (Non-blocking by Default)', 'Continue directly when scope and naming are both unambiguous', 'The final review confirms the change name, scope, and artifact content together', ], From 65ce5fb3b4281ed309707137883794b5631d5147 Mon Sep 17 00:00:00 2001 From: CYJiang Date: Wed, 29 Jul 2026 15:46:25 +0800 Subject: [PATCH 2/3] fix: tighten lightweight workflow recommendations --- assets/skills-zh/comet-open/SKILL.md | 8 +-- assets/skills/comet-open/SKILL.md | 8 +-- assets/skills/comet/scripts/comet-runtime.mjs | 24 +++---- .../comet-classic/classic-intent-command.ts | 21 ++++-- domains/comet-classic/classic-intent.ts | 10 +-- .../comet-classic/classic-project-config.ts | 4 +- .../comet-classic/classic-intent.test.ts | 65 ++++++++++++++++--- .../classic-project-config.test.ts | 19 +++--- .../comet-classic/classic-runtime.test.ts | 14 +++- .../workflow-optimization-contract.test.ts | 1 + 10 files changed, 123 insertions(+), 51 deletions(-) diff --git a/assets/skills-zh/comet-open/SKILL.md b/assets/skills-zh/comet-open/SKILL.md index 75a80c83..2c391430 100644 --- a/assets/skills-zh/comet-open/SKILL.md +++ b/assets/skills-zh/comet-open/SKILL.md @@ -61,10 +61,10 @@ comet classic openspec -- --version 当 Comet intent runtime 返回轻量路径推荐 metadata 时,必须在创建 OpenSpec artifacts 前展示推荐理由,并按用户决策点协议暂停等待用户选择。 -选项必须互斥: -- 「切换到 `/comet-tweak`」— 由 tweak workflow 创建并拥有 change -- 「切换到 `/comet-hotfix`」— 仅当请求是已有行为修复或回归时可选 -- 「继续 full `/comet-open`」— 保持完整 OpenSpec + Design 流程 +选项必须直接来自 runtime 返回的 `recommendation.options`,不得自行推断或硬编码 tweak/hotfix 可用性。每个选项必须互斥: +- `tweak` → 「切换到 `/comet-tweak`」,由 tweak workflow 创建并拥有 change +- `hotfix` → 「切换到 `/comet-hotfix`」,仅当 runtime recommendation options 包含 `hotfix` 时展示 +- `full` → 「继续 full `/comet-open`」,保持完整 OpenSpec + Design 流程 不得在轻量路径推荐确认前运行 `openspec new change`,不得创建 proposal/design/tasks,也不得自动把 full 请求降级为 tweak/hotfix。 diff --git a/assets/skills/comet-open/SKILL.md b/assets/skills/comet-open/SKILL.md index 619ebcef..43c237ec 100644 --- a/assets/skills/comet-open/SKILL.md +++ b/assets/skills/comet-open/SKILL.md @@ -61,10 +61,10 @@ The clarification summary must include: goals, non-goals, scope boundaries, key When the Comet intent runtime returns lightweight path recommendation metadata, present the recommendation reasons before creating OpenSpec artifacts, then pause under the user decision-point protocol. -Options must be mutually exclusive: -- "Switch to `/comet-tweak`" — the tweak workflow creates and owns the change -- "Switch to `/comet-hotfix`" — only available for existing behavior fixes or regressions -- "Continue full `/comet-open`" — keep the full OpenSpec + Design workflow +Options must be built directly from the runtime's `recommendation.options`; do not infer or hardcode tweak/hotfix availability independently. Each option must be mutually exclusive: +- `tweak` -> "Switch to `/comet-tweak`" — the tweak workflow creates and owns the change +- `hotfix` -> "Switch to `/comet-hotfix`" — shown only when runtime recommendation options include `hotfix` +- `full` -> "Continue full `/comet-open`" — keep the full OpenSpec + Design workflow The agent must not run `openspec new change` before lightweight path recommendation confirmation, must not create proposal/design/tasks, and must not automatically downgrade a full request to tweak/hotfix. diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 8717b104..92d2e3f7 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -12944,7 +12944,7 @@ import os from "os"; import path26 from "path"; var WORKFLOW_INTENSITIES = ["light", "standard", "thorough"]; function configCandidates(options = {}) { - const cwd = options.cwd ?? process.cwd(); + const cwd = options.projectRoot ?? options.cwd ?? options.invocationCwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); const candidates = [ { file: path26.resolve(cwd, ".comet", "config.yaml"), source: ".comet/config.yaml" }, @@ -15341,7 +15341,7 @@ function lowRiskEvidence(frame) { return hasSmallScopeEvidence(frame) || /docs?|prompt|wording|config|readme|small|tiny|minor/iu.test(frame.utterance); } function recommendationFor(frame) { - if (hasRiskSignal(frame)) return null; + if (hasRiskSignal(frame) || !hasSmallScopeEvidence(frame)) return null; const intensity = frame.context.workflow_intensity; const evidenceIsStrong = lowRiskEvidence(frame); if (intensity === "thorough" && !evidenceIsStrong) return null; @@ -15354,7 +15354,7 @@ function recommendationFor(frame) { options: ["hotfix", "full"] }; } - if ((evidenceIsStrong || intensity === "light") && (frame.intent.name === "start_change" || frame.intent.name === "make_tweak" || frame.slots.requested_action === "modify" || frame.slots.requested_action === "create")) { + if (evidenceIsStrong && (frame.intent.name === "start_change" || frame.intent.name === "make_tweak" || frame.slots.requested_action === "modify" || frame.slots.requested_action === "create")) { return { workflow: "tweak", next_skill: "comet-tweak", @@ -15423,11 +15423,11 @@ function resolveCometIntentRoute(input) { } else if (recommendation) { resolved = route("full", confidence, null, recommendation); } else if (frame.intent.name === "fix_bug" && frame.slots.existing_behavior === true && hasEvidence(frame, "slots.workflow_candidate")) { - resolved = route("hotfix", confidence); + resolved = route("full", confidence); } else if (frame.intent.name === "make_tweak" && frame.slots.workflow_candidate === "tweak" && hasEvidence(frame, "slots.workflow_candidate")) { - resolved = route("tweak", confidence); + resolved = route("full", confidence); } else if (frame.slots.workflow_candidate && hasEvidence(frame, "slots.workflow_candidate")) { - resolved = workflowRoute(frame.slots.workflow_candidate, confidence); + resolved = route("full", confidence); } else if (frame.slots.workflow_candidate === "full") { resolved = route("full", confidence); } else { @@ -15490,13 +15490,13 @@ async function readStdin() { function isRecord3(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -async function withConfiguredWorkflowIntensity(input) { - if (!isRecord3(input)) return input; - const context = isRecord3(input.context) ? { ...input.context } : {}; +async function withConfiguredWorkflowIntensity(input, options = {}) { + if (!isRecord3(input) || !isRecord3(input.context)) return input; + const context = { ...input.context }; if (context.workflow_intensity !== void 0 && context.workflow_intensity !== null) { return input; } - const configured = await readClassicWorkflowIntensity(); + const configured = await readClassicWorkflowIntensity(options); return { ...input, context: { @@ -15505,14 +15505,14 @@ async function withConfiguredWorkflowIntensity(input) { } }; } -var classicIntentCommand = async (args, _options) => { +var classicIntentCommand = async (args, options) => { const [subcommand, input] = args; if (subcommand !== "route") return usage(); const source = input === "--stdin" ? await readStdin() : input; if (!source) return usage(); try { const resolution = resolveCometIntentRoute( - await withConfiguredWorkflowIntensity(JSON.parse(source)) + await withConfiguredWorkflowIntensity(JSON.parse(source), options) ); return result2(0, `${JSON.stringify(resolution, null, 2)} `); diff --git a/domains/comet-classic/classic-intent-command.ts b/domains/comet-classic/classic-intent-command.ts index 0fb557d7..7420de47 100644 --- a/domains/comet-classic/classic-intent-command.ts +++ b/domains/comet-classic/classic-intent-command.ts @@ -1,4 +1,8 @@ -import type { ClassicCommandHandler, ClassicCommandResult } from './classic-cli.js'; +import type { + ClassicCommandHandler, + ClassicCommandOptions, + ClassicCommandResult, +} from './classic-cli.js'; import { CometIntentValidationError, resolveCometIntentRoute } from './classic-intent.js'; import { readClassicWorkflowIntensity } from './classic-project-config.js'; @@ -30,13 +34,16 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -async function withConfiguredWorkflowIntensity(input: unknown): Promise { - if (!isRecord(input)) return input; - const context = isRecord(input.context) ? { ...input.context } : {}; +async function withConfiguredWorkflowIntensity( + input: unknown, + options: Pick = {}, +): Promise { + if (!isRecord(input) || !isRecord(input.context)) return input; + const context = { ...input.context }; if (context.workflow_intensity !== undefined && context.workflow_intensity !== null) { return input; } - const configured = await readClassicWorkflowIntensity(); + const configured = await readClassicWorkflowIntensity(options); return { ...input, context: { @@ -46,7 +53,7 @@ async function withConfiguredWorkflowIntensity(input: unknown): Promise }; } -export const classicIntentCommand: ClassicCommandHandler = async (args, _options) => { +export const classicIntentCommand: ClassicCommandHandler = async (args, options) => { const [subcommand, input] = args; if (subcommand !== 'route') return usage(); @@ -55,7 +62,7 @@ export const classicIntentCommand: ClassicCommandHandler = async (args, _options try { const resolution = resolveCometIntentRoute( - await withConfiguredWorkflowIntensity(JSON.parse(source)), + await withConfiguredWorkflowIntensity(JSON.parse(source), options), ); return result(0, `${JSON.stringify(resolution, null, 2)}\n`); } catch (error) { diff --git a/domains/comet-classic/classic-intent.ts b/domains/comet-classic/classic-intent.ts index 89c4994c..fcd14532 100644 --- a/domains/comet-classic/classic-intent.ts +++ b/domains/comet-classic/classic-intent.ts @@ -380,7 +380,7 @@ function lowRiskEvidence(frame: CometIntentFrame): boolean { } function recommendationFor(frame: CometIntentFrame): CometIntentRecommendation | null { - if (hasRiskSignal(frame)) return null; + if (hasRiskSignal(frame) || !hasSmallScopeEvidence(frame)) return null; const intensity = frame.context.workflow_intensity; const evidenceIsStrong = lowRiskEvidence(frame); if (intensity === 'thorough' && !evidenceIsStrong) return null; @@ -396,7 +396,7 @@ function recommendationFor(frame: CometIntentFrame): CometIntentRecommendation | } if ( - (evidenceIsStrong || intensity === 'light') && + evidenceIsStrong && (frame.intent.name === 'start_change' || frame.intent.name === 'make_tweak' || frame.slots.requested_action === 'modify' || @@ -503,15 +503,15 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut frame.slots.existing_behavior === true && hasEvidence(frame, 'slots.workflow_candidate') ) { - resolved = route('hotfix', confidence); + resolved = route('full', confidence); } else if ( frame.intent.name === 'make_tweak' && frame.slots.workflow_candidate === 'tweak' && hasEvidence(frame, 'slots.workflow_candidate') ) { - resolved = route('tweak', confidence); + resolved = route('full', confidence); } else if (frame.slots.workflow_candidate && hasEvidence(frame, 'slots.workflow_candidate')) { - resolved = workflowRoute(frame.slots.workflow_candidate, confidence); + resolved = route('full', confidence); } else if (frame.slots.workflow_candidate === 'full') { resolved = route('full', confidence); } else { diff --git a/domains/comet-classic/classic-project-config.ts b/domains/comet-classic/classic-project-config.ts index 85c3a56d..aaca2dc0 100644 --- a/domains/comet-classic/classic-project-config.ts +++ b/domains/comet-classic/classic-project-config.ts @@ -8,6 +8,8 @@ type ClassicConfigValue = { }; type ClassicConfigOptions = { + invocationCwd?: string; + projectRoot?: string; cwd?: string; homeDir?: string; }; @@ -25,7 +27,7 @@ function configCandidates(options: ClassicConfigOptions = {}): Array<{ file: string; source: string; }> { - const cwd = options.cwd ?? process.cwd(); + const cwd = options.projectRoot ?? options.cwd ?? options.invocationCwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); const candidates = [ { file: path.resolve(cwd, '.comet', 'config.yaml'), source: '.comet/config.yaml' }, diff --git a/test/domains/comet-classic/classic-intent.test.ts b/test/domains/comet-classic/classic-intent.test.ts index f5d4d0dd..d5b0d618 100644 --- a/test/domains/comet-classic/classic-intent.test.ts +++ b/test/domains/comet-classic/classic-intent.test.ts @@ -9,7 +9,16 @@ import { type CometIntentFrame, } from '../../../domains/comet-classic/classic-intent.js'; -function frame(overrides: Partial = {}): CometIntentFrame { +type FrameOverrides = Partial< + Omit +> & { + intent?: Partial; + slots?: Partial; + context?: Partial; + proposed_route?: Partial; +}; + +function frame(overrides: FrameOverrides = {}): CometIntentFrame { const base: CometIntentFrame = { schema_version: 'comet.intent.v1', utterance: 'fix the failing comet guard regression', @@ -104,7 +113,7 @@ describe('resolveCometIntentRoute', () => { expect(result.route).toMatchObject({ name: 'full', next_skill: 'comet-open', - recommendation: { workflow: 'hotfix', next_skill: 'comet-hotfix' }, + recommendation: null, }); expect(result.normalizedFrame).toMatchObject({ locale: 'unknown', @@ -282,7 +291,7 @@ describe('resolveCometIntentRoute', () => { schema_change: false, cross_module_change: false, }, - context: { workflow_intensity: 'thorough' } as never, + context: { workflow_intensity: 'thorough' }, evidence: [{ field: 'intent.name', quote: 'modify', source: 'user' }], proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, }), @@ -306,7 +315,7 @@ describe('resolveCometIntentRoute', () => { new_capability: true, public_api_change: true, }, - context: { workflow_intensity: 'light' } as never, + context: { workflow_intensity: 'light' }, evidence: [ { field: 'slots.scope', quote: 'small', source: 'user' }, { field: 'slots.public_api_change', quote: 'public API', source: 'user' }, @@ -319,6 +328,49 @@ describe('resolveCometIntentRoute', () => { expect(result.route.recommendation).toBeNull(); }); + it('keeps thorough intensity on full for non-small bug or tweak candidates', () => { + for (const candidate of ['hotfix', 'tweak'] as const) { + const result = resolveCometIntentRoute( + frame({ + utterance: + candidate === 'hotfix' ? 'fix the workflow regression' : 'tweak the workflow behavior', + intent: { name: candidate === 'hotfix' ? 'fix_bug' : 'make_tweak', confidence: 0.92 }, + slots: { + requested_action: candidate === 'hotfix' ? 'fix' : 'modify', + workflow_candidate: candidate, + scope: 'unknown', + existing_behavior: candidate === 'hotfix' ? true : null, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + context: { workflow_intensity: 'thorough' }, + evidence: [ + { + field: 'intent.name', + quote: candidate === 'hotfix' ? 'fix' : 'tweak', + source: 'user', + }, + { field: 'slots.workflow_candidate', quote: candidate, source: 'user' }, + ], + proposed_route: { + name: candidate, + next_skill: `comet-${candidate}`, + confidence: 0.9, + }, + }), + ); + + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: false, + recommendation: null, + }); + } + }); + it('routes new capability and public API risk signals to full', () => { const result = resolveCometIntentRoute( frame({ @@ -497,7 +549,6 @@ describe('resolveCometIntentRoute', () => { it('intent command applies workflow_intensity from Classic project config', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-intent-command-')); - const previousCwd = process.cwd(); try { await fs.mkdir(path.join(projectRoot, '.comet'), { recursive: true }); await fs.writeFile( @@ -505,7 +556,6 @@ describe('resolveCometIntentRoute', () => { 'classic:\n workflow_intensity: thorough\n', 'utf8', ); - process.chdir(projectRoot); const result = await classicIntentCommand( [ @@ -529,7 +579,7 @@ describe('resolveCometIntentRoute', () => { }), ), ], - { json: false }, + { json: false, projectRoot }, ); expect(result.exitCode).toBe(0); @@ -543,7 +593,6 @@ describe('resolveCometIntentRoute', () => { recommendation: null, }); } finally { - process.chdir(previousCwd); await fs.rm(projectRoot, { recursive: true, force: true }); } }); diff --git a/test/domains/comet-classic/classic-project-config.test.ts b/test/domains/comet-classic/classic-project-config.test.ts index 24cd50db..bae58f4b 100644 --- a/test/domains/comet-classic/classic-project-config.test.ts +++ b/test/domains/comet-classic/classic-project-config.test.ts @@ -95,14 +95,17 @@ describe('Classic project config', () => { } }); - it('reads workflow_intensity from the nested Classic block', async () => { - await writeConfig(projectRoot, 'classic:\n workflow_intensity: light\n'); - - await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ - value: 'light', - source: '.comet/config.yaml', - }); - }); + it.each(['light', 'standard', 'thorough'] as const)( + 'reads workflow_intensity=%s from the nested Classic block', + async (value) => { + await writeConfig(projectRoot, `classic:\n workflow_intensity: ${value}\n`); + + await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ + value, + source: '.comet/config.yaml', + }); + }, + ); it('defaults workflow_intensity to standard when absent', async () => { await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ diff --git a/test/domains/comet-classic/classic-runtime.test.ts b/test/domains/comet-classic/classic-runtime.test.ts index d56c2ccb..aede4270 100644 --- a/test/domains/comet-classic/classic-runtime.test.ts +++ b/test/domains/comet-classic/classic-runtime.test.ts @@ -536,7 +536,12 @@ describe('Classic runtime CLI adapter', () => { expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout ?? '')).toMatchObject({ - route: { name: 'hotfix', next_skill: 'comet-hotfix' }, + route: { + name: 'full', + next_skill: 'comet-open', + requires_confirmation: true, + recommendation: { workflow: 'hotfix', next_skill: 'comet-hotfix' }, + }, }); }); @@ -594,7 +599,12 @@ describe('Classic runtime CLI adapter', () => { expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout ?? '')).toMatchObject({ - route: { name: 'hotfix', next_skill: 'comet-hotfix' }, + route: { + name: 'full', + next_skill: 'comet-open', + requires_confirmation: true, + recommendation: { workflow: 'hotfix', next_skill: 'comet-hotfix' }, + }, }); }); }); diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts index 004af63c..c404b499 100644 --- a/test/domains/skill/workflow-optimization-contract.test.ts +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -56,6 +56,7 @@ describe('Comet workflow optimization contracts', () => { expect(skill).toContain(heading); expect(skill).toContain(guard); expect(skill.indexOf(heading)).toBeLessThan(skill.indexOf('openspec new change')); + expect(skill.indexOf(guard)).toBeLessThan(skill.indexOf('openspec new change')); }, ); From f55519b68d2733542186fde3ff8c66049fb2e7de Mon Sep 17 00:00:00 2001 From: CYJiang Date: Wed, 29 Jul 2026 22:12:59 +0800 Subject: [PATCH 3/3] fix: align workflow recommendations with evidence --- CHANGELOG.md | 2 +- README-zh.md | 2 +- README.md | 2 +- assets/manifest.json | 2 +- assets/skills-zh/comet-open/SKILL.md | 2 +- .../scripts/comet-native-runtime.mjs | 11 +- assets/skills/comet-open/SKILL.md | 2 +- .../comet/scripts/comet-entry-runtime.mjs | 5 + .../comet/scripts/comet-hook-router.mjs | 5 + assets/skills/comet/scripts/comet-runtime.mjs | 97 +++---- .../comet-classic/classic-intent-command.ts | 15 +- domains/comet-classic/classic-intent.ts | 89 +++---- .../comet-classic/classic-project-config.ts | 42 ++- domains/skill/platform-install.ts | 6 +- domains/workflow-contract/project-config.ts | 21 +- domains/workflow-contract/types.ts | 1 + package-lock.json | 4 +- package.json | 2 +- test/app/cli-help.test.ts | 2 +- .../comet-classic/classic-intent.test.ts | 250 +++++++++++------- .../classic-project-config.test.ts | 37 +-- .../comet-native/native-config.test.ts | 1 + test/domains/skill/skills.test.ts | 13 +- .../workflow-optimization-contract.test.ts | 12 +- .../workflow-contract.test.ts | 5 +- test/repository/release-metadata.test.ts | 2 +- 26 files changed, 373 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b35903b..56fd583b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Added -- **Lightweight workflow recommendations**: Classic Comet now exposes `classic.workflow_intensity` and typed route recommendation metadata so evidence-backed tweak or hotfix candidates can present confirmed lightweight workflow options before continuing full open, while risk signals keep the conservative full workflow path. +- **Lightweight workflow recommendations**: Classic Comet now exposes `classic.recommend_lightweight_workflows` and typed route recommendation metadata so evidence-backed tweak or hotfix candidates can present confirmed lightweight workflow options before continuing full open, while risk signals keep the conservative full workflow path. ## What's Changed [0.4.0-beta.11] - 2026-07-29 diff --git a/README-zh.md b/README-zh.md index 39c4ba3b..4f5cd5f5 100644 --- a/README-zh.md +++ b/README-zh.md @@ -143,7 +143,7 @@ classic: artifact_layout: docs language: zh-CN context_compression: off - workflow_intensity: standard + recommend_lightweight_workflows: true review_mode: standard auto_transition: true ``` diff --git a/README.md b/README.md index ac4e827b..c61be4ee 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ classic: artifact_layout: docs language: en context_compression: off - workflow_intensity: standard + recommend_lightweight_workflows: true review_mode: standard auto_transition: true ``` diff --git a/assets/manifest.json b/assets/manifest.json index 221b3a93..0d4dee13 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-beta.11", + "version": "0.4.0-beta.12", "skills": [ "comet/SKILL.md", "comet-classic/SKILL.md", diff --git a/assets/skills-zh/comet-open/SKILL.md b/assets/skills-zh/comet-open/SKILL.md index 2c391430..0a08e31d 100644 --- a/assets/skills-zh/comet-open/SKILL.md +++ b/assets/skills-zh/comet-open/SKILL.md @@ -66,7 +66,7 @@ comet classic openspec -- --version - `hotfix` → 「切换到 `/comet-hotfix`」,仅当 runtime recommendation options 包含 `hotfix` 时展示 - `full` → 「继续 full `/comet-open`」,保持完整 OpenSpec + Design 流程 -不得在轻量路径推荐确认前运行 `openspec new change`,不得创建 proposal/design/tasks,也不得自动把 full 请求降级为 tweak/hotfix。 +不得在轻量路径推荐确认前运行 `comet classic openspec -- new change`,不得创建 proposal/design/tasks,也不得自动把 full 请求降级为 tweak/hotfix。 ### 1b. PRD 拆分预检(阻塞点) diff --git a/assets/skills/comet-native/scripts/comet-native-runtime.mjs b/assets/skills/comet-native/scripts/comet-native-runtime.mjs index cde63e80..1edd2fb0 100644 --- a/assets/skills/comet-native/scripts/comet-native-runtime.mjs +++ b/assets/skills/comet-native/scripts/comet-native-runtime.mjs @@ -8049,7 +8049,7 @@ var COMMENTS = { "classic.artifact_layout": "# Selects the Classic artifact layout. The default is docs; update preserves detected root-level legacy artifacts.\n# artifact_layout: legacy | docs", "classic.language": "# Artifact language used by Classic workflow documents.\n# language: en | zh-CN", "classic.context_compression": "# Controls beta context compression for new Classic changes.\n# context_compression: off | beta", - "classic.workflow_intensity": "# Tunes how aggressively Classic recommends lightweight workflows.\n# workflow_intensity: light | standard | thorough", + "classic.recommend_lightweight_workflows": "# Controls whether Classic can recommend tweak or hotfix before continuing full open.\n# recommend_lightweight_workflows: true | false", "classic.review_mode": "# Sets the default review depth for new Classic changes.\n# review_mode: off | standard | thorough", "classic.auto_transition": "# Automatically enters the next Classic phase after a phase passes.\n# auto_transition: true | false" }, @@ -8074,7 +8074,7 @@ var COMMENTS = { "classic.artifact_layout": "# Classic 产物布局;默认使用 docs,update 检测到根目录 legacy 产物时予以保留。\n# 可选值:legacy | docs", "classic.language": "# Classic 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "classic.context_compression": "# 新建 Classic change 是否启用 beta 上下文压缩。\n# 可选值:off | beta", - "classic.workflow_intensity": "# 调节 Classic 推荐轻量工作流的积极程度。\n# 可选值:light | standard | thorough", + "classic.recommend_lightweight_workflows": "# 是否允许 Classic 在进入完整 open 前推荐 tweak 或 hotfix。\n# 可选值:true | false", "classic.review_mode": "# 新建 Classic change 默认使用的审查深度。\n# 可选值:off | standard | thorough", "classic.auto_transition": "# Classic 阶段通过后是否自动进入下一阶段。\n# 可选值:true | false" } @@ -8334,6 +8334,10 @@ function normalizeWorkflowClassicProjectConfig(value) { if (reviewMode !== "off" && reviewMode !== "standard" && reviewMode !== "thorough") { throw new Error("classic.review_mode must be off, standard, or thorough"); } + const recommendLightweightWorkflows = classic.recommend_lightweight_workflows ?? true; + if (typeof recommendLightweightWorkflows !== "boolean") { + throw new Error("classic.recommend_lightweight_workflows must be true or false"); + } const autoTransition = classic.auto_transition ?? true; if (typeof autoTransition !== "boolean") { throw new Error("classic.auto_transition must be true or false"); @@ -8342,6 +8346,7 @@ function normalizeWorkflowClassicProjectConfig(value) { artifact_layout: normalizeClassicArtifactLayout(classic.artifact_layout), language: projectConfigLanguage(classic.language, "zh-CN", "classic.language"), context_compression: contextCompression, + recommend_lightweight_workflows: recommendLightweightWorkflows, review_mode: reviewMode, auto_transition: autoTransition }; @@ -8450,6 +8455,7 @@ function workflowProjectConfigManagedValue(config) { artifact_layout: config.classic.artifact_layout, language: config.classic.language, context_compression: config.classic.context_compression, + recommend_lightweight_workflows: config.classic.recommend_lightweight_workflows, review_mode: config.classic.review_mode, auto_transition: config.classic.auto_transition } @@ -8506,6 +8512,7 @@ function mergeWorkflowProjectConfigDocument(existing, config) { artifact_layout: validated.classic.artifact_layout, language: validated.classic.language, context_compression: validated.classic.context_compression, + recommend_lightweight_workflows: validated.classic.recommend_lightweight_workflows, review_mode: validated.classic.review_mode, auto_transition: validated.classic.auto_transition }; diff --git a/assets/skills/comet-open/SKILL.md b/assets/skills/comet-open/SKILL.md index 43c237ec..ab154604 100644 --- a/assets/skills/comet-open/SKILL.md +++ b/assets/skills/comet-open/SKILL.md @@ -66,7 +66,7 @@ Options must be built directly from the runtime's `recommendation.options`; do n - `hotfix` -> "Switch to `/comet-hotfix`" — shown only when runtime recommendation options include `hotfix` - `full` -> "Continue full `/comet-open`" — keep the full OpenSpec + Design workflow -The agent must not run `openspec new change` before lightweight path recommendation confirmation, must not create proposal/design/tasks, and must not automatically downgrade a full request to tweak/hotfix. +The agent must not run `comet classic openspec -- new change` before lightweight path recommendation confirmation, must not create proposal/design/tasks, and must not automatically downgrade a full request to tweak/hotfix. ### 1b. PRD Split Preflight (Blocking Point) diff --git a/assets/skills/comet/scripts/comet-entry-runtime.mjs b/assets/skills/comet/scripts/comet-entry-runtime.mjs index fd4cf9f7..9c0224aa 100644 --- a/assets/skills/comet/scripts/comet-entry-runtime.mjs +++ b/assets/skills/comet/scripts/comet-entry-runtime.mjs @@ -7597,6 +7597,10 @@ function normalizeWorkflowClassicProjectConfig(value) { if (reviewMode !== "off" && reviewMode !== "standard" && reviewMode !== "thorough") { throw new Error("classic.review_mode must be off, standard, or thorough"); } + const recommendLightweightWorkflows = classic.recommend_lightweight_workflows ?? true; + if (typeof recommendLightweightWorkflows !== "boolean") { + throw new Error("classic.recommend_lightweight_workflows must be true or false"); + } const autoTransition = classic.auto_transition ?? true; if (typeof autoTransition !== "boolean") { throw new Error("classic.auto_transition must be true or false"); @@ -7605,6 +7609,7 @@ function normalizeWorkflowClassicProjectConfig(value) { artifact_layout: normalizeClassicArtifactLayout(classic.artifact_layout), language: projectConfigLanguage(classic.language, "zh-CN", "classic.language"), context_compression: contextCompression, + recommend_lightweight_workflows: recommendLightweightWorkflows, review_mode: reviewMode, auto_transition: autoTransition }; diff --git a/assets/skills/comet/scripts/comet-hook-router.mjs b/assets/skills/comet/scripts/comet-hook-router.mjs index 963c97f7..75bc78bd 100644 --- a/assets/skills/comet/scripts/comet-hook-router.mjs +++ b/assets/skills/comet/scripts/comet-hook-router.mjs @@ -7589,6 +7589,10 @@ function normalizeWorkflowClassicProjectConfig(value) { if (reviewMode !== "off" && reviewMode !== "standard" && reviewMode !== "thorough") { throw new Error("classic.review_mode must be off, standard, or thorough"); } + const recommendLightweightWorkflows = classic.recommend_lightweight_workflows ?? true; + if (typeof recommendLightweightWorkflows !== "boolean") { + throw new Error("classic.recommend_lightweight_workflows must be true or false"); + } const autoTransition = classic.auto_transition ?? true; if (typeof autoTransition !== "boolean") { throw new Error("classic.auto_transition must be true or false"); @@ -7597,6 +7601,7 @@ function normalizeWorkflowClassicProjectConfig(value) { artifact_layout: normalizeClassicArtifactLayout(classic.artifact_layout), language: projectConfigLanguage(classic.language, "zh-CN", "classic.language"), context_compression: contextCompression, + recommend_lightweight_workflows: recommendLightweightWorkflows, review_mode: reviewMode, auto_transition: autoTransition }; diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 92d2e3f7..93591410 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -7633,6 +7633,10 @@ function normalizeWorkflowClassicProjectConfig(value) { if (reviewMode !== "off" && reviewMode !== "standard" && reviewMode !== "thorough") { throw new Error("classic.review_mode must be off, standard, or thorough"); } + const recommendLightweightWorkflows = classic.recommend_lightweight_workflows ?? true; + if (typeof recommendLightweightWorkflows !== "boolean") { + throw new Error("classic.recommend_lightweight_workflows must be true or false"); + } const autoTransition2 = classic.auto_transition ?? true; if (typeof autoTransition2 !== "boolean") { throw new Error("classic.auto_transition must be true or false"); @@ -7641,6 +7645,7 @@ function normalizeWorkflowClassicProjectConfig(value) { artifact_layout: normalizeClassicArtifactLayout(classic.artifact_layout), language: projectConfigLanguage(classic.language, "zh-CN", "classic.language"), context_compression: contextCompression2, + recommend_lightweight_workflows: recommendLightweightWorkflows, review_mode: reviewMode, auto_transition: autoTransition2 }; @@ -7748,7 +7753,7 @@ var init_project_config = __esm({ "classic.artifact_layout": "# Selects the Classic artifact layout. The default is docs; update preserves detected root-level legacy artifacts.\n# artifact_layout: legacy | docs", "classic.language": "# Artifact language used by Classic workflow documents.\n# language: en | zh-CN", "classic.context_compression": "# Controls beta context compression for new Classic changes.\n# context_compression: off | beta", - "classic.workflow_intensity": "# Tunes how aggressively Classic recommends lightweight workflows.\n# workflow_intensity: light | standard | thorough", + "classic.recommend_lightweight_workflows": "# Controls whether Classic can recommend tweak or hotfix before continuing full open.\n# recommend_lightweight_workflows: true | false", "classic.review_mode": "# Sets the default review depth for new Classic changes.\n# review_mode: off | standard | thorough", "classic.auto_transition": "# Automatically enters the next Classic phase after a phase passes.\n# auto_transition: true | false" }, @@ -7773,7 +7778,7 @@ var init_project_config = __esm({ "classic.artifact_layout": "# Classic 产物布局;默认使用 docs,update 检测到根目录 legacy 产物时予以保留。\n# 可选值:legacy | docs", "classic.language": "# Classic 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "classic.context_compression": "# 新建 Classic change 是否启用 beta 上下文压缩。\n# 可选值:off | beta", - "classic.workflow_intensity": "# 调节 Classic 推荐轻量工作流的积极程度。\n# 可选值:light | standard | thorough", + "classic.recommend_lightweight_workflows": "# 是否允许 Classic 在进入完整 open 前推荐 tweak 或 hotfix。\n# 可选值:true | false", "classic.review_mode": "# 新建 Classic change 默认使用的审查深度。\n# 可选值:off | standard | thorough", "classic.auto_transition": "# Classic 阶段通过后是否自动进入下一阶段。\n# 可选值:true | false" } @@ -12942,7 +12947,6 @@ var classicValidateCommand = async (args, options) => withClassicCommandContext( init_project_config_reader(); import os from "os"; import path26 from "path"; -var WORKFLOW_INTENSITIES = ["light", "standard", "thorough"]; function configCandidates(options = {}) { const cwd = options.projectRoot ?? options.cwd ?? options.invocationCwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); @@ -12974,16 +12978,20 @@ async function readClassicConfigValue(field2, options = {}) { } return null; } -async function readClassicWorkflowIntensity(options = {}) { - const configured = await readClassicConfigValue("workflow_intensity", options); - if (!configured) return { value: "standard", source: "default" }; - if (!WORKFLOW_INTENSITIES.includes(configured.value)) { - throw new Error( - `classic.workflow_intensity must be light, standard, or thorough, got '${configured.value}' from ${configured.source}` - ); - } +function parseClassicBoolean(value, field2, source) { + if (value === "true") return true; + if (value === "false") return false; + throw new Error(`${field2} must be true or false, got '${value}' from ${source}`); +} +async function readClassicRecommendLightweightWorkflows(options = {}) { + const configured = await readClassicConfigValue("recommend_lightweight_workflows", options); + if (!configured) return { value: true, source: "default" }; return { - value: configured.value, + value: parseClassicBoolean( + configured.value, + "classic.recommend_lightweight_workflows", + configured.source + ), source: configured.source }; } @@ -15116,7 +15124,6 @@ var REQUESTED_ACTIONS = [ "unknown" ]; var WORKFLOWS = ["full", "hotfix", "tweak"]; -var WORKFLOW_INTENSITIES2 = ["light", "standard", "thorough"]; var SCOPES = ["small", "medium", "large", "unknown"]; var ROUTES = ["full", "hotfix", "tweak", "resume", "ask_user", "out_of_scope"]; var NEXT_SKILLS = [ @@ -15284,12 +15291,11 @@ function validateFrame(input) { "context.dirty_worktree", issues ), - workflow_intensity: context.workflow_intensity === void 0 || context.workflow_intensity === null ? "standard" : enumValue2( - context.workflow_intensity, - WORKFLOW_INTENSITIES2, - "context.workflow_intensity", + recommend_lightweight_workflows: context.recommend_lightweight_workflows === void 0 || context.recommend_lightweight_workflows === null ? true : optionalBooleanValue( + context.recommend_lightweight_workflows, + "context.recommend_lightweight_workflows", issues - ) ?? "standard" + ) ?? true }, evidence: evidence.map((item, index) => { const record2 = isRecord2(item) ? item : {}; @@ -15328,41 +15334,38 @@ function validateFrame(input) { function hasEvidence(frame, field2) { return frame.evidence.some((item) => item.field === field2 && item.quote.trim() !== ""); } -function hasSmallScopeEvidence(frame) { - if (frame.slots.scope === "small") return true; - return frame.evidence.some( - (item) => item.field === "slots.scope" && /\b(small|tiny|minor)\b|轻量|小型|较小|很小|微小|小改动|小修改/iu.test(item.quote) - ); +function hasWorkflowCandidateEvidence(frame, workflow) { + return frame.slots.workflow_candidate === workflow && hasEvidence(frame, "slots.workflow_candidate"); +} +function hasRiskSignalEntity(frame) { + return frame.entities.some((entity) => entity.type === "risk_signal"); } -function hasRiskSignal(frame) { - return frame.slots.new_capability === true || frame.slots.public_api_change === true || frame.slots.schema_change === true || frame.slots.cross_module_change === true; +function hasHardRiskSignal(frame) { + return hasRiskSignalEntity(frame) || frame.slots.public_api_change === true || frame.slots.schema_change === true || frame.slots.cross_module_change === true; } -function lowRiskEvidence(frame) { - return hasSmallScopeEvidence(frame) || /docs?|prompt|wording|config|readme|small|tiny|minor/iu.test(frame.utterance); +function scopeAllowsLightweightRecommendation(frame) { + return frame.slots.scope !== "large"; } function recommendationFor(frame) { - if (hasRiskSignal(frame) || !hasSmallScopeEvidence(frame)) return null; - const intensity = frame.context.workflow_intensity; - const evidenceIsStrong = lowRiskEvidence(frame); - if (intensity === "thorough" && !evidenceIsStrong) return null; - if (frame.intent.name === "fix_bug" && frame.slots.existing_behavior === true) { + if (!frame.context.recommend_lightweight_workflows || hasHardRiskSignal(frame) || !scopeAllowsLightweightRecommendation(frame)) { + return null; + } + if (hasWorkflowCandidateEvidence(frame, "hotfix") && (frame.intent.name === "fix_bug" || frame.slots.existing_behavior === true)) { return { workflow: "hotfix", next_skill: "comet-hotfix", - intensity, - reasons: ["existing behavior fix", `workflow_intensity=${intensity}`], + reasons: [ + "workflow_candidate=hotfix evidence", + frame.slots.existing_behavior === true ? "existing behavior fix" : "bug fix intent" + ], options: ["hotfix", "full"] }; } - if (evidenceIsStrong && (frame.intent.name === "start_change" || frame.intent.name === "make_tweak" || frame.slots.requested_action === "modify" || frame.slots.requested_action === "create")) { + if (hasWorkflowCandidateEvidence(frame, "tweak")) { return { workflow: "tweak", next_skill: "comet-tweak", - intensity, - reasons: [ - evidenceIsStrong ? "small low-risk request" : "low-risk request", - `workflow_intensity=${intensity}` - ], + reasons: ["workflow_candidate=tweak evidence"], options: ["tweak", "full"] }; } @@ -15412,13 +15415,13 @@ function resolveCometIntentRoute(input) { confidence, "user asked a question without requesting a Comet workflow" ); - } else if (frame.slots.user_explicit_workflow && frame.slots.user_explicit_workflow !== "full" && hasRiskSignal(frame)) { + } else if (frame.slots.user_explicit_workflow && frame.slots.user_explicit_workflow !== "full" && hasHardRiskSignal(frame)) { resolved = askUser( `explicit workflow '${frame.slots.user_explicit_workflow}' conflicts with risk signals` ); } else if (frame.slots.user_explicit_workflow) { resolved = workflowRoute(frame.slots.user_explicit_workflow, confidence); - } else if (hasRiskSignal(frame)) { + } else if (hasHardRiskSignal(frame)) { resolved = route("full", confidence); } else if (recommendation) { resolved = route("full", confidence, null, recommendation); @@ -15490,18 +15493,18 @@ async function readStdin() { function isRecord3(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -async function withConfiguredWorkflowIntensity(input, options = {}) { +async function withConfiguredLightweightRecommendationSwitch(input, options = {}) { if (!isRecord3(input) || !isRecord3(input.context)) return input; const context = { ...input.context }; - if (context.workflow_intensity !== void 0 && context.workflow_intensity !== null) { + if (context.recommend_lightweight_workflows !== void 0 && context.recommend_lightweight_workflows !== null) { return input; } - const configured = await readClassicWorkflowIntensity(options); + const configured = await readClassicRecommendLightweightWorkflows(options); return { ...input, context: { ...context, - workflow_intensity: configured.value + recommend_lightweight_workflows: configured.value } }; } @@ -15512,7 +15515,7 @@ var classicIntentCommand = async (args, options) => { if (!source) return usage(); try { const resolution = resolveCometIntentRoute( - await withConfiguredWorkflowIntensity(JSON.parse(source), options) + await withConfiguredLightweightRecommendationSwitch(JSON.parse(source), options) ); return result2(0, `${JSON.stringify(resolution, null, 2)} `); diff --git a/domains/comet-classic/classic-intent-command.ts b/domains/comet-classic/classic-intent-command.ts index 7420de47..0666df54 100644 --- a/domains/comet-classic/classic-intent-command.ts +++ b/domains/comet-classic/classic-intent-command.ts @@ -4,7 +4,7 @@ import type { ClassicCommandResult, } from './classic-cli.js'; import { CometIntentValidationError, resolveCometIntentRoute } from './classic-intent.js'; -import { readClassicWorkflowIntensity } from './classic-project-config.js'; +import { readClassicRecommendLightweightWorkflows } from './classic-project-config.js'; function result(exitCode: number, stdout?: string, stderr?: string): ClassicCommandResult { return { @@ -34,21 +34,24 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -async function withConfiguredWorkflowIntensity( +async function withConfiguredLightweightRecommendationSwitch( input: unknown, options: Pick = {}, ): Promise { if (!isRecord(input) || !isRecord(input.context)) return input; const context = { ...input.context }; - if (context.workflow_intensity !== undefined && context.workflow_intensity !== null) { + if ( + context.recommend_lightweight_workflows !== undefined && + context.recommend_lightweight_workflows !== null + ) { return input; } - const configured = await readClassicWorkflowIntensity(options); + const configured = await readClassicRecommendLightweightWorkflows(options); return { ...input, context: { ...context, - workflow_intensity: configured.value, + recommend_lightweight_workflows: configured.value, }, }; } @@ -62,7 +65,7 @@ export const classicIntentCommand: ClassicCommandHandler = async (args, options) try { const resolution = resolveCometIntentRoute( - await withConfiguredWorkflowIntensity(JSON.parse(source), options), + await withConfiguredLightweightRecommendationSwitch(JSON.parse(source), options), ); return result(0, `${JSON.stringify(resolution, null, 2)}\n`); } catch (error) { diff --git a/domains/comet-classic/classic-intent.ts b/domains/comet-classic/classic-intent.ts index fcd14532..3b1a56bc 100644 --- a/domains/comet-classic/classic-intent.ts +++ b/domains/comet-classic/classic-intent.ts @@ -31,7 +31,6 @@ const REQUESTED_ACTIONS = [ 'unknown', ] as const; const WORKFLOWS = ['full', 'hotfix', 'tweak'] as const; -const WORKFLOW_INTENSITIES = ['light', 'standard', 'thorough'] as const; const SCOPES = ['small', 'medium', 'large', 'unknown'] as const; const ROUTES = ['full', 'hotfix', 'tweak', 'resume', 'ask_user', 'out_of_scope'] as const; const NEXT_SKILLS = [ @@ -51,7 +50,6 @@ export type CometIntentName = ValueOf; export type CometIntentEntityType = ValueOf; export type CometIntentRequestedAction = ValueOf; export type CometIntentWorkflow = ValueOf; -export type CometIntentWorkflowIntensity = ValueOf; export type CometIntentScope = ValueOf; export type CometIntentRouteName = ValueOf; export type CometIntentNextSkill = ValueOf; @@ -80,7 +78,7 @@ export interface CometIntentFrame { active_changes_count: number; active_change_names: string[]; dirty_worktree: boolean | null; - workflow_intensity: CometIntentWorkflowIntensity; + recommend_lightweight_workflows: boolean; }; evidence: Array<{ field: string; quote: string; source: CometIntentEvidenceSource }>; proposed_route: CometIntentRoute; @@ -102,7 +100,6 @@ export interface CometIntentRoute { export interface CometIntentRecommendation { workflow: Exclude; next_skill: Extract; - intensity: CometIntentWorkflowIntensity; reasons: string[]; options: CometIntentWorkflow[]; } @@ -299,15 +296,15 @@ function validateFrame(input: unknown): CometIntentFrame { 'context.dirty_worktree', issues, ), - workflow_intensity: - context.workflow_intensity === undefined || context.workflow_intensity === null - ? 'standard' - : (enumValue( - context.workflow_intensity, - WORKFLOW_INTENSITIES, - 'context.workflow_intensity', + recommend_lightweight_workflows: + context.recommend_lightweight_workflows === undefined || + context.recommend_lightweight_workflows === null + ? true + : (optionalBooleanValue( + context.recommend_lightweight_workflows, + 'context.recommend_lightweight_workflows', issues, - ) ?? 'standard'), + ) ?? true), }, evidence: evidence.map((item, index) => { const record = isRecord(item) ? item : {}; @@ -353,63 +350,61 @@ function hasEvidence(frame: CometIntentFrame, field: string): boolean { return frame.evidence.some((item) => item.field === field && item.quote.trim() !== ''); } -function hasSmallScopeEvidence(frame: CometIntentFrame): boolean { - if (frame.slots.scope === 'small') return true; - - return frame.evidence.some( - (item) => - item.field === 'slots.scope' && - /\b(small|tiny|minor)\b|轻量|小型|较小|很小|微小|小改动|小修改/iu.test(item.quote), +function hasWorkflowCandidateEvidence( + frame: CometIntentFrame, + workflow: Exclude, +): boolean { + return ( + frame.slots.workflow_candidate === workflow && hasEvidence(frame, 'slots.workflow_candidate') ); } -function hasRiskSignal(frame: CometIntentFrame): boolean { +function hasRiskSignalEntity(frame: CometIntentFrame): boolean { + return frame.entities.some((entity) => entity.type === 'risk_signal'); +} + +function hasHardRiskSignal(frame: CometIntentFrame): boolean { return ( - frame.slots.new_capability === true || + hasRiskSignalEntity(frame) || frame.slots.public_api_change === true || frame.slots.schema_change === true || frame.slots.cross_module_change === true ); } -function lowRiskEvidence(frame: CometIntentFrame): boolean { - return ( - hasSmallScopeEvidence(frame) || - /docs?|prompt|wording|config|readme|small|tiny|minor/iu.test(frame.utterance) - ); +function scopeAllowsLightweightRecommendation(frame: CometIntentFrame): boolean { + return frame.slots.scope !== 'large'; } function recommendationFor(frame: CometIntentFrame): CometIntentRecommendation | null { - if (hasRiskSignal(frame) || !hasSmallScopeEvidence(frame)) return null; - const intensity = frame.context.workflow_intensity; - const evidenceIsStrong = lowRiskEvidence(frame); - if (intensity === 'thorough' && !evidenceIsStrong) return null; + if ( + !frame.context.recommend_lightweight_workflows || + hasHardRiskSignal(frame) || + !scopeAllowsLightweightRecommendation(frame) + ) { + return null; + } - if (frame.intent.name === 'fix_bug' && frame.slots.existing_behavior === true) { + if ( + hasWorkflowCandidateEvidence(frame, 'hotfix') && + (frame.intent.name === 'fix_bug' || frame.slots.existing_behavior === true) + ) { return { workflow: 'hotfix', next_skill: 'comet-hotfix', - intensity, - reasons: ['existing behavior fix', `workflow_intensity=${intensity}`], + reasons: [ + 'workflow_candidate=hotfix evidence', + frame.slots.existing_behavior === true ? 'existing behavior fix' : 'bug fix intent', + ], options: ['hotfix', 'full'], }; } - if ( - evidenceIsStrong && - (frame.intent.name === 'start_change' || - frame.intent.name === 'make_tweak' || - frame.slots.requested_action === 'modify' || - frame.slots.requested_action === 'create') - ) { + if (hasWorkflowCandidateEvidence(frame, 'tweak')) { return { workflow: 'tweak', next_skill: 'comet-tweak', - intensity, - reasons: [ - evidenceIsStrong ? 'small low-risk request' : 'low-risk request', - `workflow_intensity=${intensity}`, - ], + reasons: ['workflow_candidate=tweak evidence'], options: ['tweak', 'full'], }; } @@ -487,14 +482,14 @@ export function resolveCometIntentRoute(input: unknown): CometIntentRouteResolut } else if ( frame.slots.user_explicit_workflow && frame.slots.user_explicit_workflow !== 'full' && - hasRiskSignal(frame) + hasHardRiskSignal(frame) ) { resolved = askUser( `explicit workflow '${frame.slots.user_explicit_workflow}' conflicts with risk signals`, ); } else if (frame.slots.user_explicit_workflow) { resolved = workflowRoute(frame.slots.user_explicit_workflow, confidence); - } else if (hasRiskSignal(frame)) { + } else if (hasHardRiskSignal(frame)) { resolved = route('full', confidence); } else if (recommendation) { resolved = route('full', confidence, null, recommendation); diff --git a/domains/comet-classic/classic-project-config.ts b/domains/comet-classic/classic-project-config.ts index aaca2dc0..688a92ab 100644 --- a/domains/comet-classic/classic-project-config.ts +++ b/domains/comet-classic/classic-project-config.ts @@ -14,12 +14,8 @@ type ClassicConfigOptions = { homeDir?: string; }; -const WORKFLOW_INTENSITIES = ['light', 'standard', 'thorough'] as const; - -type ClassicWorkflowIntensity = (typeof WORKFLOW_INTENSITIES)[number]; - -type ClassicWorkflowIntensityValue = { - value: ClassicWorkflowIntensity; +type ClassicRecommendLightweightWorkflowsValue = { + value: boolean; source: string; }; @@ -65,26 +61,26 @@ async function readClassicConfigValue( return null; } -async function readClassicWorkflowIntensity( +function parseClassicBoolean(value: string, field: string, source: string): boolean { + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error(`${field} must be true or false, got '${value}' from ${source}`); +} + +async function readClassicRecommendLightweightWorkflows( options: ClassicConfigOptions = {}, -): Promise { - const configured = await readClassicConfigValue('workflow_intensity', options); - if (!configured) return { value: 'standard', source: 'default' }; - if (!WORKFLOW_INTENSITIES.includes(configured.value as ClassicWorkflowIntensity)) { - throw new Error( - `classic.workflow_intensity must be light, standard, or thorough, got '${configured.value}' from ${configured.source}`, - ); - } +): Promise { + const configured = await readClassicConfigValue('recommend_lightweight_workflows', options); + if (!configured) return { value: true, source: 'default' }; return { - value: configured.value as ClassicWorkflowIntensity, + value: parseClassicBoolean( + configured.value, + 'classic.recommend_lightweight_workflows', + configured.source, + ), source: configured.source, }; } -export { configCandidates, readClassicConfigValue, readClassicWorkflowIntensity }; -export type { - ClassicConfigOptions, - ClassicConfigValue, - ClassicWorkflowIntensity, - ClassicWorkflowIntensityValue, -}; +export { configCandidates, readClassicConfigValue, readClassicRecommendLightweightWorkflows }; +export type { ClassicConfigOptions, ClassicConfigValue, ClassicRecommendLightweightWorkflowsValue }; diff --git a/domains/skill/platform-install.ts b/domains/skill/platform-install.ts index 41497fed..6036e16d 100644 --- a/domains/skill/platform-install.ts +++ b/domains/skill/platform-install.ts @@ -1602,9 +1602,9 @@ function managedConfigFields(language: string = 'en'): ManagedConfigFields { comment: projectConfigComment('classic.context_compression', commentLanguage), }, { - key: 'workflow_intensity', - def: 'standard', - comment: projectConfigComment('classic.workflow_intensity', commentLanguage), + key: 'recommend_lightweight_workflows', + def: 'true', + comment: projectConfigComment('classic.recommend_lightweight_workflows', commentLanguage), }, { key: 'review_mode', diff --git a/domains/workflow-contract/project-config.ts b/domains/workflow-contract/project-config.ts index 07c7b776..4a65ef82 100644 --- a/domains/workflow-contract/project-config.ts +++ b/domains/workflow-contract/project-config.ts @@ -48,7 +48,7 @@ type ProjectConfigCommentKey = | 'classic.artifact_layout' | 'classic.language' | 'classic.context_compression' - | 'classic.workflow_intensity' + | 'classic.recommend_lightweight_workflows' | 'classic.review_mode' | 'classic.auto_transition'; @@ -89,8 +89,8 @@ const COMMENTS: Record { expect(help.status, help.stderr).toBe(0); expect(help.stdout).toContain(tagline); expect(packageJson.description).toBe(tagline); - expect(packageJson.version).toBe('0.4.0-beta.11'); + expect(packageJson.version).toBe('0.4.0-beta.12'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/domains/comet-classic/classic-intent.test.ts b/test/domains/comet-classic/classic-intent.test.ts index d5b0d618..e88de9fd 100644 --- a/test/domains/comet-classic/classic-intent.test.ts +++ b/test/domains/comet-classic/classic-intent.test.ts @@ -42,6 +42,7 @@ function frame(overrides: FrameOverrides = {}): CometIntentFrame { active_changes_count: 0, active_change_names: [], dirty_worktree: false, + recommend_lightweight_workflows: true, }, evidence: [ { field: 'intent.name', quote: 'fix', source: 'user' }, @@ -77,7 +78,6 @@ describe('resolveCometIntentRoute', () => { recommendation: { workflow: 'hotfix', next_skill: 'comet-hotfix', - intensity: 'standard', }, }); expect(result.route.recommendation?.reasons.join(' ')).toContain('existing behavior'); @@ -113,7 +113,11 @@ describe('resolveCometIntentRoute', () => { expect(result.route).toMatchObject({ name: 'full', next_skill: 'comet-open', - recommendation: null, + requires_confirmation: true, + recommendation: { + workflow: 'hotfix', + next_skill: 'comet-hotfix', + }, }); expect(result.normalizedFrame).toMatchObject({ locale: 'unknown', @@ -128,7 +132,7 @@ describe('resolveCometIntentRoute', () => { }); }); - it('recommends tweak confirmation for doc, config, and prompt changes', () => { + it('recommends tweak confirmation for evidence-backed tweak candidates', () => { const result = resolveCometIntentRoute( frame({ utterance: 'tweak the comet prompt wording', @@ -155,10 +159,11 @@ describe('resolveCometIntentRoute', () => { recommendation: { workflow: 'tweak', next_skill: 'comet-tweak', - intensity: 'standard', }, }); - expect(result.route.recommendation?.reasons.join(' ')).toContain('small'); + expect(result.route.recommendation?.reasons.join(' ')).toContain( + 'workflow_candidate=tweak evidence', + ); }); it('does not offer hotfix for ordinary tweak recommendations', () => { @@ -206,7 +211,7 @@ describe('resolveCometIntentRoute', () => { }); }); - it('recommends tweak confirmation for small low-risk work under standard intensity', () => { + it('does not infer tweak from small docs wording without workflow candidate evidence', () => { const result = resolveCometIntentRoute( frame({ utterance: 'add a small docs note', @@ -233,25 +238,20 @@ describe('resolveCometIntentRoute', () => { expect(result.route).toMatchObject({ name: 'full', next_skill: 'comet-open', - requires_confirmation: true, - recommendation: { - workflow: 'tweak', - next_skill: 'comet-tweak', - intensity: 'standard', - }, + requires_confirmation: false, + recommendation: null, }); - expect(result.route.recommendation?.reasons.join(' ')).toContain('small'); }); - it('does not treat medium or large scope evidence as low-risk evidence', () => { - for (const scope of ['medium', 'large'] as const) { + it('uses scope only as a large-scope filter for lightweight recommendations', () => { + for (const scope of ['medium', 'unknown'] as const) { const result = resolveCometIntentRoute( frame({ utterance: `${scope} workflow update`, - intent: { name: 'start_change', confidence: 0.92 }, + intent: { name: 'make_tweak', confidence: 0.92 }, slots: { requested_action: 'modify', - workflow_candidate: 'full', + workflow_candidate: 'tweak', scope, existing_behavior: false, new_capability: false, @@ -260,30 +260,97 @@ describe('resolveCometIntentRoute', () => { cross_module_change: false, }, evidence: [ - { field: 'intent.name', quote: 'update', source: 'user' }, + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, { field: 'slots.scope', quote: `${scope} workflow update`, source: 'user' }, ], - proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, }), ); expect(result.route).toMatchObject({ name: 'full', next_skill: 'comet-open', - requires_confirmation: false, - recommendation: null, + requires_confirmation: true, + recommendation: { + workflow: 'tweak', + next_skill: 'comet-tweak', + }, }); } + + const largeResult = resolveCometIntentRoute( + frame({ + utterance: 'large workflow update', + intent: { name: 'make_tweak', confidence: 0.92 }, + slots: { + requested_action: 'modify', + workflow_candidate: 'tweak', + scope: 'large', + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + evidence: [ + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, + { field: 'slots.scope', quote: 'large workflow update', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, + }), + ); + + expect(largeResult.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: false, + recommendation: null, + }); }); - it('keeps thorough intensity conservative for unclear small work', () => { + it('does not recommend lightweight paths when the project disables recommendations', () => { const result = resolveCometIntentRoute( frame({ - utterance: 'modify the workflow', - intent: { name: 'start_change', confidence: 0.92 }, + utterance: 'tweak a small workflow behavior', + intent: { name: 'make_tweak', confidence: 0.92 }, slots: { requested_action: 'modify', - workflow_candidate: 'full', + workflow_candidate: 'tweak', + scope: 'small', + existing_behavior: false, + new_capability: false, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + context: { recommend_lightweight_workflows: false }, + evidence: [ + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, + { field: 'slots.scope', quote: 'small workflow update', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, + }), + ); + + expect(result.route).toMatchObject({ + name: 'full', + next_skill: 'comet-open', + requires_confirmation: false, + recommendation: null, + }); + }); + + it('defaults lightweight recommendations to enabled when context omits the switch', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'tweak a workflow behavior', + intent: { name: 'make_tweak', confidence: 0.92 }, + slots: { + requested_action: 'modify', + workflow_candidate: 'tweak', scope: 'unknown', existing_behavior: false, new_capability: false, @@ -291,36 +358,44 @@ describe('resolveCometIntentRoute', () => { schema_change: false, cross_module_change: false, }, - context: { workflow_intensity: 'thorough' }, - evidence: [{ field: 'intent.name', quote: 'modify', source: 'user' }], - proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + context: { recommend_lightweight_workflows: undefined }, + evidence: [ + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, }), ); - expect(result.route.name).toBe('full'); - expect(result.route.requires_confirmation).toBe(false); - expect(result.route.recommendation).toBeNull(); + expect(result.route.recommendation).toMatchObject({ + workflow: 'tweak', + next_skill: 'comet-tweak', + }); }); - it('does not recommend lightweight paths when risk signals are present', () => { + it('does not recommend lightweight paths when risk signal entities are present', () => { const result = resolveCometIntentRoute( frame({ - utterance: 'add a small public API', - intent: { name: 'start_change', confidence: 0.92 }, + utterance: 'tweak a security-sensitive workflow', + intent: { name: 'make_tweak', confidence: 0.92 }, + entities: [ + { type: 'risk_signal', value: 'security_sensitive', text: 'security-sensitive' }, + ], slots: { - requested_action: 'create', - workflow_candidate: 'full', + requested_action: 'modify', + workflow_candidate: 'tweak', scope: 'small', existing_behavior: false, - new_capability: true, - public_api_change: true, + new_capability: false, + public_api_change: false, }, - context: { workflow_intensity: 'light' }, + context: { recommend_lightweight_workflows: true }, evidence: [ + { field: 'intent.name', quote: 'tweak', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, { field: 'slots.scope', quote: 'small', source: 'user' }, - { field: 'slots.public_api_change', quote: 'public API', source: 'user' }, ], - proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, }), ); @@ -328,50 +403,36 @@ describe('resolveCometIntentRoute', () => { expect(result.route.recommendation).toBeNull(); }); - it('keeps thorough intensity on full for non-small bug or tweak candidates', () => { - for (const candidate of ['hotfix', 'tweak'] as const) { - const result = resolveCometIntentRoute( - frame({ - utterance: - candidate === 'hotfix' ? 'fix the workflow regression' : 'tweak the workflow behavior', - intent: { name: candidate === 'hotfix' ? 'fix_bug' : 'make_tweak', confidence: 0.92 }, - slots: { - requested_action: candidate === 'hotfix' ? 'fix' : 'modify', - workflow_candidate: candidate, - scope: 'unknown', - existing_behavior: candidate === 'hotfix' ? true : null, - new_capability: false, - public_api_change: false, - schema_change: false, - cross_module_change: false, - }, - context: { workflow_intensity: 'thorough' }, - evidence: [ - { - field: 'intent.name', - quote: candidate === 'hotfix' ? 'fix' : 'tweak', - source: 'user', - }, - { field: 'slots.workflow_candidate', quote: candidate, source: 'user' }, - ], - proposed_route: { - name: candidate, - next_skill: `comet-${candidate}`, - confidence: 0.9, - }, - }), - ); + it('allows small feature tweak recommendations when new_capability is the only risk-like slot', () => { + const result = resolveCometIntentRoute( + frame({ + utterance: 'add a small quality-of-life toggle', + intent: { name: 'start_change', confidence: 0.92 }, + slots: { + requested_action: 'create', + workflow_candidate: 'tweak', + scope: 'small', + existing_behavior: false, + new_capability: true, + public_api_change: false, + schema_change: false, + cross_module_change: false, + }, + evidence: [ + { field: 'intent.name', quote: 'add', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single OpenSpec change', source: 'user' }, + { field: 'slots.scope', quote: 'small quality-of-life toggle', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, + }), + ); - expect(result.route).toMatchObject({ - name: 'full', - next_skill: 'comet-open', - requires_confirmation: false, - recommendation: null, - }); - } + expect(result.route.recommendation).toMatchObject({ + workflow: 'tweak', + }); }); - it('routes new capability and public API risk signals to full', () => { + it('routes public API risk signals to full even for new capability tweak candidates', () => { const result = resolveCometIntentRoute( frame({ utterance: 'add a public API for intent routing', @@ -379,17 +440,18 @@ describe('resolveCometIntentRoute', () => { entities: [{ type: 'risk_signal', value: 'public_api_change', text: 'public API' }], slots: { requested_action: 'create', - workflow_candidate: 'full', - scope: 'large', + workflow_candidate: 'tweak', + scope: 'small', existing_behavior: false, new_capability: true, public_api_change: true, }, evidence: [ { field: 'intent.name', quote: 'add', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single OpenSpec change', source: 'user' }, { field: 'slots.public_api_change', quote: 'public API', source: 'user' }, ], - proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.93 }, + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.93 }, }), ); @@ -547,13 +609,13 @@ describe('resolveCometIntentRoute', () => { ); }); - it('intent command applies workflow_intensity from Classic project config', async () => { + it('intent command applies recommend_lightweight_workflows from Classic project config', async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-intent-command-')); try { await fs.mkdir(path.join(projectRoot, '.comet'), { recursive: true }); await fs.writeFile( path.join(projectRoot, '.comet', 'config.yaml'), - 'classic:\n workflow_intensity: thorough\n', + 'classic:\n recommend_lightweight_workflows: false\n', 'utf8', ); @@ -566,16 +628,20 @@ describe('resolveCometIntentRoute', () => { intent: { name: 'start_change', confidence: 0.92 }, slots: { requested_action: 'modify', - workflow_candidate: 'full', - scope: 'unknown', + workflow_candidate: 'tweak', + scope: 'small', existing_behavior: false, new_capability: false, public_api_change: false, schema_change: false, cross_module_change: false, }, - evidence: [{ field: 'intent.name', quote: 'modify', source: 'user' }], - proposed_route: { name: 'full', next_skill: 'comet-open', confidence: 0.9 }, + context: { recommend_lightweight_workflows: undefined }, + evidence: [ + { field: 'intent.name', quote: 'modify', source: 'user' }, + { field: 'slots.workflow_candidate', quote: 'single change', source: 'user' }, + ], + proposed_route: { name: 'tweak', next_skill: 'comet-tweak', confidence: 0.9 }, }), ), ], @@ -586,7 +652,7 @@ describe('resolveCometIntentRoute', () => { const payload = JSON.parse(result.stdout ?? '{}') as ReturnType< typeof resolveCometIntentRoute >; - expect(payload.normalizedFrame.context.workflow_intensity).toBe('thorough'); + expect(payload.normalizedFrame.context.recommend_lightweight_workflows).toBe(false); expect(payload.route).toMatchObject({ name: 'full', requires_confirmation: false, diff --git a/test/domains/comet-classic/classic-project-config.test.ts b/test/domains/comet-classic/classic-project-config.test.ts index bae58f4b..127379a9 100644 --- a/test/domains/comet-classic/classic-project-config.test.ts +++ b/test/domains/comet-classic/classic-project-config.test.ts @@ -5,7 +5,7 @@ import path from 'path'; import { readClassicConfigValue, - readClassicWorkflowIntensity, + readClassicRecommendLightweightWorkflows, } from '../../../domains/comet-classic/classic-project-config.js'; describe('Classic project config', () => { @@ -95,30 +95,37 @@ describe('Classic project config', () => { } }); - it.each(['light', 'standard', 'thorough'] as const)( - 'reads workflow_intensity=%s from the nested Classic block', - async (value) => { - await writeConfig(projectRoot, `classic:\n workflow_intensity: ${value}\n`); + it.each([ + ['true', true], + ['false', false], + ] as const)( + 'reads recommend_lightweight_workflows=%s from the nested Classic block', + async (value, expected) => { + await writeConfig(projectRoot, `classic:\n recommend_lightweight_workflows: ${value}\n`); - await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ - value, + await expect( + readClassicRecommendLightweightWorkflows({ cwd: projectRoot, homeDir }), + ).resolves.toEqual({ + value: expected, source: '.comet/config.yaml', }); }, ); - it('defaults workflow_intensity to standard when absent', async () => { - await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).resolves.toEqual({ - value: 'standard', + it('defaults recommend_lightweight_workflows to true when absent', async () => { + await expect( + readClassicRecommendLightweightWorkflows({ cwd: projectRoot, homeDir }), + ).resolves.toEqual({ + value: true, source: 'default', }); }); - it('rejects invalid workflow_intensity values', async () => { - await writeConfig(projectRoot, 'classic:\n workflow_intensity: fast\n'); + it('rejects invalid recommend_lightweight_workflows values', async () => { + await writeConfig(projectRoot, 'classic:\n recommend_lightweight_workflows: maybe\n'); - await expect(readClassicWorkflowIntensity({ cwd: projectRoot, homeDir })).rejects.toThrow( - /workflow_intensity must be light, standard, or thorough/u, - ); + await expect( + readClassicRecommendLightweightWorkflows({ cwd: projectRoot, homeDir }), + ).rejects.toThrow(/classic\.recommend_lightweight_workflows must be true or false/u); }); }); diff --git a/test/domains/comet-native/native-config.test.ts b/test/domains/comet-native/native-config.test.ts index f114e36b..096a82ab 100644 --- a/test/domains/comet-native/native-config.test.ts +++ b/test/domains/comet-native/native-config.test.ts @@ -155,6 +155,7 @@ describe('Native project configuration', () => { artifact_layout: 'docs', language: 'zh-CN', context_compression: 'off', + recommend_lightweight_workflows: true, review_mode: 'standard', auto_transition: true, }; diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index b94e04b8..a03e13a1 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -3041,6 +3041,8 @@ describe('skills', () => { expect(output).toContain('language: en'); expect(output).toContain('# Controls beta context compression'); expect(output).toContain('context_compression: off'); + expect(output).toContain('# Controls whether Classic can recommend tweak or hotfix'); + expect(output).toContain('recommend_lightweight_workflows: true'); expect(output).toContain('# Sets the default review depth'); expect(output).toContain('review_mode: standard'); expect(output).toContain('# Automatically enters the next Classic phase'); @@ -3103,12 +3105,15 @@ describe('skills', () => { artifact_layout: 'docs', language: 'en', context_compression: 'off', + recommend_lightweight_workflows: true, review_mode: 'standard', auto_transition: true, }, }); expect(parse(content)).not.toHaveProperty('native'); - expect(content).not.toMatch(/^(language|context_compression|review_mode|auto_transition):/mu); + expect(content).not.toMatch( + /^(language|context_compression|recommend_lightweight_workflows|review_mode|auto_transition):/mu, + ); }); it('adds every managed Native default only to an existing Native block', async () => { @@ -3324,6 +3329,7 @@ describe('skills', () => { 'classic:', ' language: zh-CN', ' context_compression: beta', + ' recommend_lightweight_workflows: false', ' review_mode: thorough', ' auto_transition: false', 'native:', @@ -3344,12 +3350,15 @@ describe('skills', () => { artifact_layout: 'docs', language: 'zh-CN', context_compression: 'beta', + recommend_lightweight_workflows: false, review_mode: 'thorough', auto_transition: false, }, native: { artifact_root: 'docs', language: 'en' }, }); - expect(second).not.toMatch(/^(language|context_compression|review_mode|auto_transition):/mu); + expect(second).not.toMatch( + /^(language|context_compression|recommend_lightweight_workflows|review_mode|auto_transition):/mu, + ); expect(second).toBe(first); }); }); diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts index c404b499..78d3509f 100644 --- a/test/domains/skill/workflow-optimization-contract.test.ts +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -40,13 +40,13 @@ describe('Comet workflow optimization contracts', () => { '中文', zhSkillRoot, '### 1a. 轻量路径推荐确认(阻塞点)', - '不得在轻量路径推荐确认前运行 `openspec new change`', + '不得在轻量路径推荐确认前运行 `comet classic openspec -- new change`', ], [ 'English', skillRoot, '### 1a. Lightweight Path Recommendation Confirmation (Blocking Point)', - 'must not run `openspec new change` before lightweight path recommendation confirmation', + 'must not run `comet classic openspec -- new change` before lightweight path recommendation confirmation', ], ])( '%s open flow confirms lightweight recommendation before artifact creation', @@ -55,8 +55,12 @@ describe('Comet workflow optimization contracts', () => { expect(skill).toContain(heading); expect(skill).toContain(guard); - expect(skill.indexOf(heading)).toBeLessThan(skill.indexOf('openspec new change')); - expect(skill.indexOf(guard)).toBeLessThan(skill.indexOf('openspec new change')); + expect(skill.indexOf(heading)).toBeLessThan( + skill.indexOf('comet classic openspec -- new change'), + ); + expect(skill.indexOf(guard)).toBeLessThan( + skill.indexOf('comet classic openspec -- new change'), + ); }, ); diff --git a/test/domains/workflow-contract/workflow-contract.test.ts b/test/domains/workflow-contract/workflow-contract.test.ts index 373379ec..6e036dd8 100644 --- a/test/domains/workflow-contract/workflow-contract.test.ts +++ b/test/domains/workflow-contract/workflow-contract.test.ts @@ -114,7 +114,7 @@ describe('workflow contract normalization', () => { ' max_files: 12000', ' max_total_bytes: 268435456', ' max_duration_ms: 90000', - 'classic: { artifact_layout: docs, language: zh-CN, review_mode: thorough }', + 'classic: { artifact_layout: docs, language: zh-CN, recommend_lightweight_workflows: false, review_mode: thorough }', 'extension:', ' owners: [platform, workflow]', ' note: "value: with # content"', @@ -140,6 +140,7 @@ describe('workflow contract normalization', () => { classic: { artifact_layout: 'docs', language: 'zh-CN', + recommend_lightweight_workflows: false, review_mode: 'thorough', }, }); @@ -160,7 +161,7 @@ describe('workflow contract normalization', () => { ], [ 'invalid managed fields', - 'schema: comet.project.v1\ndefault_workflow: classic\nclassic:\n review_mode: casual\n', + 'schema: comet.project.v1\ndefault_workflow: classic\nclassic:\n recommend_lightweight_workflows: maybe\n', ], ])('fails closed for project config with %s', (_label, source) => { expect(() => parseWorkflowProjectConfigDocument(source)).toThrow(); diff --git a/test/repository/release-metadata.test.ts b/test/repository/release-metadata.test.ts index 79523f63..d10a8649 100644 --- a/test/repository/release-metadata.test.ts +++ b/test/repository/release-metadata.test.ts @@ -16,7 +16,7 @@ describe('release metadata', () => { readFileSync(path.join(repositoryRoot, 'assets', 'manifest.json'), 'utf8'), ) as { version: string }; - expect(packageJson.version).toBe('0.4.0-beta.11'); + expect(packageJson.version).toBe('0.4.0-beta.12'); expect(packageLock.version).toBe(packageJson.version); expect(packageLock.packages[''].version).toBe(packageJson.version); expect(assetsManifest.version).toBe(packageJson.version);