diff --git a/CHANGELOG.md b/CHANGELOG.md index 154414b1..6c40fbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.6] - 2026-07-18 + +### 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. + +### Changed + +- **Current-isolation drift detection**: `isolation: current` now records the branch it was established on in the change state, so switching branches mid-change is reliably detected at every build/verify/archive entry check and by the write guard — including when only a single change is active — and is no longer silently reset by re-selecting the current change. Selecting a change whose bound branch has drifted is refused instead of reported as successful. Legacy changes without a recorded binding bind to the current Git branch on their next select or check, projects that are not Git worktrees are never blocked by branch binding, and establishing `isolation: current` on a detached HEAD is rejected. +- **Branch/worktree drift detection**: `isolation: branch` and `isolation: worktree` now use the same bound-branch safety checks as current-branch isolation, so switching branches inside those workspace modes blocks entry checks and write guards until the user switches back or explicitly rebinds the change. Switching a change between workspace modes re-points the binding to the current branch, while repeating the same mode keeps the existing binding. +- **`comet status`**: Now surfaces the selected isolation mode and bound branch for branch-bound workspace modes in both text and `--json` output. +- **Archive branch handling for `current` isolation**: No longer offers feature-branch-oriented merge/PR/keep choices; instead asks whether to push the current branch or keep it local. +- **Hotfix/tweak workspace isolation**: No longer defaults silently to the current branch; both presets now pause to ask the user to choose between working directly on the current branch, creating a new branch, or creating a worktree. +- **Full workflow current-branch isolation**: Full workflows can now let users explicitly keep working on the current branch instead of forcing a new branch or worktree, while preserving the same bound-branch drift checks used by hotfix and tweak ([#190](https://github.com/rpamis/comet/issues/190)). + +### Fixed + +- **Skill discovery resilience**: Malformed YAML frontmatter in an unrelated local Skill no longer crashes bundle factory guidance or candidate discovery; Comet now skips the broken description and continues scanning. + ## What's Changed [0.4.0-beta.5] - 2026-07-14 ### Changed diff --git a/README-zh.md b/README-zh.md index b8096ae0..c38a2717 100644 --- a/README-zh.md +++ b/README-zh.md @@ -499,7 +499,8 @@ 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 # 隔离方式:current | branch | worktree +bound_branch: null # current/branch/worktree 模式绑定的 Git 分支;切换分支会触发阻塞 verify_mode: null # 验证模式:light | full design_doc: docs/superpowers/specs/.md # 设计文档路径 plan: docs/superpowers/plans/YYYY-MM-DD-feature.md # 实现计划路径 @@ -541,7 +542,8 @@ Comet 通过自动化状态转换确保 agent 执行可靠性: - 检测未知/拼写错误字段 4. **Build 决策强制** — Guard 和状态转换同时拦截跳过关键选择 - - `isolation` 必须是 `branch` 或 `worktree` + - `isolation` 必须是 `current`、`branch` 或 `worktree` + - `isolation: current`、`branch`、`worktree` 都会绑定当前 Git 分支,后续入口检查会拦截意外切换分支 - `build_mode` 必须已选择 - `build_pause: plan-ready` 是 plan 生成后的可恢复暂停点,不是 `build_mode` - full workflow 的 `build_mode: direct` 必须有 `direct_override: true` diff --git a/README.md b/README.md index 2b7beaaf..605a5ef8 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,8 @@ 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: current | branch | worktree +bound_branch: null # Git branch bound by current/branch/worktree isolation; branch drift blocks progress 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 +582,8 @@ 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 `current`, `branch`, or `worktree` + - `isolation: current`, `branch`, and `worktree` bind the current Git branch, and later entry checks block accidental branch drift - `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` diff --git a/app/commands/status.ts b/app/commands/status.ts index f25509df..3096f312 100644 --- a/app/commands/status.ts +++ b/app/commands/status.ts @@ -7,6 +7,7 @@ import { latestCommandCheck, type RecordedCommandCheck, } from '../../domains/comet-classic/classic-command-checks.js'; +import { requiresBranchBinding } from '../../domains/comet-classic/classic-branch-binding.js'; export interface ChangeStatus { name: string; @@ -17,6 +18,7 @@ export interface ChangeStatus { phase: string | null; buildMode: string | null; isolation: string | null; + boundBranch: string | null; verifyMode: string | null; verifyResult: string | null; designDoc: string | null; @@ -73,6 +75,7 @@ async function getActiveChanges(projectPath: string): Promise { phase: null, buildMode: null, isolation: null, + boundBranch: null, verifyMode: null, verifyResult: null, designDoc: null, @@ -100,6 +103,7 @@ async function getActiveChanges(projectPath: string): Promise { phase: 'invalid', buildMode: projection.classic?.buildMode ?? null, isolation: projection.classic?.isolation ?? null, + boundBranch: projection.classic?.boundBranch ?? null, verifyMode: projection.classic?.verifyMode ?? null, verifyResult: projection.classic?.verifyResult ?? 'pending', designDoc: projection.classic?.designDoc ?? null, @@ -133,6 +137,7 @@ async function getActiveChanges(projectPath: string): Promise { phase: diagnostic.phase, buildMode: projection.classic.buildMode, isolation: projection.classic.isolation, + boundBranch: projection.classic.boundBranch, verifyMode: projection.classic.verifyMode, verifyResult: projection.classic.verifyResult, designDoc: projection.classic.designDoc, @@ -162,6 +167,7 @@ async function getActiveChanges(projectPath: string): Promise { phase: diagnostic.phase, buildMode: projection.classic?.buildMode ?? null, isolation: projection.classic?.isolation ?? null, + boundBranch: projection.classic?.boundBranch ?? null, verifyMode: projection.classic?.verifyMode ?? null, verifyResult: projection.classic?.verifyResult ?? 'pending', designDoc: projection.classic?.designDoc ?? null, @@ -185,6 +191,7 @@ async function getActiveChanges(projectPath: string): Promise { phase: 'invalid', buildMode: null, isolation: null, + boundBranch: null, verifyMode: null, verifyResult: 'pending', designDoc: null, @@ -250,6 +257,11 @@ function displayStatus(changes: ChangeStatus[]): void { continue; } console.log(` workflow: ${c.workflow} | build_mode: ${c.buildMode}`); + if (c.isolation) { + const branchSuffix = + requiresBranchBinding(c.isolation) && c.boundBranch ? ` (bound: ${c.boundBranch})` : ''; + console.log(` isolation: ${c.isolation}${branchSuffix}`); + } if (c.currentStep) console.log(` run_step: ${c.currentStep}`); console.log(` runtime_mode: ${c.runtimeMode}`); if (c.runtimeEval) { 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..b4227df3 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -28,6 +28,8 @@ comet state check archive 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 +若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 + ### 1. 归档前最终确认(阻塞点) 入口验证通过后,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认是否立即归档**。不得在用户确认前运行 `comet archive ""`。 @@ -104,15 +106,12 @@ git commit -m "chore: archive " ### 5. 归档提交后的分支处理 -归档提交成功后,**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。该步骤必须位于归档与归档提交之后,确保最终分支/PR 包含 spec 合并和归档元数据。 - -如该技能不可用,停止流程并提示安装或启用;不得把 `branch_status` 标记为完成。技能加载后,按 `comet/reference/decision-point.md` 暂停让用户选择: +归档提交成功后,先读取 `comet state get isolation`,按隔离方式分流: -1. 本地合并到主分支 -2. 推送并创建 PR -3. 保持当前分支稍后处理 +- `isolation !== current`:**立即执行:** 使用 Skill 工具加载 Superpowers `finishing-a-development-branch` 技能。该步骤必须位于归档与归档提交之后,确保最终分支/PR 包含 spec 合并和归档元数据。如该技能不可用,停止流程并提示安装或启用;不得把 `branch_status` 标记为完成。技能加载后,按 `comet/reference/decision-point.md` 暂停让用户选择:本地合并到主分支、推送并创建 PR、保持当前分支稍后处理。 +- `isolation === current`:跳过 Superpowers `finishing-a-development-branch`。按 `comet/reference/decision-point.md` 暂停让用户二选一:推送当前分支,或暂不推送并保留本地状态。 -归档已经完成,因此这里不提供“丢弃工作”选项。只有用户选择的操作成功完成(或明确选择保持分支)后,才运行: +归档已经完成,因此这里不提供“丢弃工作”选项。只有用户选择的操作成功完成、明确选择保持分支,或在 `current` 模式下明确选择暂不推送后,才运行: ```bash comet state set branch_status handled diff --git a/assets/skills-zh/comet-build/SKILL.md b/assets/skills-zh/comet-build/SKILL.md index a7164057..a66162ca 100644 --- a/assets/skills-zh/comet-build/SKILL.md +++ b/assets/skills-zh/comet-build/SKILL.md @@ -23,6 +23,8 @@ comet state check build 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 +若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 + **幂等性**:build 阶段所有操作可安全重复执行。读取 `.comet.yaml` 的 `phase` 字段确认仍在 build 阶段,读取 plan 文件头的 `base-ref`,再按文档顺序解析 tasks.md 的复选框,从第一个未勾选任务继续执行。已提交的任务不得重复提交。 ### 1. 制定计划(Subagent Offload) @@ -116,12 +118,14 @@ comet state set build_pause null | 选项 | 方式 | 说明 | |------|------|------| -| A | 创建分支 | 在当前仓库创建新分支,简单快速 | -| B | 创建 Worktree | 隔离工作区,完全独立,适合并行开发 | +| A | 当前分支直接工作 | 不创建新分支,如实绑定当前 Git 分支 | +| B | 创建分支 | 在当前仓库创建新分支,简单快速 | +| C | 创建 Worktree | 隔离工作区,完全独立,适合并行开发 | **推荐规则**: -- 变更涉及 ≤ 3 个文件 → 推荐 A -- 需要并行开发、当前分支有未提交工作 → 推荐 B +- 用户明确希望沿用当前分支,或当前分支本身就是该 change 的目标分支 → 推荐 A +- 变更涉及 ≤ 3 个文件且当前分支干净 → 推荐 B +- 需要并行开发、当前分支有未提交工作 → 推荐 C **执行方式**: @@ -135,12 +139,12 @@ comet state set build_pause null - 任务数 ≤ 2 且无跨模块依赖 → 推荐 B - 来自 hotfix 路径 → 推荐 B -这些表格是 Step 2 联合决策的一部分,不再单独暂停。先移除能力预检判定为不可执行的选项;在剩余多个合法选项时,不得根据推荐规则自行选择 `branch` 或 `worktree`,也不得自行选择执行方式、TDD 模式或代码审查模式。推荐规则只能用于说明建议,不能替代用户确认。 +这些表格是 Step 2 联合决策的一部分,不再单独暂停。先移除能力预检判定为不可执行的选项;在剩余多个合法选项时,不得根据推荐规则自行选择 `current`、`branch` 或 `worktree`,也不得自行选择执行方式、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` @@ -166,7 +170,7 @@ comet state set isolation 运行 `comet state set review_mode ` -`isolation` 是脚本级硬约束。full workflow 初始化时可以为 `null`,但只允许存在到本步骤之前。若保持 `null`,`build → verify` 的 guard 和 `comet state transition build-complete` 都会失败。 +`isolation` 是脚本级硬约束。full workflow 初始化时可以为 `null`,但只允许存在到本步骤之前。若保持 `null`,`build → verify` 的 guard 和 `comet state transition build-complete` 都会失败。full workflow 允许 `current`、`branch` 或 `worktree`,但 `current` 必须通过用户在 Step 2 显式选择后写入,不得静默默认。 `subagent_dispatch` 是脚本级硬约束。`build_mode: subagent-driven-development` 离开 build 阶段前必须同时满足 `subagent_dispatch: confirmed`,否则 `comet guard build --apply` 和 `comet state transition build-complete` 都会失败。 @@ -185,6 +189,8 @@ comet state set build_mode direct **执行隔离**: +- **current**:不创建新分支或 worktree,直接在当前 Git 分支执行。立即运行 `comet state set isolation current`;该命令会把当前分支写入 `bound_branch`。如果当前是 detached HEAD,必须停止并让用户先切回真实分支,因为没有可审计的绑定分支。 + - **branch**:使用 Step 2 已确认的分支名,不得再次暂停。若旧状态恢复时缺少该次联合决策中的分支名,重新进入 Step 2 的同一个联合决策;不得创建第二个独立分支命名决策点。 分支命名规范: @@ -196,7 +202,7 @@ comet state set build_mode direct 示例:如果 change 名称为 `fix-login-bug`,今天是 2026-06-09,则推荐 `feature/20260609/fix-login-bug` - 分支名由 Step 2 确认后,立即执行 `git checkout -b `,后续工作在新分支上进行。 + 分支名由 Step 2 确认后,立即执行 `git checkout -b `,然后运行 `comet state set isolation branch`,把新分支写入 `bound_branch`。后续工作在新分支上进行。 - **worktree**:必须使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能创建隔离工作区。禁止用普通 shell 命令或原生工具绕过该技能;如该技能不可用,停止流程并提示安装或启用 Superpowers 技能。 @@ -207,7 +213,7 @@ git add docs/superpowers/plans/YYYY-MM-DD-feature.md git commit -m "chore: add implementation plan" ``` -进入最终执行分支或 worktree 后,必须在该实际工作区重新绑定当前 change。branch 切换会使入口绑定失效,新 worktree 也不会继承原工作区的本地选择文件: +进入最终执行分支或 worktree 后,必须在该实际工作区重新绑定当前 change。branch 模式已在切换后通过 `isolation branch` 绑定;worktree 模式必须在新工作区运行 `comet state set isolation worktree`,把 worktree 的当前分支写入 `bound_branch`。新 worktree 不会继承原工作区的本地选择文件,因此还必须选择当前 change: ```bash comet state select @@ -288,7 +294,7 @@ Build 是最长阶段,可能跨越大量任务。为支持上下文压缩后 - tasks.md 全部勾选 - 代码已提交 - 已显式运行项目对应的构建/测试命令并通过(不要只依赖 guard 自动猜测) -- `isolation` 已写为 `branch` 或 `worktree` +- `isolation` 已写为 `current`、`branch` 或 `worktree` - `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..8f6061d2 100644 --- a/assets/skills-zh/comet-hotfix/SKILL.md +++ b/assets/skills-zh/comet-hotfix/SKILL.md @@ -42,7 +42,21 @@ comet state select comet state check open ``` -hotfix 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能把它改为 `branch` 或 `worktree`。随后按指引创建精简版产物: +若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 + +入口工作区隔离是用户决策点,不再把 `current` 当作默认隔离模式写入。按 `comet/reference/decision-point.md` 暂停让用户单选: + +- A. 当前分支直接工作:运行 `comet state set isolation current`,如实绑定当前分支 +- B. 创建分支:先创建并切换到 `hotfix/YYYYMMDD/`,再运行 `comet state set isolation branch` +- C. 创建 worktree:必须先使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能,由该技能创建隔离工作区;进入 worktree 后运行 `comet state set isolation worktree` + +B/C 完成后,必须在实际执行分支或 worktree 中重新运行: + +```bash +comet state select +``` + +随后按指引创建精简版产物: - `proposal.md` — 问题描述 + 根因分析 + 修复目标(无需方案对比) - `design.md` — 修复方案(1 个即可,无需多方案对比) - `tasks.md` — 修复任务清单 @@ -65,7 +79,7 @@ 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。 +使用 hotfix 默认值:`build_mode: direct`、`tdd_mode: direct`、`review_mode: off`。`isolation` 必须沿用 Step 1 中用户已确认的入口工作区隔离方式,不得自行改回 `current`。`direct` 表示不进入完整规划/TDD 编排,不表示可以跳过复现、回归测试或验证。跳过 Superpowers `brainstorming` 和 `writing-plans`;**任务数量本身不触发 `/comet-build`**,任务较多时仍在当前 hotfix 的 tasks.md 中按顺序执行,只有命中后文质变信号或范围 tripwire 才交给用户决定是否升级 full。 继续或开始修改前,按 `comet/reference/dirty-worktree.md` 协议处理未提交改动。若归因后发现修复命中质变信号或文件数 tripwire,按本文件「升级判定」处理。 diff --git a/assets/skills-zh/comet-tweak/SKILL.md b/assets/skills-zh/comet-tweak/SKILL.md index 88db7197..b3039012 100644 --- a/assets/skills-zh/comet-tweak/SKILL.md +++ b/assets/skills-zh/comet-tweak/SKILL.md @@ -50,14 +50,26 @@ comet state init tweak comet state select ``` -tweak 默认 `isolation: current`,表示在当前工作区执行;只有用户实际创建/选择了分支或 worktree 后,才能改为 `branch` 或 `worktree`。 - 初始化后验证状态: ```bash comet state check open ``` +若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 + +入口工作区隔离是用户决策点,不再把 `current` 当作默认隔离模式写入。按 `comet/reference/decision-point.md` 暂停让用户单选: + +- A. 当前分支直接工作:运行 `comet state set isolation current`,如实绑定当前分支 +- B. 创建分支:先创建并切换到 `tweak/YYYYMMDD/`,再运行 `comet state set isolation branch` +- C. 创建 worktree:必须先使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能,由该技能创建隔离工作区;进入 worktree 后运行 `comet state set isolation worktree` + +B/C 完成后,必须在实际执行分支或 worktree 中重新运行: + +```bash +comet state select +``` + 阶段守卫完成 open → build 过渡: ```bash @@ -66,7 +78,7 @@ comet guard open --apply ### 2. OpenSpec apply 构建(tweak 专用预设 build) -使用 tweak 默认值:`build_mode: direct`。跳过 Superpowers `brainstorming` 和 `writing-plans`,改由 OpenSpec 的 apply action 执行当前 change 的 tasks。 +使用 tweak 默认值:`build_mode: direct`。`isolation` 必须沿用 Step 1 中用户已确认的入口工作区隔离方式,不得自行改回 `current`。跳过 Superpowers `brainstorming` 和 `writing-plans`,改由 OpenSpec 的 apply action 执行当前 change 的 tasks。 这条 apply 路径只属于 tweak。完整 `/comet` 或 `workflow: full` 不得套用 tweak 的 `openspec-apply-change` 构建路径;full 仍必须先通过 `/comet-design` 生成 Design Doc,再由 `/comet-build` 通过 Superpowers `writing-plans`、执行方式选择和对应执行技能完成构建。 diff --git a/assets/skills-zh/comet-verify/SKILL.md b/assets/skills-zh/comet-verify/SKILL.md index 3e38c7dc..2d17a21b 100644 --- a/assets/skills-zh/comet-verify/SKILL.md +++ b/assets/skills-zh/comet-verify/SKILL.md @@ -27,6 +27,8 @@ comet state check verify 验证通过后继续 Step 1。验证失败时脚本会输出具体失败原因。 +若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 + **幂等性**:verify 阶段所有检查可安全重复执行。如 `verify_result` 已为 `pass`,说明验证已完成并应进入 archive;`branch_status` 在归档提交和最终分支处理完成前保持 `pending`。如 `verify_result` 为 `pending`,从头开始验证。 ### 1. 改动规模评估 diff --git a/assets/skills-zh/comet/SKILL.md b/assets/skills-zh/comet/SKILL.md index b0bb95ba..2ed1abb9 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` 必须是 `current`、`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..a46712cd 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -20,6 +20,7 @@ tdd_mode: tdd review_mode: standard auto_transition: true isolation: branch +bound_branch: null verify_mode: light verify_result: pending verify_failures: 0 @@ -46,7 +47,8 @@ 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` | `current`、`branch` 或 `worktree`。full 初始化可为 `null`,离开 build 前必须由用户显式选择 `current`、实际创建/选择 `branch`,或实际创建/选择 `worktree`;hotfix/tweak 在入口用户决策点后也可如实使用三种模式,不得在未创建分支时虚构为 `branch` | +| `bound_branch` | 工作区分支绑定记录,可为空。`isolation: current` / `branch` / `worktree` 首次设置或入口检查时记录命令执行目录所在的当前 Git 分支(worktree 模式请在对应工作区内执行 set/check/guard,否则会绑定/比对错误的分支);在不同工作区模式之间切换 `isolation` 会重新绑定到当前分支,重复设置同一模式保持原绑定。后续 `comet state select` / `comet state check` 必须确认绑定分支与当前分支一致;漂移时 `select` 直接拒绝、检查进入 `BLOCKED`,按决策点协议让用户选择切回绑定分支或明确确认后运行 `comet state rebind `。清空 `isolation` 时会清空该字段 | | `verify_mode` | `light` 或 `full`,可为空 | | `auto_transition` | `true` 或 `false`。只控制阶段守卫推进 phase 后是否自动调用下一个 skill;`false` 时由 `comet-state next` 输出 `manual`,暂停下一 skill 调用,但不阻止 phase 字段更新 | | `verify_result` | `pending`、`pass` 或 `fail` | @@ -66,7 +68,7 @@ archived: false ## 状态机硬约束 -- full workflow 的 `build → verify` 前,`isolation` 必须是 `branch` 或 `worktree`;hotfix/tweak 可使用 `current` +- `build → verify` 前,`isolation` 必须是 `current`、`branch` 或 `worktree` - `build → verify` 前,`build_mode` 必须已选择 - `build_mode: subagent-driven-development` 必须同时有 `subagent_dispatch: confirmed` - full workflow 离开 build 阶段前 `tdd_mode` 必须已选择为 `tdd` 或 `direct` diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 0e26601d..18a129e4 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -28,6 +28,8 @@ comet state check archive Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. +If the `select` / `check` output is `BLOCKED` because `bound_branch` does not match the current branch, immediately pause under `comet/reference/decision-point.md` and let the user choose one option: switch back to the bound branch and rerun entry verification, or run `comet state rebind ` after the user explicitly confirms the current branch should take over this change, then rerun entry verification. Do not switch branches or rebind on your own. + ### 1. Final Archive Confirmation (Blocking Point) After entry verification passes, **must follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to confirm whether to archive immediately**. Must not run `comet archive ""` before user confirmation. @@ -104,15 +106,12 @@ 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. - -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: +After the archive commit succeeds, first read `comet state get isolation` and route by isolation: -1. Merge locally into the main branch -2. Push and create a PR -3. Keep the current branch for later +- `isolation !== current`: **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: merge locally into the main branch, push and create a PR, or keep the current branch for later. +- `isolation === current`: skip Superpowers `finishing-a-development-branch`. Pause under `comet/reference/decision-point.md` and let the user choose one option: push the current branch, or do not push yet and keep the local state. -Archive is already complete, so do not offer "discard work". Only after the selected operation succeeds (or the user explicitly keeps the branch), run: +Archive is already complete, so do not offer "discard work". Only after the selected operation succeeds, the user explicitly keeps the branch, or the user explicitly chooses not to push in `current` mode, run: ```bash comet state set branch_status handled diff --git a/assets/skills/comet-build/SKILL.md b/assets/skills/comet-build/SKILL.md index f2065785..fbe6b497 100644 --- a/assets/skills/comet-build/SKILL.md +++ b/assets/skills/comet-build/SKILL.md @@ -23,6 +23,8 @@ comet state check build Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. +If the `select` / `check` output is `BLOCKED` because `bound_branch` does not match the current branch, immediately pause under `comet/reference/decision-point.md` and let the user choose one option: switch back to the bound branch and rerun entry verification, or run `comet state rebind ` after the user explicitly confirms the current branch should take over this change, then rerun entry verification. Do not switch branches or rebind on your own. + **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. ### 1. Create Plan (Subagent Offload) @@ -116,12 +118,14 @@ The plan is on the current branch. These settings are all part of the single Ste | Option | Method | Description | |--------|--------|-------------| -| 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 | +| A | Work on current branch | Do not create a new branch; truthfully bind the current Git branch | +| B | Create branch | Create a new branch in the current repo, simple and fast | +| C | Create Worktree | Isolated workspace, fully independent, suitable for parallel development | **Recommendation rules**: -- Change involves ≤ 3 files → Recommend A -- Need parallel development, current branch has uncommitted work → Recommend B +- User explicitly wants to keep the current branch, or the current branch is already the target branch for this change → Recommend A +- Change involves ≤ 3 files and the current branch is clean → Recommend B +- Need parallel development, current branch has uncommitted work → 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, do not choose `current`, `branch`, or `worktree`, execution method, TDD mode, or review mode from recommendations. 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` @@ -166,7 +170,7 @@ Run `comet state set tdd_mode ` Run `comet state set review_mode ` -`isolation` is a script-enforced hard constraint. Full workflow init may temporarily leave it as `null`, but only before this step. If it remains `null`, both the `build → verify` guard and `comet state transition build-complete` will fail. +`isolation` is a script-enforced hard constraint. Full workflow init may temporarily leave it as `null`, but only before this step. If it remains `null`, both the `build → verify` guard and `comet state transition build-complete` will fail. Full workflow allows `current`, `branch`, or `worktree`, but `current` must be written only after the user explicitly selects it in Step 2; never make it a silent default. `subagent_dispatch` is a script-enforced hard constraint. `build_mode: subagent-driven-development` requires `subagent_dispatch: confirmed` before leaving the build phase, otherwise both `comet guard build --apply` and `comet state transition build-complete` will fail. @@ -185,6 +189,8 @@ Without `direct_override: true`, `build_mode=direct` in full workflow is blocked **Execute isolation**: +- **current**: Do not create a new branch or worktree; execute directly on the current Git branch. Run `comet state set isolation current` immediately; the command writes the current branch to `bound_branch`. If HEAD is detached, stop and ask the user to check out a real branch first, because there is no auditable branch binding. + - **branch**: Use the branch name already confirmed in Step 2; do not pause again. If legacy recovery no longer has the branch name from that joint decision, re-enter the same Step 2 decision instead of creating a separate branch-naming decision. Branch naming convention: @@ -196,7 +202,7 @@ Without `direct_override: true`, `build_mode=direct` in full workflow is blocked Example: if change name is `fix-login-bug` and today is 2026-06-09, recommend `feature/20260609/fix-login-bug` - Immediately after Step 2 confirms the branch name, run `git checkout -b ` and continue on the new branch. + Immediately after Step 2 confirms the branch name, run `git checkout -b `, then run `comet state set isolation branch` to write the new branch to `bound_branch`. Continue on the new branch. - **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. @@ -207,7 +213,7 @@ git add docs/superpowers/plans/YYYY-MM-DD-feature.md git commit -m "chore: add implementation plan" ``` -After entering the final execution branch or worktree, bind the current change again inside that actual workspace. A branch switch invalidates the entry binding, and a new worktree does not inherit the original workspace-local selection file: +After entering the final execution branch or worktree, bind the current change again inside that actual workspace. Branch mode is bound after checkout with `isolation branch`; worktree mode must run `comet state set isolation worktree` inside the new workspace to write that worktree's current branch to `bound_branch`. A new worktree does not inherit the original workspace-local selection file, so select the current change too: ```bash comet state select @@ -288,7 +294,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 `current`, `branch`, or `worktree` - `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..7b2c3853 100644 --- a/assets/skills/comet-hotfix/SKILL.md +++ b/assets/skills/comet-hotfix/SKILL.md @@ -42,7 +42,21 @@ 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: +If the `select` / `check` output is `BLOCKED` because `bound_branch` does not match the current branch, immediately pause under `comet/reference/decision-point.md` and let the user choose one option: switch back to the bound branch and rerun entry verification, or run `comet state rebind ` after the user explicitly confirms the current branch should take over this change, then rerun entry verification. Do not switch branches or rebind on your own. + +Entry workspace isolation is a user decision point; do not use `current` as the default isolation mode. Pause under `comet/reference/decision-point.md` and let the user choose one option: + +- A. Work directly on the current branch: run `comet state set isolation current` to truthfully bind the current branch +- B. Create a branch: create and switch to `hotfix/YYYYMMDD/`, then run `comet state set isolation branch` +- C. Create a worktree: first use the Skill tool to load Superpowers `using-git-worktrees`; let that skill create the isolated workspace, then run `comet state set isolation worktree` inside the worktree + +After B/C, rerun this in the actual execution branch or worktree: + +```bash +comet state select +``` + +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 +79,7 @@ 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. +Use hotfix defaults: `build_mode: direct`, `tdd_mode: direct`, and `review_mode: off`. `isolation` must keep the entry workspace isolation the user confirmed in Step 1; do not change it back to `current` on your own. 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". diff --git a/assets/skills/comet-tweak/SKILL.md b/assets/skills/comet-tweak/SKILL.md index 1bcb1d3f..aa9cc7c5 100644 --- a/assets/skills/comet-tweak/SKILL.md +++ b/assets/skills/comet-tweak/SKILL.md @@ -50,14 +50,26 @@ comet state init tweak comet state select ``` -Tweak 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. - Verify initialized state: ```bash comet state check open ``` +If the `select` / `check` output is `BLOCKED` because `bound_branch` does not match the current branch, immediately pause under `comet/reference/decision-point.md` and let the user choose one option: switch back to the bound branch and rerun entry verification, or run `comet state rebind ` after the user explicitly confirms the current branch should take over this change, then rerun entry verification. Do not switch branches or rebind on your own. + +Entry workspace isolation is a user decision point; do not use `current` as the default isolation mode. Pause under `comet/reference/decision-point.md` and let the user choose one option: + +- A. Work directly on the current branch: run `comet state set isolation current` to truthfully bind the current branch +- B. Create a branch: create and switch to `tweak/YYYYMMDD/`, then run `comet state set isolation branch` +- C. Create a worktree: first use the Skill tool to load Superpowers `using-git-worktrees`; let that skill create the isolated workspace, then run `comet state set isolation worktree` inside the worktree + +After B/C, rerun this in the actual execution branch or worktree: + +```bash +comet state select +``` + Run phase guard to transition open → build: ```bash @@ -66,7 +78,7 @@ comet guard open --apply ### 2. OpenSpec Apply Build (tweak-only preset build) -Use tweak defaults: `build_mode: direct`. Skip Superpowers `brainstorming` and `writing-plans`, and let OpenSpec's apply action execute the current change's tasks. +Use tweak defaults: `build_mode: direct`. `isolation` must keep the entry workspace isolation the user confirmed in Step 1; do not change it back to `current` on your own. Skip Superpowers `brainstorming` and `writing-plans`, and let OpenSpec's apply action execute the current change's tasks. This apply path belongs only to tweak. Full `/comet` or `workflow: full` must not use tweak's `openspec-apply-change` build path; full must still generate a Design Doc through `/comet-design`, then let `/comet-build` use Superpowers `writing-plans`, execution-method selection, and the corresponding execution skill to build. diff --git a/assets/skills/comet-verify/SKILL.md b/assets/skills/comet-verify/SKILL.md index c97c5d53..f036d943 100644 --- a/assets/skills/comet-verify/SKILL.md +++ b/assets/skills/comet-verify/SKILL.md @@ -27,6 +27,8 @@ comet state check verify Proceed to Step 1 after verification passes. The script outputs specific failure reasons when verification fails. +If the `select` / `check` output is `BLOCKED` because `bound_branch` does not match the current branch, immediately pause under `comet/reference/decision-point.md` and let the user choose one option: switch back to the bound branch and rerun entry verification, or run `comet state rebind ` after the user explicitly confirms the current branch should take over this change, then rerun entry verification. Do not switch branches or rebind on your own. + **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. ### 1. Scale Assessment diff --git a/assets/skills/comet/SKILL.md b/assets/skills/comet/SKILL.md index 6e5433c4..144c9e46 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 `current`, `branch`, or `worktree`; all three workspace modes must come from explicit user selection and bind the current branch - 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..b57e2f86 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -21,6 +21,7 @@ tdd_mode: tdd review_mode: standard auto_transition: true isolation: branch +bound_branch: null verify_mode: light verify_result: pending verify_failures: 0 @@ -47,7 +48,8 @@ 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` | `current`, `branch`, or `worktree`. Full init may be `null`, but before leaving build the user must explicitly select `current`, create/select a real `branch`, or create/select a real `worktree`; hotfix/tweak may also truthfully use all three modes after the entry user decision point, and must not claim branch isolation before creating one | +| `bound_branch` | Workspace branch binding record; may be empty. `isolation: current` / `branch` / `worktree` records the current Git branch of the directory the command runs in on first setting or entry check (for worktree mode, run set/check/guard inside that worktree, or the wrong branch gets bound/compared); switching `isolation` between workspace modes re-binds to the current branch, while repeating the same mode keeps the existing binding. Later `comet state select` / `comet state check` must confirm the bound branch still matches the current branch. On drift, `select` refuses and checks return `BLOCKED`; follow the decision-point protocol so the user chooses whether to switch back to the bound branch or explicitly confirm and run `comet state rebind `. Clearing `isolation` clears this field | | `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` | @@ -67,7 +69,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 `current`, `branch`, or `worktree` - 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/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index 32dbd252..0475ce2b 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -126,17 +126,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 +147,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 +174,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 +195,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 +228,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 +854,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 +876,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 +906,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 +924,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 +939,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 +958,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 +969,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 +3485,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 +3562,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 +3584,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 +3598,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 +3618,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 +5584,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 +5595,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 +5622,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 +6927,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 +7062,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 +7257,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 +7283,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 +7319,7 @@ var require_public_api = __commonJS({ } exports.parse = parse2; exports.parseAllDocuments = parseAllDocuments; - exports.parseDocument = parseDocument7; + exports.parseDocument = parseDocument8; exports.stringify = stringify; } }); @@ -7824,6 +7824,7 @@ var CLASSIC_WIRE_KEYS = [ "tdd_mode", "review_mode", "isolation", + "bound_branch", "verify_mode", "auto_transition", "base_ref", @@ -7942,6 +7943,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"), @@ -8007,6 +8009,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, @@ -9493,6 +9496,7 @@ function applyClassicTransition(current, event, options = {}) { setField(classic, effects, "tddMode", null); setField(classic, effects, "reviewMode", null); setField(classic, effects, "isolation", null); + setField(classic, effects, "boundBranch", null); setField(classic, effects, "verifyMode", null); setField(classic, effects, "directOverride", null); } else if (event === "archive-confirm") { @@ -9907,11 +9911,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"; @@ -10213,6 +10217,12 @@ var classicValidateCommand = async (args) => { fail3(`${field2}='${value}' is not valid. Expected: ${values.join(" ")}`); } } + if (Object.prototype.hasOwnProperty.call(record, "bound_branch")) { + const value = record.bound_branch; + if (value !== null && typeof value !== "string") { + fail3(`bound_branch='${text(value)}' is not a string or null`); + } + } if (Object.prototype.hasOwnProperty.call(record, "verify_failures")) { const value = record.verify_failures; if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { @@ -10303,6 +10313,96 @@ async function readClassicConfigValue(field2, options = {}) { return null; } +// domains/comet-classic/classic-branch-binding.ts +var import_yaml5 = __toESM(require_dist(), 1); +import { execFileSync } from "child_process"; +import { randomUUID as randomUUID6 } from "crypto"; +import { promises as fs15 } from "fs"; +import path16 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 isGitWorkTree(cwd) { + try { + return execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim() === "true"; + } catch { + return false; + } +} +var BOUND_BRANCH_ISOLATIONS = ["current", "branch", "worktree"]; +function requiresBranchBinding(isolation) { + return BOUND_BRANCH_ISOLATIONS.includes(isolation); +} +function evaluateBranchBinding(input) { + if (!requiresBranchBinding(input.isolation)) return { status: "not-applicable" }; + if (input.boundBranch === null && input.currentBranch === null && input.gitWorkTree === false) { + 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 resolveBranchBinding(changeDir, options) { + const file = path16.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml5.parseDocument)(await fs15.readFile(file, "utf8"), { uniqueKeys: false }); + if (document.errors.length > 0) { + throw new Error(`Invalid .comet.yaml: ${document.errors[0].message}`); + } + const record = document.toJS() ?? {}; + const isolation = typeof record.isolation === "string" ? record.isolation : null; + const boundBranch = typeof record.bound_branch === "string" && record.bound_branch !== "" ? record.bound_branch : null; + const bindingRequired = requiresBranchBinding(isolation); + const currentBranch = liveGitBranch(options.cwd); + const gitWorkTree = bindingRequired && boundBranch === null && currentBranch === null ? isGitWorkTree(options.cwd) : true; + const verdict = evaluateBranchBinding({ isolation, boundBranch, currentBranch, gitWorkTree }); + if (verdict.status === "needs-heal" && options.heal) { + await healBoundBranch(changeDir, verdict.branch); + return { status: "healed", branch: verdict.branch, bindingRequired, currentBranch }; + } + return { ...verdict, bindingRequired, currentBranch }; +} +async function healBoundBranch(changeDir, branch) { + const file = path16.join(changeDir, ".comet.yaml"); + const document = (0, import_yaml5.parseDocument)(await fs15.readFile(file, "utf8"), { uniqueKeys: false }); + document.set("bound_branch", branch); + const temporary = `${file}.${randomUUID6()}.tmp`; + try { + await fs15.writeFile(temporary, document.toString(), "utf8"); + await fs15.rename(temporary, file); + } catch (error) { + await fs15.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 a branch-bound workspace mode but has no bound branch and HEAD is detached; checkout a branch first before continuing.`; +} + // domains/comet-classic/classic-guard.ts var GREEN3 = "\x1B[32m"; var RED3 = "\x1B[31m"; @@ -10367,7 +10467,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 +10476,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 +10490,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 +10532,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 +10556,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 +10576,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 +10646,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 +10729,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 +10753,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 +10766,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( @@ -10677,14 +10777,36 @@ Next: check off corresponding completed plan tasks, then commit the plan update. } return pass(); } +async function boundBranchMatches(changeDir, change) { + let outcome; + try { + outcome = await resolveBranchBinding(changeDir, { heal: true, cwd: process.cwd() }); + } catch (error) { + throw new GuardFailure(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + } + switch (outcome.status) { + case "drift": + return fail(driftBlockedMessage(change, outcome.boundBranch, outcome.currentBranch)); + case "unbound-detached": + return fail(unboundDetachedMessage(change)); + case "healed": + return pass(`bound_branch lazily set to ${outcome.branch}`); + case "needs-heal": + case "ok": + case "not-applicable": + return pass(); + default: { + const exhaustive = outcome; + throw new Error(`unhandled branch binding status: ${JSON.stringify(exhaustive)}`); + } + } +} async function isolationSelected(changeDir, change) { const isolation = await readField(changeDir, "isolation"); - 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" ? "" : ""; + if (isolation === "current" || isolation === "branch" || isolation === "worktree") return pass(); + const allowedValues = ""; return fail( - `isolation must be ${workflow === "full" ? "branch or worktree" : "current, branch, or worktree"}, got '${isolation || "null"}' + `isolation must be 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}` ); @@ -10758,7 +10880,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 +10945,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 +10972,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 +11009,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 +11034,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 +11050,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,6 +11121,7 @@ async function guardDesignChecks(output, changeDir, change) { } async function guardBuildChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches workspace mode", () => boundBranchMatches(changeDir, change)), check("isolation selected", () => isolationSelected(changeDir, change)), check("build_mode selected", () => buildModeSelected(changeDir, change)), check("build_mode allowed for workflow", () => buildModeAllowedForWorkflow(changeDir)), @@ -11009,11 +11132,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 +11153,7 @@ async function guardBuildChecks(output, changeDir, change, run) { } async function guardVerifyChecks(output, changeDir, change, run) { return runChecks(output, [ + check("bound branch matches workspace mode", () => 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 +11172,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 workspace mode", () => 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( @@ -11066,9 +11191,10 @@ async function guardArchiveChecks(output, changeDir) { ) ]); } -async function applyStateUpdate(output, change, changeDir, phase, context) { +async function applyStateUpdate(output, change, changeDir, phase) { const event = CLASSIC_GUARD_TRANSITION_EVENT[phase]; if (!event) return; + const context = await ensureClassicRuntimeRun(changeDir); const result5 = applyClassicTransition(context.classic, event); await transitionClassicRuntimeRun(changeDir, result5.classic, context.run, { event, @@ -11122,7 +11248,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")); @@ -11131,7 +11257,7 @@ Valid phases: open, design, build, verify, archive` output.stderr.push(""); output.stderr.push(green2("ALL CHECKS PASSED — ready for next phase")); if (flag === "--apply") { - await applyStateUpdate(output, change, changeDir, phase, runContext); + await applyStateUpdate(output, change, changeDir, phase); } return output.toResult(0); } catch (error) { @@ -11144,10 +11270,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 +11307,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 +11316,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 +11345,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 +11393,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 +11418,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 +11441,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 +11481,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 +11494,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 +11503,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 +11522,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 +11557,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 +11680,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 +11688,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 +11745,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( @@ -11690,30 +11803,40 @@ function parseSelection(source) { throw new Error("current change selection change must be a string"); } assertOpenSpecChangeName(record.change); - if (record.branch !== null && typeof record.branch !== "string") { + if (record.branch !== void 0 && 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 + branch: record.branch ?? null }; } async function selectCurrentChange(projectRoot2, changeName) { await validateActiveChange(projectRoot2, changeName); + const outcome = await resolveBranchBinding(changeDirectory(projectRoot2, changeName), { + heal: true, + cwd: projectRoot2 + }); + if (outcome.status === "drift") { + throw new Error(driftStaleReason(changeName, outcome.boundBranch, outcome.currentBranch)); + } + if (outcome.status === "unbound-detached") { + throw new Error(unboundDetachedMessage(changeName)); + } const selection = { version: 1, change: changeName, - branch: currentBranch(projectRoot2) + branch: outcome.currentBranch }; 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 +11844,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 { @@ -11739,17 +11862,32 @@ async function resolveCurrentChange(projectRoot2) { reason: error instanceof Error ? error.message : String(error) }; } - const branch = currentBranch(projectRoot2); - if (selection.branch !== null && branch !== selection.branch) { + const outcome = await resolveBranchBinding(changeDirectory(projectRoot2, selection.change), { + heal: false, + cwd: projectRoot2 + }); + if (outcome.status === "drift") { + return { + status: "stale", + reason: driftStaleReason(selection.change, outcome.boundBranch, outcome.currentBranch) + }; + } + if (outcome.status === "unbound-detached") { + return { status: "stale", reason: unboundDetachedMessage(selection.change) }; + } + if (outcome.status === "ok") { + return { status: "selected", selection }; + } + if (selection.branch !== null && outcome.currentBranch !== selection.branch) { return { status: "stale", - reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${branch ?? "detached HEAD"}'` + reason: `current change '${selection.change}' was selected on branch '${selection.branch}', current branch is '${outcome.currentBranch ?? "detached HEAD"}'` }; } return { status: "selected", selection }; } 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 +11919,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 +11994,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 +12050,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", @@ -11978,7 +12116,30 @@ async function repoSourceGoverningChange(projectRoot2, relativePath2) { ) }; } - if (active.length === 1) return active[0]; + if (active.length === 1) { + const sole = active[0]; + if (sole.changeDir !== null) { + const outcome = await resolveBranchBinding(sole.changeDir, { + heal: false, + cwd: projectRoot2 + }); + const name = governingChangeName(sole) ?? "unknown"; + if (outcome.status === "drift") { + return { + blockedResult: blockedStaleSelection( + relativePath2, + driftStaleReason(name, outcome.boundBranch, outcome.currentBranch) + ) + }; + } + if (outcome.status === "unbound-detached") { + return { + blockedResult: blockedStaleSelection(relativePath2, unboundDetachedMessage(name)) + }; + } + } + return sole; + } return { blockedResult: blockedMultipleChanges( relativePath2, @@ -11992,8 +12153,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 +12762,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 +12806,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 +12876,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 +13188,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 +13207,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)) @@ -13144,7 +13306,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 +13315,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 +13327,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; } } @@ -13255,6 +13417,7 @@ function sparseClassicState(record) { ["current", "branch", "worktree"], null ), + boundBranch: nullableRecordString(record, "bound_branch"), verifyMode: enumRecordValue(record, "verify_mode", ["light", "full"], null), autoTransition: nullableRecordBoolean(record, "auto_transition"), baseRef: nullableRecordString(record, "base_ref"), @@ -13327,7 +13490,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 +13508,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); @@ -13380,7 +13543,35 @@ async function setField2(output, name, field2, value, options = {}) { validateSetValue(field2, value); const { file, directory } = await stateFile(name); const document = await readDocument2(file); + const previousRecord = document.toJS() ?? {}; document.set(field2, parsedValue(field2, value)); + if (field2 === "isolation") { + if (requiresBranchBinding(value)) { + const previousIsolation = typeof previousRecord.isolation === "string" ? previousRecord.isolation : null; + const existing = previousRecord.bound_branch; + const alreadyBound = typeof existing === "string" && existing !== ""; + if (!alreadyBound || previousIsolation !== value) { + const currentBranch = liveGitBranch(process.cwd()); + const verdict = evaluateBranchBinding({ + isolation: value, + boundBranch: null, + currentBranch, + gitWorkTree: currentBranch === null ? isGitWorkTree(process.cwd()) : true + }); + if (verdict.status === "needs-heal") { + document.set("bound_branch", verdict.branch); + } else if (verdict.status === "unbound-detached") { + fail2( + `ERROR: cannot bind isolation=${value} while HEAD is detached; checkout a branch first` + ); + } else { + document.set("bound_branch", null); + } + } + } else { + document.set("bound_branch", null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS(), run); if (projection.run) { @@ -13430,10 +13621,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 +13634,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 +13666,10 @@ 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"]; + const allowedIsolation = ["current", "branch", "worktree"]; if (!allowedIsolation.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 current, branch, or worktree, got '${isolation || "null"}'` ); } if (!["subagent-driven-development", "executing-plans", "direct"].includes(buildMode)) { @@ -13511,13 +13702,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 +13716,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 +13738,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 +13785,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 +13849,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 +13889,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 +13916,29 @@ async function check2(output, name, phase) { const archived = await readField3(name, "archived"); (archived !== "true" ? pass2 : reject)(`archived=${archived} (expected: not true)`); } + const binding = await resolveBranchBinding(directory, { heal: true, cwd: process.cwd() }); + if (binding.bindingRequired) { + switch (binding.status) { + case "drift": + reject(driftBlockedMessage(name, binding.boundBranch, binding.currentBranch)); + break; + case "unbound-detached": + reject(unboundDetachedMessage(name)); + break; + case "healed": + pass2(`bound_branch lazily set to ${binding.branch}`); + break; + case "needs-heal": + case "ok": + case "not-applicable": + pass2("bound_branch matches current branch"); + break; + default: { + const exhaustive = binding; + throw new Error(`unhandled branch binding status: ${JSON.stringify(exhaustive)}`); + } + } + } output.stdout.push(""); if (blocked2) { output.stderr.push(red4("BLOCKED — fix failing checks before proceeding")); @@ -13734,7 +13948,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 +13957,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 +13970,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 +13984,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 +14018,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 +14027,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; } @@ -13951,19 +14165,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"); @@ -14043,15 +14257,42 @@ async function selectChange(output, name) { validateChangeName4(name); try { const selection = await selectCurrentChange(process.cwd(), name); + const boundBranch = await readField3(name, "bound_branch"); + const bound = boundBranch && boundBranch !== "null" ? boundBranch : null; output.stderr.push( - green4( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ""}` - ) + green4(`[SELECTED] current change: ${selection.change}${bound ? ` (branch: ${bound})` : ""}`) ); } catch (error) { fail2(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } } +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 ' 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 currentChange(output) { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === "selected") { @@ -14105,6 +14346,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/docs/superpowers/plans/2026-07-18-current-branch-isolation.md b/docs/superpowers/plans/2026-07-18-current-branch-isolation.md new file mode 100644 index 00000000..c3c53ab7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-current-branch-isolation.md @@ -0,0 +1,618 @@ +# current 隔离模式:实现计划 + +- 关联 spec:`docs/superpowers/specs/2026-07-18-current-branch-isolation-design.md`(本计划的每个 Task 都标注对应的 spec 章节,改动前请先读该章节的场景走查) +- 关联 issue:#190 +- 基线:master `5e97d19`,本计划所有行号均已对照该基线核实 + +**范围说明**:spec 里的"问题 2"(hotfix/tweak worktree 限制)和"问题 4"(ESLint 转义符)核实后确认在 master 上不存在,不需要任何改动,本计划不包含对应 Task。"问题 3" 的设计已从"新增 `branch_action` 字段"改为"archive Step 5 按 isolation 分流、不新增字段",范围比最初设想的小。"缺口 C"(hotfix/tweak 的 isolation 选择)已从"保留默认值、防止措辞冲突"改为"彻底移除默认值,改为主动询问三选一"(spec §5.7),对应 Task 4、Task 7b、Task 9。 + +## Global Constraints + +- **Runtime rebuild is mandatory**:任何改动 `domains/comet-classic/*.ts` 的 Task,必须在改完后跑 `pnpm build` 重新生成 `assets/skills/comet/scripts/comet-runtime.mjs`,否则 `comet-scripts.test.ts`/`classic-runtime.test.ts` 的新鲜度检查会失败。仅改 SKILL.md 或 `app/commands/status.ts` 的 Task 不需要这一步。 +- **`.comet.yaml` 字段三处同步**:新字段必须同时进 `classic-state.ts`(interface + wire key + 序列化/反序列化)、`classic-state-command.ts`(`sparseClassicState`,及 `MACHINE_OWNED_FIELDS`/`FIELD_ENUMS` 视字段性质而定)、`test/domains/comet-classic/comet-scripts.test.ts`(yaml 字符串)。`classic-validate-command.ts` 的 `KNOWN_KEYS` 自动派生自 `CLASSIC_WIRE_KEYS`(`classic-validate-command.ts:45-50`),新增自由字符串字段不需要改它;只有需要枚举校验时才要同步 `ENUMS`(`classic-validate-command.ts:24-44`)。 +- **不 bump `classic_migration`**:`bound_branch` 不进 `REQUIRED_CLASSIC_KEYS`(`classic-state.ts:101-112`),`CLASSIC_MIGRATION_VERSION` 保持 `1`;`migrationVersion()` 是严格相等校验,bump 会让所有在跑的 change 在下次读取时报错。 +- **detached HEAD 禁止建立绑定**:首次绑定与 `rebind` 都必须拒绝 detached HEAD;已绑定的 change 检测到 detached HEAD 必须判 `drift`(BLOCKED),不能跳过。 +- **双语顺序**:SKILL.md/规则文档改动,中文定稿并经用户确认后,必须在同一轮提交前完成英文同步,不允许中英不同步的中间状态进入 review(spec §5.7)。 +- **Changelog**:英文书写,只写用户可见行为,版本从 `package.json` 当前的 `0.4.0-beta.5` 升到 `0.4.0-beta.6`;同一开发周期内产生的多个用户可见变更追加到同一个版本条目下,不要拆成多个版本号。 +- **Commit 规范**:`: `(feat/fix/docs/chore/refactor/test/build/ci/perf)。 +- **测试命令**:`npx vitest run test/domains/comet-classic/comet-scripts.test.ts` 覆盖 Classic 脚本契约;`npx vitest run` 全量;`pnpm format:check && pnpm lint && pnpm build` 是提交前三件套。 + +--- + +## Task 1:`bound_branch` 字段(数据模型) + +对应 spec §5.3、§5.6。 + +**Files**: +- Modify: `domains/comet-classic/classic-state.ts` +- Modify: `domains/comet-classic/classic-state-command.ts`(`sparseClassicState`) +- Test: `test/domains/comet-classic/classic-state.test.ts`(round-trip) +- Test: `test/domains/comet-classic/comet-scripts.test.ts`(validate 接受新字段) + +**Interfaces 产出**:`ClassicState.boundBranch: string | null`(wire key `bound_branch`),后续所有 Task 都依赖这个字段存在且可读写。 + +- [x] Step 1:在 `test/domains/comet-classic/classic-state.test.ts` 的 round-trip fixture 里,紧跟 `isolation` 之后加一行 `boundBranch: null,`。跑 `npx vitest run test/domains/comet-classic/classic-state.test.ts -t "round-trips"` 确认 FAIL(缺字段)。 +- [x] Step 2:`classic-state.ts:35` 之后(`isolation` 字段声明后)加: + ```ts + boundBranch: string | null; + ``` +- [x] Step 3:`CLASSIC_WIRE_KEYS`(`classic-state.ts:72` `'isolation',` 之后)加 `'bound_branch',`。 +- [x] Step 4:`classicStateFromDocument`(`classic-state.ts:213` `isolation:` 之后)加: + ```ts + boundBranch: nullableString(doc, 'bound_branch'), + ``` +- [x] Step 5:`classicStateToDocument`(`classic-state.ts:302` `isolation: state.isolation,` 之后)加: + ```ts + bound_branch: state.boundBranch, + ``` +- [x] Step 6:`classic-state-command.ts:259` `sparseClassicState` 里,对应 `isolation` 的取值逻辑之后加 `boundBranch` 的同款取值(用该函数里已有的可空字符串 helper,风格与 `isolation`/`branch_status` 一致)。 +- [x] Step 7:重跑 Step 1 的测试确认 PASS。`pnpm build`。 +- [x] Step 8:在 `comet-scripts.test.ts` 里新增一个 CLI 测试:`.comet.yaml` 含 `bound_branch: feature-A` 时 `comet-yaml-validate.mjs` 校验通过,stderr 不含 `unknown field 'bound_branch'`。跑通过。 +- [x] Step 9:提交:`git add domains/comet-classic/classic-state.ts domains/comet-classic/classic-state-command.ts test/domains/comet-classic/classic-state.test.ts test/domains/comet-classic/comet-scripts.test.ts assets/skills/comet/scripts/comet-runtime.mjs && git commit -m "feat: add bound_branch field to classic state model"` + +--- + +## Task 2:共享分支绑定判定模块 + +对应 spec §5.3、§5.4、§5.6。 + +**Files**: +- Create: `domains/comet-classic/classic-branch-binding.ts` +- Test: `test/domains/comet-classic/classic-branch-binding.test.ts` + +**Interfaces 产出**:`liveGitBranch`、`evaluateBranchBinding`、`healBoundBranch`、`driftBlockedMessage`、`driftStaleReason`、`unboundDetachedMessage`——Task 3/4/5/6/7 全部消费这一个模块,不允许各自重复实现判定逻辑(这是 spec §5.2 目标"三处漂移检测口径一致"的落地方式)。 + +- [x] Step 1:写 `test/domains/comet-classic/classic-branch-binding.test.ts`,覆盖 `evaluateBranchBinding` 的五种输入组合: + ```ts + 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'", + ); + }); + }); + ``` + 跑测试确认 FAIL(模块不存在)。 +- [x] Step 2:实现 `domains/comet-classic/classic-branch-binding.ts`: + ```ts + 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.`; + } + ``` +- [x] Step 3:重跑测试确认 PASS。`pnpm build`(模块暂无消费方,但确认 esbuild 能正确打包新文件)。 +- [x] Step 4:提交:`git commit -m "feat: add shared branch-binding verdict module"` + +--- + +## Task 3:sidecar 瘦身,`resolveCurrentChange` 改走 `bound_branch` + +对应 spec §5.1、§5.4。 + +**Files**: +- Modify: `domains/comet-classic/classic-current-change.ts`(当前 155 行,全文见下方 diff 描述) +- Modify: `domains/comet-classic/classic-state-command.ts`(`selectChange` 第 1234 行、`currentChange` 第 1248 行的输出) +- Test: `test/domains/comet-classic/classic-current-change.test.ts` +- Test: `test/domains/comet-classic/comet-scripts.test.ts`(hook-guard 漂移测试) + +**现状确认**(master 实际代码,`classic-current-change.ts` 全文 155 行):`CurrentChangeSelection` 含 `branch: string | null`;`currentBranch()`(8-34 行)是私有的活跃分支读取函数;`selectCurrentChange()`(97-118 行)**每次调用都无条件写入 `branch: currentBranch(projectRoot)`**;`resolveCurrentChange()`(120-151 行)第 144 行的比较是 `if (selection.branch !== null && branch !== selection.branch)`——`selection.branch` 为 `null` 时整个条件短路跳过,这正是 spec §5.1 描述的 bug 的确切代码位置。 + +- [x] Step 1:更新 `test/domains/comet-classic/classic-current-change.test.ts`: + - 把"atomically selects..."测试改为断言 `selectCurrentChange` 返回 `{ version: 1, change: 'change-a' }`(不含 `branch`)。 + - 把"marks a selection stale after the branch changes"测试改为:change 是 `isolation: current` 且 `.comet.yaml` 里 `bound_branch: main`,`selectCurrentChange` 后切到 `other` 分支,`resolveCurrentChange` 必须返回 `{ status: 'stale', reason: "change 'change-a' is bound to branch 'main', but current branch is 'other'" }`。 + - 新增一个测试:`isolation: branch`(非 current)的 change,切换分支后 `resolveCurrentChange` 仍然 `{ status: 'selected' }`(证明只有 `current` 模式受漂移检测约束,`branch`/`worktree` 模式行为不变)。 + 跑测试确认 FAIL。 +- [x] Step 2:改写 `classic-current-change.ts`: + - imports 里去掉 `execFileSync`(不再需要私有 `currentBranch`),加入: + ```ts + import { + driftStaleReason, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, + } from './classic-branch-binding.js'; + import { readClassicState } from './classic-store.js'; + ``` + - `CurrentChangeSelection` 去掉 `branch` 字段。 + - 删除私有函数 `currentBranch()`。 + - `parseSelection` 去掉对 `record.branch` 的解析和校验。 + - `selectCurrentChange` 不再写 `branch`,只写 `{ version: 1, change: changeName }`。 + - `resolveCurrentChange` 末尾(原第 143-150 行)替换为: + ```ts + const projection = await readClassicState(changeDirectory(projectRoot, selection.change), { migrate: false }); + const branch = liveGitBranch(projectRoot); + const verdict = evaluateBranchBinding({ + isolation: projection.classic?.isolation ?? null, + boundBranch: projection.classic?.boundBranch ?? null, + 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(changeDirectory(projectRoot, selection.change), verdict.branch); + } + return { status: 'selected', selection }; + ``` + (`changeDirectory` 是文件里已有的私有 helper,第 36-38 行,直接复用。) +- [x] Step 3:`classic-state-command.ts` 的 `selectChange`(第 1234 行附近)和 `currentChange`(第 1248 行附近)——检查两处是否读取了 `selection.branch`;若有,改为从 `resolveCurrentChange` 返回的 Classic state 或单独一次 `readField(name, 'bound_branch')` 读取,不再依赖 sidecar 里的分支字段。 +- [x] Step 4:重跑 Step 1 测试确认 PASS。`pnpm build`。 +- [x] Step 5:在 `comet-scripts.test.ts` 新增 hook-guard 回归测试:`isolation: current` 且已绑定 `feature-A` 的 change,`select` 后切到 `feature-B`,尝试写一个仓库源文件,`runHookGuard` 必须返回非 0,stderr 包含 `current change selection is stale or invalid` 和 `bound to branch 'feature-A', but current branch is 'feature-B'`。跑通过。 +- [x] Step 6:提交:`git commit -m "feat: bind current-isolation drift detection to bound_branch"` + +--- + +## Task 4:`set isolation current` 首次绑定 + +对应 spec §5.4、§5.5(缺口 B 的因果,本 Task 的惰性补绑在 Task 5 才真正落地,这里只做显式 `set` 触发的首次绑定)。 + +**Files**: +- Modify: `domains/comet-classic/classic-state-command.ts`(`MACHINE_OWNED_FIELDS` 第 42-48 行、`setField` 第 423-490 行) +- Test: `test/domains/comet-classic/comet-scripts.test.ts` + +- [x] Step 1:写失败的 CLI 测试(新增 `describe('isolation=current branch binding', ...)`),覆盖: + - 首次 `set isolation current` 在真实 git 分支上写入 `bound_branch` = 当前分支。 + - 已绑定后重复 `set isolation current`(哪怕当前分支已经变了)不覆盖已有 `bound_branch`。 + - `set isolation branch` 把 `bound_branch` 清空为 `null`。 + - detached HEAD 下 `set isolation current` 报错退出非 0,stderr 含 `HEAD is detached`,且不写入 `bound_branch`。 + - 直接 `set bound_branch x` 报错,stderr 含 `machine-owned`。 + 跑测试确认 FAIL。 +- [x] Step 2:`MACHINE_OWNED_FIELDS`(`classic-state-command.ts:42-48`)加入 `'bound_branch'`。 +- [x] Step 3:文件顶部 import 区加:`import { liveGitBranch } from './classic-branch-binding.js';` +- [x] Step 4:`setField` 里 `document.set(field, parsedValue(field, value));`(第 446 行)之后插入: + ```ts + 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); + } + } + ``` +- [x] Step 5:重跑 Step 1 测试确认 PASS。`pnpm build`,再跑一遍确认(`fail()` 抛出的 `CommandFailure` 依赖构建产物)。 +- [x] Step 6:提交:`git commit -m "feat: capture bound_branch on set isolation current"` + +--- + +## Task 5:`state check` 漂移检测与惰性补绑(含缺口 B 场景) + +对应 spec §5.4、§5.5。 + +**Files**: +- Modify: `domains/comet-classic/classic-state-command.ts`(`check` 函数,第 790-850 行) +- Test: `test/domains/comet-classic/comet-scripts.test.ts` + +- [x] Step 1:写失败的 CLI 测试,覆盖: + - `current` 模式绑定 `feature-A`,切到 `feature-B` 后 `state check verify` 返回非 0,stdout 含 `BLOCKED` 与 `bound to branch 'feature-A', but current branch is 'feature-B'`。 + - sidecar(`.comet/`)被删除后重新 `select`,漂移检测依然生效(证明检测依据 `.comet.yaml` 而非 sidecar)。 + - detached HEAD 下检查已绑定的 change,返回非 0,stdout 含 `detached HEAD`。 + - **缺口 B 场景**:`isolation: current` 但 `bound_branch` 缺失(模拟 hotfix/tweak `init` 直写的情况,不经过 `set`),在真实分支上 `state check` 必须自动补绑并返回 0,随后 `get bound_branch` 能读到该分支名。 + 跑测试确认 FAIL。 +- [x] Step 2:文件顶部 import 扩展为: + ```ts + import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, + } from './classic-branch-binding.js'; + ``` +- [x] Step 3:`check` 函数里,`output.stdout.push('');`(第 844 行)之前插入: + ```ts + const isolation = await readField(name, 'isolation'); + if (isolation === 'current') { + const boundBranchRaw = await readField(name, 'bound_branch'); + const verdict = evaluateBranchBinding({ + isolation, + boundBranch: boundBranchRaw && boundBranchRaw !== 'null' ? boundBranchRaw : 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)'); + } + } + ``` + (`reject`/`pass`/`directory` 都是 `check` 函数已有的局部变量/闭包,第 793、797-801 行已定义,直接复用;`reject` 已经会把 `blocked` 置 `true`,不需要额外处理。) +- [x] Step 4:重跑 Step 1 测试确认 PASS。`pnpm build`。 +- [x] Step 5:提交:`git commit -m "feat: enforce bound_branch drift in state check"` + +--- + +## Task 6:`state rebind` 子命令 + +对应 spec §5.4。 + +**Files**: +- Modify: `domains/comet-classic/classic-state-events.ts`(`event` 类型加宽) +- Modify: `domains/comet-classic/classic-state-command.ts`(新增 `rebind` 函数 + 分发) +- Test: `test/domains/comet-classic/comet-scripts.test.ts` + +- [x] Step 1:`classic-state-events.ts:10` 的 `event: ClassicTransitionEvent;` 改为 `event: ClassicTransitionEvent | 'rebind';` +- [x] Step 2:写失败的 CLI 测试,覆盖: + - 已绑定 `feature-A`、切到 `feature-B` 后 `rebind ` 成功,`get bound_branch` 变为 `feature-B`,随后 `state check verify` 通过;`.comet/state-events.jsonl` 最后一条记录 `event: 'rebind'`、`effects` 含 `{ field: 'boundBranch', from: 'feature-A', to: 'feature-B' }`。 + - 未绑定(`bound_branch: null`)时 `rebind` 报错,stderr 含 `not yet bound`。 + - detached HEAD 下 `rebind` 报错,stderr 含 `HEAD is detached`。 + 跑测试确认 FAIL。 +- [x] Step 3:在 `classic-state-command.ts` 里 `selectChange` 函数附近新增: + ```ts + 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}`)); + } + ``` + 并在 `classicStateCommand` 的分发链(第 1302 行 `select` 分支旁)加: + ```ts + } else if (subcommand === 'rebind') { + requiredExact(rest, 1, 'Usage: comet-state.mjs rebind '); + await rebind(output, rest[0]); + } else if (subcommand === 'select') { + ``` +- [x] Step 4:重跑 Step 2 测试确认 PASS。`pnpm build`。 +- [x] Step 5:提交:`git commit -m "feat: add state rebind subcommand with audit trail"` + +--- + +## Task 7:phase guard 漂移校验 + +对应 spec §5.6。 + +**Files**: +- Modify: `domains/comet-classic/classic-guard.ts` +- Test: `test/domains/comet-classic/comet-scripts.test.ts` + +**现状确认**:`classic-guard.ts` 里 `readField`(128 行)、`check` helper(273 行)、`runChecks`(300 行)已存在,可直接复用;`isolationSelected`(488-497 行)是既有的、与本次改动无关的字段选择检查(不要混淆)。 + +- [x] Step 1:写失败的 CLI 测试:`isolation: current` 绑定 `feature-A`、`archived: true`、`verify_result: pass`,切到 `feature-B` 后 `comet-guard archive` 返回非 0,stderr 含 `BLOCKED` 与 `bound to branch 'feature-A', but current branch is 'feature-B'`。 +- [x] Step 2:import 区加: + ```ts + import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + liveGitBranch, + unboundDetachedMessage, + } from './classic-branch-binding.js'; + ``` + 新增检查函数(放在 `isolationSelected` 附近): + ```ts + 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(); + } + ``` +- [x] Step 3:把 `check('bound branch matches isolation=current', () => boundBranchMatches(changeDir, change)),` 注册为 `guardBuildChecks`、`guardVerifyChecks`、`guardArchiveChecks` 三个 `runChecks(output, [...])` 数组的**第一项**(找 guard.ts 里这三个函数,定位方式:`grep -n "guardBuildChecks\|guardVerifyChecks\|guardArchiveChecks" classic-guard.ts`)。若 `guardArchiveChecks` 当前签名不含 `change` 参数,扩展签名并同步更新调用点。 +- [x] Step 4:`pnpm build`,重跑 Step 1 测试确认 PASS。 +- [x] Step 5:跑现有 guard 相关测试全集,确认无回归:`npx vitest run test/domains/comet-classic/comet-scripts.test.ts test/domains/comet-classic/classic-guard.test.ts`。特别注意:任何"`isolation=current` 下 build/verify/archive 完整流程通过"的既有测试,若原先在无 git 仓库环境下运行,会因为 `liveGitBranch` 返回 `null` 而变成 `unbound-detached` 判定并 FAIL——按 spec §5.5 的原则修 fixture(补 `gitInit` + 匹配的 `bound_branch`),**不得放宽新检查来迁就旧测试**。 +- [x] Step 6:提交:`git commit -m "feat: mirror bound_branch drift check in phase guard"` + +--- + +## Task 7b:`preset-escalate` 同步清空 `bound_branch` + +对应 spec §5.6"追加发现"、§5.8 Step 7b。这是追问"hotfix/tweak 写入 isolation 的时机"时发现的第二个写入点——`preset-escalate` 走 `classic-transitions.ts` 而不是 `classic-state-command.ts` 的 `setField`,Task 4 加的清空副作用覆盖不到它。 + +**Files**: +- Modify: `domains/comet-classic/classic-transitions.ts` +- Test: `test/domains/comet-classic/comet-scripts.test.ts`(或 `classic-transitions.test.ts`,视现有测试组织方式而定) + +**现状确认**:`applyClassicTransition()` 的 `preset-escalate` 分支(第 147-164 行)用文件内部私有的纯函数 `setField(classic, effects, field, value)`(第 96-105 行,操作内存中的 `ClassicState` 对象,和 `classic-state-command.ts` 里那个操作 YAML `Document` 的同名异物函数完全不是一回事)依次清空 `workflow`/`classicProfile`/`phase`/`designDoc`/`buildPause`/`buildMode`/`subagentDispatch`/`tddMode`/`reviewMode`/`isolation`/`verifyMode`/`directOverride`,第 162 行 `setField(classic, effects, 'isolation', null);` 之后紧接着第 163 行才是 `verifyMode`。 + +- [x] Step 1:写失败测试:`isolation: current` 且已绑定 `bound_branch: feature-A` 的 hotfix change,`phase: build`,执行 `comet state transition preset-escalate` 后 `get bound_branch` 必须为 `null`(当前行为:仍是 `feature-A`,因为没人清它)。跑测试确认 FAIL。 +- [x] Step 2:`classic-transitions.ts:162` 之后加一行: + ```ts + setField(classic, effects, 'boundBranch', null); + ``` +- [x] Step 3:重跑 Step 1 测试确认 PASS。`pnpm build`。 +- [x] Step 4:提交:`git commit -m "fix: clear bound_branch when preset-escalate clears isolation"` + +--- + +## Task 8:全量回归 + +- [x] `npx vitest run` 全通过。 +- [x] `pnpm lint && pnpm build` 通过。 +- [x] 若 Step 5-7b 暴露的既有 fixture 需要调整,一并提交:`git commit -m "test: reconcile existing fixtures with bound_branch drift checks"`(若无改动跳过)。 + +--- + +## Task 9:hotfix/tweak 的 isolation 改为主动询问 + +对应 spec §5.5、§5.7(设计变更:用户明确要求把 hotfix/tweak 的 isolation 默认值改为主动询问,不再保留"默认 current")。 + +**Files**: +- Modify: `domains/comet-classic/classic-state-command.ts`(`init()` 第 511 行) +- Modify: `assets/skills-zh/comet-hotfix/SKILL.md`、`assets/skills-zh/comet-tweak/SKILL.md` +- Test: `test/domains/comet-classic/comet-scripts.test.ts` + +- [x] Step 1:写失败测试:`comet state init hotfix` 与 `comet state init tweak` 之后,`get isolation` 必须返回 `null`(不再是 `current`)。跑测试确认 FAIL。 +- [x] Step 2:`classic-state-command.ts:511` 把 `isolation: preset ? 'current' : null,` 改为 `isolation: null,`(对所有 workflow 统一,不再按 `preset` 区分)。 +- [x] Step 3:重跑 Step 1 测试确认 PASS。`pnpm build`。 +- [x] Step 4:**回归排查**:`grep -rn "isolation.*current" test/domains/comet-classic/comet-scripts.test.ts` 找出所有依赖"hotfix/tweak `init` 后 `isolation` 直接是 `current`"的既有 fixture/断言,逐一改为在 `init` 之后显式补一次 `comet state set isolation current`(或按测试场景选 `branch`/`worktree`)再继续,不得为了让旧测试通过而把 `init` 的默认值改回去。跑 `npx vitest run test/domains/comet-classic/comet-scripts.test.ts` 确认全部恢复 PASS。 +- [x] Step 5:编辑中文 `comet-hotfix/SKILL.md`(原第 45 行):删除"hotfix 默认 `isolation: current`..."整句,替换为工作区隔离决策点: + ```markdown + ### 工作区隔离(用户决策点) + + 展示决策前先做能力预检:确认 `using-git-worktrees` 是否可用、当前仓库能否安全创建分支。只展示当前真实可执行的选项。 + + 这是用户决策点。**必须按 `comet/reference/decision-point.md` 的协议暂停**,让用户从以下选项中选择: + + | 选项 | 方式 | 说明 | + |------|------|------| + | A | 当前分支直接工作 | 不新建分支/worktree,直接在当前所在分支上完成本次 hotfix | + | B | 创建分支 | 命名规范同 `/comet-build`:`hotfix/YYYYMMDD/` | + | C | 创建 Worktree | 完全独立的工作区,适合当前分支有未提交工作、不想打断的场景 | + + 不得自动选择,也不得沿用其他 change 的历史选择。 + + 用户选择后: + + - **A**:`comet state set isolation current` + - **B**:确认分支名后执行 `git checkout -b `,再运行 `comet state set isolation branch` + - **C**:**立即执行:** 使用 Skill 工具加载 Superpowers `using-git-worktrees` 技能创建隔离工作区;禁止用普通 shell 命令绕过。创建后运行 `comet state set isolation worktree` + + 选择 B 或 C 后,必须在新工作区重新运行 `comet state select `,再开始创建精简版产物。 + ``` + 请用户审阅中文措辞。 +- [x] Step 6:对 `comet-tweak/SKILL.md`(原第 53 行)做同样的替换,把命名规范换成 `tweak/YYYYMMDD/`,其余文案与 hotfix 版一致。请用户审阅中文措辞。 +- [x] Step 7:`grep -rn "压缩门\|调试门\|确认门" assets/skills-zh/comet-hotfix/SKILL.md assets/skills-zh/comet-tweak/SKILL.md` 确认零命中。 +- [x] Step 8:提交(中文部分):`git commit -m "feat: ask hotfix/tweak to choose isolation explicitly instead of defaulting to current"` + +--- + +## Task 10:五份 SKILL.md 的漂移 decision-point + +对应 spec §5.8 Step 10。这一步和 Task 9 是两件不同的事:Task 9 是"流程入口选一次工作区隔离",本 Task 是"选完之后如果中途分支漂移,检测到 `BLOCKED` 时怎么处理"——五份文件都需要,包括 hotfix/tweak(选完 `current` 之后同样可能中途被切走分支)。 + +**Files**:`assets/skills-zh/comet-build/SKILL.md`、`comet-verify/SKILL.md`、`comet-archive/SKILL.md`、`comet-hotfix/SKILL.md`、`comet-tweak/SKILL.md`。 + +- [x] Step 1:在五份 SKILL.md 的入口命令块(`comet state select` / `comet state check `)之后,插入一段:检测到 `BLOCKED`(分支绑定漂移)时,按 `comet/reference/decision-point.md` 协议暂停,等待用户选择"切回绑定分支"或(明确确认后)执行 `comet state rebind `;不得自行切换分支或换绑。 +- [x] Step 2:**人工验收**:逐字通读 hotfix/tweak 两份 SKILL.md 里 Task 9 新加的"工作区隔离决策点"和本 Task 新加的"漂移 decision-point",确认两段话读起来是"入口选一次"和"中途出问题再暂停"两件不同的事,不会被读成同一个决策点。 +- [x] Step 3:`grep -rn "压缩门\|调试门\|确认门" assets/skills-zh/comet-build/SKILL.md assets/skills-zh/comet-verify/SKILL.md assets/skills-zh/comet-archive/SKILL.md assets/skills-zh/comet-hotfix/SKILL.md assets/skills-zh/comet-tweak/SKILL.md` 确认零命中。 +- [x] Step 4:请用户审阅中文措辞。 +- [x] Step 5:中文确认后,**同一轮**同步 Task 9 + 本 Task 涉及的全部五份英文版 SKILL.md,以及 `assets/skills/comet/reference/comet-yaml-fields.md` + 中文版补充 `bound_branch` 字段说明。不得带着中英不同步的状态提交。 +- [x] Step 6:提交:`git commit -m "docs: add current-isolation drift decision point to build/verify/archive/hotfix/tweak skills"` + +--- + +## Task 11:`comet status` 暴露 isolation / 绑定分支(缺口 A) + +对应 spec §6。依赖 Task 1(`ClassicState.boundBranch` 必须已存在)。 + +**Files**: +- Modify: `app/commands/status.ts` +- Test: `test/app/` 下对应 status 测试文件 + +- [x] Step 1:新增/扩展测试:`isolation: current` 且已绑定的 change,`comet status`(文本输出)包含 `isolation: current (bound: feature-A)`;`isolation` 为 `null` 或 `branch`/`worktree` 时不打印该行(不引入噪音,`branch`/`worktree` 也不显示绑定分支);`comet status --json` 输出的每条 change 记录包含 `"boundBranch"` 字段且与 `.comet.yaml` 一致。跑测试确认 FAIL。 +- [x] Step 2:`app/commands/status.ts` 的 `ChangeStatus` 接口(第 11-40 行)在 `isolation: string | null;` 之后加: + ```ts + boundBranch: string | null; + ``` +- [x] Step 3:`getActiveChanges()` 内四条构造 `ChangeStatus` 记录的分支(unknown-keys 分支约第 94-115 行、valid 分支约第 121-153 行、invalid 分支约第 156-177 行、catch 分支约第 179-200 行)都在 `isolation: ...` 那一行之后加 `boundBranch: projection.classic?.boundBranch ?? null,`(catch 分支没有 `projection` 可用,保持 `boundBranch: null,` 即可,与该分支 `isolation: null,` 的写法一致)。 +- [x] Step 4:`displayStatus()` 里,`console.log(\` workflow: ${c.workflow} | build_mode: ${c.buildMode}\`);`(第 252 行)之后加: + ```ts + if (c.isolation) { + const branchSuffix = c.isolation === 'current' && c.boundBranch ? ` (bound: ${c.boundBranch})` : ''; + console.log(` isolation: ${c.isolation}${branchSuffix}`); + } + ``` +- [x] Step 5:重跑 Step 1 测试确认 PASS;`npx vitest run` 确认无回归(本 Task 不涉及 `domains/comet-classic`,不需要 `pnpm build`)。 +- [x] Step 6:提交:`git commit -m "feat: surface isolation and bound branch in comet status"` + +--- + +## Task 12:archive Step 5 按 isolation 分流(问题 3,修正后设计) + +对应 spec §7。建议在 Task 10 落地之后再做,避免同一批 SKILL.md 冲突。 + +**Files**: +- Modify: `assets/skills-zh/comet-archive/SKILL.md`(第 105-123 行附近的 Step 5) +- Modify: `assets/skills/comet-archive/SKILL.md`(英文同步) + +- [x] Step 1:编辑中文 `comet-archive/SKILL.md` Step 5:在加载 Superpowers `finishing-a-development-branch` 之前,插入 `isolation` 判断: + - `isolation !== 'current'`:完全保留现有三选一流程(本地合并到主分支 / 推送并创建 PR / 保持分支),不改一个字。 + - `isolation === 'current'`:跳过 `finishing-a-development-branch`,改为两选一 decision-point:"1. 推送当前分支" / "2. 暂不推送,保留本地"。选 1 时执行 `git push`(若无上游分支,`git push -u origin <当前分支>`);选 2 不执行任何 git 操作。 + 两条路径收尾都保持不变:执行完选择后运行 `comet state set branch_status handled` → `comet guard archive` → `comet state clear-selection`。 +- [x] Step 2:**人工验收**(同 Task 10 的性质,不是自动化测试):确认新增的两选一文案措辞,与 Task 10 加入的"检测到 BLOCKED 时暂停"decision-point 文案、以及 Task 9 hotfix/tweak 的入口工作区隔离决策点,三者互相之间不冲突——这是三个不同触发条件的独立决策点(流程入口选一次隔离方式 / 漂移检测时触发 / 归档收尾时触发),不要在同一份文件里写出让人误解成同一件事的表述。 +- [x] Step 3:若仓库现有对 SKILL.md 正文做断言式测试(例如 `test/domains/skill/skills.test.ts` 只做结构/存在性校验而非语义断言),本 Task 不强行新增脆弱的文本匹配测试;如需要回归保障,可在该测试文件里补一条"comet-archive SKILL.md 包含 isolation 关键字"的存在性检查即可,不用 assert 具体措辞。 +- [x] Step 4:中文确认后,同一轮同步英文版 `assets/skills/comet-archive/SKILL.md`。 +- [x] Step 5:`npx vitest run && pnpm lint`(本 Task 只改 SKILL.md,不涉及 `domains/comet-classic/*.ts`,不需要 `pnpm build`)。 +- [x] Step 6:提交:`git commit -m "docs: branch current isolation archive flow away from finishing-a-development-branch"` + +--- + +## Task 13:Changelog 与版本号 + +- [x] `package.json` 的 `"version"` 从 `0.4.0-beta.5` 改为 `0.4.0-beta.6`。 +- [x] `CHANGELOG.md` 顶部新增(若已有同版本条目则追加到同一条目下,不新开版本号): + ```markdown + ## What's Changed [0.4.0-beta.6] - 2026-07-18 + + ### 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. + + ### Changed + + - **Current-isolation drift detection**: `isolation: current` now persists its bound branch in the committed change state instead of a local sidecar, so switching branches mid-change is reliably detected at every build/verify/archive entry check and by the write guard, and is no longer silently reset by re-selecting the current change. Establishing `isolation: current` on a detached HEAD is now rejected. + - **`comet status`**: now surfaces the selected isolation mode and, for `current` isolation, the bound branch, in both text and `--json` output. + - **Archive branch handling for `current` isolation**: no longer offers the feature-branch-oriented merge/PR/keep choices; instead asks whether to push the current branch or keep it local. + - **hotfix/tweak workspace isolation**: no longer defaults silently to the current branch; both presets now pause to ask the user to choose between working directly on the current branch, creating a new branch, or creating a worktree. + ``` +- [x] `node -p "require('./package.json').version"` 输出 `0.4.0-beta.6`,与 changelog 标题一致。 +- [x] `npx vitest run && pnpm lint && pnpm build` 全部通过。 +- [x] 提交:`git commit -m "chore: release 0.4.0-beta.6 with current-isolation branch binding"` + +--- + +## 执行顺序回顾 + +Task 1 → 2 → 3 → 4 → 5 → 6 → 7 → 7b → 8(问题 1 + 缺口 B + 两个 isolation 写入点的联动清空,Task 1-8 环环相扣,必须按序)→ 9(hotfix/tweak 主动询问,缺口 C 新设计)→ 10(五份 SKILL.md 的漂移 decision-point)→ 11(缺口 A,依赖 Task 1)→ 12(问题 3)→ 13(发布)。Task 11 也可以在 Task 8 之后立即插入(只依赖 Task 1 的字段,不依赖 Task 9/10),如果想让 `comet status` 更早可用可以提前做;Task 12 建议放在 Task 10 之后以避开同批 SKILL.md 冲突。 diff --git a/docs/superpowers/specs/2026-07-18-current-branch-isolation-design.md b/docs/superpowers/specs/2026-07-18-current-branch-isolation-design.md new file mode 100644 index 00000000..cbefa409 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-current-branch-isolation-design.md @@ -0,0 +1,391 @@ +# current 隔离模式:完整设计与实现步骤(合并 PR #203 全部 review 反馈) + +- 状态:设计中,待用户审阅 +- 关联 issue:#190() +- 关联 PR:#203(已关闭,未合并;作者决定基于最新 master 重新设计) +- Review 来源: + - benym 首轮 review(问题 1-4): + - 作者对首轮 review 的回复: + - benym 二轮 review(合并事故 + 缺口 A/C): + - CodeRabbit 自动化 review 三轮(2026-07-13 13:18 / 2026-07-17 05:34 / 2026-07-17 06:08) +- 本文档取代此前四份草稿(已删除):`2026-07-16-current-isolation-branch-binding-design.md`、`2026-07-16-pr203-review-outstanding-issues.md`、`2026-07-16-pr203-outstanding-issues-2-3-4-design.md`、`2026-07-16-current-isolation-branch-binding.md`(旧 plan)、`2026-07-16-pr203-outstanding-issues-2-3-4.md`(旧 plan)。方案设计与实现步骤合并到本文档一处维护。 + +## 0. 为什么要重写 + +PR #203 实现了 issue #190(新增 `isolation: current` 模式),经过 benym 两轮人工 review 与 CodeRabbit 三轮自动化 review 后,作者在 2026-07-18 关闭该 PR: + +> "那我先把这个pr关了吧, 我用主分支最新的代码重新设计。代码和skill中的细节问题我会根据近几次review的反馈的结果再好好打磨下" + +关闭前的最后一次 review(benym,2026-07-18)除了指出合并操作本身的事故(`wechat.png`、`website` 子模块指针被误回退,属于 git 操作问题,不在本文档范围内),还发现了三处此前四份草稿从未提及的缺口。 + +**本文档写作过程中的一次重要修正**:初稿曾照搬旧草稿对"问题 2/3/4"的描述,未逐条对照当前 master 代码重新核实。在准备写实现步骤时逐项核查代码后发现:**问题 2、问题 4 在 master 上根本不存在**(都是 PR #203 分支自己引入又自己改错的东西,从未合入 master);**问题 3 的问题现象描述也是错的**(master 现在的 archive 流程已经是"先产生归档 commit、再做分支处理决策",不存在"commit 遗留在没人管的本地"的缺口)。以下 §3/§4/§7 是核实后的结论,不是最初的假设。 + +PR #203 从未合并到 master,因此下文所有"现状"描述均以当前 master(`5e97d19`)为准,且均已逐项对照 master 实际代码核实(不是转述旧草稿)。 + +## 1. 问题清单总览 + +| 编号 | 内容 | 来源 | 核实结论 | 影响面 | +|---|---|---|---|---| +| 1 | `current` 模式分支绑定被标准入口流程静默覆盖 | benym 首轮 | **属实**,master 确认存在 | `domains/comet-classic/*` 核心逻辑 | +| 2 | hotfix/tweak 被误限制为不能选 `worktree` | benym 首轮 | **不存在于 master**,仅存在于 PR #203 分支 | 无需改动 | +| 3 | archive 阶段分支处理对 `isolation: current` 文不对题 | benym 首轮(描述已修正) | **原描述不准确**;master 的真实缺口是分支处理步骤未按 isolation 区分 | `comet-archive/SKILL.md` | +| 4 | 测试文件 ESLint `no-useless-escape` | benym 首轮 | **不存在于 master**,`eslint` 实测 0 errors | 无需改动 | +| A | `comet status` 未按 issue #190 要求展示 isolation / 绑定分支 | benym 二轮 | **属实**,master 确认存在 | `app/commands/status.ts` | +| B | hotfix/tweak 的 `isolation: current` 由 `init` 直写,不经过问题 1 的首次绑定副作用;追问 isolation 写入时机时又发现 `preset-escalate` 清空 `isolation` 也不经过同一副作用 | 代码核查发现 | **属实**;设计已变更为根治(§5.5、§5.7),不再只靠兜底 | `classic-state-command.ts` init()、`classic-transitions.ts` | +| C | hotfix/tweak 默认 `isolation: current` 与新增决策点表述可能冲突 | benym 二轮 → 用户决定改变方向 | **已升级为正式设计**:不再回避矛盾,改为主动询问三选一,彻底移除默认值 | `classic-state-command.ts` init()、hotfix/tweak SKILL.md 中英文 | + +## 2. 实现顺序建议 + +问题 2、4 核实后不需要任何代码改动,从实现顺序中移除。缺口 A 依赖问题 1 的 `boundBranch` 字段。问题 3(修正后)与问题 1 无数据依赖,但共享 `comet-archive/SKILL.md` 的编辑范围,建议问题 1 的 SKILL.md 改动落地后再改问题 3,避免同一份文件的合并冲突。缺口 B、C 现在有独立代码改动(`init()` 默认值、`preset-escalate` 清空逻辑、hotfix/tweak 决策点),已并入问题 1 的 Task 序列(§5.8 Step 7b/9),不再是"仅测试覆盖"的轻量项。 + +推荐顺序:**问题 1(含缺口 B、C)→ 缺口 A → 问题 3**。问题 1 是后续一切的地基;缺口 A 紧随其后,因为它是问题 1 新增字段的直接消费方;问题 3 放最后,因为它需要在问题 1 的 SKILL.md 改动稳定后再动 `comet-archive/SKILL.md`。 + +--- + +## 3. 问题 2:hotfix/tweak 的 `worktree` 限制——核实结论:master 不存在,无需改动 + +旧草稿描述 `domains/comet-classic/classic-state.ts` 有一张 `ISOLATION_MODES` 表,`{ value: 'worktree', allowedInPreset: false }` 把 hotfix/tweak 收窄成 `branch | current`。**这段代码在当前 master 上不存在**: + +```bash +$ grep -rn "PRESET_ALLOWED_ISOLATIONS\|allowedInPreset\|ISOLATION_MODES\b" domains/comet-classic/*.ts +# 零命中 +``` + +实际校验逻辑分布在两处,均已允许 hotfix/tweak 三选一: + +```ts +// classic-guard.ts:488-497 isolationSelected() +if (isolation === 'branch' || isolation === 'worktree') return pass(); +if (isolation === 'current' && (workflow === 'hotfix' || workflow === 'tweak')) return pass(); +// ... allowedValues = workflow === 'full' ? '' : '' + +// classic-state-command.ts:545-546 requireBuildDecisions() +const allowedIsolation = + workflow === 'full' ? ['branch', 'worktree'] : ['current', 'branch', 'worktree']; +``` + +**结论**:这是 PR #203 分支自己引入又自己在 review 中改错的一处回归,从未合入 master。基于 master 重新设计不需要"撤销"任何限制,无需任何代码改动。保留本节仅为了让读到 benym 首轮 review 的人知道这条已经核实过、不是被遗漏。 + +--- + +## 4. 问题 4:ESLint `no-useless-escape`——核实结论:master 不存在,无需改动 + +旧草稿描述 `test/domains/comet-classic/comet-scripts.test.ts` 里有 `'node -e \"process.exit(0)\"'` 这种多余转义。核实: + +```bash +$ grep -n 'node -e' test/domains/comet-classic/comet-scripts.test.ts +# 17 处命中,全部已是 'node -e "process.exit(0)"',没有一处带 \" + +$ npx eslint test/domains/comet-classic/comet-scripts.test.ts +✖ 2 problems (0 errors, 2 warnings) +``` + +**结论**:同样是 PR #203 分支自身引入的问题,从未合入 master。无需任何代码改动。保留本节同样只是为了可追溯。 + +--- + +## 5. 问题 1:`current` 隔离模式分支绑定持久化(含缺口 B) + +### 5.1 场景重现(问题所在) + +`isolation: current` 允许 change 直接在用户当前所在的分支上开发。为检测"开发过程中分支被意外切换",运行时需要记录"这个 change 当初绑定的是哪个分支",并在每次进入 build/verify/archive 阶段时和实时分支比较,不一致就必须拦下来。 + +当前实现把这份绑定信息存在仓库本地、不受版本控制的 sidecar 文件 `.comet/current-change.json` 里,而 `selectCurrentChange()`(`classic-current-change.ts`)**每次调用都无条件用当前分支重写这个文件**。`comet-build`/`comet-verify`/`comet-archive` 的入口第一步统一都会先跑 `comet state select `,于是漂移检测在结构上不可能触发: + +1. 在分支 `A` 上选择 change,`isolation: current`,sidecar 记录 `branch: A`。 +2. build 完成后意外切到 `B`。 +3. 进入 `/comet-verify`,入口先跑 `comet state select`,把 sidecar 的 `branch` 覆盖成 `B`。 +4. 后续比较两边都是 `B`,永远一致,检测失效——工作流在错误分支上继续跑,用户毫不知情。 + +这与 issue #190 明确要求的"当前分支意外变化时必须警告并要求确认"不一致。此外,绑定信息只存在本地 sidecar 里:一旦被清理(`git clean -fdx`、CI 换 worker、`.comet/` 被删)或在另一个 checkout 里恢复现场,绑定关系直接丢失,下一次 `select` 会把当前分支当成"首次绑定"重新写入,用户不知情地换绑。 + +### 5.2 目标 / 非目标 + +**目标**: +- 分支绑定关系不会被标准入口流程(`state select`)静默覆盖。 +- 绑定关系持久化在跟随仓库提交的正式状态文件(`.comet.yaml`)里,不依赖本地 sidecar 存活。 +- `state check`、hook guard、phase guard 三处漂移检测口径一致(同一个判定函数)。 +- detached HEAD 场景被明确处理,不能绕过检测。 +- 换绑必须是显式操作,留下审计记录。 +- 对已经在 in-flight 的 `isolation: current` change(本次修复上线前创建的)平滑兼容,不因缺字段直接报错。 + +**非目标**: +- 不改变 `branch`/`worktree` 隔离模式的任何行为。 + +**设计决策变更(2026-07-18 追加)**:最初设计把"hotfix/tweak 默认 `isolation: current`,无需用户选择"列为不可触碰的非目标(详见 §5.7 的历史记录)。用户在 review 本文档时明确要求改为**主动询问**——hotfix/tweak 不再静默预填 `isolation: current`,改为在流程入口显式暂停,让用户从 `current`/`branch`/`worktree` 三选一。这条决策变更同时取代了原来的防呆约束,§5.7 已按新设计重写。 + +### 5.3 设计概览 + +核心思路:**把"绑定的是哪个分支"这条信息从 sidecar 挪进 `.comet.yaml`,变成新字段 `bound_branch`;sidecar 从此只负责"当前在操作哪个 change",不再存分支数据,因此也没有可覆盖的东西。** + +| 文件 | 修复前 | 修复后 | +|---|---|---| +| `.comet/current-change.json`(sidecar,不受版本控制) | 存 `change` + `branch`,每次 `select` 都重写 | 只存 `change`,`select` 不再碰分支 | +| `openspec/changes//.comet.yaml`(正式状态,跟随仓库提交) | 无分支信息 | 新增 `bound_branch: string \| null`,写入后只能通过显式 `rebind` 命令修改 | + +`domains/comet-classic/classic-state.ts` 新增: + +```ts +export interface ClassicState { + ... + boundBranch: string | null; // wire key: bound_branch +} +``` + +- 属于**机器拥有字段**(加入 `MACHINE_OWNED_FIELDS`),`comet state set bound_branch xxx` 不允许直接写。 +- **不加入** `REQUIRED_CLASSIC_KEYS`,不 bump `CLASSIC_MIGRATION_VERSION`(当前为 `1`)——`classic_migration` 校验是严格相等,升版本号会让所有 in-flight change 在下次 `readClassicState` 时校验失败,是要避免的破坏性行为。可选字段能完全绕开这个问题,也是存量 change 自动兼容的关键。 +- 只在 `isolation === 'current'` 时有意义;`isolation` 为 `branch`/`worktree` 时恒为 `null`。 + +`.comet/current-change.json` 瘦身: + +```ts +export interface CurrentChangeSelection { + version: 1; + change: string; + // branch 字段删除 +} +``` + +`selectCurrentChange()` 不再写分支——不是让 `select` 变聪明去判断该不该覆盖,而是让它压根没有分支数据可写,从结构上消除"select 顺带覆盖绑定"的可能。 + +### 5.4 核心流程(走一遍完整场景) + +**首次绑定**: + +```bash +git branch --show-current # feature-A +comet state set my-change isolation current +``` + +`set` 命令处理 `isolation` 字段时新增副作用:新值是 `current` 且当前 `bound_branch` 为 `null` 时,读取当前 git 分支一并写入: + +```yaml +isolation: current +bound_branch: feature-A +``` + +**关键约束**:只有 `bound_branch` 当前为 `null` 时才会写入。重复执行 `set isolation current`(脚本重跑、幂等调用)不会用当前分支重新覆盖已有值——否则等于把"静默覆盖"这个 bug 从 `select` 搬到了 `set`,没有真正解决问题。 + +**detached HEAD 场景下禁止建立绑定**:若执行 `set isolation current` 时当前分支解析为 `null`(detached HEAD),命令直接报错拒绝:`ERROR: cannot bind isolation=current while HEAD is detached; checkout a branch first`。不能建立"绑定到 null"的状态,否则漂移检测会因为找不到比较基准而失效。 + +若 `isolation` 从 `current` 改成 `branch`/`worktree`,`bound_branch` 同步清空为 `null`;下次再切回 `current` 视为全新首次绑定。 + +**分支漂移(核心场景)**: + +```bash +git checkout feature-B # 手滑切错分支 +comet state select my-change # 只更新 sidecar 的 change 指针,不影响 bound_branch +comet state check my-change verify +``` + +比较 `.comet.yaml` 里的 `bound_branch: feature-A` vs 实时分支 `feature-B`,不一致 → `[FAIL]`,整体 `BLOCKED`: + +``` +BLOCKED: change 'my-change' is bound to branch 'feature-A', but current branch is 'feature-B'. +Next: ask the user to confirm — switch back to 'feature-A', or run `comet state rebind my-change` after explicit confirmation. +``` + +**sidecar 丢失后的恢复**:`bound_branch` 存在 `.comet.yaml` 里,不受 sidecar 存亡影响,漂移检测照常生效。 + +**detached HEAD**:只要 `bound_branch` 非空,实时分支解析为 `null`(detached)就必须视为不一致,直接 FAIL,没有跳过检测的分支。 + +**显式换绑(rebind)**: + +```bash +comet state rebind my-change +``` + +要求 `bound_branch` 当前非空,要求实时分支非 detached HEAD,读取实时分支写入新 `bound_branch`,并调用 `appendClassicStateEvent()` 记一条 `event: 'rebind', from, to` 的审计事件。命令本身不做交互确认——交互确认的职责在 SKILL.md 层的 decision-point 协议,CLI 只负责在用户已确认之后执行写入并留痕。 + +### 5.5 缺口 B:hotfix/tweak 的首次绑定改为走正常路径(原"惰性补绑兜底"设计已被主动询问取代) + +**历史背景**:最初核查 `classic-state-command.ts` 的 `init` 函数发现 `isolation: preset ? 'current' : null`——hotfix/tweak 走 preset 分支时,`.comet.yaml` 在**创建时刻**就直接写了 `isolation: 'current'`,完全绕过 `setField`,问题 1(§5.4)"首次绑定 `bound_branch`"这个副作用(挂在 `setField` 处理 `isolation` 字段的分支上)永远不会触发。当时的结论是:"功能上不会漏判——`state check`/guard 的惰性补绑(`needs-heal`)天然兜住这个场景",把这个当作可以接受的既有设计保留。 + +**设计变更**:用户决定把 hotfix/tweak 的 isolation 选择从"静默默认"改成"主动询问"(详见 §5.7)。这条变更直接消除了本节最初描述的因果链条本身——**`init` 不再预填 `isolation: current`,改为写 `isolation: null`**,hotfix/tweak 与 full workflow 使用完全相同的初始值。真正的绑定动作挪到流程入口的决策点,由用户显式选择后调用 `comet state set isolation current`,这时会**正常触发 `setField` 的首次绑定副作用**,不再需要依赖惰性补绑作为主路径。 + +走一遍新设计下的场景: + +```bash +git branch --show-current # feature-hotfix +comet state init my-fix hotfix # 现在写入 isolation: null(不再预填 current) +comet state select my-fix +comet state check my-fix open # isolation 为 null,isolationSelected 的既有校验本就会挡在离开 build 之前,这里的 open 检查不涉及 isolation,先放行 +``` + +进入 hotfix 流程后,SKILL.md 的决策点(§5.7)暂停,用户选择"当前分支直接工作": + +```bash +comet state set my-fix isolation current # 触发 setField 的首次绑定,写入 bound_branch: feature-hotfix +``` + +`bound_branch` 在这一步被正常写入,不再依赖 `state check` 的惰性补绑去猜测"用户是不是想要 current"。 + +**惰性补绑没有被移除,但角色改成纯粹的兼容兜底**:升级前创建、`.comet.yaml` 里已经是 `isolation: current` 但缺 `bound_branch` 字段的存量 hotfix/tweak change(本次改动上线前,走的还是旧版 `init` 直写逻辑),第一次经过 `state check`/guard 时仍然会走 `needs-heal` 惰性补绑——这条路径不删除,只是不再是设计里描述的"主要机制",纯粹是给存量数据兜底(和 §5.9 兼容性策略里"缺 `bound_branch` 字段的存量 change"是同一件事,不是两套逻辑)。 + +detached HEAD 场景:决策点在 `set isolation current` 时会走 §5.4 已经设计好的拒绝逻辑(`liveGitBranch` 返回 `null` 直接报错),比原设计"要等到第一次 `state check` 才发现"更早、更直接——这是本次设计变更顺带解决的一个体验改进,不需要额外实现。 + +### 5.6 涉及的具体文件改动 + +| 文件 | 改动内容 | +|---|---| +| `domains/comet-classic/classic-state.ts` | 新增 `boundBranch` 字段、`bound_branch` wire key 及序列化/反序列化 | +| `domains/comet-classic/classic-branch-binding.ts`(新建) | 共享判定模块:`liveGitBranch` / `evaluateBranchBinding` / `healBoundBranch` / `driftBlockedMessage` / `driftStaleReason` / `unboundDetachedMessage`,`state check`、guard、`resolveCurrentChange` 三处统一消费 | +| `domains/comet-classic/classic-current-change.ts` | `CurrentChangeSelection` 去掉 `branch`;`selectCurrentChange()` 不再读写分支;`resolveCurrentChange()` 改为读 `.comet.yaml` 的 `bound_branch` 做比较 | +| `domains/comet-classic/classic-state-command.ts` | `MACHINE_OWNED_FIELDS` 加入 `bound_branch`;`set isolation current` 首次绑定副作用;新增 `rebind` 子命令;`check()` 新增漂移检查;`currentChange()` 输出补充 isolation/branch;`init()` 第 511 行 `isolation: preset ? 'current' : null` 改为对 hotfix/tweak 也写 `null`(§5.5/§5.7) | +| `domains/comet-classic/classic-transitions.ts` | `preset-escalate` 分支(第 147-164 行)在清空 `isolation` 的同时同步清空 `boundBranch`,避免升级到 full workflow 后残留孤儿 `bound_branch`(详见本节下方"追加发现") | +| `domains/comet-classic/classic-guard.ts` | 新增 `boundBranchMatches()`,复用共享判定模块,在 build/verify/archive 三个关卡各自独立比较 | +| `domains/comet-classic/classic-state-events.ts` | 审计事件 `event` 类型加入 `'rebind'` | +| `domains/comet-classic/classic-validate-command.ts` | 无需显式改动——已知字段集合派生自 `CLASSIC_WIRE_KEYS`,`bound_branch` 自动被接受为可选字段 | +| `assets/skills-zh/comet-build\|verify\|archive/SKILL.md` + 对应英文版 | 检测到 `BLOCKED`(分支漂移)时的 decision-point 处理步骤;`rebind` 命令使用条件 | +| `assets/skills-zh/comet-hotfix\|tweak/SKILL.md` + 对应英文版 | 移除"默认 `isolation: current`"表述,替换为主动询问的工作区隔离决策点(§5.7 全新设计);同时也要有检测到 `BLOCKED` 时的 decision-point 处理步骤(和 build/verify/archive 一致) | + +**追加发现(用户追问"hotfix/tweak 写入 isolation 的时机"时定位)**:`isolation` 字段除了 `init()` 直写,还有第二个写入点——`preset-escalate` transition(hotfix/tweak 升级为 full workflow 时触发)。它不经过 `classic-state-command.ts` 的 `setField()`,而是走 `classic-transitions.ts` 的 `applyClassicTransition()`(该文件内部另有一个同名但完全独立的纯函数 `setField`,只操作内存中的 `ClassicState` 对象),在第 162 行 `setField(classic, effects, 'isolation', null);` 清空 `isolation`。这条路径不会触发 Task 4(§5.4)里"`isolation` 离开 `current` 时清空 `bound_branch`"的副作用,因为那段逻辑写在另一个文件的另一个函数里。修复方式:在 `classic-transitions.ts:162` 之后加一行 `setField(classic, effects, 'boundBranch', null);`,保持"`isolation` 离开 `current` 时 `bound_branch` 必须同步清空"这条不变量在两条独立的写入路径上都成立。 + +### 5.7 缺口 C(已升级为正式设计):hotfix/tweak 的工作区隔离改为主动询问 + +**历史记录**:benym 二轮 review 原话是"Tweak Skill 自相矛盾:写默认 isolation: current,后面又要求用户显式选择 branch/worktree/current"。本文档最初的应对是把"不修改 hotfix/tweak 既有默认表述"定为防呆非目标(避免重蹈 PR #203 覆辙)。用户在审阅本文档时明确要求改变方向:**不再回避这个矛盾,而是直接解决它**——把 hotfix/tweak 的 isolation 选择从"静默默认"改成和 full workflow 一致的"主动询问"。 + +**为什么这是合理的方向,而不是走回 PR #203 的老路**:PR #203 当时的问题不是"不该问",而是"半改"——加了一段要求显式选择的 decision-point 文案,却没有同步删掉"默认 current,无需选择"那句老话术,两句话留在同一份文件里互相矛盾。本次是**完整替换**:直接删掉"默认"表述,替换成结构完整的决策点,不存在两句话并存的中间状态,从根源上避免同一类矛盾。 + +**现状核查**(为设计提供依据): +- `classic-state-command.ts:511`(`init()`):`isolation: preset ? 'current' : null`——hotfix/tweak 创建时直接预填 `current`。 +- `isolationSelected()`(`classic-guard.ts:488-497`)与 `requireBuildDecisions()`(`classic-state-command.ts:545-546`)**早已要求** `isolation` 必须是 `current|branch|worktree` 三者之一才能离开 build 阶段,对 hotfix/tweak 同样生效——这条强制机制本来就存在,只是因为 `init` 预填了 `current` 而从未真正生效过。 +- full workflow 的等价决策点(`comet-build/SKILL.md` 第 78-222 行)已经有一套成熟模式:能力预检(`using-git-worktrees` 是否可用、仓库能否安全创建分支)→ 展示可执行选项 → 用户选择 → 按分支命名规范(`hotfix/YYYYMMDD/`、`tweak/YYYYMMDD/` 已经预留了 hotfix/tweak 前缀,第 193-194 行)创建分支或加载 `using-git-worktrees` 技能创建 worktree → 在新工作区重新 `comet state select`。 + +**设计**: +1. `init()` 改为对所有 workflow(含 hotfix/tweak)统一写 `isolation: null`,不再区分 preset。 +2. hotfix/tweak 的 SKILL.md 在入口(`comet state init` → `comet state select` → `comet state check open` 之后,创建 proposal.md/design.md/tasks.md 之前)插入一个决策点,比 full workflow 的 Step 2 简单——hotfix/tweak 没有 `build_mode`/`tdd_mode`/`review_mode` 需要联合决策(这三项仍是固定预设值),**只需要决定 `isolation` 一项**,多一个 full workflow 没有的选项——`current`: + + | 选项 | 方式 | 说明 | + |------|------|------| + | A | 当前分支直接工作 | 不新建分支/worktree,直接在当前所在分支上完成本次 change;适合当前分支本来就是目标分支的快速修复场景 | + | B | 创建分支 | 复用 `/comet-build` 已有的分支命名规范(`hotfix/YYYYMMDD/` / `tweak/YYYYMMDD/`) | + | C | 创建 Worktree | 复用 `/comet-build` 已有的 `using-git-worktrees` 技能加载步骤 | + + 这是用户决策点,按 `comet/reference/decision-point.md` 协议暂停;能力预检判定不可执行的选项要先移除;剩多个合法选项时不得自动选择。 + +3. 用户选择后: + - **A**:`comet state set isolation current`(触发 §5.4 的首次绑定,同时也触发 detached HEAD 拒绝——比旧设计"要等到第一次 `state check` 才发现"更早)。 + - **B**:确认分支名 → `git checkout -b ` → `comet state set isolation branch`。 + - **C**:加载 `using-git-worktrees` 技能创建隔离工作区 → `comet state set isolation worktree`。 + - B/C 选择后必须在新工作区重新 `comet state select `,再开始创建精简版产物——完全复用 `/comet-build` 第 210-216 行已经验证过的"新工作区重新绑定"逻辑,不发明新机制。 + +具体场景走查:change `my-fix`,workflow `hotfix`。`comet state init my-fix hotfix` 后 `isolation: null`。决策点暂停,展示能力预检结果(假设 `using-git-worktrees` 可用、当前仓库可以安全建分支)和三个选项。用户选 A(当前分支直接工作,此时用户在 `feature-payments` 上),执行 `comet state set my-fix isolation current`,`bound_branch` 自动写为 `feature-payments`(§5.4 首次绑定)。若用户后来手滑切到 `feature-billing` 又忘了,`comet-verify`/`comet-archive` 入口的漂移检测会照常拦下来(§5.4)——这正是 issue #190 想要的效果:不是不让默认落在当前分支,而是让"落在当前分支"这件事经过用户明确确认,而不是流程自己悄悄替用户决定。 + +- §5.6 涉及的所有 SKILL.md/规则文档改动,中文定稿并经用户确认后,必须在同一轮提交 review/合并之前完成英文同步,不允许带着中英不同步的中间状态进入 review(沿用原有的双语纪律,本次没有变化)。 + +### 5.8 实现步骤 + +- [ ] **Step 1:数据模型** — `classic-state.ts` 新增 `boundBranch` 字段、`bound_branch` wire key、序列化/反序列化;`classic-state-command.ts` 的 `sparseClassicState` 同步。先写 `test/domains/comet-classic/classic-state.test.ts` 的 round-trip 断言并确认 FAIL,再实现,再确认 PASS。`pnpm build`。追加 CLI 测试:`.comet.yaml` 校验接受 `bound_branch` 为已知可选字段。 +- [ ] **Step 2:共享判定模块** — 新建 `classic-branch-binding.ts`(`liveGitBranch` / `evaluateBranchBinding` / `healBoundBranch` / `driftBlockedMessage` / `driftStaleReason` / `unboundDetachedMessage`)。先写 `test/domains/comet-classic/classic-branch-binding.test.ts` 覆盖纯函数 `evaluateBranchBinding` 的五种判定(`not-applicable` / `ok` / `drift` / `needs-heal` / `unbound-detached`,含 detached HEAD 必须判 `drift` 而非跳过)和两个消息函数,确认 FAIL 后实现,确认 PASS。`pnpm build`。 +- [ ] **Step 3:sidecar 瘦身与漂移路由** — `classic-current-change.ts`:`CurrentChangeSelection` 去掉 `branch`;`selectCurrentChange()` 不再读写分支;`resolveCurrentChange()` 改为消费 Step 2 的共享判定。同步改 `classic-state-command.ts` 的 `selectChange`/`currentChange` 输出。先改测试(`classic-current-change.test.ts` 的 select/drift 用例),确认 FAIL,再实现。新增 hook-guard CLI 测试:`current` 模式下分支漂移必须阻断一次仓库源码写入。`pnpm build`。 +- [ ] **Step 4:`set isolation current` 首次绑定** — `MACHINE_OWNED_FIELDS` 加入 `bound_branch`;`setField` 里 `isolation` 分支新增首次绑定/清空副作用(含 detached HEAD 拒绝、重复调用不覆盖)。先写 CLI 测试覆盖:首次绑定写入当前分支、重复调用不覆盖、切离 `current` 时清空、detached HEAD 拒绝、直接 `set bound_branch` 被拒绝,确认 FAIL 后实现。`pnpm build`。 +- [ ] **Step 5:`state check` 漂移与惰性补绑** — `check()` 新增:`isolation === 'current'` 时用共享判定比较,`drift`/`unbound-detached` 时 `BLOCKED`,`needs-heal` 时惰性补绑并 PASS。先写 CLI 测试覆盖漂移 BLOCKED、sidecar 删除后仍能检出漂移、detached HEAD 下 BLOCKED、遗留未绑定 change 惰性补绑成功(对应 §5.5 缺口 B 的场景),确认 FAIL 后实现。`pnpm build`。 +- [ ] **Step 6:`rebind` 子命令** — `classic-state-events.ts` 的 `event` 类型加入 `'rebind'`;新增 `rebind` 函数与子命令分发。先写 CLI 测试覆盖:成功换绑并写审计事件、未绑定时拒绝、detached HEAD 时拒绝,确认 FAIL 后实现。`pnpm build`。 +- [ ] **Step 7:phase guard 漂移校验** — `classic-guard.ts` 新增 `boundBranchMatches()`,注册为 `guardBuildChecks`/`guardVerifyChecks`/`guardArchiveChecks` 的首个检查项。先写 guard CLI 测试(archive 阶段漂移必须 BLOCKED),确认 FAIL 后实现。注意回归:已有的"`isolation=current` 下 build 完整流程通过"测试在纯 git-free 环境下会因 `unbound-detached` 变红,需要按场景补 `gitInit` + 匹配的 `bound_branch`,不得放宽新检查来迁就旧测试。`pnpm build`。 +- [ ] **Step 7b:`preset-escalate` 同步清空 `bound_branch`** — `classic-transitions.ts` 第 162 行 `setField(classic, effects, 'isolation', null);` 之后加 `setField(classic, effects, 'boundBranch', null);`(见 §5.6"追加发现")。先写失败测试:`isolation: current` 且已绑定的 hotfix change,执行 `comet state transition preset-escalate` 后 `get bound_branch` 必须为 `null`,确认 FAIL 后实现,确认 PASS。`pnpm build`。 +- [ ] **Step 8:全量回归** — `npx vitest run`、`pnpm lint`、`pnpm build` 全部通过;修复过程中暴露的既有 fixture 按 Step 7 的原则调整,不弱化新检查。 +- [ ] **Step 9:hotfix/tweak 主动询问 isolation(§5.7 新设计)** — + 1. `classic-state-command.ts:511` 的 `init()` 把 `isolation: preset ? 'current' : null` 改为对所有 workflow 统一写 `isolation: null`。先写失败测试:`comet state init hotfix`/`tweak` 后 `get isolation` 返回 `null`(不再是 `current`),确认 FAIL 后实现,确认 PASS。`pnpm build`。 + 2. 这一步会让现有依赖"hotfix/tweak `init` 后 `isolation` 直接是 `current`"的既有测试全部变红——逐一检查这些测试,改为先补一次 `comet state set isolation current`(或 `branch`/`worktree`,按测试语境选)再继续,不得为了让旧测试通过而把 `init` 的默认值改回去。 + 3. 中文 `comet-hotfix/SKILL.md`、`comet-tweak/SKILL.md`:删除"默认 `isolation: current`"那句话,替换为 §5.7 设计的工作区隔离决策点(三选一表格 + 能力预检 + 分支命名规范复用 + `using-git-worktrees` 技能加载 + 新工作区重新 `select`)。请用户审阅中文措辞。 +- [ ] **Step 10:五份 SKILL.md 的漂移 decision-point(中文先行)** — 在 `comet-build`/`comet-verify`/`comet-archive`/`comet-hotfix`/`comet-tweak` **五份**中文 SKILL.md 的入口命令块后,插入"检测到 `BLOCKED`(分支漂移)后暂停、等待用户选择切回或 `rebind`"的 decision-point 段落。hotfix/tweak 也需要这段——即使入口多了 Step 9.3 的工作区隔离选择,`isolation: current` 绑定之后同样可能在流程中途发生分支漂移(例如 hotfix 进行到一半用户手滑切了分支),需要同样的 `BLOCKED` 处理。这条和 Step 9.3 的 hotfix/tweak 入口决策点是两个不同触发条件(一个在漂移检测时触发、一个在流程入口选择工作区隔离时触发一次),措辞上要能看出是两件事,不要写得像同一个决策点。确认后请用户审阅。 +- [ ] **Step 11:SKILL.md 英文同步** — 用户确认 Step 9.3、Step 10 的中文措辞后,在同一轮改动内完成 `comet-build`/`comet-verify`/`comet-archive`/`comet-hotfix`/`comet-tweak` 五份英文版同步,不得带着中英不同步的状态提交。同步更新 `assets/skills/comet/reference/comet-yaml-fields.md` 及中文版,补充 `bound_branch` 字段说明。 +- [ ] **Step 12:Changelog 与版本号** — 按 CLAUDE.md 规范,`package.json` 版本从 `0.4.0-beta.5` 升到 `0.4.0-beta.6`,`CHANGELOG.md` 顶部新增条目,只写用户可见行为(`comet state rebind` 新命令、current 模式漂移检测持久化、hotfix/tweak 的工作区隔离改为主动询问),不写 SKILL.md 措辞调整等开发过程内容。 + +### 5.9 兼容性 / 迁移策略 + +`bound_branch` 不加入 `REQUIRED_CLASSIC_KEYS`,不提升 `CLASSIC_MIGRATION_VERSION`。升级前已存在的 `isolation: current` change(缺少 `bound_branch` 字段——包括本次改动上线前用旧版 `init` 直写逻辑创建的存量 hotfix/tweak change,见 §5.5)读到时缺省为 `null`;漂移检查在 `bound_branch === null` 时视为"尚未绑定",走一次惰性首次绑定,存量 change 能在下一次经过 `state check`/guard 时自动补齐字段。本次改动上线后新创建的 hotfix/tweak change 不会再依赖这条兜底路径(§5.7 的决策点会显式调用 `set isolation current` 走正常首次绑定),惰性补绑只保留给上线前的存量数据。 + +--- + +## 6. 缺口 A:`comet status` 未暴露 isolation / 绑定分支 + +### 6.1 问题所在 + +issue #190 原文明确要求:"`comet status` should surface the selected isolation mode and current branch name"。这一条在 PR #203 和旧草稿里都从未被实现或提及。 + +核查 `app/commands/status.ts` 现状:`ChangeStatus` 接口(第 11-40 行)已有 `isolation: string | null` 字段,`comet status --json` 能看到;但**没有** `boundBranch` 字段;`displayStatus()`(文本输出,第 227-280 行)打印 `workflow`、`build_mode`、`runtime_mode` 等字段,**唯独没有打印 `isolation`**——JSON 里已有的字段,人读的默认输出反而看不到。 + +具体场景:用户在 `isolation: current` 的 change 上跑 `comet status`,期望看到绑定分支信息,实际输出只有 `workflow: full | build_mode: executing-plans`、`runtime_mode: ...` 等,看不到任何 isolation 相关内容;加 `--json` 能看到 `"isolation": "current"`,但看不到绑定的是哪个分支。 + +### 6.2 设计 + +`ChangeStatus` 新增 `boundBranch: string | null`(仅 `isolation === 'current'` 时非空)。`getActiveChanges()` 的四条分支(valid / invalid / unknown-keys / error)都填充 `boundBranch: projection.classic?.boundBranch ?? null`,与 `isolation` 字段同源同步。`displayStatus()` 紧跟在 `workflow: ... | build_mode: ...` 之后新增一行: + +```ts +if (c.isolation) { + const branchSuffix = c.isolation === 'current' && c.boundBranch ? ` (bound: ${c.boundBranch})` : ''; + console.log(` isolation: ${c.isolation}${branchSuffix}`); +} +``` + +修复后同样的场景,输出变为 `isolation: current (bound: feature-A)`;`--json` 与文本输出保持同一数据源,不再出现"JSON 有、文本没有"的不一致。 + +**依赖**:`boundBranch` 字段来自问题 1(§5.3)新增的 `ClassicState.boundBranch`,本节必须在问题 1 落地之后实现。 + +### 6.3 实现步骤 + +- [ ] Step 1:`test/app/` 下补充 `comet status` 的测试断言——`isolation: current` 且已绑定时文本输出包含 `isolation: current (bound: ...)`;`isolation` 为 `null` 时不打印该行;`--json` 输出包含 `boundBranch` 字段且与 `.comet.yaml` 一致。确认 FAIL。 +- [ ] Step 2:`app/commands/status.ts` 的 `ChangeStatus` 新增 `boundBranch`,四条 `getActiveChanges()` 分支填充;`displayStatus()` 新增 isolation 输出行。 +- [ ] Step 3:跑 Step 1 测试确认 PASS;跑 `npx vitest run` 确认无回归(此步不涉及 `domains/comet-classic`,无需 `pnpm build`)。 +- [ ] Step 4:提交,commit message 形如 `feat: surface isolation and bound branch in comet status`。 + +--- + +## 7. 问题 3:archive 分支处理步骤要按 isolation 区分(原描述已修正) + +### 7.1 旧描述错在哪,真实缺口是什么 + +旧草稿假设的 bug("verify 阶段已 push,archive 又产生一个没人管的新 commit")不成立。核实 master 现状: + +`comet-verify/SKILL.md` 第 176 行明确禁止 verify 阶段碰分支: + +> "不要在 verify 阶段处理、合并或丢弃分支,也不要写入 `branch_status: handled`;...分支收尾统一由 `/comet-archive` 在归档提交后执行。" + +`comet-archive/SKILL.md` 第 86-123 行的真实流程:Step 4 产生归档 commit → Step 5**紧接着**加载 Superpowers `finishing-a-development-branch`,暂停让用户选(本地合并到主分支 / 推送并创建 PR / 保持分支)→ 只有用户选择的动作执行完,才写 `comet state set branch_status handled`。归档 commit 必然被包含在用户选择的动作里(因为决策发生在 commit 之后),流程到这一步就是终态("完成"小节明确写"Comet 流程全部完成"),**不存在后续还会再产生一个 commit 的情况**。旧草稿"两次决策夹一个孤儿 commit"的场景在 master 上无法复现。 + +真实缺口是另一个问题:`grep -n "isolation\|current" comet-archive/SKILL.md comet-verify/SKILL.md` **零命中**——这两份 SKILL.md 完全没有针对 `isolation` 做任何区分。hotfix/tweak 默认 `isolation: current`(§5.7 已确认这是既有设计),意味着这些 change 走到 archive Step 5 时,会被套用 `finishing-a-development-branch` 那三个面向"独立 feature 分支"的选项——但 `isolation: current` 的定义就是"直接在当前分支上工作,没有另建分支","本地合并到主分支"这个选项对它是文不对题的(合并到哪?当前分支本身可能就是目标分支)。 + +### 7.2 设计:archive Step 5 按 isolation 分流,不新增字段 + +核心结论:**不需要新增 `branch_action` 字段**。旧设计新增字段是为了解决"push 状态记录不下来"的问题,但既然真实流程里分支处理决策和执行是同一步、原地完成、后面没有任何步骤依赖这个记录(archive 是终态),持久化"选了哪个动作"就是没有消费方的多余状态——加了只是徒增一个字段,不解决任何实际问题。 + +具体设计:`comet-archive/SKILL.md` Step 5 读取 `isolation` 做分流: + +- **`isolation !== 'current'`(`branch`/`worktree`,含遗留未设置的 change)**:完全不变,继续走现有的 `finishing-a-development-branch` 三选一流程。这条路径本来就没有 bug。 +- **`isolation === 'current'`**:跳过 `finishing-a-development-branch`(选项语义不适配),改为一个贴合"直接在当前分支工作"场景的两选一 decision-point: + 1. 推送当前分支(`git push`,若已有上游分支直接 push,若没有则 `git push -u origin <当前分支名>`) + 2. 暂不推送,保留本地(不执行任何 git 操作) + + 执行完选择的动作后,同样写 `comet state set branch_status handled`——复用已有字段,不新增。archive guard(`branchStatusHandled` 检查)不需要改动,它只关心 `branch_status === 'handled'`,不关心走的是哪条分支。 + +具体场景走查:change `my-fix` 是 hotfix,`isolation: current`,绑定在 `feature-A`(问题 1 的 `bound_branch`)。归档 Step 4 提交后,Step 5 检测到 `isolation: current`,不再询问"本地合并到主分支"(`feature-A` 本身可能就是用户打算长期工作的分支,没有另一个"主分支"要合并进去),而是问"要不要现在 push `feature-A`"。用户选 1,执行 `git push`,随后 `branch_status: handled`。全程只有一次决策,不产生"合并到不存在的目标"这种无意义选项。 + +**目标**:`isolation: current` 的 change 在 archive 分支处理步骤上得到与其语义匹配的决策选项;`branch`/`worktree` 模式行为完全不变,零回归风险。 +**非目标**:不改变 `finishing-a-development-branch`(Superpowers 技能,不可修改)本身的行为;不引入新字段、不新增 phase gate;不重新讨论 verify 阶段是否该处理分支(master 现状已经是"不处理",且是正确的,维持不变)。 + +### 7.3 实现步骤 + +- [ ] Step 1:在 `test/domains/skill/skills.test.ts`(或对应 SKILL.md 内容校验测试,视仓库现有 skill 文本测试的组织方式而定)新增断言:`comet-archive/SKILL.md` 中文版包含 `isolation === 'current'` 分流后的两选一措辞,且不再无差别套用 `finishing-a-development-branch` 三选一。若仓库当前没有对 SKILL.md 正文做断言式测试(只做结构/存在性校验),此步改为人工验收清单项,不强行新增脆弱的文本匹配测试。 +- [ ] Step 2:编辑 `assets/skills-zh/comet-archive/SKILL.md` 第 105-123 行附近的 Step 5:在加载 `finishing-a-development-branch` 之前插入 `isolation` 判断——`current` 时改为两选一 decision-point(推送当前分支 / 暂不推送),其余分支保持原有的 `finishing-a-development-branch` 三选一不变;两条路径收尾都执行 `comet state set branch_status handled` → `comet guard archive` → `comet state clear-selection`。请用户审阅中文措辞,重点检查是否与 §5.7 hotfix/tweak 入口的工作区隔离决策点混淆——这里的两选一针对的是"archive 收尾时要不要 push",§5.7 是"流程一开始选 current/branch/worktree 哪一种",两个是不同触发时机的独立决策点,措辞上要能看出是两件事。 +- [ ] Step 3:中文措辞确认后,同一轮同步英文版 `assets/skills/comet-archive/SKILL.md`,不留中英不同步的中间状态提交(§5.7 的流程约束同样适用于本节)。 +- [ ] Step 4:全量回归 `npx vitest run && pnpm lint`(本节不涉及 `domains/comet-classic/*.ts` 代码改动,只改 SKILL.md,无需 `pnpm build`)。 +- [ ] Step 5:Changelog——若与问题 1 在同一发布周期内完成,追加到同一个 `0.4.0-beta.6` 条目下;只写用户可见行为,例如:"current 模式归档时不再套用面向独立分支的三选一,改为贴合当前分支场景的推送/暂不推送二选一"。 + +--- + +## 8. 数据模型变更汇总 + +| 字段 | 定义位置 | wire key | 机器拥有 | 加入 REQUIRED_CLASSIC_KEYS | 说明 | +|---|---|---|---|---|---| +| `boundBranch` | `classic-state.ts` | `bound_branch` | 是 | 否 | 问题 1,§5.3 | +| `ChangeStatus.boundBranch` | `app/commands/status.ts` | 不适用(非 `.comet.yaml` 字段,衍生自 `boundBranch`) | 不适用 | 不适用 | 缺口 A,§6.2 | + +问题 3 核实后不新增字段(§7.2)。唯一新增的 `.comet.yaml` 字段是 `bound_branch`,不加入 `REQUIRED_CLASSIC_KEYS`,不 bump `CLASSIC_MIGRATION_VERSION`(现为 `1`),存量 in-flight change 不受影响,不需要 migration。 + +## 9. 未决问题 + +1. `state rebind` 是否需要限制调用来源(例如只允许特定 `phase` 执行)?倾向不加限制,交互确认已在 SKILL.md 层的 decision-point 协议完成,留待实现阶段视测试情况再定。 +2. §5.5 缺口 B 提到的 detached HEAD 场景,是否要在 `init` 阶段为 preset workflow 补一次前置检查?倾向不做(YAGNI:多一处检查点,换来的只是把同一个错误提示提前一步弹出),留待实现中如有更多信号再评估。 +3. §7.2 `isolation: current` 的推送选项,"暂不推送"之后是否需要在下一次 `/comet` 相关命令入口提醒用户"还有未推送的分支"?倾向不做——这已经超出 issue #190 和本次 review 反馈的范围,属于独立的用户体验改进,留待后续单独提出。 diff --git a/domains/comet-classic/classic-branch-binding.ts b/domains/comet-classic/classic-branch-binding.ts new file mode 100644 index 00000000..a5b30cda --- /dev/null +++ b/domains/comet-classic/classic-branch-binding.ts @@ -0,0 +1,150 @@ +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 function isGitWorkTree(cwd: string): boolean { + try { + return ( + execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() === 'true' + ); + } catch { + return false; + } +} + +export type BranchBindingVerdict = + | { status: 'not-applicable' } + | { status: 'ok' } + | { status: 'needs-heal'; branch: string } + | { status: 'unbound-detached' } + | { status: 'drift'; boundBranch: string; currentBranch: string | null }; + +export const BOUND_BRANCH_ISOLATIONS = ['current', 'branch', 'worktree'] as const; + +export function requiresBranchBinding(isolation: string | null): boolean { + return BOUND_BRANCH_ISOLATIONS.includes(isolation as (typeof BOUND_BRANCH_ISOLATIONS)[number]); +} + +export function evaluateBranchBinding(input: { + isolation: string | null; + boundBranch: string | null; + currentBranch: string | null; + gitWorkTree?: boolean; +}): BranchBindingVerdict { + if (!requiresBranchBinding(input.isolation)) return { status: 'not-applicable' }; + if (input.boundBranch === null && input.currentBranch === null && input.gitWorkTree === false) { + 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 }; +} + +/** + * A `BranchBindingVerdict` joined with the live git facts it was computed + * from, so callers never re-derive them (or re-read the state file). + */ +export type BranchBindingOutcome = (BranchBindingVerdict | { status: 'healed'; branch: string }) & { + bindingRequired: boolean; + currentBranch: string | null; +}; + +/** + * Single entry point for every consumer of the branch binding: reads + * isolation/bound_branch straight from `.comet.yaml` (typed — a yaml `null` + * is a JS null, never a "null" string), spawns git lazily, and optionally + * performs the lazy heal. Read-only paths (the PreToolUse hook) must pass + * `heal: false` so resolving never writes to disk. + */ +export async function resolveBranchBinding( + changeDir: string, + options: { heal: boolean; cwd: string }, +): Promise { + const file = path.join(changeDir, '.comet.yaml'); + const document = parseDocument(await fs.readFile(file, 'utf8'), { uniqueKeys: false }); + if (document.errors.length > 0) { + throw new Error(`Invalid .comet.yaml: ${document.errors[0].message}`); + } + const record = (document.toJS() ?? {}) as Record; + const isolation = typeof record.isolation === 'string' ? record.isolation : null; + const boundBranch = + typeof record.bound_branch === 'string' && record.bound_branch !== '' + ? record.bound_branch + : null; + const bindingRequired = requiresBranchBinding(isolation); + const currentBranch = liveGitBranch(options.cwd); + const gitWorkTree = + bindingRequired && boundBranch === null && currentBranch === null + ? isGitWorkTree(options.cwd) + : true; + const verdict = evaluateBranchBinding({ isolation, boundBranch, currentBranch, gitWorkTree }); + if (verdict.status === 'needs-heal' && options.heal) { + await healBoundBranch(changeDir, verdict.branch); + return { status: 'healed', branch: verdict.branch, bindingRequired, currentBranch }; + } + return { ...verdict, bindingRequired, 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 a branch-bound workspace mode 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..2e8fe067 100644 --- a/domains/comet-classic/classic-current-change.ts +++ b/domains/comet-classic/classic-current-change.ts @@ -1,7 +1,11 @@ -import { execFileSync } from 'child_process'; import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; +import { + driftStaleReason, + resolveBranchBinding, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { assertOpenSpecChangeName } from './classic-paths.js'; import { readClassicState } from './classic-store.js'; @@ -20,19 +24,6 @@ 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); } @@ -84,13 +75,14 @@ function parseSelection(source: string): CurrentChangeSelection { throw new Error('current change selection change must be a string'); } assertOpenSpecChangeName(record.change); - if (record.branch !== null && typeof record.branch !== 'string') { + // `branch` may be absent in files written by early 0.4.0-beta.6 builds. + if (record.branch !== undefined && 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, + branch: (record.branch as string | null | undefined) ?? null, }; } @@ -99,10 +91,20 @@ export async function selectCurrentChange( changeName: string, ): Promise { await validateActiveChange(projectRoot, changeName); + const outcome = await resolveBranchBinding(changeDirectory(projectRoot, changeName), { + heal: true, + cwd: projectRoot, + }); + if (outcome.status === 'drift') { + throw new Error(driftStaleReason(changeName, outcome.boundBranch, outcome.currentBranch)); + } + if (outcome.status === 'unbound-detached') { + throw new Error(unboundDetachedMessage(changeName)); + } const selection: CurrentChangeSelection = { version: 1, change: changeName, - branch: currentBranch(projectRoot), + branch: outcome.currentBranch, }; const file = currentChangeFile(projectRoot); const temporary = `${file}.${randomUUID()}.tmp`; @@ -140,11 +142,30 @@ export async function resolveCurrentChange(projectRoot: string): Promise { return pass(); } +async function boundBranchMatches(changeDir: string, change: string): Promise { + let outcome: BranchBindingOutcome; + try { + outcome = await resolveBranchBinding(changeDir, { heal: true, cwd: process.cwd() }); + } catch (error) { + throw new GuardFailure(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + } + switch (outcome.status) { + case 'drift': + return fail(driftBlockedMessage(change, outcome.boundBranch, outcome.currentBranch)); + case 'unbound-detached': + return fail(unboundDetachedMessage(change)); + case 'healed': + return pass(`bound_branch lazily set to ${outcome.branch}`); + case 'needs-heal': + case 'ok': + case 'not-applicable': + return pass(); + default: { + const exhaustive: never = outcome; + throw new Error(`unhandled branch binding status: ${JSON.stringify(exhaustive)}`); + } + } +} + async function isolationSelected(changeDir: string, change: string): Promise { const isolation = await readField(changeDir, 'isolation'); - 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' ? '' : ''; + if (isolation === 'current' || isolation === 'branch' || isolation === 'worktree') return pass(); + const allowedValues = ''; 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 must be 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}`, ); } @@ -811,6 +840,7 @@ async function guardBuildChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches workspace mode', () => boundBranchMatches(changeDir, change)), check('isolation selected', () => isolationSelected(changeDir, change)), check('build_mode selected', () => buildModeSelected(changeDir, change)), check('build_mode allowed for workflow', () => buildModeAllowedForWorkflow(changeDir)), @@ -846,6 +876,7 @@ async function guardVerifyChecks( run: ClassicRunContext['run'], ): Promise { return runChecks(output, [ + check('bound branch matches workspace mode', () => 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 +895,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 workspace mode', () => 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(''), @@ -885,11 +921,14 @@ async function applyStateUpdate( change: string, changeDir: string, phase: string, - context: ClassicRunContext, ): Promise { const event = CLASSIC_GUARD_TRANSITION_EVENT[phase as ClassicPhase]; if (!event) return; + // Re-read instead of reusing the run context captured before the checks: + // boundBranchMatches may have lazily healed bound_branch on disk, and a + // stale projection would write the pre-heal null back over it. + const context = await ensureClassicRuntimeRun(changeDir); const result = applyClassicTransition(context.classic, event); await transitionClassicRuntimeRun(changeDir, result.classic, context.run, { event, @@ -946,7 +985,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(''); @@ -956,7 +995,7 @@ export const classicGuardCommand: ClassicCommandHandler = async (args, options) output.stderr.push(''); output.stderr.push(green('ALL CHECKS PASSED — ready for next phase')); if (flag === '--apply') { - await applyStateUpdate(output, change, changeDir, phase, runContext); + await applyStateUpdate(output, change, changeDir, phase); } return output.toResult(0); } catch (error) { diff --git a/domains/comet-classic/classic-hook-guard.ts b/domains/comet-classic/classic-hook-guard.ts index fb6fd291..aa4bfce3 100644 --- a/domains/comet-classic/classic-hook-guard.ts +++ b/domains/comet-classic/classic-hook-guard.ts @@ -1,6 +1,11 @@ import { existsSync, promises as fs, readFileSync } from 'fs'; import path from 'path'; import type { ClassicCommandHandler, ClassicCommandResult } from './classic-cli.js'; +import { + driftStaleReason, + resolveBranchBinding, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { resolveCurrentChange } from './classic-current-change.js'; import { readClassicState, readLegacyState } from './classic-store.js'; import type { ClassicPhase, ClassicState } from './classic-state.js'; @@ -310,7 +315,33 @@ async function repoSourceGoverningChange( ), }; } - if (active.length === 1) return active[0]; + if (active.length === 1) { + // No selection file exists, so the drift check inside + // resolveCurrentChange never ran — enforce the branch binding here + // (read-only) before letting the sole active change govern the write. + const sole = active[0]; + if (sole.changeDir !== null) { + const outcome = await resolveBranchBinding(sole.changeDir, { + heal: false, + cwd: projectRoot, + }); + const name = governingChangeName(sole) ?? 'unknown'; + if (outcome.status === 'drift') { + return { + blockedResult: blockedStaleSelection( + relativePath, + driftStaleReason(name, outcome.boundBranch, outcome.currentBranch), + ), + }; + } + if (outcome.status === 'unbound-detached') { + return { + blockedResult: blockedStaleSelection(relativePath, unboundDetachedMessage(name)), + }; + } + } + return sole; + } return { blockedResult: blockedMultipleChanges( relativePath, diff --git a/domains/comet-classic/classic-state-command.ts b/domains/comet-classic/classic-state-command.ts index 073199ad..a16e1f46 100644 --- a/domains/comet-classic/classic-state-command.ts +++ b/domains/comet-classic/classic-state-command.ts @@ -9,6 +9,16 @@ import { resolveCurrentChange, selectCurrentChange, } from './classic-current-change.js'; +import { + driftBlockedMessage, + evaluateBranchBinding, + healBoundBranch, + isGitWorkTree, + liveGitBranch, + requiresBranchBinding, + resolveBranchBinding, + unboundDetachedMessage, +} from './classic-branch-binding.js'; import { collectClassicEvidence } from './classic-evidence.js'; import { openSpecChangeNameError, resolveClassicChangeDirectory } from './classic-paths.js'; import { resolveClassicStepId } from './classic-resolver.js'; @@ -45,6 +55,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)), @@ -289,6 +300,7 @@ function sparseClassicState(record: Record): ClassicState { ['current', 'branch', 'worktree'] as const, 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'), @@ -443,7 +455,39 @@ async function setField( validateSetValue(field, value); const { file, directory } = await stateFile(name); const document = await readDocument(file); + const previousRecord = (document.toJS() ?? {}) as Record; document.set(field, parsedValue(field, value)); + if (field === 'isolation') { + if (requiresBranchBinding(value)) { + const previousIsolation = + typeof previousRecord.isolation === 'string' ? previousRecord.isolation : null; + const existing = previousRecord.bound_branch; + const alreadyBound = typeof existing === 'string' && existing !== ''; + // Switching between workspace modes is an explicit new workspace + // decision and re-points the binding; repeating the same mode keeps + // the sticky binding that drift checks rely on. + if (!alreadyBound || previousIsolation !== value) { + const currentBranch = liveGitBranch(process.cwd()); + const verdict = evaluateBranchBinding({ + isolation: value, + boundBranch: null, + currentBranch, + gitWorkTree: currentBranch === null ? isGitWorkTree(process.cwd()) : true, + }); + if (verdict.status === 'needs-heal') { + document.set('bound_branch', verdict.branch); + } else if (verdict.status === 'unbound-detached') { + fail( + `ERROR: cannot bind isolation=${value} while HEAD is detached; checkout a branch first`, + ); + } else { + document.set('bound_branch', null); + } + } + } else { + document.set('bound_branch', null); + } + } const run = await readRunState(directory); const projection = parseClassicStateDocument(document.toJS() as Record, run); if (projection.run) { @@ -508,7 +552,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 +586,10 @@ 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']; + const allowedIsolation = ['current', 'branch', 'worktree']; if (!allowedIsolation.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 current, branch, or worktree, got '${isolation || 'null'}'`, ); } if (!['subagent-driven-development', 'executing-plans', 'direct'].includes(buildMode)) { @@ -841,6 +884,29 @@ 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 binding = await resolveBranchBinding(directory, { heal: true, cwd: process.cwd() }); + if (binding.bindingRequired) { + switch (binding.status) { + case 'drift': + reject(driftBlockedMessage(name, binding.boundBranch, binding.currentBranch)); + break; + case 'unbound-detached': + reject(unboundDetachedMessage(name)); + break; + case 'healed': + pass(`bound_branch lazily set to ${binding.branch}`); + break; + case 'needs-heal': + case 'ok': + case 'not-applicable': + pass('bound_branch matches current branch'); + break; + default: { + const exhaustive: never = binding; + throw new Error(`unhandled branch binding status: ${JSON.stringify(exhaustive)}`); + } + } + } output.stdout.push(''); if (blocked) { output.stderr.push(red('BLOCKED — fix failing checks before proceeding')); @@ -1235,16 +1301,44 @@ async function selectChange(output: CommandOutput, name: string): Promise validateChangeName(name); try { const selection = await selectCurrentChange(process.cwd(), name); + const boundBranch = await readField(name, 'bound_branch'); + const bound = boundBranch && boundBranch !== 'null' ? boundBranch : null; output.stderr.push( - green( - `[SELECTED] current change: ${selection.change}${selection.branch ? ` (branch: ${selection.branch})` : ''}`, - ), + green(`[SELECTED] current change: ${selection.change}${bound ? ` (branch: ${bound})` : ''}`), ); } catch (error) { fail(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } } +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 ' 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 currentChange(output: CommandOutput): Promise { const resolution = await resolveCurrentChange(process.cwd()); if (resolution.status === 'selected') { @@ -1299,6 +1393,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..427d0055 100644 --- a/domains/comet-classic/classic-state.ts +++ b/domains/comet-classic/classic-state.ts @@ -33,6 +33,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; @@ -70,6 +71,7 @@ export const CLASSIC_WIRE_KEYS = [ 'tdd_mode', 'review_mode', 'isolation', + 'bound_branch', 'verify_mode', 'auto_transition', 'base_ref', @@ -211,6 +213,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'), @@ -300,6 +303,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, diff --git a/domains/comet-classic/classic-transitions.ts b/domains/comet-classic/classic-transitions.ts index 33738bbe..f9e9bdc8 100644 --- a/domains/comet-classic/classic-transitions.ts +++ b/domains/comet-classic/classic-transitions.ts @@ -160,6 +160,7 @@ export function applyClassicTransition( setField(classic, effects, 'tddMode', null); setField(classic, effects, 'reviewMode', null); setField(classic, effects, 'isolation', null); + setField(classic, effects, 'boundBranch', null); setField(classic, effects, 'verifyMode', null); setField(classic, effects, 'directOverride', null); } else if (event === 'archive-confirm') { diff --git a/domains/comet-classic/classic-validate-command.ts b/domains/comet-classic/classic-validate-command.ts index d1b474d6..95fefb1e 100644 --- a/domains/comet-classic/classic-validate-command.ts +++ b/domains/comet-classic/classic-validate-command.ts @@ -131,6 +131,12 @@ export const classicValidateCommand: ClassicCommandHandler = async (args) => { fail(`${field}='${value}' is not valid. Expected: ${values.join(' ')}`); } } + if (Object.prototype.hasOwnProperty.call(record, 'bound_branch')) { + const value = record.bound_branch; + if (value !== null && typeof value !== 'string') { + fail(`bound_branch='${text(value)}' is not a string or null`); + } + } if (Object.prototype.hasOwnProperty.call(record, 'verify_failures')) { const value = record.verify_failures; if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { diff --git a/domains/skill/find.ts b/domains/skill/find.ts index bf237d7a..b30ec8b7 100644 --- a/domains/skill/find.ts +++ b/domains/skill/find.ts @@ -177,7 +177,12 @@ async function hashSkillDirectory(root: string): Promise { function skillDescription(skillMd: string): string { const match = skillMd.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u); if (!match) return ''; - const document = parse(match[1]) as unknown; + let document: unknown; + try { + document = parse(match[1]) as unknown; + } catch { + return ''; + } if (!document || typeof document !== 'object' || Array.isArray(document)) return ''; const description = (document as Record).description; return typeof description === 'string' ? description : ''; 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/app/status.test.ts b/test/app/status.test.ts index 7bffde41..b2a9b4da 100644 --- a/test/app/status.test.ts +++ b/test/app/status.test.ts @@ -33,6 +33,21 @@ async function snapshotChange(changeDir: string): Promise<{ files: string[]; yam }; } +async function setCometYamlField( + changeDir: string, + field: string, + value: string | null, +): Promise { + const yamlPath = path.join(changeDir, '.comet.yaml'); + const yaml = await fs.readFile(yamlPath, 'utf8'); + const rendered = value === null ? 'null' : value; + const pattern = new RegExp(`^${field}:.*$`, 'mu'); + const next = pattern.test(yaml) + ? yaml.replace(pattern, `${field}: ${rendered}`) + : `${yaml.trimEnd()}\n${field}: ${rendered}\n`; + await fs.writeFile(yamlPath, next); +} + describe('status command', () => { let tmpDir: string; @@ -99,6 +114,7 @@ describe('status command', () => { phase: null, buildMode: null, isolation: null, + boundBranch: null, verifyMode: null, verifyResult: null, designDoc: null, @@ -140,6 +156,9 @@ describe('status command', () => { tasksTotal: 1, commandChecks: null, }); + expect(changes.every((change: { boundBranch?: unknown }) => 'boundBranch' in change)).toBe( + true, + ); }); it('includes latest build and verify command checks for a synchronized Comet Run', async () => { @@ -271,6 +290,60 @@ describe('status command', () => { expect(output).toContain('run_step: full.build.plan'); }); + it('prints branch-bound workspace modes with bound branch and omits bound suffix for null isolation', async () => { + const changesDir = path.join(tmpDir, 'openspec', 'changes'); + state(tmpDir, 'init', 'current-bound', 'full'); + await setCometYamlField(path.join(changesDir, 'current-bound'), 'isolation', 'current'); + await setCometYamlField(path.join(changesDir, 'current-bound'), 'bound_branch', 'feature-A'); + + state(tmpDir, 'init', 'branch-bound', 'full'); + await setCometYamlField(path.join(changesDir, 'branch-bound'), 'isolation', 'branch'); + await setCometYamlField(path.join(changesDir, 'branch-bound'), 'bound_branch', 'feature-B'); + + state(tmpDir, 'init', 'worktree-bound', 'full'); + await setCometYamlField(path.join(changesDir, 'worktree-bound'), 'isolation', 'worktree'); + await setCometYamlField(path.join(changesDir, 'worktree-bound'), 'bound_branch', 'feature-C'); + + state(tmpDir, 'init', 'null-bound', 'full'); + await setCometYamlField(path.join(changesDir, 'null-bound'), 'bound_branch', 'feature-D'); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + let output: string; + try { + await statusCommand(tmpDir); + output = log.mock.calls.map((call) => call.join(' ')).join('\n'); + } finally { + log.mockRestore(); + } + + expect(output).toContain('isolation: current (bound: feature-A)'); + expect(output).toContain('isolation: branch (bound: feature-B)'); + expect(output).toContain('isolation: worktree (bound: feature-C)'); + expect(output).not.toContain('feature-D'); + }); + + it('includes boundBranch in JSON status output', async () => { + const changeDir = path.join(tmpDir, 'openspec', 'changes', 'current-bound'); + state(tmpDir, 'init', 'current-bound', 'full'); + await setCometYamlField(changeDir, 'isolation', 'current'); + await setCometYamlField(changeDir, 'bound_branch', 'feature-A'); + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + let json: string; + try { + await statusCommand(tmpDir, { json: true }); + json = log.mock.calls.map((call) => call.join(' ')).join('\n'); + } finally { + log.mockRestore(); + } + + expect(JSON.parse(json).changes[0]).toMatchObject({ + name: 'current-bound', + isolation: 'current', + boundBranch: 'feature-A', + }); + }); + it('keeps legacy state without a Run byte-for-byte read-only in text and JSON status', async () => { const changeDir = path.join(tmpDir, 'openspec', 'changes', 'next-verify'); state(tmpDir, 'init', 'next-verify', 'full'); 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..48133fea --- /dev/null +++ b/test/domains/comet-classic/classic-branch-binding.test.ts @@ -0,0 +1,177 @@ +import { execFileSync } from 'child_process'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + evaluateBranchBinding, + resolveBranchBinding, + driftBlockedMessage, + driftStaleReason, +} from '../../../domains/comet-classic/classic-branch-binding.js'; + +describe('evaluateBranchBinding', () => { + it('is not applicable before isolation is selected', () => { + expect( + evaluateBranchBinding({ isolation: null, boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'not-applicable' }); + }); + it.each(['current', 'branch', 'worktree'])( + 'passes for isolation: %s when the bound branch matches the current branch', + (isolation) => { + expect( + evaluateBranchBinding({ isolation, boundBranch: 'feature-A', currentBranch: 'feature-A' }), + ).toEqual({ status: 'ok' }); + }, + ); + it.each(['current', 'branch', 'worktree'])( + 'reports drift for isolation: %s when the current branch differs', + (isolation) => { + expect( + evaluateBranchBinding({ isolation, boundBranch: 'feature-A', currentBranch: 'feature-B' }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: 'feature-B' }); + }, + ); + it.each(['current', 'branch', 'worktree'])( + 'reports drift for isolation: %s when bound but HEAD is detached', + (isolation) => { + expect( + evaluateBranchBinding({ isolation, boundBranch: 'feature-A', currentBranch: null }), + ).toEqual({ status: 'drift', boundBranch: 'feature-A', currentBranch: null }); + }, + ); + it.each(['current', 'branch', 'worktree'])( + 'requests a lazy heal for isolation: %s when unbound on a real branch', + (isolation) => { + expect( + evaluateBranchBinding({ isolation, boundBranch: null, currentBranch: 'feature-A' }), + ).toEqual({ status: 'needs-heal', branch: 'feature-A' }); + }, + ); + it.each(['current', 'branch', 'worktree'])( + 'refuses to lazy-bind isolation: %s when unbound and detached', + (isolation) => { + expect(evaluateBranchBinding({ isolation, boundBranch: null, currentBranch: null })).toEqual({ + status: 'unbound-detached', + }); + }, + ); + it.each(['current', 'branch', 'worktree'])( + 'skips branch binding for isolation: %s when the project is not a git worktree', + (isolation) => { + expect( + evaluateBranchBinding({ + isolation, + boundBranch: null, + currentBranch: null, + gitWorkTree: false, + }), + ).toEqual({ status: 'not-applicable' }); + }, + ); +}); + +describe('resolveBranchBinding', () => { + let root: string; + let changeDir: string; + + function git(...args: string[]): string { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + } + + async function seedState(lines: string[]): Promise { + await fs.writeFile(path.join(changeDir, '.comet.yaml'), lines.join('\n') + '\n'); + } + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-branch-binding-')); + changeDir = path.join(root, 'openspec', 'changes', 'demo'); + await fs.mkdir(changeDir, { recursive: true }); + git('init', '-b', 'main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test User'); + await fs.writeFile(path.join(root, 'README.md'), '# Test\n'); + git('add', 'README.md'); + git('commit', '-m', 'init'); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + it('heals an unbound binding-isolation change when heal is enabled', async () => { + await seedState(['workflow: hotfix', 'phase: build', 'isolation: current']); + + const outcome = await resolveBranchBinding(changeDir, { heal: true, cwd: root }); + + expect(outcome).toMatchObject({ status: 'healed', branch: 'main', currentBranch: 'main' }); + expect(await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8')).toContain( + 'bound_branch: main', + ); + }); + + it('reports needs-heal without writing when heal is disabled', async () => { + await seedState(['workflow: hotfix', 'phase: build', 'isolation: current']); + + const outcome = await resolveBranchBinding(changeDir, { heal: false, cwd: root }); + + expect(outcome).toMatchObject({ status: 'needs-heal', branch: 'main' }); + expect(await fs.readFile(path.join(changeDir, '.comet.yaml'), 'utf8')).not.toContain( + 'bound_branch', + ); + }); + + it('reports drift with the live current branch', async () => { + await seedState([ + 'workflow: hotfix', + 'phase: build', + 'isolation: branch', + 'bound_branch: feature-A', + ]); + git('switch', '-c', 'feature-B'); + + const outcome = await resolveBranchBinding(changeDir, { heal: true, cwd: root }); + + expect(outcome).toMatchObject({ + status: 'drift', + boundBranch: 'feature-A', + currentBranch: 'feature-B', + }); + }); + + it('is not applicable before isolation requires a binding', async () => { + await seedState(['workflow: full', 'phase: open', 'isolation: null']); + + const outcome = await resolveBranchBinding(changeDir, { heal: true, cwd: root }); + + expect(outcome).toMatchObject({ + status: 'not-applicable', + bindingRequired: false, + currentBranch: 'main', + }); + }); + + it('treats an explicit null bound_branch the same as an absent one', async () => { + await seedState(['workflow: hotfix', 'phase: build', 'isolation: current', 'bound_branch: null']); + + const outcome = await resolveBranchBinding(changeDir, { heal: false, cwd: root }); + + expect(outcome).toMatchObject({ status: 'needs-heal', branch: 'main' }); + }); +}); + +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..f3a048c9 100644 --- a/test/domains/comet-classic/classic-contract.test.ts +++ b/test/domains/comet-classic/classic-contract.test.ts @@ -200,6 +200,7 @@ function legacyProjection(document: Record): Record { } } -async function seedActiveChange(root: string, name: string, archived: boolean): Promise { +async function seedActiveChange( + root: string, + name: string, + archived: boolean, + options: { isolation?: string; boundBranch?: string | null } = {}, +): Promise { + const { isolation = 'branch', boundBranch = null } = options; 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: branch', - 'verify_mode: null', - 'verify_result: pending', - 'verified_at: null', - `archived: ${archived}`, - '', - ].join('\n'), - ); + const lines = [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/design.md', + 'plan: null', + 'build_mode: executing-plans', + `isolation: ${isolation}`, + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + `archived: ${archived}`, + ]; + if (boundBranch !== null) lines.push(`bound_branch: ${boundBranch}`); + lines.push(''); + await fs.writeFile(path.join(changeDir, '.comet.yaml'), lines.join('\n')); } describe('Classic current change selection', () => { @@ -62,7 +67,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 selection branch', async () => { await seedActiveChange(root, 'change-a', false); const selected = await selectCurrentChange(root, 'change-a'); @@ -72,6 +77,16 @@ describe('Classic current change selection', () => { expect((await fs.readdir(path.join(root, '.comet'))).sort()).toEqual(['current-change.json']); }); + it('refuses to select a change whose bound branch drifted', async () => { + await seedActiveChange(root, 'change-a', false, { isolation: 'current', boundBranch: 'main' }); + git(root, 'switch', '-c', 'other'); + + await expect(selectCurrentChange(root, 'change-a')).rejects.toThrow( + "change 'change-a' is bound to branch 'main', but current branch is 'other'", + ); + expect(await exists(currentChangeFile(root))).toBe(false); + }); + it('rejects missing, archived, and invalid changes', async () => { await expect(selectCurrentChange(root, '../escape')).rejects.toThrow('Invalid change name'); await expect(selectCurrentChange(root, 'missing')).rejects.toThrow('active change'); @@ -79,16 +94,101 @@ describe('Classic current change selection', () => { await expect(selectCurrentChange(root, 'archived-change')).rejects.toThrow('archived'); }); - it('marks a selection stale after the branch changes', async () => { - await seedActiveChange(root, 'change-a', false); + it('marks a selection stale after the bound branch drifts', async () => { + await seedActiveChange(root, 'change-a', false, { isolation: 'current', boundBranch: 'main' }); await selectCurrentChange(root, 'change-a'); git(root, 'switch', '-c', 'other'); expect(await resolveCurrentChange(root)).toEqual({ status: 'stale', - reason: "current change 'change-a' was selected on branch 'main', current branch is 'other'", + reason: "change 'change-a' is bound to branch 'main', but current branch is 'other'", + }); + }); + + it.each(['branch', 'worktree'])( + 'marks isolation: %s selections stale after the bound branch drifts', + async (isolation) => { + await seedActiveChange(root, 'change-a', false, { isolation, boundBranch: '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.each(['current', 'branch', 'worktree'])( + 'lazily binds isolation: %s selections with no bound branch', + async (isolation) => { + await seedActiveChange(root, 'change-a', false, { isolation }); + await selectCurrentChange(root, 'change-a'); + expect( + await fs.readFile( + path.join(root, 'openspec', 'changes', 'change-a', '.comet.yaml'), + 'utf8', + ), + ).toContain('bound_branch: main'); + + expect(await resolveCurrentChange(root)).toEqual({ + status: 'selected', + selection: { version: 1, change: 'change-a', branch: 'main' }, + }); + expect( + await fs.readFile( + path.join(root, 'openspec', 'changes', 'change-a', '.comet.yaml'), + 'utf8', + ), + ).toContain('bound_branch: main'); + }, + ); + + it('marks isolation: null selections stale after switching branches', async () => { + await seedActiveChange(root, 'change-a', false, { isolation: 'null' }); + await selectCurrentChange(root, 'change-a'); + + git(root, 'switch', '-c', 'other'); + + expect(await resolveCurrentChange(root)).toEqual({ + status: 'stale', + reason: + "current change 'change-a' was selected on branch 'main', current branch is 'other'", + }); + }); + + it('tolerates legacy selections without a recorded branch', async () => { + await seedActiveChange(root, 'change-a', false, { isolation: 'null' }); + await fs.mkdir(path.dirname(currentChangeFile(root)), { recursive: true }); + await fs.writeFile( + currentChangeFile(root), + JSON.stringify({ version: 1, change: 'change-a' }) + '\n', + ); + git(root, 'switch', '-c', 'other'); + + expect(await resolveCurrentChange(root)).toEqual({ + status: 'selected', + selection: { version: 1, change: 'change-a', branch: null }, + }); + }); + + it('does not write bound_branch while resolving an unbound selection', async () => { + await seedActiveChange(root, 'change-a', false, { isolation: 'current' }); + await fs.mkdir(path.dirname(currentChangeFile(root)), { recursive: true }); + await fs.writeFile( + currentChangeFile(root), + JSON.stringify({ version: 1, change: 'change-a', branch: 'main' }) + '\n', + ); + + expect(await resolveCurrentChange(root)).toEqual({ + status: 'selected', + selection: { version: 1, change: 'change-a', branch: 'main' }, }); + expect( + await fs.readFile(path.join(root, 'openspec', 'changes', 'change-a', '.comet.yaml'), 'utf8'), + ).not.toContain('bound_branch'); }); it('reports malformed selection data as stale instead of missing', async () => { diff --git a/test/domains/comet-classic/classic-guard.test.ts b/test/domains/comet-classic/classic-guard.test.ts index 1c36e285..85a1594a 100644 --- a/test/domains/comet-classic/classic-guard.test.ts +++ b/test/domains/comet-classic/classic-guard.test.ts @@ -59,6 +59,7 @@ describe('Classic guard command', () => { it('passes the open guard and applies the transition when artifacts exist', async () => { const dir = await makeProject(); run(dir, 'state', 'init', 'demo', 'hotfix'); + run(dir, 'state', 'set', 'demo', 'isolation', 'branch'); const changeDir = path.join(dir, 'openspec', 'changes', 'demo'); await fs.writeFile(path.join(changeDir, 'proposal.md'), 'proposal\n'); await fs.writeFile(path.join(changeDir, 'design.md'), 'design\n'); diff --git a/test/domains/comet-classic/classic-hook-guard.test.ts b/test/domains/comet-classic/classic-hook-guard.test.ts index b3030c7a..ff0cffa7 100644 --- a/test/domains/comet-classic/classic-hook-guard.test.ts +++ b/test/domains/comet-classic/classic-hook-guard.test.ts @@ -76,6 +76,8 @@ async function seedChange( designDoc?: string | null; plan?: string | null; verificationReport?: string | null; + isolation?: string; + boundBranch?: string; } = {}, ): Promise { const changeDir = path.join(dir, 'openspec', 'changes', name); @@ -87,23 +89,24 @@ async function seedChange( ? `docs/superpowers/specs/${name}-design.md` : null : options.designDoc; - await fs.writeFile( - path.join(changeDir, '.comet.yaml'), - [ - `workflow: ${workflow}`, - `phase: ${phase}`, - `design_doc: ${designDoc ?? 'null'}`, - `plan: ${options.plan ?? 'null'}`, - `verification_report: ${options.verificationReport ?? 'null'}`, - `build_mode: ${phase === 'open' || phase === 'design' ? 'null' : 'executing-plans'}`, - `isolation: ${phase === 'open' || phase === 'design' ? 'null' : 'branch'}`, - `verify_mode: ${phase === 'verify' || phase === 'archive' ? 'light' : 'null'}`, - `verify_result: ${phase === 'archive' ? 'pass' : 'pending'}`, - `verified_at: ${phase === 'archive' ? '2026-07-12' : 'null'}`, - `archived: ${options.archived ?? false}`, - '', - ].join('\n'), - ); + const isolation = + options.isolation ?? (phase === 'open' || phase === 'design' ? 'null' : 'branch'); + const lines = [ + `workflow: ${workflow}`, + `phase: ${phase}`, + `design_doc: ${designDoc ?? 'null'}`, + `plan: ${options.plan ?? 'null'}`, + `verification_report: ${options.verificationReport ?? 'null'}`, + `build_mode: ${phase === 'open' || phase === 'design' ? 'null' : 'executing-plans'}`, + `isolation: ${isolation}`, + `verify_mode: ${phase === 'verify' || phase === 'archive' ? 'light' : 'null'}`, + `verify_result: ${phase === 'archive' ? 'pass' : 'pending'}`, + `verified_at: ${phase === 'archive' ? '2026-07-12' : 'null'}`, + `archived: ${options.archived ?? false}`, + ]; + if (options.boundBranch) lines.push(`bound_branch: ${options.boundBranch}`); + lines.push(''); + await fs.writeFile(path.join(changeDir, '.comet.yaml'), lines.join('\n')); return changeDir; } @@ -322,7 +325,7 @@ describe('Classic hook guard command', () => { it('fails closed for a stale selection before a standard plan first write', async () => { const dir = await makeProject(); await initializeGitProject(dir); - await seedChange(dir, 'build-change', 'build'); + await seedChange(dir, 'build-change', 'build', { isolation: 'current', boundBranch: 'main' }); await seedChange(dir, 'other-build', 'build'); expect(run(dir, 'state', ['select', 'build-change']).status).toBe(0); git(dir, ['switch', '-c', 'other']); @@ -464,17 +467,33 @@ describe('Classic hook guard command', () => { expect(result.stderr).toContain('invalid JSON'); }); - it('fails closed when the selected branch changes', async () => { + it.each(['current', 'branch', 'worktree'])( + 'fails closed when the bound branch drifts (isolation: %s)', + async (isolation) => { + const dir = await makeProject(); + await initializeGitProject(dir); + await seedChange(dir, 'build-ready', 'build', { isolation, boundBranch: 'main' }); + expect(run(dir, 'state', ['select', 'build-ready']).status).toBe(0); + git(dir, ['switch', '-c', 'other']); + + const result = run(dir, 'hook-guard', [], hookInput(path.join(dir, 'src', 'feature.ts'))); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("bound to branch 'main'"); + expect(result.stderr).toContain("current branch is 'other'"); + }, + ); + + it('fails closed for a drifted sole active change without a selection', async () => { const dir = await makeProject(); await initializeGitProject(dir); - await seedChange(dir, 'build-ready', 'build'); - expect(run(dir, 'state', ['select', 'build-ready']).status).toBe(0); + await seedChange(dir, 'build-ready', 'build', { isolation: 'current', boundBranch: 'main' }); git(dir, ['switch', '-c', 'other']); const result = run(dir, 'hook-guard', [], hookInput(path.join(dir, 'src', 'feature.ts'))); expect(result.status).toBe(2); - expect(result.stderr).toContain("selected on branch 'main'"); + expect(result.stderr).toContain("bound to branch 'main'"); expect(result.stderr).toContain("current branch is 'other'"); }); @@ -491,6 +510,7 @@ 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); const selected = run(dir, 'state', ['select', 'demo']); diff --git a/test/domains/comet-classic/classic-state.test.ts b/test/domains/comet-classic/classic-state.test.ts index 147415d5..f0d6b212 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', diff --git a/test/domains/comet-classic/comet-scripts-guard.test.ts b/test/domains/comet-classic/comet-scripts-guard.test.ts index eacf16ee..5f598a22 100644 --- a/test/domains/comet-classic/comet-scripts-guard.test.ts +++ b/test/domains/comet-classic/comet-scripts-guard.test.ts @@ -347,6 +347,26 @@ describe('comet guard', () => { ]); } + it('keeps a bound_branch healed during guard --apply', async () => { + execFileSync('git', ['switch', '-c', 'feat-x'], { cwd: tmpDir, stdio: 'ignore' }); + await createChange(tmpDir, 'apply-heal', buildYaml); + await writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ scripts: { build: 'node -e "process.exit(0)"' } }), + ); + + const result = runNode(tmpDir, guardScript, ['apply-heal', 'build', '--apply'], {}, 20000); + + expect(result.status, JSON.stringify({ stderr: result.stderr })).toBe(0); + expect(result.stderr).toContain('bound_branch lazily set to feat-x'); + expect(result.stderr).toContain('[TRANSITION] build-complete'); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'apply-heal', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain('bound_branch: feat-x'); + }); + it('explains how to recover when a commandless build has no recorded evidence', async () => { await createChange(tmpDir, 'commandless-build', buildYaml); diff --git a/test/domains/comet-classic/comet-scripts.test.ts b/test/domains/comet-classic/comet-scripts.test.ts index c9ed5dce..83a77201 100644 --- a/test/domains/comet-classic/comet-scripts.test.ts +++ b/test/domains/comet-classic/comet-scripts.test.ts @@ -229,16 +229,13 @@ describe('comet scripts', () => { }, 20_000); it.each(['hotfix', 'tweak'])( - 'initializes %s in the current workspace without claiming branch isolation', + 'initializes %s with isolation pending until the user chooses a workspace mode', async (workflow) => { const result = runNode(tmpDir, stateScript, ['init', `${workflow}-current`, workflow]); - const yaml = await fs.readFile( - path.join(tmpDir, 'openspec', 'changes', `${workflow}-current`, '.comet.yaml'), - 'utf8', - ); + const isolation = runNode(tmpDir, stateScript, ['get', `${workflow}-current`, 'isolation']); expect(result.status).toBe(0); - expect(yaml).toContain('isolation: current'); + expect(isolation.stdout.trim()).toBe('null'); }, 20_000, ); @@ -292,6 +289,49 @@ describe('comet scripts', () => { expect(result.status).toBe(2); }, 20_000); + it('blocks repo source writes when an isolation: current change drifts 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 writeFile(path.join(tmpDir, 'README.md'), 'base\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'drift-change', + [ + 'workflow: full', + 'phase: build', + 'design_doc: docs/superpowers/specs/design.md', + 'plan: null', + 'build_mode: executing-plans', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const select = runNode(tmpDir, stateScript, ['select', 'drift-change']); + expect(select.status).toBe(0); + + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const targetFile = path.join(tmpDir, 'src', 'index.ts'); + await fs.mkdir(path.dirname(targetFile), { recursive: true }); + const result = runHookGuard(tmpDir, hookGuardScript, hookStdin(targetFile)); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('current change selection is stale or invalid'); + expect(result.stderr).toContain( + "bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); + it('falls back to the embedded Classic runtime package when installed script assets omit internal runtime files', async () => { const init = runNode(tmpDir, stateScript, ['init', 'embedded-runtime', 'full'], { COMET_RUNTIME_CLASSIC_ROOT: '', @@ -745,6 +785,141 @@ describe('comet scripts', () => { expect(valid.status).toBe(0); }, 20_000); + it('accepts bound_branch field in .comet.yaml without unknown field errors', async () => { + await createChange( + tmpDir, + 'bound-branch-validate', + [ + 'workflow: full', + 'phase: design', + 'build_mode: null', + 'build_pause: null', + 'tdd_mode: null', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: null', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const valid = runNode(tmpDir, path.join(tmpDir, 'scripts', 'comet-yaml-validate.mjs'), [ + 'bound-branch-validate', + ]); + + expect(valid.status).toBe(0); + expect(valid.stderr).not.toContain("unknown field 'bound_branch'"); + }, 20_000); + + it('rejects a numeric bound_branch value in comet validate', async () => { + await createChange( + tmpDir, + 'bound-branch-number', + [ + 'workflow: full', + 'phase: design', + 'build_mode: null', + 'design_doc: null', + 'plan: null', + 'isolation: current', + 'bound_branch: 123', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const valid = runNode(tmpDir, path.join(tmpDir, 'scripts', 'comet-yaml-validate.mjs'), [ + 'bound-branch-number', + ]); + + expect(valid.status).toBe(1); + expect(valid.stderr).toContain("bound_branch='123' is not a string or null"); + }, 20_000); + + it('rejects array and mapping bound_branch values in comet validate', async () => { + await createChange( + tmpDir, + 'bound-branch-array', + [ + 'workflow: full', + 'phase: design', + 'build_mode: null', + 'design_doc: null', + 'plan: null', + 'isolation: current', + 'bound_branch: [feature-A, feature-B]', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + await createChange( + tmpDir, + 'bound-branch-mapping', + [ + 'workflow: full', + 'phase: design', + 'build_mode: null', + 'design_doc: null', + 'plan: null', + 'isolation: current', + 'bound_branch:', + ' name: feature-A', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const validateScript = path.join(tmpDir, 'scripts', 'comet-yaml-validate.mjs'); + const arrayResult = runNode(tmpDir, validateScript, ['bound-branch-array']); + const mappingResult = runNode(tmpDir, validateScript, ['bound-branch-mapping']); + + expect(arrayResult.status).toBe(1); + expect(arrayResult.stderr).toContain('is not a string or null'); + expect(mappingResult.status).toBe(1); + expect(mappingResult.stderr).toContain('is not a string or null'); + }, 20_000); + + it('accepts a quoted numeric bound_branch string in comet validate', async () => { + await createChange( + tmpDir, + 'bound-branch-quoted', + [ + 'workflow: full', + 'phase: design', + 'build_mode: null', + 'design_doc: null', + 'plan: null', + 'isolation: current', + "bound_branch: '123'", + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const valid = runNode(tmpDir, path.join(tmpDir, 'scripts', 'comet-yaml-validate.mjs'), [ + 'bound-branch-quoted', + ]); + + expect(valid.status).toBe(0); + expect(valid.stderr).not.toContain('bound_branch'); + }, 20_000); + it('next resolves auto for full workflow when auto_transition is true', async () => { await createChange( tmpDir, @@ -2060,11 +2235,68 @@ describe('comet scripts', () => { 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 ', + 'comet state set missing-build-decisions isolation ', ); 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 current, branch, or worktree'); + }, 20_000); + + it('allows full workflow build completion with current-branch isolation', async () => { + execFileSync('git', ['init', '-b', 'feature'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + await createChange( + tmpDir, + 'full-current-isolation', + [ + 'workflow: full', + 'phase: build', + 'build_mode: executing-plans', + 'build_pause: null', + 'subagent_dispatch: null', + 'tdd_mode: direct', + 'review_mode: standard', + 'isolation: current', + 'bound_branch: feature', + 'verify_mode: null', + 'design_doc: docs/superpowers/specs/full-current-design.md', + 'plan: docs/superpowers/plans/full-current-plan.md', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + '- [x] done\n', + ); + await fs.mkdir(path.join(tmpDir, 'docs', 'superpowers', 'specs'), { recursive: true }); + await writeFile( + path.join(tmpDir, 'docs', 'superpowers', 'specs', 'full-current-design.md'), + '---\nchange: full-current-isolation\n---\n# Design\n', + ); + await writeFile( + path.join(tmpDir, 'docs', 'superpowers', 'plans', 'full-current-plan.md'), + '- [x] done\n', + ); + await writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ scripts: { build: 'node -e "process.exit(0)"' } }), + ); + + const guard = runNode(tmpDir, guardScript, ['full-current-isolation', 'build']); + const transition = runNode(tmpDir, stateScript, [ + 'transition', + 'full-current-isolation', + 'build-complete', + ]); + + expect(guard.status).toBe(0); + expect(transition.status).toBe(0); + expect(transition.stderr).toContain('[SET] phase=verify'); + expect(transition.stderr).toContain('[TRANSITION] build-complete'); }, 20_000); it('blocks build completion until tdd_mode is selected for full workflow', async () => { @@ -4050,6 +4282,39 @@ describe('comet scripts', () => { expect(wrongPhaseResult.stderr).toContain('expected phase build'); }, 20_000); + it('clears bound_branch when preset-escalate clears isolation', async () => { + const name = 'escalate-with-bound-branch'; + await createChange( + tmpDir, + name, + [ + 'workflow: hotfix', + 'phase: build', + 'build_mode: direct', + 'build_pause: null', + 'tdd_mode: direct', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: light', + 'review_mode: off', + 'design_doc: null', + 'plan: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const transitionResult = runNode(tmpDir, stateScript, ['transition', name, 'preset-escalate']); + const boundBranch = runNode(tmpDir, stateScript, ['get', name, 'bound_branch']); + const isolation = runNode(tmpDir, stateScript, ['get', name, 'isolation']); + + expect(transitionResult.status).toBe(0); + expect(boundBranch.stdout.trim()).toBe('null'); + expect(isolation.stdout.trim()).toBe('null'); + }, 20_000); + it('reports error for malformed .comet.yaml on get', async () => { const changeDir = path.join(tmpDir, 'openspec', 'changes', 'bad-yaml'); await fs.mkdir(changeDir, { recursive: true }); @@ -5874,4 +6139,597 @@ describe('comet scripts', () => { expect(result.status).toBe(0); }, 20_000); }); + + describe('workspace mode branch binding', () => { + const stateScript = path.join(scriptsDir, 'comet-state.mjs'); + + it.each(['current', 'branch', 'worktree'])( + 'first-time set isolation %s writes bound_branch to current branch', + async (isolation) => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'bind-workspace', + ['workflow: full', 'phase: build', 'isolation: null', 'bound_branch: null', ''].join( + '\n', + ), + ); + + const result = runNode(tmpDir, stateScript, [ + 'set', + 'bind-workspace', + 'isolation', + isolation, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toContain(`[SET] isolation=${isolation}`); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'bind-workspace', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain(`isolation: ${isolation}`); + expect(yaml).toContain('bound_branch: main'); + }, + 20_000, + ); + + it.each(['current', 'branch', 'worktree'])( + 'already-bound change: repeat set isolation %s does not overwrite existing bound_branch', + async (isolation) => { + execFileSync('git', ['init', '-b', 'branch-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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'branch-B'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'rebind-test', + [ + `workflow: full`, + 'phase: build', + `isolation: ${isolation}`, + 'bound_branch: branch-A', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, ['set', 'rebind-test', 'isolation', isolation]); + + expect(result.status).toBe(0); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'rebind-test', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain(`isolation: ${isolation}`); + expect(yaml).toContain('bound_branch: branch-A'); + }, + 20_000, + ); + + it('re-points bound_branch when switching between workspace modes', async () => { + execFileSync('git', ['init', '-b', 'branch-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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['switch', '-c', 'branch-B'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'mode-switch', + [ + 'workflow: full', + 'phase: build', + 'isolation: branch', + 'bound_branch: branch-A', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, ['set', 'mode-switch', 'isolation', 'worktree']); + + expect(result.status).toBe(0); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'mode-switch', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain('isolation: worktree'); + expect(yaml).toContain('bound_branch: branch-B'); + }, 20_000); + + it('omits the branch suffix when selecting a change without a binding', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + const init = runNode(tmpDir, stateScript, ['init', 'unbound-select', 'full']); + expect(init.status).toBe(0); + + const result = runNode(tmpDir, stateScript, ['select', 'unbound-select']); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('[SELECTED] current change: unbound-select'); + expect(result.stderr).not.toContain('(branch:'); + }, 20_000); + + it('rejects select while the bound branch has drifted', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + const init = runNode(tmpDir, stateScript, ['init', 'drift-select', 'full']); + expect(init.status).toBe(0); + const stateFile = path.join(tmpDir, 'openspec', 'changes', 'drift-select', '.comet.yaml'); + await fs.writeFile( + stateFile, + (await fs.readFile(stateFile, 'utf-8')).replace('isolation: null', 'isolation: current') + + 'bound_branch: main\n', + ); + execFileSync('git', ['switch', '-c', 'other'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, stateScript, ['select', 'drift-select']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("bound to branch 'main'"); + expect(result.stderr).toContain("current branch is 'other'"); + await expect(fs.access(path.join(tmpDir, '.comet', 'current-change.json'))).rejects.toThrow(); + }, 20_000); + + it.each(['current', 'branch', 'worktree'])( + 'detached HEAD rejects set isolation %s with "HEAD is detached" error', + async (isolation) => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + execFileSync('git', ['checkout', '--detach'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'detached-test', + ['workflow: full', 'phase: build', 'isolation: null', 'bound_branch: null', ''].join( + '\n', + ), + ); + + const result = runNode(tmpDir, stateScript, [ + 'set', + 'detached-test', + 'isolation', + isolation, + ]); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('HEAD is detached'); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'detached-test', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain('isolation: null'); + expect(yaml).toContain('bound_branch: null'); + }, + 20_000, + ); + + it.each(['current', 'branch', 'worktree'])( + 'non-git project can set isolation %s without binding a branch', + async (isolation) => { + await createChange( + tmpDir, + 'non-git-workspace', + ['workflow: full', 'phase: build', 'isolation: null', 'bound_branch: null', ''].join( + '\n', + ), + ); + + const result = runNode(tmpDir, stateScript, [ + 'set', + 'non-git-workspace', + 'isolation', + isolation, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toContain(`[SET] isolation=${isolation}`); + const yaml = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'non-git-workspace', '.comet.yaml'), + 'utf-8', + ); + expect(yaml).toContain(`isolation: ${isolation}`); + expect(yaml).toContain('bound_branch: null'); + }, + 20_000, + ); + + it.each(['current', 'branch', 'worktree'])( + 'selecting a legacy isolation %s change with no bound_branch writes the current git branch', + async (isolation) => { + execFileSync('git', ['init', '-b', 'workflow-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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + const init = runNode(tmpDir, stateScript, ['init', 'legacy-select', 'full']); + expect(init.status).toBe(0); + const stateFile = path.join(tmpDir, 'openspec', 'changes', 'legacy-select', '.comet.yaml'); + const originalYaml = await fs.readFile(stateFile, 'utf-8'); + await fs.writeFile( + stateFile, + originalYaml + .replace('isolation: null', `isolation: ${isolation}`) + .replace(/^bound_branch: .*\n/mu, ''), + ); + + const result = runNode(tmpDir, stateScript, ['select', 'legacy-select']); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('(branch: workflow-branch)'); + const yaml = await fs.readFile(stateFile, 'utf-8'); + expect(yaml).toContain('bound_branch: workflow-branch'); + }, + 20_000, + ); + + it('rejects direct set bound_branch with "machine-owned" error', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'direct-bound-test', + ['workflow: full', 'phase: build', 'isolation: current', 'bound_branch: null', ''].join( + '\n', + ), + ); + + const result = runNode(tmpDir, stateScript, [ + 'set', + 'direct-bound-test', + 'bound_branch', + 'some-branch', + ]); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('machine-owned'); + }, 20_000); + }); + + describe('state check bound_branch drift', () => { + const stateScript = path.join(scriptsDir, 'comet-state.mjs'); + + it.each(['current', 'branch', 'worktree'])( + 'drifted %s-bound change: state check returns non-zero with BLOCKED and drift message', + async (isolation) => { + 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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'check-drift', + [ + 'workflow: full', + 'phase: verify', + `isolation: ${isolation}`, + 'bound_branch: feature-A', + 'verify_result: pending', + '', + ].join('\n'), + ); + + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, stateScript, ['check', 'check-drift', 'verify']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('BLOCKED'); + expect(result.stdout).toContain( + "bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, + 20_000, + ); + + it('drift is still detected after the .comet/ sidecar is deleted (reads .comet.yaml, not the sidecar)', 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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'check-drift-no-sidecar', + [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const select = runNode(tmpDir, stateScript, ['select', 'check-drift-no-sidecar']); + expect(select.status).toBe(0); + + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + await fs.rm(path.join(tmpDir, '.comet'), { recursive: true, force: true }); + + const result = runNode(tmpDir, stateScript, ['check', 'check-drift-no-sidecar', 'verify']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('BLOCKED'); + expect(result.stdout).toContain( + "bound to branch 'feature-A', but current branch is 'feature-B'", + ); + }, 20_000); + + it('detached HEAD on an already-bound change: state check returns non-zero with detached HEAD in output', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'check-detached', + [ + 'workflow: full', + 'phase: verify', + 'isolation: current', + 'bound_branch: main', + 'verify_result: pending', + '', + ].join('\n'), + ); + + execFileSync('git', ['checkout', '--detach'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, stateScript, ['check', 'check-detached', 'verify']); + + expect(result.status).not.toBe(0); + expect(result.stdout).toContain('detached HEAD'); + }, 20_000); + + it.each(['current', 'branch', 'worktree'])( + 'gap B: isolation=%s with no bound_branch self-heals on a real branch and passes', + async (isolation) => { + execFileSync('git', ['init', '-b', 'hotfix-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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'check-gap-b', + [ + 'workflow: full', + 'phase: verify', + `isolation: ${isolation}`, + 'bound_branch: null', + 'verify_result: pending', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, ['check', 'check-gap-b', 'verify']); + + expect(result.status).toBe(0); + + const get = runNode(tmpDir, stateScript, ['get', 'check-gap-b', 'bound_branch']); + expect(get.stdout.trim()).toBe('hotfix-branch'); + }, + 20_000, + ); + }); + + describe('state rebind', () => { + const stateScript = path.join(scriptsDir, 'comet-state.mjs'); + + it('rebind updates bound_branch, passes state check, and appends a rebind audit event', 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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'rebind-success', + [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: current', + 'bound_branch: feature-A', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, stateScript, ['rebind', 'rebind-success']); + expect(result.status).toBe(0); + + const get = runNode(tmpDir, stateScript, ['get', 'rebind-success', 'bound_branch']); + expect(get.stdout.trim()).toBe('feature-B'); + + const check = runNode(tmpDir, stateScript, ['check', 'rebind-success', 'verify']); + expect(check.status).toBe(0); + + const eventsLog = await fs.readFile( + path.join(tmpDir, 'openspec', 'changes', 'rebind-success', '.comet', 'state-events.jsonl'), + 'utf8', + ); + const lines = eventsLog.trim().split('\n'); + const lastEvent = JSON.parse(lines[lines.length - 1]); + expect(lastEvent.event).toBe('rebind'); + expect(lastEvent.effects).toContainEqual({ + field: 'boundBranch', + from: 'feature-A', + to: 'feature-B', + }); + }, 20_000); + + it('rejects rebind when the change is not yet bound', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'rebind-unbound', + [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: null', + 'bound_branch: null', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + const result = runNode(tmpDir, stateScript, ['rebind', 'rebind-unbound']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('not yet bound'); + }, 20_000); + + it('rejects rebind while HEAD is detached', async () => { + execFileSync('git', ['init', '-b', 'main'], { 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'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'rebind-detached', + [ + 'workflow: full', + 'phase: verify', + 'design_doc: null', + 'plan: null', + 'build_mode: null', + 'isolation: current', + 'bound_branch: main', + 'verify_mode: null', + 'verify_result: pending', + 'verified_at: null', + 'archived: false', + '', + ].join('\n'), + ); + + execFileSync('git', ['checkout', '--detach'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, stateScript, ['rebind', 'rebind-detached']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('HEAD is detached'); + }, 20_000); + }); + + describe('guard bound_branch drift', () => { + const guardScript = path.join(scriptsDir, 'comet-guard.mjs'); + + it.each(['current', 'branch', 'worktree'])( + 'drifted %s-bound change: comet-guard archive returns non-zero with BLOCKED and drift message', + async (isolation) => { + 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 writeFile(path.join(tmpDir, 'README.md'), 'test\n'); + execFileSync('git', ['add', '.'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + await createChange( + tmpDir, + 'guard-archive-drift', + [ + 'workflow: full', + 'phase: archive', + 'design_doc: null', + 'plan: null', + 'build_mode: executing-plans', + `isolation: ${isolation}`, + 'bound_branch: feature-A', + 'verify_mode: null', + 'verify_result: pass', + 'verified_at: null', + 'archived: true', + 'branch_status: handled', + '', + ].join('\n'), + ); + + execFileSync('git', ['switch', '-c', 'feature-B'], { cwd: tmpDir, stdio: 'ignore' }); + + const result = runNode(tmpDir, guardScript, ['guard-archive-drift', 'archive']); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('BLOCKED'); + expect(result.stderr).toContain( + "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..0aaea7aa 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1379,7 +1379,7 @@ describe('skills', () => { '不得用“跳过重复上下文探索”削弱 Superpowers `brainstorming` 的澄清流程', ); expect(zhDesign).not.toContain('跳过重复上下文探索,直接进入设计提问'); - expect(zhBuild).toContain('不得根据推荐规则自行选择 `branch` 或 `worktree`'); + expect(zhBuild).toContain('不得根据推荐规则自行选择 `current`、`branch` 或 `worktree`'); expect(zhBuild).toContain('也不得自行选择执行方式、TDD 模式或代码审查模式'); expect(zhBuild).toContain('`comet/reference/decision-point.md`'); expect(zhVerify).toContain('前 3 次可修复失败自动回到 build'); @@ -1763,7 +1763,7 @@ 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', + 'do not choose `current`, `branch`, or `worktree`, execution method, TDD mode, or review mode from recommendations', ); expect(enBuild).toContain('`comet/reference/decision-point.md`'); expect(enVerify).toContain( diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts index ea6520ff..7ed3911c 100644 --- a/test/domains/skill/workflow-optimization-contract.test.ts +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -54,21 +54,28 @@ describe('Comet workflow optimization contracts', () => { ); it.each([ - ['中文', zhSkillRoot, '先复现问题并记录失败证据', '任务数量本身不触发 `/comet-build`'], + [ + '中文', + zhSkillRoot, + '先复现问题并记录失败证据', + '任务数量本身不触发 `/comet-build`', + '入口工作区隔离是用户决策点', + ], [ 'English', skillRoot, 'reproduce the bug and record failing evidence first', 'task count alone does not route to `/comet-build`', + 'Entry workspace isolation is a user decision point', ], ])( '%s hotfix flow preserves regression evidence without task-count routing', - async (_language, root, regression, routing) => { + async (_language, root, regression, routing, isolationDecision) => { const skill = await readSkill(root, 'comet-hotfix'); expect(skill).toContain(regression); expect(skill).toContain(routing); - expect(skill).toContain('isolation: current'); + expect(skill).toContain(isolationDecision); }, );