diff --git a/.gitignore b/.gitignore index 16248db8..e5e852a8 100644 --- a/.gitignore +++ b/.gitignore @@ -89,7 +89,7 @@ skills-lock.json .agents/ # Superpowers docs (local only) -#docs/superpowers/ +docs/superpowers/ .codegraph/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 154414b1..4cb367b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.6] - 2026-07-16 + +### Added + +- **`comet state rebind`**: new command to explicitly re-bind an `isolation: current` change to the current branch after user confirmation, recording an audit event; refuses to run while HEAD is detached or before an initial binding exists. +- **Current-branch isolation drift detection**: `isolation: current` changes now persist their bound branch in the committed change state, so switching branches mid-change is reliably detected at every build/verify/archive entry check and by the write guard; establishing `isolation: current` on a detached HEAD is rejected. + +### Changed + +- **Preset isolation choice**: hotfix/tweak workflows now require an explicit `branch`, `worktree`, or `current` isolation choice before build instead of silently defaulting to `current`. +- **Archive branch handling records an action**: archive now records which branch handling method was used (`branch_action`: push / keep-local / merged-locally / pushed-pr) alongside `branch_status: handled`. + ## What's Changed [0.4.0-beta.5] - 2026-07-14 ### Changed diff --git a/README-zh.md b/README-zh.md index b8096ae0..468ea306 100644 --- a/README-zh.md +++ b/README-zh.md @@ -499,7 +499,7 @@ build_mode: subagent-driven-development # 构建方式:subage build_pause: null # `build_pause` 记录 build 阶段内部暂停点:null 无暂停,`plan-ready` 表示 plan 已生成 subagent_dispatch: null # subagent 分派确认;进入 verify 前需 confirmed tdd_mode: null # full workflow 的 build 选择:tdd | direct -isolation: branch # 隔离方式:branch | worktree +isolation: branch # 隔离方式:branch | worktree | current verify_mode: null # 验证模式:light | full design_doc: docs/superpowers/specs/.md # 设计文档路径 plan: docs/superpowers/plans/YYYY-MM-DD-feature.md # 实现计划路径 @@ -541,7 +541,7 @@ Comet 通过自动化状态转换确保 agent 执行可靠性: - 检测未知/拼写错误字段 4. **Build 决策强制** — Guard 和状态转换同时拦截跳过关键选择 - - `isolation` 必须是 `branch` 或 `worktree` + - `isolation` 必须是 `branch`、`worktree` 或 `current` - `build_mode` 必须已选择 - `build_pause: plan-ready` 是 plan 生成后的可恢复暂停点,不是 `build_mode` - full workflow 的 `build_mode: direct` 必须有 `direct_override: true` @@ -675,7 +675,7 @@ Benchmark 核心结论: 抖音群(推荐) -
+
微信群 diff --git a/README.md b/README.md index 2b7beaaf..2b573973 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,7 @@ build_mode: subagent-driven-development # Build mode: subagent- build_pause: null # `build_pause` records an internal build-phase pause point: null none, `plan-ready` means the plan has been generated subagent_dispatch: null # Dispatch confirmation; confirm before verify tdd_mode: null # Full-workflow build choice: tdd | direct -isolation: branch # Isolation mode: branch | worktree +isolation: branch # Isolation mode: branch | worktree | current verify_mode: null # Verification mode: light | full design_doc: docs/superpowers/specs/.md # Design doc path plan: docs/superpowers/plans/YYYY-MM-DD-feature.md # Implementation plan path @@ -581,7 +581,7 @@ Comet ensures agent execution reliability through automated state transitions: - Detects unknown/typos fields 4. **Build Decision Enforcement** — Guard and state transitions both block skipped build choices - - `isolation` must be `branch` or `worktree` + - `isolation` must be `branch`, `worktree`, or `current` - `build_mode` must be selected before leaving build - `build_pause: plan-ready` is a recoverable pause after plan generation, not a `build_mode` - Full workflow `build_mode: direct` requires `direct_override: true` @@ -721,7 +721,7 @@ Track our development progress and upcoming features on the [Comet Roadmap](http DouYin (Recommended) -
+
WeChat diff --git a/assets/manifest.json b/assets/manifest.json index 6c897e66..7420e207 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "skills": [ "comet/SKILL.md", "comet/reference/auto-transition.md", diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 1f0a0f31..41702efc 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -26,6 +26,8 @@ comet state select comet state check archive ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 ### 1. 归档前最终确认(阻塞点) @@ -104,17 +106,23 @@ git commit -m "chore: archive " ### 5. 归档提交后的分支处理 -归档提交成功后,**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。该步骤必须位于归档与归档提交之后,确保最终分支/PR 包含 spec 合并和归档元数据。 +归档提交成功后,读取 `isolation` 字段: + +- 若 `isolation: branch` 或 `worktree`:**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。该步骤必须位于归档与归档提交之后,确保最终分支/PR 包含 spec 合并和归档元数据。如该技能不可用,停止流程并提示安装或启用;不得把 `branch_status` 标记为完成。技能加载后,按 `comet/reference/decision-point.md` 暂停让用户选择,并写入对应 `branch_action`: + 1. 本地合并到主分支 → `branch_action: merged-locally` + 2. 推送并创建 PR → `branch_action: pushed-pr` + 3. 保持当前分支稍后处理 → `branch_action: keep-local` -如该技能不可用,停止流程并提示安装或启用;不得把 `branch_status` 标记为完成。技能加载后,按 `comet/reference/decision-point.md` 暂停让用户选择: + 归档已经完成,因此这里不提供“丢弃工作”选项。 -1. 本地合并到主分支 -2. 推送并创建 PR -3. 保持当前分支稍后处理 +- 若 `isolation: current`:没有独立开发分支要收尾。这是用户决策点,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**,并写入对应 `branch_action`: + 1. 立即 push 当前分支 → `branch_action: push` + 2. 暂不 push,保留本地分支状态 → `branch_action: keep-local` -归档已经完成,因此这里不提供“丢弃工作”选项。只有用户选择的操作成功完成(或明确选择保持分支)后,才运行: +只有用户选择的操作成功完成(或明确选择保持分支)后,才运行: ```bash +comet state set branch_action comet state set branch_status handled comet guard archive comet state clear-selection diff --git a/assets/skills-zh/comet-build/SKILL.md b/assets/skills-zh/comet-build/SKILL.md index a7164057..a0222ac8 100644 --- a/assets/skills-zh/comet-build/SKILL.md +++ b/assets/skills-zh/comet-build/SKILL.md @@ -21,6 +21,8 @@ comet state select comet state check build ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 **幂等性**:build 阶段所有操作可安全重复执行。读取 `.comet.yaml` 的 `phase` 字段确认仍在 build 阶段,读取 plan 文件头的 `base-ref`,再按文档顺序解析 tasks.md 的复选框,从第一个未勾选任务继续执行。已提交的任务不得重复提交。 @@ -118,10 +120,12 @@ comet state set build_pause null |------|------|------| | A | 创建分支 | 在当前仓库创建新分支,简单快速 | | B | 创建 Worktree | 隔离工作区,完全独立,适合并行开发 | +| C | 使用当前分支 | 不创建新分支或 worktree,直接在当前分支继续 | **推荐规则**: - 变更涉及 ≤ 3 个文件 → 推荐 A - 需要并行开发、当前分支有未提交工作 → 推荐 B +- 当前分支本身就是长期集成分支、子模块工作分支,或用户明确要求不新建分支/worktree → 推荐 C **执行方式**: @@ -135,12 +139,12 @@ comet state set build_pause null - 任务数 ≤ 2 且无跨模块依赖 → 推荐 B - 来自 hotfix 路径 → 推荐 B -这些表格是 Step 2 联合决策的一部分,不再单独暂停。先移除能力预检判定为不可执行的选项;在剩余多个合法选项时,不得根据推荐规则自行选择 `branch` 或 `worktree`,也不得自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 +这些表格是 Step 2 联合决策的一部分,不再单独暂停。先移除能力预检判定为不可执行的选项;在剩余多个合法选项时,不得根据推荐规则自行选择 `branch`、`worktree` 或 `current`,也不得根据推荐规则自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 用户选择后,更新 `isolation`、执行方式、TDD 模式和代码审查模式相关字段: ```bash -comet state set isolation +comet state set isolation ``` - 若用户选择 `executing-plans`:运行 `comet state set subagent_dispatch null`,再运行 `comet state set build_mode executing-plans` @@ -200,6 +204,14 @@ comet state set build_mode direct - **worktree**:必须使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能创建隔离工作区。禁止用普通 shell 命令或原生工具绕过该技能;如该技能不可用,停止流程并提示安装或启用 Superpowers 技能。 +- **current**:不创建新分支,也不创建 worktree。立即运行: + +```bash +comet state set isolation current +``` + +随后继续在当前分支执行,不要因为没有 branch/worktree 切换而额外重新选择 current change。只有当后续实际发生 branch 切换时,才按 stale selection 处理并重新绑定。 + 创建隔离后,确认计划文件可访问(分支方式天然可访问;worktree 方式需确认计划已提交)。若 worktree 模式下计划文件尚未提交,先提交计划文件再创建 worktree: ```bash @@ -213,7 +225,7 @@ git commit -m "chore: add implementation plan" comet state select ``` -重新绑定成功后才能开始源码写入。 +`current` 模式不发生工作区切换时,不需要为了这一步重复绑定;保留当前工作区的既有选择即可。重新绑定成功后才能开始源码写入。 **执行计划**:必须按 `build_mode` 的真实运行位置处理。 @@ -288,7 +300,7 @@ Build 是最长阶段,可能跨越大量任务。为支持上下文压缩后 - tasks.md 全部勾选 - 代码已提交 - 已显式运行项目对应的构建/测试命令并通过(不要只依赖 guard 自动猜测) -- `isolation` 已写为 `branch` 或 `worktree` +- `isolation` 已写为 `branch`、`worktree` 或 `current` - `build_mode` 已写为 `subagent-driven-development`、`executing-plans` 或带显式 override 的 `direct`;若为 `subagent-driven-development`,`subagent_dispatch` 必须为 `confirmed` - `tdd_mode` 已写为 `tdd` 或 `direct` - `review_mode` 已写为 `off`、`standard` 或 `thorough` diff --git a/assets/skills-zh/comet-hotfix/SKILL.md b/assets/skills-zh/comet-hotfix/SKILL.md index 9040e339..1a73cc33 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -28,6 +28,8 @@ description: "仅在用户明确调用 /comet-hotfix,或由 Comet 根 Skill/ru 恢复已有 hotfix change 时,第一项状态操作必须是 `comet state select `;创建新 change 时,在 `.comet.yaml` 初始化成功后立即运行该命令,再进入源码写入步骤。 +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + ### 1. 快速开启(预设 open) 复用 Comet open 能力创建 change,但使用 hotfix 默认值:不执行 `openspec-explore` 长探索,直接进入精简 change 创建。 @@ -42,7 +44,7 @@ comet state select comet state check open ``` -hotfix 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能把它改为 `branch` 或 `worktree`。随后按指引创建精简版产物: +hotfix 初始保持 `isolation: null`。在 Step 2 明确选择隔离方式前,不要假定工作区。随后按指引创建精简版产物: - `proposal.md` — 问题描述 + 根因分析 + 修复目标(无需方案对比) - `design.md` — 修复方案(1 个即可,无需多方案对比) - `tasks.md` — 修复任务清单 @@ -65,7 +67,21 @@ comet state next ### 2. 直接构建(预设 build) -使用 hotfix 默认值:`build_mode: direct`、`tdd_mode: direct`、`review_mode: off`、`isolation: current`。`direct` 表示不进入完整规划/TDD 编排,不表示可以跳过复现、回归测试或验证。跳过 Superpowers `brainstorming` 和 `writing-plans`;**任务数量本身不触发 `/comet-build`**,任务较多时仍在当前 hotfix 的 tasks.md 中按顺序执行,只有命中后文质变信号或范围 tripwire 才交给用户决定是否升级 full。 +开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: + +- A. 创建分支(推荐默认) +- B. 使用 worktree +- C. 使用当前分支 + +用户选择后立即写入: + +```bash +comet state set isolation +``` + +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change。若选择 `worktree`,按 `/comet-build` 中的 worktree 规则创建隔离工作区,并在 worktree 内重新执行 `comet state select `。若选择 `current`,不创建新分支或 worktree,也不要因为没有切换工作区而额外重新选择。 + +使用 hotfix 默认值:`build_mode: direct`、`tdd_mode: direct`、`review_mode: off`。`direct` 表示不进入完整规划/TDD 编排,不表示可以跳过复现、回归测试或验证。跳过 Superpowers `brainstorming` 和 `writing-plans`;**任务数量本身不触发 `/comet-build`**,任务较多时仍在当前 hotfix 的 tasks.md 中按顺序执行,只有命中后文质变信号或范围 tripwire 才交给用户决定是否升级 full。 继续或开始修改前,按 `comet/reference/dirty-worktree.md` 协议处理未提交改动。若归因后发现修复命中质变信号或文件数 tripwire,按本文件「升级判定」处理。 @@ -139,8 +155,9 @@ comet guard build --apply Hotfix 流程默认 **一次性连续执行**。调用 `/comet-hotfix` 后,agent 在 hotfix 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 hotfix 流程,还是升级为完整 `/comet` 流程 -2. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 -3. 归档前最终确认,以及归档提交后的分支处理决策 +2. 初始隔离选择(`branch` / `worktree` / `current`) +3. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 +4. 归档前最终确认,以及归档提交后的分支处理决策 执行顺序:快速开启 → 直接构建 → 根因消除检查 → 验证 → 归档 → 完成 diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 88db7197..09138dcf 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -31,6 +31,8 @@ Tweak 是 Comet 五阶段能力的预设工作流,不是独立的平行流程 恢复已有 tweak change 时,第一项状态操作必须是 `comet state select `;创建新 change 时,在 `.comet.yaml` 初始化成功后立即运行该命令,再进入源码写入步骤。 +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + ### 1. 快速开启(预设 open) 复用 Comet open 能力创建 change,但使用 tweak 默认值:不执行 `openspec-explore` 长探索,直接进入精简 change 创建。 @@ -66,6 +68,20 @@ comet guard open --apply ### 2. OpenSpec apply 构建(tweak 专用预设 build) +开始执行前,必须先让用户显式选择隔离方式。这是用户决策点,必须按 `comet/reference/decision-point.md` 暂停并等待用户明确选择: + +- A. 创建分支(推荐默认) +- B. 使用 worktree +- C. 使用当前分支 + +用户选择后立即写入: + +```bash +comet state set isolation +``` + +若选择 `branch`,按 `/comet-build` 中的 branch 规则确认分支名并创建分支,然后重新执行 `comet state select ` 绑定当前 change。若选择 `worktree`,按 `/comet-build` 中的 worktree 规则创建隔离工作区,并在 worktree 内重新执行 `comet state select `。若选择 `current`,不创建新分支或 worktree,也不要因为没有切换工作区而额外重新选择。 + 使用 tweak 默认值:`build_mode: direct`。跳过 Superpowers `brainstorming` 和 `writing-plans`,改由 OpenSpec 的 apply action 执行当前 change 的 tasks。 @@ -134,8 +150,9 @@ comet state set verify_mode full Tweak 流程默认 **一次性连续执行**。调用 `/comet-tweak` 后,agent 在 tweak 自有步骤间自动推进,不主动停顿。**例外**:若 `auto_transition: false`,则在每个 phase 边界(build/verify/archive 之间)结束当前调用并按 `HINT` 交还控制权,由用户稍后手动运行下一阶段命令;这是手动衔接,不是新的确认点。无论 `auto_transition` 取何值,以下真正的用户决策仍需暂停: 1. 遇到升级判定信号(见「升级判定」章节),**必须使用当前平台可用的用户输入/确认机制暂停并等待用户明确选择**:继续 tweak 轻量流程,还是升级为完整 `/comet` 流程 -2. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 -3. 归档前最终确认,以及归档提交后的分支处理决策 +2. 初始隔离选择(`branch` / `worktree` / `current`) +3. 验证阶段(comet-verify)接受 WARNING/SUGGESTION 偏差、处理 Spec 漂移或超过自动修复上限后的策略决策;前 3 次明确可修复失败自动闭环 +4. 归档前最终确认,以及归档提交后的分支处理决策 执行顺序:快速开启 → 构建(含升级判定检查)→ 验证 → 归档 → 完成 diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 3e38c7dc..b9c2d371 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -25,6 +25,8 @@ comet state select comet state check verify ``` +若入口 `comet state check` 因分支绑定不一致返回 `BLOCKED`(`isolation: current` 的 change 绑定分支与当前分支不同,或当前处于 detached HEAD),这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户明确选择**:切回绑定分支后重试,或在用户明确确认换绑后运行 `comet state rebind ` 再重试。不得自行切换分支,也不得自行换绑。 + 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 **幂等性**:verify 阶段所有检查可安全重复执行。如 `verify_result` 已为 `pass`,说明验证已完成并应进入 archive;`branch_status` 在归档提交和最终分支处理完成前保持 `pending`。如 `verify_result` 为 `pending`,从头开始验证。 diff --git a/assets/skills-zh/comet/SKILL.md b/assets/skills-zh/comet/SKILL.md index b0bb95ba..3b1af16b 100644 --- a/assets/skills-zh/comet/SKILL.md +++ b/assets/skills-zh/comet/SKILL.md @@ -256,7 +256,7 @@ agent 不应跳过这些决策点;其他明确无歧义的阶段衔接必须 ### 状态机硬约束 -- full workflow 的 `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`;hotfix/tweak 可如实使用 `current` +- `build → verify` 前,`isolation` 必须是 `branch`、`worktree` 或 `current` - `build → verify` 前,`build_mode` 必须已选择 - `build_mode: subagent-driven-development` 必须同时有 `subagent_dispatch: confirmed` - full workflow 离开 build 阶段前 `tdd_mode` 必须已选择为 `tdd` 或 `direct` diff --git a/assets/skills-zh/comet/reference/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index b1ba24cb..a1503aaa 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -46,13 +46,14 @@ archived: false | `subagent_dispatch` | `null` 或 `confirmed`。仅当已确认当前平台存在真实后台 subagent / Task / multi-agent 调度能力时,`build_mode: subagent-driven-development` 才能写入并用于离开 build 阶段 | | `tdd_mode` | `tdd` 或 `direct`。full workflow 离开 build 阶段前必须已选择。`tdd` 强制每个任务先写失败测试再实现;`direct` 不强制逐任务 TDD,但仍需相关测试与 bug 回归证据。hotfix/tweak 默认 `direct` | | `review_mode` | `off`、`standard` 或 `thorough`。full workflow 离开 build 阶段前必须已选择;hotfix/tweak 默认 `off` | -| `isolation` | `current`、`branch` 或 `worktree`。full 初始化可为 `null`,离开 build 前必须实际创建/选择 `branch` 或 `worktree`;hotfix/tweak 默认 `current`,不得在未创建分支时虚构为 `branch` | +| `isolation` | `branch`、`worktree` 或 `current`,工作区隔离方式。full 初始化可为 `null`,但只允许持续到 `/comet-build` Step 3 前;hotfix/tweak 必须显式选择 `branch`、`worktree` 或 `current`,不再默认写入 | | `verify_mode` | `light` 或 `full`,可为空 | | `auto_transition` | `true` 或 `false`。只控制阶段守卫推进 phase 后是否自动调用下一个 skill;`false` 时由 `comet-state next` 输出 `manual`,暂停下一 skill 调用,但不阻止 phase 字段更新 | | `verify_result` | `pending`、`pass` 或 `fail` | | `verify_failures` | 机器维护的连续验证失败次数;`verify-fail` 自动加一,`verify-pass` 或 `archive-reopen` 重置为 `0`。达到 `3` 后下一次失败必须进入超限策略决策 | | `verification_report` | 验证报告文件路径,verify 通过前必须指向已存在文件 | -| `branch_status` | `pending` 或 `handled`。verify 和 archive 执行期间保持 `pending`;归档改动提交且用户选择的分支处理完成后设为 `handled` | +| `branch_status` | `pending` 或 `handled`。verify 阶段用户完成分支处理选择(见 `branch_action`)后设为 `handled` | +| `branch_action` | `null`、`push`、`keep-local`、`merged-locally` 或 `pushed-pr`。记录 verify 阶段用户实际选择的分支处理方式,供 archive 阶段决定是否推送归档 commit | | `created_at` | change 创建日期(init 时自动写入),格式 `YYYY-MM-DD` | | `verified_at` | 验证通过时间,可为空 | | `archive_confirmation` | `null`、`pending` 或 `confirmed`。`verify-pass` 进入 archive 阶段时写入 `pending`;用户在 `/comet-archive` 最终确认选择「确认归档」后,`archive-confirm` transition 写入 `confirmed`;`archive-reopen` 会清空该字段,防止复用旧确认 | @@ -66,7 +67,7 @@ archived: false ## 状态机硬约束 -- full workflow 的 `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`;hotfix/tweak 可使用 `current` +- `build → verify` 前,`isolation` 必须是 `branch`、`worktree` 或 `current` - `build → verify` 前,`build_mode` 必须已选择 - `build_mode: subagent-driven-development` 必须同时有 `subagent_dispatch: confirmed` - full workflow 离开 build 阶段前 `tdd_mode` 必须已选择为 `tdd` 或 `direct` diff --git a/assets/skills-zh/comet/reference/scripts.md b/assets/skills-zh/comet/reference/scripts.md index bc0f9043..d779f9f9 100644 --- a/assets/skills-zh/comet/reference/scripts.md +++ b/assets/skills-zh/comet/reference/scripts.md @@ -18,7 +18,7 @@ comet handoff comet archive ``` -当多个 active change 共存时,进入明确的 change 后先运行 `comet state select `。普通源码写入只受该选择管辖;尚未选择时 hook 会阻塞并要求选择。单 active change 可继续自动归属。切换 branch/worktree 或选择失效后必须重新运行 `select`。 +当多个 active change 共存时,进入明确的 change 后先运行 `comet state select `。普通源码写入只受该选择管辖;尚未选择时 hook 会阻塞并要求选择。单 active change 可继续自动归属。切换 branch/worktree 或选择失效后必须重新运行 `select`。`isolation: current` 模式下如果后续真的发生 branch 切换,这属于异常偏离,原选择同样视为 stale,必须先确认目标 change 再重新选择。 guard 的 `--apply` 在检查通过后推进状态。需要直接表达状态事件时使用 `comet state transition`;阶段推进后使用 `comet state next` 解析是否自动调用下一 Skill。 diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 0e26601d..8c6fc403 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -26,6 +26,8 @@ comet state select comet state check archive ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. ### 1. Final Archive Confirmation (Blocking Point) @@ -104,17 +106,23 @@ Stop if the commit fails or the staged diff contains unrelated paths. ### 5. Handle the Branch After the Archive Commit -After the archive commit succeeds, **immediately execute:** use the Skill tool to load Superpowers `finishing-a-development-branch`. This ordering ensures the final branch or PR contains the main-spec merge and archive metadata. +After the archive commit succeeds, read the `isolation` field: + +- If `isolation: branch` or `worktree`: **immediately execute:** use the Skill tool to load Superpowers `finishing-a-development-branch`. This ordering ensures the final branch or PR contains the main-spec merge and archive metadata. If the skill is unavailable, stop and prompt the user to enable/install it; do not mark `branch_status` handled. After loading it, pause under `comet/reference/decision-point.md` and let the user choose, recording the matching `branch_action`: + 1. Merge locally into the main branch → `branch_action: merged-locally` + 2. Push and create a PR → `branch_action: pushed-pr` + 3. Keep the current branch for later → `branch_action: keep-local` -If the skill is unavailable, stop and prompt the user to enable/install it; do not mark `branch_status` handled. After loading it, pause under `comet/reference/decision-point.md` and let the user choose: + Archive is already complete, so do not offer "discard work". -1. Merge locally into the main branch -2. Push and create a PR -3. Keep the current branch for later +- If `isolation: current`: there is no separate development branch to finish. This is a user decision point; **must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to explicitly choose**, recording the matching `branch_action`: + 1. Push the current branch now → `branch_action: push` + 2. Leave the current branch local for now → `branch_action: keep-local` -Archive is already complete, so do not offer "discard work". Only after the selected operation succeeds (or the user explicitly keeps the branch), run: +Only after the selected operation succeeds (or the user explicitly keeps the branch), run: ```bash +comet state set branch_action comet state set branch_status handled comet guard archive comet state clear-selection diff --git a/assets/skills/comet-build/SKILL.md b/assets/skills/comet-build/SKILL.md index f2065785..62ffd68f 100644 --- a/assets/skills/comet-build/SKILL.md +++ b/assets/skills/comet-build/SKILL.md @@ -21,6 +21,8 @@ comet state select comet state check build ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. **Idempotency**: All build phase operations can be safely re-executed. Read `.comet.yaml` `phase` to confirm build, read the plan header `base-ref`, then parse tasks.md checkboxes in document order and resume from the first unchecked task. Already-committed tasks must not be re-committed. @@ -118,10 +120,12 @@ The plan is on the current branch. These settings are all part of the single Ste |--------|--------|-------------| | A | Create branch | Create a new branch in the current repo, simple and fast | | B | Create Worktree | Isolated workspace, fully independent, suitable for parallel development | +| C | Use current branch | Create neither a new branch nor a worktree; continue on the current branch | **Recommendation rules**: - Change involves ≤ 3 files → Recommend A - Need parallel development, current branch has uncommitted work → Recommend B +- The current branch is already the long-lived integration branch, a submodule work branch, or the user explicitly does not want a new branch/worktree → Recommend C **Execution Method**: @@ -135,12 +139,12 @@ The plan is on the current branch. These settings are all part of the single Ste - Task count ≤ 2 and no cross-module dependencies → Recommend B - From hotfix path → Recommend B -These tables are part of the Step 2 joint decision and do not create another pause. First remove options that capability preflight found unavailable. When multiple valid options remain, do not choose `branch` or `worktree`, execution method, TDD mode, or review mode from recommendations. Recommendations explain a preference; they never replace user confirmation. +These tables are part of the Step 2 joint decision and do not create another pause. First remove options that capability preflight found unavailable. When multiple valid options remain, must not choose `branch`, `worktree`, or `current` based on recommendation rules, and must not choose the execution method, TDD mode, or code review mode based on recommendation rules. Recommendations explain a preference; they never replace user confirmation. After user selection, update `isolation`, execution method, TDD mode, and code review mode fields: ```bash -comet state set isolation +comet state set isolation ``` - If the user chooses `executing-plans`: run `comet state set subagent_dispatch null`, then run `comet state set build_mode executing-plans` @@ -200,6 +204,14 @@ Without `direct_override: true`, `build_mode=direct` in full workflow is blocked - **worktree**: Must use the Skill tool to load the Superpowers `using-git-worktrees` skill to create isolated workspace. Do not bypass this skill with plain shell commands or native tools; if the skill is unavailable, stop the process and prompt to install or enable Superpowers skills. +- **current**: Create neither a new branch nor a worktree. Immediately run: + +```bash +comet state set isolation current +``` + +Then continue execution on the current branch. Do not force a re-selection simply because no branch/worktree switch occurred. Only if a later real branch switch happens should the selection be treated as stale and rebound. + After creating isolation, confirm plan file is accessible (naturally accessible with branch method; for worktree method, confirm plan has been committed). If the plan file has not been committed under worktree mode, commit it first before creating the worktree: ```bash @@ -213,7 +225,7 @@ After entering the final execution branch or worktree, bind the current change a comet state select ``` -Do not begin source writes until this binding succeeds. +In `current` mode with no workspace switch, do not repeat this step just for formality; keep the current workspace selection. Do not begin source writes until the required binding is valid. **Execute plan**: Must handle execution according to the actual runtime of `build_mode`. @@ -288,7 +300,7 @@ Build is the longest phase and may span many tasks. To support resume after cont - All tasks.md checked - Code committed - Project-specific build/tests explicitly run and pass; do not rely only on guard auto-detection -- `isolation` has been written as `branch` or `worktree` +- `isolation` has been written as `branch`, `worktree`, or `current` - `build_mode` has been written as `subagent-driven-development`, `executing-plans`, or `direct` with explicit override; if `subagent-driven-development`, `subagent_dispatch` must be `confirmed` - `tdd_mode` has been written as `tdd` or `direct` - `review_mode` has been written as `off`, `standard`, or `thorough` diff --git a/assets/skills/comet-hotfix/SKILL.md b/assets/skills/comet-hotfix/SKILL.md index a54c8592..a26401dc 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -28,6 +28,8 @@ Before starting, locate Comet scripts via `comet/reference/scripts.md`. When res When resuming an existing hotfix change, the first state operation must be `comet state select `. For a new change, run the command immediately after `.comet.yaml` initialization and before source writes. +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + ### 1. Quick Open (preset open) Reuse Comet open capability to create change, but use hotfix defaults: do not execute `openspec-explore` long exploration, directly enter streamlined change creation. @@ -42,7 +44,7 @@ comet state select comet state check open ``` -Hotfix defaults to `isolation: current`, truthfully indicating execution in the current workspace. Change it to `branch` or `worktree` only after that workspace is actually created/selected. Then create the streamlined artifacts: +Hotfix starts with `isolation: null`. Do not assume a workspace until the explicit isolation decision in Step 2. Then create the streamlined artifacts: - `proposal.md` — problem description + root cause analysis + fix goal (no solution comparison needed) - `design.md` — fix solution (one is enough, no multi-solution comparison needed) - `tasks.md` — fix task list @@ -65,7 +67,21 @@ comet state next ### 2. Direct Build (preset build) -Use hotfix defaults: `build_mode: direct`, `tdd_mode: direct`, `review_mode: off`, and `isolation: current`. Here `direct` skips full planning/TDD orchestration; it never skips reproduction, regression coverage, or verification. Skip Superpowers `brainstorming` and `writing-plans`; **task count alone does not route to `/comet-build`**. Keep larger task lists ordered in the current hotfix and ask about upgrading only when a qualitative-change signal or scope tripwire is hit. +Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: + +- A. create a branch (recommended default) +- B. use a worktree +- C. use the current branch + +After the choice, immediately write: + +```bash +comet state set isolation +``` + +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change. If `worktree` is chosen, follow `/comet-build`'s worktree rules and re-run `comet state select ` inside the worktree. If `current` is chosen, create no new branch or worktree and do not force an extra re-selection when no workspace switch happened. + +Use hotfix defaults: `build_mode: direct`, `tdd_mode: direct`, `review_mode: off`. Here `direct` skips full planning/TDD orchestration; it never skips reproduction, regression coverage, or verification. Skip Superpowers `brainstorming` and `writing-plans`; **task count alone does not route to `/comet-build`**. Keep larger task lists ordered in the current hotfix and ask about upgrading only when a qualitative-change signal or scope tripwire is hit. Before continuing or starting changes, handle uncommitted changes through `comet/reference/dirty-worktree.md`. If attribution shows a qualitative-change signal or file-count tripwire is hit, handle it through this file's "Upgrade Assessment". @@ -143,8 +159,9 @@ Exception: when `.comet.yaml` has `auto_transition: false`, end the current invo The following genuine user decisions still pause: 1. Encountering an upgrade-assessment signal (see "Upgrade Assessment" section). **Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly choose**: continue the hotfix flow, or upgrade to the full `/comet` workflow -2. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically -3. Final archive confirmation and the branch-handling decision after the archive commit +2. initial isolation choice (`branch` / `worktree` / `current`) +3. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically +4. Final archive confirmation and the branch-handling decision after the archive commit Execution order: quick open → direct build → root cause check → verification → archive → complete diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 1bcb1d3f..4168ddf8 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -31,6 +31,8 @@ Before starting, locate Comet scripts via `comet/reference/scripts.md`. When res When resuming an existing tweak change, the first state operation must be `comet state select `. For a new change, run the command immediately after `.comet.yaml` initialization and before source writes. +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + ### 1. Quick Open (preset open) Reuse Comet open capability to create change, but use tweak defaults: do not execute `openspec-explore` long exploration, directly enter streamlined change creation. @@ -66,6 +68,20 @@ comet guard open --apply ### 2. OpenSpec Apply Build (tweak-only preset build) +Before execution starts, the user must explicitly choose isolation. This is a user decision point and must pause per `comet/reference/decision-point.md` until the user explicitly chooses: + +- A. create a branch (recommended default) +- B. use a worktree +- C. use the current branch + +After the choice, immediately write: + +```bash +comet state set isolation +``` + +If `branch` is chosen, follow `/comet-build`'s branch rules to confirm the branch name and create it, then re-run `comet state select ` to bind the current change. If `worktree` is chosen, follow `/comet-build`'s worktree rules and re-run `comet state select ` inside the worktree. If `current` is chosen, create no new branch or worktree and do not force an extra re-selection when no workspace switch happened. + Use tweak defaults: `build_mode: direct`. Skip Superpowers `brainstorming` and `writing-plans`, and let OpenSpec's apply action execute the current change's tasks. @@ -137,8 +153,9 @@ Exception: when `.comet.yaml` has `auto_transition: false`, end the current invo The following genuine user decisions still pause: 1. Encountering an upgrade-assessment signal (see "Upgrade Assessment" section). **Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly choose**: continue the tweak lightweight flow, or upgrade to the full `/comet` workflow -2. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically -3. Final archive confirmation and the branch-handling decision after the archive commit +2. initial isolation choice (`branch` / `worktree` / `current`) +3. Verify-phase acceptance of WARNING/SUGGESTION deviations, Spec drift handling, or strategy after the automatic repair limit; the first 3 clearly repairable failures close automatically +4. Final archive confirmation and the branch-handling decision after the archive commit Execution order: quick open → build (with upgrade assessment) → verification → archive → complete diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index c97c5d53..378ef898 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -25,6 +25,8 @@ comet state select comet state check verify ``` +If entry `comet state check` returns `BLOCKED` due to a branch binding mismatch (an `isolation: current` change's bound branch differs from the current branch, or HEAD is currently detached), this is a user decision point. **Must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user's explicit choice**: switch back to the bound branch and retry, or run `comet state rebind ` and retry only after the user explicitly confirms the rebind. Do not switch branches or rebind on your own. + Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. **Idempotency**: All verify checks are safe to repeat. If `verify_result` is already `pass`, verification is complete and archive should continue; keep `branch_status: pending` until archive changes are committed and final branch handling finishes. If `verify_result` is `pending`, start verification from the beginning. diff --git a/assets/skills/comet/SKILL.md b/assets/skills/comet/SKILL.md index 6e5433c4..a1bd106d 100644 --- a/assets/skills/comet/SKILL.md +++ b/assets/skills/comet/SKILL.md @@ -248,7 +248,7 @@ Agents should not skip these decision points; other unambiguous phase transition ### State Machine Hard Constraints -- Before full-workflow `build → verify`, `isolation` must be `branch` or `worktree`; hotfix/tweak may truthfully use `current` +- Before `build → verify`, `isolation` must be `branch`, `worktree`, or `current` - Before `build → verify`, `build_mode` must be selected - `build_mode: subagent-driven-development` must also have `subagent_dispatch: confirmed` - Before full workflow leaves build phase, `tdd_mode` must be selected as `tdd` or `direct` diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index 3e89ff32..8840963e 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -47,13 +47,14 @@ archived: false | `subagent_dispatch` | `null` or `confirmed`. Only when the platform's real background subagent/Task/multi-agent dispatch capability is confirmed may `build_mode: subagent-driven-development` be written and used to leave the build phase | | `tdd_mode` | `tdd` or `direct`. Full workflow must select before leaving build. `tdd` forces write-failing-test-first per task; `direct` skips per-task TDD but still requires relevant tests and bug-regression evidence. hotfix/tweak default to `direct` | | `review_mode` | `off`, `standard`, or `thorough`. Full workflow must select before leaving build; hotfix/tweak default to `off` | -| `isolation` | `current`, `branch`, or `worktree`. Full init may be `null` but must use a real `branch` or `worktree` before leaving build; hotfix/tweak default to `current` and must not claim branch isolation before creating one | +| `isolation` | `branch`, `worktree`, or `current`, workspace isolation mode. Full init may be `null` but only until `/comet-build` Step 3; hotfix/tweak must explicitly choose `branch`, `worktree`, or `current` and no longer default it | | `verify_mode` | `light` or `full`; may be empty | | `auto_transition` | `true` or `false`. Only controls whether to automatically invoke the next skill after phase guard advances phase; `false` outputs `manual` from `comet-state next`, pausing next-skill invocation but not blocking phase field updates | | `verify_result` | `pending`, `pass`, or `fail` | | `verify_failures` | Machine-owned consecutive verification failure count. `verify-fail` increments it; `verify-pass` or `archive-reopen` resets it to `0`. At `3`, the next failure requires the retry-limit strategy decision | | `verification_report` | Verification report file path; must point to an existing file before verify passes | -| `branch_status` | `pending` or `handled`; keep pending through verify/archive, then set handled after the archive commit and selected branch handling complete | +| `branch_status` | `pending` or `handled`; set to `handled` once the user completes branch handling in verify (see `branch_action`) | +| `branch_action` | `null`, `push`, `keep-local`, `merged-locally`, or `pushed-pr`. Records the branch handling method the user actually chose during verify, used by archive to decide whether to push the archive commit | | `created_at` | Change creation date (auto-written at init), format `YYYY-MM-DD` | | `verified_at` | Verification pass timestamp; may be empty | | `archive_confirmation` | `null`, `pending`, or `confirmed`. `verify-pass` writes `pending` when entering the archive phase; after the user selects "Confirm archive" in `/comet-archive`, the `archive-confirm` transition writes `confirmed`; `archive-reopen` clears the field so an earlier confirmation cannot be reused | @@ -67,7 +68,7 @@ archived: false ## State Machine Hard Constraints -- Before full-workflow `build → verify`, `isolation` must be `branch` or `worktree`; hotfix/tweak may use `current` +- Before `build → verify`, `isolation` must be `branch`, `worktree`, or `current` - Before `build → verify`, `build_mode` must be selected - `build_mode: subagent-driven-development` requires `subagent_dispatch: confirmed` - Full workflow must select `tdd_mode` as `tdd` or `direct` before leaving build diff --git a/assets/skills/comet/reference/scripts.md b/assets/skills/comet/reference/scripts.md index 093c9c5b..3c3fc745 100644 --- a/assets/skills/comet/reference/scripts.md +++ b/assets/skills/comet/reference/scripts.md @@ -18,7 +18,7 @@ comet handoff comet archive ``` -When multiple active changes coexist, run `comet state select ` after resolving the intended change. Ordinary source writes are governed only by that selection; without one, the hook blocks and asks for a choice. A single active change retains automatic routing. Select again after switching branch/worktree or when the recorded selection becomes stale. +When multiple active changes coexist, run `comet state select ` after resolving the intended change. Ordinary source writes are governed only by that selection; without one, the hook blocks and asks for a choice. A single active change retains automatic routing. Select again after switching branch/worktree or when the recorded selection becomes stale. In `isolation: current` mode, if a real branch switch later happens, treat that as abnormal drift: confirm the intended change again and re-select before continuing. Guard `--apply` advances state after checks pass. Use `comet state transition` when expressing a state event directly, and `comet state next` after phase advancement to determine whether to invoke the next Skill automatically. diff --git a/assets/skills/comet/rules/comet-phase-guard.md b/assets/skills/comet/rules/comet-phase-guard.md index 0cc854b6..c0e37614 100644 --- a/assets/skills/comet/rules/comet-phase-guard.md +++ b/assets/skills/comet/rules/comet-phase-guard.md @@ -26,7 +26,7 @@ comet state select | `design` | brainstorming, 创建 Design Doc, 运行 guard | 写源代码 | | `build` | 写源代码、测试、执行计划 | 跳过用户确认点 | | `verify` | 验证、记录验证报告 | 跳过失败处理、提前处理分支 | -| `archive` | 确认归档、运行归档脚本、提交归档改动、分支处理 | 写源代码 | +| `archive` | 确认归档、运行归档脚本、提交归档改动、branch handling / current-branch handling | 写源代码 | Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpowers/*`、`.claude/*` 和 `.comet/*` 等流程/平台工作区;这些路径可写不代表可以跳过当前阶段的产物和确认要求。 @@ -74,7 +74,7 @@ Hook 硬拦截白名单包括 `openspec/*`、`docs/superpowers/*`、`.superpower - **design**: brainstorming 方案确认(确认前不得创建 Design Doc) - **build**: 能力预检后,在一个联合决策中选择 plan-ready 暂停或所有可执行的 `isolation` / `build_mode` / `tdd_mode` / `review_mode`;选择 branch 时同时确认分支名,另含 spec 大规模变更确认、预设升级判定 - **verify**: 接受 WARNING/SUGGESTION 偏差、Spec 漂移处理,或超过自动修复上限后的继续/停止策略 -- **archive**: 归档前最终确认,以及归档提交后的 branch handling 选择 +- **archive**: 归档前最终确认,以及归档提交后的 branch handling / current-branch handling 选择 ## Design 阶段专项 diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 32dbd252..82bb21cd 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -49,6 +49,170 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); +// domains/engine/state.ts +var state_exports = {}; +__export(state_exports, { + RUN_STATE_FILE: () => RUN_STATE_FILE, + applyRunStateToDocument: () => applyRunStateToDocument, + readRunState: () => readRunState, + removeRunState: () => removeRunState, + runStateFromDocument: () => runStateFromDocument, + writeRunState: () => writeRunState +}); +import { randomUUID } from "crypto"; +import { promises as fs3 } from "fs"; +import path3 from "path"; +function requiredString(doc, key) { + const value = doc[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Invalid Run state: ${key} must be a non-empty string`); + } + return value; +} +function requiredRunReference(doc, key) { + const value = requiredString(doc, key); + if (path3.isAbsolute(value) || /^(?:[A-Za-z]:|[\\/]|~)/u.test(value) || value.split(/[\\/]/u).includes("..")) { + throw new Error(`Invalid Run state: ${key} must stay inside the change directory`); + } + return value; +} +function retries(doc) { + const raw = doc.run_retries ?? "{}"; + let value; + try { + value = typeof raw === "string" ? JSON.parse(raw) : raw; + } catch (error) { + throw new Error("Invalid Run state: run_retries must be a JSON object", { cause: error }); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Run state: run_retries must be a JSON object"); + } + for (const count of Object.values(value)) { + if (!Number.isInteger(count) || Number(count) < 0) { + throw new Error("Invalid Run state: retry counts must be non-negative integers"); + } + } + return value; +} +function runStateFromDocument(doc) { + if (!doc.run_id) return null; + const runId = requiredString(doc, "run_id"); + const skill = requiredString(doc, "skill"); + const skillVersion = requiredString(doc, "skill_version"); + const skillHash = requiredString(doc, "skill_hash"); + const pendingRef = requiredRunReference(doc, "pending_ref"); + const trajectoryRef = requiredRunReference(doc, "trajectory_ref"); + const contextRef = requiredRunReference(doc, "context_ref"); + const artifactsRef = requiredRunReference(doc, "artifacts_ref"); + const checkpointRef = requiredRunReference(doc, "checkpoint_ref"); + const iteration = Number(doc.iteration); + if (!Number.isInteger(iteration) || iteration < 0) { + throw new Error("Invalid Run state: iteration must be a non-negative integer"); + } + if (doc.orchestration !== "deterministic" && doc.orchestration !== "adaptive") { + throw new Error("Invalid Run state: orchestration must be deterministic or adaptive"); + } + if (doc.run_status !== "running" && doc.run_status !== "waiting" && doc.run_status !== "completed" && doc.run_status !== "failed") { + throw new Error("Invalid Run state: run_status is invalid"); + } + return { + runId, + skill, + skillVersion, + skillHash, + orchestration: doc.orchestration, + currentStep: field(doc, "current_step"), + iteration, + pending: field(doc, "pending"), + pendingRef, + trajectoryRef, + contextRef, + artifactsRef, + checkpointRef, + status: doc.run_status, + retries: retries(doc) + }; +} +function applyRunStateToDocument(doc, state) { + if (state) { + doc.run_id = state.runId; + } else { + delete doc.run_id; + } +} +function runStateToJson(state) { + return { + runId: state.runId, + skill: state.skill, + skillVersion: state.skillVersion, + skillHash: state.skillHash, + orchestration: state.orchestration, + currentStep: state.currentStep, + iteration: state.iteration, + pending: state.pending, + pendingRef: state.pendingRef, + trajectoryRef: state.trajectoryRef, + contextRef: state.contextRef, + artifactsRef: state.artifactsRef, + checkpointRef: state.checkpointRef, + status: state.status, + retries: state.retries + }; +} +function runStateFromJson(json) { + const doc = { + run_id: json.runId, + skill: json.skill, + skill_version: json.skillVersion, + skill_hash: json.skillHash, + orchestration: json.orchestration, + current_step: json.currentStep, + iteration: json.iteration, + pending: json.pending, + pending_ref: json.pendingRef, + trajectory_ref: json.trajectoryRef, + context_ref: json.contextRef, + artifacts_ref: json.artifactsRef, + checkpoint_ref: json.checkpointRef, + run_status: json.status, + run_retries: JSON.stringify(json.retries) + }; + return runStateFromDocument(doc); +} +async function readRunState(changeDir) { + const file = path3.join(changeDir, RUN_STATE_FILE); + let raw; + try { + raw = await fs3.readFile(file, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + const json = JSON.parse(raw); + return runStateFromJson(json); +} +async function writeRunState(changeDir, state) { + await fs3.mkdir(path3.join(changeDir, ".comet"), { recursive: true }); + const file = path3.join(changeDir, RUN_STATE_FILE); + const temporary = path3.join(changeDir, ".comet", `run-state.${randomUUID()}.tmp`); + await fs3.writeFile(temporary, JSON.stringify(runStateToJson(state), null, 2), "utf8"); + await fs3.rename(temporary, file); +} +async function removeRunState(changeDir) { + await fs3.rm(path3.join(changeDir, RUN_STATE_FILE), { force: true }); +} +var field, RUN_STATE_FILE; +var init_state = __esm({ + "domains/engine/state.ts"() { + "use strict"; + field = (doc, key) => { + const value = doc[key]; + return value === null || value === void 0 ? null : String(value); + }; + RUN_STATE_FILE = ".comet/run-state.json"; + } +}); + // node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js var require_identity = __commonJS({ "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js"(exports) { @@ -126,17 +290,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path22) { - const ctrl = callVisitor(key, node, visitor, path22); + function visit_(key, node, visitor, path23) { + const ctrl = callVisitor(key, node, visitor, path23); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path22, ctrl); - return visit_(key, ctrl, visitor, path22); + replaceNode(key, path23, ctrl); + return visit_(key, ctrl, visitor, path23); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path22 = Object.freeze(path22.concat(node)); + path23 = Object.freeze(path23.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path22); + const ci = visit_(i, node.items[i], visitor, path23); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -147,13 +311,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path22 = Object.freeze(path22.concat(node)); - const ck = visit_("key", node.key, visitor, path22); + path23 = Object.freeze(path23.concat(node)); + const ck = visit_("key", node.key, visitor, path23); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path22); + const cv = visit_("value", node.value, visitor, path23); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -174,17 +338,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path22) { - const ctrl = await callVisitor(key, node, visitor, path22); + async function visitAsync_(key, node, visitor, path23) { + const ctrl = await callVisitor(key, node, visitor, path23); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path22, ctrl); - return visitAsync_(key, ctrl, visitor, path22); + replaceNode(key, path23, ctrl); + return visitAsync_(key, ctrl, visitor, path23); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path22 = Object.freeze(path22.concat(node)); + path23 = Object.freeze(path23.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path22); + const ci = await visitAsync_(i, node.items[i], visitor, path23); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -195,13 +359,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path22 = Object.freeze(path22.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path22); + path23 = Object.freeze(path23.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path23); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path22); + const cv = await visitAsync_("value", node.value, visitor, path23); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -228,23 +392,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path22) { + function callVisitor(key, node, visitor, path23) { if (typeof visitor === "function") - return visitor(key, node, path22); + return visitor(key, node, path23); if (identity.isMap(node)) - return visitor.Map?.(key, node, path22); + return visitor.Map?.(key, node, path23); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path22); + return visitor.Seq?.(key, node, path23); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path22); + return visitor.Pair?.(key, node, path23); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path22); + return visitor.Scalar?.(key, node, path23); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path22); + return visitor.Alias?.(key, node, path23); return void 0; } - function replaceNode(key, path22, node) { - const parent = path22[path22.length - 1]; + function replaceNode(key, path23, node) { + const parent = path23[path23.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -854,10 +1018,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path22, value) { + function collectionFromPath(schema, path23, value) { let v = value; - for (let i = path22.length - 1; i >= 0; --i) { - const k = path22[i]; + for (let i = path23.length - 1; i >= 0; --i) { + const k = path23[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -876,7 +1040,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path22) => path22 == null || typeof path22 === "object" && !!path22[Symbol.iterator]().next().done; + var isEmptyPath = (path23) => path23 == null || typeof path23 === "object" && !!path23[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -906,11 +1070,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path22, value) { - if (isEmptyPath(path22)) + addIn(path23, value) { + if (isEmptyPath(path23)) this.add(value); else { - const [key, ...rest] = path22; + const [key, ...rest] = path23; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -924,8 +1088,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path22) { - const [key, ...rest] = path22; + deleteIn(path23) { + const [key, ...rest] = path23; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -939,8 +1103,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path22, keepScalar) { - const [key, ...rest] = path22; + getIn(path23, keepScalar) { + const [key, ...rest] = path23; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -958,8 +1122,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path22) { - const [key, ...rest] = path22; + hasIn(path23) { + const [key, ...rest] = path23; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -969,8 +1133,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path22, value) { - const [key, ...rest] = path22; + setIn(path23, value) { + const [key, ...rest] = path23; if (rest.length === 0) { this.set(key, value); } else { @@ -3485,9 +3649,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path22, value) { + addIn(path23, value) { if (assertCollection(this.contents)) - this.contents.addIn(path22, value); + this.contents.addIn(path23, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3562,14 +3726,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path22) { - if (Collection.isEmptyPath(path22)) { + deleteIn(path23) { + if (Collection.isEmptyPath(path23)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path22) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path23) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3584,10 +3748,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path22, keepScalar) { - if (Collection.isEmptyPath(path22)) + getIn(path23, keepScalar) { + if (Collection.isEmptyPath(path23)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path22, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path23, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3598,10 +3762,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path22) { - if (Collection.isEmptyPath(path22)) + hasIn(path23) { + if (Collection.isEmptyPath(path23)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path22) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path23) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3618,13 +3782,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path22, value) { - if (Collection.isEmptyPath(path22)) { + setIn(path23, value) { + if (Collection.isEmptyPath(path23)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path22), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path23), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path22, value); + this.contents.setIn(path23, value); } } /** @@ -5584,9 +5748,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path22) => { + visit.itemAtPath = (cst, path23) => { let item = cst; - for (const [field2, index] of path22) { + for (const [field2, index] of path23) { const tok = item?.[field2]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5595,23 +5759,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path22) => { - const parent = visit.itemAtPath(cst, path22.slice(0, -1)); - const field2 = path22[path22.length - 1][0]; + visit.parentCollection = (cst, path23) => { + const parent = visit.itemAtPath(cst, path23.slice(0, -1)); + const field2 = path23[path23.length - 1][0]; const coll = parent?.[field2]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path22, item, visitor) { - let ctrl = visitor(item, path22); + function _visit(path23, item, visitor) { + let ctrl = visitor(item, path23); if (typeof ctrl === "symbol") return ctrl; for (const field2 of ["key", "value"]) { const token = item[field2]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path22.concat([[field2, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path23.concat([[field2, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5622,10 +5786,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field2 === "key") - ctrl = ctrl(item, path22); + ctrl = ctrl(item, path23); } } - return typeof ctrl === "function" ? ctrl(item, path22) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path23) : ctrl; } exports.visit = visit; } @@ -6927,14 +7091,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs21 = this.flowScalar(this.type); + const fs22 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs21, sep: [] }); + map.items.push({ start, key: fs22, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs21); + this.stack.push(fs22); } else { - Object.assign(it, { key: fs21, sep: [] }); + Object.assign(it, { key: fs22, sep: [] }); this.onKeyLine = true; } return; @@ -7062,13 +7226,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs21 = this.flowScalar(this.type); + const fs22 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs21, sep: [] }); + fc.items.push({ start: [], key: fs22, sep: [] }); else if (it.sep) - this.stack.push(fs21); + this.stack.push(fs22); else - Object.assign(it, { key: fs21, sep: [] }); + Object.assign(it, { key: fs22, sep: [] }); return; } case "flow-map-end": @@ -7257,7 +7421,7 @@ var require_public_api = __commonJS({ return docs; return Object.assign([], { empty: true }, composer$1.streamInfo()); } - function parseDocument7(source, options = {}) { + function parseDocument8(source, options = {}) { const { lineCounter: lineCounter2, prettyErrors } = parseOptions(options); const parser$1 = new parser.Parser(lineCounter2?.addNewLine); const composer$1 = new composer.Composer(options); @@ -7283,7 +7447,7 @@ var require_public_api = __commonJS({ } else if (options === void 0 && reviver && typeof reviver === "object") { options = reviver; } - const doc = parseDocument7(src, options); + const doc = parseDocument8(src, options); if (!doc) return null; doc.warnings.forEach((warning) => log.warn(doc.options.logLevel, warning)); @@ -7319,7 +7483,7 @@ var require_public_api = __commonJS({ } exports.parse = parse2; exports.parseAllDocuments = parseAllDocuments; - exports.parseDocument = parseDocument7; + exports.parseDocument = parseDocument8; exports.stringify = stringify; } }); @@ -7376,230 +7540,66 @@ var require_dist = __commonJS({ } }); -// domains/engine/state.ts -var state_exports = {}; -__export(state_exports, { - RUN_STATE_FILE: () => RUN_STATE_FILE, - applyRunStateToDocument: () => applyRunStateToDocument, - readRunState: () => readRunState, - removeRunState: () => removeRunState, - runStateFromDocument: () => runStateFromDocument, - writeRunState: () => writeRunState -}); -import { randomUUID } from "crypto"; -import { promises as fs3 } from "fs"; -import path3 from "path"; -function requiredString(doc, key) { - const value = doc[key]; - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Invalid Run state: ${key} must be a non-empty string`); +// domains/comet-classic/classic-cli.ts +import { pathToFileURL } from "url"; + +// domains/comet-classic/classic-archive.ts +import { createHash as createHash3 } from "crypto"; +import { spawnSync } from "child_process"; +import { promises as fs11 } from "fs"; +import path12 from "path"; + +// domains/comet-classic/classic-paths.ts +import { promises as fs } from "fs"; +import path from "path"; +async function exists(file) { + try { + await fs.access(file); + return true; + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; } - return value; } -function requiredRunReference(doc, key) { - const value = requiredString(doc, key); - if (path3.isAbsolute(value) || /^(?:[A-Za-z]:|[\\/]|~)/u.test(value) || value.split(/[\\/]/u).includes("..")) { - throw new Error(`Invalid Run state: ${key} must stay inside the change directory`); +function filesystemPath(relativePath2) { + return path.resolve(...relativePath2.split("/")); +} +function openSpecChangeNameError(name) { + if (!name) return "Change name cannot be empty"; + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(name)) { + return `Invalid change name: '${name}' +Valid format: lowercase kebab-case (a-z, 0-9, single hyphens)`; } - return value; + if (name.includes("..")) return "Change name cannot contain '..' (path traversal not allowed)"; + return null; } -function retries(doc) { - const raw = doc.run_retries ?? "{}"; - let value; - try { - value = typeof raw === "string" ? JSON.parse(raw) : raw; - } catch (error) { - throw new Error("Invalid Run state: run_retries must be a JSON object", { cause: error }); +function assertOpenSpecChangeName(name) { + const error = openSpecChangeNameError(name); + if (error) throw new Error(error); +} +async function resolveClassicChangeDirectory(name) { + const active = `openspec/changes/${name}`; + if (await exists(filesystemPath(active))) { + return { label: active, directory: filesystemPath(active) }; } - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Invalid Run state: run_retries must be a JSON object"); + const archiveRoot = "openspec/changes/archive"; + const exactArchive = `${archiveRoot}/${name}`; + if (await exists(filesystemPath(exactArchive))) { + return { label: exactArchive, directory: filesystemPath(exactArchive) }; } - for (const count of Object.values(value)) { - if (!Number.isInteger(count) || Number(count) < 0) { - throw new Error("Invalid Run state: retry counts must be non-negative integers"); + if (await exists(filesystemPath(archiveRoot))) { + const matches = []; + for (const entry2 of await fs.readdir(filesystemPath(archiveRoot), { withFileTypes: true })) { + if (!entry2.isDirectory() || !entry2.name.endsWith(`-${name}`)) continue; + const candidate = `${archiveRoot}/${entry2.name}`; + if (await exists(path.join(filesystemPath(candidate), ".comet.yaml"))) { + matches.push(candidate); + } } + const latest = matches.sort((left, right) => right.localeCompare(left))[0]; + if (latest) return { label: latest, directory: filesystemPath(latest) }; } - return value; -} -function runStateFromDocument(doc) { - if (!doc.run_id) return null; - const runId = requiredString(doc, "run_id"); - const skill = requiredString(doc, "skill"); - const skillVersion = requiredString(doc, "skill_version"); - const skillHash = requiredString(doc, "skill_hash"); - const pendingRef = requiredRunReference(doc, "pending_ref"); - const trajectoryRef = requiredRunReference(doc, "trajectory_ref"); - const contextRef = requiredRunReference(doc, "context_ref"); - const artifactsRef = requiredRunReference(doc, "artifacts_ref"); - const checkpointRef = requiredRunReference(doc, "checkpoint_ref"); - const iteration = Number(doc.iteration); - if (!Number.isInteger(iteration) || iteration < 0) { - throw new Error("Invalid Run state: iteration must be a non-negative integer"); - } - if (doc.orchestration !== "deterministic" && doc.orchestration !== "adaptive") { - throw new Error("Invalid Run state: orchestration must be deterministic or adaptive"); - } - if (doc.run_status !== "running" && doc.run_status !== "waiting" && doc.run_status !== "completed" && doc.run_status !== "failed") { - throw new Error("Invalid Run state: run_status is invalid"); - } - return { - runId, - skill, - skillVersion, - skillHash, - orchestration: doc.orchestration, - currentStep: field(doc, "current_step"), - iteration, - pending: field(doc, "pending"), - pendingRef, - trajectoryRef, - contextRef, - artifactsRef, - checkpointRef, - status: doc.run_status, - retries: retries(doc) - }; -} -function applyRunStateToDocument(doc, state) { - if (state) { - doc.run_id = state.runId; - } else { - delete doc.run_id; - } -} -function runStateToJson(state) { - return { - runId: state.runId, - skill: state.skill, - skillVersion: state.skillVersion, - skillHash: state.skillHash, - orchestration: state.orchestration, - currentStep: state.currentStep, - iteration: state.iteration, - pending: state.pending, - pendingRef: state.pendingRef, - trajectoryRef: state.trajectoryRef, - contextRef: state.contextRef, - artifactsRef: state.artifactsRef, - checkpointRef: state.checkpointRef, - status: state.status, - retries: state.retries - }; -} -function runStateFromJson(json) { - const doc = { - run_id: json.runId, - skill: json.skill, - skill_version: json.skillVersion, - skill_hash: json.skillHash, - orchestration: json.orchestration, - current_step: json.currentStep, - iteration: json.iteration, - pending: json.pending, - pending_ref: json.pendingRef, - trajectory_ref: json.trajectoryRef, - context_ref: json.contextRef, - artifacts_ref: json.artifactsRef, - checkpoint_ref: json.checkpointRef, - run_status: json.status, - run_retries: JSON.stringify(json.retries) - }; - return runStateFromDocument(doc); -} -async function readRunState(changeDir) { - const file = path3.join(changeDir, RUN_STATE_FILE); - let raw; - try { - raw = await fs3.readFile(file, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return null; - throw error; - } - const json = JSON.parse(raw); - return runStateFromJson(json); -} -async function writeRunState(changeDir, state) { - await fs3.mkdir(path3.join(changeDir, ".comet"), { recursive: true }); - const file = path3.join(changeDir, RUN_STATE_FILE); - const temporary = path3.join(changeDir, ".comet", `run-state.${randomUUID()}.tmp`); - await fs3.writeFile(temporary, JSON.stringify(runStateToJson(state), null, 2), "utf8"); - await fs3.rename(temporary, file); -} -async function removeRunState(changeDir) { - await fs3.rm(path3.join(changeDir, RUN_STATE_FILE), { force: true }); -} -var field, RUN_STATE_FILE; -var init_state = __esm({ - "domains/engine/state.ts"() { - "use strict"; - field = (doc, key) => { - const value = doc[key]; - return value === null || value === void 0 ? null : String(value); - }; - RUN_STATE_FILE = ".comet/run-state.json"; - } -}); - -// domains/comet-classic/classic-cli.ts -import { pathToFileURL } from "url"; - -// domains/comet-classic/classic-archive.ts -import { createHash as createHash3 } from "crypto"; -import { spawnSync } from "child_process"; -import { promises as fs11 } from "fs"; -import path12 from "path"; - -// domains/comet-classic/classic-paths.ts -import { promises as fs } from "fs"; -import path from "path"; -async function exists(file) { - try { - await fs.access(file); - return true; - } catch (error) { - if (error.code === "ENOENT") return false; - throw error; - } -} -function filesystemPath(relativePath2) { - return path.resolve(...relativePath2.split("/")); -} -function openSpecChangeNameError(name) { - if (!name) return "Change name cannot be empty"; - if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(name)) { - return `Invalid change name: '${name}' -Valid format: lowercase kebab-case (a-z, 0-9, single hyphens)`; - } - if (name.includes("..")) return "Change name cannot contain '..' (path traversal not allowed)"; - return null; -} -function assertOpenSpecChangeName(name) { - const error = openSpecChangeNameError(name); - if (error) throw new Error(error); -} -async function resolveClassicChangeDirectory(name) { - const active = `openspec/changes/${name}`; - if (await exists(filesystemPath(active))) { - return { label: active, directory: filesystemPath(active) }; - } - const archiveRoot = "openspec/changes/archive"; - const exactArchive = `${archiveRoot}/${name}`; - if (await exists(filesystemPath(exactArchive))) { - return { label: exactArchive, directory: filesystemPath(exactArchive) }; - } - if (await exists(filesystemPath(archiveRoot))) { - const matches = []; - for (const entry2 of await fs.readdir(filesystemPath(archiveRoot), { withFileTypes: true })) { - if (!entry2.isDirectory() || !entry2.name.endsWith(`-${name}`)) continue; - const candidate = `${archiveRoot}/${entry2.name}`; - if (await exists(path.join(filesystemPath(candidate), ".comet.yaml"))) { - matches.push(candidate); - } - } - const latest = matches.sort((left, right) => right.localeCompare(left))[0]; - if (latest) return { label: latest, directory: filesystemPath(latest) }; - } - return { label: active, directory: filesystemPath(active) }; + return { label: active, directory: filesystemPath(active) }; } // domains/comet-classic/classic-runtime-run.ts @@ -7723,79 +7723,6 @@ import { createHash as createHash2, randomUUID as randomUUID5 } from "crypto"; import { promises as fs8 } from "fs"; import path9 from "path"; -// domains/comet-classic/classic-resolver.ts -function profileFor(classic) { - return classic.classicProfile ?? classic.workflow; -} -function fullBuildConfigured(classic) { - if (!classic.buildMode || !classic.tddMode || !classic.isolation || !classic.verifyMode) { - return false; - } - if (classic.buildMode === "subagent-driven-development") { - return classic.subagentDispatch === "confirmed"; - } - if (classic.buildMode === "direct") return classic.directOverride === true; - return true; -} -function presetBuildConfigured(classic) { - return Boolean( - classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation !== null && classic.verifyMode === "light" - ); -} -function resolveBuild(profile, classic, evidence) { - if (classic.verifyResult === "fail") { - return profile === "full" ? "full.build.fix" : `${profile}.build.execute`; - } - if (profile === "full") { - if (!evidenceSatisfied(evidence, "build.plan")) return "full.build.plan"; - if (classic.buildPause === "plan-ready") return "full.build.plan-ready"; - if (!fullBuildConfigured(classic)) return "full.build.configure"; - } else if (!presetBuildConfigured(classic)) { - throw new Error(`${profile} build configuration is incomplete`); - } - return evidenceSatisfied(evidence, "build.tasks-complete") ? `${profile}.build.complete` : `${profile}.build.execute`; -} -function resolveVerify(profile, classic, evidence) { - if (classic.verifyResult !== "pass" || !evidenceSatisfied(evidence, "verification.report")) { - return `${profile}.verify.run`; - } - return `${profile}.verify.branch`; -} -function resolveArchive(profile, classic) { - if (classic.verifyResult !== "pass") { - throw new Error("archive requires verify_result=pass"); - } - return classic.archiveConfirmation === "confirmed" ? `${profile}.archive.execute` : `${profile}.archive.confirm`; -} -function resolveClassicStepId(classic, evidence) { - const profile = profileFor(classic); - if (classic.archived && classic.phase !== "archive") { - throw new Error("archived=true requires phase=archive"); - } - if (classic.archived) return "completed"; - if (profile !== "full" && classic.phase === "design") { - throw new Error(`${profile} workflow cannot enter design`); - } - switch (classic.phase) { - case "open": - return `${profile}.open`; - case "design": - return evidenceSatisfied(evidence, "design.handoff") ? "full.design.document" : "full.design.handoff"; - case "build": - return resolveBuild(profile, classic, evidence); - case "verify": - return resolveVerify(profile, classic, evidence); - case "archive": - return resolveArchive(profile, classic); - } -} - -// domains/comet-classic/classic-store.ts -var import_yaml = __toESM(require_dist(), 1); -import { randomUUID as randomUUID2 } from "crypto"; -import { promises as fs4 } from "fs"; -import path4 from "path"; - // domains/comet-classic/classic-state.ts init_state(); var CLASSIC_PROFILES = ["full", "hotfix", "tweak"]; @@ -7808,10 +7735,19 @@ var BUILD_PAUSES = ["plan-ready"]; var SUBAGENT_DISPATCH = ["confirmed"]; var TDD_MODES = ["tdd", "direct"]; var REVIEW_MODES = ["off", "standard", "thorough"]; -var ISOLATIONS = ["current", "branch", "worktree"]; +var ISOLATION_MODES = [ + { value: "branch", allowedInPreset: true }, + { value: "worktree", allowedInPreset: true }, + { value: "current", allowedInPreset: true } +]; +var ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value); +var PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.allowedInPreset).map( + (mode) => mode.value +); var VERIFY_MODES = ["light", "full"]; var VERIFY_RESULTS = ["pending", "pass", "fail"]; var BRANCH_STATUSES = ["pending", "handled"]; +var BRANCH_ACTIONS = ["push", "keep-local", "merged-locally", "pushed-pr"]; var ARCHIVE_CONFIRMATIONS = ["pending", "confirmed"]; var CLASSIC_WIRE_KEYS = [ "workflow", @@ -7824,6 +7760,7 @@ var CLASSIC_WIRE_KEYS = [ "tdd_mode", "review_mode", "isolation", + "bound_branch", "verify_mode", "auto_transition", "base_ref", @@ -7833,6 +7770,7 @@ var CLASSIC_WIRE_KEYS = [ "verify_failures", "verification_report", "branch_status", + "branch_action", "created_at", "verified_at", "archive_confirmation", @@ -7942,6 +7880,7 @@ function classicStateFromDocument(doc) { tddMode: enumValue(doc, "tdd_mode", TDD_MODES), reviewMode: enumValue(doc, "review_mode", REVIEW_MODES), isolation: enumValue(doc, "isolation", ISOLATIONS), + boundBranch: nullableString(doc, "bound_branch"), verifyMode: enumValue(doc, "verify_mode", VERIFY_MODES), autoTransition: booleanValue(doc, "auto_transition"), baseRef: nullableString(doc, "base_ref"), @@ -7951,6 +7890,7 @@ function classicStateFromDocument(doc) { verifyFailures: nonNegativeInteger(doc, "verify_failures"), verificationReport: relativePath(doc, "verification_report"), branchStatus: enumValue(doc, "branch_status", BRANCH_STATUSES), + branchAction: enumValue(doc, "branch_action", BRANCH_ACTIONS), createdAt: nullableString(doc, "created_at"), verifiedAt: nullableString(doc, "verified_at"), archiveConfirmation: enumValue(doc, "archive_confirmation", ARCHIVE_CONFIRMATIONS), @@ -8007,6 +7947,7 @@ function classicStateToDocument(state) { tdd_mode: state.tddMode, review_mode: state.reviewMode, isolation: state.isolation, + bound_branch: state.boundBranch, verify_mode: state.verifyMode, auto_transition: state.autoTransition, base_ref: state.baseRef, @@ -8016,6 +7957,7 @@ function classicStateToDocument(state) { verify_failures: state.verifyFailures, verification_report: state.verificationReport, branch_status: state.branchStatus, + branch_action: state.branchAction, created_at: state.createdAt, verified_at: state.verifiedAt, archive_confirmation: state.archiveConfirmation, @@ -8028,7 +7970,78 @@ function classicStateToDocument(state) { }; } +// domains/comet-classic/classic-resolver.ts +function profileFor(classic) { + return classic.classicProfile ?? classic.workflow; +} +function fullBuildConfigured(classic) { + if (!classic.buildMode || !classic.tddMode || !classic.isolation || !classic.verifyMode) { + return false; + } + if (classic.buildMode === "subagent-driven-development") { + return classic.subagentDispatch === "confirmed"; + } + if (classic.buildMode === "direct") return classic.directOverride === true; + return true; +} +function presetBuildConfigured(classic) { + return Boolean( + classic.buildMode === "direct" && classic.tddMode === "direct" && classic.isolation !== null && PRESET_ALLOWED_ISOLATIONS.includes(classic.isolation) && classic.verifyMode === "light" + ); +} +function resolveBuild(profile, classic, evidence) { + if (classic.verifyResult === "fail") { + return profile === "full" ? "full.build.fix" : `${profile}.build.execute`; + } + if (profile === "full") { + if (!evidenceSatisfied(evidence, "build.plan")) return "full.build.plan"; + if (classic.buildPause === "plan-ready") return "full.build.plan-ready"; + if (!fullBuildConfigured(classic)) return "full.build.configure"; + } else if (!presetBuildConfigured(classic)) { + return `${profile}.build.execute`; + } + return evidenceSatisfied(evidence, "build.tasks-complete") ? `${profile}.build.complete` : `${profile}.build.execute`; +} +function resolveVerify(profile, classic, evidence) { + if (classic.verifyResult !== "pass" || !evidenceSatisfied(evidence, "verification.report")) { + return `${profile}.verify.run`; + } + return `${profile}.verify.branch`; +} +function resolveArchive(profile, classic) { + if (classic.verifyResult !== "pass") { + throw new Error("archive requires verify_result=pass"); + } + return classic.archiveConfirmation === "confirmed" ? `${profile}.archive.execute` : `${profile}.archive.confirm`; +} +function resolveClassicStepId(classic, evidence) { + const profile = profileFor(classic); + if (classic.archived && classic.phase !== "archive") { + throw new Error("archived=true requires phase=archive"); + } + if (classic.archived) return "completed"; + if (profile !== "full" && classic.phase === "design") { + throw new Error(`${profile} workflow cannot enter design`); + } + switch (classic.phase) { + case "open": + return `${profile}.open`; + case "design": + return evidenceSatisfied(evidence, "design.handoff") ? "full.design.document" : "full.design.handoff"; + case "build": + return resolveBuild(profile, classic, evidence); + case "verify": + return resolveVerify(profile, classic, evidence); + case "archive": + return resolveArchive(profile, classic); + } +} + // domains/comet-classic/classic-store.ts +var import_yaml = __toESM(require_dist(), 1); +import { randomUUID as randomUUID2 } from "crypto"; +import { promises as fs4 } from "fs"; +import path4 from "path"; init_state(); function documentRecord(document) { const value = document.toJS(); @@ -9907,11 +9920,11 @@ var classicArchiveCommand = async (args) => { }; // domains/comet-classic/classic-guard.ts -var import_yaml5 = __toESM(require_dist(), 1); +var import_yaml6 = __toESM(require_dist(), 1); import { spawnSync as spawnSync2 } from "child_process"; import { createHash as createHash4 } from "crypto"; -import { existsSync, promises as fs15, readFileSync } from "fs"; -import path16 from "path"; +import { existsSync, promises as fs16, readFileSync } from "fs"; +import path17 from "path"; // domains/comet-classic/classic-command-checks.ts import path13 from "path"; @@ -10090,10 +10103,67 @@ async function inspectClassicChange(changeDir, name) { } } -// domains/comet-classic/classic-validate-command.ts +// domains/comet-classic/classic-branch-binding.ts var import_yaml3 = __toESM(require_dist(), 1); +import { execFileSync } from "child_process"; +import { randomUUID as randomUUID6 } from "crypto"; import { promises as fs12 } from "fs"; import path14 from "path"; +function liveGitBranch(cwd) { + try { + const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + return branch && branch !== "HEAD" ? branch : null; + } catch { + return null; + } +} +function evaluateBranchBinding(input) { + if (input.isolation !== "current") return { status: "not-applicable" }; + if (input.boundBranch === null) { + return input.currentBranch === null ? { status: "unbound-detached" } : { status: "needs-heal", branch: input.currentBranch }; + } + if (input.currentBranch === input.boundBranch) return { status: "ok" }; + return { + status: "drift", + boundBranch: input.boundBranch, + currentBranch: input.currentBranch + }; +} +async function healBoundBranch(changeDir, branch) { + const file = path14.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml3.parseDocument)(await fs12.readFile(file, "utf8"), { uniqueKeys: false }); + document.set("bound_branch", branch); + const temporary = `${file}.${randomUUID6()}.tmp`; + try { + await fs12.writeFile(temporary, document.toString(), "utf8"); + await fs12.rename(temporary, file); + } catch (error) { + await fs12.rm(temporary, { force: true }); + throw error; + } +} +function branchLabel(currentBranch) { + return currentBranch ?? "detached HEAD"; +} +function driftBlockedMessage(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'. +Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.`; +} +function driftStaleReason(change, boundBranch, currentBranch) { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; +} +function unboundDetachedMessage(change) { + return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} + +// domains/comet-classic/classic-validate-command.ts +var import_yaml4 = __toESM(require_dist(), 1); +import { promises as fs13 } from "fs"; +import path15 from "path"; var GREEN2 = "\x1B[32m"; var RED2 = "\x1B[31m"; var YELLOW2 = "\x1B[33m"; @@ -10120,11 +10190,12 @@ var ENUMS = { subagent_dispatch: ["confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["current", "branch", "worktree"], + isolation: ISOLATIONS, verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + branch_action: ["push", "keep-local", "merged-locally", "pushed-pr"], archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], @@ -10143,7 +10214,7 @@ function color(code, message) { } async function exists3(file) { try { - await fs12.access(file); + await fs13.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10164,7 +10235,7 @@ var classicValidateCommand = async (args) => { }; } const { directory, label } = await resolveClassicChangeDirectory(name); - const yamlFile = path14.join(directory, ".comet.yaml"); + const yamlFile = path15.join(directory, ".comet.yaml"); const lines = [`[VALIDATE] ${label}/.comet.yaml`]; let errors = 0; let warnings = 0; @@ -10178,7 +10249,7 @@ var classicValidateCommand = async (args) => { }; let source; try { - source = await fs12.readFile(yamlFile, "utf8"); + source = await fs13.readFile(yamlFile, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail3(".comet.yaml does not exist"); @@ -10187,10 +10258,10 @@ var classicValidateCommand = async (args) => { } throw error; } - const document = (0, import_yaml3.parseDocument)(source); - if (document.errors.length > 0 || !(0, import_yaml3.isMap)(document.contents)) { + const document = (0, import_yaml4.parseDocument)(source); + if (document.errors.length > 0 || !(0, import_yaml4.isMap)(document.contents)) { for (const error of document.errors) fail3(error.message); - if (!(0, import_yaml3.isMap)(document.contents)) fail3("document root must be a mapping"); + if (!(0, import_yaml4.isMap)(document.contents)) fail3("document root must be a mapping"); lines.push("", color(RED2, `${errors} error(s), ${warnings} warning(s) — validation FAILED`)); return { exitCode: 1, stderr: lines.join("\n") }; } @@ -10221,7 +10292,7 @@ var classicValidateCommand = async (args) => { } for (const field2 of ["design_doc", "plan", "handoff_context"]) { const value = text(record[field2]); - if (value && !await exists3(path14.resolve(value))) { + if (value && !await exists3(path15.resolve(value))) { fail3(`${field2}='${value}' does not exist on disk`); } } @@ -10244,16 +10315,16 @@ var classicValidateCommand = async (args) => { }; // domains/comet-classic/classic-project-config.ts -var import_yaml4 = __toESM(require_dist(), 1); +var import_yaml5 = __toESM(require_dist(), 1); import os from "os"; -import { promises as fs14 } from "fs"; -import path15 from "path"; +import { promises as fs15 } from "fs"; +import path16 from "path"; // platform/fs/file-system.ts -import { promises as fs13 } from "fs"; +import { promises as fs14 } from "fs"; async function fileExists3(filePath) { try { - await fs13.access(filePath); + await fs14.access(filePath); return true; } catch (error) { if (isNotFoundError(error)) return false; @@ -10262,7 +10333,7 @@ async function fileExists3(filePath) { } async function readDir(dirPath) { try { - return await fs13.readdir(dirPath); + return await fs14.readdir(dirPath); } catch (error) { const code = error?.code; if (code === "ENOENT" || code === "ENOTDIR") { @@ -10280,9 +10351,9 @@ function configCandidates(options = {}) { const cwd = options.cwd ?? process.cwd(); const homeDir = options.homeDir ?? os.homedir(); const candidates = [ - { file: path15.resolve(cwd, ".comet", "config.yaml"), source: ".comet/config.yaml" }, + { file: path16.resolve(cwd, ".comet", "config.yaml"), source: ".comet/config.yaml" }, { - file: path15.resolve(homeDir, ".comet", "config.yaml"), + file: path16.resolve(homeDir, ".comet", "config.yaml"), source: "~/.comet/config.yaml" } ]; @@ -10293,7 +10364,7 @@ function configCandidates(options = {}) { async function readClassicConfigValue(field2, options = {}) { for (const candidate of configCandidates(options)) { if (!await fileExists3(candidate.file)) continue; - const document = (0, import_yaml4.parseDocument)(await fs14.readFile(candidate.file, "utf8"), { + const document = (0, import_yaml5.parseDocument)(await fs15.readFile(candidate.file, "utf8"), { uniqueKeys: false }); const value = document.get(field2); @@ -10367,7 +10438,7 @@ var GuardOutput = class { }; async function exists4(file) { try { - await fs15.access(file); + await fs16.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10376,7 +10447,7 @@ async function exists4(file) { } async function nonempty(file) { try { - return (await fs15.stat(file)).size > 0; + return (await fs16.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -10390,8 +10461,8 @@ async function resolveChangeDir(name) { return (await resolveClassicChangeDirectory(name)).label; } async function readField(changeDir, field2) { - const file = path16.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml5.parseDocument)(await fs15.readFile(file, "utf8"), { uniqueKeys: false }); + const file = path17.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml6.parseDocument)(await fs16.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new GuardFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -10432,7 +10503,7 @@ function countEnglishWords(source) { } async function documentLanguageMatchesConfigured(changeDir, file) { const language = await configuredLanguage(changeDir); - const source = stripFencedCodeBlocks(await fs15.readFile(file, "utf8")); + const source = stripFencedCodeBlocks(await fs16.readFile(file, "utf8")); const cjk = countCjkChars(source); const englishWords = countEnglishWords(source); if (language === "zh-CN" && cjk < 20 && englishWords >= 20) { @@ -10456,7 +10527,7 @@ async function handoffSourceFiles(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists4(specs)) { - for (const entry2 of (await fs15.readdir(specs)).sort()) { + for (const entry2 of (await fs16.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists4(spec)) files.push(spec); } @@ -10476,7 +10547,7 @@ async function preflight(changeDir, name) { if (!await exists4(changeDir)) { throw new GuardFailure(red2(`FATAL: change directory not found: ${changeDir}`)); } - if (!await exists4(path16.join(changeDir, ".comet.yaml"))) { + if (!await exists4(path17.join(changeDir, ".comet.yaml"))) { throw new GuardFailure(red2(`FATAL: .comet.yaml not found in ${changeDir}`)); } const result5 = await classicValidateCommand([name], { json: false }); @@ -10546,9 +10617,9 @@ var INFERRED_COMMAND_SOURCES = [ "Cargo.toml" ]; async function removedProjectCommandField(field2) { - const config = path16.join(".comet", "config.yaml"); + const config = path17.join(".comet", "config.yaml"); if (!await exists4(config)) return false; - const document = (0, import_yaml5.parseDocument)(await fs15.readFile(config, "utf8")); + const document = (0, import_yaml6.parseDocument)(await fs16.readFile(config, "utf8")); if (document.errors.length > 0) { throw new Error( `.comet/config.yaml is invalid YAML (${document.errors[0].message}); cannot check for removed "${field2}" field. Fix the config and retry.` @@ -10629,14 +10700,14 @@ ${recoveryCommand(change, scope, recorded.command)}` return { status: 0, output: evidenceDetail(recorded) }; } async function tasksAllDone(changeDir) { - const tasks = path16.join(changeDir, "tasks.md"); + const tasks = path17.join(changeDir, "tasks.md"); if (!await exists4(tasks)) { return fail( `tasks.md is missing at ${tasks} Next: restore or create tasks.md for this change before leaving build.` ); } - const source = await fs15.readFile(tasks, "utf8"); + const source = await fs16.readFile(tasks, "utf8"); if (!/- \[x\]/u.test(source)) { return fail( "tasks.md has no completed tasks.\nNext: complete implementation tasks and mark them with '- [x]'." @@ -10653,9 +10724,9 @@ Next: complete or explicitly remove unfinished tasks, then mark tasks.md with '- return pass(); } async function tasksHasAny(changeDir) { - const tasks = path16.join(changeDir, "tasks.md"); + const tasks = path17.join(changeDir, "tasks.md"); if (!await exists4(tasks)) return false; - return /- \[/u.test(await fs15.readFile(tasks, "utf8")); + return /- \[/u.test(await fs16.readFile(tasks, "utf8")); } async function planTasksAllDone(changeDir) { const plan = await readField(changeDir, "plan"); @@ -10666,7 +10737,7 @@ async function planTasksAllDone(changeDir) { Next: restore the Superpowers plan file or update .comet.yaml plan before leaving build.` ); } - const source = await fs15.readFile(plan, "utf8"); + const source = await fs16.readFile(plan, "utf8"); const unfinished = source.split(/\r?\n/u).map((line, index) => ({ line, number: index + 1 })).filter((entry2) => /^\s*- \[ \]/u.test(entry2.line)); if (unfinished.length > 0) { return fail( @@ -10679,16 +10750,42 @@ Next: check off corresponding completed plan tasks, then commit the plan update. } async function isolationSelected(changeDir, change) { const isolation = await readField(changeDir, "isolation"); + if (ISOLATIONS.includes(isolation)) return pass(); + return fail( + `isolation must be one of ${ISOLATIONS.join(", ")}, got '${isolation || "null"}' +Next: ask the user to choose branch, worktree, or current, create the chosen isolation when needed, then run: + node "$COMET_STATE" set ${change} isolation <${ISOLATIONS.join("|")}>` + ); +} +async function isolationAllowedForWorkflow(changeDir, change) { const workflow = await readField(changeDir, "workflow"); - if (isolation === "branch" || isolation === "worktree") return pass(); - if (isolation === "current" && (workflow === "hotfix" || workflow === "tweak")) return pass(); - const allowedValues = workflow === "full" ? "" : ""; + const isolation = await readField(changeDir, "isolation"); + if (workflow !== "hotfix" && workflow !== "tweak") return pass(); + if (PRESET_ALLOWED_ISOLATIONS.includes(isolation)) return pass(); return fail( - `isolation must be ${workflow === "full" ? "branch or worktree" : "current, branch, or worktree"}, got '${isolation || "null"}' -Next: choose a valid workspace mode, prepare it when needed, then run: - comet state set ${change} isolation ${allowedValues}` + `isolation=${isolation || "null"} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(" or ")}) +Next: ask the user to choose ${PRESET_ALLOWED_ISOLATIONS.join(" or ")}, then run: + node "$COMET_STATE" set ${change} isolation <${PRESET_ALLOWED_ISOLATIONS.join("|")}>` ); } +async function boundBranchMatches(changeDir, change) { + const isolation = await readField(changeDir, "isolation"); + const boundBranch = await readField(changeDir, "bound_branch"); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== "null" ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()) + }); + if (verdict.status === "drift") { + return fail(driftBlockedMessage(change, verdict.boundBranch, verdict.currentBranch)); + } + if (verdict.status === "unbound-detached") return fail(unboundDetachedMessage(change)); + if (verdict.status === "needs-heal") { + await healBoundBranch(changeDir, verdict.branch); + return pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } + return pass(); +} async function buildModeSelected(changeDir, change) { const buildMode = await readField(changeDir, "build_mode"); if (["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) @@ -10758,7 +10855,7 @@ async function archivedIsTrue(changeDir) { return await readField(changeDir, "archived") === "true"; } async function designDocFrontmatterHas(designDoc, field2, expected) { - const source = (await fs15.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); + const source = (await fs16.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); let inFrontmatter = false; for (const line of source.split(/\r?\n/u)) { if (!inFrontmatter) { @@ -10823,7 +10920,7 @@ async function designHandoffMarkdownTraceable(changeDir) { const markdown = `${context.replace(/\.json$/u, "")}.md`; if (!await nonempty(markdown)) return fail(`design handoff markdown is missing or empty: ${markdown}`); - const source = await fs15.readFile(markdown, "utf8"); + const source = await fs16.readFile(markdown, "utf8"); const problems = []; if (!/^Generated-by: comet-handoff\.sh$/mu.test(source)) { problems.push("handoff markdown is missing Generated-by marker"); @@ -10850,7 +10947,7 @@ async function betaSpecJsonStructurallyValid(changeDir) { const context = await readField(changeDir, "handoff_context"); if (!context || context === "null") return fail("handoff_context is missing from .comet.yaml"); if (!await nonempty(context)) return fail(`spec-context.json is missing or empty: ${context}`); - const source = await fs15.readFile(context, "utf8"); + const source = await fs16.readFile(context, "utf8"); const problems = []; let parsed; try { @@ -10887,19 +10984,19 @@ async function guardOpenChecks(output, changeDir) { const checks = [ check( "proposal.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check( "tasks.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "tasks.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "tasks.md")) ? pass() : fail("") ), check( "tasks.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "tasks.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "tasks.md")) ), check( "tasks.md has at least one task", @@ -10912,11 +11009,11 @@ async function guardOpenChecks(output, changeDir) { 0, check( "design.md exists and non-empty", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check( "design.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "design.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "design.md")) ) ); } @@ -10928,27 +11025,27 @@ async function guardDesignChecks(output, changeDir, change) { const builders = [ check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check( "design.md exists", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check( "design.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "design.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "design.md")) ), check( "tasks.md exists", - async () => await nonempty(path16.join(changeDir, "tasks.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "tasks.md")) ? pass() : fail("") ), check( "tasks.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "tasks.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "tasks.md")) ), check("design handoff context exists", () => designHandoffContextValid(changeDir, change)), check("design handoff markdown is traceable", () => designHandoffMarkdownTraceable(changeDir)) @@ -10999,7 +11096,9 @@ async function guardDesignChecks(output, changeDir, change) { } async function guardBuildChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("isolation selected", () => isolationSelected(changeDir, change)), + check("isolation allowed for workflow", () => isolationAllowedForWorkflow(changeDir, change)), check("build_mode selected", () => buildModeSelected(changeDir, change)), check("build_mode allowed for workflow", () => buildModeAllowedForWorkflow(changeDir)), check("subagent dispatch confirmed", () => subagentDispatchConfirmed(changeDir, change)), @@ -11009,11 +11108,11 @@ async function guardBuildChecks(output, changeDir, change, run) { check("Superpowers plan all tasks checked", () => planTasksAllDone(changeDir)), check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "proposal.md matches configured language", - () => documentLanguageMatchesConfigured(changeDir, path16.join(changeDir, "proposal.md")) + () => documentLanguageMatchesConfigured(changeDir, path17.join(changeDir, "proposal.md")) ), check("Superpowers plan matches configured language", async () => { const plan = await readField(changeDir, "plan"); @@ -11030,6 +11129,7 @@ async function guardBuildChecks(output, changeDir, change, run) { } async function guardVerifyChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("tasks.md all tasks checked", () => tasksAllDone(changeDir)), // Verification command runs after tasks check — no point running tests // if tasks.md is incomplete. @@ -11048,16 +11148,17 @@ async function guardVerifyChecks(output, changeDir, change, run) { }) ]); } -async function guardArchiveChecks(output, changeDir) { +async function guardArchiveChecks(output, changeDir, change) { return runChecks(output, [ + check("bound branch matches isolation=current", () => boundBranchMatches(changeDir, change)), check("archived is true", async () => await archivedIsTrue(changeDir) ? pass() : fail("")), check( "proposal.md exists", - async () => await nonempty(path16.join(changeDir, "proposal.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "proposal.md")) ? pass() : fail("") ), check( "design.md exists", - async () => await nonempty(path16.join(changeDir, "design.md")) ? pass() : fail("") + async () => await nonempty(path17.join(changeDir, "design.md")) ? pass() : fail("") ), check("tasks.md all tasks checked", () => tasksAllDone(changeDir)), check( @@ -11122,7 +11223,7 @@ Valid phases: open, design, build, verify, archive` blocked2 = await guardBuildChecks(output, changeDir, change, runContext.run); else if (phase === "verify") blocked2 = await guardVerifyChecks(output, changeDir, change, runContext.run); - else blocked2 = await guardArchiveChecks(output, changeDir); + else blocked2 = await guardArchiveChecks(output, changeDir, change); if (blocked2) { output.stderr.push(""); output.stderr.push(red2("BLOCKED — fix failing checks before proceeding to next phase")); @@ -11144,10 +11245,10 @@ Valid phases: open, design, build, verify, archive` }; // domains/comet-classic/classic-handoff.ts -var import_yaml6 = __toESM(require_dist(), 1); +var import_yaml7 = __toESM(require_dist(), 1); import { createHash as createHash5 } from "crypto"; -import { promises as fs16, readFileSync as readFileSync2 } from "fs"; -import path17 from "path"; +import { promises as fs17, readFileSync as readFileSync2 } from "fs"; +import path18 from "path"; var GREEN4 = "\x1B[32m"; var RED4 = "\x1B[31m"; var YELLOW4 = "\x1B[33m"; @@ -11181,7 +11282,7 @@ var HandoffOutput = class { }; async function exists5(file) { try { - await fs16.access(file); + await fs17.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -11190,7 +11291,7 @@ async function exists5(file) { } async function nonempty2(file) { try { - return (await fs16.stat(file)).size > 0; + return (await fs17.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -11219,7 +11320,7 @@ async function handoffSourceFiles2(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists5(specs)) { - for (const entry2 of (await fs16.readdir(specs)).sort()) { + for (const entry2 of (await fs17.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists5(spec)) files.push(spec); } @@ -11267,7 +11368,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output ]; for (const file of await handoffSourceFiles2(changeDir)) { if (!await exists5(file)) continue; - const content = await fs16.readFile(file, "utf8"); + const content = await fs17.readFile(file, "utf8"); const total = lineCount(content); lines.push( `## ${file}`, @@ -11292,7 +11393,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output } lines.push(""); } - await fs16.writeFile(output, lines.join("\n")); + await fs17.writeFile(output, lines.join("\n")); } async function writeJsonContext(changeDir, change, mode, contextHash, output) { const entries = []; @@ -11315,7 +11416,7 @@ async function writeJsonContext(changeDir, change, mode, contextHash, output) { "}", "" ].join("\n"); - await fs16.writeFile(output, document); + await fs17.writeFile(output, document); } async function writeSpecProjectionForFile(file, content) { return [ @@ -11355,11 +11456,11 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) const specs = `${changeDir}/specs`; let projected = false; if (await exists5(specs)) { - for (const entry2 of (await fs16.readdir(specs)).sort()) { + for (const entry2 of (await fs17.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (!await exists5(spec)) continue; projected = true; - lines.push(...await writeSpecProjectionForFile(spec, await fs16.readFile(spec, "utf8"))); + lines.push(...await writeSpecProjectionForFile(spec, await fs17.readFile(spec, "utf8"))); } } if (!projected) { @@ -11368,7 +11469,7 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) lines.push( "Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only." ); - await fs16.writeFile(output, lines.join("\n")); + await fs17.writeFile(output, lines.join("\n")); } async function writeSpecJsonContext(changeDir, change, contextHash, output) { const entries = []; @@ -11377,7 +11478,7 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { const role = /\/specs\/[^/]+\/spec\.md$/u.test(file) ? "spec" : "supporting"; entries.push({ path: file, sha256: hashFile2(file), role }); } - await fs16.writeFile( + await fs17.writeFile( output, `${JSON.stringify( { @@ -11396,8 +11497,8 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { ); } async function readField2(changeDir, field2) { - const file = path17.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml6.parseDocument)(await fs16.readFile(file, "utf8"), { uniqueKeys: false }); + const file = path18.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml7.parseDocument)(await fs17.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new HandoffFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -11431,7 +11532,7 @@ async function completedHandoffIsCurrent(changeDir, run, contextHash, contextJso readCheckpoint(changeDir, run.checkpointRef) ]); if (!await exists5(contextJson) || !await exists5(contextMd)) return false; - if (context !== await fs16.readFile(contextMd, "utf8")) return false; + if (context !== await fs17.readFile(contextMd, "utf8")) return false; if (artifacts.handoff_context !== contextJson || artifacts.handoff_markdown !== contextMd) { return false; } @@ -11554,7 +11655,7 @@ var classicHandoffCommand = async (args) => { run: pendingRun, unknownKeys: (await readClassicState(changeDir)).unknownKeys }); - await fs16.mkdir(handoffDir, { recursive: true }); + await fs17.mkdir(handoffDir, { recursive: true }); if (handoffMode === "beta") { await writeSpecMarkdownContext(changeDir, change, contextHash, contextMd); await writeSpecJsonContext(changeDir, change, contextHash, contextJson); @@ -11562,7 +11663,7 @@ var classicHandoffCommand = async (args) => { await writeMarkdownContext(changeDir, change, handoffMode, contextHash, contextMd); await writeJsonContext(changeDir, change, handoffMode, contextHash, contextJson); } - const context = await fs16.readFile(contextMd, "utf8"); + const context = await fs17.readFile(contextMd, "utf8"); await writeContext(changeDir, pendingRun.contextRef, context); const artifacts = { ...await readArtifacts(changeDir, pendingRun.artifactsRef), @@ -11619,37 +11720,24 @@ var classicHandoffCommand = async (args) => { }; // domains/comet-classic/classic-hook-guard.ts -import { existsSync as existsSync2, promises as fs18, readFileSync as readFileSync3 } from "fs"; -import path19 from "path"; +import { existsSync as existsSync2, promises as fs19, readFileSync as readFileSync3 } from "fs"; +import path20 from "path"; // domains/comet-classic/classic-current-change.ts -import { execFileSync } from "child_process"; -import { randomUUID as randomUUID6 } from "crypto"; -import { promises as fs17 } from "fs"; -import path18 from "path"; +import { randomUUID as randomUUID7 } from "crypto"; +import { promises as fs18 } from "fs"; +import path19 from "path"; function currentChangeFile(projectRoot2) { - return path18.join(projectRoot2, ".comet", "current-change.json"); -} -function currentBranch(projectRoot2) { - try { - const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: projectRoot2, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }).trim(); - return branch && branch !== "HEAD" ? branch : null; - } catch { - return null; - } + return path19.join(projectRoot2, ".comet", "current-change.json"); } function changeDirectory(projectRoot2, changeName) { - return path18.join(projectRoot2, "openspec", "changes", changeName); + return path19.join(projectRoot2, "openspec", "changes", changeName); } async function validateActiveChange(projectRoot2, changeName) { assertOpenSpecChangeName(changeName); const changeDir = changeDirectory(projectRoot2, changeName); try { - await fs17.access(path18.join(changeDir, ".comet.yaml")); + await fs18.access(path19.join(changeDir, ".comet.yaml")); } catch (error) { if (error.code === "ENOENT") { throw new Error( @@ -11668,6 +11756,7 @@ async function validateActiveChange(projectRoot2, changeName) { if (projection.classic.archived) { throw new Error(`Cannot select current change '${changeName}': change is archived`); } + return projection.classic; } function parseSelection(source) { let value; @@ -11693,27 +11782,23 @@ function parseSelection(source) { if (record.branch !== null && typeof record.branch !== "string") { throw new Error("current change selection branch must be a string or null"); } - return { - version: 1, - change: record.change, - branch: record.branch - }; + return { version: 1, change: record.change, branch: record.branch }; } async function selectCurrentChange(projectRoot2, changeName) { await validateActiveChange(projectRoot2, changeName); const selection = { version: 1, change: changeName, - branch: currentBranch(projectRoot2) + branch: liveGitBranch(projectRoot2) }; const file = currentChangeFile(projectRoot2); - const temporary = `${file}.${randomUUID6()}.tmp`; - await fs17.mkdir(path18.dirname(file), { recursive: true }); + const temporary = `${file}.${randomUUID7()}.tmp`; + await fs18.mkdir(path19.dirname(file), { recursive: true }); try { - await fs17.writeFile(temporary, JSON.stringify(selection, null, 2) + "\n", "utf8"); - await fs17.rename(temporary, file); + await fs18.writeFile(temporary, JSON.stringify(selection, null, 2) + "\n", "utf8"); + await fs18.rename(temporary, file); } catch (error) { - await fs17.rm(temporary, { force: true }); + await fs18.rm(temporary, { force: true }); throw error; } return selection; @@ -11721,7 +11806,7 @@ async function selectCurrentChange(projectRoot2, changeName) { async function resolveCurrentChange(projectRoot2) { let source; try { - source = await fs17.readFile(currentChangeFile(projectRoot2), "utf8"); + source = await fs18.readFile(currentChangeFile(projectRoot2), "utf8"); } catch (error) { if (error.code === "ENOENT") return { status: "missing" }; return { @@ -11730,26 +11815,48 @@ async function resolveCurrentChange(projectRoot2) { }; } let selection; + let classic; try { selection = parseSelection(source); - await validateActiveChange(projectRoot2, selection.change); + classic = await validateActiveChange(projectRoot2, selection.change); } catch (error) { return { status: "stale", reason: error instanceof Error ? error.message : String(error) }; } - const branch = currentBranch(projectRoot2); - if (selection.branch !== null && branch !== selection.branch) { + const branch = liveGitBranch(projectRoot2); + if (classic.isolation === "current") { + const verdict = evaluateBranchBinding({ + isolation: classic.isolation, + boundBranch: classic.boundBranch, + currentBranch: branch + }); + if (verdict.status === "drift") { + return { + status: "stale", + reason: driftStaleReason(selection.change, verdict.boundBranch, verdict.currentBranch) + }; + } + if (verdict.status === "unbound-detached") { + return { status: "stale", reason: unboundDetachedMessage(selection.change) }; + } + if (verdict.status === "needs-heal") { + await healBoundBranch( + path19.join(projectRoot2, "openspec", "changes", selection.change), + verdict.branch + ); + } + } else if (selection.branch !== null && branch !== selection.branch) { return { status: "stale", reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${branch ?? "detached HEAD"}'` }; } - return { status: "selected", selection }; + return { status: "selected", selection, classic, branch }; } async function clearCurrentChange(projectRoot2) { - await fs17.rm(currentChangeFile(projectRoot2), { force: true }); + await fs18.rm(currentChangeFile(projectRoot2), { force: true }); } // domains/comet-classic/classic-hook-guard.ts @@ -11781,52 +11888,52 @@ function comparisonKey(value) { function parseProjectRoot(args) { const index = args.indexOf("--project-root"); const value = index >= 0 ? args[index + 1] : void 0; - return path19.resolve(value && !value.startsWith("--") ? value : process.cwd()); + return path20.resolve(value && !value.startsWith("--") ? value : process.cwd()); } function relativeToProjectRoot(target, projectRoot2) { - const relative = normalized(path19.relative(projectRoot2, target)); + const relative = normalized(path20.relative(projectRoot2, target)); if (relative === "") return ""; - if (relative.startsWith("../") || relative === ".." || path19.isAbsolute(relative)) return null; + if (relative.startsWith("../") || relative === ".." || path20.isAbsolute(relative)) return null; return relative; } async function physicalPathForPossiblyMissingTarget(target) { - const resolved = path19.resolve(target); - const root = path19.parse(resolved).root; + const resolved = path20.resolve(target); + const root = path20.parse(resolved).root; const missingSegments = []; let cursor = resolved; while (cursor && cursor !== root) { try { - const physicalBase = await fs18.realpath(cursor); - return path19.join(physicalBase, ...missingSegments.reverse()); + const physicalBase = await fs19.realpath(cursor); + return path20.join(physicalBase, ...missingSegments.reverse()); } catch (error) { const code = error.code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; - missingSegments.push(path19.basename(cursor)); - cursor = path19.dirname(cursor); + missingSegments.push(path20.basename(cursor)); + cursor = path20.dirname(cursor); } } try { - const physicalRoot = await fs18.realpath(root); - return path19.join(physicalRoot, ...missingSegments.reverse()); + const physicalRoot = await fs19.realpath(root); + return path20.join(physicalRoot, ...missingSegments.reverse()); } catch { return null; } } async function projectRelative(target, projectRoot2) { - const rawCandidate = path19.isAbsolute(target) ? target : path19.resolve(process.cwd(), target); + const rawCandidate = path20.isAbsolute(target) ? target : path20.resolve(process.cwd(), target); let candidate = normalized(rawCandidate); const rootRelative = relativeToProjectRoot(rawCandidate, projectRoot2); if (rootRelative !== null) return rootRelative; try { const physicalCandidate = await physicalPathForPossiblyMissingTarget(rawCandidate); - const physicalRoot = await fs18.realpath(projectRoot2); + const physicalRoot = await fs19.realpath(projectRoot2); if (physicalCandidate) { const physicalRootRelative = relativeToProjectRoot(physicalCandidate, physicalRoot); if (physicalRootRelative !== null) return physicalRootRelative; candidate = normalized(physicalCandidate); } } catch { - if (!path19.isAbsolute(target)) return normalized(target).replace(/^\.\//u, ""); + if (!path20.isAbsolute(target)) return normalized(target).replace(/^\.\//u, ""); } return candidate.replace(/^\.\//u, ""); } @@ -11856,15 +11963,15 @@ async function loadGoverningChange(changeDir) { } } async function activeChanges(projectRoot2) { - const changesDir = path19.join(projectRoot2, "openspec", "changes"); + const changesDir = path20.join(projectRoot2, "openspec", "changes"); const governingChanges = []; if (!existsSync2(changesDir)) return governingChanges; - for (const entry2 of (await fs18.readdir(changesDir, { withFileTypes: true })).sort( + for (const entry2 of (await fs19.readdir(changesDir, { withFileTypes: true })).sort( (left, right) => left.name.localeCompare(right.name) )) { if (!entry2.isDirectory() || entry2.name === "archive") continue; - const changeDir = path19.join(changesDir, entry2.name); - if (!existsSync2(path19.join(changeDir, ".comet.yaml"))) continue; + const changeDir = path20.join(changesDir, entry2.name); + if (!existsSync2(path20.join(changeDir, ".comet.yaml"))) continue; const governing = await loadGoverningChange(changeDir); if (!governing || governing.archived) continue; governingChanges.push(governing); @@ -11912,7 +12019,7 @@ function allowsSuperpowersArtifacts(governing) { return governing.phase === "design" || governing.phase === "build" || governing.phase === "verify"; } function governingChangeName(governing) { - return governing.changeDir ? path19.basename(governing.changeDir) : null; + return governing.changeDir ? path20.basename(governing.changeDir) : null; } var SUPERPOWERS_ARTIFACT_SUFFIXES = /* @__PURE__ */ new Set([ "design", @@ -11992,8 +12099,8 @@ async function governingChange(relativePath2, projectRoot2) { const rest = relativePath2.slice(prefix.length); const [name] = rest.split("/"); if (name && name !== "archive") { - const changeDir = path19.join(projectRoot2, "openspec", "changes", name); - const stateFile2 = path19.join(changeDir, ".comet.yaml"); + const changeDir = path20.join(projectRoot2, "openspec", "changes", name); + const stateFile2 = path20.join(changeDir, ".comet.yaml"); if (existsSync2(stateFile2)) { const governing = await loadGoverningChange(changeDir); if (governing) return governing; @@ -12601,8 +12708,8 @@ var classicIntentCommand = async (args, _options) => { }; // domains/comet-classic/classic-resume-probe.ts -import path20 from "path"; -import { promises as fs19 } from "fs"; +import path21 from "path"; +import { promises as fs20 } from "fs"; import { spawn } from "child_process"; var COMET_RESUME_PROBE_SCHEMA_VERSION = "comet.resume_probe.v1"; function isRecord2(value) { @@ -12645,13 +12752,13 @@ function result3(action, change, confidence, reason, evidence = []) { } async function readIfExists(filePath) { if (!await fileExists3(filePath)) return ""; - return fs19.readFile(filePath, "utf8"); + return fs20.readFile(filePath, "utf8"); } async function changeSearchText(changeDir, classic) { const files = ["proposal.md", "design.md", "tasks.md"]; const parts = [classic.name, classic.workflow, classic.phase]; for (const file of files) { - parts.push(await readIfExists(path20.join(changeDir, file))); + parts.push(await readIfExists(path21.join(changeDir, file))); } return parts.join("\n").toLowerCase(); } @@ -12715,19 +12822,19 @@ function diagnosticFromProjection(changeDir, name, projection) { }; } async function hasOpenSpecChangeFiles(changeDir) { - return await fileExists3(path20.join(changeDir, "proposal.md")) || await fileExists3(path20.join(changeDir, "design.md")) || await fileExists3(path20.join(changeDir, "tasks.md")); + return await fileExists3(path21.join(changeDir, "proposal.md")) || await fileExists3(path21.join(changeDir, "design.md")) || await fileExists3(path21.join(changeDir, "tasks.md")); } async function discoverActiveChanges(projectRoot2) { - const changesDir = path20.join(projectRoot2, "openspec", "changes"); + const changesDir = path21.join(projectRoot2, "openspec", "changes"); if (!await fileExists3(changesDir)) return []; const entries = await readDir(changesDir); const changes = []; for (const entry2 of entries) { if (entry2 === "archive") continue; - const changeDir = path20.join(changesDir, entry2); - const stat = await fs19.stat(changeDir).catch(() => null); + const changeDir = path21.join(changesDir, entry2); + const stat = await fs20.stat(changeDir).catch(() => null); if (!stat?.isDirectory()) continue; - const hasCometState = await fileExists3(path20.join(changeDir, ".comet.yaml")); + const hasCometState = await fileExists3(path21.join(changeDir, ".comet.yaml")); if (!hasCometState) { if (!await hasOpenSpecChangeFiles(changeDir)) continue; const missingStateChange = { @@ -13027,11 +13134,11 @@ var classicResumeProbeCommand = async (args) => { }; // domains/comet-classic/classic-state-command.ts -var import_yaml7 = __toESM(require_dist(), 1); +var import_yaml8 = __toESM(require_dist(), 1); import { spawnSync as spawnSync3 } from "child_process"; -import { randomUUID as randomUUID7 } from "crypto"; -import { existsSync as existsSync3, promises as fs20 } from "fs"; -import path21 from "path"; +import { randomUUID as randomUUID8 } from "crypto"; +import { existsSync as existsSync3, promises as fs21 } from "fs"; +import path22 from "path"; init_state(); var GREEN5 = "\x1B[32m"; var RED5 = "\x1B[31m"; @@ -13046,7 +13153,8 @@ var MACHINE_OWNED_FIELDS = /* @__PURE__ */ new Set([ "archive_confirmation", "verify_failures", "classic_profile", - "classic_migration" + "classic_migration", + "bound_branch" ]); var SETTABLE_FIELDS = new Set( CLASSIC_WIRE_KEYS.filter((field2) => !MACHINE_OWNED_FIELDS.has(field2)) @@ -13060,11 +13168,12 @@ var FIELD_ENUMS = { subagent_dispatch: ["null", "confirmed"], tdd_mode: ["tdd", "direct"], review_mode: ["off", "standard", "thorough"], - isolation: ["current", "branch", "worktree"], + isolation: ISOLATIONS, verify_mode: ["light", "full"], auto_transition: ["true", "false"], verify_result: ["pending", "pass", "fail"], branch_status: ["pending", "handled"], + branch_action: ["push", "keep-local", "merged-locally", "pushed-pr"], archive_confirmation: ["pending", "confirmed"], archived: ["true", "false"], direct_override: ["true", "false"], @@ -13075,6 +13184,7 @@ var PATH_FIELDS = /* @__PURE__ */ new Set(["design_doc", "plan", "verification_r var CLASSIC_FIELD_WIRE_NAMES2 = { archived: "archived", branchStatus: "branch_status", + branchAction: "branch_action", classicProfile: "classic_profile", designDoc: "design_doc", language: "language", @@ -13144,7 +13254,7 @@ function validateRelativePath(value, field2) { } async function exists6(file) { try { - await fs20.access(file); + await fs21.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -13153,7 +13263,7 @@ async function exists6(file) { } async function nonempty3(file) { try { - return (await fs20.stat(file)).size > 0; + return (await fs21.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -13165,27 +13275,27 @@ async function changeDirectory2(name) { async function readDocument2(file) { let source; try { - source = await fs20.readFile(file, "utf8"); + source = await fs21.readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail2( - `ERROR: .comet.yaml not found at ${path21.relative(process.cwd(), file).replaceAll("\\", "/")}` + `ERROR: .comet.yaml not found at ${path22.relative(process.cwd(), file).replaceAll("\\", "/")}` ); } throw error; } - const document = (0, import_yaml7.parseDocument)(source, { uniqueKeys: false }); + const document = (0, import_yaml8.parseDocument)(source, { uniqueKeys: false }); if (document.errors.length > 0) fail2(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); return document; } async function atomicWrite2(file, content) { - await fs20.mkdir(path21.dirname(file), { recursive: true }); - const temporary = `${file}.${randomUUID7()}.tmp`; + await fs21.mkdir(path22.dirname(file), { recursive: true }); + const temporary = `${file}.${randomUUID8()}.tmp`; try { - await fs20.writeFile(temporary, content, "utf8"); - await fs20.rename(temporary, file); + await fs21.writeFile(temporary, content, "utf8"); + await fs21.rename(temporary, file); } catch (error) { - await fs20.rm(temporary, { force: true }); + await fs21.rm(temporary, { force: true }); throw error; } } @@ -13249,12 +13359,8 @@ function sparseClassicState(record) { ["off", "standard", "thorough"], null ), - isolation: enumRecordValue( - record, - "isolation", - ["current", "branch", "worktree"], - null - ), + isolation: enumRecordValue(record, "isolation", ISOLATIONS, null), + boundBranch: nullableRecordString(record, "bound_branch"), verifyMode: enumRecordValue(record, "verify_mode", ["light", "full"], null), autoTransition: nullableRecordBoolean(record, "auto_transition"), baseRef: nullableRecordString(record, "base_ref"), @@ -13269,6 +13375,12 @@ function sparseClassicState(record) { verifyFailures: nonNegativeRecordInteger(record, "verify_failures"), verificationReport: nullableRecordString(record, "verification_report"), branchStatus: enumRecordValue(record, "branch_status", ["pending", "handled"], null), + branchAction: enumRecordValue( + record, + "branch_action", + ["push", "keep-local", "merged-locally", "pushed-pr"], + null + ), createdAt: nullableRecordString(record, "created_at"), verifiedAt: nullableRecordString(record, "verified_at"), archiveConfirmation: enumRecordValue( @@ -13327,7 +13439,7 @@ async function stateFile(name) { const change = await changeDirectory2(name); return { ...change, - file: path21.join(change.directory, ".comet.yaml") + file: path22.join(change.directory, ".comet.yaml") }; } async function readField3(name, field2) { @@ -13345,7 +13457,7 @@ async function readField3(name, field2) { return scalar(value); } function parsedValue(field2, value) { - const document = (0, import_yaml7.parseDocument)(`${field2}: ${value} + const document = (0, import_yaml8.parseDocument)(`${field2}: ${value} `); if (document.errors.length > 0) fail2(`ERROR: Invalid value: '${value}'`); return document.get(field2); @@ -13381,6 +13493,24 @@ async function setField2(output, name, field2, value, options = {}) { const { file, directory } = await stateFile(name); const document = await readDocument2(file); document.set(field2, parsedValue(field2, value)); + if (field2 === "isolation") { + if (value === "current") { + const record = document.toJS(); + const existing = record.bound_branch; + const alreadyBound = typeof existing === "string" && existing !== ""; + if (!alreadyBound) { + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail2( + "ERROR: cannot bind isolation=current while HEAD is detached; checkout a branch first" + ); + } + document.set("bound_branch", branch); + } + } else { + document.set("bound_branch", null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS(), run); if (projection.run) { @@ -13430,10 +13560,10 @@ async function init(output, name, workflow) { validateEnum(workflow, PROFILES); const { file, label, directory } = await stateFile(name); if (await exists6(file)) fail2(`ERROR: .comet.yaml already exists at ${label}/.comet.yaml`); - await fs20.mkdir(directory, { recursive: true }); + await fs21.mkdir(directory, { recursive: true }); const preset = workflow !== "full"; const reviewMode = preset ? "off" : await reviewModeDefault(); - const document = new import_yaml7.Document({ + const document = new import_yaml8.Document({ workflow, language: await projectLanguageDefault(), phase: "open", @@ -13443,7 +13573,7 @@ async function init(output, name, workflow) { subagent_dispatch: null, tdd_mode: preset ? "direct" : null, review_mode: reviewMode, - isolation: preset ? "current" : null, + isolation: null, verify_mode: preset ? "light" : null, auto_transition: await autoTransition() === "true", base_ref: gitOutput(["rev-parse", "--verify", "HEAD"]), @@ -13475,10 +13605,14 @@ async function requireBuildDecisions(name) { const subagentDispatch = await readField3(name, "subagent_dispatch"); const tddMode = await readField3(name, "tdd_mode"); const reviewMode = await readField3(name, "review_mode"); - const allowedIsolation = workflow === "full" ? ["branch", "worktree"] : ["current", "branch", "worktree"]; - if (!allowedIsolation.includes(isolation)) { + if (!ISOLATIONS.includes(isolation)) { fail2( - `ERROR: Cannot transition '${name}': isolation must be ${workflow === "full" ? "branch or worktree" : "current, branch, or worktree"}, got '${isolation || "null"}'` + `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(", ")}, got '${isolation || "null"}'` + ); + } + if (["hotfix", "tweak"].includes(workflow) && !PRESET_ALLOWED_ISOLATIONS.includes(isolation)) { + fail2( + `ERROR: Cannot transition '${name}': isolation=${isolation} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(" or ")})` ); } if (!["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) { @@ -13511,13 +13645,13 @@ async function requireOpenArtifacts(name) { const { directory } = await stateFile(name); const workflow = await readField3(name, "workflow"); for (const artifact of ["proposal.md", "tasks.md"]) { - if (!await nonempty3(path21.join(directory, artifact))) { + if (!await nonempty3(path22.join(directory, artifact))) { fail2( `ERROR: Cannot transition '${name}': ${artifact} must exist and be non-empty before leaving open` ); } } - if (workflow === "full" && !await nonempty3(path21.join(directory, "design.md"))) { + if (workflow === "full" && !await nonempty3(path22.join(directory, "design.md"))) { fail2( `ERROR: Cannot transition '${name}': design.md must exist and be non-empty before leaving open` ); @@ -13525,14 +13659,14 @@ async function requireOpenArtifacts(name) { } async function requireDesignEvidence(name) { const designDoc = await readField3(name, "design_doc"); - if (!designDoc || designDoc === "null" || !await nonempty3(path21.resolve(designDoc))) { + if (!designDoc || designDoc === "null" || !await nonempty3(path22.resolve(designDoc))) { fail2( `ERROR: Cannot transition '${name}': design_doc must point to an existing Design Doc before leaving design` ); } } async function writeSparseTransitionEffects(directory, effects) { - const file = path21.join(directory, ".comet.yaml"); + const file = path22.join(directory, ".comet.yaml"); const document = await readDocument2(file); for (const effect of effects) { const field2 = wireField2(effect.field); @@ -13547,7 +13681,7 @@ async function applyTransitionEvent(output, name, event) { let sparse = false; if (!classic) { if (projection.run) fail2("ERROR: Classic state projection is missing"); - const document = await readDocument2(path21.join(directory, ".comet.yaml")); + const document = await readDocument2(path22.join(directory, ".comet.yaml")); classic = sparseClassicState(document.toJS()); sparse = true; } @@ -13594,7 +13728,7 @@ async function transition(output, name, event) { } else if (event === "verify-pass") { await requirePhase(name, "verify"); const report = await readField3(name, "verification_report"); - if (!report || !await exists6(path21.resolve(report))) { + if (!report || !await exists6(path22.resolve(report))) { fail2( `ERROR: Cannot transition '${name}': verification_report must point to an existing report file` ); @@ -13658,9 +13792,9 @@ async function next(output, name) { async function taskCheckoff(output, taskFile, taskText) { validateRelativePath(taskFile, "task file"); if (!taskText) fail2("ERROR: Task text cannot be empty"); - const file = path21.resolve(taskFile); + const file = path22.resolve(taskFile); if (!await exists6(file)) fail2(`ERROR: Task file not found: ${taskFile}`); - const lines = (await fs20.readFile(file, "utf8")).split(/\r?\n/u); + const lines = (await fs21.readFile(file, "utf8")).split(/\r?\n/u); const matches = lines.filter( (line) => [`- [ ] ${taskText}`, `- [x] ${taskText}`, `- [X] ${taskText}`].includes(line) ); @@ -13698,21 +13832,21 @@ async function check2(output, name, phase) { designDoc ? `design_doc=${designDoc} (expected: empty/null)` : "design_doc is empty/null" ); for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { - (await nonempty3(path21.join(directory, artifact)) ? pass2 : reject)( - `${artifact} ${await nonempty3(path21.join(directory, artifact)) ? "non-empty" : "missing or empty"}` + (await nonempty3(path22.join(directory, artifact)) ? pass2 : reject)( + `${artifact} ${await nonempty3(path22.join(directory, artifact)) ? "non-empty" : "missing or empty"}` ); } } else if (phase === "build") { const workflow = await readField3(name, "workflow"); const designDoc = await readField3(name, "design_doc"); if (workflow === "full") { - (designDoc && designDoc !== "null" && await exists6(path21.resolve(designDoc)) ? pass2 : reject)(`design_doc=${designDoc} (expected: non-null and file exists)`); + (designDoc && designDoc !== "null" && await exists6(path22.resolve(designDoc)) ? pass2 : reject)(`design_doc=${designDoc} (expected: non-null and file exists)`); } else { pass2(`workflow=${workflow} (design_doc not required)`); } for (const artifact of ["proposal.md", "tasks.md"]) { - (await nonempty3(path21.join(directory, artifact)) ? pass2 : reject)( - `${artifact} ${await nonempty3(path21.join(directory, artifact)) ? "non-empty" : "missing or empty"}` + (await nonempty3(path22.join(directory, artifact)) ? pass2 : reject)( + `${artifact} ${await nonempty3(path22.join(directory, artifact)) ? "non-empty" : "missing or empty"}` ); } } else if (phase === "verify") { @@ -13725,6 +13859,25 @@ async function check2(output, name, phase) { const archived = await readField3(name, "archived"); (archived !== "true" ? pass2 : reject)(`archived=${archived} (expected: not true)`); } + const isolation = await readField3(name, "isolation"); + if (isolation === "current") { + const boundBranch = await readField3(name, "bound_branch"); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== "null" ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()) + }); + if (verdict.status === "drift") { + reject(driftBlockedMessage(name, verdict.boundBranch, verdict.currentBranch)); + } else if (verdict.status === "unbound-detached") { + reject(unboundDetachedMessage(name)); + } else if (verdict.status === "needs-heal") { + await healBoundBranch(directory, verdict.branch); + pass2(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } else { + pass2(`bound_branch matches current branch (isolation=current)`); + } + } output.stdout.push(""); if (blocked2) { output.stderr.push(red4("BLOCKED — fix failing checks before proceeding")); @@ -13734,7 +13887,7 @@ async function check2(output, name, phase) { } function fieldStatus(field2, value, file) { if (!value || value === "null") return ` - ${field2}: PENDING`; - if (file && !existsSync3(path21.resolve(file))) { + if (file && !existsSync3(path22.resolve(file))) { return ` - ${field2}: BROKEN (path ${value} does not exist)`; } return ` - ${field2}: DONE (${value})`; @@ -13743,7 +13896,7 @@ async function recoverOpen(output, directory) { output.stdout.push(" Artifacts:"); let complete = 0; for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { - const done = await nonempty3(path21.join(directory, artifact)); + const done = await nonempty3(path22.join(directory, artifact)); if (done) complete += 1; output.stdout.push(` - ${artifact}: ${done ? "DONE" : "PENDING"}`); } @@ -13756,7 +13909,7 @@ async function recoverDesign(output, name, directory) { output.stdout.push(" Artifacts:"); for (const artifact of ["proposal.md", "design.md", "tasks.md"]) { output.stdout.push( - ` - ${artifact}: ${await nonempty3(path21.join(directory, artifact)) ? "DONE" : "MISSING (unexpected in design phase)"}` + ` - ${artifact}: ${await nonempty3(path22.join(directory, artifact)) ? "DONE" : "MISSING (unexpected in design phase)"}` ); } const handoff = await readField3(name, "handoff_context"); @@ -13770,11 +13923,11 @@ async function recoverDesign(output, name, directory) { fieldStatus("design_doc", design, design), "" ); - if (design && design !== "null" && await exists6(path21.resolve(design))) { + if (design && design !== "null" && await exists6(path22.resolve(design))) { output.stdout.push( "Recovery action: Design Doc already created and linked. Run guard to transition to build." ); - } else if (handoff && handoff !== "null" && await exists6(path21.resolve(handoff))) { + } else if (handoff && handoff !== "null" && await exists6(path22.resolve(handoff))) { output.stdout.push( "Recovery action: Handoff generated but Design Doc not yet created. Resume from brainstorming confirmation (Step 1c)." ); @@ -13804,7 +13957,7 @@ async function recoverBuild(output, name, directory, workflow) { decisions.push(fieldStatus("subagent_dispatch", subagentDispatch)); } output.stdout.push(...decisions, "", " Plan:", fieldStatus("plan", plan, plan), ""); - const tasks = path21.join(directory, "tasks.md"); + const tasks = path22.join(directory, "tasks.md"); if (!await exists6(tasks)) { output.stdout.push( " Tasks: tasks.md MISSING", @@ -13813,14 +13966,14 @@ async function recoverBuild(output, name, directory, workflow) { ); return; } - const lines = (await fs20.readFile(tasks, "utf8")).split(/\r?\n/u); + const lines = (await fs21.readFile(tasks, "utf8")).split(/\r?\n/u); const total = lines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; const done = lines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; const pending = total - done; let planTotal = 0; let planDone = 0; - if (plan && plan !== "null" && await exists6(path21.resolve(plan))) { - const planLines = (await fs20.readFile(path21.resolve(plan), "utf8")).split(/\r?\n/u); + if (plan && plan !== "null" && await exists6(path22.resolve(plan))) { + const planLines = (await fs21.readFile(path22.resolve(plan), "utf8")).split(/\r?\n/u); planTotal = planLines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; planDone = planLines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; } @@ -13866,7 +14019,7 @@ function resolveBuildRecoveryAction(workflow, isolation, buildMode, pause, subag return "Recovery action: Plan-ready pause is stale and all tasks are done. Clear build_pause to null, then run guard to transition to verify."; } if (isMissingStateValue(isolation)) { - return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree choice."; + return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree/current choice."; } if (isMissingStateValue(buildMode)) { return "Recovery action: Build mode not selected. Use the current platform's user confirmation mechanism to ask user for execution method."; @@ -13951,19 +14104,19 @@ async function scale(output, name) { validateChangeName4(name); const { file, directory, label } = await stateFile(name); if (!await exists6(file)) fail2(`ERROR: .comet.yaml not found at ${label}/.comet.yaml`); - const tasksFile = path21.join(directory, "tasks.md"); - const taskCount = await exists6(tasksFile) ? (await fs20.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; - const specs = path21.join(directory, "specs"); + const tasksFile = path22.join(directory, "tasks.md"); + const taskCount = await exists6(tasksFile) ? (await fs21.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; + const specs = path22.join(directory, "specs"); let deltaSpecs = 0; if (await exists6(specs)) { - for (const entry2 of await fs20.readdir(specs)) { - if (await exists6(path21.join(specs, entry2, "spec.md"))) deltaSpecs += 1; + for (const entry2 of await fs21.readdir(specs)) { + if (await exists6(path22.join(specs, entry2, "spec.md"))) deltaSpecs += 1; } } const plan = await readField3(name, "plan"); let baseRef = ""; - if (plan && plan !== "null" && await exists6(path21.resolve(plan))) { - const match = (await fs20.readFile(path21.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); + if (plan && plan !== "null" && await exists6(path22.resolve(plan))) { + const match = (await fs21.readFile(path22.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); baseRef = match?.[1].trim() ?? ""; } if (!baseRef) baseRef = await readField3(name, "base_ref"); @@ -14039,15 +14192,38 @@ function required(args, count, usage3) { function requiredExact(args, count, usage3) { if (args.length !== count) fail2(usage3); } +async function rebind(output, name) { + validateChangeName4(name); + const { directory } = await stateFile(name); + const boundBranch = await readField3(name, "bound_branch"); + if (!boundBranch || boundBranch === "null") { + fail2( + `ERROR: '${name}' is not yet bound; use 'comet state set ${name} isolation current' to establish the first binding` + ); + } + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail2("ERROR: cannot rebind while HEAD is detached; checkout a branch first"); + } + const before = await readClassicState(directory); + if (!before.classic) fail2("ERROR: Classic state projection is missing"); + await healBoundBranch(directory, branch); + const after = { ...before.classic, boundBranch: branch }; + await appendClassicStateEvent(directory, { + change: name, + event: "rebind", + source: "comet-state", + from: before.classic, + to: after, + effects: [{ field: "boundBranch", from: boundBranch, to: branch }] + }); + output.stderr.push(green4(`[REBIND] bound_branch: ${boundBranch} → ${branch}`)); +} async function selectChange(output, name) { validateChangeName4(name); try { const selection = await selectCurrentChange(process.cwd(), name); - output.stderr.push( - green4( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ""}` - ) - ); + output.stderr.push(green4(`[SELECTED] current change: ${selection.change}`)); } catch (error) { fail2(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } @@ -14056,6 +14232,11 @@ async function currentChange(output) { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === "selected") { output.stdout.push(resolution.selection.change); + const isolation = resolution.classic.isolation ?? null; + const branch = resolution.classic.boundBranch ?? resolution.branch; + output.stderr.push( + `[CURRENT] isolation: ${isolation ?? "null"}${branch ? `, branch: ${branch}` : ""}` + ); return; } if (resolution.status === "missing") { @@ -14105,6 +14286,9 @@ var classicStateCommand = async (args) => { } else if (subcommand === "task-checkoff") { required(rest, 2, "Usage: comet-state.mjs task-checkoff "); await taskCheckoff(output, rest[0], rest[1]); + } else if (subcommand === "rebind") { + requiredExact(rest, 1, "Usage: comet-state.mjs rebind "); + await rebind(output, rest[0]); } else if (subcommand === "select") { requiredExact(rest, 1, "Usage: comet-state.mjs select "); await selectChange(output, rest[0]); diff --git a/domains/comet-classic/classic-branch-binding.ts b/domains/comet-classic/classic-branch-binding.ts new file mode 100644 index 00000000..daa3e57d --- /dev/null +++ b/domains/comet-classic/classic-branch-binding.ts @@ -0,0 +1,85 @@ +import { execFileSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { parseDocument } from 'yaml'; + +export function liveGitBranch(cwd: string): string | null { + try { + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return branch && branch !== 'HEAD' ? branch : null; + } catch { + return null; + } +} + +export type BranchBindingVerdict = + | { status: 'not-applicable' } + | { status: 'ok' } + | { status: 'needs-heal'; branch: string } + | { status: 'unbound-detached' } + | { status: 'drift'; boundBranch: string; currentBranch: string | null }; + +export function evaluateBranchBinding(input: { + isolation: string | null; + boundBranch: string | null; + currentBranch: string | null; +}): BranchBindingVerdict { + if (input.isolation !== 'current') return { status: 'not-applicable' }; + if (input.boundBranch === null) { + return input.currentBranch === null + ? { status: 'unbound-detached' } + : { status: 'needs-heal', branch: input.currentBranch }; + } + if (input.currentBranch === input.boundBranch) return { status: 'ok' }; + return { + status: 'drift', + boundBranch: input.boundBranch, + currentBranch: input.currentBranch, + }; +} + +export async function healBoundBranch(changeDir: string, branch: string): Promise { + const file = path.join(changeDir, '.comet.yaml'); + const document = parseDocument(await fs.readFile(file, 'utf8'), { uniqueKeys: false }); + document.set('bound_branch', branch); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporary, document.toString(), 'utf8'); + await fs.rename(temporary, file); + } catch (error) { + await fs.rm(temporary, { force: true }); + throw error; + } +} + +function branchLabel(currentBranch: string | null): string { + return currentBranch ?? 'detached HEAD'; +} + +export function driftBlockedMessage( + change: string, + boundBranch: string, + currentBranch: string | null, +): string { + return ( + `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'.\n` + + `Next: ask the user to confirm — switch back to '${boundBranch}', or run \`comet state rebind ${change}\` after explicit confirmation.` + ); +} + +export function driftStaleReason( + change: string, + boundBranch: string, + currentBranch: string | null, +): string { + return `change '${change}' is bound to branch '${boundBranch}', but current branch is '${branchLabel(currentBranch)}'`; +} + +export function unboundDetachedMessage(change: string): string { + return `change '${change}' uses isolation=current but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} diff --git a/domains/comet-classic/classic-current-change.ts b/domains/comet-classic/classic-current-change.ts index ce781c05..5bd35325 100644 --- a/domains/comet-classic/classic-current-change.ts +++ b/domains/comet-classic/classic-current-change.ts @@ -1,9 +1,16 @@ -import { execFileSync } from 'child_process'; import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import { assertOpenSpecChangeName } from './classic-paths.js'; import { readClassicState } from './classic-store.js'; +import type { ClassicState } from './classic-state.js'; +import { + driftStaleReason, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; export interface CurrentChangeSelection { version: 1; @@ -12,7 +19,12 @@ export interface CurrentChangeSelection { } export type CurrentChangeResolution = - | { status: 'selected'; selection: CurrentChangeSelection } + | { + status: 'selected'; + selection: CurrentChangeSelection; + classic: ClassicState; + branch: string | null; + } | { status: 'missing' } | { status: 'stale'; reason: string }; @@ -20,24 +32,14 @@ export function currentChangeFile(projectRoot: string): string { return path.join(projectRoot, '.comet', 'current-change.json'); } -function currentBranch(projectRoot: string): string | null { - try { - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: projectRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return branch && branch !== 'HEAD' ? branch : null; - } catch { - return null; - } -} - function changeDirectory(projectRoot: string, changeName: string): string { return path.join(projectRoot, 'openspec', 'changes', changeName); } -async function validateActiveChange(projectRoot: string, changeName: string): Promise { +async function validateActiveChange( + projectRoot: string, + changeName: string, +): Promise { assertOpenSpecChangeName(changeName); const changeDir = changeDirectory(projectRoot, changeName); try { @@ -61,6 +63,7 @@ async function validateActiveChange(projectRoot: string, changeName: string): Pr if (projection.classic.archived) { throw new Error(`Cannot select current change '${changeName}': change is archived`); } + return projection.classic; } function parseSelection(source: string): CurrentChangeSelection { @@ -87,11 +90,7 @@ function parseSelection(source: string): CurrentChangeSelection { if (record.branch !== null && typeof record.branch !== 'string') { throw new Error('current change selection branch must be a string or null'); } - return { - version: 1, - change: record.change, - branch: record.branch as string | null, - }; + return { version: 1, change: record.change, branch: record.branch as string | null }; } export async function selectCurrentChange( @@ -102,7 +101,7 @@ export async function selectCurrentChange( const selection: CurrentChangeSelection = { version: 1, change: changeName, - branch: currentBranch(projectRoot), + branch: liveGitBranch(projectRoot), }; const file = currentChangeFile(projectRoot); const temporary = `${file}.${randomUUID()}.tmp`; @@ -130,9 +129,10 @@ export async function resolveCurrentChange(projectRoot: string): Promise { diff --git a/domains/comet-classic/classic-guard.ts b/domains/comet-classic/classic-guard.ts index bc68c8a9..2e6ac9a9 100644 --- a/domains/comet-classic/classic-guard.ts +++ b/domains/comet-classic/classic-guard.ts @@ -13,7 +13,19 @@ import { inspectClassicChange } from './classic-diagnostics.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; import { ensureClassicRuntimeRun, transitionClassicRuntimeRun } from './classic-runtime-run.js'; import type { ClassicRunContext } from './classic-migrate.js'; -import type { ClassicPhase, ClassicState } from './classic-state.js'; +import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; +import { + ISOLATIONS, + PRESET_ALLOWED_ISOLATIONS, + type ClassicPhase, + type ClassicState, +} from './classic-state.js'; import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_GUARD_TRANSITION_EVENT, applyClassicTransition } from './classic-transitions.js'; import { classicValidateCommand } from './classic-validate-command.js'; @@ -487,15 +499,44 @@ async function planTasksAllDone(changeDir: string): Promise { async function isolationSelected(changeDir: string, change: string): Promise { const isolation = await readField(changeDir, 'isolation'); + if ((ISOLATIONS as readonly string[]).includes(isolation)) return pass(); + return fail( + `isolation must be one of ${ISOLATIONS.join(', ')}, got '${isolation || 'null'}'\nNext: ask the user to choose branch, worktree, or current, create the chosen isolation when needed, then run:\n node "$COMET_STATE" set ${change} isolation <${ISOLATIONS.join('|')}>`, + ); +} + +async function isolationAllowedForWorkflow( + changeDir: string, + change: string, +): Promise { const workflow = await readField(changeDir, 'workflow'); - if (isolation === 'branch' || isolation === 'worktree') return pass(); - if (isolation === 'current' && (workflow === 'hotfix' || workflow === 'tweak')) return pass(); - const allowedValues = workflow === 'full' ? '' : ''; + const isolation = await readField(changeDir, 'isolation'); + if (workflow !== 'hotfix' && workflow !== 'tweak') return pass(); + if ((PRESET_ALLOWED_ISOLATIONS as readonly string[]).includes(isolation)) return pass(); return fail( - `isolation must be ${workflow === 'full' ? 'branch or worktree' : 'current, branch, or worktree'}, got '${isolation || 'null'}'\nNext: choose a valid workspace mode, prepare it when needed, then run:\n comet state set ${change} isolation ${allowedValues}`, + `isolation=${isolation || 'null'} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(' or ')})\nNext: ask the user to choose ${PRESET_ALLOWED_ISOLATIONS.join(' or ')}, then run:\n node "$COMET_STATE" set ${change} isolation <${PRESET_ALLOWED_ISOLATIONS.join('|')}>`, ); } +async function boundBranchMatches(changeDir: string, change: string): Promise { + const isolation = await readField(changeDir, 'isolation'); + const boundBranch = await readField(changeDir, 'bound_branch'); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== 'null' ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()), + }); + if (verdict.status === 'drift') { + return fail(driftBlockedMessage(change, verdict.boundBranch, verdict.currentBranch)); + } + if (verdict.status === 'unbound-detached') return fail(unboundDetachedMessage(change)); + if (verdict.status === 'needs-heal') { + await healBoundBranch(changeDir, verdict.branch); + return pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } + return pass(); +} + async function buildModeSelected(changeDir: string, change: string): Promise { const buildMode = await readField(changeDir, 'build_mode'); if (['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) @@ -811,7 +852,9 @@ async function guardBuildChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('isolation selected', () => isolationSelected(changeDir, change)), + check('isolation allowed for workflow', () => isolationAllowedForWorkflow(changeDir, change)), check('build_mode selected', () => buildModeSelected(changeDir, change)), check('build_mode allowed for workflow', () => buildModeAllowedForWorkflow(changeDir)), check('subagent dispatch confirmed', () => subagentDispatchConfirmed(changeDir, change)), @@ -846,6 +889,7 @@ async function guardVerifyChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('tasks.md all tasks checked', () => tasksAllDone(changeDir)), // Verification command runs after tasks check — no point running tests // if tasks.md is incomplete. @@ -864,8 +908,13 @@ async function guardVerifyChecks( ]); } -async function guardArchiveChecks(output: GuardOutput, changeDir: string): Promise { +async function guardArchiveChecks( + output: GuardOutput, + changeDir: string, + change: string, +): Promise { return runChecks(output, [ + check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)), check('archived is true', async () => ((await archivedIsTrue(changeDir)) ? pass() : fail(''))), check('proposal.md exists', async () => (await nonempty(path.join(changeDir, 'proposal.md'))) ? pass() : fail(''), @@ -946,7 +995,7 @@ export const classicGuardCommand: ClassicCommandHandler = async (args, options) blocked = await guardBuildChecks(output, changeDir, change, runContext.run); else if (phase === 'verify') blocked = await guardVerifyChecks(output, changeDir, change, runContext.run); - else blocked = await guardArchiveChecks(output, changeDir); + else blocked = await guardArchiveChecks(output, changeDir, change); if (blocked) { output.stderr.push(''); diff --git a/domains/comet-classic/classic-resolver.ts b/domains/comet-classic/classic-resolver.ts index 7996d88e..790c888f 100644 --- a/domains/comet-classic/classic-resolver.ts +++ b/domains/comet-classic/classic-resolver.ts @@ -1,7 +1,11 @@ import type { DeterministicResolver } from '../../domains/engine/resolver.js'; import type { ClassicEvidence } from './classic-evidence.js'; import { evidenceSatisfied } from './classic-evidence.js'; -import type { ClassicProfile, ClassicState } from './classic-state.js'; +import { + PRESET_ALLOWED_ISOLATIONS, + type ClassicProfile, + type ClassicState, +} from './classic-state.js'; export interface ClassicResolverContext { classic: ClassicState; @@ -28,6 +32,7 @@ function presetBuildConfigured(classic: ClassicState): boolean { classic.buildMode === 'direct' && classic.tddMode === 'direct' && classic.isolation !== null && + PRESET_ALLOWED_ISOLATIONS.includes(classic.isolation) && classic.verifyMode === 'light', ); } @@ -46,7 +51,12 @@ function resolveBuild( if (classic.buildPause === 'plan-ready') return 'full.build.plan-ready'; if (!fullBuildConfigured(classic)) return 'full.build.configure'; } else if (!presetBuildConfigured(classic)) { - throw new Error(`${profile} build configuration is incomplete`); + // Presets no longer default isolation at init (the user must choose branch/current + // explicitly), so build configuration can be incomplete the instant phase becomes + // 'build' — same situation `full` handles via `full.build.configure`. Presets have + // no dedicated configure step, so route to `.build.execute` (guard's build-phase + // checks already block leaving build until configuration is complete). + return `${profile}.build.execute`; } return evidenceSatisfied(evidence, 'build.tasks-complete') diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 073199ad..f30d555b 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -16,11 +16,20 @@ import { transitionClassicRuntimeRun, validateClassicRuntimeRun } from './classi import { appendClassicStateEvent } from './classic-state-events.js'; import { CLASSIC_WIRE_KEYS, + ISOLATIONS, + PRESET_ALLOWED_ISOLATIONS, RUN_WIRE_KEYS, parseClassicStateDocument, type ClassicState, } from './classic-state.js'; import { readClassicState, writeClassicState } from './classic-store.js'; +import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { CLASSIC_TRANSITION_EVENTS, applyClassicTransition, @@ -45,6 +54,7 @@ const MACHINE_OWNED_FIELDS = new Set([ 'verify_failures', 'classic_profile', 'classic_migration', + 'bound_branch', ]); const SETTABLE_FIELDS = new Set( CLASSIC_WIRE_KEYS.filter((field) => !MACHINE_OWNED_FIELDS.has(field)), @@ -59,11 +69,12 @@ const FIELD_ENUMS: Record = { subagent_dispatch: ['null', 'confirmed'], tdd_mode: ['tdd', 'direct'], review_mode: ['off', 'standard', 'thorough'], - isolation: ['current', 'branch', 'worktree'], + isolation: ISOLATIONS, verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + branch_action: ['push', 'keep-local', 'merged-locally', 'pushed-pr'], archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], @@ -75,6 +86,7 @@ const PATH_FIELDS = new Set(['design_doc', 'plan', 'verification_report', 'hando const CLASSIC_FIELD_WIRE_NAMES: Partial> = { archived: 'archived', branchStatus: 'branch_status', + branchAction: 'branch_action', classicProfile: 'classic_profile', designDoc: 'design_doc', language: 'language', @@ -283,12 +295,8 @@ function sparseClassicState(record: Record): ClassicState { ['off', 'standard', 'thorough'] as const, null, ), - isolation: enumRecordValue( - record, - 'isolation', - ['current', 'branch', 'worktree'] as const, - null, - ), + isolation: enumRecordValue(record, 'isolation', ISOLATIONS, null), + boundBranch: nullableRecordString(record, 'bound_branch'), verifyMode: enumRecordValue(record, 'verify_mode', ['light', 'full'] as const, null), autoTransition: nullableRecordBoolean(record, 'auto_transition'), baseRef: nullableRecordString(record, 'base_ref'), @@ -303,6 +311,12 @@ function sparseClassicState(record: Record): ClassicState { verifyFailures: nonNegativeRecordInteger(record, 'verify_failures'), verificationReport: nullableRecordString(record, 'verification_report'), branchStatus: enumRecordValue(record, 'branch_status', ['pending', 'handled'] as const, null), + branchAction: enumRecordValue( + record, + 'branch_action', + ['push', 'keep-local', 'merged-locally', 'pushed-pr'] as const, + null, + ), createdAt: nullableRecordString(record, 'created_at'), verifiedAt: nullableRecordString(record, 'verified_at'), archiveConfirmation: enumRecordValue( @@ -444,6 +458,24 @@ async function setField( const { file, directory } = await stateFile(name); const document = await readDocument(file); document.set(field, parsedValue(field, value)); + if (field === 'isolation') { + if (value === 'current') { + const record = document.toJS() as Record; + const existing = record.bound_branch; + const alreadyBound = typeof existing === 'string' && existing !== ''; + if (!alreadyBound) { + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail( + 'ERROR: cannot bind isolation=current while HEAD is detached; checkout a branch first', + ); + } + document.set('bound_branch', branch); + } + } else { + document.set('bound_branch', null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS() as Record, run); if (projection.run) { @@ -508,7 +540,7 @@ async function init(output: CommandOutput, name: string, workflow: string): Prom subagent_dispatch: null, tdd_mode: preset ? 'direct' : null, review_mode: reviewMode, - isolation: preset ? 'current' : null, + isolation: null, verify_mode: preset ? 'light' : null, auto_transition: (await autoTransition()) === 'true', base_ref: gitOutput(['rev-parse', '--verify', 'HEAD']), @@ -542,11 +574,17 @@ async function requireBuildDecisions(name: string): Promise { const subagentDispatch = await readField(name, 'subagent_dispatch'); const tddMode = await readField(name, 'tdd_mode'); const reviewMode = await readField(name, 'review_mode'); - const allowedIsolation = - workflow === 'full' ? ['branch', 'worktree'] : ['current', 'branch', 'worktree']; - if (!allowedIsolation.includes(isolation)) { + if (!(ISOLATIONS as readonly string[]).includes(isolation)) { fail( - `ERROR: Cannot transition '${name}': isolation must be ${workflow === 'full' ? 'branch or worktree' : 'current, branch, or worktree'}, got '${isolation || 'null'}'`, + `ERROR: Cannot transition '${name}': isolation must be one of ${ISOLATIONS.join(', ')}, got '${isolation || 'null'}'`, + ); + } + if ( + ['hotfix', 'tweak'].includes(workflow) && + !(PRESET_ALLOWED_ISOLATIONS as readonly string[]).includes(isolation) + ) { + fail( + `ERROR: Cannot transition '${name}': isolation=${isolation} is not allowed for ${workflow} (preset workflows only allow ${PRESET_ALLOWED_ISOLATIONS.join(' or ')})`, ); } if (!['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) { @@ -841,6 +879,25 @@ async function check(output: CommandOutput, name: string, phase: string): Promis const archived = await readField(name, 'archived'); (archived !== 'true' ? pass : reject)(`archived=${archived} (expected: not true)`); } + const isolation = await readField(name, 'isolation'); + if (isolation === 'current') { + const boundBranch = await readField(name, 'bound_branch'); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranch && boundBranch !== 'null' ? boundBranch : null, + currentBranch: liveGitBranch(process.cwd()), + }); + if (verdict.status === 'drift') { + reject(driftBlockedMessage(name, verdict.boundBranch, verdict.currentBranch)); + } else if (verdict.status === 'unbound-detached') { + reject(unboundDetachedMessage(name)); + } else if (verdict.status === 'needs-heal') { + await healBoundBranch(directory, verdict.branch); + pass(`bound_branch lazily set to ${verdict.branch} (isolation=current)`); + } else { + pass(`bound_branch matches current branch (isolation=current)`); + } + } output.stdout.push(''); if (blocked) { output.stderr.push(red('BLOCKED — fix failing checks before proceeding')); @@ -1025,7 +1082,7 @@ function resolveBuildRecoveryAction( return 'Recovery action: Plan-ready pause is stale and all tasks are done. Clear build_pause to null, then run guard to transition to verify.'; } if (isMissingStateValue(isolation)) { - return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree choice."; + return "Recovery action: Isolation not selected. Use the current platform's user confirmation mechanism to ask user for branch/worktree/current choice."; } if (isMissingStateValue(buildMode)) { return "Recovery action: Build mode not selected. Use the current platform's user confirmation mechanism to ask user for execution method."; @@ -1231,15 +1288,39 @@ function requiredExact(args: string[], count: number, usage: string): void { if (args.length !== count) fail(usage); } +async function rebind(output: CommandOutput, name: string): Promise { + validateChangeName(name); + const { directory } = await stateFile(name); + const boundBranch = await readField(name, 'bound_branch'); + if (!boundBranch || boundBranch === 'null') { + fail( + `ERROR: '${name}' is not yet bound; use 'comet state set ${name} isolation current' to establish the first binding`, + ); + } + const branch = liveGitBranch(process.cwd()); + if (branch === null) { + fail('ERROR: cannot rebind while HEAD is detached; checkout a branch first'); + } + const before = await readClassicState(directory); + if (!before.classic) fail('ERROR: Classic state projection is missing'); + await healBoundBranch(directory, branch); + const after: ClassicState = { ...before.classic, boundBranch: branch }; + await appendClassicStateEvent(directory, { + change: name, + event: 'rebind', + source: 'comet-state', + from: before.classic, + to: after, + effects: [{ field: 'boundBranch', from: boundBranch, to: branch }], + }); + output.stderr.push(green(`[REBIND] bound_branch: ${boundBranch} → ${branch}`)); +} + async function selectChange(output: CommandOutput, name: string): Promise { validateChangeName(name); try { const selection = await selectCurrentChange(process.cwd(), name); - output.stderr.push( - green( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ''}`, - ), - ); + output.stderr.push(green(`[SELECTED] current change: ${selection.change}`)); } catch (error) { fail(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } @@ -1249,6 +1330,11 @@ async function currentChange(output: CommandOutput): Promise { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === 'selected') { output.stdout.push(resolution.selection.change); + const isolation = resolution.classic.isolation ?? null; + const branch = resolution.classic.boundBranch ?? resolution.branch; + output.stderr.push( + `[CURRENT] isolation: ${isolation ?? 'null'}${branch ? `, branch: ${branch}` : ''}`, + ); return; } if (resolution.status === 'missing') { @@ -1299,6 +1385,9 @@ export const classicStateCommand: ClassicCommandHandler = async (args) => { } else if (subcommand === 'task-checkoff') { required(rest, 2, 'Usage: comet-state.mjs task-checkoff '); await taskCheckoff(output, rest[0], rest[1]); + } else if (subcommand === 'rebind') { + requiredExact(rest, 1, 'Usage: comet-state.mjs rebind '); + await rebind(output, rest[0]); } else if (subcommand === 'select') { requiredExact(rest, 1, 'Usage: comet-state.mjs select '); await selectChange(output, rest[0]); diff --git a/domains/comet-classic/classic-state-events.ts b/domains/comet-classic/classic-state-events.ts index 1ec744f4..ae9d23f0 100644 --- a/domains/comet-classic/classic-state-events.ts +++ b/domains/comet-classic/classic-state-events.ts @@ -7,7 +7,7 @@ export type ClassicStateEventSource = 'comet-state' | 'comet-guard' | 'comet-arc export interface ClassicStateEventInput { change: string; - event: ClassicTransitionEvent; + event: ClassicTransitionEvent | 'rebind'; source: ClassicStateEventSource; from: ClassicState; to: ClassicState; diff --git a/domains/comet-classic/classic-state.ts b/domains/comet-classic/classic-state.ts index cad6b70f..9f147c49 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -12,10 +12,20 @@ const BUILD_PAUSES = ['plan-ready'] as const; const SUBAGENT_DISPATCH = ['confirmed'] as const; const TDD_MODES = ['tdd', 'direct'] as const; const REVIEW_MODES = ['off', 'standard', 'thorough'] as const; -const ISOLATIONS = ['current', 'branch', 'worktree'] as const; +export const ISOLATION_MODES = [ + { value: 'branch', allowedInPreset: true }, + { value: 'worktree', allowedInPreset: true }, + { value: 'current', allowedInPreset: true }, +] as const; +type IsolationMode = (typeof ISOLATION_MODES)[number]['value']; +export const ISOLATIONS = ISOLATION_MODES.map((mode) => mode.value) as IsolationMode[]; +export const PRESET_ALLOWED_ISOLATIONS = ISOLATION_MODES.filter((mode) => mode.allowedInPreset).map( + (mode) => mode.value, +) as IsolationMode[]; const VERIFY_MODES = ['light', 'full'] as const; const VERIFY_RESULTS = ['pending', 'pass', 'fail'] as const; const BRANCH_STATUSES = ['pending', 'handled'] as const; +const BRANCH_ACTIONS = ['push', 'keep-local', 'merged-locally', 'pushed-pr'] as const; const ARCHIVE_CONFIRMATIONS = ['pending', 'confirmed'] as const; export type ClassicProfile = (typeof CLASSIC_PROFILES)[number]; @@ -33,6 +43,7 @@ export interface ClassicState { tddMode: (typeof TDD_MODES)[number] | null; reviewMode: (typeof REVIEW_MODES)[number] | null; isolation: (typeof ISOLATIONS)[number] | null; + boundBranch: string | null; verifyMode: (typeof VERIFY_MODES)[number] | null; autoTransition: boolean | null; baseRef: string | null; @@ -42,6 +53,7 @@ export interface ClassicState { verifyFailures: number; verificationReport: string | null; branchStatus: (typeof BRANCH_STATUSES)[number] | null; + branchAction: (typeof BRANCH_ACTIONS)[number] | null; createdAt: string | null; verifiedAt: string | null; archiveConfirmation: (typeof ARCHIVE_CONFIRMATIONS)[number] | null; @@ -70,6 +82,7 @@ export const CLASSIC_WIRE_KEYS = [ 'tdd_mode', 'review_mode', 'isolation', + 'bound_branch', 'verify_mode', 'auto_transition', 'base_ref', @@ -79,6 +92,7 @@ export const CLASSIC_WIRE_KEYS = [ 'verify_failures', 'verification_report', 'branch_status', + 'branch_action', 'created_at', 'verified_at', 'archive_confirmation', @@ -211,6 +225,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { tddMode: enumValue(doc, 'tdd_mode', TDD_MODES), reviewMode: enumValue(doc, 'review_mode', REVIEW_MODES), isolation: enumValue(doc, 'isolation', ISOLATIONS), + boundBranch: nullableString(doc, 'bound_branch'), verifyMode: enumValue(doc, 'verify_mode', VERIFY_MODES), autoTransition: booleanValue(doc, 'auto_transition'), baseRef: nullableString(doc, 'base_ref'), @@ -220,6 +235,7 @@ function classicStateFromDocument(doc: StateDocument): ClassicState | null { verifyFailures: nonNegativeInteger(doc, 'verify_failures'), verificationReport: relativePath(doc, 'verification_report'), branchStatus: enumValue(doc, 'branch_status', BRANCH_STATUSES), + branchAction: enumValue(doc, 'branch_action', BRANCH_ACTIONS), createdAt: nullableString(doc, 'created_at'), verifiedAt: nullableString(doc, 'verified_at'), archiveConfirmation: enumValue(doc, 'archive_confirmation', ARCHIVE_CONFIRMATIONS), @@ -300,6 +316,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { tdd_mode: state.tddMode, review_mode: state.reviewMode, isolation: state.isolation, + bound_branch: state.boundBranch, verify_mode: state.verifyMode, auto_transition: state.autoTransition, base_ref: state.baseRef, @@ -309,6 +326,7 @@ export function classicStateToDocument(state: ClassicState): StateDocument { verify_failures: state.verifyFailures, verification_report: state.verificationReport, branch_status: state.branchStatus, + branch_action: state.branchAction, created_at: state.createdAt, verified_at: state.verifiedAt, archive_confirmation: state.archiveConfirmation, diff --git a/domains/comet-classic/classic-validate-command.ts b/domains/comet-classic/classic-validate-command.ts index d1b474d6..3fbec884 100644 --- a/domains/comet-classic/classic-validate-command.ts +++ b/domains/comet-classic/classic-validate-command.ts @@ -3,7 +3,7 @@ import path from 'path'; import { isMap, parseDocument } from 'yaml'; import type { ClassicCommandHandler } from './classic-cli.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; -import { CLASSIC_WIRE_KEYS, RUN_WIRE_KEYS } from './classic-state.js'; +import { CLASSIC_WIRE_KEYS, ISOLATIONS, RUN_WIRE_KEYS } from './classic-state.js'; const GREEN = '\u001b[32m'; const RED = '\u001b[31m'; @@ -31,11 +31,12 @@ const ENUMS: Record = { subagent_dispatch: ['confirmed'], tdd_mode: ['tdd', 'direct'], review_mode: ['off', 'standard', 'thorough'], - isolation: ['current', 'branch', 'worktree'], + isolation: ISOLATIONS, verify_mode: ['light', 'full'], auto_transition: ['true', 'false'], verify_result: ['pending', 'pass', 'fail'], branch_status: ['pending', 'handled'], + branch_action: ['push', 'keep-local', 'merged-locally', 'pushed-pr'], archive_confirmation: ['pending', 'confirmed'], archived: ['true', 'false'], direct_override: ['true', 'false'], diff --git a/img/wechat.jpg b/img/wechat.jpg new file mode 100644 index 00000000..560570a4 Binary files /dev/null and b/img/wechat.jpg differ diff --git a/img/wechat.png b/img/wechat.png deleted file mode 100644 index fc97fca1..00000000 Binary files a/img/wechat.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 50c1c0c3..dc5c7df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a6116b9d..f0d5e2dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.6", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index 196927a1..b0dd2aa8 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -35,10 +35,10 @@ describe('CLI help text', () => { 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.5'); - expect(packageLock.version).toBe('0.4.0-beta.5'); - expect(packageLock.packages[''].version).toBe('0.4.0-beta.5'); - expect(assetsManifest.version).toBe('0.4.0-beta.5'); + expect(packageJson.version).toBe('0.4.0-beta.6'); + expect(packageLock.version).toBe('0.4.0-beta.6'); + expect(packageLock.packages[''].version).toBe('0.4.0-beta.6'); + expect(assetsManifest.version).toBe('0.4.0-beta.6'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/domains/comet-classic/classic-branch-binding.test.ts b/test/domains/comet-classic/classic-branch-binding.test.ts new file mode 100644 index 00000000..a5261935 --- /dev/null +++ b/test/domains/comet-classic/classic-branch-binding.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + evaluateBranchBinding, + driftBlockedMessage, + driftStaleReason, +} from '../../../domains/comet-classic/classic-branch-binding.js'; + +describe('evaluateBranchBinding', () => { + it('is not applicable when isolation is not current', () => { + expect( + evaluateBranchBinding({ isolation: 'branch', boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'not-applicable' }); + }); + + it('passes when the bound branch matches the current branch', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: 'feature-A', + }), + ).toEqual({ status: 'ok' }); + }); + + it('reports drift when the current branch differs', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: 'feature-B', + }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: 'feature-B' }); + }); + + it('reports drift (never a skip) when bound but HEAD is detached', () => { + expect( + evaluateBranchBinding({ + isolation: 'current', + boundBranch: 'feature-A', + currentBranch: null, + }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: null }); + }); + + it('requests a lazy heal when unbound on a real branch', () => { + expect( + evaluateBranchBinding({ isolation: 'current', boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'needs-heal', branch: 'feature-A' }); + }); + + it('refuses to lazy-bind when unbound and detached', () => { + expect( + evaluateBranchBinding({ isolation: 'current', boundBranch: null, currentBranch: null }), + ).toEqual({ status: 'unbound-detached' }); + }); +}); + +describe('drift messages', () => { + it('renders the blocked message with a detached-HEAD label', () => { + expect(driftBlockedMessage('my-change', 'feature-A', null)).toContain( + "bound to branch 'feature-A', but current branch is 'detached HEAD'", + ); + expect(driftBlockedMessage('my-change', 'feature-A', null)).toContain( + 'comet state rebind my-change', + ); + }); + + it('renders the stale reason with the current branch name', () => { + expect(driftStaleReason('my-change', 'feature-A', 'feature-B')).toBe( + "change 'my-change' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }); +}); diff --git a/test/domains/comet-classic/classic-contract.test.ts b/test/domains/comet-classic/classic-contract.test.ts index 20403f59..0000cd00 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -204,6 +204,16 @@ function legacyProjection(document: Record): Record !runKeys.has(key))); } diff --git a/test/domains/comet-classic/classic-current-change.test.ts b/test/domains/comet-classic/classic-current-change.test.ts index 3d7f2b5f..6068f5bc 100644 --- a/test/domains/comet-classic/classic-current-change.test.ts +++ b/test/domains/comet-classic/classic-current-change.test.ts @@ -45,6 +45,32 @@ async function seedActiveChange(root: string, name: string, archived: boolean): ); } +async function seedCurrentIsolationChange( + root: string, + name: string, + boundBranch: string, +): Promise { + const changeDir = path.join(root, 'openspec', 'changes', name); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, '.comet.yaml'), + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/design.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${boundBranch}`, + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); +} + describe('Classic current change selection', () => { let root: string; @@ -62,7 +88,7 @@ describe('Classic current change selection', () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); - it('atomically selects an active change with the current branch', async () => { + it('atomically selects an active change and records the current branch', async () => { await seedActiveChange(root, 'change-a', false); const selected = await selectCurrentChange(root, 'change-a'); @@ -79,7 +105,19 @@ describe('Classic current change selection', () => { await expect(selectCurrentChange(root, 'archived-change')).rejects.toThrow('archived'); }); - it('marks a selection stale after the branch changes', async () => { + it('marks a current-isolation selection stale when the bound branch drifts', async () => { + await seedCurrentIsolationChange(root, 'change-a', 'main'); + await selectCurrentChange(root, 'change-a'); + + git(root, 'switch', '-c', 'other'); + + expect(await resolveCurrentChange(root)).toEqual({ + status: 'stale', + reason: "change 'change-a' is bound to branch 'main', but current branch is 'other'", + }); + }); + + it('marks a branch-isolation selection stale when the branch changes', async () => { await seedActiveChange(root, 'change-a', false); await selectCurrentChange(root, 'change-a'); diff --git a/test/domains/comet-classic/classic-guard.test.ts b/test/domains/comet-classic/classic-guard.test.ts index 1c36e285..75948ac8 100644 --- a/test/domains/comet-classic/classic-guard.test.ts +++ b/test/domains/comet-classic/classic-guard.test.ts @@ -81,7 +81,9 @@ describe('Classic guard command', () => { const runState = await readRunState(changeDir); expect(runState).not.toBeNull(); expect(runState!.skill).toBe('comet-classic'); - expect(runState!.currentStep).toBe('hotfix.build.complete'); + // isolation is no longer defaulted for hotfix/tweak at init, so build + // configuration is still incomplete here even though tasks.md is checked off. + expect(runState!.currentStep).toBe('hotfix.build.execute'); expect(runState!.iteration).toBe(1); const eventLog = await fs.readFile( path.join(changeDir, '.comet', 'state-events.jsonl'), diff --git a/test/domains/comet-classic/classic-hook-guard.test.ts b/test/domains/comet-classic/classic-hook-guard.test.ts index b3030c7a..3bf91186 100644 --- a/test/domains/comet-classic/classic-hook-guard.test.ts +++ b/test/domains/comet-classic/classic-hook-guard.test.ts @@ -491,13 +491,18 @@ describe('Classic hook guard command', () => { it('selects, reads, and clears the current change through the state launcher', async () => { const dir = await makeProject(); + await initializeGitProject(dir); expect(run(dir, 'state', ['init', 'demo', 'hotfix']).status).toBe(0); + expect(run(dir, 'state', ['set', 'demo', 'isolation', 'current']).status).toBe(0); const selected = run(dir, 'state', ['select', 'demo']); expect(selected.status).toBe(0); expect(selected.stderr).toContain('[SELECTED] current change: demo'); - expect(run(dir, 'state', ['current']).stdout.trim()).toBe('demo'); + const current = run(dir, 'state', ['current']); + expect(current.stdout.trim()).toBe('demo'); + expect(current.stderr).toContain('[CURRENT] isolation: current'); + expect(current.stderr).toContain('branch: main'); expect(run(dir, 'state', ['clear-selection']).status).toBe(0); expect(run(dir, 'state', ['clear-selection']).status).toBe(0); expect(run(dir, 'state', ['current']).status).not.toBe(0); diff --git a/test/domains/comet-classic/classic-resolver.test.ts b/test/domains/comet-classic/classic-resolver.test.ts index 223b222d..bbce064a 100644 --- a/test/domains/comet-classic/classic-resolver.test.ts +++ b/test/domains/comet-classic/classic-resolver.test.ts @@ -197,6 +197,18 @@ const cases: ResolverCase[] = [ evidence: evidence('build.tasks-complete'), expected: 'hotfix.build.complete', }, + { + name: 'hotfix build execution on current branch isolation', + classic: state({ + workflow: 'hotfix', + phase: 'build', + buildMode: 'direct', + tddMode: 'direct', + isolation: 'current', + verifyMode: 'light', + }), + expected: 'hotfix.build.execute', + }, { name: 'tweak verification', classic: state({ workflow: 'tweak', phase: 'verify' }), diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index 147415d5..23da83cc 100644 --- a/test/domains/comet-classic/classic-state.test.ts +++ b/test/domains/comet-classic/classic-state.test.ts @@ -21,6 +21,7 @@ function classicState(): ClassicState { tddMode: 'tdd', reviewMode: null, isolation: 'worktree', + boundBranch: null, verifyMode: 'full', autoTransition: false, baseRef: 'abc123', @@ -30,6 +31,7 @@ function classicState(): ClassicState { verifyFailures: 2, verificationReport: 'docs/verification.md', branchStatus: 'handled', + branchAction: null, createdAt: '2026-06-01', verifiedAt: '2026-06-02', archiveConfirmation: null, @@ -163,6 +165,16 @@ describe('Classic state projection', () => { await expect(readClassicState(changeDir)).rejects.toThrow(`Invalid Classic state: ${field}`); }); + it('accepts isolation=current in persisted state', async () => { + const state = classicState(); + state.isolation = 'current'; + await writeClassicState(changeDir, { classic: state, run: runState() }); + + const projection = await readClassicState(changeDir); + + expect(projection.classic?.isolation).toBe('current'); + }); + it('rejects malformed YAML without replacing the original file', async () => { const malformed = 'workflow: [full\nphase: build\n'; await fs.writeFile(stateFile, malformed); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index c9ed5dce..1463d616 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -229,7 +229,7 @@ describe('comet scripts', () => { }, 20_000); it.each(['hotfix', 'tweak'])( - 'initializes %s in the current workspace without claiming branch isolation', + 'initializes %s without defaulting or claiming any isolation', async (workflow) => { const result = runNode(tmpDir, stateScript, ['init', `${workflow}-current`, workflow]); const yaml = await fs.readFile( @@ -238,7 +238,7 @@ describe('comet scripts', () => { ); expect(result.status).toBe(0); - expect(yaml).toContain('isolation: current'); + expect(yaml).toContain('isolation: null'); }, 20_000, ); @@ -666,6 +666,82 @@ describe('comet scripts', () => { expect(setInvalid.stderr).toContain('Invalid value'); }, 20_000); + it('sets each branch_action value and rejects invalid branch_action values', async () => { + await createChange( + tmpDir, + 'branch-action-set', + [ + 'workflow: full', + 'phase: verify', + 'build_mode: executing-plans', + 'build_pause: null', + 'tdd_mode: tdd', + 'isolation: branch', + 'verify_mode: full', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'branch_status: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + for (const value of ['push', 'keep-local', 'merged-locally', 'pushed-pr']) { + const set = runNode(tmpDir, stateScript, [ + 'set', + 'branch-action-set', + 'branch_action', + value, + ]); + const get = runNode(tmpDir, stateScript, ['get', 'branch-action-set', 'branch_action']); + expect(set.status).toBe(0); + expect(get.stdout.trim()).toBe(value); + } + + const setInvalid = runNode(tmpDir, stateScript, [ + 'set', + 'branch-action-set', + 'branch_action', + 'bogus', + ]); + expect(setInvalid.status).not.toBe(0); + expect(setInvalid.stderr).toContain('Invalid value'); + }, 20_000); + + it('rejects invalid branch_action values during schema validation', async () => { + await createChange( + tmpDir, + 'invalid-branch-action', + [ + 'workflow: full', + 'phase: verify', + 'build_mode: executing-plans', + 'build_pause: null', + 'tdd_mode: tdd', + 'isolation: branch', + 'verify_mode: full', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'branch_status: pending', + 'branch_action: bogus', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, guardScript, ['invalid-branch-action', 'verify']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("branch_action='bogus' is not valid"); + expect(result.stderr).toContain('FATAL: .comet.yaml schema validation failed'); + }, 20_000); + it('validates the language field in .comet.yaml', async () => { await createChange( tmpDir, @@ -2058,15 +2134,330 @@ describe('comet scripts', () => { expect(guard.status).not.toBe(0); expect(guard.stderr).toContain('[FAIL] isolation selected'); expect(guard.stderr).toContain('[FAIL] build_mode selected'); - expect(guard.stderr).toContain('Next: choose a valid workspace mode'); - expect(guard.stderr).toContain( - 'comet state set missing-build-decisions isolation ', - ); + expect(guard.stderr).toContain('Next: ask the user to choose branch, worktree, or current'); expect(guard.stderr).toContain('Next: ask the user to choose an execution mode'); expect(transition.status).not.toBe(0); - expect(transition.stderr).toContain('isolation must be branch or worktree'); + expect(transition.stderr).toContain('isolation must be one of branch, worktree, current'); }, 20_000); + it('allows full workflow build completion with isolation=current', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await createChange( + tmpDir, + 'current-isolation-build', + [ + 'workflow: full', + 'phase: build', + 'build_mode: executing-plans', + 'build_pause: null', + 'subagent_dispatch: null', + 'tdd_mode: tdd', + 'review_mode: standard', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + '- [x] done\n', + ); + await writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ scripts: { build: 'node -e "process.exit(0)"' } }), + ); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + + const guard = runNode(tmpDir, guardScript, ['current-isolation-build', 'build']); + const transition = runNode(tmpDir, stateScript, [ + 'transition', + 'current-isolation-build', + 'build-complete', + ]); + + expect(guard.status).toBe(0); + expect(transition.status).toBe(0); + }, 20_000); + + describe('isolation=current branch binding', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + async function readBoundBranch(name: string): Promise { + const get = runNode(tmpDir, stateScript, ['get', name, 'bound_branch']); + return get.stdout.trim(); + } + + it('captures the current branch when isolation is first set to current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'bind-once', + ['workflow: full', 'phase: build', 'isolation: null', 'archived: false', ''].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'bind-once', 'isolation', 'current']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('bind-once')).toBe('feature-A'); + }); + + it('does not overwrite an existing bound_branch on a repeated set isolation current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'bind-idempotent', + [ + 'workflow: full', + 'phase: build', + 'isolation: current', + 'bound_branch: feature-A', + 'archived: false', + '', + ].join('\n'), + ); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const set = runNode(tmpDir, stateScript, ['set', 'bind-idempotent', 'isolation', 'current']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('bind-idempotent')).toBe('feature-A'); + }); + + it('clears bound_branch when isolation switches away from current', async () => { + await gitInit('feature-A'); + await createChange( + tmpDir, + 'switch-away', + [ + 'workflow: full', + 'phase: build', + 'isolation: current', + 'bound_branch: feature-A', + 'archived: false', + '', + ].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'switch-away', 'isolation', 'branch']); + + expect(set.status).toBe(0); + expect(await readBoundBranch('switch-away')).toBe('null'); + }); + + it('rejects set isolation current while HEAD is detached', async () => { + await gitInit('feature-A'); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + await createChange( + tmpDir, + 'detached-bind', + [ + 'workflow: full', + 'phase: build', + 'isolation: null', + 'bound_branch: null', + 'archived: false', + '', + ].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'detached-bind', 'isolation', 'current']); + + expect(set.status).not.toBe(0); + expect(set.stderr).toContain('HEAD is detached'); + expect(await readBoundBranch('detached-bind')).toBe('null'); + }); + + it('rejects a direct set of the machine-owned bound_branch field', async () => { + await createChange( + tmpDir, + 'direct-bound', + ['workflow: full', 'phase: build', 'isolation: current', 'archived: false', ''].join('\n'), + ); + + const set = runNode(tmpDir, stateScript, ['set', 'direct-bound', 'bound_branch', 'x']); + + expect(set.status).not.toBe(0); + expect(set.stderr).toContain('machine-owned'); + }); + }); + + describe('state check current-isolation drift', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + function currentChangeYaml(bound: string | null): string { + return [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${bound ?? 'null'}`, + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'); + } + + it('blocks the verify entry check after the branch drifts off bound_branch', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'verify-drift', currentChangeYaml('feature-A')); + runNode(tmpDir, stateScript, ['select', 'verify-drift']); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const check = runNode(tmpDir, stateScript, ['check', 'verify-drift', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout + check.stderr).toContain('BLOCKED'); + expect(check.stdout).toContain( + "change 'verify-drift' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }); + + it('still detects drift after the sidecar is deleted and re-selected', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'sidecar-loss', currentChangeYaml('feature-A')); + runNode(tmpDir, stateScript, ['select', 'sidecar-loss']); + await fs.rm(path.join(tmpDir, '.comet'), { recursive: true, force: true }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + runNode(tmpDir, stateScript, ['select', 'sidecar-loss']); + + const check = runNode(tmpDir, stateScript, ['check', 'sidecar-loss', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout).toContain("bound to branch 'feature-A'"); + }); + + it('blocks the check when a bound change is inspected while HEAD is detached', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'detached-check', currentChangeYaml('feature-A')); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + + const check = runNode(tmpDir, stateScript, ['check', 'detached-check', 'verify']); + + expect(check.status).not.toBe(0); + expect(check.stdout).toContain('detached HEAD'); + }); + + it('lazily binds a legacy unbound current-isolation change and passes', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'legacy-unbound', currentChangeYaml(null)); + + const check = runNode(tmpDir, stateScript, ['check', 'legacy-unbound', 'verify']); + const bound = runNode(tmpDir, stateScript, ['get', 'legacy-unbound', 'bound_branch']); + + expect(check.status).toBe(0); + expect(bound.stdout.trim()).toBe('feature-A'); + }); + }); + + describe('state rebind', () => { + async function gitInit(branch: string) { + execFileSync('git', ['init', '-b', branch], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await writeFile(path.join(tmpDir, 'README.md'), '# t\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + } + + function boundYaml(bound: string | null): string { + return [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + `bound_branch: ${bound ?? 'null'}`, + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'); + } + + it('rebinds to the current branch, passes a later check, and records an audit event', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-ok', boundYaml('feature-A')); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-ok']); + const bound = runNode(tmpDir, stateScript, ['get', 'rebind-ok', 'bound_branch']); + const check = runNode(tmpDir, stateScript, ['check', 'rebind-ok', 'verify']); + + expect(rebind.status).toBe(0); + expect(bound.stdout.trim()).toBe('feature-B'); + expect(check.status).toBe(0); + + const log = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'rebind-ok', '.comet', 'state-events.jsonl'), + 'utf8', + ); + const record = JSON.parse(log.trim().split('\n').at(-1)!); + expect(record.event).toBe('rebind'); + expect(record.effects).toContainEqual( + expect.objectContaining({ field: 'boundBranch', from: 'feature-A', to: 'feature-B' }), + ); + }); + + it('refuses to rebind a change that has no bound_branch', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-unbound', boundYaml(null)); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-unbound']); + + expect(rebind.status).not.toBe(0); + expect(rebind.stderr).toContain('not yet bound'); + }); + + it('refuses to rebind while HEAD is detached', async () => { + await gitInit('feature-A'); + await createChange(tmpDir, 'rebind-detached', boundYaml('feature-A')); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['checkout', sha], { cwd: tmpDir, stdio: 'ignore' }); + + const rebind = runNode(tmpDir, stateScript, ['rebind', 'rebind-detached']); + + expect(rebind.status).not.toBe(0); + expect(rebind.stderr).toContain('HEAD is detached'); + }); + }); + it('blocks build completion until tdd_mode is selected for full workflow', async () => { await createChange( tmpDir, @@ -2925,6 +3316,68 @@ describe('comet scripts', () => { ); }, 20_000); + it('guard blocks archive when a current-isolation change drifted off its bound branch', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await createChange( + tmpDir, + 'archive-drift', + [ + 'workflow: full', + 'phase: archive', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pass', + 'verified_at: 2026-07-16', + 'archived: true', + '', + ].join('\n'), + ); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const guard = runNode(tmpDir, guardScript, ['archive-drift', 'archive']); + + expect(guard.status).not.toBe(0); + expect(guard.stderr).toContain('BLOCKED'); + expect(guard.stderr).toContain( + "change 'archive-drift' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); + + it('accepts bound_branch as a known optional field in validation', async () => { + await createChange( + tmpDir, + 'bound-branch-known', + [ + 'workflow: full', + 'phase: build', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const valid = runNode(tmpDir, validateScript, ['bound-branch-known']); + + expect(valid.status).toBe(0); + expect(valid.stderr).toContain('validation PASSED'); + expect(valid.stderr).not.toContain("unknown field 'bound_branch'"); + }); + it('reports accurate archive step counts when syncing and annotating', async () => { const archiveScript = path.join(tmpDir, 'scripts', 'comet-archive.mjs'); const { command, logFile } = await createFakeOpenSpecArchive(tmpDir); @@ -3389,6 +3842,36 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }); + it('allows build-complete for hotfix workflow with isolation worktree', async () => { + await createChange( + tmpDir, + 'hotfix-worktree', + [ + 'workflow: hotfix', + 'phase: build', + 'build_mode: direct', + 'build_pause: null', + 'tdd_mode: direct', + 'isolation: worktree', + 'verify_mode: light', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'archived: false', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, [ + 'transition', + 'hotfix-worktree', + 'build-complete', + ]); + + expect(result.status).toBe(0); + }, 20_000); + it('transitions full workflow from open to design', async () => { await createChange( tmpDir, @@ -5873,5 +6356,46 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }, 20_000); + + it('blocks a repo source write when the current-isolation change drifted off its bound branch', async () => { + execFileSync('git', ['init', '-b', 'feature-A'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir }); + await createChange( + tmpDir, + 'drift-hook', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/drift-hook.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: full', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + await writeFile( + path.join(tmpDir, 'docs', 'superpowers', 'specs', 'drift-hook.md'), + '# Design\n', + ); + expect(runNode(tmpDir, stateScript, ['select', 'drift-hook']).status).toBe(0); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const targetFile = path.join(tmpDir, 'src', 'feature.ts'); + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile)); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('current change selection is stale or invalid'); + expect(result.stderr).toContain( + "change 'drift-hook' is bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); }); }); diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index 2cd041a6..278344b3 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1379,8 +1379,8 @@ describe('skills', () => { '不得用“跳过重复上下文探索”削弱 Superpowers `brainstorming` 的澄清流程', ); expect(zhDesign).not.toContain('跳过重复上下文探索,直接进入设计提问'); - expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch` 或 `worktree`'); - expect(zhBuild).toContain('也不得自行选择执行方式、TDD 模式或代码审查模式'); + expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch`、`worktree` 或 `current`'); + expect(zhBuild).toContain('不得根据推荐规则自行选择执行方式'); expect(zhBuild).toContain('`comet/reference/decision-point.md`'); expect(zhVerify).toContain('前 3 次可修复失败自动回到 build'); expect(zhVerify).toContain( @@ -1488,6 +1488,8 @@ describe('skills', () => { // MEDIUM: hotfix task count alone does not escalate; only qualitative scope signals do. expect(zhHotfix).toContain('任务数量本身不触发 `/comet-build`'); + // MEDIUM: hotfix requires an explicit isolation choice as a user decision point. + expect(zhHotfix).toContain('初始隔离选择(`branch` / `worktree` / `current`)'); // LOW: comet-build "中" level requires user confirmation before brainstorming expect(zhBuild).toContain( @@ -1763,7 +1765,10 @@ describe('skills', () => { 'show the plan summary, pause option, and every executable Step 3 setting together', ); expect(enBuild).toContain( - 'do not choose `branch` or `worktree`, execution method, TDD mode, or review mode from recommendations', + 'must not choose `branch`, `worktree`, or `current` based on recommendation rules', + ); + expect(enBuild).toContain( + 'must not choose the execution method, TDD mode, or code review mode based on recommendation rules', ); expect(enBuild).toContain('`comet/reference/decision-point.md`'); expect(enVerify).toContain( @@ -1883,6 +1888,7 @@ describe('skills', () => { ); expect(enHotfix).toContain('6 quick checks'); expect(enHotfix).toContain('task count alone does not route to `/comet-build`'); + expect(enHotfix).toContain('initial isolation choice (`branch` / `worktree` / `current`)'); expect(enBuild).toContain( "Must use the current platform's available user input/confirmation mechanism to pause and wait for the user to explicitly confirm", ); diff --git a/website b/website index 375fd7d5..34d1ed76 160000 --- a/website +++ b/website @@ -1 +1 @@ -Subproject commit 375fd7d53175872068c570a2fd4f78cb2d0a0c99 +Subproject commit 34d1ed76a82d0389175724be490a02d0ff0835a3