diff --git a/AGENTS.md b/AGENTS.md index c9c824c1..aae5d310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,10 +233,11 @@ Changelog写英文 在这个仓库中,开始处理需要改动或调查的任务前,如果可能存在活跃 Comet workflow,把当前用户请求传入只读探针:`comet resume-probe . --stdin --json`。 +- 如果用户通过宿主明确调用任意 Comet Skill(例如 `@comet`、`/comet`、`@comet-native` 或 `/comet-hotfix`),显式调用优先于本恢复协议;不要运行 resume probe,直接进入被调用的 Skill。 - 只信任返回的 `workflow`、`skill` 和 `entrySource`;它们只由项目配置或无配置兼容回退决定。不得扫描或切换另一套 workflow。 - 如果 probe 返回 `auto_resume`,简短说明选中的 active change,并进入 `nextCommand` 指向的永久入口。不要把状态命令当作恢复入口直接推进。 - 如果 probe 返回 `ask_user`,只问一个简短问题并等待用户回复。 -- 如果 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。 +- 如果当前请求未明确调用 Comet Skill,且 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。 - 如果配置或状态无效且没有 `nextCommand`,停止并报告原因;不要猜测另一个 workflow。 - 不能只因为存在 active change 就把无关任务挂到该 change。Native 的未提交改动由 Native 入口检查,不由探针自动归因。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b10e10..1e410f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,23 @@ All notable changes to @rpamis/comet will be documented in this file. -## What's Changed [0.4.0-beta.9] - 2026-07-23 +## What's Changed [0.4.0-beta.9] - 2026-07-25 ### Added +- **Sequential clarification evaluation**: Adds a repeatable multi-turn Native evaluation that checks whether Sequential investigates repository facts, resolves dependent user-owned decisions one at a time, records each answer, confirms a complete shared understanding before Build, and finishes verified implementation. Task-defined reply sequences keep decision paths reproducible instead of allowing the simulated user to invent additional choices. - **`comet native evidence format`**: New command that serializes acceptance evidence entries into the exact canonical Markdown block `verification.md` requires, so evidence blocks no longer need to be hand-formatted to match byte-for-byte and no longer trigger spurious "canonical serialization" rejections during Verify. +### Changed + +- **Native clarification modes**: Sequential mode now recalculates remaining user-visible decisions after each answer and asks exactly one most-upstream decision with a recommendation and impact per round. Batch maintains a prerequisite-aware decision tree, asks the entire ready frontier each round, and keeps environment-fact investigations from delaying other ready questions when parallel work is available. Both modes require every behavior in the final shared-understanding summary to be traceable, and Runtime enforces explicit confirmation before Build; older `implicit` changes must also confirm before leaving Build. + ### Fixed +- **Native baselines for large repositories**: Native content snapshots now support baseline-bound include/exclude policies and configurable file-count, total-byte, and duration budgets in `.comet/config.yaml`, with a 256 MiB default total budget and no separate 5 MiB per-file cap. Runtime continues to hash actual working-tree content with streaming SHA-256, records the effective policy and limits for audit, and reports actionable configuration fixes when a complete baseline cannot be captured ([#226](https://github.com/rpamis/comet/issues/226)). +- **Global workflow selection**: `comet init` now offers Native, Classic, or both for global installs and accepts `--scope global --workflow native|classic|both`, so global Skill installation exposes the same workflow choices as project scope while preserving Classic as the non-interactive default when no workflow is specified ([#234](https://github.com/rpamis/comet/issues/234)). +- **Explicit Comet Skill invocation**: Ambient Resume project instructions now give host-recognized manual Comet Skill invocations precedence over recovery probing, preventing `none` or `out_of_scope` results from skipping `/comet` when no active change exists ([#235](https://github.com/rpamis/comet/issues/235)). +- **Classic archive final state**: Classic now confirms immediate remote delivery before irreversible archive, writes `branch_status: handled` before the single archive commit, and pushes that complete commit once. Successful archive no longer leaves an uncommitted `.comet.yaml` or a remote archive stuck at `pending` ([#237](https://github.com/rpamis/comet/issues/237)). - **Plugin marketplace superpowers detection**: `comet init` no longer crashes with an `ENOTDIR` error when `~/.claude/plugins/cache/` (or the Codex equivalent) contains a stray file where a marketplace directory was expected. ### Security diff --git a/CLAUDE.md b/CLAUDE.md index 0898ae50..88a02491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,10 +231,11 @@ Changelog写英文 在这个仓库中,开始处理需要改动或调查的任务前,如果可能存在活跃 Comet workflow,把当前用户请求传入只读探针:`comet resume-probe . --stdin --json`。 +- 如果用户通过宿主明确调用任意 Comet Skill(例如 `@comet`、`/comet`、`@comet-native` 或 `/comet-hotfix`),显式调用优先于本恢复协议;不要运行 resume probe,直接进入被调用的 Skill。 - 只信任返回的 `workflow`、`skill` 和 `entrySource`;它们只由项目配置或无配置兼容回退决定。不得扫描或切换另一套 workflow。 - 如果 probe 返回 `auto_resume`,简短说明选中的 active change,并进入 `nextCommand` 指向的永久入口。不要把状态命令当作恢复入口直接推进。 - 如果 probe 返回 `ask_user`,只问一个简短问题并等待用户回复。 -- 如果 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。 +- 如果当前请求未明确调用 Comet Skill,且 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。 - 如果配置或状态无效且没有 `nextCommand`,停止并报告原因;不要猜测另一个 workflow。 - 不能只因为存在 active change 就把无关任务挂到该 change。Native 的未提交改动由 Native 入口检查,不由探针自动归因。 diff --git a/app/commands/init.ts b/app/commands/init.ts index a975fc9f..0fdd7c46 100644 --- a/app/commands/init.ts +++ b/app/commands/init.ts @@ -466,11 +466,8 @@ export async function initCommand( const detected = await detectPlatforms(projectPath); const scope = await selectScope(options, lang); - if ( - scope === 'global' && - (options.workflow !== undefined || options.artifactRoot !== undefined) - ) { - throw new Error('--workflow and --root are only valid for project-scope initialization'); + if (scope === 'global' && options.artifactRoot !== undefined) { + throw new Error('--root is only valid for project-scope initialization'); } if (scope === 'project') { await readProjectRegistry({ strict: true }); @@ -482,10 +479,11 @@ export async function initCommand( artifactRoot: options.artifactRoot, }) : null; - const workflowSelection = - scope === 'project' - ? await selectWorkflow(options, lang, suggestedWorkflowDecision?.workflow ?? 'native') - : 'classic'; + const workflowSelection = await selectWorkflow( + options, + lang, + suggestedWorkflowDecision?.workflow ?? 'classic', + ); const workflow: CometWorkflow = workflowSelection === 'both' ? 'native' : workflowSelection; const workflowDecision = scope === 'project' diff --git a/assets/skills-zh/comet-archive/SKILL.md b/assets/skills-zh/comet-archive/SKILL.md index 8d87907c..2eda2c55 100644 --- a/assets/skills-zh/comet-archive/SKILL.md +++ b/assets/skills-zh/comet-archive/SKILL.md @@ -30,28 +30,30 @@ comet state check archive 若上述 `select` / `check` 输出 `BLOCKED`,且原因是 `bound_branch` 与当前分支不一致,立即按 `comet/reference/decision-point.md` 暂停,让用户单选:切回绑定分支后重新运行入口验证,或在用户明确确认当前分支应接管该 change 后运行 `comet state rebind ` 并重新入口验证。不得自行切换分支,不得自行换绑。 -### 1. 归档前最终确认(阻塞点) +### 1. 归档与交付前最终确认(阻塞点) -入口验证通过后,**必须按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认是否立即归档**。不得在用户确认前运行 `comet archive ""`。 +入口验证通过后,先读取 `comet state get isolation`,再**按 `comet/reference/decision-point.md` 的协议暂停并等待用户确认是否立即归档和远端交付**。不得在用户确认前运行 `comet state transition archive-confirm` 或 `comet archive ""`。 确认前必须向用户展示简短摘要: - change 名称 - 验证报告路径和结论 - 当前分支/工作区和未提交改动归因摘要 - 本次归档将执行的不可逆动作:按 OpenSpec delta 语义合并主 spec、标注 design doc / plan、移动 change 到 archive 目录 +- 归档完成后将执行的远端交付:只推送当前绑定分支,或推送后创建 PR 用户确认问题必须以单选题形式呈现,包含以下选项: -- 「确认归档」— 写入最终确认状态后执行归档脚本,完成 spec 合并和 change 移动 +- 「确认归档并立即推送」— 完成归档、创建唯一归档提交并推送当前绑定分支 +- 「确认归档、立即推送并创建 PR」— 完成归档、创建唯一归档提交、推送当前绑定分支并创建 PR - 「需要调整或重新验证」— 不执行归档;运行 `comet state transition archive-reopen` 回到 `phase: verify`,再调用 `/comet-verify`。若验证阶段确认需要修复,再按 `/comet-verify` 的验证失败决策回到 `/comet-build` -- 「暂不归档」— 不执行归档,保留当前 `phase: archive` 状态,等待用户稍后再次调用 `/comet-archive` +- 「暂不归档」— 不执行 `archive-confirm` 或归档命令,保留 active change、`phase: archive` 和 `branch_status: pending`,等待用户稍后再次调用 `/comet-archive` -用户选择「确认归档」后,立即执行: +只有用户选择前两个立即交付选项之一后,才记录其选择并立即执行: ```bash comet state transition archive-confirm ``` -如 transition 返回非零退出码,报告错误并停止。只有 transition 成功后,才允许继续 Step 2。用户选择「需要调整或重新验证」后,必须先执行 `archive-reopen` 状态回退,不得手动编辑 `.comet.yaml`。 +如 transition 返回非零退出码,报告错误并停止。只有 transition 成功后,才允许继续 Step 2。用户选择「需要调整或重新验证」后,必须先执行 `archive-reopen` 状态回退,不得手动编辑 `.comet.yaml`。用户选择「暂不归档」后直接停止,不得归档、提交、推送或把 `branch_status` 设为 `handled`。 ### 2. 执行归档 @@ -92,7 +94,16 @@ brainstorming → delta spec → 实施 → 验证 → 主 spec 合并 → desig - 主 spec 按 delta 语义合并的内容 - design doc / plan 的归档元数据标注 -归档后先读取 `git status --short`,并以归档前的 dirty-worktree 归因记录为基线。只允许暂存可归因于当前 change 的路径:原 active change 路径、脚本输出的实际 archive 路径、被本次 delta 更新的 main specs,以及当前 Design Doc/Plan 的归档元数据。存在无法归因的路径时停止并请求用户处理。 +先把已确认的交付方式持久化到归档状态,再运行最终 archive guard: + +```bash +comet state set branch_status handled +comet guard archive +``` + +这里的 `handled` 只表示用户已经确认如何远端交付这次完整归档提交,不表示 push 或 PR 创建已经成功。状态写入或 guard 失败时停止,不得提交或执行远端操作。 + +归档后读取 `git status --short`,并以归档前的 dirty-worktree 归因记录为基线。只允许暂存可归因于当前 change 的路径:原 active change 路径、脚本输出的实际 archive 路径、归档目录中已更新为 `branch_status: handled` 的 `.comet.yaml`、被本次 delta 更新的 main specs,以及当前 Design Doc/Plan 的归档元数据。存在无法归因的路径时停止并请求用户处理。 使用显式 pathspec 暂存核对后的路径,再检查 staged diff;不得使用全仓库暂存,也不得把用户已有改动混入归档提交: @@ -104,30 +115,28 @@ git commit -m "chore: archive " 提交失败或 staged diff 含无关路径时停止,不得继续分支处理。 -### 5. 归档提交后的分支处理 +### 5. 交付归档提交并完成 -归档提交成功后,先读取 `comet state get isolation`,按隔离方式分流: +归档提交成功后,只执行 Step 1 中用户已经确认的远端交付方式: -- `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` 暂停让用户二选一:推送当前分支,或暂不推送并保留本地状态。 +- 「确认归档并立即推送」:推送当前绑定分支一次。 +- 「确认归档、立即推送并创建 PR」:先推送当前绑定分支一次,再使用当前平台可用的 GitHub 能力创建 PR;Step 1 的明确选择就是创建 PR 的授权,不得再次改成其他分支处置方式。 -归档已经完成,因此这里不提供“丢弃工作”选项。只有用户选择的操作成功完成、明确选择保持分支,或在 `current` 模式下明确选择暂不推送后,才运行: +push 失败时报告错误,保留 current selection 记录,不得清除选择或宣告完成;当前任务中只重试同一个 push。PR 创建失败时分支已经包含完整归档提交,报告错误并保留 current selection 记录;当前任务中只重试创建 PR。不得在失败后自动切换、删除、变基或改写分支。 -```bash -comet state set branch_status handled -comet guard archive -``` +只有用户选择的远端交付操作全部成功后,才运行 `comet state clear-selection` 并宣告 Classic workflow 完成。 -archive guard 必须同时确认归档产物完整且 `branch_status: handled`;失败时流程仍未完成。 +归档阶段不再调用 Superpowers `finishing-a-development-branch`。本地合并、保留分支稍后处理或暂不推送都不会立即形成远端最终状态,必须在 Step 1 选择「暂不归档」,不能在归档后再选择。 ## 退出条件 - 归档脚本执行成功(退出码 0) - 归档目录 `openspec/changes/archive/YYYY-MM-DD-/` 存在 - 归档后的 `.comet.yaml` 中 `archived: true` -- 归档改动已通过精确 pathspec 提交 -- 用户选择的分支处理已完成,归档状态中的 `branch_status: handled` +- 归档状态中的 `branch_status: handled` 已包含在唯一归档提交中 - `comet guard archive` 通过 +- 唯一归档提交已按用户在归档前确认的方式成功推送;若用户选择创建 PR,PR 已成功创建 +- current selection 已在远端交付成功后清除 归档脚本会把 `openspec/changes//` 移动到 `openspec/changes/archive/YYYY-MM-DD-/`。 @@ -139,4 +148,4 @@ Comet Classic 流程全部完成。如需开始新的 Classic 工作,调用 `/ ## 上下文压缩恢复 -按 `comet/reference/context-recovery.md` 执行,phase 参数为 `archive`。若 `archived: true` 且归档目录存在,归档已完成,无需再次执行归档操作。 +按 `comet/reference/context-recovery.md` 执行,phase 参数为 `archive`。若 `archived: true` 且归档目录存在,不得再次执行归档操作;只有当前任务的上下文已明确记录 Step 1 选择的远端交付方式时,才能重试同一个 push 或 PR 创建操作。本 Skill 不承诺用户脱离流程、自行改变分支拓扑后的自动恢复。 diff --git a/assets/skills-zh/comet-native/SKILL.md b/assets/skills-zh/comet-native/SKILL.md index 81a00f5e..a584acb6 100644 --- a/assets/skills-zh/comet-native/SKILL.md +++ b/assets/skills-zh/comet-native/SKILL.md @@ -11,7 +11,7 @@ Native 保存需求、完整目标规格、状态和证据。你负责理解、 ## 需求澄清协议 -从 `.comet/config.yaml` 读取 `native.clarification_mode`。允许值为 `sequential` 和 `batch`;字段缺失时使用 `sequential`。该配置只决定用户问题的组织方式,不改变 Native 的阶段、状态、Guard、安全确认或调用方停点。 +从 `.comet/config.yaml` 读取 `native.clarification_mode`。允许值为 `sequential` 和 `batch`;字段缺失时使用 `sequential`。该配置决定用户问题的组织方式和离开 Shape 前的确认契约,不改变 Native 的阶段、change schema、安全确认或调用方停点。 先识别会改变用户可见结果、但尚未定义的分支。“规范化”“直观”“标准”“预期”等词不是产品契约;只有用户原话、用户确认的答案,或明确适用于当前行为的公开契约可以关闭这类分支。 @@ -34,19 +34,25 @@ Native 保存需求、完整目标规格、状态和证据。你负责理解、 ### Sequential 模式 -发现用户决定后: +把当前目标视为一棵用户可见结果的决策树。进入 Shape、用户每次回答后,以及相关仓库事实发生变化后,都必须重新执行以下循环: -1. 在 brief 中记录一个 `[blocking]` 问题。 -2. 一次只问最上游的一个问题。 -3. 给出“问题 / 推荐 / 影响”,然后结束本轮。 +1. 先调查当前分支依赖的仓库、工具和运行环境事实,不把可自行查明的事实问给用户。 +2. 从目标向下逐一遍历所有合理可达的用户可见分支,包括当前答案会引出的下游边界、失败结果和默认行为。不能在得到第一个可行解释后停止,也不能用推荐项静默关闭未确认分支。 +3. 在正式产物中维护可复核的未决事项及必要依赖摘要,不保存隐藏推理或完整内部推演。若仍有多个独立决定,选择前置条件已满足且最上游的一个;其他决定留到后续轮次。 +4. 在 brief 中只记录当前一道 `- [blocking] <问题>`。一次只问最上游的一个问题:每轮只询问这一个用户决定,不把多个独立决定塞进同一道多选题或多个并列问句。 +5. 给出“问题 / 推荐 / 影响”,用选项、正例或反例让差异可判断,然后结束本轮。推荐必须明确,但不能代用户选择。新答案引出的每一道下游或依赖问题也必须完整保留这三部分,不能只给一个候选集合后要求用户增删。 -没有用户决定时直接继续,不增加通用的最终确认。 +用户回答后,立即把确认内容写入 Decisions 和完整目标规格,移除当前阻塞项,再从目标开始重新遍历整棵决策树。目标规格尚不存在时,必须在处理该答案的同一轮创建;只更新 brief、等到最终确认或 Build 才补规格,都不算完成答案持久化。把答案当作产品约束,不当作获准采用的实现算法:一轮答案只能关闭该问题明确选择的输入→输出结果,不能因为某种实现会顺带处理其他反例,就把独立的空输入、失败或嵌入边界静默视为已决定。新答案引出的分支必须进入后续轮次;模糊、部分或未回答的内容继续保持 `[blocking]`。 + +当没有未决用户决定时,仍不能直接进入 Build。先进行一次完整性复核,主动寻找被遗漏或静默假设的用户可见分支;然后向用户给出包含目标、范围、关键决定、验收标准和明确非目标的共享理解摘要,并在 brief 中记录 `- [blocking] CONFIRM: <确认内容>`。这次复核也是可追溯性检查:摘要和验收示例中的每一项产品行为都必须能追溯到用户原话、某轮已确认答案或明确适用的公开契约;“一致”“直观”“通常如此”和仓库惯例不算确认。若某项策略第一次出现在摘要中,说明澄清循环尚未完成,必须把它重新标为用户问题,而不能借最终确认一次性决定。用户明确确认前,不修改项目实现、不进入 Build、也不调用 `next`。用户补充或否定时,更新正式产物并重新执行上述循环;明确确认后移除该阻塞项,记录确认,并使用 `--confirmed` 推进。 ### Batch 模式 -把尚未确定的用户决定按前置关系组织起来。只需要维护可复核的未决事项、依赖摘要和正式产物,不保存隐藏推理或完整内部推演。 +把当前目标视为一棵用户可见结果的决策树,并把尚未确定的用户决定按前置关系组织起来。只需要维护可复核的未决事项、依赖摘要和正式产物,不保存隐藏推理或完整内部推演。 + +每轮计算“本轮可回答问题集”:其中每个问题的前置决定都已确定,所需环境事实也已查清,并且答案不依赖本轮其他问题。这些问题组成当前 frontier;依赖仍未确定决定或调查中事实的问题留到后续轮次。 -每轮计算“本轮可回答问题集”:其中每个问题的前置决定都已确定,所需环境事实也已查清,并且答案不依赖本轮其他问题。依赖仍未确定决定或调查中事实的问题留到后续轮次。 +某个候选分支需要环境事实时,事实调查仍由你负责。宿主支持 sub-agent 或其他并行工作时,必须并行启动相互独立的事实调查;调查中的事实只暂缓依赖它的下游问题,其他 frontier 问题必须在当前轮继续提出。宿主不支持并行时,由当前执行者直接调查,不能把并行能力作为流程前提,也不能把可查明的事实转交用户。 对本轮可回答问题集执行以下步骤: @@ -68,7 +74,7 @@ Native 保存需求、完整目标规格、状态和证据。你负责理解、 用户回答后,把已确认内容写入 Decisions 和完整目标规格,移除对应 `[blocking]`;没有回答或回答不明确的问题继续保持 `[blocking]`,不得按推荐项自行补全。随后根据新答案重新计算本轮可回答问题集,逐轮处理新出现的分支。 -当本轮可回答问题集为空、相关事实已经查清且所有已识别用户决定均已处理时,执行一次完整性复核,重新检查是否仍有未处理或被静默假设的用户可见分支。向用户给出包含目标、范围、关键决定、验收标准和明确非目标的共享理解摘要,并在 brief 中使用 `- [blocking] CONFIRM: <确认内容>` 记录最终确认。用户明确确认前,不进入 Build,也不调用 `next`;用户补充或否定时,更新相应分支并继续下一轮。明确确认后,移除该阻塞项、记录确认,并按正常 transition 推进。 +当本轮可回答问题集为空、相关事实已经查清且所有已识别用户决定均已处理时,执行一次完整性复核,重新检查是否仍有未处理或被静默假设的用户可见分支。向用户给出包含目标、范围、关键决定、验收标准和明确非目标的共享理解摘要,并在 brief 中使用 `- [blocking] CONFIRM: <确认内容>` 记录最终确认。用户明确确认前,不进入 Build,也不调用 `next`;用户补充或否定时,更新相应分支并继续下一轮。明确确认后,移除该阻塞项、记录确认,并使用 `--confirmed` 推进。 以文本“规范化”为例,一个完整问题应同时说明大小写折叠、外围标点、内部标点或撇号保留,并用反例展示不同选择的输出。 @@ -132,9 +138,11 @@ comet native new --language zh-CN 文本或 token 行为通常要检查大小写、首尾与内部标点、空白、Unicode、空输入、重复项、顺序和并列结果。CLI 或 API 行为通常要检查默认值和错误结果。不要为了覆盖清单制造不存在的歧义。 +对解析、计数、分词或边界识别,完整性复核还要用反例检查:空输入与纯空白、没有分隔符、连续或混合分隔符,以及看似分隔符但属于有效内容的字符(例如缩写中的句点、数字中的小数点或转义后的分隔符)。只有任务范围内的反例才需要询问;明确非目标应直接排除。若两个反例可以独立选择不同结果,就不能合并为一个用户决定。契约只覆盖“非空输入”时,不能把它当作空输入行为的依据。 + 只有用户给出的信息、明确非目标、已确认决定,或当前能力的明确公开契约可以消除分支。发现阻塞后,按需求澄清协议为当前模式计算并提出一个问题或本轮可回答问题集;回答前不要调用 `next` 或修改项目实现。 -当未决事项清单为空,且仅凭 brief、完整目标规格、仓库事实和项目规则就能实现并验收,Sequential 模式直接继续;Batch 模式先完成最终共享理解确认。 +当未决事项清单为空,且仅凭 brief、完整目标规格、仓库事实和项目规则就能实现并验收,两种模式都先完成最终共享理解确认;Sequential 每轮仍只提出一个用户决定,Batch 仍按本轮可回答问题集成组提出。 ## 推进契约 @@ -146,7 +154,7 @@ Shape、Build、Verify 的 transition 会返回 `next: auto | manual`,以及 ` 若 disposition 为 `await-user`、`blocked` 或 `next: manual`,先根据磁盘事实和 blocking findings 处理。只有缺少的输入确实属于用户决定时才提问。 -Batch 模式中尚未回答的问题和最终共享理解确认都保持为 `[blocking]`。它们是需要等待用户输入的正常停点,不改变 continuation 契约,也不能通过自动推进绕过。 +Sequential 模式的当前问题和最终共享理解确认,以及 Batch 模式中尚未回答的问题和最终共享理解确认,都保持为 `[blocking]`。它们是需要等待用户输入的正常停点,不能通过自动推进绕过。 `workspace-root-changed` 与 `workspace-inspection-unavailable` 是只读提示,不单独阻止推进或归档。未知 workspace finding、确定冲突、失效证据和 repair stop 必须处理。 @@ -168,16 +176,16 @@ Batch 模式中尚未回答的问题和最终共享理解确认都保持为 `[bl 准备完成后运行: ```text -comet native next --summary <摘要> +comet native next --summary <摘要> [--confirmed] ``` -仅在本轮刚记录用户对既有阻塞问题的确认时追加 `--confirmed`;Batch 模式必须先取得最终共享理解确认。Runtime 会把 approval 绑定到当时的 brief/spec contract hash;若 Build 中 contract 发生变化,先取得用户对当前 contract 的确认,再按 status 返回的命令重试。不要手工编辑 `approval` 或 `approved_contract_hash`。 +仅在本轮刚记录用户对既有阻塞问题的确认时追加 `--confirmed`;Sequential 和 Batch 模式都必须先取得最终共享理解确认。Runtime 会把 approval 绑定到当时的 brief/spec contract hash;若 Build 中 contract 发生变化,先取得用户对当前 contract 的确认,再按 status 返回的命令重试。不要手工编辑 `approval` 或 `approved_contract_hash`。 ## Build 选择满足 brief 与拟议规格的最简单可靠方案。实现方式、是否保存计划、测试粒度、调试方法和审查强度由你根据风险决定。 -不要为流程制造额外文档。发现需求或规格漂移时,先更新 Native 产物;出现新的用户决定时标记 `[blocking]`,按当前配置的澄清协议处理。Batch 模式需要重新计算问题集,并在继续实现前取得更新后共享理解的最终确认。 +不要为流程制造额外文档。发现需求或规格漂移时,先更新 Native 产物;出现新的用户决定时标记 `[blocking]`,按当前配置的澄清协议处理。Sequential 模式需要重新遍历决策树,Batch 模式需要重新计算问题集;两种模式都要在继续实现前取得更新后共享理解的最终确认。 完成后提供真实项目产物;没有代码变化时给出明确理由。然后运行: diff --git a/assets/skills-zh/comet-native/reference/artifacts.md b/assets/skills-zh/comet-native/reference/artifacts.md index 06d4555e..26167e67 100644 --- a/assets/skills-zh/comet-native/reference/artifacts.md +++ b/assets/skills-zh/comet-native/reference/artifacts.md @@ -53,9 +53,18 @@ native: artifact_root: docs language: zh-CN clarification_mode: sequential + snapshot: + include: + - "**/*" + exclude: [] + max_files: 10000 + max_total_bytes: 268435456 + max_duration_ms: 60000 ``` -`clarification_mode` 只控制 Native 如何组织用户决定:`sequential` 每轮询问一个最上游问题,`batch` 每轮询问所有前置条件已经确定的问题。字段缺失时使用 `sequential`。它不改变 change schema、生命周期、Guard、安全确认或调用方停点。 +`clarification_mode` 控制 Native 如何组织用户决定,以及离开 Shape 前采用哪条确认契约:`sequential` 每轮询问一个最上游问题,`batch` 每轮询问所有前置条件已经确定的问题。字段缺失时使用 `sequential`。它不改变 change schema、生命周期、安全确认或调用方停点。 + +`snapshot` 定义内容快照的显式范围与资源预算。`include`/`exclude` 使用项目相对 `/` 路径以及 `*`、`**`、`?`;规范化策略及 hash 会写入新 change 的 baseline。后续 current snapshot 继续使用 baseline 策略,不能靠中途修改范围隐藏实现变化。`max_files`、`max_total_bytes` 和 `max_duration_ms` 只限制捕获工作,可按仓库规模提高;文件内容使用流式 SHA-256,不依赖 Git object hash,也没有独立的 5 MiB 单文件限制。 根目录迁移期间会出现 runtime 管理的 `pending_root_move`。存在该字段时普通写命令必须停止,不能自行选择旧根或新根。 @@ -103,7 +112,7 @@ run_id: null 不要直接编辑 Runtime 管理字段。`phase`、`revision`、`approval`、`approved_contract_hash`、`spec_changes`、operation、`base_hash`、三个 evidence ref、`run_id` 和 `archived` 都由 Runtime 管理。 -`approved_contract_hash` 把 approval 绑定到当时的 brief/spec contract。contract 发生变化后,必须由用户重新确认。需要改变需求时,只更新 brief 和 `specs//spec.md`;删除 capability 使用 `comet native spec remove`,再由命令检查并推进。 +`approval: confirmed` 表示 Runtime 已记录用户对当前共享理解的明确确认。`implicit` 只用于兼容旧 change,不代表用户已经确认;处于 Build 的旧 `implicit` change 必须确认后才能进入 Verify。`approved_contract_hash` 把 approval 绑定到当时的 brief/spec contract,contract 发生变化后也必须由用户重新确认。需要改变需求时,只更新 brief 和 `specs//spec.md`;删除 capability 使用 `comet native spec remove`,再由命令检查并推进。 ## Brief @@ -122,7 +131,7 @@ run_id: null 前四节必须有实质内容。仍阻塞实现的问题在 Open questions 下以 `- [blocking]` 开头;普通备注不会阻塞 Shape。 -Sequential 模式的 Open questions 同时保存一个最上游阻塞问题。Batch 模式使用 `- [blocking] Q1: <问题>`、`- [blocking] Q2: <问题>` 保存本轮全部可回答问题;该无序列表前缀是 Runtime 识别阻塞的固定格式,不能改成 Markdown 有序列表。未回答项继续保持 `[blocking]`。本轮问题处理完且完整性复核通过后,Batch 模式使用 `- [blocking] CONFIRM: <确认内容>` 保存共享理解确认,明确确认前不能进入 Build。 +Sequential 模式的 Open questions 同时保存一个最上游阻塞问题。Batch 模式使用 `- [blocking] Q1: <问题>`、`- [blocking] Q2: <问题>` 保存本轮全部可回答问题;该无序列表前缀是 Runtime 识别阻塞的固定格式,不能改成 Markdown 有序列表。未回答项继续保持 `[blocking]`。当前模式的全部问题处理完且完整性复核通过后,两种模式都使用 `- [blocking] CONFIRM: <确认内容>` 保存共享理解确认,明确确认前不能进入 Build。 问题编号只服务于当前澄清轮次。已确认答案应写入 Decisions 和完整目标规格;不要新增决策树产物,也不要把隐藏推理写入 brief。 @@ -185,7 +194,7 @@ comet native evidence format [--entries ] ## 内容寻址证据 -- `baseline-manifest.json`:change 创建时的有界项目快照。它只记录项目相对路径、size、hash、capture provider 和省略事实,不保存文件内容。Git provider 纳入 tracked 和未被 ignore 的 untracked 文件,并把 submodule/gitlink 作为原子条目;非 Git 项目使用带前后枚举围栏的有界物理树 provider。创建时若项目所有范围内仍有省略项,`new` 会失败并清理未完成 change。 +- `baseline-manifest.json`:change 创建时的有界项目快照。它只记录项目相对路径、size、内容 hash、capture provider、规范化 scope policy、实际资源预算和省略事实,不保存文件内容。Git 只用于项目文件枚举和 gitlink 边界,不作为普通文件的内容身份;普通文件始终流式计算 SHA-256。Git provider 纳入 tracked 和未被 ignore 的 untracked 文件,并把 submodule/gitlink 作为原子条目;非 Git 项目使用带前后枚举围栏的有界物理树 provider。显式排除项属于 baseline 定义之外,不伪装成 omission;创建时若策略范围内仍有省略项,`new` 会失败并清理未完成 change,同时返回实际限制与支持的配置修复路径。 - `git-selection-changed`:等待 Git 写入稳定后重试,不能授权为 partial scope。 - `git-enumeration-limit`:先缩小或清理项目所有范围。只有 current snapshot 返回可授权 scope,且用户接受未知尾部的具体风险时,才能按精确 hash、理由与 `--confirmed` 使用 partial 协议。 - `physical-selection-changed` 和 `physical-enumeration-limit`:稳定或缩小项目树后重试,不能授权为 partial scope。 diff --git a/assets/skills-zh/comet-native/reference/commands.md b/assets/skills-zh/comet-native/reference/commands.md index 8ac61681..2f31f2b3 100644 --- a/assets/skills-zh/comet-native/reference/commands.md +++ b/assets/skills-zh/comet-native/reference/commands.md @@ -87,8 +87,8 @@ comet native archive --dry-run comet native archive --expect-preflight ``` -- Shape:brief 和拟议规格通过后推进;只有本轮包含用户刚确认的决定时才传 `--confirmed`。成功进入 Build 时,Runtime 会把 approval 绑定到当前 contract hash。 -- Build:重新检查 brief 和拟议规格;至少给出一个真实项目产物,或使用 `--no-code-reason`。若 contract 在 approval 后变化,status/next 会要求用户重新确认当前 contract;只有取得该确认后才传 `--confirmed`。无法证明完整 scope 时,第一次调用返回 scope hash 与有界未归属明细而不推进;超出明细预算的变化由 `scope-detail-overflow` 的数量与内容 hash 表示。只有用户接受具体风险后,才可用完全匹配的 `--allow-partial-scope`、理由与 `--confirmed` 重试。 +- Shape:brief 和拟议规格通过后推进。Sequential 与 Batch 都必须取得最终共享理解确认并传 `--confirmed`。成功进入 Build 时,Runtime 会把 confirmed approval 绑定到当前 contract hash。 +- Build:重新检查 brief 和拟议规格;至少给出一个真实项目产物,或使用 `--no-code-reason`。旧 change 若仍为 `approval: implicit`,必须先确认当前共享理解;若 contract 在 approval 后变化,status/next 会要求用户重新确认当前 contract。两种情况都只有取得确认后才传 `--confirmed`。无法证明完整 scope 时,第一次调用返回 scope hash 与有界未归属明细而不推进;超出明细预算的变化由 `scope-detail-overflow` 的数量与内容 hash 表示。只有用户接受具体风险后,才可用完全匹配的 `--allow-partial-scope`、理由与 `--confirmed` 重试。 - Verify:必须提供 `--result` 和完整 `--report`;可选 `--receipt` 必须是当前 change、revision、contract 与 implementation scope 上 fresh 的内置 receipt。fail 回到 Build,可用失败分类和检查 ID 形成无进展签名;pass 进入 Archive。 - Repair:第三次相同失败会返回 manual stop。scope 真正变化时普通 Build `next` 会结束旧 repair episode 并继续;scope 不变时只能用 status 返回的 signature 和非空摘要 override 一次。单个 episode 的 semantic repair budget 与已耗尽 override 不可绕过;通用 Run iteration 只提供事件序号,不是长期 change 的永久停止条件。 - Archive:只能由 `archive` 命令完成,不能用 `next` 代替。先 `--dry-run`,再把同一次预演返回的 `preflightHash` 原样传给 `--expect-preflight`;Runtime 在锁内重算后才提交。 diff --git a/assets/skills-zh/comet-native/reference/recovery.md b/assets/skills-zh/comet-native/reference/recovery.md index 92af7556..5d7e5529 100644 --- a/assets/skills-zh/comet-native/reference/recovery.md +++ b/assets/skills-zh/comet-native/reference/recovery.md @@ -19,7 +19,7 @@ Shape 或 Build 存在 `[blocking]` 时,从 brief 的 Open questions 恢复当 能从仓库、工具或运行环境查明的事实继续由 Agent 调查。宿主支持并行工作时可以并行查证,但恢复不能依赖任何可选的并行能力;调查中的事实只延后依赖它的问题。 -Batch 模式中,未回答的问题继续保持 `[blocking]`。全部问题解决后仍要恢复或建立最终共享理解确认;只有用户明确确认后,才能移除该阻塞项并进入 Build。该过程不增加新 phase、change 状态字段或独立决策树文件。 +Sequential 模式恢复当前最上游问题,Batch 模式恢复所有仍未回答的编号问题;两者都继续保持 `[blocking]`。全部问题解决后仍要恢复或建立最终共享理解确认;只有用户明确确认后,才能移除该阻塞项并进入 Build。该过程不增加新 phase、change 状态字段或独立决策树文件。 长任务在同一 phase 内中断前可写 checkpoint: diff --git a/assets/skills-zh/comet/reference/comet-yaml-fields.md b/assets/skills-zh/comet/reference/comet-yaml-fields.md index a14258df..7a4f0b46 100644 --- a/assets/skills-zh/comet/reference/comet-yaml-fields.md +++ b/assets/skills-zh/comet/reference/comet-yaml-fields.md @@ -54,10 +54,10 @@ archived: false | `verify_result` | `pending`、`pass` 或 `fail` | | `verify_failures` | 机器维护的连续验证失败次数;`verify-fail` 自动加一,`verify-pass` 或 `archive-reopen` 重置为 `0`。达到 `3` 后下一次失败必须进入超限策略决策 | | `verification_report` | 验证报告文件路径,verify 通过前必须指向已存在文件 | -| `branch_status` | `pending` 或 `handled`。verify 和 archive 执行期间保持 `pending`;归档改动提交且用户选择的分支处理完成后设为 `handled` | +| `branch_status` | `pending` 或 `handled`。verify 阶段保持 `pending`;用户在归档前确认立即远端交付后,归档完成时设为 `handled` 并包含在唯一归档提交中。`handled` 只表示交付方式已经确认,不表示 push 或 PR 创建已经成功;只有远端操作成功后才能清除 current selection 并宣告 workflow 完成 | | `created_at` | change 创建日期(init 时自动写入),格式 `YYYY-MM-DD` | | `verified_at` | 验证通过时间,可为空 | -| `archive_confirmation` | `null`、`pending` 或 `confirmed`。`verify-pass` 进入 archive 阶段时写入 `pending`;用户在 `/comet-archive` 最终确认选择「确认归档」后,`archive-confirm` transition 写入 `confirmed`;`archive-reopen` 会清空该字段,防止复用旧确认 | +| `archive_confirmation` | `null`、`pending` 或 `confirmed`。`verify-pass` 进入 archive 阶段时写入 `pending`;用户在 `/comet-archive` 最终确认选择任一“确认归档并立即远端交付”选项后,`archive-confirm` transition 写入 `confirmed`;`archive-reopen` 会清空该字段,防止复用旧确认 | | `archived` | change 是否已归档 | ## 可选字段 diff --git a/assets/skills/comet-archive/SKILL.md b/assets/skills/comet-archive/SKILL.md index 0c5afd2c..855bea28 100644 --- a/assets/skills/comet-archive/SKILL.md +++ b/assets/skills/comet-archive/SKILL.md @@ -30,28 +30,30 @@ Proceed to Step 1 after verification passes. The script outputs specific failure 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) +### 1. Final Archive and Delivery 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. +After entry verification passes, first read `comet state get isolation`, then **follow the `comet/reference/decision-point.md` protocol to pause and wait for the user to confirm whether to archive and deliver remotely now**. Must not run `comet state transition archive-confirm` or `comet archive ""` before user confirmation. Before confirmation, show the user a brief summary: - Change name - Verification report path and result - Current branch/workspace and attribution summary for pre-existing dirty changes - Irreversible actions this archive will perform: merge main specs with OpenSpec delta semantics, annotate design doc / plan, and move the change to the archive directory +- Remote delivery to perform after archive: push the current bound branch only, or push and then create a PR The user confirmation question must be presented as a single-select question with these options: -- "Confirm archive" — record the final confirmation state, then run the archive script to complete spec merge and change movement +- "Confirm archive and push now" — complete archive, create the only archive commit, and push the current bound branch +- "Confirm archive, push now, and create a PR" — complete archive, create the only archive commit, push the current bound branch, and create a PR - "Needs adjustment or re-verification" — do not archive; run `comet state transition archive-reopen` to return to `phase: verify`, then invoke `/comet-verify`. If verification confirms fixes are needed, follow `/comet-verify`'s verification-failure decision flow back to `/comet-build` -- "Do not archive yet" — do not archive; keep the current `phase: archive` state and wait for the user to invoke `/comet-archive` again later +- "Do not archive yet" — do not run `archive-confirm` or the archive command; keep the active change, `phase: archive`, and `branch_status: pending`, then wait for the user to invoke `/comet-archive` again later -After the user selects "Confirm archive", immediately run: +Only after the user selects one of the first two immediate-delivery choices, record that choice and immediately run: ```bash comet state transition archive-confirm ``` -If the transition returns a non-zero exit code, report the error and stop. Only after the transition succeeds may Step 2 continue. After the user selects "Needs adjustment or re-verification", must first run the `archive-reopen` state transition; do not edit `.comet.yaml` manually. +If the transition returns a non-zero exit code, report the error and stop. Only after the transition succeeds may Step 2 continue. After the user selects "Needs adjustment or re-verification", must first run the `archive-reopen` state transition; do not edit `.comet.yaml` manually. After the user selects "Do not archive yet", stop immediately; do not archive, commit, push, or set `branch_status` to `handled`. ### 2. Execute Archive @@ -92,7 +94,16 @@ The archive script only moves files and merges the spec; it does not commit. Aft - The main spec content merged via delta semantics - Archive metadata annotations on the design doc / plan -After archive, read `git status --short` and compare it with the pre-archive dirty-worktree attribution baseline. Stage only paths attributable to this change: the original active path, actual archive path printed by the command, main specs changed by this delta, and archive metadata on this Design Doc/Plan. Stop if any path cannot be attributed. +First persist the confirmed delivery choice into archived state, then run the final archive guard: + +```bash +comet state set branch_status handled +comet guard archive +``` + +Here, `handled` means only that the user confirmed how to deliver this complete archive commit remotely. It does not mean that push or PR creation has succeeded. Stop without committing or performing remote operations if the state write or guard fails. + +After archive, read `git status --short` and compare it with the pre-archive dirty-worktree attribution baseline. Stage only paths attributable to this change: the original active path, actual archive path printed by the command, the archived `.comet.yaml` updated to `branch_status: handled`, main specs changed by this delta, and archive metadata on this Design Doc/Plan. Stop if any path cannot be attributed. Use explicit pathspecs, then inspect the staged diff. Never stage the whole repository or mix the user's pre-existing changes into the archive commit: @@ -104,30 +115,28 @@ git commit -m "chore: archive " Stop if the commit fails or the staged diff contains unrelated paths. -### 5. Handle the Branch After the Archive Commit +### 5. Deliver the Archive Commit and Complete -After the archive commit succeeds, first read `comet state get isolation` and route by isolation: +After the archive commit succeeds, perform only the remote delivery method the user confirmed in Step 1: -- `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. +- "Confirm archive and push now": push the current bound branch once. +- "Confirm archive, push now, and create a PR": push the current bound branch once, then create a PR using the current platform's available GitHub capability. The explicit Step 1 choice authorizes PR creation; do not substitute another branch disposition. -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: +If push fails, report the error and retain the current selection record; do not clear selection or report completion. Within the current task, retry only that same push. If PR creation fails, the branch already contains the complete archive commit; report the error and retain the current selection record. Within the current task, retry only PR creation. Do not automatically switch, delete, rebase, or rewrite branches after failure. -```bash -comet state set branch_status handled -comet guard archive -``` +Only after every remote delivery operation selected by the user succeeds may you run `comet state clear-selection` and report the Classic workflow complete. -The archive guard must verify both archive completeness and `branch_status: handled`; a failure means the workflow is still incomplete. +Archive no longer invokes Superpowers `finishing-a-development-branch`. Local merge, keeping a branch for later, or postponing push does not immediately produce final remote state, so the user must choose "Do not archive yet" in Step 1 rather than choosing it after archive. ## Exit Conditions - Archive script executed successfully (exit code 0) - Archive directory `openspec/changes/archive/YYYY-MM-DD-/` exists - Archived `.comet.yaml` contains `archived: true` -- Archive changes were committed with exact pathspecs -- The user's branch decision completed and archived state has `branch_status: handled` +- Archived `branch_status: handled` is included in the only archive commit - `comet guard archive` passes +- The only archive commit was pushed successfully using the delivery method confirmed before archive; if the user selected PR creation, the PR was created successfully +- Current selection was cleared after remote delivery succeeded The archive script moves `openspec/changes//` to `openspec/changes/archive/YYYY-MM-DD-/`. @@ -139,4 +148,4 @@ Comet Classic workflow complete. To start new Classic work, invoke `/comet-class ## Context Compression Recovery -Follow `comet/reference/context-recovery.md` with phase set to `archive`. If `archived: true` and archive directory exists, archival is complete — do not re-execute archive operations. +Follow `comet/reference/context-recovery.md` with phase set to `archive`. If `archived: true` and the archive directory exists, do not re-execute archive operations. Retry the same push or PR creation only when the current task context explicitly records the remote delivery method selected in Step 1. This Skill does not promise automatic recovery after the user leaves the flow and changes branch topology independently. diff --git a/assets/skills/comet-native/SKILL.md b/assets/skills/comet-native/SKILL.md index fe07e27a..a35ea43f 100644 --- a/assets/skills/comet-native/SKILL.md +++ b/assets/skills/comet-native/SKILL.md @@ -11,7 +11,7 @@ Run the entire workflow inside this Skill. Do not load phase Skills or impose fi ## Clarification Protocol -Read `native.clarification_mode` from `.comet/config.yaml`. Allowed values are `sequential` and `batch`; use `sequential` when the field is absent. This setting changes only how user questions are organized. It does not change Native phases, state, Guards, safety confirmations, or caller-defined stop points. +Read `native.clarification_mode` from `.comet/config.yaml`. Allowed values are `sequential` and `batch`; use `sequential` when the field is absent. This setting determines how user questions are organized and which confirmation contract applies before leaving Shape. It does not change Native phases, the change schema, safety confirmations, or caller-defined stop points. First identify undefined branches that would change user-visible results. Words such as “normalize,” “intuitive,” “standard,” and “expected” are not product contracts. Only the user's words, a confirmed answer, or a published contract that clearly applies to the current behavior can close such a branch. @@ -34,19 +34,25 @@ Before asking, inspect the current host's tool list. When the current tool list ### Sequential mode -When a user decision remains: +Treat the current goal as a decision tree of user-visible results. Run the following loop when entering Shape, after every user answer, and whenever relevant repository facts change: -1. Record one `[blocking]` question in the brief. -2. Ask only the most upstream question. -3. Provide “Question / Recommendation / Impact,” then end the turn. +1. First investigate repository, tool, and runtime facts required by the current branch. Do not ask the user for facts you can establish yourself. +2. Traverse every reasonably reachable user-visible branch from the goal, including downstream edge cases, failure results, and defaults introduced by the current answer. Do not stop after finding the first workable interpretation, and do not silently close an unconfirmed branch with the recommendation. +3. Maintain reviewable unresolved items and only the necessary dependency summary in the formal artifacts; do not persist hidden reasoning or a complete internal exploration. If several independent decisions remain, choose the most upstream one whose prerequisites are settled and leave the others for later rounds. +4. Record only the current `- [blocking] ` in the brief. Ask only the most upstream question: each round asks exactly this one user decision, without packing independent decisions into a multi-select or several parallel clauses. +5. Provide “Question / Recommendation / Impact,” using options, examples, or counterexamples that make the difference decidable, then end the turn. State the recommendation clearly, but never choose it for the user. Every downstream or dependent question surfaced by a new answer must retain all three parts; do not merely present a candidate collection and ask the user to edit it. -When no user decision remains, continue directly without adding a generic final confirmation. +After the user answers, immediately write the confirmed content into Decisions and the complete target specifications, remove the current blocking item, and traverse the entire decision tree again from the goal. If a target specification does not exist yet, create it in the same turn that handles the answer; updating only the brief and deferring the specification until final confirmation or Build is incomplete persistence. Treat the answer as a product constraint, not as approval of an implementation algorithm: one answer closes only the input-to-output result explicitly selected by that question. Do not silently settle independent empty-input, failure, or embedded-boundary examples merely because one possible implementation would handle them as a side effect. Branches introduced by the new answer must enter later rounds; ambiguous, partial, or unanswered content stays `[blocking]`. + +When no unresolved user decision remains, do not enter Build directly. Perform one completeness review and actively look for omitted or silently assumed user-visible branches. Then present a shared-understanding summary covering the outcome, scope, key decisions, acceptance criteria, and explicit non-goals, and record `- [blocking] CONFIRM: `. This review is also a traceability check: every product behavior in the summary and acceptance examples must trace to the user's words, a confirmed answer from an earlier round, or a published contract that clearly applies. “Consistent,” “intuitive,” “usual,” and repository convention are not confirmation. If a policy appears for the first time in the summary, the clarification loop is incomplete; restore it as a user question instead of deciding it through the final confirmation. Until the user confirms explicitly, do not modify project implementation, enter Build, or call `next`. If the user adds or rejects anything, update the formal artifacts and run the loop again. After explicit confirmation, remove the blocking item, record the confirmation, and advance with `--confirmed`. ### Batch mode -Organize unresolved user decisions by their prerequisite relationships. Maintain only reviewable open items, dependency summaries, and formal artifacts; do not persist hidden reasoning or a complete internal exploration. +Treat the current goal as a decision tree of user-visible results and organize unresolved user decisions by their prerequisite relationships. Maintain only reviewable open items, dependency summaries, and formal artifacts; do not persist hidden reasoning or a complete internal exploration. + +For each round, compute the ready question set. Every question in the set must have all prerequisite decisions settled, all required environment facts established, and an answer that does not depend on another question in the same round. These questions form the current frontier. Defer questions that depend on an unresolved decision or a fact still under investigation. -For each round, compute the ready question set. Every question in the set must have all prerequisite decisions settled, all required environment facts established, and an answer that does not depend on another question in the same round. Defer questions that depend on an unresolved decision or a fact still under investigation. +When a candidate branch needs an environment fact, investigating it remains your responsibility. When the host supports sub-agents or other parallel work, you must start independent fact investigations in parallel. A fact under investigation defers only its downstream questions; other frontier questions must still be asked in the current round. When the host does not support parallel work, investigate directly. Parallel capability is not a workflow prerequisite, and facts available from the environment must never be delegated to the user. For the ready question set: @@ -68,7 +74,7 @@ Use this format: After the user answers, write confirmed content into Decisions and the complete target specifications, then remove the corresponding `[blocking]` items. Keep unanswered or ambiguous items `[blocking]`; never fill them from the recommendation. Recompute the ready question set from the new answers and continue round by round as new branches become available. -When the ready question set is empty, all relevant facts are established, and every identified user decision is resolved, perform one completeness review. Recheck that no user-visible branch remains unaddressed or silently assumed. Present a shared-understanding summary that covers the outcome, scope, key decisions, acceptance criteria, and explicit non-goals, then persist the final confirmation as `- [blocking] CONFIRM: ` in the brief. Until the user confirms explicitly, do not enter Build or call `next`. If the user adds or rejects anything, update the affected branches and continue with another round. After explicit confirmation, remove the blocking item, record the confirmation, and follow the normal transition. +When the ready question set is empty, all relevant facts are established, and every identified user decision is resolved, perform one completeness review. Recheck that no user-visible branch remains unaddressed or silently assumed. Present a shared-understanding summary that covers the outcome, scope, key decisions, acceptance criteria, and explicit non-goals, then persist the final confirmation as `- [blocking] CONFIRM: ` in the brief. Until the user confirms explicitly, do not enter Build or call `next`. If the user adds or rejects anything, update the affected branches and continue with another round. After explicit confirmation, remove the blocking item, record the confirmation, and advance with `--confirmed`. For text “normalization,” for example, cover case folding, surrounding punctuation, preservation of internal punctuation or apostrophes, and use counterexamples to show how each choice changes output. @@ -132,9 +138,11 @@ Rewrite important nouns or actions as distinguishing “input → output” or For text or token behavior, normally inspect case, surrounding and internal punctuation, whitespace, Unicode, empty input, duplicates, ordering, and tied results. For CLI or API behavior, inspect defaults and error results. Do not invent ambiguity merely to cover a checklist. +For parsing, counting, tokenization, or boundary detection, the completeness review also uses counterexamples for empty and whitespace-only input, no delimiter, consecutive or mixed delimiters, and delimiter-like characters embedded in valid content, such as periods in abbreviations, decimal points in numbers, or escaped separators. Ask only about examples inside the stated scope; explicit non-goals close the rest. If two counterexamples can independently choose different results, they are not one user decision. A contract limited to “non-empty input” cannot justify an empty-input policy. + Only user-provided information, explicit non-goals, confirmed decisions, or a clear published contract for the current capability may close a branch. When blocked, follow the Clarification Protocol to compute and ask either one question or the ready question set for the configured mode. Do not call `next` or modify project implementation before the answer. -When no unresolved branch remains and the brief, complete target specifications, repository facts, and project rules are sufficient to implement and accept the work, Sequential mode continues directly. Batch mode first completes its final shared-understanding confirmation. +When no unresolved branch remains and the brief, complete target specifications, repository facts, and project rules are sufficient to implement and accept the work, both modes first complete the final shared-understanding confirmation. Sequential still asks only one user decision per round; Batch still asks the ready question set together. ## Progression Contract @@ -146,7 +154,7 @@ After `next: auto` with disposition `continue`, reread the returned phase and re For `await-user`, `blocked`, or `next: manual`, first resolve the returned disk facts and blocking findings. Ask only when the missing input is genuinely a user decision. -In Batch mode, unanswered questions and the final shared-understanding confirmation remain `[blocking]`. They are normal stop points for user input. They do not change the continuation contract and cannot be bypassed by automatic progression. +The current Sequential question and final shared-understanding confirmation, as well as unanswered Batch questions and its final shared-understanding confirmation, remain `[blocking]`. They are normal stop points for user input and cannot be bypassed by automatic progression. `workspace-root-changed` and `workspace-inspection-unavailable` are read-only advisories and do not block progress or archive by themselves. Unknown workspace findings, confirmed conflicts, stale evidence, and repair stops must be resolved. @@ -168,16 +176,16 @@ Shape is complete only when the brief, complete target specifications, repositor When ready, run: ```text -comet native next --summary +comet native next --summary [--confirmed] ``` -Append `--confirmed` only when this turn recorded the user's answer to an existing blocking question; Batch mode must first obtain the final shared-understanding confirmation. The Runtime binds approval to the current brief/spec contract hash. If the contract changes during Build, obtain user confirmation for the current contract and retry with the command returned by status. Do not edit `approval` or `approved_contract_hash` manually. +Append `--confirmed` only when this turn recorded the user's answer to an existing blocking question; both Sequential and Batch modes must first obtain the final shared-understanding confirmation. The Runtime binds approval to the current brief/spec contract hash. If the contract changes during Build, obtain user confirmation for the current contract and retry with the command returned by status. Do not edit `approval` or `approved_contract_hash` manually. ## Build Choose the simplest reliable implementation that satisfies the brief and proposed specifications. Decide implementation details, whether to save a plan, test granularity, debugging method, and review depth according to risk. -Do not create extra documents merely to satisfy the workflow. If requirements or specifications drift, update the Native artifacts first. If a new user decision appears, mark it `[blocking]` and follow the configured clarification protocol. Batch mode must recompute the ready question set and obtain a final confirmation of the updated shared understanding before implementation continues. +Do not create extra documents merely to satisfy the workflow. If requirements or specifications drift, update the Native artifacts first. If a new user decision appears, mark it `[blocking]` and follow the configured clarification protocol. Sequential mode must traverse the decision tree again, while Batch mode must recompute the ready question set; both modes must obtain final confirmation of the updated shared understanding before implementation continues. When implementation is complete, provide real project artifacts. If no code changed, provide a concrete reason. Then run: diff --git a/assets/skills/comet-native/reference/artifacts.md b/assets/skills/comet-native/reference/artifacts.md index ba92d414..33f9fed3 100644 --- a/assets/skills/comet-native/reference/artifacts.md +++ b/assets/skills/comet-native/reference/artifacts.md @@ -53,9 +53,18 @@ native: artifact_root: docs language: en clarification_mode: sequential + snapshot: + include: + - '**/*' + exclude: [] + max_files: 10000 + max_total_bytes: 268435456 + max_duration_ms: 60000 ``` -`clarification_mode` controls only how Native organizes user decisions. `sequential` asks one most-upstream question per round, while `batch` asks every question whose prerequisites are settled. The default is `sequential` when the field is absent. It does not change the change schema, lifecycle, Guards, safety confirmations, or caller-defined stop points. +`clarification_mode` controls how Native organizes user decisions and which confirmation contract applies before leaving Shape. `sequential` asks one most-upstream question per round, while `batch` asks every question whose prerequisites are settled. The default is `sequential` when the field is absent. It does not change the change schema, lifecycle, safety confirmations, or caller-defined stop points. + +`snapshot` defines the explicit content-snapshot scope and bounded resource budget. `include` and `exclude` use project-relative `/` paths with `*`, `**`, and `?`; the normalized policy and its hash are persisted in each new change baseline. Later current snapshots keep using the baseline policy, so changing configuration mid-change cannot hide implementation changes. `max_files`, `max_total_bytes`, and `max_duration_ms` bound capture work and can be raised for larger repositories. File content uses streaming SHA-256, does not depend on Git object hashes, and has no separate 5 MiB per-file limit. During an artifact-root move, the runtime-managed `pending_root_move` field is present. Ordinary write commands must stop while it exists; never choose the old or new root yourself. @@ -103,7 +112,7 @@ run_id: null Do not edit Runtime-managed fields directly. The Runtime owns `phase`, `revision`, `approval`, `approved_contract_hash`, `spec_changes`, operation, `base_hash`, all three evidence refs, `run_id`, and `archived`. -`approved_contract_hash` binds approval to the brief/spec contract from that moment. Later contract drift requires fresh user confirmation. To change requirements, edit only the brief and `specs//spec.md`; remove a capability with `comet native spec remove`, then let the command validate and advance state. +`approval: confirmed` means the Runtime recorded explicit user confirmation of the current shared understanding. `implicit` exists only for compatibility with older changes and does not prove confirmation; an older `implicit` change in Build must be confirmed before Verify. `approved_contract_hash` binds approval to the brief/spec contract from that moment, and later contract drift also requires fresh user confirmation. To change requirements, edit only the brief and `specs//spec.md`; remove a capability with `comet native spec remove`, then let the command validate and advance state. ## Brief @@ -122,7 +131,7 @@ Do not edit Runtime-managed fields directly. The Runtime owns `phase`, `revision The first four sections require substantive content. Prefix unresolved implementation-blocking questions under Open questions with `- [blocking]`; ordinary notes do not block Shape. -In Sequential mode, Open questions holds one most-upstream blocking question at a time. In Batch mode, persist the current ready question set as `- [blocking] Q1: `, `- [blocking] Q2: `, and so on. This unordered-list prefix is the fixed form recognized by the Runtime and must not be replaced with a Markdown ordered list. Unanswered items remain `[blocking]`. After the round is resolved and the completeness review passes, Batch mode persists the shared-understanding confirmation as `- [blocking] CONFIRM: `. Build cannot begin before explicit confirmation. +In Sequential mode, Open questions holds one most-upstream blocking question at a time. In Batch mode, persist the current ready question set as `- [blocking] Q1: `, `- [blocking] Q2: `, and so on. This unordered-list prefix is the fixed form recognized by the Runtime and must not be replaced with a Markdown ordered list. Unanswered items remain `[blocking]`. After all questions for the current mode are resolved and the completeness review passes, both modes persist the shared-understanding confirmation as `- [blocking] CONFIRM: `. Build cannot begin before explicit confirmation. Question numbers apply only to the current clarification round. Write confirmed answers into Decisions and the complete target specifications. Do not add a decision-tree artifact or persist hidden reasoning in the brief. diff --git a/assets/skills/comet-native/reference/commands.md b/assets/skills/comet-native/reference/commands.md index a56734f2..0f725910 100644 --- a/assets/skills/comet-native/reference/commands.md +++ b/assets/skills/comet-native/reference/commands.md @@ -87,8 +87,8 @@ comet native archive --dry-run comet native archive --expect-preflight ``` -- Shape: advance after the brief and proposed specifications pass; add `--confirmed` only when this turn contains a decision the user just confirmed. On successful entry to Build, the Runtime binds approval to the current contract hash. -- Build: recheck the brief and proposed specifications; provide at least one real project artifact or use `--no-code-reason`. If the contract changed after approval, status/next requires the user to reconfirm the current contract; pass `--confirmed` only after obtaining that confirmation. If complete scope cannot be proven, the first call returns a scope hash and bounded unattributed details without advancing; changes beyond the detail budget are represented by a `scope-detail-overflow` count and content hash. Retry only after the user accepts the specific risk, with the exact `--allow-partial-scope`, a reason, and `--confirmed`. +- Shape: advance after the brief and proposed specifications pass. Both Sequential and Batch must obtain the final shared-understanding confirmation and pass `--confirmed`. On successful entry to Build, the Runtime binds confirmed approval to the current contract hash. +- Build: recheck the brief and proposed specifications; provide at least one real project artifact or use `--no-code-reason`. An older change that still has `approval: implicit` must first confirm the current shared understanding. If the contract changed after approval, status/next requires the user to reconfirm the current contract. In either case, pass `--confirmed` only after obtaining confirmation. If complete scope cannot be proven, the first call returns a scope hash and bounded unattributed details without advancing; changes beyond the detail budget are represented by a `scope-detail-overflow` count and content hash. Retry only after the user accepts the specific risk, with the exact `--allow-partial-scope`, a reason, and `--confirmed`. - Verify: provide both `--result` and a complete `--report`. An optional `--receipt` must be fresh for the current change, revision, contract, and implementation scope. A failure returns to Build and may use failure categories and check IDs to form a no-progress signature; a pass enters Archive. - Repair: the third identical failure returns a manual stop. A genuine scope change on an ordinary Build `next` closes the old repair episode and continues. With unchanged scope, only one override is allowed, using the exact signature returned by status plus a non-empty summary. Neither a semantic repair budget nor an exhausted override can be bypassed; the generic Run iteration is only an event sequence number, not a permanent stop condition for a long-lived change. - Archive: only `archive` completes this phase; `next` cannot substitute for it. First run `--dry-run`, then pass the returned `preflightHash` unchanged to `--expect-preflight`. The runtime recomputes it under the mutation lock before committing. diff --git a/assets/skills/comet-native/reference/recovery.md b/assets/skills/comet-native/reference/recovery.md index e7fd47c9..298937b9 100644 --- a/assets/skills/comet-native/reference/recovery.md +++ b/assets/skills/comet-native/reference/recovery.md @@ -19,7 +19,7 @@ When Shape or Build contains `[blocking]` items, recover the current open decisi Continue investigating facts available from the repository, tools, or runtime environment yourself. Independent facts may be investigated in parallel when the host supports it, but recovery must not depend on any optional parallel capabilities. A fact still under investigation delays only the questions that depend on it. -In Batch mode, unanswered questions remain `[blocking]`. After all questions are resolved, recover or establish the final shared-understanding confirmation. Remove that blocking item and enter Build only after explicit user confirmation. This process adds no phase, change-state field, or separate decision-tree file. +Sequential mode recovers the current most-upstream question, while Batch mode recovers every unanswered numbered question; both remain `[blocking]`. After all questions are resolved, recover or establish the final shared-understanding confirmation. Remove that blocking item and enter Build only after explicit user confirmation. This process adds no phase, change-state field, or separate decision-tree file. Before interrupting a long task within the same phase, write a checkpoint when useful: diff --git a/assets/skills/comet-native/scripts/comet-native-runtime.mjs b/assets/skills/comet-native/scripts/comet-native-runtime.mjs index b1cdfc9a..bd78ee8d 100644 --- a/assets/skills/comet-native/scripts/comet-native-runtime.mjs +++ b/assets/skills/comet-native/scripts/comet-native-runtime.mjs @@ -8026,6 +8026,12 @@ var COMMENTS = { "native.artifact_root": "# Root directory where Native stores Comet specs, changes, and runtime data.", "native.language": "# Artifact language used by Native workflow documents.\n# language: en | zh-CN", "native.clarification_mode": "# Controls whether Native asks one clarification at a time or every currently answerable question in a round.\n# clarification_mode: sequential | batch", + "native.snapshot": "# Controls the auditable project scope and bounded work used by Native content snapshots.", + "native.snapshot.include": "# Selects the project-relative paths included in Native snapshots. Patterns use / and support *, **, and ?.", + "native.snapshot.exclude": "# Removes paths from the included scope. Exclusions are bound into each new change baseline.", + "native.snapshot.max_files": "# Bounds the number of files captured by one snapshot. Increase it for large monorepos.", + "native.snapshot.max_total_bytes": "# Bounds the total file content hashed by one snapshot. Content is streamed and does not depend on Git hashes.", + "native.snapshot.max_duration_ms": "# Bounds snapshot capture time in milliseconds. Increase it together with the byte budget on slower or larger repositories.", classic: "# Classic workflow settings. They do not change Native state or behavior.", "classic.language": "# Artifact language used by Classic workflow documents.\n# language: en | zh-CN", "classic.context_compression": "# Controls beta context compression for new Classic changes.\n# context_compression: off | beta", @@ -8041,6 +8047,12 @@ var COMMENTS = { "native.artifact_root": "# Native 产物的存放根目录,包括规格、change 和运行时数据。", "native.language": "# Native 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "native.clarification_mode": "# Native 每轮询问一个问题,或一次提出当前所有可回答的问题。\n# 可选值:sequential | batch", + "native.snapshot": "# Native 内容快照使用的可审计项目范围与有界工作预算。", + "native.snapshot.include": "# Native 快照纳入的项目相对路径;模式使用 /,支持 *、** 和 ?。", + "native.snapshot.exclude": "# 从纳入范围中排除路径;新 change 会把排除策略绑定到 baseline。", + "native.snapshot.max_files": "# 单次快照最多捕获的文件数;大型 monorepo 可按需提高。", + "native.snapshot.max_total_bytes": "# 单次快照最多哈希的文件内容总字节数;内容采用流式读取,不依赖 Git hash。", + "native.snapshot.max_duration_ms": "# 单次快照的最长执行时间(毫秒);较慢或更大的仓库应与字节预算一并提高。", classic: "# Classic 工作流配置,不会改变 Native 的状态或行为。", "classic.language": "# Classic 工作流文档使用的产物语言。\n# 可选值:en | zh-CN", "classic.context_compression": "# 新建 Classic change 是否启用 beta 上下文压缩。\n# 可选值:off | beta", @@ -8051,7 +8063,7 @@ var COMMENTS = { function projectConfigComment(key, language) { return COMMENTS[language][key]; } -function commentKey(line, block) { +function commentKey(line, block, nativeNested) { const match = /^(\s*)([a-z_]+):/u.exec(line); if (!match) return null; const indent = match[1].length; @@ -8061,13 +8073,18 @@ function commentKey(line, block) { const blockKey = `${block}.${key}`; if (blockKey in COMMENTS.en) return blockKey; } + if (indent === 4 && block === "native" && nativeNested === "snapshot") { + const nestedKey = `native.snapshot.${key}`; + if (nestedKey in COMMENTS.en) return nestedKey; + } return null; } function renderStructuredProjectConfig(value, language) { const output = []; let block = null; + let nativeNested = null; for (const line of (0, import_yaml.stringify)(value).trimEnd().split("\n")) { - const key = commentKey(line, block); + const key = commentKey(line, block, nativeNested); if (key) { const indent = line.match(/^\s*/u)?.[0] ?? ""; for (const comment of projectConfigComment(key, language).split("\n")) { @@ -8079,6 +8096,9 @@ function renderStructuredProjectConfig(value, language) { if (line.startsWith("native:")) block = "native"; else if (line.startsWith("classic:")) block = "classic"; else block = null; + nativeNested = null; + } else if (/^ {2}[a-z_]+:/u.test(line) && block === "native") { + nativeNested = line.startsWith(" snapshot:") ? "snapshot" : null; } } output.push(""); @@ -8673,11 +8693,100 @@ var NATIVE_KEYS = /* @__PURE__ */ new Set([ "artifact_root", "language", "clarification_mode", + "snapshot", "pending_root_move" ]); +var SNAPSHOT_KEYS = /* @__PURE__ */ new Set([ + "include", + "exclude", + "max_files", + "max_total_bytes", + "max_duration_ms" +]); var PENDING_KEYS = /* @__PURE__ */ new Set(["id", "from_artifact_root", "to_artifact_root", "stage", "cleanup"]); var NATIVE_PROJECT_CONFIG_MAX_BYTES = 64 * 1024; var CLEANUP_KEYS = /* @__PURE__ */ new Set(["kind", "state", "manifest_hash"]); +var MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH = 1024; +var MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS = 64; +var DEFAULT_NATIVE_SNAPSHOT_CONFIG = { + include: ["**/*"], + exclude: [], + max_files: 1e4, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 6e4 +}; +function normalizeNativeSnapshotPattern(value, label) { + if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.split("/").includes("..")) { + throw new Error(`${label} contains an unsafe pattern`); + } + if (value.length > MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH) { + throw new Error(`${label} exceeds ${MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH} characters`); + } + let wildcardTokens = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === "?") { + wildcardTokens += 1; + } else if (value[index] === "*") { + wildcardTokens += 1; + if (value[index + 1] === "*") index += 1; + } + } + if (wildcardTokens > MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS) { + throw new Error( + `${label} contains more than ${MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS} wildcard tokens` + ); + } + return value; +} +function snapshotPatterns(value, label, fallback) { + if (value === void 0) return [...fallback]; + if (!Array.isArray(value)) { + throw new Error(`${label} contains an unsafe pattern`); + } + return [...new Set(value.map((pattern) => normalizeNativeSnapshotPattern(pattern, label)))].sort( + (left, right) => left.localeCompare(right, "en") + ); +} +function positiveSnapshotInteger(value, fallback, label) { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${label} must be a positive integer`); + } + return resolved; +} +function parseSnapshot(value) { + if (value === void 0) + return { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ["**/*"], exclude: [] }; + const snapshot2 = record(value, "native.snapshot"); + rejectUnknown(snapshot2, SNAPSHOT_KEYS, "native.snapshot"); + return { + include: snapshotPatterns( + snapshot2.include, + "native.snapshot.include", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.include + ), + exclude: snapshotPatterns( + snapshot2.exclude, + "native.snapshot.exclude", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.exclude + ), + max_files: positiveSnapshotInteger( + snapshot2.max_files, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_files, + "native.snapshot.max_files" + ), + max_total_bytes: positiveSnapshotInteger( + snapshot2.max_total_bytes, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_total_bytes, + "native.snapshot.max_total_bytes" + ), + max_duration_ms: positiveSnapshotInteger( + snapshot2.max_duration_ms, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_duration_ms, + "native.snapshot.max_duration_ms" + ) + }; +} function record(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a mapping`); @@ -8763,6 +8872,7 @@ function parseConfig(value) { throw new Error("native.clarification_mode must be sequential or batch"); } const pending = parsePending(native.pending_root_move); + const snapshot2 = parseSnapshot(native.snapshot); return { schema: "comet.project.v1", default_workflow: root.default_workflow, @@ -8772,6 +8882,7 @@ function parseConfig(value) { artifact_root: normalizeArtifactRootRef(native.artifact_root), language, clarification_mode: clarificationMode, + snapshot: snapshot2, ...pending ? { pending_root_move: pending } : {} } }; @@ -8784,7 +8895,8 @@ function defaultProjectConfig(artifactRoot = "docs", language = "en") { native: { artifact_root: normalizeArtifactRootRef(artifactRoot), language, - clarification_mode: "sequential" + clarification_mode: "sequential", + snapshot: { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ["**/*"], exclude: [] } } }; } @@ -8853,6 +8965,7 @@ async function writeProjectConfig(projectRoot, config) { artifact_root: config.native.artifact_root, language: config.native.language, clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot, ...config.native.pending_root_move ? { pending_root_move: { id: config.native.pending_root_move.id, @@ -8880,6 +8993,7 @@ async function writeProjectConfig(projectRoot, config) { artifact_root: validated.native.artifact_root, language: validated.native.language, clarification_mode: validated.native.clarification_mode, + snapshot: validated.native.snapshot, ...validated.native.pending_root_move ? { pending_root_move: { id: validated.native.pending_root_move.id, @@ -10177,9 +10291,17 @@ var MANIFEST_KEYS = /* @__PURE__ */ new Set([ "entries", "omitted", "omittedCount", - "omissionOverflow" + "omissionOverflow", + "policy" +]); +var LIMIT_KEYS = /* @__PURE__ */ new Set([ + "maxFiles", + "maxFileBytes", + "maxTotalBytes", + "maxManifestBytes", + "maxDurationMs" ]); -var LIMIT_KEYS = /* @__PURE__ */ new Set(["maxFiles", "maxFileBytes", "maxTotalBytes", "maxManifestBytes"]); +var POLICY_KEYS = /* @__PURE__ */ new Set(["schema", "include", "exclude", "hash"]); var CAPTURE_KEYS = /* @__PURE__ */ new Set(["provider", "gitSelection", "physicalSelection", "projection"]); var GIT_PROJECTION_KEYS = /* @__PURE__ */ new Set(["provider", "selection"]); var GIT_SELECTION_KEYS = /* @__PURE__ */ new Set([ @@ -11096,6 +11218,133 @@ function isChangedDuringReadError(error) { function serializedManifestBytes(manifest) { return Buffer.byteLength(JSON.stringify(manifest, null, 2) + "\n"); } +function snapshotPolicyHash(include, exclude) { + return sha256Text( + `comet.native.snapshot-policy.v1 +${JSON.stringify({ include, exclude, hash: "sha256" })}` + ); +} +function epsilonClosure(tokens, positions, checkpoint) { + const closure = new Set(positions); + const pending = [...positions]; + while (pending.length > 0) { + if (checkpoint && !checkpoint()) return null; + const position = pending.pop(); + const token = tokens[position]; + if (token && (token.kind === "star" || token.kind === "globstar" || token.kind === "globstar-slash") && !closure.has(position + 1)) { + closure.add(position + 1); + pending.push(position + 1); + } + } + return closure; +} +function cooperativePatternCheckpoint(hasBudget) { + let operationsUntilCheck = 0; + return () => { + if (operationsUntilCheck > 0) { + operationsUntilCheck -= 1; + return true; + } + if (!hasBudget()) return false; + operationsUntilCheck = 63; + return true; + }; +} +function compileNativeSnapshotPattern(pattern) { + const normalized = normalizeNativeSnapshotPattern(pattern, "Native snapshot pattern"); + const tokens = []; + for (let index = 0; index < normalized.length; index += 1) { + const character = normalized[index]; + if (character === "*" && normalized[index + 1] === "*") { + index += 1; + if (normalized[index + 1] === "/") { + index += 1; + tokens.push({ kind: "globstar-slash" }); + } else { + tokens.push({ kind: "globstar" }); + } + } else if (character === "*") { + tokens.push({ kind: "star" }); + } else if (character === "?") { + tokens.push({ kind: "question" }); + } else { + tokens.push({ kind: "literal", value: character }); + } + } + return (relative, hasBudget) => { + const checkpoint = hasBudget ? cooperativePatternCheckpoint(hasBudget) : void 0; + if (checkpoint && !checkpoint()) return false; + let positions = epsilonClosure(tokens, /* @__PURE__ */ new Set([0]), checkpoint); + if (positions === null) return false; + for (const character of relative) { + if (checkpoint && !checkpoint()) return false; + const next = /* @__PURE__ */ new Set(); + for (const position of positions) { + if (checkpoint && !checkpoint()) return false; + const token = tokens[position]; + if (!token) continue; + if (token.kind === "literal" && token.value === character) { + next.add(position + 1); + } else if (token.kind === "question" && character !== "/") { + next.add(position + 1); + } else if (token.kind === "star" && character !== "/") { + next.add(position); + } else if (token.kind === "globstar") { + next.add(position); + } else if (token.kind === "globstar-slash") { + next.add(position); + if (character === "/") next.add(position + 1); + } + } + positions = epsilonClosure(tokens, next, checkpoint); + if (positions === null) return false; + if (positions.size === 0) return false; + } + return epsilonClosure(tokens, positions, checkpoint)?.has(tokens.length) ?? false; + }; +} +function resolveSnapshotPolicy(value) { + if (value === void 0) return void 0; + const include = [ + ...new Set(value.include.map((item) => normalizeNativeSnapshotPattern(item, "include"))) + ].sort((left, right) => left.localeCompare(right, "en")); + const exclude = [ + ...new Set(value.exclude.map((item) => normalizeNativeSnapshotPattern(item, "exclude"))) + ].sort((left, right) => left.localeCompare(right, "en")); + if (include.length === 0) throw new Error("Native snapshot policy include must not be empty"); + const hash6 = snapshotPolicyHash(include, exclude); + if ("hash" in value && value.hash !== hash6) { + throw new Error("Native snapshot policy hash is invalid"); + } + return { + manifest: { + schema: "comet.native.snapshot-policy.v1", + include, + exclude, + hash: hash6 + }, + includeMatchers: include.map(compileNativeSnapshotPattern), + excludeMatchers: exclude.map(compileNativeSnapshotPattern) + }; +} +function snapshotPolicyIncludes(policy, relative, execution) { + if (!policy) return true; + const hasBudget = () => nativeSnapshotExecutionHasBudget(execution); + let included = false; + for (const matcher of policy.includeMatchers) { + if (!hasBudget()) return false; + if (matcher(relative, hasBudget)) { + included = true; + break; + } + } + if (!included) return false; + for (const matcher of policy.excludeMatchers) { + if (!hasBudget()) return false; + if (matcher(relative, hasBudget)) return false; + } + return true; +} function foldSnapshotOverflowHash(previous, kind, value) { const payload = JSON.stringify(value); return sha256Text( @@ -11463,8 +11712,28 @@ function parseNativeContentSnapshotManifest(value) { maxManifestBytes: positiveInteger( limitValue.maxManifestBytes, "Native snapshot maxManifestBytes" - ) + ), + ...limitValue.maxDurationMs === void 0 ? {} : { + maxDurationMs: positiveInteger(limitValue.maxDurationMs, "Native snapshot maxDurationMs") + } }; + let policy; + if (manifest.policy !== void 0) { + const policyValue = record3(manifest.policy, "Native snapshot policy"); + rejectUnknown3(policyValue, POLICY_KEYS, "Native snapshot policy"); + if (policyValue.schema !== "comet.native.snapshot-policy.v1") { + throw new Error("Native snapshot policy schema is invalid"); + } + if (!Array.isArray(policyValue.include) || !Array.isArray(policyValue.exclude)) { + throw new Error("Native snapshot policy patterns must be arrays"); + } + policy = resolveSnapshotPolicy({ + include: policyValue.include, + exclude: policyValue.exclude, + hash: policyValue.hash, + schema: "comet.native.snapshot-policy.v1" + }).manifest; + } if (!Array.isArray(manifest.entries) || !Array.isArray(manifest.omitted)) { throw new Error("Native content snapshot entries and omissions must be arrays"); } @@ -11547,6 +11816,7 @@ function parseNativeContentSnapshotManifest(value) { createdAt: manifest.createdAt, complete: manifest.complete, limits, + ...policy ? { policy } : {}, entries, omitted, omittedCount, @@ -11687,13 +11957,18 @@ function nativeBaselineManifestFile(paths, name) { return path10.join(changeDir, "runtime", "baseline-manifest.json"); } async function createNativeContentSnapshot(paths, options = {}) { - const execution = createNativeSnapshotExecution(options); const limits = { maxFiles: options.limits?.maxFiles ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxFiles, maxFileBytes: options.limits?.maxFileBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxFileBytes, maxTotalBytes: options.limits?.maxTotalBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxTotalBytes, - maxManifestBytes: options.limits?.maxManifestBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxManifestBytes + maxManifestBytes: options.limits?.maxManifestBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxManifestBytes, + ...options.limits?.maxDurationMs === void 0 ? {} : { maxDurationMs: options.limits.maxDurationMs } }; + const policy = resolveSnapshotPolicy(options.policy); + const execution = createNativeSnapshotExecution({ + ...options, + deadlineMs: options.deadlineMs ?? limits.maxDurationMs + }); const gitSelectionLimits = resolveNativeGitSelectionLimits(options.gitSelectionLimits); const physicalSelectionLimits = resolveNativePhysicalSelectionLimits( options.physicalSelectionLimits @@ -12052,6 +12327,8 @@ async function createNativeContentSnapshot(paths, options = {}) { for (const record8 of before.records) { if (record8.type !== "file" && record8.type !== "symlink") continue; if (remainingNativeSnapshotTime(execution) < 1) break; + if (!snapshotPolicyIncludes(policy, record8.path, execution)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; const target = path10.resolve(projectRoot, ...record8.path.split("/")); let stat; try { @@ -12108,6 +12385,9 @@ async function createNativeContentSnapshot(paths, options = {}) { await options.gitSelectionHooks?.afterInitialSelection?.(); for (const relative of selectionPaths(gitSelection)) { if (!isSnapshotProjectRef(paths, relative)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; + if (!snapshotPolicyIncludes(policy, relative, execution)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; const target = path10.resolve(projectRoot, ...relative.split("/")); if (target === configFile || target === selectionFile || denylist.some((denied) => sameOrInside(denied, target))) { continue; @@ -12232,6 +12512,7 @@ async function createNativeContentSnapshot(paths, options = {}) { createdAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(), complete: omittedCount === 0, limits, + ...policy ? { policy: policy.manifest } : {}, entries, omitted, omittedCount, @@ -12268,6 +12549,21 @@ async function createNativeContentSnapshot(paths, options = {}) { } return manifest; } +async function createNativeCurrentContentSnapshot(paths, baseline, options = {}) { + const config = await readProjectConfig(paths.projectRoot); + const settings = config?.native.snapshot ?? DEFAULT_NATIVE_SNAPSHOT_CONFIG; + return createNativeContentSnapshot(paths, { + ...options, + policy: baseline.policy, + limits: { + maxFiles: settings.max_files, + maxFileBytes: settings.max_total_bytes, + maxTotalBytes: settings.max_total_bytes, + maxDurationMs: settings.max_duration_ms + }, + deadlineMs: settings.max_duration_ms + }); +} async function writeNativeBaselineManifest(paths, name, manifest) { const file = nativeBaselineManifestFile(paths, name); await resolveContainedNativePath(paths.nativeRoot, file); @@ -13312,15 +13608,17 @@ var NativeChangeRevisionConflictError = class extends Error { code = "native-change-revision-conflict"; }; var NativeBaselineIncompleteError = class extends Error { - constructor(change, omittedCount, omittedByReason, samplePaths, sampleTruncated) { + constructor(change, omittedCount, omittedByReason, samplePaths, sampleTruncated, effectiveLimits = null, policyHash = null) { super( - `Native change ${change} baseline is incomplete (${omittedCount} omitted entr${omittedCount === 1 ? "y" : "ies"})` + `Native change ${change} baseline is incomplete (${omittedCount} omitted entr${omittedCount === 1 ? "y" : "ies"}). Adjust native.snapshot scope or resource budgets in .comet/config.yaml, then retry.` ); this.change = change; this.omittedCount = omittedCount; this.omittedByReason = omittedByReason; this.samplePaths = samplePaths; this.sampleTruncated = sampleTruncated; + this.effectiveLimits = effectiveLimits; + this.policyHash = policyHash; this.name = "NativeBaselineIncompleteError"; } change; @@ -13328,6 +13626,8 @@ var NativeBaselineIncompleteError = class extends Error { omittedByReason; samplePaths; sampleTruncated; + effectiveLimits; + policyHash; code = "native-baseline-incomplete"; }; var NATIVE_BRIEF_TEMPLATE = [ @@ -13763,9 +14063,19 @@ async function createNativeChangeLocked(options) { fs13.mkdir(path16.join(changeDir, "runtime", "checkpoints"), { recursive: true }), atomicWriteText(path16.join(changeDir, "brief.md"), NATIVE_BRIEF_TEMPLATE) ]); + const projectConfig = await readProjectConfig(options.paths.projectRoot); + const snapshot2 = projectConfig?.native.snapshot ?? DEFAULT_NATIVE_SNAPSHOT_CONFIG; const baseline = await createNativeContentSnapshot(options.paths, { now: options.now, - origin: "change-created" + origin: "change-created", + policy: snapshot2, + limits: { + maxFiles: snapshot2.max_files, + maxFileBytes: snapshot2.max_total_bytes, + maxTotalBytes: snapshot2.max_total_bytes, + maxDurationMs: snapshot2.max_duration_ms + }, + deadlineMs: snapshot2.max_duration_ms }); if (!baseline.complete) { const health = inspectNativeContentSnapshotHealth(baseline); @@ -13780,7 +14090,9 @@ async function createNativeChangeLocked(options) { baseline.omittedCount, omittedByReason, health.samplePaths, - health.sampleTruncated + health.sampleTruncated, + baseline.limits, + baseline.policy?.hash ?? null ); } await writeNativeBaselineManifest(options.paths, state.name, baseline); @@ -14867,8 +15179,10 @@ function snapshotProjection(manifest) { maxFiles: parsed.limits.maxFiles, maxFileBytes: parsed.limits.maxFileBytes, maxTotalBytes: parsed.limits.maxTotalBytes, - maxManifestBytes: parsed.limits.maxManifestBytes + maxManifestBytes: parsed.limits.maxManifestBytes, + ...parsed.limits.maxDurationMs === void 0 ? {} : { maxDurationMs: parsed.limits.maxDurationMs } }, + ...parsed.policy ? { policy: parsed.policy } : {}, entries, omitted, omittedCount: parsed.omittedCount, @@ -15346,7 +15660,7 @@ function parseNativeSnapshotProjection(value, expectedHash) { exactScopeKeys( root, ["schema", "origin", "complete", "limits", "entries", "omitted", "omittedCount"], - ["capture", "omissionOverflow"], + ["capture", "omissionOverflow", "policy"], "Native snapshot projection" ); if (root.schema !== NATIVE_SNAPSHOT_PROJECTION_SCHEMA) { @@ -15359,6 +15673,7 @@ function parseNativeSnapshotProjection(value, expectedHash) { createdAt: "1970-01-01T00:00:00.000Z", complete: root.complete, limits: root.limits, + ...root.policy === void 0 ? {} : { policy: root.policy }, entries: root.entries, omitted: root.omitted, omittedCount: root.omittedCount, @@ -16376,7 +16691,7 @@ function assertEvidenceDocumentBudget(value) { function serializedEvidenceBytes2(value) { return Buffer.byteLength(JSON.stringify(value, null, 2) + "\n", "utf8"); } -function parseSnapshot(value, expectedHash) { +function parseSnapshot2(value, expectedHash) { return parseNativeSnapshotProjection(value, expectedHash); } function parseScope(value, expectedHash) { @@ -16452,10 +16767,10 @@ async function readNativeImplementationScopeBundle(paths, name, ref, hooks) { const currentHash = parseEvidenceRef(scope.currentProjectionRef, "snapshots"); const [baseline, current] = await Promise.all([ readEvidenceDocument({ paths, name, kind: "snapshots", hash: baselineHash }).then( - (value) => parseSnapshot(value, baselineHash) + (value) => parseSnapshot2(value, baselineHash) ), readEvidenceDocument({ paths, name, kind: "snapshots", hash: currentHash }).then( - (value) => parseSnapshot(value, currentHash) + (value) => parseSnapshot2(value, currentHash) ) ]); return rebuildNativeImplementationScopeBundle({ baseline, current, scope }); @@ -19226,6 +19541,7 @@ function projectionManifest(projection) { createdAt: "1970-01-01T00:00:00.000Z", complete: projection.complete, limits: projection.limits, + ...projection.policy ? { policy: projection.policy } : {}, entries: projection.entries, omitted: projection.omitted, omittedCount: projection.omittedCount, @@ -19240,12 +19556,13 @@ function nativeRootRef2(paths) { return value; } async function currentProjectionHash(options) { - const current = await createNativeContentSnapshot(options.paths, { + const baseline = projectionManifest(options.bundle.baseline); + const current = await createNativeCurrentContentSnapshot(options.paths, baseline, { origin: "explicit", now: options.now }); return buildNativeImplementationScopeBundle({ - baseline: projectionManifest(options.bundle.baseline), + baseline, current, contractHash: options.bundle.scope.contractHash, declaredArtifacts: options.bundle.scope.declaredArtifacts, @@ -22134,8 +22451,12 @@ async function inspectNativeRunConsistency(paths, state) { // domains/comet-native/native-continuation.ts var REPAIR_CODES = /^(?:run-|trajectory-|checkpoint-(?:missing|mismatch|invalid|progress-invalid)|transition-(?:incomplete|invalid))/u; function requiredPhaseInputs(state) { - if (state.phase === "shape") return ["summary"]; - if (state.phase === "build") return ["summary", "artifact-or-no-code-reason"]; + if (state.phase === "shape") { + return ["summary", "shared-understanding-confirmation"]; + } + if (state.phase === "build") { + return state.approval === "confirmed" ? ["summary", "artifact-or-no-code-reason"] : ["summary", "artifact-or-no-code-reason", "shared-understanding-confirmation"]; + } if (state.phase === "verify") return ["summary", "verification-result", "verification-report"]; return []; } @@ -22266,6 +22587,7 @@ function nativeContinuation(options) { requiredInputs: options.archiveReady ? [] : ["archive-readiness"] }; } + const confirmationSuffix = options.state.phase === "shape" || options.state.phase === "build" && options.state.approval !== "confirmed" ? " --confirmed" : ""; return { schema: "comet.native.continuation.v1", skill: "comet-native", @@ -22274,7 +22596,7 @@ function nativeContinuation(options) { revision: options.state.revision, disposition: "continue", action: "advance-phase", - command: `comet native next ${options.state.name} --summary ""`, + command: `comet native next ${options.state.name} --summary ""${confirmationSuffix}`, requiresUserDecision: false, requiredInputs: requiredPhaseInputs(options.state) }; @@ -22290,6 +22612,18 @@ var EXACT_METADATA = { retry: "next", repair: "none" }, + "shape-confirmation-required": { + severity: "error", + requiredAction: "confirm-shared-understanding", + retry: "next", + repair: "none" + }, + "approval-confirmation-required": { + severity: "error", + requiredAction: "confirm-shared-understanding", + retry: "next", + repair: "none" + }, "transition-incomplete": { severity: "error", requiredAction: "recover-transition", @@ -22461,7 +22795,7 @@ function projectRelativePath3(paths, state, finding) { } function retryCommand(retry, state, code) { if (retry === "next") { - return `comet native next ${state.name} --summary ""${code === "contract-changed-after-approval" ? " --confirmed" : ""}`; + return `comet native next ${state.name} --summary ""${code === "contract-changed-after-approval" || code === "shape-confirmation-required" || code === "approval-confirmation-required" ? " --confirmed" : ""}`; } if (retry === "status") return `comet native status ${state.name} --details`; return null; @@ -22479,7 +22813,7 @@ function structureNativeFindings(options) { repairCommand: metadata.repair === "doctor" ? `comet native doctor ${options.state.name} --repair${finding.code.startsWith("transition-") ? " --strategy continue" : ""}` : null, // This is intentionally code-based, not severity-based. Model-actionable // missing data must never be presented as a user decision. - requiresUserDecision: finding.code === "brief-blocking-question" || finding.code === "contract-changed-after-approval" || finding.code === "verification-scope-partial" || finding.code === "repair-iteration-limit" || finding.code === "repair-override-exhausted" + requiresUserDecision: finding.code === "brief-blocking-question" || finding.code === "shape-confirmation-required" || finding.code === "approval-confirmation-required" || finding.code === "contract-changed-after-approval" || finding.code === "verification-scope-partial" || finding.code === "repair-iteration-limit" || finding.code === "repair-override-exhausted" }; }).sort((left, right) => { const severityRank = { error: 0, warning: 1, info: 2 }; @@ -22793,11 +23127,11 @@ async function selectedName(paths) { return null; } } -function nativeNextCommand(state, archiveReady, evidenceRetreat = false) { +function nativeNextCommand(state, archiveReady, evidenceRetreat = false, _clarificationMode) { if (state.phase === "archive") { return archiveReady ? `comet native archive ${state.name} --dry-run` : evidenceRetreat ? `comet native next ${state.name} --summary ""` : null; } - return `comet native next ${state.name} --summary ""`; + return `comet native next ${state.name} --summary ""${state.phase === "shape" || state.phase === "build" && state.approval !== "confirmed" ? " --confirmed" : ""}`; } async function statusFindings(paths, state) { const changeDir = nativeChangeDir(paths, state.name); @@ -23128,13 +23462,19 @@ async function inspectNativeStatus(paths, name, options) { verificationResult: state.verification_result, specChanges: state.spec_changes.length, selected, - nextCommand: mutationBlocked || repairBlocked ? null : nativeNextCommand(state, archiveReady, evidenceRetreat), + nextCommand: mutationBlocked || repairBlocked ? null : nativeNextCommand(state, archiveReady, evidenceRetreat, options?.clarificationMode), archiveReady, inspection: resume.inspection, findingSummary: summarizeNativeFindings(findings), detailsCommand: `comet native status ${state.name} --details`, checkpoint: resume.checkpoint, - continuation: nativeContinuation({ state, findings, archiveReady, evidenceRetreat }), + continuation: nativeContinuation({ + state, + findings, + archiveReady, + evidenceRetreat, + clarificationMode: options?.clarificationMode + }), repair, ...options?.details ? { ...acceptancePage ? { acceptancePage } : {}, @@ -23217,7 +23557,11 @@ async function listNativeStatusPage(paths, options) { cursor: options?.cursor }); const candidates = await Promise.all( - names.slice(offset, offset + NATIVE_STATUS_PAGE_LIMITS.maxItems).map((name) => inspectNativeStatus(paths, name)) + names.slice(offset, offset + NATIVE_STATUS_PAGE_LIMITS.maxItems).map( + (name) => inspectNativeStatus(paths, name, { + clarificationMode: options?.clarificationMode + }) + ) ); const items = []; for (const candidate of candidates) { @@ -23249,8 +23593,10 @@ async function listNativeStatusPage(paths, options) { limits: { ...NATIVE_STATUS_PAGE_LIMITS } }; } -async function listNativeStatus(paths) { - return (await listNativeStatusPage(paths)).items; +async function listNativeStatus(paths, options) { + return (await listNativeStatusPage(paths, { + clarificationMode: options?.clarificationMode + })).items; } // domains/comet-native/native-doctor.ts @@ -25558,7 +25904,8 @@ async function finishForwardMove(options) { const stableNative = { artifact_root: config.native.artifact_root, language: config.native.language, - clarification_mode: config.native.clarification_mode + clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot }; const committed = { ...config, @@ -25701,7 +26048,8 @@ async function recoverNativeRootMove(options) { const stableNative = { artifact_root: config.native.artifact_root, language: config.native.language, - clarification_mode: config.native.clarification_mode + clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot }; const restored = { ...config, @@ -26445,6 +26793,7 @@ function projectionManifest2(projection) { createdAt: "1970-01-01T00:00:00.000Z", complete: projection.complete, limits: projection.limits, + ...projection.policy ? { policy: projection.policy } : {}, entries: projection.entries, omitted: projection.omitted, omittedCount: projection.omittedCount, @@ -26452,16 +26801,17 @@ function projectionManifest2(projection) { }; } async function collectBoundFacts(options) { + const baseline = projectionManifest2(options.scope.baseline); const [contract, snapshot2] = await Promise.all([ collectNativeContractFiles({ changeDir: nativeChangeDir(options.paths, options.state.name), briefRef: options.state.brief, specChanges: options.state.spec_changes }), - createNativeContentSnapshot(options.paths, { origin: "explicit" }) + createNativeCurrentContentSnapshot(options.paths, baseline, { origin: "explicit" }) ]); const currentBundle = buildNativeImplementationScopeBundle({ - baseline: projectionManifest2(options.scope.baseline), + baseline, current: snapshot2, contractHash: options.scope.authority.contractHash, declaredArtifacts: options.scope.authority.declaredArtifacts, @@ -27315,12 +27665,24 @@ async function inspectNativeGuard(options) { const brief = await validateNativeBrief(changeDir, options.state.brief); const specs = await validateNativeSpecChanges(options.paths, options.state); findings.push(...brief.findings, ...specs.findings); + if (findings.length === 0 && !options.evidence.confirmed) { + findings.push({ + code: "shape-confirmation-required", + message: "Native clarification requires explicit user confirmation of the shared understanding before Build" + }); + } } else if (options.state.phase === "build") { findings.push( ...(await validateNativeBrief(changeDir, options.state.brief)).findings, ...(await validateNativeSpecChanges(options.paths, options.state)).findings ); findings.push(...await validateBuildArtifacts(options.paths, options.evidence)); + if (findings.length === 0 && options.state.approval !== "confirmed" && !options.evidence.confirmed) { + findings.push({ + code: "approval-confirmation-required", + message: "Native approval is implicit; confirm the current shared understanding before leaving Build" + }); + } } else if (options.state.phase === "verify") { const report = options.evidence.verificationReport ?? options.state.verification_report; if (!report) { @@ -27499,7 +27861,7 @@ async function inspectNativeBuildEvidence(options) { baseline, refs: options.artifactRefs }); - const current = await createNativeContentSnapshot(options.paths, { + const current = await createNativeCurrentContentSnapshot(options.paths, baseline, { origin: "explicit", now: options.now }); @@ -27697,7 +28059,8 @@ async function retreatStaleNativeEvidence(options) { findings, continuation: nativeContinuation({ state: options.state, - archiveReady: previousPhase === "archive" + archiveReady: previousPhase === "archive", + clarificationMode: options.transition.clarificationMode }) }; } @@ -27752,7 +28115,10 @@ async function retreatStaleNativeEvidence(options) { next: "auto", nextCommand: null, findings: [], - continuation: nativeContinuation({ state: persisted }) + continuation: nativeContinuation({ + state: persisted, + clarificationMode: options.transition.clarificationMode + }) }; } async function advanceNativeChange(options) { @@ -27799,7 +28165,8 @@ async function advanceNativeChangeLocked(options) { continuation: nativeContinuation({ state, findings: repairFindings2, - archiveReady: state.phase === "archive" && state.verification_result === "pass" + archiveReady: state.phase === "archive" && state.verification_result === "pass", + clarificationMode: options.clarificationMode }), ...repair ? { repair } : {} }; @@ -27838,7 +28205,8 @@ async function advanceNativeChangeLocked(options) { const guard = await inspectNativeGuard({ paths: options.paths, state: candidate, - evidence: options.evidence + evidence: options.evidence, + clarificationMode: options.clarificationMode }); if (!guard.valid) { const findings = structureNativeFindings({ @@ -27852,7 +28220,11 @@ async function advanceNativeChangeLocked(options) { next: "manual", nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }) + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode + }) }; } const shapeContract = state.phase === "shape" ? await collectNativeContractFiles({ @@ -27891,7 +28263,11 @@ async function advanceNativeChangeLocked(options) { next: "manual", nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }) + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode + }) }; } const preparedScope = buildEvidence ? { @@ -27923,7 +28299,11 @@ async function advanceNativeChangeLocked(options) { next: "manual", nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode + }), preparedScope }; } @@ -27968,7 +28348,11 @@ async function advanceNativeChangeLocked(options) { next: "manual", nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode + }), preparedScope: preparedScope ? { ...preparedScope, partialAllowanceRef: null } : preparedScope }; } @@ -27997,7 +28381,11 @@ async function advanceNativeChangeLocked(options) { next: "manual", nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }) + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode + }) }; } let repairDecision = null; @@ -28052,7 +28440,7 @@ async function advanceNativeChangeLocked(options) { ...candidate, revision: state.revision + 1, phase: advanced.currentStep, - approval: options.evidence.confirmed ? "confirmed" : state.phase === "shape" && state.approval === null ? "implicit" : state.approval, + approval: options.evidence.confirmed ? "confirmed" : state.approval, approved_contract_hash: state.phase === "shape" ? shapeContract.contract.contractHash : state.phase === "build" && options.evidence.confirmed ? buildEvidence.contract.contract.contractHash : state.approved_contract_hash ?? null, run_id: run.runId, ...state.phase === "build" ? { @@ -28129,7 +28517,8 @@ async function advanceNativeChangeLocked(options) { continuation: nativeContinuation({ state: persisted, findings: repairFindings, - archiveReady: persisted.phase === "archive" && persisted.verification_result === "pass" + archiveReady: persisted.phase === "archive" && persisted.verification_result === "pass", + clarificationMode: options.clarificationMode }), ...preparedScope ? { preparedScope } : {}, ...repairDecision ? { repair: repairDecision } : {} @@ -28647,7 +29036,9 @@ async function dispatch(rawArgs, explicitProjectRoot) { await ensureNativeDirectories(paths); const state = await createNativeChange({ paths, name, language }); await selectNativeChange(paths, state.name); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode + }); return success( "new", { ...state, continuation: status.continuation }, @@ -28661,9 +29052,11 @@ async function dispatch(rawArgs, explicitProjectRoot) { const name = requiredPositional(rawArgs, "change name"); const capability = requiredPositional(rawArgs, "capability"); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const state = await markNativeSpecRemoval(paths, name, capability); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode + }); return success( "spec remove", { ...state, continuation: status.continuation }, @@ -28676,9 +29069,11 @@ async function dispatch(rawArgs, explicitProjectRoot) { const summary = takeOption(rawArgs, "--summary"); if (!summary) throw new NativeUsageError("--summary is required"); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const state = await rebaseNativeSpecChanges({ paths, name, summary }); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode + }); return success( "spec rebase", { ...state, continuation: status.continuation }, @@ -28691,8 +29086,11 @@ async function dispatch(rawArgs, explicitProjectRoot) { if (command === "list") { const cursor = takeOption(rawArgs, "--cursor"); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); - const page = await listNativeStatusPage(paths, cursor ? { cursor } : void 0); + const { config, paths } = await configuredPaths(projectRoot); + const page = await listNativeStatusPage(paths, { + ...cursor ? { cursor } : {}, + clarificationMode: config.native.clarification_mode + }); return success("list", page); } if (command === "show") { @@ -28748,19 +29146,25 @@ async function dispatch(rawArgs, explicitProjectRoot) { throw new NativeUsageError("--acceptance-cursor requires a change name"); } assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const data = name ? await inspectNativeStatus(paths, name, { details, - ...acceptanceCursor2 ? { acceptanceCursor: acceptanceCursor2 } : {} - }) : await listNativeStatusPage(paths, cursor ? { cursor } : void 0); + ...acceptanceCursor2 ? { acceptanceCursor: acceptanceCursor2 } : {}, + clarificationMode: config.native.clarification_mode + }) : await listNativeStatusPage(paths, { + ...cursor ? { cursor } : {}, + clarificationMode: config.native.clarification_mode + }); return success("status", data); } if (command === "select") { const name = requiredPositional(rawArgs, "change name"); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); await selectNativeChange(paths, name); - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode + }); return success( "select", { selected: name, continuation: status.continuation }, @@ -28777,7 +29181,7 @@ async function dispatch(rawArgs, explicitProjectRoot) { const artifacts = takeMany(rawArgs, "--artifact"); const expectedRevision = revisionOption(rawArgs); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const result2 = await checkpointNativeChange({ paths, name, @@ -28786,7 +29190,9 @@ async function dispatch(rawArgs, explicitProjectRoot) { artifacts, expectedRevision }); - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode + }); const manifestRef = path47.relative( paths.projectRoot, path47.join(nativeChangeDir(paths, name), ...result2.checkpoint.manifestRef.split("/")) @@ -28910,7 +29316,7 @@ async function dispatch(rawArgs, explicitProjectRoot) { throw new NativeUsageError("--override-repair cannot be combined with --result"); } assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const evidence = { summary, ...confirmed ? { confirmed: true } : {}, @@ -28926,7 +29332,12 @@ async function dispatch(rawArgs, explicitProjectRoot) { ...repairOverrideSignature ? { repairOverrideSignature } : {}, ...repairOverrideSummary ? { repairOverrideSummary } : {} }; - const result2 = await advanceNativeChange({ paths, name, evidence }); + const result2 = await advanceNativeChange({ + paths, + name, + evidence, + clarificationMode: config.native.clarification_mode + }); if (result2.next === "manual") { const repairBlocked = result2.repair?.disposition === "manual-stop" || result2.repair?.disposition === "hard-stop" || result2.findings.some( (finding) => [ @@ -28945,7 +29356,9 @@ async function dispatch(rawArgs, explicitProjectRoot) { } }; } - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode + }); return success("next", { ...result2, continuation: status.continuation }); } if (command === "archive") { @@ -29062,6 +29475,13 @@ function errorResult(command, error) { omittedByReason: error.omittedByReason, samplePaths: error.samplePaths, sampleTruncated: error.sampleTruncated, + effectiveLimits: error.effectiveLimits, + policyHash: error.policyHash, + configPath: ".comet/config.yaml", + supportedFixes: [ + "increase native.snapshot.max_total_bytes or native.snapshot.max_duration_ms", + "add an explicit native.snapshot.exclude pattern for data outside implementation scope" + ], requiredAction: "resolve-native-baseline" }, error: { code: "baseline-incomplete", message: error.message } diff --git a/assets/skills/comet/reference/comet-yaml-fields.md b/assets/skills/comet/reference/comet-yaml-fields.md index c3a1e768..ea0eb117 100644 --- a/assets/skills/comet/reference/comet-yaml-fields.md +++ b/assets/skills/comet/reference/comet-yaml-fields.md @@ -55,10 +55,10 @@ archived: false | `verify_result` | `pending`, `pass`, or `fail` | | `verify_failures` | Machine-owned consecutive verification failure count. `verify-fail` increments it; `verify-pass` or `archive-reopen` resets it to `0`. At `3`, the next failure requires the retry-limit strategy decision | | `verification_report` | Verification report file path; must point to an existing file before verify passes | -| `branch_status` | `pending` or `handled`; keep pending through verify/archive, then set handled after the archive commit and selected branch handling complete | +| `branch_status` | `pending` or `handled`; keep `pending` through verify. After the user confirms immediate remote delivery before archive, set `handled` when archive finishes and include it in the only archive commit. `handled` means only that the delivery method is confirmed, not that push or PR creation succeeded; clear current selection and report workflow completion only after remote operations succeed | | `created_at` | Change creation date (auto-written at init), format `YYYY-MM-DD` | | `verified_at` | Verification pass timestamp; may be empty | -| `archive_confirmation` | `null`, `pending`, or `confirmed`. `verify-pass` writes `pending` when entering the archive phase; after the user selects "Confirm archive" in `/comet-archive`, the `archive-confirm` transition writes `confirmed`; `archive-reopen` clears the field so an earlier confirmation cannot be reused | +| `archive_confirmation` | `null`, `pending`, or `confirmed`. `verify-pass` writes `pending` when entering the archive phase; after the user selects either "confirm archive and deliver remotely now" choice in `/comet-archive`, the `archive-confirm` transition writes `confirmed`; `archive-reopen` clears the field so an earlier confirmation cannot be reused | | `archived` | Whether the change has been archived | ## Optional Fields diff --git a/assets/skills/comet/scripts/comet-entry-runtime.mjs b/assets/skills/comet/scripts/comet-entry-runtime.mjs index 4212a73e..17754819 100644 --- a/assets/skills/comet/scripts/comet-entry-runtime.mjs +++ b/assets/skills/comet/scripts/comet-entry-runtime.mjs @@ -7625,11 +7625,100 @@ var NATIVE_KEYS = /* @__PURE__ */ new Set([ "artifact_root", "language", "clarification_mode", + "snapshot", "pending_root_move" ]); +var SNAPSHOT_KEYS = /* @__PURE__ */ new Set([ + "include", + "exclude", + "max_files", + "max_total_bytes", + "max_duration_ms" +]); var PENDING_KEYS = /* @__PURE__ */ new Set(["id", "from_artifact_root", "to_artifact_root", "stage", "cleanup"]); var NATIVE_PROJECT_CONFIG_MAX_BYTES = 64 * 1024; var CLEANUP_KEYS = /* @__PURE__ */ new Set(["kind", "state", "manifest_hash"]); +var MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH = 1024; +var MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS = 64; +var DEFAULT_NATIVE_SNAPSHOT_CONFIG = { + include: ["**/*"], + exclude: [], + max_files: 1e4, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 6e4 +}; +function normalizeNativeSnapshotPattern(value, label) { + if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.split("/").includes("..")) { + throw new Error(`${label} contains an unsafe pattern`); + } + if (value.length > MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH) { + throw new Error(`${label} exceeds ${MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH} characters`); + } + let wildcardTokens = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === "?") { + wildcardTokens += 1; + } else if (value[index] === "*") { + wildcardTokens += 1; + if (value[index + 1] === "*") index += 1; + } + } + if (wildcardTokens > MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS) { + throw new Error( + `${label} contains more than ${MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS} wildcard tokens` + ); + } + return value; +} +function snapshotPatterns(value, label, fallback) { + if (value === void 0) return [...fallback]; + if (!Array.isArray(value)) { + throw new Error(`${label} contains an unsafe pattern`); + } + return [...new Set(value.map((pattern) => normalizeNativeSnapshotPattern(pattern, label)))].sort( + (left, right) => left.localeCompare(right, "en") + ); +} +function positiveSnapshotInteger(value, fallback, label) { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${label} must be a positive integer`); + } + return resolved; +} +function parseSnapshot(value) { + if (value === void 0) + return { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ["**/*"], exclude: [] }; + const snapshot = record(value, "native.snapshot"); + rejectUnknown(snapshot, SNAPSHOT_KEYS, "native.snapshot"); + return { + include: snapshotPatterns( + snapshot.include, + "native.snapshot.include", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.include + ), + exclude: snapshotPatterns( + snapshot.exclude, + "native.snapshot.exclude", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.exclude + ), + max_files: positiveSnapshotInteger( + snapshot.max_files, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_files, + "native.snapshot.max_files" + ), + max_total_bytes: positiveSnapshotInteger( + snapshot.max_total_bytes, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_total_bytes, + "native.snapshot.max_total_bytes" + ), + max_duration_ms: positiveSnapshotInteger( + snapshot.max_duration_ms, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_duration_ms, + "native.snapshot.max_duration_ms" + ) + }; +} function record(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a mapping`); @@ -7715,6 +7804,7 @@ function parseConfig(value) { throw new Error("native.clarification_mode must be sequential or batch"); } const pending = parsePending(native.pending_root_move); + const snapshot = parseSnapshot(native.snapshot); return { schema: "comet.project.v1", default_workflow: root.default_workflow, @@ -7724,6 +7814,7 @@ function parseConfig(value) { artifact_root: normalizeArtifactRootRef(native.artifact_root), language, clarification_mode: clarificationMode, + snapshot, ...pending ? { pending_root_move: pending } : {} } }; diff --git a/assets/skills/comet/scripts/comet-hook-router.mjs b/assets/skills/comet/scripts/comet-hook-router.mjs index fa8f3150..c1c83b96 100644 --- a/assets/skills/comet/scripts/comet-hook-router.mjs +++ b/assets/skills/comet/scripts/comet-hook-router.mjs @@ -9501,11 +9501,100 @@ var NATIVE_KEYS = /* @__PURE__ */ new Set([ "artifact_root", "language", "clarification_mode", + "snapshot", "pending_root_move" ]); +var SNAPSHOT_KEYS = /* @__PURE__ */ new Set([ + "include", + "exclude", + "max_files", + "max_total_bytes", + "max_duration_ms" +]); var PENDING_KEYS = /* @__PURE__ */ new Set(["id", "from_artifact_root", "to_artifact_root", "stage", "cleanup"]); var NATIVE_PROJECT_CONFIG_MAX_BYTES = 64 * 1024; var CLEANUP_KEYS = /* @__PURE__ */ new Set(["kind", "state", "manifest_hash"]); +var MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH = 1024; +var MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS = 64; +var DEFAULT_NATIVE_SNAPSHOT_CONFIG = { + include: ["**/*"], + exclude: [], + max_files: 1e4, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 6e4 +}; +function normalizeNativeSnapshotPattern(value, label) { + if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.split("/").includes("..")) { + throw new Error(`${label} contains an unsafe pattern`); + } + if (value.length > MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH) { + throw new Error(`${label} exceeds ${MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH} characters`); + } + let wildcardTokens = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === "?") { + wildcardTokens += 1; + } else if (value[index] === "*") { + wildcardTokens += 1; + if (value[index + 1] === "*") index += 1; + } + } + if (wildcardTokens > MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS) { + throw new Error( + `${label} contains more than ${MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS} wildcard tokens` + ); + } + return value; +} +function snapshotPatterns(value, label, fallback) { + if (value === void 0) return [...fallback]; + if (!Array.isArray(value)) { + throw new Error(`${label} contains an unsafe pattern`); + } + return [...new Set(value.map((pattern) => normalizeNativeSnapshotPattern(pattern, label)))].sort( + (left, right) => left.localeCompare(right, "en") + ); +} +function positiveSnapshotInteger(value, fallback, label) { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${label} must be a positive integer`); + } + return resolved; +} +function parseSnapshot(value) { + if (value === void 0) + return { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ["**/*"], exclude: [] }; + const snapshot = record(value, "native.snapshot"); + rejectUnknown(snapshot, SNAPSHOT_KEYS, "native.snapshot"); + return { + include: snapshotPatterns( + snapshot.include, + "native.snapshot.include", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.include + ), + exclude: snapshotPatterns( + snapshot.exclude, + "native.snapshot.exclude", + DEFAULT_NATIVE_SNAPSHOT_CONFIG.exclude + ), + max_files: positiveSnapshotInteger( + snapshot.max_files, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_files, + "native.snapshot.max_files" + ), + max_total_bytes: positiveSnapshotInteger( + snapshot.max_total_bytes, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_total_bytes, + "native.snapshot.max_total_bytes" + ), + max_duration_ms: positiveSnapshotInteger( + snapshot.max_duration_ms, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_duration_ms, + "native.snapshot.max_duration_ms" + ) + }; +} function record(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a mapping`); @@ -9591,6 +9680,7 @@ function parseConfig(value) { throw new Error("native.clarification_mode must be sequential or batch"); } const pending = parsePending(native.pending_root_move); + const snapshot = parseSnapshot(native.snapshot); return { schema: "comet.project.v1", default_workflow: root.default_workflow, @@ -9600,6 +9690,7 @@ function parseConfig(value) { artifact_root: normalizeArtifactRootRef(native.artifact_root), language, clarification_mode: clarificationMode, + snapshot, ...pending ? { pending_root_move: pending } : {} } }; diff --git a/domains/comet-entry/project-status.ts b/domains/comet-entry/project-status.ts index e959a4f7..fe1ace39 100644 --- a/domains/comet-entry/project-status.ts +++ b/domains/comet-entry/project-status.ts @@ -205,7 +205,11 @@ export async function inspectCometProjectStatus(startPath: string): Promise, readonly samplePaths: string[], readonly sampleTruncated: boolean, + readonly effectiveLimits: NativeContentSnapshotManifest['limits'] | null = null, + readonly policyHash: string | null = null, ) { super( - `Native change ${change} baseline is incomplete (${omittedCount} omitted entr${omittedCount === 1 ? 'y' : 'ies'})`, + `Native change ${change} baseline is incomplete (${omittedCount} omitted entr${omittedCount === 1 ? 'y' : 'ies'}). Adjust native.snapshot scope or resource budgets in .comet/config.yaml, then retry.`, ); this.name = 'NativeBaselineIncompleteError'; } @@ -629,9 +636,19 @@ async function createNativeChangeLocked(options: { fs.mkdir(path.join(changeDir, 'runtime', 'checkpoints'), { recursive: true }), atomicWriteText(path.join(changeDir, 'brief.md'), NATIVE_BRIEF_TEMPLATE), ]); + const projectConfig = await readProjectConfig(options.paths.projectRoot); + const snapshot = projectConfig?.native.snapshot ?? DEFAULT_NATIVE_SNAPSHOT_CONFIG; const baseline = await createNativeContentSnapshot(options.paths, { now: options.now, origin: 'change-created', + policy: snapshot, + limits: { + maxFiles: snapshot.max_files, + maxFileBytes: snapshot.max_total_bytes, + maxTotalBytes: snapshot.max_total_bytes, + maxDurationMs: snapshot.max_duration_ms, + }, + deadlineMs: snapshot.max_duration_ms, }); if (!baseline.complete) { const health = inspectNativeContentSnapshotHealth(baseline); @@ -647,6 +664,8 @@ async function createNativeChangeLocked(options: { omittedByReason, health.samplePaths, health.sampleTruncated, + baseline.limits, + baseline.policy?.hash ?? null, ); } await writeNativeBaselineManifest(options.paths, state.name, baseline); diff --git a/domains/comet-native/native-check-receipt.ts b/domains/comet-native/native-check-receipt.ts index b08077d2..588f000c 100644 --- a/domains/comet-native/native-check-receipt.ts +++ b/domains/comet-native/native-check-receipt.ts @@ -18,7 +18,7 @@ import { collectNativeContractFiles } from './native-contract-files.js'; import { readNativeImplementationScopeBundle } from './native-evidence-storage.js'; import { sameNativeFileObject } from './native-file-identity.js'; import { isInsidePath } from './native-paths.js'; -import { createNativeContentSnapshot } from './native-snapshot.js'; +import { createNativeCurrentContentSnapshot } from './native-snapshot.js'; import type { NativeChangeState, NativeContentSnapshotManifest, @@ -99,6 +99,7 @@ function projectionManifest(projection: NativeSnapshotProjection): NativeContent createdAt: '1970-01-01T00:00:00.000Z', complete: projection.complete, limits: projection.limits, + ...(projection.policy ? { policy: projection.policy } : {}), entries: projection.entries, omitted: projection.omitted, omittedCount: projection.omittedCount, @@ -111,16 +112,17 @@ async function collectBoundFacts(options: { state: NativeChangeState; scope: NativeImplementationScopeBundle; }): Promise { + const baseline = projectionManifest(options.scope.baseline); const [contract, snapshot] = await Promise.all([ collectNativeContractFiles({ changeDir: nativeChangeDir(options.paths, options.state.name), briefRef: options.state.brief, specChanges: options.state.spec_changes, }), - createNativeContentSnapshot(options.paths, { origin: 'explicit' }), + createNativeCurrentContentSnapshot(options.paths, baseline, { origin: 'explicit' }), ]); const currentBundle = buildNativeImplementationScopeBundle({ - baseline: projectionManifest(options.scope.baseline), + baseline, current: snapshot, contractHash: options.scope.authority.contractHash, declaredArtifacts: options.scope.authority.declaredArtifacts, diff --git a/domains/comet-native/native-cli.ts b/domains/comet-native/native-cli.ts index d0837571..fd6b4f42 100644 --- a/domains/comet-native/native-cli.ts +++ b/domains/comet-native/native-cli.ts @@ -339,7 +339,9 @@ async function dispatch( await ensureNativeDirectories(paths); const state = await createNativeChange({ paths, name, language }); await selectNativeChange(paths, state.name); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode, + }); return success( 'new', { ...state, continuation: status.continuation }, @@ -352,9 +354,11 @@ async function dispatch( const name = requiredPositional(rawArgs, 'change name'); const capability = requiredPositional(rawArgs, 'capability'); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const state = await markNativeSpecRemoval(paths, name, capability); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode, + }); return success( 'spec remove', { ...state, continuation: status.continuation }, @@ -366,9 +370,11 @@ async function dispatch( const summary = takeOption(rawArgs, '--summary'); if (!summary) throw new NativeUsageError('--summary is required'); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const state = await rebaseNativeSpecChanges({ paths, name, summary }); - const status = await inspectNativeStatus(paths, state.name); + const status = await inspectNativeStatus(paths, state.name, { + clarificationMode: config.native.clarification_mode, + }); return success( 'spec rebase', { ...state, continuation: status.continuation }, @@ -380,8 +386,11 @@ async function dispatch( if (command === 'list') { const cursor = takeOption(rawArgs, '--cursor'); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); - const page = await listNativeStatusPage(paths, cursor ? { cursor } : undefined); + const { config, paths } = await configuredPaths(projectRoot); + const page = await listNativeStatusPage(paths, { + ...(cursor ? { cursor } : {}), + clarificationMode: config.native.clarification_mode, + }); return success('list', page); } if (command === 'show') { @@ -437,21 +446,27 @@ async function dispatch( throw new NativeUsageError('--acceptance-cursor requires a change name'); } assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const data = name ? await inspectNativeStatus(paths, name, { details, ...(acceptanceCursor ? { acceptanceCursor } : {}), + clarificationMode: config.native.clarification_mode, }) - : await listNativeStatusPage(paths, cursor ? { cursor } : undefined); + : await listNativeStatusPage(paths, { + ...(cursor ? { cursor } : {}), + clarificationMode: config.native.clarification_mode, + }); return success('status', data); } if (command === 'select') { const name = requiredPositional(rawArgs, 'change name'); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); await selectNativeChange(paths, name); - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode, + }); return success( 'select', { selected: name, continuation: status.continuation }, @@ -467,7 +482,7 @@ async function dispatch( const artifacts = takeMany(rawArgs, '--artifact'); const expectedRevision = revisionOption(rawArgs); assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const result = await checkpointNativeChange({ paths, name, @@ -476,7 +491,9 @@ async function dispatch( artifacts, expectedRevision, }); - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode, + }); const manifestRef = path .relative( paths.projectRoot, @@ -607,7 +624,7 @@ async function dispatch( throw new NativeUsageError('--override-repair cannot be combined with --result'); } assertNoArguments(rawArgs); - const { paths } = await configuredPaths(projectRoot); + const { config, paths } = await configuredPaths(projectRoot); const evidence: NativeAdvanceEvidence = { summary, ...(confirmed ? { confirmed: true } : {}), @@ -623,7 +640,12 @@ async function dispatch( ...(repairOverrideSignature ? { repairOverrideSignature } : {}), ...(repairOverrideSummary ? { repairOverrideSummary } : {}), }; - const result = await advanceNativeChange({ paths, name, evidence }); + const result = await advanceNativeChange({ + paths, + name, + evidence, + clarificationMode: config.native.clarification_mode, + }); if (result.next === 'manual') { const repairBlocked = result.repair?.disposition === 'manual-stop' || @@ -645,7 +667,9 @@ async function dispatch( }, }; } - const status = await inspectNativeStatus(paths, name); + const status = await inspectNativeStatus(paths, name, { + clarificationMode: config.native.clarification_mode, + }); return success('next', { ...result, continuation: status.continuation }); } if (command === 'archive') { @@ -767,6 +791,13 @@ function errorResult(command: string | null, error: unknown): DispatchResult { omittedByReason: error.omittedByReason, samplePaths: error.samplePaths, sampleTruncated: error.sampleTruncated, + effectiveLimits: error.effectiveLimits, + policyHash: error.policyHash, + configPath: '.comet/config.yaml', + supportedFixes: [ + 'increase native.snapshot.max_total_bytes or native.snapshot.max_duration_ms', + 'add an explicit native.snapshot.exclude pattern for data outside implementation scope', + ], requiredAction: 'resolve-native-baseline', }, error: { code: 'baseline-incomplete', message: error.message }, diff --git a/domains/comet-native/native-config.ts b/domains/comet-native/native-config.ts index f59b6a4a..6c3d743c 100644 --- a/domains/comet-native/native-config.ts +++ b/domains/comet-native/native-config.ts @@ -16,17 +16,118 @@ import type { CometProjectConfig, NativePendingRootMove, NativeProjectPaths, + NativeSnapshotConfig, } from './native-types.js'; const NATIVE_KEYS = new Set([ 'artifact_root', 'language', 'clarification_mode', + 'snapshot', 'pending_root_move', ]); +const SNAPSHOT_KEYS = new Set([ + 'include', + 'exclude', + 'max_files', + 'max_total_bytes', + 'max_duration_ms', +]); const PENDING_KEYS = new Set(['id', 'from_artifact_root', 'to_artifact_root', 'stage', 'cleanup']); const NATIVE_PROJECT_CONFIG_MAX_BYTES = 64 * 1024; const CLEANUP_KEYS = new Set(['kind', 'state', 'manifest_hash']); +export const MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH = 1024; +export const MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS = 64; +export const DEFAULT_NATIVE_SNAPSHOT_CONFIG: NativeSnapshotConfig = { + include: ['**/*'], + exclude: [], + max_files: 10_000, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 60_000, +}; + +export function normalizeNativeSnapshotPattern(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + value.split('/').includes('..') + ) { + throw new Error(`${label} contains an unsafe pattern`); + } + if (value.length > MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH) { + throw new Error(`${label} exceeds ${MAX_NATIVE_SNAPSHOT_PATTERN_LENGTH} characters`); + } + let wildcardTokens = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '?') { + wildcardTokens += 1; + } else if (value[index] === '*') { + wildcardTokens += 1; + if (value[index + 1] === '*') index += 1; + } + } + if (wildcardTokens > MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS) { + throw new Error( + `${label} contains more than ${MAX_NATIVE_SNAPSHOT_PATTERN_WILDCARDS} wildcard tokens`, + ); + } + return value; +} + +function snapshotPatterns(value: unknown, label: string, fallback: string[]): string[] { + if (value === undefined) return [...fallback]; + if (!Array.isArray(value)) { + throw new Error(`${label} contains an unsafe pattern`); + } + return [...new Set(value.map((pattern) => normalizeNativeSnapshotPattern(pattern, label)))].sort( + (left, right) => left.localeCompare(right, 'en'), + ); +} + +function positiveSnapshotInteger(value: unknown, fallback: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || (resolved as number) < 1) { + throw new Error(`${label} must be a positive integer`); + } + return resolved as number; +} + +function parseSnapshot(value: unknown): NativeSnapshotConfig { + if (value === undefined) + return { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ['**/*'], exclude: [] }; + const snapshot = record(value, 'native.snapshot'); + rejectUnknown(snapshot, SNAPSHOT_KEYS, 'native.snapshot'); + return { + include: snapshotPatterns( + snapshot.include, + 'native.snapshot.include', + DEFAULT_NATIVE_SNAPSHOT_CONFIG.include, + ), + exclude: snapshotPatterns( + snapshot.exclude, + 'native.snapshot.exclude', + DEFAULT_NATIVE_SNAPSHOT_CONFIG.exclude, + ), + max_files: positiveSnapshotInteger( + snapshot.max_files, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_files, + 'native.snapshot.max_files', + ), + max_total_bytes: positiveSnapshotInteger( + snapshot.max_total_bytes, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_total_bytes, + 'native.snapshot.max_total_bytes', + ), + max_duration_ms: positiveSnapshotInteger( + snapshot.max_duration_ms, + DEFAULT_NATIVE_SNAPSHOT_CONFIG.max_duration_ms, + 'native.snapshot.max_duration_ms', + ), + }; +} function record(value: unknown, label: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -125,6 +226,7 @@ function parseConfig(value: unknown): CometProjectConfig { throw new Error('native.clarification_mode must be sequential or batch'); } const pending = parsePending(native.pending_root_move); + const snapshot = parseSnapshot(native.snapshot); return { schema: 'comet.project.v1', default_workflow: root.default_workflow, @@ -134,6 +236,7 @@ function parseConfig(value: unknown): CometProjectConfig { artifact_root: normalizeArtifactRootRef(native.artifact_root), language, clarification_mode: clarificationMode, + snapshot, ...(pending ? { pending_root_move: pending } : {}), }, }; @@ -151,6 +254,7 @@ export function defaultProjectConfig( artifact_root: normalizeArtifactRootRef(artifactRoot), language, clarification_mode: 'sequential', + snapshot: { ...DEFAULT_NATIVE_SNAPSHOT_CONFIG, include: ['**/*'], exclude: [] }, }, }; } @@ -234,6 +338,7 @@ export async function writeProjectConfig( artifact_root: config.native.artifact_root, language: config.native.language, clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot, ...(config.native.pending_root_move ? { pending_root_move: { @@ -265,6 +370,7 @@ export async function writeProjectConfig( artifact_root: validated.native.artifact_root, language: validated.native.language, clarification_mode: validated.native.clarification_mode, + snapshot: validated.native.snapshot, ...(validated.native.pending_root_move ? { pending_root_move: { diff --git a/domains/comet-native/native-continuation.ts b/domains/comet-native/native-continuation.ts index 9af1888c..670ed21e 100644 --- a/domains/comet-native/native-continuation.ts +++ b/domains/comet-native/native-continuation.ts @@ -1,5 +1,6 @@ import type { NativeChangeState, + NativeClarificationMode, NativeContinuation, NativeStructuredFinding, } from './native-types.js'; @@ -9,8 +10,14 @@ const REPAIR_CODES = /^(?:run-|trajectory-|checkpoint-(?:missing|mismatch|invalid|progress-invalid)|transition-(?:incomplete|invalid))/u; function requiredPhaseInputs(state: NativeChangeState): string[] { - if (state.phase === 'shape') return ['summary']; - if (state.phase === 'build') return ['summary', 'artifact-or-no-code-reason']; + if (state.phase === 'shape') { + return ['summary', 'shared-understanding-confirmation']; + } + if (state.phase === 'build') { + return state.approval === 'confirmed' + ? ['summary', 'artifact-or-no-code-reason'] + : ['summary', 'artifact-or-no-code-reason', 'shared-understanding-confirmation']; + } if (state.phase === 'verify') return ['summary', 'verification-result', 'verification-report']; return []; } @@ -21,6 +28,7 @@ export function nativeContinuation(options: { archiveReady?: boolean; evidenceRetreat?: boolean; done?: boolean; + clarificationMode?: NativeClarificationMode; }): NativeContinuation { const findings = options.findings ?? []; const actionableFindings = findings.filter( @@ -149,6 +157,11 @@ export function nativeContinuation(options: { requiredInputs: options.archiveReady ? [] : ['archive-readiness'], }; } + const confirmationSuffix = + options.state.phase === 'shape' || + (options.state.phase === 'build' && options.state.approval !== 'confirmed') + ? ' --confirmed' + : ''; return { schema: 'comet.native.continuation.v1', skill: 'comet-native', @@ -157,7 +170,7 @@ export function nativeContinuation(options: { revision: options.state.revision, disposition: 'continue', action: 'advance-phase', - command: `comet native next ${options.state.name} --summary ""`, + command: `comet native next ${options.state.name} --summary ""${confirmationSuffix}`, requiresUserDecision: false, requiredInputs: requiredPhaseInputs(options.state), }; diff --git a/domains/comet-native/native-diagnostics.ts b/domains/comet-native/native-diagnostics.ts index e953f4db..83cfe231 100644 --- a/domains/comet-native/native-diagnostics.ts +++ b/domains/comet-native/native-diagnostics.ts @@ -34,6 +34,7 @@ import { import { captureNativeProtectedDirectoryGuard } from './native-protected-file.js'; import type { NativeChangeState, + NativeClarificationMode, NativeFinding, NativeProjectPaths, NativeStatusPageProjection, @@ -61,6 +62,7 @@ export function nativeNextCommand( state: NativeChangeState, archiveReady: boolean, evidenceRetreat = false, + _clarificationMode?: NativeClarificationMode, ): string | null { if (state.phase === 'archive') { return archiveReady @@ -69,7 +71,11 @@ export function nativeNextCommand( ? `comet native next ${state.name} --summary ""` : null; } - return `comet native next ${state.name} --summary ""`; + return `comet native next ${state.name} --summary ""${ + state.phase === 'shape' || (state.phase === 'build' && state.approval !== 'confirmed') + ? ' --confirmed' + : '' + }`; } async function statusFindings( @@ -164,7 +170,11 @@ async function statusFindings( export async function inspectNativeStatus( paths: NativeProjectPaths, name: string, - options?: { details?: boolean; acceptanceCursor?: string }, + options?: { + details?: boolean; + acceptanceCursor?: string; + clarificationMode?: NativeClarificationMode; + }, ): Promise { const selected = (await selectedName(paths)) === name; let state: NativeChangeState; @@ -434,13 +444,19 @@ export async function inspectNativeStatus( nextCommand: mutationBlocked || repairBlocked ? null - : nativeNextCommand(state, archiveReady, evidenceRetreat), + : nativeNextCommand(state, archiveReady, evidenceRetreat, options?.clarificationMode), archiveReady, inspection: resume.inspection, findingSummary: summarizeNativeFindings(findings), detailsCommand: `comet native status ${state.name} --details`, checkpoint: resume.checkpoint, - continuation: nativeContinuation({ state, findings, archiveReady, evidenceRetreat }), + continuation: nativeContinuation({ + state, + findings, + archiveReady, + evidenceRetreat, + clarificationMode: options?.clarificationMode, + }), repair, ...(options?.details ? { @@ -531,7 +547,7 @@ function nativeStatusOffset(options: { export async function listNativeStatusPage( paths: NativeProjectPaths, - options?: { cursor?: string | null }, + options?: { cursor?: string | null; clarificationMode?: NativeClarificationMode }, ): Promise { const names = await boundedNativeChangeNames(paths); const namesHash = canonicalHash('comet.native.status-names.v1', names); @@ -541,9 +557,11 @@ export async function listNativeStatusPage( cursor: options?.cursor, }); const candidates = await Promise.all( - names - .slice(offset, offset + NATIVE_STATUS_PAGE_LIMITS.maxItems) - .map((name) => inspectNativeStatus(paths, name)), + names.slice(offset, offset + NATIVE_STATUS_PAGE_LIMITS.maxItems).map((name) => + inspectNativeStatus(paths, name, { + clarificationMode: options?.clarificationMode, + }), + ), ); const items: NativeStatusProjection[] = []; for (const candidate of candidates) { @@ -582,8 +600,13 @@ export async function listNativeStatusPage( /** Compatibility projection for in-process callers; CLI consumers receive the resumable page. */ export async function listNativeStatus( paths: NativeProjectPaths, + options?: { clarificationMode?: NativeClarificationMode }, ): Promise { - return (await listNativeStatusPage(paths)).items; + return ( + await listNativeStatusPage(paths, { + clarificationMode: options?.clarificationMode, + }) + ).items; } export async function inspectNativeArtifactFindings( diff --git a/domains/comet-native/native-findings.ts b/domains/comet-native/native-findings.ts index 6d34b3e5..dc7a713a 100644 --- a/domains/comet-native/native-findings.ts +++ b/domains/comet-native/native-findings.ts @@ -28,6 +28,18 @@ const EXACT_METADATA: Record = { retry: 'next', repair: 'none', }, + 'shape-confirmation-required': { + severity: 'error', + requiredAction: 'confirm-shared-understanding', + retry: 'next', + repair: 'none', + }, + 'approval-confirmation-required': { + severity: 'error', + requiredAction: 'confirm-shared-understanding', + retry: 'next', + repair: 'none', + }, 'transition-incomplete': { severity: 'error', requiredAction: 'recover-transition', @@ -213,7 +225,11 @@ function retryCommand( ): string | null { if (retry === 'next') { return `comet native next ${state.name} --summary ""${ - code === 'contract-changed-after-approval' ? ' --confirmed' : '' + code === 'contract-changed-after-approval' || + code === 'shape-confirmation-required' || + code === 'approval-confirmation-required' + ? ' --confirmed' + : '' }`; } if (retry === 'status') return `comet native status ${state.name} --details`; @@ -245,6 +261,8 @@ export function structureNativeFindings(options: { // missing data must never be presented as a user decision. requiresUserDecision: finding.code === 'brief-blocking-question' || + finding.code === 'shape-confirmation-required' || + finding.code === 'approval-confirmation-required' || finding.code === 'contract-changed-after-approval' || finding.code === 'verification-scope-partial' || finding.code === 'repair-iteration-limit' || diff --git a/domains/comet-native/native-guards.ts b/domains/comet-native/native-guards.ts index 772f335d..c33e4403 100644 --- a/domains/comet-native/native-guards.ts +++ b/domains/comet-native/native-guards.ts @@ -17,6 +17,7 @@ import type { NativeAdvanceEvidence, NativeArtifactValidation, NativeChangeState, + NativeClarificationMode, NativeFinding, NativeProjectPaths, } from './native-types.js'; @@ -76,6 +77,7 @@ export async function inspectNativeGuard(options: { paths: NativeProjectPaths; state: NativeChangeState; evidence: NativeAdvanceEvidence; + clarificationMode: NativeClarificationMode; }): Promise { const findings: NativeFinding[] = []; const changeDir = nativeChangeDir(options.paths, options.state.name); @@ -120,12 +122,30 @@ export async function inspectNativeGuard(options: { const brief = await validateNativeBrief(changeDir, options.state.brief); const specs = await validateNativeSpecChanges(options.paths, options.state); findings.push(...brief.findings, ...specs.findings); + if (findings.length === 0 && !options.evidence.confirmed) { + findings.push({ + code: 'shape-confirmation-required', + message: + 'Native clarification requires explicit user confirmation of the shared understanding before Build', + }); + } } else if (options.state.phase === 'build') { findings.push( ...(await validateNativeBrief(changeDir, options.state.brief)).findings, ...(await validateNativeSpecChanges(options.paths, options.state)).findings, ); findings.push(...(await validateBuildArtifacts(options.paths, options.evidence))); + if ( + findings.length === 0 && + options.state.approval !== 'confirmed' && + !options.evidence.confirmed + ) { + findings.push({ + code: 'approval-confirmation-required', + message: + 'Native approval is implicit; confirm the current shared understanding before leaving Build', + }); + } } else if (options.state.phase === 'verify') { const report = options.evidence.verificationReport ?? options.state.verification_report; if (!report) { diff --git a/domains/comet-native/native-root-move.ts b/domains/comet-native/native-root-move.ts index 5366adb0..2acc36d0 100644 --- a/domains/comet-native/native-root-move.ts +++ b/domains/comet-native/native-root-move.ts @@ -845,6 +845,7 @@ async function finishForwardMove(options: { artifact_root: config.native.artifact_root, language: config.native.language, clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot, }; const committed: CometProjectConfig = { ...config, @@ -1009,6 +1010,7 @@ export async function recoverNativeRootMove(options: { artifact_root: config.native.artifact_root, language: config.native.language, clarification_mode: config.native.clarification_mode, + snapshot: config.native.snapshot, }; const restored: CometProjectConfig = { ...config, diff --git a/domains/comet-native/native-snapshot.ts b/domains/comet-native/native-snapshot.ts index ab2352f6..4140a584 100644 --- a/domains/comet-native/native-snapshot.ts +++ b/domains/comet-native/native-snapshot.ts @@ -4,6 +4,11 @@ import { promises as fs } from 'fs'; import path from 'path'; import { atomicWriteJson } from './native-atomic-file.js'; +import { + DEFAULT_NATIVE_SNAPSHOT_CONFIG, + normalizeNativeSnapshotPattern, + readProjectConfig, +} from './native-config.js'; import { sha256Text } from './native-hash.js'; import { hasComparableNativeFileObject, sameNativeFileObject } from './native-file-identity.js'; import { isInsidePath, resolveContainedNativePath } from './native-paths.js'; @@ -18,6 +23,7 @@ import type { NativeSnapshotEntry, NativeSnapshotOmission, NativeSnapshotOmissionOverflow, + NativeSnapshotPolicy, } from './native-types.js'; export const DEFAULT_NATIVE_SNAPSHOT_LIMITS = { @@ -41,8 +47,16 @@ const MANIFEST_KEYS = new Set([ 'omitted', 'omittedCount', 'omissionOverflow', + 'policy', ]); -const LIMIT_KEYS = new Set(['maxFiles', 'maxFileBytes', 'maxTotalBytes', 'maxManifestBytes']); +const LIMIT_KEYS = new Set([ + 'maxFiles', + 'maxFileBytes', + 'maxTotalBytes', + 'maxManifestBytes', + 'maxDurationMs', +]); +const POLICY_KEYS = new Set(['schema', 'include', 'exclude', 'hash']); const CAPTURE_KEYS = new Set(['provider', 'gitSelection', 'physicalSelection', 'projection']); const GIT_PROJECTION_KEYS = new Set(['provider', 'selection']); const GIT_SELECTION_KEYS = new Set([ @@ -105,6 +119,7 @@ interface SnapshotOptions { now?: Date; origin?: NativeContentSnapshotManifest['origin']; limits?: Partial; + policy?: Pick | NativeSnapshotPolicy; denylist?: readonly string[]; gitSelectionLimits?: Partial; gitSelectionHooks?: NativeGitSelectionHooks; @@ -1334,6 +1349,165 @@ function serializedManifestBytes(manifest: NativeContentSnapshotManifest): numbe return Buffer.byteLength(JSON.stringify(manifest, null, 2) + '\n'); } +function snapshotPolicyHash(include: readonly string[], exclude: readonly string[]): string { + return sha256Text( + `comet.native.snapshot-policy.v1\n${JSON.stringify({ include, exclude, hash: 'sha256' })}`, + ); +} + +type SnapshotPatternToken = + | { kind: 'literal'; value: string } + | { kind: 'question' | 'star' | 'globstar' | 'globstar-slash' }; + +type SnapshotPatternMatcher = (relative: string, hasBudget?: () => boolean) => boolean; + +interface ResolvedSnapshotPolicy { + manifest: NativeSnapshotPolicy; + includeMatchers: SnapshotPatternMatcher[]; + excludeMatchers: SnapshotPatternMatcher[]; +} + +function epsilonClosure( + tokens: readonly SnapshotPatternToken[], + positions: ReadonlySet, + checkpoint?: () => boolean, +): Set | null { + const closure = new Set(positions); + const pending = [...positions]; + while (pending.length > 0) { + if (checkpoint && !checkpoint()) return null; + const position = pending.pop()!; + const token = tokens[position]; + if ( + token && + (token.kind === 'star' || token.kind === 'globstar' || token.kind === 'globstar-slash') && + !closure.has(position + 1) + ) { + closure.add(position + 1); + pending.push(position + 1); + } + } + return closure; +} + +function cooperativePatternCheckpoint(hasBudget: () => boolean): () => boolean { + let operationsUntilCheck = 0; + return (): boolean => { + if (operationsUntilCheck > 0) { + operationsUntilCheck -= 1; + return true; + } + if (!hasBudget()) return false; + operationsUntilCheck = 63; + return true; + }; +} + +export function compileNativeSnapshotPattern(pattern: string): SnapshotPatternMatcher { + const normalized = normalizeNativeSnapshotPattern(pattern, 'Native snapshot pattern'); + const tokens: SnapshotPatternToken[] = []; + for (let index = 0; index < normalized.length; index += 1) { + const character = normalized[index]!; + if (character === '*' && normalized[index + 1] === '*') { + index += 1; + if (normalized[index + 1] === '/') { + index += 1; + tokens.push({ kind: 'globstar-slash' }); + } else { + tokens.push({ kind: 'globstar' }); + } + } else if (character === '*') { + tokens.push({ kind: 'star' }); + } else if (character === '?') { + tokens.push({ kind: 'question' }); + } else { + tokens.push({ kind: 'literal', value: character }); + } + } + + return (relative: string, hasBudget?: () => boolean): boolean => { + const checkpoint = hasBudget ? cooperativePatternCheckpoint(hasBudget) : undefined; + if (checkpoint && !checkpoint()) return false; + let positions = epsilonClosure(tokens, new Set([0]), checkpoint); + if (positions === null) return false; + for (const character of relative) { + if (checkpoint && !checkpoint()) return false; + const next = new Set(); + for (const position of positions) { + if (checkpoint && !checkpoint()) return false; + const token = tokens[position]; + if (!token) continue; + if (token.kind === 'literal' && token.value === character) { + next.add(position + 1); + } else if (token.kind === 'question' && character !== '/') { + next.add(position + 1); + } else if (token.kind === 'star' && character !== '/') { + next.add(position); + } else if (token.kind === 'globstar') { + next.add(position); + } else if (token.kind === 'globstar-slash') { + next.add(position); + if (character === '/') next.add(position + 1); + } + } + positions = epsilonClosure(tokens, next, checkpoint); + if (positions === null) return false; + if (positions.size === 0) return false; + } + return epsilonClosure(tokens, positions, checkpoint)?.has(tokens.length) ?? false; + }; +} + +function resolveSnapshotPolicy( + value: SnapshotOptions['policy'], +): ResolvedSnapshotPolicy | undefined { + if (value === undefined) return undefined; + const include = [ + ...new Set(value.include.map((item) => normalizeNativeSnapshotPattern(item, 'include'))), + ].sort((left, right) => left.localeCompare(right, 'en')); + const exclude = [ + ...new Set(value.exclude.map((item) => normalizeNativeSnapshotPattern(item, 'exclude'))), + ].sort((left, right) => left.localeCompare(right, 'en')); + if (include.length === 0) throw new Error('Native snapshot policy include must not be empty'); + const hash = snapshotPolicyHash(include, exclude); + if ('hash' in value && value.hash !== hash) { + throw new Error('Native snapshot policy hash is invalid'); + } + return { + manifest: { + schema: 'comet.native.snapshot-policy.v1', + include, + exclude, + hash, + }, + includeMatchers: include.map(compileNativeSnapshotPattern), + excludeMatchers: exclude.map(compileNativeSnapshotPattern), + }; +} + +function snapshotPolicyIncludes( + policy: ResolvedSnapshotPolicy | undefined, + relative: string, + execution: NativeSnapshotExecution, +): boolean { + if (!policy) return true; + const hasBudget = (): boolean => nativeSnapshotExecutionHasBudget(execution); + let included = false; + for (const matcher of policy.includeMatchers) { + if (!hasBudget()) return false; + if (matcher(relative, hasBudget)) { + included = true; + break; + } + } + if (!included) return false; + for (const matcher of policy.excludeMatchers) { + if (!hasBudget()) return false; + if (matcher(relative, hasBudget)) return false; + } + return true; +} + function foldSnapshotOverflowHash(previous: string, kind: string, value: unknown): string { const payload = JSON.stringify(value); return sha256Text( @@ -1781,7 +1955,29 @@ export function parseNativeContentSnapshotManifest(value: unknown): NativeConten limitValue.maxManifestBytes, 'Native snapshot maxManifestBytes', ), + ...(limitValue.maxDurationMs === undefined + ? {} + : { + maxDurationMs: positiveInteger(limitValue.maxDurationMs, 'Native snapshot maxDurationMs'), + }), }; + let policy: NativeSnapshotPolicy | undefined; + if (manifest.policy !== undefined) { + const policyValue = record(manifest.policy, 'Native snapshot policy'); + rejectUnknown(policyValue, POLICY_KEYS, 'Native snapshot policy'); + if (policyValue.schema !== 'comet.native.snapshot-policy.v1') { + throw new Error('Native snapshot policy schema is invalid'); + } + if (!Array.isArray(policyValue.include) || !Array.isArray(policyValue.exclude)) { + throw new Error('Native snapshot policy patterns must be arrays'); + } + policy = resolveSnapshotPolicy({ + include: policyValue.include as string[], + exclude: policyValue.exclude as string[], + hash: policyValue.hash as string, + schema: 'comet.native.snapshot-policy.v1', + })!.manifest; + } if (!Array.isArray(manifest.entries) || !Array.isArray(manifest.omitted)) { throw new Error('Native content snapshot entries and omissions must be arrays'); } @@ -1879,6 +2075,7 @@ export function parseNativeContentSnapshotManifest(value: unknown): NativeConten createdAt: manifest.createdAt, complete: manifest.complete, limits, + ...(policy ? { policy } : {}), entries, omitted, omittedCount, @@ -2065,14 +2262,21 @@ export async function createNativeContentSnapshot( paths: NativeProjectPaths, options: SnapshotOptions = {}, ): Promise { - const execution = createNativeSnapshotExecution(options); const limits = { maxFiles: options.limits?.maxFiles ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxFiles, maxFileBytes: options.limits?.maxFileBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxFileBytes, maxTotalBytes: options.limits?.maxTotalBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxTotalBytes, maxManifestBytes: options.limits?.maxManifestBytes ?? DEFAULT_NATIVE_SNAPSHOT_LIMITS.maxManifestBytes, + ...(options.limits?.maxDurationMs === undefined + ? {} + : { maxDurationMs: options.limits.maxDurationMs }), }; + const policy = resolveSnapshotPolicy(options.policy); + const execution = createNativeSnapshotExecution({ + ...options, + deadlineMs: options.deadlineMs ?? limits.maxDurationMs, + }); const gitSelectionLimits = resolveNativeGitSelectionLimits(options.gitSelectionLimits); const physicalSelectionLimits = resolveNativePhysicalSelectionLimits( options.physicalSelectionLimits, @@ -2502,6 +2706,8 @@ export async function createNativeContentSnapshot( for (const record of before.records) { if (record.type !== 'file' && record.type !== 'symlink') continue; if (remainingNativeSnapshotTime(execution) < 1) break; + if (!snapshotPolicyIncludes(policy, record.path, execution)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; const target = path.resolve(projectRoot, ...record.path.split('/')); let stat: import('fs').Stats; try { @@ -2560,6 +2766,9 @@ export async function createNativeContentSnapshot( await options.gitSelectionHooks?.afterInitialSelection?.(); for (const relative of selectionPaths(gitSelection)) { if (!isSnapshotProjectRef(paths, relative)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; + if (!snapshotPolicyIncludes(policy, relative, execution)) continue; + if (remainingNativeSnapshotTime(execution) < 1) break; const target = path.resolve(projectRoot, ...relative.split('/')); if ( target === configFile || @@ -2698,6 +2907,7 @@ export async function createNativeContentSnapshot( createdAt: (options.now ?? new Date()).toISOString(), complete: omittedCount === 0, limits, + ...(policy ? { policy: policy.manifest } : {}), entries, omitted, omittedCount, @@ -2738,6 +2948,26 @@ export async function createNativeContentSnapshot( return manifest; } +export async function createNativeCurrentContentSnapshot( + paths: NativeProjectPaths, + baseline: NativeContentSnapshotManifest, + options: Pick = {}, +): Promise { + const config = await readProjectConfig(paths.projectRoot); + const settings = config?.native.snapshot ?? DEFAULT_NATIVE_SNAPSHOT_CONFIG; + return createNativeContentSnapshot(paths, { + ...options, + policy: baseline.policy, + limits: { + maxFiles: settings.max_files, + maxFileBytes: settings.max_total_bytes, + maxTotalBytes: settings.max_total_bytes, + maxDurationMs: settings.max_duration_ms, + }, + deadlineMs: settings.max_duration_ms, + }); +} + export async function writeNativeBaselineManifest( paths: NativeProjectPaths, name: string, diff --git a/domains/comet-native/native-transitions.ts b/domains/comet-native/native-transitions.ts index 88352f55..b4111b70 100644 --- a/domains/comet-native/native-transitions.ts +++ b/domains/comet-native/native-transitions.ts @@ -49,6 +49,7 @@ import type { NativeAdvanceEvidence, NativeAdvanceResult, NativeChangeState, + NativeClarificationMode, NativePhase, NativeProjectPaths, NativeRepairDecisionProjection, @@ -59,6 +60,7 @@ interface AdvanceNativeChangeOptions { paths: NativeProjectPaths; name: string; evidence: NativeAdvanceEvidence; + clarificationMode: NativeClarificationMode; now?: Date; runId?: () => string; transitionId?: () => string; @@ -228,6 +230,7 @@ async function retreatStaleNativeEvidence(options: { continuation: nativeContinuation({ state: options.state, archiveReady: previousPhase === 'archive', + clarificationMode: options.transition.clarificationMode, }), }; } @@ -282,7 +285,10 @@ async function retreatStaleNativeEvidence(options: { next: 'auto', nextCommand: null, findings: [], - continuation: nativeContinuation({ state: persisted }), + continuation: nativeContinuation({ + state: persisted, + clarificationMode: options.transition.clarificationMode, + }), }; } @@ -344,6 +350,7 @@ async function advanceNativeChangeLocked( state, findings: repairFindings, archiveReady: state.phase === 'archive' && state.verification_result === 'pass', + clarificationMode: options.clarificationMode, }), ...(repair ? { repair } : {}), }; @@ -386,6 +393,7 @@ async function advanceNativeChangeLocked( paths: options.paths, state: candidate, evidence: options.evidence, + clarificationMode: options.clarificationMode, }); if (!guard.valid) { const findings = structureNativeFindings({ @@ -399,7 +407,11 @@ async function advanceNativeChangeLocked( next: 'manual', nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode, + }), }; } @@ -456,7 +468,11 @@ async function advanceNativeChangeLocked( next: 'manual', nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode, + }), }; } const preparedScope = buildEvidence @@ -490,7 +506,11 @@ async function advanceNativeChangeLocked( next: 'manual', nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode, + }), preparedScope, }; } @@ -543,7 +563,11 @@ async function advanceNativeChangeLocked( next: 'manual', nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode, + }), preparedScope: preparedScope ? { ...preparedScope, partialAllowanceRef: null } : preparedScope, @@ -578,7 +602,11 @@ async function advanceNativeChangeLocked( next: 'manual', nextCommand: null, findings, - continuation: nativeContinuation({ state, findings }), + continuation: nativeContinuation({ + state, + findings, + clarificationMode: options.clarificationMode, + }), }; } @@ -647,11 +675,7 @@ async function advanceNativeChangeLocked( ...candidate, revision: state.revision + 1, phase: advanced.currentStep as NativePhase, - approval: options.evidence.confirmed - ? ('confirmed' as const) - : state.phase === 'shape' && state.approval === null - ? ('implicit' as const) - : state.approval, + approval: options.evidence.confirmed ? ('confirmed' as const) : state.approval, approved_contract_hash: state.phase === 'shape' ? shapeContract!.contract.contractHash @@ -756,6 +780,7 @@ async function advanceNativeChangeLocked( state: persisted, findings: repairFindings, archiveReady: persisted.phase === 'archive' && persisted.verification_result === 'pass', + clarificationMode: options.clarificationMode, }), ...(preparedScope ? { preparedScope } : {}), ...(repairDecision ? { repair: repairDecision } : {}), diff --git a/domains/comet-native/native-types.ts b/domains/comet-native/native-types.ts index da0d5eda..b426adc3 100644 --- a/domains/comet-native/native-types.ts +++ b/domains/comet-native/native-types.ts @@ -32,6 +32,21 @@ export interface NativePendingRootMove { cleanup?: NativeRootMoveCleanup; } +export interface NativeSnapshotConfig { + include: string[]; + exclude: string[]; + max_files: number; + max_total_bytes: number; + max_duration_ms: number; +} + +export interface NativeSnapshotPolicy { + schema: 'comet.native.snapshot-policy.v1'; + include: string[]; + exclude: string[]; + hash: string; +} + export interface CometProjectConfig { schema: 'comet.project.v1'; default_workflow: 'native' | 'classic'; @@ -41,6 +56,7 @@ export interface CometProjectConfig { artifact_root: string; language: 'en' | 'zh-CN'; clarification_mode: NativeClarificationMode; + snapshot: NativeSnapshotConfig; pending_root_move?: NativePendingRootMove; }; classic?: { @@ -229,7 +245,9 @@ export interface NativeContentSnapshotManifest { maxFileBytes: number; maxTotalBytes: number; maxManifestBytes: number; + maxDurationMs?: number; }; + policy?: NativeSnapshotPolicy; entries: NativeSnapshotEntry[]; omitted: NativeSnapshotOmission[]; omittedCount: number; diff --git a/domains/comet-native/native-verification-runtime.ts b/domains/comet-native/native-verification-runtime.ts index 02c7bcc6..4f7728bf 100644 --- a/domains/comet-native/native-verification-runtime.ts +++ b/domains/comet-native/native-verification-runtime.ts @@ -22,7 +22,7 @@ import { writeNativeVerificationReportSnapshot, writeNativeVerificationEvidence, } from './native-evidence-storage.js'; -import { createNativeContentSnapshot } from './native-snapshot.js'; +import { createNativeCurrentContentSnapshot } from './native-snapshot.js'; import type { NativeChangeState, NativeContentSnapshotManifest, @@ -72,6 +72,7 @@ function projectionManifest(projection: NativeSnapshotProjection): NativeContent createdAt: '1970-01-01T00:00:00.000Z', complete: projection.complete, limits: projection.limits, + ...(projection.policy ? { policy: projection.policy } : {}), entries: projection.entries, omitted: projection.omitted, omittedCount: projection.omittedCount, @@ -92,12 +93,13 @@ async function currentProjectionHash(options: { bundle: NativeImplementationScopeBundle; now?: Date; }): Promise { - const current = await createNativeContentSnapshot(options.paths, { + const baseline = projectionManifest(options.bundle.baseline); + const current = await createNativeCurrentContentSnapshot(options.paths, baseline, { origin: 'explicit', now: options.now, }); return buildNativeImplementationScopeBundle({ - baseline: projectionManifest(options.bundle.baseline), + baseline, current, contractHash: options.bundle.scope.contractHash, declaredArtifacts: options.bundle.scope.declaredArtifacts, diff --git a/domains/comet-native/native-verification-scope.ts b/domains/comet-native/native-verification-scope.ts index 7a1a722e..a7aa81d5 100644 --- a/domains/comet-native/native-verification-scope.ts +++ b/domains/comet-native/native-verification-scope.ts @@ -7,6 +7,7 @@ import type { NativeContentSnapshotManifest, NativeSnapshotEntry, NativeSnapshotOmission, + NativeSnapshotPolicy, } from './native-types.js'; export const NATIVE_IMPLEMENTATION_SCOPE_SCHEMA = 'comet.native.implementation-scope.v2' as const; @@ -98,6 +99,7 @@ export interface NativeSnapshotProjection { capture?: NativeContentSnapshotManifest['capture']; complete: boolean; limits: NativeContentSnapshotManifest['limits']; + policy?: NativeSnapshotPolicy; entries: NativeSnapshotEntry[]; omitted: NativeSnapshotOmission[]; omittedCount: number; @@ -362,7 +364,11 @@ function snapshotProjection(manifest: NativeContentSnapshotManifest): NativeSnap maxFileBytes: parsed.limits.maxFileBytes, maxTotalBytes: parsed.limits.maxTotalBytes, maxManifestBytes: parsed.limits.maxManifestBytes, + ...(parsed.limits.maxDurationMs === undefined + ? {} + : { maxDurationMs: parsed.limits.maxDurationMs }), }, + ...(parsed.policy ? { policy: parsed.policy } : {}), entries, omitted, omittedCount: parsed.omittedCount, @@ -986,7 +992,7 @@ export function parseNativeSnapshotProjection( exactScopeKeys( root, ['schema', 'origin', 'complete', 'limits', 'entries', 'omitted', 'omittedCount'], - ['capture', 'omissionOverflow'], + ['capture', 'omissionOverflow', 'policy'], 'Native snapshot projection', ); if (root.schema !== NATIVE_SNAPSHOT_PROJECTION_SCHEMA) { @@ -999,6 +1005,7 @@ export function parseNativeSnapshotProjection( createdAt: '1970-01-01T00:00:00.000Z', complete: root.complete, limits: root.limits, + ...(root.policy === undefined ? {} : { policy: root.policy }), entries: root.entries, omitted: root.omitted, omittedCount: root.omittedCount, diff --git a/domains/dashboard/native-collector.ts b/domains/dashboard/native-collector.ts index 9b5535f3..5d27ea08 100644 --- a/domains/dashboard/native-collector.ts +++ b/domains/dashboard/native-collector.ts @@ -281,7 +281,10 @@ export async function collectNativeDashboardProjection( let statusCursor: string | null = null; let totalStatusCount: number | undefined; do { - const page = await listNativeStatusPage(paths, { cursor: statusCursor }); + const page = await listNativeStatusPage(paths, { + cursor: statusCursor, + clarificationMode: config.native.clarification_mode, + }); totalStatusCount ??= page.total; if (page.total !== totalStatusCount) { throw new Error('Native status total changed during Dashboard pagination'); diff --git a/domains/skill/platform-install.ts b/domains/skill/platform-install.ts index ded1f123..f48b5a7e 100644 --- a/domains/skill/platform-install.ts +++ b/domains/skill/platform-install.ts @@ -1721,6 +1721,15 @@ async function mergeProjectConfig( } nativeBlock[f.key] = coerceConfigScalar(value); } + if (nativeBlock.snapshot === undefined) { + nativeBlock.snapshot = { + include: ['**/*'], + exclude: [], + max_files: 10_000, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 60_000, + }; + } root.native = nativeBlock; } diff --git a/domains/skill/project-instructions.ts b/domains/skill/project-instructions.ts index 248d1a5f..87b67a6a 100644 --- a/domains/skill/project-instructions.ts +++ b/domains/skill/project-instructions.ts @@ -29,10 +29,11 @@ export function renderCometAmbientResumeContent(languageId: SkillLanguageId): st '', '在这个仓库中,开始处理需要改动或调查的任务前,如果可能存在活跃 Comet workflow,把当前用户请求传入只读探针:`comet resume-probe . --stdin --json`。', '', + '- 如果用户通过宿主明确调用任意 Comet Skill(例如 `@comet`、`/comet`、`@comet-native` 或 `/comet-hotfix`),显式调用优先于本恢复协议;不要运行 resume probe,直接进入被调用的 Skill。', '- 只信任返回的 `workflow`、`skill` 和 `entrySource`;它们只由项目配置或无配置兼容回退决定。不得扫描或切换另一套 workflow。', '- 如果 probe 返回 `auto_resume`,简短说明选中的 active change,并进入 `nextCommand` 指向的永久入口。不要把状态命令当作恢复入口直接推进。', '- 如果 probe 返回 `ask_user`,只问一个简短问题并等待用户回复。', - '- 如果 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。', + '- 如果当前请求未明确调用 Comet Skill,且 probe 返回 `out_of_scope` 或 `none`,不要进入 Comet workflow。', '- 如果配置或状态无效且没有 `nextCommand`,停止并报告原因;不要猜测另一个 workflow。', '- 不能只因为存在 active change 就把无关任务挂到该 change。Native 的未提交改动由 Native 入口检查,不由探针自动归因。', '', @@ -47,10 +48,11 @@ export function renderCometAmbientResumeContent(languageId: SkillLanguageId): st '', 'In this repository, before starting work that may need code changes or investigation, pass the current user request to the read-only probe when a Comet workflow may already be active: `comet resume-probe . --stdin --json`.', '', + '- If the user explicitly invokes any Comet Skill through the host (for example, `@comet`, `/comet`, `@comet-native`, or `/comet-hotfix`), that explicit invocation takes precedence over this resume protocol; do not run the resume probe, and enter the invoked Skill directly.', '- Trust only the returned `workflow`, `skill`, and `entrySource`; project configuration or the no-config compatibility fallback alone selects them. Do not scan or switch to the other workflow.', '- If the probe returns `auto_resume`, briefly state the selected active change and enter the permanent entry in `nextCommand`. Do not treat a state command as the resume entry or advance it blindly.', '- If the probe returns `ask_user`, ask one short question and wait.', - '- If the probe returns `out_of_scope` or `none`, do not enter the Comet workflow.', + '- If the current request did not explicitly invoke a Comet Skill and the probe returns `out_of_scope` or `none`, do not enter the Comet workflow.', '- If configuration or state is invalid and `nextCommand` is absent, stop and report the reason; do not guess another workflow.', '- Never attach unrelated work merely because an active change exists. The Native entry inspects uncommitted work; the probe does not attribute it automatically.', '', diff --git a/domains/workflow-contract/project-config.ts b/domains/workflow-contract/project-config.ts index b0b45950..499a4dd8 100644 --- a/domains/workflow-contract/project-config.ts +++ b/domains/workflow-contract/project-config.ts @@ -13,6 +13,12 @@ type ProjectConfigCommentKey = | 'native.artifact_root' | 'native.language' | 'native.clarification_mode' + | 'native.snapshot' + | 'native.snapshot.include' + | 'native.snapshot.exclude' + | 'native.snapshot.max_files' + | 'native.snapshot.max_total_bytes' + | 'native.snapshot.max_duration_ms' | 'classic' | 'classic.language' | 'classic.context_compression' @@ -33,6 +39,18 @@ const COMMENTS: Record str: + result = subprocess.run( + [sys.executable, "wordcount.py", *args], + input=input_text, + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def test_count_words(): + assert "Words: 3" in run_cli(input_text="hello world foo") + + +def test_count_lines(): + output = run_cli("--lines", input_text="hello\nworld\nfoo") + assert "Words: 3" in output + assert "Lines: 3" in output diff --git a/eval/local/tasks/comet-native-clarification-depth/environment/wordcount.py b/eval/local/tasks/comet-native-clarification-depth/environment/wordcount.py new file mode 100644 index 00000000..b6ab91f5 --- /dev/null +++ b/eval/local/tasks/comet-native-clarification-depth/environment/wordcount.py @@ -0,0 +1,36 @@ +"""Simple word count CLI tool.""" + +import argparse +import sys + + +def count_words(text: str) -> int: + """Count words in text.""" + return len(text.split()) + + +def count_lines(text: str) -> int: + """Count lines in text.""" + return len(text.splitlines()) + + +def main(): + parser = argparse.ArgumentParser(description="Count words and lines in text") + parser.add_argument("file", nargs="?", help="Input file (reads stdin if omitted)") + parser.add_argument("--lines", action="store_true", help="Also count lines") + parser.add_argument("--words", action="store_true", default=True, help="Count words (default)") + args = parser.parse_args() + + if args.file: + with open(args.file) as source: + text = source.read() + else: + text = sys.stdin.read() + + print(f"Words: {count_words(text)}") + if args.lines: + print(f"Lines: {count_lines(text)}") + + +if __name__ == "__main__": + main() diff --git a/eval/local/tasks/comet-native-clarification-depth/instruction.md b/eval/local/tasks/comet-native-clarification-depth/instruction.md new file mode 100644 index 00000000..b48bcb98 --- /dev/null +++ b/eval/local/tasks/comet-native-clarification-depth/instruction.md @@ -0,0 +1,17 @@ +You are working on a Python project named `wordcount-cli`. + +Begin by invoking the `/comet-native` Skill. Initialize Native under `docs/comet/`, create one change named `add-sentence-counting`, and add an opt-in `--sentences` CLI flag that prints `Sentences: N` after any requested `Lines: N` output while preserving the existing word and line counters. + +The fixed interface contract is: + +- only the ASCII terminal characters `.`, `!`, and `?` can create sentence boundaries; +- non-empty input with no terminal character counts as one sentence; +- when `--sentences` is present, it prints independently of `--lines`; the flag behavior and output order described above are not open product decisions. + +The input domain is ordinary ASCII prose. Numbers, URLs, file paths, code, markup, quoted data, and Unicode-specific segmentation are out of scope. + +The contract deliberately leaves three areas unresolved: empty or whitespace-only input, consecutive terminal characters, and terminal-looking characters embedded inside ordinary prose tokens. Resolve them in that listed order through the configured clarification protocol. An answer about whether an embedded character creates a boundary may introduce later decisions about how the recognized token collection is maintained and its exact contents; handle each dependent decision immediately after its parent. + +The requested interface is intentionally underspecified. Follow the already configured Native Sequential clarification protocol and resolve every undefined user-visible behavior before implementation. Investigate repository-owned facts yourself, and do not change the configured clarification mode. + +Complete Shape, Build, Verify, and Archive using only the Comet Native Skill and its bundled runtime. diff --git a/eval/local/tasks/comet-native-clarification-depth/task.toml b/eval/local/tasks/comet-native-clarification-depth/task.toml new file mode 100644 index 00000000..1657af12 --- /dev/null +++ b/eval/local/tasks/comet-native-clarification-depth/task.toml @@ -0,0 +1,47 @@ +[metadata] +name = "comet-native-clarification-depth" +description = "Measure whether Native Sequential discovers hidden user-owned decisions before implementation" +difficulty = "hard" +category = "comet-native" +tags = ["comet-native", "clarification", "sequential", "multi-turn", "decision-depth"] +default_treatments = ["COMET_NATIVE_SEQUENTIAL"] + +[template] +required = [] + +[environment] +description = "Python word-count workspace with only the self-contained Comet Native Skill available." +dockerfile = "environment/Dockerfile" +timeout_sec = 2400 + +[validation] +test_scripts = "test_native_clarification_depth.py" +target_artifacts = [".comet/config.yaml", "docs/comet/archive", "docs/comet/specs"] +timeout = 120 + +[evaluation] +profile = "generic" +required_skills = ["comet-native"] +expected_artifacts = [".comet/config.yaml", "docs/comet/archive", "docs/comet/specs"] +require_skill_invocation = true +rubric_criteria = [ + "Investigates repository-owned facts without asking the user", + "Discovers all hidden user-owned behavior decisions one at a time", + "Provides a recommendation and practical impact for each decision", + "Obtains explicit shared-understanding confirmation before Build", + "Completes Build, Verify, and Archive using only the Comet Native Skill and bundled runtime", +] + +[interaction] +mode = "auto_user" +max_turns = 8 +decision_replies = [ + "It prints Sentences: 0.", + "They count as one sentence boundary.", + "Recognized abbreviations such as e.g. and Dr. always suppress their periods, including at end-of-input; every other occurrence of ., !, or ? remains a sentence boundary, and the non-empty fallback still makes an abbreviation-only input one sentence.", + "Use a small explicit list. Ask me for the exact entries separately.", + "Use exactly Dr., Mr., Mrs., Ms., Prof., Sr., Jr., e.g., i.e., etc., vs., Inc., Ltd., Corp., St., and Ave., matched case-sensitively exactly as written.", + "confirmed", +] +decision_patterns = ["Question:", "Recommendation:", "Impact:", "shared understanding", "Please confirm"] +continue_prompt = "Please read the persisted Native state and continue the same confirmed change through verification and archive." diff --git a/eval/local/tasks/comet-native-clarification-depth/validation/test_native_clarification_depth.py b/eval/local/tasks/comet-native-clarification-depth/validation/test_native_clarification_depth.py new file mode 100644 index 00000000..4afb8f7c --- /dev/null +++ b/eval/local/tasks/comet-native-clarification-depth/validation/test_native_clarification_depth.py @@ -0,0 +1,618 @@ +"""Validate deep Sequential clarification and terminal Native artifacts.""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import yaml + + +WORKSPACE = Path("/workspace") +RESULTS_FILE = os.environ.get("BENCH_TEST_RESULTS", "_test_results.json") +QUESTION_SIGNAL = re.compile( + r"^(?:\[blocking\]\s*)?\**\bQUESTION\b(?:\s*\([^)\n]*\))?\**\s*(?::\**|$)", + re.IGNORECASE | re.MULTILINE, +) +CONFIRM_MARKER = re.compile(r"\[blocking\]\s*CONFIRM", re.IGNORECASE) +REQUIRED_ABBREVIATIONS = ( + "dr.", + "mr.", + "mrs.", + "ms.", + "prof.", + "sr.", + "jr.", + "e.g.", + "i.e.", + "etc.", + "vs.", + "inc.", + "ltd.", + "corp.", + "st.", + "ave.", +) + + +def passed(name: str): + return {"check": name, "status": "passed"} + + +def failed(name: str, reason: str): + return {"check": name, "status": "failed", "reason": reason} + + +def read_context() -> dict: + path = WORKSPACE / "_test_context.json" + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def decision_topics(text: str) -> set[str]: + primary_question = re.split(r"\n\s*\n", text.strip(), maxsplit=1)[0] + normalized = " ".join(primary_question.lower().split()) + topics = set() + embedded_token = bool( + re.search( + r"\bterminal\s+characters?\b.{0,80}\b(?:embedded|inside)\b|" + r"\b(?:embedded|inside)\b.{0,80}\b(?:word|prose\s+token|token)\b", + normalized, + ) + ) + if re.search(r"\babbrevi|e\.g\.|\bdr\.", normalized) or embedded_token: + content_signal = bool( + re.search( + r"\b(?:exact|entries|items|members|contents?)\b|" + r"\b(?:which|what)\s+abbreviations?\b|" + r"\bset\s+of\s+(?:common\s+)?abbreviations?\b", + normalized, + ) + ) + strategy_signal = bool( + re.search( + r"\b(?:maintain|maintained|maintenance|define|defined|recognize|" + r"recognized|identify|rule|approach|hardcoded|configurable)\b", + normalized, + ) + ) + if content_signal: + topics.add("abbreviation-list-content") + if strategy_signal: + topics.add("abbreviation-list-strategy") + if not content_signal and not strategy_signal: + topics.add("abbreviation-policy") + if re.search( + r"\bempty(?:\s+or\s+whitespace(?:-only)?)?\s+input\b|" + r"\bempty\s+or\s+whitespace-only\b|" + r"\bno\s+input\b|\bzero[- ]length\b", + normalized, + ): + topics.add("empty") + if "?!" in normalized or re.search( + r"\b(?:consecutive|contiguous|run\s+of)\b.{0,80}" + r"\b(?:terminators?|terminal\s+characters?|punctuation|endings?)\b", + normalized, + ): + topics.add("terminator-run") + return topics + + +def missing_decisions(text: str) -> list[str]: + """Return fixed user decisions that are not preserved semantically.""" + normalized = " ".join(text.lower().split()) + missing = [] + abbreviation = "abbrevi" in normalized and bool( + re.search( + r"(?:do|does|should)\s+not\s+(?:end|count)|" + r"(?:ignored|filtered|excluded|suppressed?)\s+as\s+(?:a\s+)?" + r"sentence\s+boundar|" + r"suppress(?:es|ed|ing)?\s+(?:their\s+)?periods?.{0,60}" + r"(?:sentence\s+)?boundar|" + r"abbrevi.{0,240}not.{0,80}(?:sentence\s+(?:ending|boundar)|false\s+boundar)", + normalized, + ) + ) + if not abbreviation: + missing.append("abbreviation behavior") + + abbreviation_list = "abbrevi" in normalized and bool( + re.search( + r"(?:small|explicit|known).{0,50}(?:list|set|allowlist|collection)" + r".{0,100}(?:e\.g\.|dr\.)|" + r"(?:list|set|allowlist|collection).{0,80}(?:e\.g\.|dr\.)", + normalized, + ) + ) + if not abbreviation_list: + missing.append("abbreviation collection strategy") + if not all(item in normalized for item in REQUIRED_ABBREVIATIONS): + missing.append("exact abbreviation collection") + + empty = "empty" in normalized and bool( + re.search( + r"empty.{0,180}(?:returns?|prints?|result(?:\s+is)?|sentences:)" + r"[^0-9]{0,24}0\b", + normalized, + ) + ) + if not empty: + missing.append("empty-input count") + + terminators = bool( + re.search( + r"(?:consecutive|contiguous|run\s+of).{0,100}" + r"(?:terminator|terminal\s+character|punctuation)", + normalized, + ) + and re.search(r"(?:one|single|exactly\s+one).{0,40}(?:sentence\s+)?boundar", normalized) + ) + if not terminators: + missing.append("terminator-run behavior") + return missing + + +def contradictory_decisions(text: str) -> list[str]: + normalized = " ".join(text.lower().split()) + abbreviation_enabled = bool( + re.search( + r"(?:do|does|should)\s+not\s+(?:end|count)|" + r"suppress(?:es|ed|ing)?\s+(?:their\s+)?periods?.{0,60}" + r"(?:sentence\s+)?boundar|" + r"abbrevi.{0,240}not.{0,80}(?:sentence\s+(?:ending|boundar)|false\s+boundar)", + normalized, + ) + ) + abbreviation_disabled = bool( + re.search( + r"abbrevi.{0,160}(?:no\s+special[- ]cas|ordinary\s+prose\s+tokens?.{0,80}" + r"no\s+special[- ]cas)", + normalized, + ) + ) + return ["abbreviation behavior"] if abbreviation_enabled and abbreviation_disabled else [] + + +def missing_shared_understanding(text: str) -> list[str]: + missing = missing_decisions(text) + normalized = " ".join(re.sub(r"[*_`#]", "", text.lower()).split()) + summary_fields = { + "goal/outcome": r"\b(?:goal|outcome)\b", + "scope": r"\b(?:scope|in scope)\b", + "explicit non-goals": r"\b(?:explicit\s+non-goals?|non-goals?|out of scope)\b", + "acceptance criteria": r"\b(?:acceptance(?:\s+criteria)?|accepted when|done when)\b", + } + for label, pattern in summary_fields.items(): + if not re.search(pattern, normalized): + missing.append(label) + return missing + + +def is_confirmation_request(text: str) -> bool: + normalized = " ".join(text.lower().split()) + if CONFIRM_MARKER.search(text): + return True + summary = bool( + re.search( + r"\b(?:(?:shared|my|our|current|final)[- ]+understanding|" + r"understanding summary|confirmation summary)\b", + normalized, + ) + ) + request = bool( + re.search( + r"\b(?:please\s+)?confirm\b|" + r"\breply\b.{0,40}\bconfirmed\b|" + r"\brespond\b.{0,40}\bconfirmed\b", + normalized, + ) + ) + return summary and request + + +def command_mutates_target(command: str, target: str) -> bool: + if not re.search(target, command, re.IGNORECASE): + return False + return bool( + re.search( + rf"(?i)(?:>>?|tee(?:\s+-\w+)*|set-content|add-content|out-file)" + rf"\s*[^\r\n]*{target}|" + rf"(?:\b(?:apply_patch|cp|mv|touch|new-item|write_text|write_bytes|" + rf"writealltext|writeallbytes)\b|" + rf"\*\*\*\s+(?:Add|Update|Delete)\s+File:|" + rf"\bsed\b[^\r\n]*\s-i\b|\bperl\b[^\r\n]*\s-(?:p?i|i)\b" + rf")[^\r\n]*{target}|" + rf"{target}[^\r\n]{{0,200}}\.(?:write_text|write_bytes)\b|" + rf"\bopen\s*\([^\r\n]*{target}[^\r\n]*,\s*['\"][wa]", + command, + ) + ) + + +def successful_tool_call(tool_call: dict) -> bool: + return tool_call.get("success") is True + + +def excluded_evidence_path(path: str) -> bool: + normalized = path.replace("\\", "/").lower() + if normalized.startswith("/workspace/"): + normalized = normalized[len("/workspace/") :] + elif normalized.startswith("/"): + return True + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized.startswith((".eval-", "_eval_")) or normalized in { + "_test_context.json", + "_test_results.json", + } + + +def implementation_write(tool_call: dict) -> bool: + if not successful_tool_call(tool_call): + return False + name = str(tool_call.get("name") or "").lower() + path = str(tool_call.get("path") or "").replace("\\", "/").lower() + target_path = path.endswith(".py") and not excluded_evidence_path(path) + if target_path and name in {"write", "edit", "multiedit", "notebookedit"}: + return True + + command = str(tool_call.get("command") or "") + if name not in {"bash", "shell", "powershell"} or not command: + return False + python_target = r"(?:[a-z]:)?[./\\\w-]*\.py\b" + if not command_mutates_target(command, python_target): + return False + paths = re.findall(python_target, command, re.IGNORECASE) + return any(not excluded_evidence_path(path) for path in paths) + + +def investigated_existing_implementation(tool_call: dict) -> bool: + if not successful_tool_call(tool_call): + return False + name = str(tool_call.get("name") or "").lower() + path = str(tool_call.get("path") or "").replace("\\", "/").lower() + command = str(tool_call.get("command") or "") + if name == "read" and re.search(r"(?:^|/)wordcount\.py$", path): + return True + return name in {"bash", "shell", "powershell"} and bool( + re.search(r"(?i)\bwordcount\.py\b", command) + ) + + +def workspace_relative_path(path: str) -> str | None: + normalized = path.replace("\\", "/").lower() + if normalized.startswith("/workspace/"): + return normalized[len("/workspace/") :] + if normalized.startswith("/"): + return None + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + +def persisted_native_decision_kinds(tool_call: dict) -> set[str]: + if not successful_tool_call(tool_call): + return set() + name = str(tool_call.get("name") or "").lower() + relative_path = workspace_relative_path(str(tool_call.get("path") or "")) + native_root = "docs/comet/changes/add-sentence-counting/" + kinds = set() + if name in {"write", "edit", "multiedit"} and relative_path: + if relative_path == native_root + "brief.md": + kinds.add("brief") + if re.fullmatch( + re.escape(native_root) + r"specs/[^/]+/spec\.md", + relative_path, + ): + kinds.add("spec") + + command = str(tool_call.get("command") or "") + if name not in {"bash", "shell", "powershell"} or not command: + return kinds + root_pattern = r"(?:/workspace/|\./)?docs[/\\]comet[/\\]changes[/\\]" + root_pattern += r"add-sentence-counting[/\\]" + brief_pattern = root_pattern + r"brief\.md\b" + spec_pattern = root_pattern + r"specs[/\\][^/\\\s]+[/\\]spec\.md\b" + if command_mutates_target(command, brief_pattern): + kinds.add("brief") + if command_mutates_target(command, spec_pattern): + kinds.add("spec") + return kinds + + +def check_clarification_protocol(): + context = read_context() + if context.get("treatment_name") != "COMET_NATIVE_SEQUENTIAL": + return failed( + "clarification_depth_protocol", + f"Unexpected treatment: {context.get('treatment_name')!r}", + ) + + config_file = WORKSPACE / ".comet" / "config.yaml" + if not config_file.is_file(): + return failed("clarification_depth_protocol", "Native project config is missing") + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {} + mode = (config.get("native") or {}).get("clarification_mode") + if mode != "sequential": + return failed( + "clarification_depth_protocol", + f"Expected native.clarification_mode sequential, got {mode!r}", + ) + + interaction = context.get("interaction") or {} + if interaction.get("mode") != "auto_user" or interaction.get("deterministic_replies") != 6: + return failed("clarification_depth_protocol", "Interactive simulator metadata is invalid") + if interaction.get("decision_points") != 6: + return failed( + "clarification_depth_protocol", + f"Expected six decision rounds, got {interaction.get('decision_points')!r}", + ) + + turns = interaction.get("subject_turns") + if not isinstance(turns, list) or not turns: + return failed("clarification_depth_protocol", "Subject-turn evidence is missing") + if interaction.get("actual_turns") != len(turns): + return failed( + "clarification_depth_protocol", + "Subject-turn evidence does not match the loop turn count", + ) + if any( + not isinstance(turn.get("tool_calls"), list) + or any( + not isinstance(tool, dict) or not isinstance(tool.get("success"), bool) + for tool in turn.get("tool_calls") or [] + ) + for turn in turns + ): + return failed("clarification_depth_protocol", "Tool-call evidence is incomplete") + + decision_turns = [ + (index, turn) + for index, turn in enumerate(turns) + if QUESTION_SIGNAL.search(str(turn.get("result") or "")) + or is_confirmation_request(str(turn.get("result") or "")) + ] + if len(decision_turns) != 6: + return failed( + "clarification_depth_protocol", + f"Expected six decision turns with a final confirmation, got {len(decision_turns)}", + ) + + topic_order = [] + for round_number, (_, turn) in enumerate(decision_turns[:5], start=1): + result = str(turn.get("result") or "") + if len(QUESTION_SIGNAL.findall(result)) != 1 or is_confirmation_request(result): + return failed( + "clarification_depth_protocol", + f"Decision round {round_number} must contain exactly one product question", + ) + question_text = QUESTION_SIGNAL.split(result, maxsplit=1)[-1] + question_prompt = re.split( + r"\**\brecommend(?:ation|ed)?\b(?:\s+option)?\**\s*:", + question_text, + maxsplit=1, + flags=re.IGNORECASE, + )[0] + topics = decision_topics(question_prompt) + if len(topics) != 1: + return failed( + "clarification_depth_protocol", + f"Decision round {round_number} must ask exactly one hidden behavior decision", + ) + if "recommend" not in question_text.lower() or "impact" not in question_text.lower(): + return failed( + "clarification_depth_protocol", + f"Decision round {round_number} lacks recommendation or impact", + ) + topic_order.append(next(iter(topics))) + if topic_order != [ + "empty", + "terminator-run", + "abbreviation-policy", + "abbreviation-list-strategy", + "abbreviation-list-content", + ]: + return failed( + "clarification_depth_protocol", + "Sequential questions did not resolve the hidden and dependent decisions in order", + ) + + confirmation_index, confirmation_turn = decision_turns[5] + confirmation = str(confirmation_turn.get("result") or "") + if QUESTION_SIGNAL.search(confirmation) or not is_confirmation_request(confirmation): + return failed( + "clarification_depth_protocol", + "The final decision round must be one shared-understanding confirmation", + ) + missing_confirmation = missing_shared_understanding(confirmation) + if missing_confirmation: + return failed( + "clarification_depth_protocol", + "The confirmation summary is incomplete: " + ", ".join(missing_confirmation), + ) + first_question_index = decision_turns[0][0] + investigated = any( + investigated_existing_implementation(tool_call) + for turn in turns[: first_question_index + 1] + for tool_call in turn.get("tool_calls") or [] + ) + if not investigated: + return failed( + "clarification_depth_protocol", + "The existing implementation was not investigated before the first decision", + ) + for round_number, (_, turn) in enumerate(decision_turns[1:], start=2): + persistence_kinds = set() + for tool_call in turn.get("tool_calls") or []: + persistence_kinds.update(persisted_native_decision_kinds(tool_call)) + if persistence_kinds != {"brief", "spec"}: + missing_persistence = sorted({"brief", "spec"} - persistence_kinds) + return failed( + "clarification_depth_protocol", + f"Decision round {round_number} lacks prior-answer persistence evidence for " + + ", ".join(missing_persistence), + ) + for turn in turns[: confirmation_index + 1]: + for tool_call in turn.get("tool_calls") or []: + if implementation_write(tool_call): + return failed( + "clarification_depth_protocol", + "Implementation files were modified before explicit confirmation", + ) + implemented_after_confirmation = any( + implementation_write(tool_call) + for turn in turns[confirmation_index + 1 :] + for tool_call in turn.get("tool_calls") or [] + ) + if not implemented_after_confirmation: + return failed( + "clarification_depth_protocol", + "No implementation write evidence exists after explicit confirmation", + ) + return passed("clarification_depth_protocol") + + +def check_behavior(): + cases = [ + ("", "Sentences: 0"), + ("Use e.g. examples. Ask Dr. Smith!", "Sentences: 2"), + ("Really?! Yes.", "Sentences: 2"), + ] + try: + subprocess.run( + [sys.executable, "-m", "pytest", "-q"], + cwd=WORKSPACE, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + for source, expected in cases: + result = subprocess.run( + [sys.executable, "wordcount.py", "--sentences"], + cwd=WORKSPACE, + input=source, + capture_output=True, + text=True, + timeout=10, + check=True, + ) + if expected not in result.stdout: + return failed( + "clarification_depth_behavior", + f"Expected {expected!r}, got {result.stdout!r}", + ) + except Exception as error: + return failed("clarification_depth_behavior", str(error)) + return passed("clarification_depth_behavior") + + +def archive_directories(): + root = WORKSPACE / "docs" / "comet" / "archive" + return sorted(path for path in root.glob("*-*") if path.is_dir()) + + +def check_confirmed_archive(): + active_root = WORKSPACE / "docs" / "comet" / "changes" + active = sorted(path for path in active_root.glob("*") if path.is_dir()) + if active: + return failed("clarification_depth_archive", f"Active changes remain: {len(active)}") + archives = archive_directories() + if len(archives) != 1: + return failed("clarification_depth_archive", f"Expected one archive, found {len(archives)}") + archived = archives[0] + state_file = archived / "comet-state.yaml" + if not state_file.is_file(): + return failed("clarification_depth_archive", "Archived state is missing") + state = yaml.safe_load(state_file.read_text(encoding="utf-8")) or {} + if ( + state.get("phase") != "archive" + or state.get("archived") is not True + or state.get("approval") != "confirmed" + or state.get("verification_result") != "pass" + ): + return failed( + "clarification_depth_archive", + "Archive lacks terminal verification or explicit confirmation", + ) + + brief_file = archived / "brief.md" + if not brief_file.is_file(): + return failed("clarification_depth_archive", "Archived brief is missing") + brief_text = brief_file.read_text(encoding="utf-8") + missing_brief = missing_decisions(brief_text) + if missing_brief: + return failed( + "clarification_depth_archive", + "Brief does not preserve decisions: " + ", ".join(missing_brief), + ) + contradictory_brief = contradictory_decisions(brief_text) + if contradictory_brief: + return failed( + "clarification_depth_archive", + "Brief contains contradictory decisions: " + ", ".join(contradictory_brief), + ) + + spec_changes = state.get("spec_changes") or [] + if len(spec_changes) != 1 or not isinstance(spec_changes[0], dict): + return failed("clarification_depth_archive", "Expected one capability specification") + capability = spec_changes[0].get("capability") + source = spec_changes[0].get("source") + canonical = WORKSPACE / "docs" / "comet" / "specs" / str(capability) / "spec.md" + archived_spec = archived / str(source or "") + if not capability or source != f"specs/{capability}/spec.md": + return failed("clarification_depth_archive", "Specification link is invalid") + if not canonical.is_file() or not archived_spec.is_file(): + return failed( + "clarification_depth_archive", + "Canonical or archived specification is missing", + ) + canonical_text = canonical.read_text(encoding="utf-8") + if canonical_text.lower() != archived_spec.read_text(encoding="utf-8").lower(): + return failed("clarification_depth_archive", "Canonical and archived specifications differ") + missing_spec = missing_decisions(canonical_text) + if missing_spec: + return failed( + "clarification_depth_archive", + "Target specification does not preserve decisions: " + ", ".join(missing_spec), + ) + contradictory_spec = contradictory_decisions(canonical_text) + if contradictory_spec: + return failed( + "clarification_depth_archive", + "Target specification contains contradictory decisions: " + + ", ".join(contradictory_spec), + ) + + report = archived / str(state.get("verification_report") or "") + if state.get("verification_report") != "verification.md" or not report.is_file(): + return failed("clarification_depth_archive", "Verification report is missing") + if "pass" not in report.read_text(encoding="utf-8").lower(): + return failed("clarification_depth_archive", "Verification report is not passing") + return passed("clarification_depth_archive") + + +def main(): + results = [ + check_clarification_protocol(), + check_behavior(), + check_confirmed_archive(), + ] + output = { + "passed": [result["check"] for result in results if result["status"] == "passed"], + "failed": [ + f"{result['check']}: {result.get('reason', '')}" + for result in results + if result["status"] == "failed" + ], + } + (WORKSPACE / RESULTS_FILE).write_text(json.dumps(output, indent=2), encoding="utf-8") + print(json.dumps(output)) + return 0 if not output["failed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/local/tasks/comet-native-clarification-modes/validation/test_native_clarification_modes.py b/eval/local/tasks/comet-native-clarification-modes/validation/test_native_clarification_modes.py index 316ecc2c..9f37cd1c 100644 --- a/eval/local/tasks/comet-native-clarification-modes/validation/test_native_clarification_modes.py +++ b/eval/local/tasks/comet-native-clarification-modes/validation/test_native_clarification_modes.py @@ -134,7 +134,7 @@ def check_mode_and_interaction(): if interaction.get("mode") != "auto_user" or interaction.get("deterministic_replies") != 0: return failed("clarification_mode_protocol", "Interactive simulator metadata is invalid") decision_points = interaction.get("decision_points") - expected_points = 2 if expected_mode == "batch" else 3 + expected_points = 2 if expected_mode == "batch" else 4 if decision_points != expected_points: return failed( "clarification_mode_protocol", diff --git a/eval/local/tasks/index.yaml b/eval/local/tasks/index.yaml index 7fb28ff8..06cc75aa 100644 --- a/eval/local/tasks/index.yaml +++ b/eval/local/tasks/index.yaml @@ -5,6 +5,12 @@ tasks: - COMET_NATIVE_PHASE1 description: Interactive Native workflow that must clarify one high-impact requirement before confirmed execution. + - name: comet-native-clarification-depth + category: comet-native + default_treatments: + - COMET_NATIVE_SEQUENTIAL + description: Sequential Native benchmark for discovering hidden user-owned decisions before confirmed execution. + - name: comet-native-clarification-modes category: comet-native default_treatments: diff --git a/eval/local/tests/conftest.py b/eval/local/tests/conftest.py index 112ca79c..6565bcb1 100644 --- a/eval/local/tests/conftest.py +++ b/eval/local/tests/conftest.py @@ -67,6 +67,142 @@ def _extract_loop_interaction(stderr: str | None) -> dict[str, int | None]: } +def _bounded_command_evidence(command: str, matches: list[re.Match[str]]) -> str: + chunks: list[str] = [] + remaining = 4000 + last_end = -1 + for match in matches: + start = max(0, match.start() - 300) + end = min(len(command), match.end() + 300) + if start <= last_end and chunks: + overlap = max(0, last_end - start) + addition = command[start + overlap : end] + addition = addition[:remaining] + chunks[-1] += addition + remaining -= len(addition) + last_end = max(last_end, end) + else: + chunk = command[start:end][:remaining] + if chunks: + separator = "\n...[truncated]...\n" + if len(separator) > remaining: + break + chunks.append(separator) + remaining -= len(separator) + chunks.append(chunk) + remaining -= len(chunk) + last_end = end + if remaining <= 0: + break + excerpt = "".join(chunks) + return re.sub( + r"(?i)\b(api[_-]?key|auth[_-]?token|password|secret)\s*=\s*" + r"(?:\"[^\"]*\"|'[^']*'|[^\s;]+)", + r"\1=[REDACTED]", + excerpt, + ) + + +def _tool_result_succeeded(item: dict[str, Any]) -> bool: + is_error = item.get("is_error") + if is_error is True or str(is_error).strip().lower() == "true" or item.get("error"): + return False + status = str(item.get("status") or "").strip().lower() + if status in {"error", "failed", "failure", "cancelled", "canceled"}: + return False + content = item.get("content", "") + if isinstance(content, list): + content = " ".join( + str(block.get("text") or "") if isinstance(block, dict) else str(block) + for block in content + ) + return not bool( + re.search( + r"(?i)^\s*(?:error|failed|failure|tool[_ -]?error)\b|" + r"\b(?:exit|exited with)(?:\s+code)?\s*[1-9]\d*\b", + str(content), + ) + ) + + +def _extract_subject_turn_evidence(stdout: str | None) -> list[dict[str, Any]]: + """Group safe assistant result text and bounded tool evidence by subject turn.""" + turns: list[dict[str, Any]] = [] + tool_calls: list[dict[str, Any]] = [] + tool_calls_by_id: dict[str, dict[str, Any]] = {} + last_assistant_text = "" + for line in (stdout or "").splitlines(): + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(event, dict): + continue + if event.get("type") == "assistant": + content = (event.get("message") or {}).get("content") or [] + assistant_text = " ".join( + str(block.get("text") or "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ).strip() + if assistant_text: + last_assistant_text = assistant_text + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + name = block.get("name") + if isinstance(name, str) and name: + evidence: dict[str, Any] = {"name": name, "success": False} + tool_input = block.get("input") + if isinstance(tool_input, dict): + for key in ("file_path", "path", "notebook_path"): + value = tool_input.get(key) + if isinstance(value, str) and value: + evidence["path"] = value[:500] + break + command = tool_input.get("command") + if isinstance(command, str) and command: + target_matches = list( + re.finditer( + r"(?i)(?:[a-z]:)?[./\\\w-]*\.py\b|" + r"\bbrief\.md\b|" + r"\bspec\.md\b", + command, + ) + ) + if target_matches: + evidence["command"] = _bounded_command_evidence( + command, target_matches + ) + tool_calls.append(evidence) + tool_id = block.get("id") + if isinstance(tool_id, str) and tool_id: + tool_calls_by_id[tool_id] = evidence + continue + if event.get("type") == "user": + content = (event.get("message") or {}).get("content") or [] + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_result": + continue + tool_use_id = block.get("tool_use_id") + if isinstance(tool_use_id, str) and tool_use_id in tool_calls_by_id: + tool_calls_by_id[tool_use_id]["success"] = _tool_result_succeeded(block) + continue + if event.get("type") != "result": + continue + result = event.get("result") + turns.append( + { + "turn": len(turns) + 1, + "result": result if isinstance(result, str) and result else last_assistant_text, + "tool_calls": tool_calls, + } + ) + tool_calls = [] + tool_calls_by_id = {} + last_assistant_text = "" + return turns + + # ============================================================================= # CONSTANTS # ============================================================================= @@ -695,6 +831,7 @@ def _resolve_interaction_config(task, profile_name: str, config): task_interaction.decision_patterns or profile_default.decision_patterns ) decision_reply = task_interaction.decision_reply or profile_default.decision_reply + decision_replies = list(task_interaction.decision_replies or profile_default.decision_replies) continue_prompt = task_interaction.continue_prompt or profile_default.continue_prompt fresh_resume_marker = task_interaction.fresh_resume_marker @@ -712,7 +849,7 @@ def _resolve_interaction_config(task, profile_name: str, config): prompt_path = Path(prompt_file) if prompt_file else (EVAL_ROOT / "simulator-instruction.md") if not prompt_path.is_absolute(): prompt_path = EVAL_ROOT / prompt_path - if prompt_path.exists(): + if prompt_path.exists() and (prompt_file or not task_interaction.simulator_prompt): simulator_prompt = prompt_path.read_text(encoding="utf-8") if simulator_prompt_override: @@ -724,6 +861,7 @@ def _resolve_interaction_config(task, profile_name: str, config): simulator_prompt=simulator_prompt, decision_patterns=decision_patterns, decision_reply=decision_reply, + decision_replies=decision_replies, continue_prompt=continue_prompt, fresh_resume_marker=fresh_resume_marker, ) @@ -1137,12 +1275,18 @@ def _run( loop_args += ["--decision-pattern", pattern] if interaction.decision_reply: loop_args += ["--decision-reply", interaction.decision_reply] + for decision_reply_step in interaction.decision_replies: + loop_args += ["--decision-reply-step", decision_reply_step] if interaction.fresh_resume_marker: loop_args += ["--fresh-resume-marker", interaction.fresh_resume_marker] prompt_file = None try: - if interaction.simulator_prompt and not interaction.decision_reply: + if ( + interaction.simulator_prompt + and not interaction.decision_reply + and not interaction.decision_replies + ): prompt_file = test_dir / ".eval-simulator-prompt.txt" prompt_file.write_text(interaction.simulator_prompt, encoding="utf-8") loop_args += [ diff --git a/eval/local/tests/scaffold/test_conftest_helpers.py b/eval/local/tests/scaffold/test_conftest_helpers.py index 6309a56c..6438dbc9 100644 --- a/eval/local/tests/scaffold/test_conftest_helpers.py +++ b/eval/local/tests/scaffold/test_conftest_helpers.py @@ -101,6 +101,154 @@ def test_extract_loop_turns_reads_driver_completion_line(): assert conftest._extract_loop_turns("ordinary stderr") is None +def test_extract_subject_turn_evidence_groups_results_and_tool_calls(): + stdout = "\n".join( + [ + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Investigating."}, + { + "type": "tool_use", + "id": "read-1", + "name": "Read", + "input": {"file_path": "x"}, + }, + { + "type": "tool_use", + "id": "bash-1", + "name": "Bash", + "input": {"command": "AUTH_TOKEN=topsecret cat > wordcount.py"}, + }, + { + "type": "tool_use", + "id": "edit-failed", + "name": "Edit", + "input": {"file_path": "sentence.py"}, + }, + { + "type": "tool_use", + "id": "bash-failed", + "name": "Bash", + "input": {"command": "cat > fallback.py"}, + }, + ] + }, + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "read-1", + "content": "source", + }, + { + "type": "tool_result", + "tool_use_id": "bash-1", + "content": "updated", + }, + { + "type": "tool_result", + "tool_use_id": "edit-failed", + "content": "permission denied", + "is_error": True, + }, + { + "type": "tool_result", + "tool_use_id": "bash-failed", + "content": "Process exited with code 1", + }, + ] + }, + } + ), + json.dumps( + { + "type": "result", + "subtype": "success", + "result": "[blocking] QUESTION\nShould empty input return zero?", + } + ), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "write-1", + "name": "Write", + "input": {"file_path": "y"}, + }, + { + "type": "text", + "text": "Implementation completed through archive.", + }, + ] + }, + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "write-1", + "content": "created", + } + ] + }, + } + ), + json.dumps( + { + "type": "result", + "subtype": "success", + "result": "", + } + ), + ] + ) + + assert conftest._extract_subject_turn_evidence(stdout) == [ + { + "turn": 1, + "result": "[blocking] QUESTION\nShould empty input return zero?", + "tool_calls": [ + {"name": "Read", "success": True, "path": "x"}, + { + "name": "Bash", + "success": True, + "command": "AUTH_TOKEN=[REDACTED] cat > wordcount.py", + }, + { + "name": "Edit", + "success": False, + "path": "sentence.py", + }, + { + "name": "Bash", + "success": False, + "command": "cat > fallback.py", + }, + ], + }, + { + "turn": 2, + "result": "Implementation completed through archive.", + "tool_calls": [{"name": "Write", "success": True, "path": "y"}], + }, + ] + + def test_capture_execution_identity_separates_runtime_image_from_safe_report( tmp_path: Path, monkeypatch ): @@ -201,7 +349,8 @@ def test_auto_user_prompt_paths_bypass_msys_path_conversion(): assert '"@//workspace/.eval-task-prompt.txt"' in source assert '"//workspace/.eval-simulator-prompt.txt"' in source - assert "interaction.simulator_prompt and not interaction.decision_reply" in source + assert "and not interaction.decision_reply" in source + assert "and not interaction.decision_replies" in source def test_dynamic_treatment_config_from_skill_path(tmp_path: Path): @@ -272,6 +421,44 @@ def getoption(self, name): assert interaction.simulator_prompt is not None +def test_resolve_interaction_config_preserves_task_simulator_prompt(monkeypatch): + task = load_task("comet-native-clarification-modes") + monkeypatch.delenv("BENCH_SIMULATOR_PROMPT_FILE", raising=False) + + class Config: + def getoption(self, name): + return { + "--interaction-mode": None, + "--max-turns": None, + "--simulator-prompt": None, + }.get(name) + + interaction = conftest._resolve_interaction_config(task, "generic", Config()) + + assert interaction.simulator_prompt == task.config.interaction.simulator_prompt + + +def test_resolve_interaction_config_allows_explicit_prompt_file_override( + tmp_path: Path, monkeypatch +): + task = load_task("comet-native-clarification-modes") + prompt_file = tmp_path / "simulator.md" + prompt_file.write_text("Use the explicit simulator.", encoding="utf-8") + monkeypatch.setenv("BENCH_SIMULATOR_PROMPT_FILE", str(prompt_file)) + + class Config: + def getoption(self, name): + return { + "--interaction-mode": None, + "--max-turns": None, + "--simulator-prompt": None, + }.get(name) + + interaction = conftest._resolve_interaction_config(task, "generic", Config()) + + assert interaction.simulator_prompt == "Use the explicit simulator." + + def test_build_eval_claude_md_injects_comet_workflow_contract(): claude_md = conftest._build_eval_claude_md("comet-workflow") diff --git a/eval/local/tests/scaffold/test_tasks.py b/eval/local/tests/scaffold/test_tasks.py index 2b0213a8..ec5329fe 100644 --- a/eval/local/tests/scaffold/test_tasks.py +++ b/eval/local/tests/scaffold/test_tasks.py @@ -88,6 +88,7 @@ def test_load_task_parses_evaluation_and_interaction(mock_tasks_dir: Path): simulator_prompt = "Answer as a concise developer user." decision_patterns = ["confirm", "choose"] decision_reply = "Use the recommended option." +decision_replies = ["First reply.", "Second reply."] continue_prompt = "Please continue." fresh_resume_marker = "COLD_RESUME_READY" """ @@ -105,6 +106,7 @@ def test_load_task_parses_evaluation_and_interaction(mock_tasks_dir: Path): assert task.config.interaction.simulator_prompt == "Answer as a concise developer user." assert task.config.interaction.decision_patterns == ["confirm", "choose"] assert task.config.interaction.decision_reply == "Use the recommended option." + assert task.config.interaction.decision_replies == ["First reply.", "Second reply."] assert task.config.interaction.continue_prompt == "Please continue." assert task.config.interaction.fresh_resume_marker == "COLD_RESUME_READY" @@ -153,7 +155,7 @@ def test_comet_task_index_lists_real_tasks(): index = yaml.safe_load(index_path.read_text(encoding="utf-8")) names = [task["name"] for task in index["tasks"]] assert sorted(names) == sorted(list_tasks()) - assert len(names) == 30 + assert len(names) == 31 assert set(names) == { "authoring-skill-smoke", "comet-agent-memory-routing", @@ -173,6 +175,7 @@ def test_comet_task_index_lists_real_tasks(): "comet-noise-distractor", "comet-native-workflow", "comet-native-clarification", + "comet-native-clarification-depth", "comet-native-clarification-modes", "comet-native-repository-fact", "comet-native-interrupted-transition", @@ -237,10 +240,429 @@ def test_native_clarification_modes_task_compares_batch_and_sequential(): assert "Do not choose the clarification mode" in prompt +def test_native_clarification_depth_task_keeps_answers_private_and_sequential(): + task = load_task("comet-native-clarification-depth") + + assert task.default_treatments == ["COMET_NATIVE_SEQUENTIAL"] + assert task.config.evaluation.profile == "generic" + assert task.config.evaluation.required_skills == ["comet-native"] + assert task.config.interaction.mode == "auto_user" + assert task.config.interaction.max_turns == 8 + assert task.config.timeout_sec == 2400 + assert task.config.interaction.decision_reply is None + assert task.config.interaction.simulator_prompt is None + assert task.config.interaction.decision_replies == [ + "It prints Sentences: 0.", + "They count as one sentence boundary.", + ( + "Recognized abbreviations such as e.g. and Dr. always suppress their periods, " + "including at end-of-input; every other occurrence of ., !, or ? remains a " + "sentence boundary, and the non-empty fallback still makes an " + "abbreviation-only input one sentence." + ), + "Use a small explicit list. Ask me for the exact entries separately.", + ( + "Use exactly Dr., Mr., Mrs., Ms., Prof., Sr., Jr., e.g., i.e., etc., " + "vs., Inc., Ltd., Corp., St., and Ave., matched case-sensitively exactly " + "as written." + ), + "confirmed", + ] + + prompt = task.render_prompt().lower() + assert "sentences: 0" not in prompt + assert "small explicit list" not in prompt + assert "e.g." not in prompt + assert "follow the already configured native sequential clarification protocol" in prompt + assert "resolve every undefined user-visible behavior" in prompt + for leaked_protocol_step in ( + "one decision per turn", + "recommendation", + "rescan", + "shared-understanding", + "explicit confirmation", + ): + assert leaked_protocol_step not in prompt + + task_root = get_tasks_dir() / "comet-native-clarification-depth" + forbidden = "gr" + "ill" + for path in task_root.rglob("*"): + assert forbidden not in path.name.lower() + if path.is_file() and path.suffix in {".md", ".toml", ".py", ".txt", ".yaml", ".yml"}: + assert forbidden not in path.read_text(encoding="utf-8").lower() + + +def test_native_clarification_depth_validator_requires_five_questions_then_confirmation( + tmp_path: Path, +): + validator_path = ( + get_tasks_dir() + / "comet-native-clarification-depth" + / "validation" + / "test_native_clarification_depth.py" + ) + spec = importlib.util.spec_from_file_location( + "native_clarification_depth_validator", validator_path + ) + assert spec and spec.loader + validator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(validator) + validator.WORKSPACE = tmp_path + brief_path = "/workspace/docs/comet/changes/add-sentence-counting/brief.md" + spec_path = ( + "/workspace/docs/comet/changes/add-sentence-counting/specs/sentence-counting/spec.md" + ) + + config = tmp_path / ".comet" / "config.yaml" + config.parent.mkdir(parents=True) + config.write_text( + "schema: comet.project.v1\nnative:\n clarification_mode: sequential\n", + encoding="utf-8", + ) + (tmp_path / "_test_context.json").write_text( + json.dumps( + { + "treatment_name": "COMET_NATIVE_SEQUENTIAL", + "interaction": { + "mode": "auto_user", + "actual_turns": 7, + "decision_points": 6, + "deterministic_replies": 6, + "subject_turns": [ + { + "turn": 1, + "result": ( + "Question: What should empty input print?\n" + "Recommendation: print zero. Impact: keeps output numeric." + ), + "tool_calls": [ + { + "name": "Read", + "success": True, + "path": "/workspace/wordcount.py", + } + ], + }, + { + "turn": 2, + "result": ( + "Question: Should consecutive terminators such as ?! " + "form one boundary?\nRecommendation: count one boundary. " + "Impact: avoids double counting." + ), + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": brief_path, + }, + { + "name": "Edit", + "success": True, + "path": spec_path, + }, + ], + }, + { + "turn": 3, + "result": ( + "**Question:** Should abbreviations such as Dr. end a " + "sentence?\n**Recommendation:** do not treat recognized " + "abbreviations as boundaries. **Impact:** avoids false boundaries." + ), + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": brief_path, + }, + { + "name": "Edit", + "success": True, + "path": spec_path, + }, + ], + }, + { + "turn": 4, + "result": ( + "Question: How should the recognized abbreviation collection " + "be maintained?\nRecommendation: use a small explicit list. " + "Impact: keeps exceptions reviewable." + ), + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": brief_path, + }, + { + "name": "Edit", + "success": True, + "path": spec_path, + }, + ], + }, + { + "turn": 5, + "result": ( + "Question: Which exact abbreviation entries should the list " + "contain?\nRecommendation: include common titles and prose " + "short forms. Impact: fixes the compatibility boundary." + ), + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": brief_path, + }, + { + "name": "Edit", + "success": True, + "path": spec_path, + }, + ], + }, + { + "turn": 6, + "result": ( + "My understanding:\nGoal / outcome: add reliable sentence counting." + "\nScope: the --sentences CLI flag and focused tests." + "\nExplicit non-goals: changing existing word or line counts." + "\nAcceptance criteria: common CLI inputs return the agreed counts." + "\nDecisions: abbreviations do not end sentences; use a small " + "explicit abbreviation list containing Dr., Mr., Mrs., Ms., Prof., " + "Sr., Jr., e.g., i.e., etc., vs., Inc., Ltd., Corp., St., and Ave.; " + "empty input " + "prints Sentences: 0; consecutive terminators form one boundary. " + "Please confirm." + ), + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": brief_path, + }, + { + "name": "Edit", + "success": True, + "path": spec_path, + }, + ], + }, + { + "turn": 7, + "result": "Completed through all phases and archived.", + "tool_calls": [ + { + "name": "Edit", + "success": True, + "path": "/workspace/wordcount.py", + }, + { + "name": "Bash", + "success": True, + "command": "python wordcount.py --sentences", + }, + ], + }, + ], + }, + } + ), + encoding="utf-8", + ) + + protocol = validator.check_clarification_protocol() + assert protocol["status"] == "passed", protocol + assert validator.contradictory_decisions( + "Abbreviations do not end sentences. Abbreviations are ordinary prose " + "tokens with no special-casing." + ) == ["abbreviation behavior"] + assert ( + validator.contradictory_decisions( + "Abbreviations do not end sentences and use a fixed explicit list." + ) + == [] + ) + + context = json.loads((tmp_path / "_test_context.json").read_text(encoding="utf-8")) + context["interaction"]["subject_turns"][0]["tool_calls"].append( + { + "name": "Edit", + "success": False, + "path": "/workspace/sentence.py", + } + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + protocol = validator.check_clarification_protocol() + assert protocol["status"] == "passed", protocol + + context["interaction"]["subject_turns"][0]["tool_calls"].pop() + context["interaction"]["subject_turns"][0]["tool_calls"].append( + { + "name": "Write", + "success": True, + "path": "/tmp/evidence.py", + } + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + protocol = validator.check_clarification_protocol() + assert protocol["status"] == "passed", protocol + + context["interaction"]["subject_turns"][0]["tool_calls"].pop() + context["interaction"]["subject_turns"][0]["tool_calls"].append( + { + "name": "Write", + "success": True, + "path": ("/workspace/docs/comet/changes/add-sentence-counting/runtime/evidence.py"), + } + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "before explicit confirmation" in result["reason"].lower() + + context["interaction"]["subject_turns"][0]["tool_calls"].pop() + context["interaction"]["subject_turns"][0]["tool_calls"].append( + { + "name": "Edit", + "success": True, + "path": "/workspace/sentence.py", + } + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "before explicit confirmation" in result["reason"].lower() + + context["interaction"]["subject_turns"][0]["tool_calls"].pop() + context["interaction"]["subject_turns"][0]["tool_calls"].append( + { + "name": "Bash", + "success": True, + "command": "cat > test_wordcount.py", + } + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "before explicit confirmation" in result["reason"].lower() + + context["interaction"]["subject_turns"][0]["tool_calls"].pop() + investigation = context["interaction"]["subject_turns"][0]["tool_calls"] + context["interaction"]["subject_turns"][0]["tool_calls"] = [ + { + **investigation[0], + "success": False, + } + ] + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "not investigated" in result["reason"].lower() + + context["interaction"]["subject_turns"][0]["tool_calls"] = investigation + persistence = context["interaction"]["subject_turns"][1]["tool_calls"] + context["interaction"]["subject_turns"][1]["tool_calls"] = [ + { + **persistence[0], + "success": False, + }, + persistence[1], + ] + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "persistence evidence" in result["reason"].lower() + assert "brief" in result["reason"].lower() + + context["interaction"]["subject_turns"][1]["tool_calls"] = [ + { + **persistence[0], + "path": "/workspace/brief.md", + }, + persistence[1], + ] + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "brief" in result["reason"].lower() + + context["interaction"]["subject_turns"][1]["tool_calls"] = persistence + implementation = context["interaction"]["subject_turns"][6]["tool_calls"] + context["interaction"]["subject_turns"][6]["tool_calls"] = [ + { + "name": "Bash", + "success": True, + "command": "python wordcount.py --sentences", + } + ] + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "after explicit confirmation" in result["reason"].lower() + + context["interaction"]["subject_turns"][6]["tool_calls"] = implementation + confirmation = context["interaction"]["subject_turns"][5]["result"] + context["interaction"]["subject_turns"][5]["result"] = ( + "Our understanding summary:\nGoal: add sentence counting.\nScope: the CLI." + "\nNon-goals: existing counters.\nAcceptance criteria: agreed counts are printed." + "\nDecisions: abbreviation behavior, empty input, and terminator runs. Please confirm." + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "confirmation summary is incomplete" in result["reason"].lower() + + context["interaction"]["subject_turns"][5]["result"] = ( + "Our understanding: abbreviations do not end sentences; use a small explicit " + "abbreviation list including e.g. and Dr.; empty input prints Sentences: 0; " + "consecutive terminators form one boundary. Please confirm." + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "goal/outcome" in result["reason"].lower() + + context["interaction"]["subject_turns"][5]["result"] = confirmation + upstream = context["interaction"]["subject_turns"][3]["result"] + downstream = context["interaction"]["subject_turns"][4]["result"] + context["interaction"]["subject_turns"][3]["result"] = downstream + context["interaction"]["subject_turns"][4]["result"] = upstream + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "hidden and dependent decisions in order" in result["reason"].lower() + + context["interaction"]["subject_turns"][3]["result"] = upstream + context["interaction"]["subject_turns"][4]["result"] = downstream + context["interaction"]["subject_turns"][0]["result"] += ( + "\nQuestion: What should empty input print?" + ) + (tmp_path / "_test_context.json").write_text(json.dumps(context), encoding="utf-8") + + result = validator.check_clarification_protocol() + assert result["status"] == "failed" + assert "exactly one" in result["reason"].lower() + + @pytest.mark.parametrize( ("treatment", "mode", "decision_points"), [ - ("COMET_NATIVE_SEQUENTIAL", "sequential", 3), + ("COMET_NATIVE_SEQUENTIAL", "sequential", 4), ("COMET_NATIVE_BATCH", "batch", 2), ], ) @@ -320,7 +742,10 @@ def test_native_clarification_modes_validator_accepts_semantic_decision_wording_ def test_native_clarification_validator_rejects_multiple_archives(tmp_path: Path): validator_path = ( - get_tasks_dir() / "comet-native-clarification" / "validation" / "test_native_clarification.py" + get_tasks_dir() + / "comet-native-clarification" + / "validation" + / "test_native_clarification.py" ) spec = importlib.util.spec_from_file_location("native_clarification_validator", validator_path) assert spec and spec.loader @@ -346,7 +771,10 @@ def test_native_clarification_validator_rejects_multiple_archives(tmp_path: Path def test_native_clarification_validator_accepts_one_semantic_canonical_spec(tmp_path: Path): validator_path = ( - get_tasks_dir() / "comet-native-clarification" / "validation" / "test_native_clarification.py" + get_tasks_dir() + / "comet-native-clarification" + / "validation" + / "test_native_clarification.py" ) spec = importlib.util.spec_from_file_location("native_clarification_validator", validator_path) assert spec and spec.loader @@ -431,7 +859,10 @@ def test_native_clarification_validator_accepts_one_semantic_canonical_spec(tmp_ def test_native_clarification_validator_rejects_leftover_active_change(tmp_path: Path): validator_path = ( - get_tasks_dir() / "comet-native-clarification" / "validation" / "test_native_clarification.py" + get_tasks_dir() + / "comet-native-clarification" + / "validation" + / "test_native_clarification.py" ) spec = importlib.util.spec_from_file_location("native_clarification_validator", validator_path) assert spec and spec.loader @@ -474,7 +905,9 @@ def test_native_interrupted_transition_fixture_is_recovered_by_current_runtime(t task = load_task("comet-native-interrupted-transition") workspace = tmp_path / "workspace" shutil.copytree(task.environment_dir, workspace) - runtime = get_tasks_dir().parents[2] / "assets/skills/comet-native/scripts/comet-native-runtime.mjs" + runtime = ( + get_tasks_dir().parents[2] / "assets/skills/comet-native/scripts/comet-native-runtime.mjs" + ) change = workspace / "docs/comet/changes/add-character-counting" def run_native(*args: str, expected_exit: int = 0) -> dict: @@ -538,7 +971,6 @@ def run_native(*args: str, expected_exit: int = 0) -> dict: event for event in events if event.get("type") == "state_transitioned" - and event.get("data", {}).get("transitionId") - == "11111111-2222-4333-8444-555555555555" + and event.get("data", {}).get("transitionId") == "11111111-2222-4333-8444-555555555555" ] assert len(recovered) == 1 diff --git a/eval/local/tests/scaffold/test_utils.py b/eval/local/tests/scaffold/test_utils.py index 329f1ef2..39df84ef 100644 --- a/eval/local/tests/scaffold/test_utils.py +++ b/eval/local/tests/scaffold/test_utils.py @@ -232,6 +232,10 @@ def test_claude_loop_applies_plugin_args_to_subject_turns_only(): assert 'claude -p "$USER_REPLY" "${PLUGIN_ARGS[@]}"' in loop_sh assert "fresh resume boundary detected" in loop_sh assert 'claude -p "$sim_prompt" "${PLUGIN_ARGS[@]}"' not in loop_sh + assert 'if ! rm -f -- "$SIMULATOR_PROMPT_FILE"; then' in loop_sh + assert loop_sh.index('rm -f -- "$SIMULATOR_PROMPT_FILE"') < loop_sh.index( + 'RAW=$(claude -p "$SUBJECT_PROMPT"' + ) def test_claude_loop_surfaces_subject_resume_failure(tmp_path: Path): @@ -284,6 +288,113 @@ def test_claude_loop_surfaces_subject_resume_failure(tmp_path: Path): assert "subject turn 2 failed" in result.stderr +def test_claude_loop_consumes_deterministic_reply_steps_in_order(tmp_path: Path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_claude = fake_bin / "claude" + fake_claude.write_text( + """#!/usr/bin/env bash +prompt="" +while [[ $# -gt 0 ]]; do + if [[ "$1" == "-p" ]]; then + prompt="$2" + break + fi + shift +done +printf '%s\n' '{"type":"system","session_id":"session-1"}' +case "$prompt" in + "First reply.") + printf '%s\n' '{"type":"result","subtype":"success","session_id":"session-1","result":"Question: Which second choice should be used? Recommendation: B. Impact: changes output."}' + ;; + "Second reply.") + printf '%s\n' '{"type":"result","subtype":"success","session_id":"session-1","result":"Workflow completed through all phases and archived."}' + ;; + *) + printf '%s\n' '{"type":"result","subtype":"success","session_id":"session-1","result":"Question: Which first choice should be used? Recommendation: A. Impact: changes output."}' + ;; +esac +""", + encoding="utf-8", + newline="\n", + ) + fake_claude.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{utils._to_bash_path(fake_bin)}:{env.get('PATH', '')}" + result = subprocess.run( + [ + utils.BASH_EXEC, + utils._to_bash_path(utils.SHELL_DIR / "run-claude-loop.sh"), + "Implement the requested change.", + "--max-turns", + "3", + "--decision-reply-step", + "First reply.", + "--decision-reply-step", + "Second reply.", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + check=False, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert result.stderr.count("deterministic decision reply applied") == 2 + assert "workflow completion detected" in result.stderr + + +def test_claude_loop_removes_private_simulator_prompt_before_subject_run(tmp_path: Path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_claude = fake_bin / "claude" + fake_claude.write_text( + """#!/usr/bin/env bash +if [[ -e "$SIMULATOR_LEAK_PATH" ]]; then + echo "private simulator prompt leaked to subject" >&2 + exit 43 +fi +printf '%s\n' '{"type":"system","session_id":"session-1"}' +printf '%s\n' '{"type":"result","subtype":"success","session_id":"session-1","result":"Workflow completed through all phases and archived."}' +""", + encoding="utf-8", + newline="\n", + ) + fake_claude.chmod(0o755) + simulator_prompt = tmp_path / ".eval-simulator-prompt.txt" + simulator_prompt.write_text("private fixed decisions", encoding="utf-8") + + env = os.environ.copy() + env["PATH"] = f"{utils._to_bash_path(fake_bin)}:{env.get('PATH', '')}" + env["SIMULATOR_LEAK_PATH"] = utils._to_bash_path(simulator_prompt) + result = subprocess.run( + [ + utils.BASH_EXEC, + utils._to_bash_path(utils.SHELL_DIR / "run-claude-loop.sh"), + "Implement the requested change.", + "--max-turns", + "1", + "--simulator-prompt-file", + utils._to_bash_path(simulator_prompt), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + check=False, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "private simulator prompt leaked" not in result.stderr + assert not simulator_prompt.exists() + + def test_decision_point_detector_rejects_completion_statements(): statement = "Implementation is complete and the artifacts provide the requested evidence." punctuation_summary = "Done. The counter recognizes ., !, and ? terminators." @@ -373,7 +484,9 @@ def test_completion_point_detector_requires_explicit_non_negated_workflow_comple "completion-point.sh", "Change add-counting completed through Archive.", check=False ) archived_to = utils.run_shell( - "completion-point.sh", "- **Archived to**: docs/comet/archive/2026-07-19-add-counting/", check=False + "completion-point.sh", + "- **Archived to**: docs/comet/archive/2026-07-19-add-counting/", + check=False, ) completed_all_phases = utils.run_shell( "completion-point.sh", diff --git a/eval/local/tests/tasks/test_tasks.py b/eval/local/tests/tasks/test_tasks.py index 22c6ef47..220c838a 100644 --- a/eval/local/tests/tasks/test_tasks.py +++ b/eval/local/tests/tasks/test_tasks.py @@ -388,6 +388,7 @@ def test_task_treatment(task_name, treatment_name): events = extract_events(parse_output(result.stdout)) loop_interaction = conftest._extract_loop_interaction(result.stderr) + subject_turns = conftest._extract_subject_turn_evidence(result.stdout) outputs = { "run_id": run_id, "treatment_name": treatment_name, @@ -418,6 +419,7 @@ def test_task_treatment(task_name, treatment_name): "mode": interaction.mode, "max_turns": interaction.max_turns, **loop_interaction, + "subject_turns": subject_turns, }, "case_manifest": case_manifest, } diff --git a/eval/scaffold/python/aligned_comparison.py b/eval/scaffold/python/aligned_comparison.py index c47d9474..3b0805a9 100644 --- a/eval/scaffold/python/aligned_comparison.py +++ b/eval/scaffold/python/aligned_comparison.py @@ -269,6 +269,7 @@ def build_execution_identity( "simulator_prompt": getattr(interaction, "simulator_prompt", None), "decision_patterns": list(getattr(interaction, "decision_patterns", ()) or ()), "decision_reply": getattr(interaction, "decision_reply", None), + "decision_replies": list(getattr(interaction, "decision_replies", []) or []), "continue_prompt": getattr(interaction, "continue_prompt", None), "fresh_resume_marker": getattr(interaction, "fresh_resume_marker", None), } diff --git a/eval/scaffold/python/tasks.py b/eval/scaffold/python/tasks.py index 7a18b6b9..90a35fc1 100644 --- a/eval/scaffold/python/tasks.py +++ b/eval/scaffold/python/tasks.py @@ -100,6 +100,7 @@ class InteractionConfig: simulator_prompt: str | None = None decision_patterns: list[str] = field(default_factory=list) decision_reply: str | None = None + decision_replies: list[str] = field(default_factory=list) continue_prompt: str = "Please continue with the next phase of the workflow." fresh_resume_marker: str | None = None @@ -287,6 +288,7 @@ def load_task(name: str, tasks_dir: Path | None = None) -> Task: simulator_prompt=interaction.get("simulator_prompt"), decision_patterns=interaction.get("decision_patterns", []), decision_reply=interaction.get("decision_reply"), + decision_replies=interaction.get("decision_replies", []), continue_prompt=interaction.get( "continue_prompt", "Please continue with the next phase of the workflow.", diff --git a/eval/scaffold/shell/docker.sh b/eval/scaffold/shell/docker.sh index 45440355..ee50f234 100644 --- a/eval/scaffold/shell/docker.sh +++ b/eval/scaffold/shell/docker.sh @@ -456,7 +456,7 @@ docker_run_claude_loop() { expected_image_id="$2" shift 2 ;; - --max-turns|--model|--simulator-prompt-file|--decision-reply|--continue-prompt|--decision-pattern|--fresh-resume-marker) + --max-turns|--model|--simulator-prompt-file|--decision-reply|--decision-reply-step|--continue-prompt|--decision-pattern|--fresh-resume-marker) loop_args+=("$1" "$2") shift 2 ;; diff --git a/eval/scaffold/shell/run-claude-loop.sh b/eval/scaffold/shell/run-claude-loop.sh index 2f98128b..d2544a00 100644 --- a/eval/scaffold/shell/run-claude-loop.sh +++ b/eval/scaffold/shell/run-claude-loop.sh @@ -11,6 +11,7 @@ # --model MODEL Model for both subject and simulator # --simulator-prompt-file File containing the simulator system prompt # --decision-reply TEXT Deterministic reply for each detected decision point +# --decision-reply-step TEXT Queue one deterministic reply for the next decision point # --continue-prompt TEXT Nudge used when the workflow should continue # --decision-pattern TEXT Extra case-insensitive substring to treat as a decision point # --fresh-resume-marker TEXT Start the following turn in a new subject session @@ -37,6 +38,7 @@ MAX_TURNS=12 MODEL="${ANTHROPIC_MODEL:-}" SIMULATOR_PROMPT="" DECISION_REPLY="" +DECISION_REPLY_STEPS=() CONTINUE_PROMPT="Please continue with the next phase of the comet workflow." FRESH_RESUME_MARKER="" DECISION_PATTERNS=() @@ -50,10 +52,22 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --simulator-prompt-file) - SIMULATOR_PROMPT="$(cat "$2")" + SIMULATOR_PROMPT_FILE="$2" + if ! SIMULATOR_PROMPT="$(cat -- "$SIMULATOR_PROMPT_FILE")"; then + echo "[loop] unable to read private simulator prompt" >&2 + exit 2 + fi + if ! rm -f -- "$SIMULATOR_PROMPT_FILE"; then + echo "[loop] unable to remove private simulator prompt" >&2 + exit 2 + fi shift 2 ;; --decision-reply) DECISION_REPLY="$2"; shift 2 ;; + --decision-reply-step) + DECISION_REPLY_STEPS+=("$2") + shift 2 + ;; --continue-prompt) CONTINUE_PROMPT="$2"; shift 2 ;; --fresh-resume-marker) FRESH_RESUME_MARKER="$2"; shift 2 ;; --decision-pattern) @@ -107,6 +121,7 @@ SESSION_ID="" FRESH_PROMPT="" COMBINED_OUT="" TURN=0 +DECISION_REPLY_STEP_INDEX=0 while [[ $TURN -lt $MAX_TURNS ]]; do TURN=$((TURN + 1)) @@ -187,7 +202,15 @@ except: print('') if bash "$SCRIPT_DIR/decision-point.sh" "$RESULT_TEXT" "${DECISION_PATTERNS[@]}"; then echo "[loop] decision point detected; simulating user reply" >&2 - if [[ -n "$DECISION_REPLY" ]]; then + if [[ ${#DECISION_REPLY_STEPS[@]} -gt 0 ]]; then + if [[ $DECISION_REPLY_STEP_INDEX -ge ${#DECISION_REPLY_STEPS[@]} ]]; then + echo "[loop] deterministic decision reply queue exhausted" >&2 + exit 3 + fi + USER_REPLY="${DECISION_REPLY_STEPS[$DECISION_REPLY_STEP_INDEX]}" + DECISION_REPLY_STEP_INDEX=$((DECISION_REPLY_STEP_INDEX + 1)) + echo "[loop] deterministic decision reply applied" >&2 + elif [[ -n "$DECISION_REPLY" ]]; then USER_REPLY="$DECISION_REPLY" echo "[loop] deterministic decision reply applied" >&2 else diff --git a/test/app/cli-smoke.test.ts b/test/app/cli-smoke.test.ts index c73ba470..d95ded80 100644 --- a/test/app/cli-smoke.test.ts +++ b/test/app/cli-smoke.test.ts @@ -83,8 +83,8 @@ describe('built CLI smoke', () => { projectRoot, '--scope', 'global', - '--workflow', - 'native', + '--root', + 'docs', '--language', 'en', '--json', @@ -93,9 +93,9 @@ describe('built CLI smoke', () => { expect(result.status).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ status: 'failed', - error: expect.stringContaining('only valid for project-scope initialization'), + error: expect.stringContaining('--root is only valid for project-scope initialization'), }); - expect(result.stderr).toContain('only valid for project-scope initialization'); + expect(result.stderr).toContain('--root is only valid for project-scope initialization'); expect(result.stderr).not.toContain('at initCommand'); }); diff --git a/test/app/init-e2e.test.ts b/test/app/init-e2e.test.ts index 4a96124f..c5255ed1 100644 --- a/test/app/init-e2e.test.ts +++ b/test/app/init-e2e.test.ts @@ -655,22 +655,101 @@ describe('comet init E2E', () => { expect(platformSelectPrompt).not.toHaveBeenCalled(); }); - it.each([{ workflow: 'native' as const }, { artifactRoot: 'docs' }])( - 'rejects project workflow options at global scope without writes', - async (selection) => { + it('rejects project artifact roots at global scope without writes', async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + + const { initCommand } = await import('../../app/commands/init.js'); + await expect( + initCommand(tmpDir, { + yes: true, + json: true, + scope: 'global', + artifactRoot: 'docs', + }), + ).rejects.toThrow(/--root is only valid for project-scope initialization/u); + + await expect(fs.access(path.join(os.homedir(), '.comet'))).rejects.toThrow(); + await expect(fs.access(path.join(os.homedir(), '.claude'))).rejects.toThrow(); + await expect(fs.access(path.join(tmpDir, '.comet', 'config.yaml'))).rejects.toThrow(); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + }); + + it( + 'initializes both Native and Classic skills at global scope when explicitly selected', + async () => { mockExternalSuccess(); await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(fakeHome, { recursive: true }); const { initCommand } = await import('../../app/commands/init.js'); - await expect( - initCommand(tmpDir, { yes: true, json: true, scope: 'global', ...selection }), - ).rejects.toThrow(/only valid for project-scope initialization/u); + const result = await captureJsonOutput(() => + initCommand(tmpDir, { + yes: true, + json: true, + scope: 'global', + workflow: 'both', + language: 'en', + }), + ); - await expect(fs.access(path.join(os.homedir(), '.comet'))).rejects.toThrow(); - await expect(fs.access(path.join(os.homedir(), '.claude'))).rejects.toThrow(); - await expect(fs.access(path.join(tmpDir, '.comet', 'config.yaml'))).rejects.toThrow(); - expect(mockedExecFileSync).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + scope: 'global', + workflow: 'native', + initializedWorkflows: ['native', 'classic'], + workingDirsCreated: false, + }); + for (const skill of ['comet-native', 'comet-classic']) { + await expect( + fs.access(path.join(fakeHome, '.claude', 'skills', skill, 'SKILL.md')), + ).resolves.toBeUndefined(); + } + }, + INIT_E2E_TIMEOUT_MS, + ); + + it( + 'offers Native, Classic, and Both during interactive global initialization', + async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.codex'), { recursive: true }); + const fakeHome = path.join(tmpDir, 'fake-home'); + await fs.mkdir(fakeHome, { recursive: true }); + + const { checkbox, select } = await import('@inquirer/prompts'); + const { platformSelectPrompt } = await import('../../app/commands/platform-select-prompt.js'); + vi.mocked(select).mockResolvedValueOnce('both').mockResolvedValueOnce('copy'); + vi.mocked(platformSelectPrompt).mockResolvedValue(['codex']); + vi.mocked(checkbox).mockResolvedValue([]); + + const { initCommand } = await import('../../app/commands/init.js'); + await captureTextOutput(() => + initCommand(tmpDir, { + scope: 'global', + language: 'en', + }), + ); + + expect(select).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: 'Select Comet workflow(s):', + choices: [ + expect.objectContaining({ value: 'native' }), + expect.objectContaining({ value: 'classic' }), + expect.objectContaining({ value: 'both' }), + ], + default: 'classic', + }), + ); + for (const skill of ['comet-native', 'comet-classic']) { + await expect( + fs.access(path.join(fakeHome, '.agents', 'skills', skill, 'SKILL.md')), + ).resolves.toBeUndefined(); + } }, + INIT_E2E_TIMEOUT_MS, ); it('leaves project workflow state untouched when every Comet asset copy fails', async () => { diff --git a/test/domains/comet-entry/current-selection.test.ts b/test/domains/comet-entry/current-selection.test.ts index 2f8e6924..5bb31105 100644 --- a/test/domains/comet-entry/current-selection.test.ts +++ b/test/domains/comet-entry/current-selection.test.ts @@ -105,24 +105,27 @@ describe('shared Comet current selection', () => { await expect(readCometCurrentSelection(root)).rejects.toThrow('regular file'); }); - it('rejects a symlink at the selection path instead of following it', async () => { - const outside = path.join(root, 'outside-secret.json'); - await fs.writeFile( - outside, - JSON.stringify({ - schema: 'comet.selection.v2', - workflow: 'native', - change: 'not-the-real-selection', - branch: null, - }), - ); + it.skipIf(process.platform === 'win32')( + 'rejects a symlink at the selection path instead of following it', + async () => { + const outside = path.join(root, 'outside-secret.json'); + await fs.writeFile( + outside, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'not-the-real-selection', + branch: null, + }), + ); - const file = cometCurrentSelectionFile(root); - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.symlink(outside, file); + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.symlink(outside, file); - await expect(readCometCurrentSelection(root)).rejects.toThrow(/regular file|symbolic link/); - }); + await expect(readCometCurrentSelection(root)).rejects.toThrow(/regular file|symbolic link/); + }, + ); it.skipIf(process.platform === 'win32')( 'rejects a FIFO at the selection path without blocking on open', @@ -184,42 +187,45 @@ describe('shared Comet current selection', () => { } }); - it('does not follow a symlink swapped in after the regular-file check', async () => { - const file = cometCurrentSelectionFile(root); - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile( - file, - JSON.stringify({ - schema: 'comet.selection.v2', - workflow: 'native', - change: 'small-before-swap', - branch: null, - }), - ); - - const outside = path.join(root, 'outside-secret.json'); - await fs.writeFile( - outside, - JSON.stringify({ - schema: 'comet.selection.v2', - workflow: 'native', - change: 'read-through-symlink', - branch: null, - }), - ); - - const pending = readCometCurrentSelection(root); - unlinkSync(file); - symlinkSync(outside, file); - - const result = await pending.catch((error: unknown) => error as Error); - if (result instanceof Error) { - expect(result.message).toMatch(/regular file|symbolic link|changed/); - } else { - expect(result).not.toMatchObject({ - status: 'selected', - selection: { change: 'read-through-symlink' }, - }); - } - }); + it.skipIf(process.platform === 'win32')( + 'does not follow a symlink swapped in after the regular-file check', + async () => { + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'small-before-swap', + branch: null, + }), + ); + + const outside = path.join(root, 'outside-secret.json'); + await fs.writeFile( + outside, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'read-through-symlink', + branch: null, + }), + ); + + const pending = readCometCurrentSelection(root); + unlinkSync(file); + symlinkSync(outside, file); + + const result = await pending.catch((error: unknown) => error as Error); + if (result instanceof Error) { + expect(result.message).toMatch(/regular file|symbolic link|changed/); + } else { + expect(result).not.toMatchObject({ + status: 'selected', + selection: { change: 'read-through-symlink' }, + }); + } + }, + ); }); diff --git a/test/domains/comet-entry/project-status.test.ts b/test/domains/comet-entry/project-status.test.ts index 264ffb28..02228bb4 100644 --- a/test/domains/comet-entry/project-status.test.ts +++ b/test/domains/comet-entry/project-status.test.ts @@ -91,7 +91,7 @@ describe('Comet project status', () => { { name: 'native-only', phase: 'shape', - nextCommand: 'comet native next native-only --summary ""', + nextCommand: 'comet native next native-only --summary "" --confirmed', }, ], }, @@ -99,6 +99,27 @@ describe('Comet project status', () => { }, unmanagedOpenSpec: [], }); + + await writeProjectConfig(projectRoot, { + ...defaultProjectConfig('.'), + native: { + ...defaultProjectConfig('.').native, + clarification_mode: 'batch', + }, + }); + await expect(inspectCometProjectStatus(projectRoot)).resolves.toMatchObject({ + workflows: { + native: { + changes: [ + { + name: 'native-only', + phase: 'shape', + nextCommand: 'comet native next native-only --summary "" --confirmed', + }, + ], + }, + }, + }); }); it('keeps plain OpenSpec changes outside both Comet workflows', async () => { diff --git a/test/domains/comet-entry/resume-probe.test.ts b/test/domains/comet-entry/resume-probe.test.ts index d379e012..39aab601 100644 --- a/test/domains/comet-entry/resume-probe.test.ts +++ b/test/domains/comet-entry/resume-probe.test.ts @@ -353,7 +353,7 @@ describe('Comet entry resume probe v2', () => { const advanced = await advanceNativeChange({ paths, name: 'phase-routing', - evidence: { summary: 'Shape is complete.' }, + evidence: { summary: 'Shape is complete.', confirmed: true }, }); expect(advanced.findings).toEqual([]); } else if (phase === 'build') { diff --git a/test/domains/comet-native/native-build-evidence.test.ts b/test/domains/comet-native/native-build-evidence.test.ts index 9f56a334..c915160e 100644 --- a/test/domains/comet-native/native-build-evidence.test.ts +++ b/test/domains/comet-native/native-build-evidence.test.ts @@ -34,15 +34,15 @@ vi.mock('../../../domains/comet-native/native-snapshot.js', async (importOrigina await importOriginal(); return { ...actual, - createNativeContentSnapshot: async ( - ...args: Parameters + createNativeCurrentContentSnapshot: async ( + ...args: Parameters ) => { if (snapshotMock.next !== null) { const next = snapshotMock.next; snapshotMock.next = null; return next; } - return actual.createNativeContentSnapshot(...args); + return actual.createNativeCurrentContentSnapshot(...args); }, filterNativeContentSnapshotToProjectScope: async ( ...args: Parameters diff --git a/test/domains/comet-native/native-change.test.ts b/test/domains/comet-native/native-change.test.ts index 21f25b43..f726a3de 100644 --- a/test/domains/comet-native/native-change.test.ts +++ b/test/domains/comet-native/native-change.test.ts @@ -4,6 +4,10 @@ import os from 'os'; import path from 'path'; import { stringify } from 'yaml'; +import { + defaultProjectConfig, + writeProjectConfig, +} from '../../../domains/comet-native/native-config.js'; import { compareAndSwapNativeChange, createNativeChange, @@ -73,6 +77,9 @@ describe('Native change store', () => { }); it('fails at change creation when the baseline snapshot is incomplete', async () => { + const config = defaultProjectConfig('.'); + config.native.snapshot.max_total_bytes = 5 * 1024 * 1024; + await writeProjectConfig(projectRoot, config); await fs.writeFile( path.join(projectRoot, 'oversized-baseline.bin'), Buffer.alloc(5 * 1024 * 1024 + 1, 0x61), @@ -92,6 +99,72 @@ describe('Native change store', () => { ).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('creates a complete baseline for a file larger than the legacy per-file limit', async () => { + await writeProjectConfig(projectRoot, defaultProjectConfig('.')); + await fs.writeFile( + path.join(projectRoot, 'large-baseline.bin'), + Buffer.alloc(5 * 1024 * 1024 + 1, 0x61), + ); + + const state = await createNativeChange({ + paths, + name: 'large-baseline', + language: 'en', + }); + + expect(await readNativeBaselineManifest(paths, state.name)).toMatchObject({ + complete: true, + policy: { + schema: 'comet.native.snapshot-policy.v1', + include: ['**/*'], + exclude: [], + }, + entries: [ + expect.objectContaining({ + path: 'large-baseline.bin', + size: 5 * 1024 * 1024 + 1, + }), + ], + }); + }); + + it('creates a complete baseline after the project raises the total snapshot budget', async () => { + const config = defaultProjectConfig('.'); + config.native.snapshot.max_total_bytes = 1024; + await writeProjectConfig(projectRoot, config); + await fs.writeFile(path.join(projectRoot, 'dataset.bin'), Buffer.alloc(1025, 0x61)); + + await expect( + createNativeChange({ paths, name: 'budget-too-small', language: 'en' }), + ).rejects.toMatchObject({ + name: 'NativeBaselineIncompleteError', + effectiveLimits: expect.objectContaining({ + maxFileBytes: 1024, + maxTotalBytes: 1024, + }), + }); + + config.native.snapshot.max_total_bytes = 2048; + await writeProjectConfig(projectRoot, config); + const state = await createNativeChange({ + paths, + name: 'budget-raised', + language: 'en', + }); + + expect(await readNativeBaselineManifest(paths, state.name)).toMatchObject({ + complete: true, + limits: { + maxFiles: 10_000, + maxFileBytes: 2048, + maxTotalBytes: 2048, + maxManifestBytes: expect.any(Number), + maxDurationMs: 60_000, + }, + entries: [expect.objectContaining({ path: 'dataset.bin', size: 1025 })], + }); + }); + it('reads older v3 state without an approval hash and canonicalizes it to null', async () => { const state = await createNativeChange({ paths, name: 'legacy-v3-state', language: 'en' }); const file = path.join(paths.changesDir, state.name, 'comet-state.yaml'); diff --git a/test/domains/comet-native/native-check.test.ts b/test/domains/comet-native/native-check.test.ts index a98430ec..7d6fae7d 100644 --- a/test/domains/comet-native/native-check.test.ts +++ b/test/domains/comet-native/native-check.test.ts @@ -75,6 +75,7 @@ describe('Native check public seam', () => { changeName, '--summary', 'Requirements are ready', + '--confirmed', '--json', ...projectArgs(), ]); diff --git a/test/domains/comet-native/native-cli.test.ts b/test/domains/comet-native/native-cli.test.ts index e3167625..3d9a9386 100644 --- a/test/domains/comet-native/native-cli.test.ts +++ b/test/domains/comet-native/native-cli.test.ts @@ -9,6 +9,11 @@ import { parseNativeVerificationMachineBlock, } from '../../../domains/comet-native/native-acceptance.js'; import { runNativeCli } from '../../../domains/comet-native/native-cli.js'; +import { + defaultProjectConfig, + readProjectConfig, + writeProjectConfig, +} from '../../../domains/comet-native/native-config.js'; import { NATIVE_CONTRACT_FILE_LIMITS } from '../../../domains/comet-native/native-contract-files.js'; import { acquireNativeLock, releaseNativeLock } from '../../../domains/comet-native/native-lock.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; @@ -77,6 +82,9 @@ describe('Comet Native CLI dispatcher', () => { }); it('returns structured baseline diagnostics when change creation cannot capture a complete baseline', async () => { + const config = defaultProjectConfig('.'); + config.native.snapshot.max_total_bytes = 5 * 1024 * 1024; + await writeProjectConfig(projectRoot, config); await fs.writeFile( path.join(projectRoot, 'oversized-baseline.bin'), Buffer.alloc(5 * 1024 * 1024 + 1, 0x61), @@ -177,7 +185,7 @@ describe('Comet Native CLI dispatcher', () => { json(await runNativeCli(['status', 'sentence-counting', '--json', ...projectArgs()])).data, ).toMatchObject({ phase: 'shape', - nextCommand: 'comet native next sentence-counting --summary ""', + nextCommand: 'comet native next sentence-counting --summary "" --confirmed', }); expect(await runNativeCli(['select', 'sentence-counting', ...projectArgs()])).toMatchObject({ exitCode: 0, @@ -189,6 +197,7 @@ describe('Comet Native CLI dispatcher', () => { 'sentence-counting', '--summary', 'Requirements are clear', + '--confirmed', '--json', ...projectArgs(), ]), @@ -319,6 +328,7 @@ Pass. 'paged-acceptance', '--summary', 'The acceptance contract is executable', + '--confirmed', ...projectArgs(), ]) ).exitCode, @@ -513,6 +523,103 @@ Pass. }); }); + it('enforces shared-understanding confirmation in Sequential and Batch modes', async () => { + await runNativeCli(['init', '--root', 'docs', ...projectArgs()]); + await runNativeCli(['new', 'mode-boundary', ...projectArgs()]); + const paths = await nativeProjectPaths(projectRoot, 'docs'); + const changeDir = path.join(paths.changesDir, 'mode-boundary'); + await fs.writeFile(path.join(changeDir, 'brief.md'), brief); + + const blocked = json( + await runNativeCli([ + 'next', + 'mode-boundary', + '--summary', + 'Sequential clarification is complete', + '--json', + ...projectArgs(), + ]), + ); + expect(blocked).toMatchObject({ + exitCode: 65, + data: { + next: 'manual', + change: { phase: 'shape', approval: null }, + findings: [ + expect.objectContaining({ + code: 'shape-confirmation-required', + retryCommand: 'comet native next mode-boundary --summary "" --confirmed', + }), + ], + }, + }); + const sequentialStatus = json( + await runNativeCli(['status', 'mode-boundary', '--json', ...projectArgs()]), + ); + expect(sequentialStatus).toMatchObject({ + data: { + nextCommand: 'comet native next mode-boundary --summary "" --confirmed', + continuation: { + command: 'comet native next mode-boundary --summary "" --confirmed', + requiredInputs: ['summary', 'shared-understanding-confirmation'], + }, + }, + }); + + const config = await readProjectConfig(projectRoot); + expect(config).not.toBeNull(); + await writeProjectConfig(projectRoot, { + ...config!, + native: { ...config!.native, clarification_mode: 'batch' }, + }); + const batchStatus = json( + await runNativeCli(['status', 'mode-boundary', '--json', ...projectArgs()]), + ); + expect(batchStatus).toMatchObject({ + data: { + nextCommand: 'comet native next mode-boundary --summary "" --confirmed', + continuation: { + command: 'comet native next mode-boundary --summary "" --confirmed', + requiredInputs: ['summary', 'shared-understanding-confirmation'], + }, + }, + }); + + const batchBlocked = json( + await runNativeCli([ + 'next', + 'mode-boundary', + '--summary', + 'Batch clarification is complete', + '--json', + ...projectArgs(), + ]), + ); + expect(batchBlocked).toMatchObject({ + exitCode: 65, + data: { + change: { phase: 'shape', approval: null }, + findings: [expect.objectContaining({ code: 'shape-confirmation-required' })], + }, + }); + + const advanced = json( + await runNativeCli([ + 'next', + 'mode-boundary', + '--summary', + 'Batch shared understanding is confirmed', + '--confirmed', + '--json', + ...projectArgs(), + ]), + ); + expect(advanced).toMatchObject({ + exitCode: 0, + data: { change: { phase: 'build', approval: 'confirmed' } }, + }); + }); + it('records a remove intent and canonical hash through the spec command', async () => { await runNativeCli(['new', 'remove-capability', ...projectArgs()]); const paths = await nativeProjectPaths(projectRoot, 'docs'); @@ -801,24 +908,27 @@ Pass. }, ); - it('rejects a symlink entries source instead of following it', async () => { - const target = path.join(projectRoot, 'entries-target.json'); - await fs.writeFile(target, JSON.stringify([])); - const linkPath = path.join(projectRoot, 'entries-link.json'); - await fs.symlink(target, linkPath); + it.skipIf(process.platform === 'win32')( + 'rejects a symlink entries source instead of following it', + async () => { + const target = path.join(projectRoot, 'entries-target.json'); + await fs.writeFile(target, JSON.stringify([])); + const linkPath = path.join(projectRoot, 'entries-link.json'); + await fs.symlink(target, linkPath); - const result = await runNativeCli([ - 'evidence', - 'format', - '--entries', - linkPath, - '--json', - ...projectArgs(), - ]); + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + linkPath, + '--json', + ...projectArgs(), + ]); - expect(result.exitCode).toBe(65); - expect(json(result)).toMatchObject({ - error: { code: 'invalid-data', message: expect.stringContaining('not a regular file') }, - }); - }); + expect(result.exitCode).toBe(65); + expect(json(result)).toMatchObject({ + error: { code: 'invalid-data', message: expect.stringContaining('not a regular file') }, + }); + }, + ); }); diff --git a/test/domains/comet-native/native-config.test.ts b/test/domains/comet-native/native-config.test.ts index 02572d0e..02efc211 100644 --- a/test/domains/comet-native/native-config.test.ts +++ b/test/domains/comet-native/native-config.test.ts @@ -26,6 +26,13 @@ describe('Native project configuration', () => { it('builds the shared default project config with docs as the Native artifact root', () => { expect(defaultProjectConfig().native.artifact_root).toBe('docs'); expect(defaultProjectConfig().native.clarification_mode).toBe('sequential'); + expect(defaultProjectConfig().native.snapshot).toEqual({ + include: ['**/*'], + exclude: [], + max_files: 10_000, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 60_000, + }); }); it('round-trips a custom artifact root with stable YAML fields', async () => { @@ -40,13 +47,25 @@ describe('Native project configuration', () => { artifact_root: 'docs', language: 'en', clarification_mode: 'sequential', + snapshot: { + include: ['**/*'], + exclude: [], + max_files: 10_000, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 60_000, + }, }, }); const source = await fs.readFile(path.join(projectRoot, '.comet', 'config.yaml'), 'utf8'); expect(source).toContain('# Enables automatic recovery'); expect(source).toContain('# Controls whether Native asks one clarification at a time'); + expect(source).toContain('# Selects the project-relative paths included in Native snapshots'); + expect(source).toContain('# Bounds the total file content hashed by one snapshot'); expect(source).toContain('ambient_resume: true'); expect(source).toContain('clarification_mode: sequential'); + expect(source).toContain('include:'); + expect(source).toContain('- "**/*"'); + expect(source).toContain('max_total_bytes: 268435456'); }); it('reads an older project config with the missing Native defaults', async () => { @@ -57,6 +76,9 @@ describe('Native project configuration', () => { expect((await readProjectConfig(projectRoot))?.native.language).toBe('en'); expect((await readProjectConfig(projectRoot))?.native.clarification_mode).toBe('sequential'); + expect((await readProjectConfig(projectRoot))?.native.snapshot).toEqual( + defaultProjectConfig().native.snapshot, + ); expect((await readProjectConfig(projectRoot))?.ambient_resume).toBe(true); }); @@ -79,9 +101,72 @@ describe('Native project configuration', () => { expect(source).toContain('# 是否启用只读的环境感知恢复探针'); expect(source).toContain('# Native 产物的存放根目录'); expect(source).toContain('# Native 每轮询问一个问题'); + expect(source).toContain('# Native 快照纳入的项目相对路径'); + expect(source).toContain('# 单次快照最多哈希的文件内容总字节数'); expect(source).not.toContain('# Enables automatic recovery'); }); + it('rejects unsafe snapshot patterns', async () => { + await fs.writeFile( + path.join(projectRoot, '.comet', 'config.yaml'), + [ + 'schema: comet.project.v1', + 'default_workflow: native', + 'native:', + ' artifact_root: docs', + ' snapshot:', + ' include: ["../outside/**"]', + ' exclude: []', + '', + ].join('\n'), + ); + + await expect(readProjectConfig(projectRoot)).rejects.toThrow( + 'native.snapshot.include contains an unsafe pattern', + ); + }); + + it.each([ + ['max_files', 0], + ['max_total_bytes', 0], + ['max_duration_ms', 0], + ])('rejects invalid snapshot budget %s', async (field, value) => { + await fs.writeFile( + path.join(projectRoot, '.comet', 'config.yaml'), + [ + 'schema: comet.project.v1', + 'default_workflow: native', + 'native:', + ' artifact_root: docs', + ' snapshot:', + ` ${field}: ${value}`, + '', + ].join('\n'), + ); + + await expect(readProjectConfig(projectRoot)).rejects.toThrow(`native.snapshot.${field}`); + }); + + it.each([ + ['a'.repeat(1025), 'exceeds 1024 characters'], + ['*a'.repeat(65), 'contains more than 64 wildcard tokens'], + ])('rejects overly complex snapshot pattern', async (pattern, expected) => { + await fs.writeFile( + path.join(projectRoot, '.comet', 'config.yaml'), + [ + 'schema: comet.project.v1', + 'default_workflow: native', + 'native:', + ' artifact_root: docs', + ' snapshot:', + ` include: ["${pattern}"]`, + '', + ].join('\n'), + ); + + await expect(readProjectConfig(projectRoot)).rejects.toThrow(expected); + }); + it('rejects a non-boolean Ambient Resume setting', async () => { await fs.writeFile( path.join(projectRoot, '.comet', 'config.yaml'), diff --git a/test/domains/comet-native/native-confirmed-transition-helper.test.ts b/test/domains/comet-native/native-confirmed-transition-helper.test.ts new file mode 100644 index 00000000..16a0f187 --- /dev/null +++ b/test/domains/comet-native/native-confirmed-transition-helper.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + readNativeChange: vi.fn(), + advanceRuntimeNativeChange: vi.fn(), +})); + +vi.mock('../../../domains/comet-native/native-change.js', () => ({ + readNativeChange: mocks.readNativeChange, +})); + +vi.mock('../../../domains/comet-native/native-transitions.js', () => ({ + advanceNativeChange: mocks.advanceRuntimeNativeChange, +})); + +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; + +describe('advanceNativeChange test helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('falls back to runtime creation only when the change is missing', async () => { + const missing = Object.assign(new Error('missing change'), { + code: 'ENOENT', + }); + const created = { phase: 'build' }; + mocks.readNativeChange.mockRejectedValue(missing); + mocks.advanceRuntimeNativeChange.mockResolvedValue(created); + + await expect( + advanceNativeChange({ + paths: {} as never, + name: 'missing-change', + evidence: { summary: 'confirmed' }, + }), + ).resolves.toBe(created); + expect(mocks.advanceRuntimeNativeChange).toHaveBeenCalledWith( + expect.objectContaining({ + clarificationMode: 'sequential', + name: 'missing-change', + }), + ); + }); + + it('does not hide malformed state or filesystem failures', async () => { + const failure = Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + mocks.readNativeChange.mockRejectedValue(failure); + + await expect( + advanceNativeChange({ + paths: {} as never, + name: 'unreadable-change', + evidence: { summary: 'confirmed' }, + }), + ).rejects.toBe(failure); + expect(mocks.advanceRuntimeNativeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/test/domains/comet-native/native-diagnostics.test.ts b/test/domains/comet-native/native-diagnostics.test.ts index 3f00b62e..ea4c4144 100644 --- a/test/domains/comet-native/native-diagnostics.test.ts +++ b/test/domains/comet-native/native-diagnostics.test.ts @@ -16,12 +16,12 @@ import { import { nativeContinuation } from '../../../domains/comet-native/native-continuation.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; import { selectNativeChange } from '../../../domains/comet-native/native-selection.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeChangeState, NativeProjectPaths, } from '../../../domains/comet-native/native-types.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const brief = `# Outcome Ship a focused outcome. @@ -127,7 +127,7 @@ describe('Native status diagnostics', () => { expect(statuses[0]).toMatchObject({ phase: 'shape', selected: false, - nextCommand: 'comet native next alpha-change --summary ""', + nextCommand: 'comet native next alpha-change --summary "" --confirmed', }); expect(statuses[1]).toMatchObject({ selected: true }); expect(JSON.stringify(statuses)).not.toMatch(/openspec|superpowers|comet classic/iu); diff --git a/test/domains/comet-native/native-doctor.test.ts b/test/domains/comet-native/native-doctor.test.ts index bff81eed..21779dfa 100644 --- a/test/domains/comet-native/native-doctor.test.ts +++ b/test/domains/comet-native/native-doctor.test.ts @@ -19,12 +19,12 @@ import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.j import { moveNativeRoot } from '../../../domains/comet-native/native-root-move.js'; import { nativeSelectionFile } from '../../../domains/comet-native/native-selection.js'; import { createNativeTransaction } from '../../../domains/comet-native/native-transaction.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeProjectPaths } from '../../../domains/comet-native/native-types.js'; import { nativeWorkspaceFile, readNativeWorkspaceIdentity, } from '../../../domains/comet-native/native-workspace.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const validBrief = `# Outcome Ship the feature. diff --git a/test/domains/comet-native/native-evidence-transitions.test.ts b/test/domains/comet-native/native-evidence-transitions.test.ts index 510346f1..f74f3275 100644 --- a/test/domains/comet-native/native-evidence-transitions.test.ts +++ b/test/domains/comet-native/native-evidence-transitions.test.ts @@ -19,10 +19,10 @@ import { writeNativeRunState, } from '../../../domains/comet-native/native-run-store.js'; import { NATIVE_LEGACY_RUNTIME_IDENTITIES } from '../../../domains/comet-native/native-runtime-package.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeProjectPaths } from '../../../domains/comet-native/native-types.js'; import { inspectNativeVerificationFreshness } from '../../../domains/comet-native/native-verification-runtime.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const brief = `# Outcome Ship evidence-bound behavior. diff --git a/test/domains/comet-native/native-findings.test.ts b/test/domains/comet-native/native-findings.test.ts index b17c431e..d952e150 100644 --- a/test/domains/comet-native/native-findings.test.ts +++ b/test/domains/comet-native/native-findings.test.ts @@ -54,12 +54,16 @@ describe('Native structured findings', () => { expect(findings[1].path).toBe('comet/specs'); }); - it('reserves user-decision pauses for brief blocking questions only', () => { + it('reserves user-decision pauses for explicit clarification decisions', () => { const findings = structureNativeFindings({ paths, state, findings: [ { code: 'brief-blocking-question', message: 'decision needed', path: 'brief.md' }, + { + code: 'shape-confirmation-required', + message: 'shared understanding must be confirmed', + }, { code: 'build-evidence-missing', message: 'model work needed' }, ], }); @@ -67,13 +71,20 @@ describe('Native structured findings', () => { requiredAction: 'answer-blocking-question', requiresUserDecision: true, }); + expect( + findings.find((finding) => finding.code === 'shape-confirmation-required'), + ).toMatchObject({ + requiredAction: 'confirm-shared-understanding', + retryCommand: 'comet native next finding-shape --summary "" --confirmed', + requiresUserDecision: true, + }); expect(findings.find((finding) => finding.code === 'build-evidence-missing')).toMatchObject({ requiredAction: 'record-build-evidence', requiresUserDecision: false, }); expect(summarizeNativeFindings(findings)).toMatchObject({ - total: 2, - errors: 2, + total: 3, + errors: 3, requiresUserDecision: true, truncated: false, }); diff --git a/test/domains/comet-native/native-guards.test.ts b/test/domains/comet-native/native-guards.test.ts index eaed6e72..d463330b 100644 --- a/test/domains/comet-native/native-guards.test.ts +++ b/test/domains/comet-native/native-guards.test.ts @@ -51,11 +51,19 @@ describe('Native phase guards', () => { }); it('blocks incomplete Shape without mutating state', async () => { - const result = await inspectNativeGuard({ paths, state, evidence: { summary: 'ready' } }); + const result = await inspectNativeGuard({ + paths, + state, + evidence: { summary: 'ready' }, + clarificationMode: 'sequential', + }); expect(result.valid).toBe(false); expect(result.findings).toEqual( expect.arrayContaining([expect.objectContaining({ code: 'brief-section-empty' })]), ); + expect(result.findings).not.toContainEqual( + expect.objectContaining({ code: 'shape-confirmation-required' }), + ); }); it('does not let a confirmation flag bypass a blocking decision', async () => { @@ -72,6 +80,7 @@ describe('Native phase guards', () => { paths, state, evidence: { summary: 'ready', confirmed: true }, + clarificationMode: 'sequential', }) ).findings, ).toEqual( @@ -84,6 +93,7 @@ describe('Native phase guards', () => { paths, state, evidence: { summary: 'ready', confirmed: true }, + clarificationMode: 'sequential', }), ).toEqual({ valid: true, @@ -91,23 +101,53 @@ describe('Native phase guards', () => { }); }); + it('requires explicit shared-understanding confirmation in Batch mode', async () => { + await fs.writeFile(path.join(changeDir, 'brief.md'), completeBrief); + const result = await inspectNativeGuard({ + paths, + state, + evidence: { summary: 'batch frontier is complete' }, + clarificationMode: 'batch', + }); + + expect(result).toMatchObject({ + valid: false, + findings: [ + expect.objectContaining({ + code: 'shape-confirmation-required', + message: + 'Native clarification requires explicit user confirmation of the shared understanding before Build', + }), + ], + }); + }); + it('requires a real artifact or a no-code reason in Build', async () => { await fs.writeFile(path.join(changeDir, 'brief.md'), completeBrief); state = ( await advanceNativeChange({ paths, name: state.name, - evidence: { summary: 'shape is ready' }, + evidence: { summary: 'shape is ready', confirmed: true }, + clarificationMode: 'batch', }) ).change; expect( - (await inspectNativeGuard({ paths, state, evidence: { summary: 'built' } })).findings, + ( + await inspectNativeGuard({ + paths, + state, + evidence: { summary: 'built' }, + clarificationMode: 'batch', + }) + ).findings, ).toContainEqual(expect.objectContaining({ code: 'build-evidence-missing' })); expect( await inspectNativeGuard({ paths, state, evidence: { summary: 'docs only', noCodeReason: 'The change only updates documentation.' }, + clarificationMode: 'batch', }), ).toEqual({ valid: true, findings: [] }); }); @@ -118,7 +158,8 @@ describe('Native phase guards', () => { await advanceNativeChange({ paths, name: state.name, - evidence: { summary: 'shape is ready' }, + evidence: { summary: 'shape is ready', confirmed: true }, + clarificationMode: 'batch', }) ).change; await fs.writeFile( @@ -132,6 +173,7 @@ describe('Native phase guards', () => { const result = await inspectNativeGuard({ paths, state, + clarificationMode: 'batch', evidence: { summary: 'implementation paused for the decision', noCodeReason: 'The decision is still unresolved.', diff --git a/test/domains/comet-native/native-lock.test.ts b/test/domains/comet-native/native-lock.test.ts index 5172d379..2117d8c0 100644 --- a/test/domains/comet-native/native-lock.test.ts +++ b/test/domains/comet-native/native-lock.test.ts @@ -61,14 +61,17 @@ describe('Native operation locks', () => { await fs.rm(displaced, { force: true }); }); - it('rejects a symlinked lock file instead of following it', async () => { - const lock = await acquireNativeLock(paths, 'archive', 'archive example'); - const displaced = `${lock.file}.real`; - await fs.rename(lock.file, displaced); - await fs.symlink(displaced, lock.file); + it.skipIf(process.platform === 'win32')( + 'rejects a symlinked lock file instead of following it', + async () => { + const lock = await acquireNativeLock(paths, 'archive', 'archive example'); + const displaced = `${lock.file}.real`; + await fs.rename(lock.file, displaced); + await fs.symlink(displaced, lock.file); - await expect(readNativeLock(lock.file)).rejects.toThrow(/regular file/u); - }); + await expect(readNativeLock(lock.file)).rejects.toThrow(/regular file/u); + }, + ); it.skipIf(process.platform === 'win32')( 'rejects a FIFO at the lock path without blocking on open', diff --git a/test/domains/comet-native/native-phase1-matrix.test.ts b/test/domains/comet-native/native-phase1-matrix.test.ts index 382cec35..e3a7702e 100644 --- a/test/domains/comet-native/native-phase1-matrix.test.ts +++ b/test/domains/comet-native/native-phase1-matrix.test.ts @@ -100,6 +100,7 @@ async function prepareChange(options: { options.name, '--summary', 'Requirements and complete target spec are ready', + '--confirmed', ...rootArgs, ]) ).exitCode, diff --git a/test/domains/comet-native/native-progress-checkpoint.test.ts b/test/domains/comet-native/native-progress-checkpoint.test.ts index 3cb641e6..d2f81119 100644 --- a/test/domains/comet-native/native-progress-checkpoint.test.ts +++ b/test/domains/comet-native/native-progress-checkpoint.test.ts @@ -20,11 +20,11 @@ import { } from '../../../domains/comet-native/native-checkpoint-storage.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; import { checkpointNativeChange } from '../../../domains/comet-native/native-progress-checkpoint.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import { markNativeSpecRemoval } from '../../../domains/comet-native/native-specs.js'; import { doctorNativeProject } from '../../../domains/comet-native/native-doctor.js'; import { inspectNativeStatus } from '../../../domains/comet-native/native-diagnostics.js'; import type { NativeProjectPaths } from '../../../domains/comet-native/native-types.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; describe('Native progress checkpoints', () => { let projectRoot: string; diff --git a/test/domains/comet-native/native-repair-transitions.test.ts b/test/domains/comet-native/native-repair-transitions.test.ts index 34b5e2b7..45e18581 100644 --- a/test/domains/comet-native/native-repair-transitions.test.ts +++ b/test/domains/comet-native/native-repair-transitions.test.ts @@ -15,13 +15,13 @@ import { import { inspectNativeStatus } from '../../../domains/comet-native/native-diagnostics.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; import { inspectNativeRepairHistory } from '../../../domains/comet-native/native-repair-integration.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import { NATIVE_TRAJECTORY_MAX_TEXT_CHARACTERS } from '../../../domains/comet-native/native-trajectory-limits.js'; import type { NativeProjectPaths, NativeTransitionHooks, } from '../../../domains/comet-native/native-types.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const brief = `# Outcome Ship a repairable behavior. diff --git a/test/domains/comet-native/native-root-move.test.ts b/test/domains/comet-native/native-root-move.test.ts index 42faf52f..51d33a6c 100644 --- a/test/domains/comet-native/native-root-move.test.ts +++ b/test/domains/comet-native/native-root-move.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + DEFAULT_NATIVE_SNAPSHOT_CONFIG, readProjectConfig, writeProjectConfig, } from '../../../domains/comet-native/native-config.js'; @@ -67,6 +68,7 @@ describe('Native artifact root moves', () => { artifact_root: to, language: 'en', clarification_mode: 'batch', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }, }); const workspace = await readNativeWorkspaceIdentity(destinationPaths, 'identity-change'); @@ -235,6 +237,7 @@ describe('Native artifact root moves', () => { artifact_root: '.', language: 'en', clarification_mode: 'sequential', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); await expect(fs.access(path.join(projectRoot, 'docs', 'comet'))).rejects.toMatchObject({ code: 'ENOENT', diff --git a/test/domains/comet-native/native-root-recovery.test.ts b/test/domains/comet-native/native-root-recovery.test.ts index d43581d9..7b86e978 100644 --- a/test/domains/comet-native/native-root-recovery.test.ts +++ b/test/domains/comet-native/native-root-recovery.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + DEFAULT_NATIVE_SNAPSHOT_CONFIG, readProjectConfig, resolveNativeProject, writeProjectConfig, @@ -78,6 +79,7 @@ describe('Native artifact root recovery', () => { artifact_root: 'docs', language: 'en', clarification_mode: 'batch', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); await expect(fs.access(source)).rejects.toMatchObject({ code: 'ENOENT' }); const destinationPaths = await nativeProjectPaths(projectRoot, 'docs'); @@ -112,6 +114,7 @@ describe('Native artifact root recovery', () => { artifact_root: '.', language: 'en', clarification_mode: 'batch', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); expect( (await readNativeTransaction(await nativeProjectPaths(projectRoot, '.'), transactionId)) @@ -192,6 +195,7 @@ describe('Native artifact root recovery', () => { artifact_root: 'docs', language: 'en', clarification_mode: 'sequential', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); }); @@ -222,6 +226,7 @@ describe('Native artifact root recovery', () => { artifact_root: 'docs', language: 'en', clarification_mode: 'sequential', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); await expect(fs.access(quarantine)).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -309,6 +314,7 @@ describe('Native artifact root recovery', () => { artifact_root: '.', language: 'en', clarification_mode: 'sequential', + snapshot: DEFAULT_NATIVE_SNAPSHOT_CONFIG, }); await expect(fs.access(quarantine)).rejects.toMatchObject({ code: 'ENOENT' }); }); diff --git a/test/domains/comet-native/native-schema-migration.test.ts b/test/domains/comet-native/native-schema-migration.test.ts index db70791a..f87c0c33 100644 --- a/test/domains/comet-native/native-schema-migration.test.ts +++ b/test/domains/comet-native/native-schema-migration.test.ts @@ -35,7 +35,6 @@ import { nativeBaselineManifestFile, readNativeBaselineManifest, } from '../../../domains/comet-native/native-snapshot.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import { NATIVE_CHANGE_SCHEMA, NATIVE_LEGACY_CHANGE_SCHEMA, @@ -46,6 +45,7 @@ import { type NativeProjectPaths, type NativeSchemaMigrationHooks, } from '../../../domains/comet-native/native-types.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; function legacyDocument(state: NativeChangeState): Record { diff --git a/test/domains/comet-native/native-skill.test.ts b/test/domains/comet-native/native-skill.test.ts index b2c6e069..a73b0210 100644 --- a/test/domains/comet-native/native-skill.test.ts +++ b/test/domains/comet-native/native-skill.test.ts @@ -32,10 +32,21 @@ describe('Comet Native Skills', () => { '同一决定的某种合理解释', '一次只问最上游的一个问题', '问题 / 推荐 / 影响', - '不增加通用的最终确认', + '把当前目标视为一棵用户可见结果的决策树', + '逐一遍历所有合理可达的用户可见分支', + '不能在得到第一个可行解释后停止', + '每轮只询问这一个用户决定', + '立即把确认内容写入 Decisions 和完整目标规格', + '再从目标开始重新遍历整棵决策树', + '仍不能直接进入 Build', + '主动寻找被遗漏或静默假设的用户可见分支', '本轮可回答问题集', + '用户可见结果的决策树', + '组成当前 frontier', '前置决定都已确定', '答案不依赖本轮其他问题', + '其他 frontier 问题必须在当前轮继续提出', + '必须并行启动相互独立的事实调查', '一次提出整组问题', '- [blocking] Q1: <问题>', '不要使用 Markdown 有序列表', @@ -69,10 +80,21 @@ describe('Comet Native Skills', () => { 'reasonable interpretation of that same decision', 'Ask only the most upstream question', 'Question / Recommendation / Impact', - 'without adding a generic final confirmation', + 'Treat the current goal as a decision tree of user-visible results', + 'Traverse every reasonably reachable user-visible branch', + 'Do not stop after finding the first workable interpretation', + 'each round asks exactly this one user decision', + 'immediately write the confirmed content into Decisions and the complete target specifications', + 'traverse the entire decision tree again from the goal', + 'do not enter Build directly', + 'actively look for omitted or silently assumed user-visible branches', 'ready question set', + 'decision tree of user-visible results', + 'form the current frontier', 'all prerequisite decisions settled', 'does not depend on another question in the same round', + 'other frontier questions must still be asked in the current round', + 'must start independent fact investigations in parallel', 'Ask the entire set together', '- [blocking] Q1: ', 'Do not replace this prefix with a Markdown ordered list', @@ -181,16 +203,14 @@ describe('Comet Native Skills', () => { const zh = await read('zh', 'SKILL.md'); expect(zh).toContain('能从环境取得的事实不要询问用户'); expect(zh).toContain('实现方式、是否保存计划、测试粒度、调试方法和审查强度由你根据风险决定'); - expect(zh).toContain('Sequential 模式直接继续;Batch 模式先完成最终共享理解确认'); + expect(zh).toContain('两种模式都先完成最终共享理解确认'); expect(zh).toContain('Batch 模式需要重新计算问题集'); expect(zh).toContain('用户明确给出的 lowercase kebab-case capability ID 必须原样'); const en = await read('en', 'SKILL.md'); expect(en).toContain('Do not ask the user for facts available from the environment'); expect(en).toContain('Decide implementation details'); - expect(en).toContain( - 'Sequential mode continues directly. Batch mode first completes its final shared-understanding confirmation', - ); + expect(en).toContain('both modes first complete the final shared-understanding confirmation'); expect(en).toContain('Batch mode must recompute the ready question set'); expect(en).toContain('Preserve a user-provided lowercase kebab-case capability ID exactly'); }); diff --git a/test/domains/comet-native/native-snapshot.test.ts b/test/domains/comet-native/native-snapshot.test.ts index b6e4e7da..b53127d9 100644 --- a/test/domains/comet-native/native-snapshot.test.ts +++ b/test/domains/comet-native/native-snapshot.test.ts @@ -13,6 +13,7 @@ import { import { sha256Text } from '../../../domains/comet-native/native-hash.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; import { + compileNativeSnapshotPattern, createNativeContentSnapshot, filterNativeContentSnapshotToProjectScope, inspectNativeContentSnapshotHealth, @@ -1457,6 +1458,102 @@ describe('Native VCS-independent content snapshots', () => { expect(manifest.omittedCount).toBe(2); }); + it('accepts an exact total-byte boundary and omits the first byte beyond it', async () => { + await Promise.all([ + fs.writeFile(path.join(projectRoot, 'a.bin'), Buffer.alloc(4, 0x61)), + fs.writeFile(path.join(projectRoot, 'b.bin'), Buffer.alloc(1, 0x62)), + ]); + + const exact = await createNativeContentSnapshot(paths, { + limits: { maxFileBytes: 5, maxTotalBytes: 5 }, + }); + const exceeded = await createNativeContentSnapshot(paths, { + limits: { maxFileBytes: 5, maxTotalBytes: 4 }, + }); + + expect(exact).toMatchObject({ + complete: true, + omittedCount: 0, + }); + expect(exact.entries.map((entry) => [entry.path, entry.size])).toEqual([ + ['a.bin', 4], + ['b.bin', 1], + ]); + expect(exceeded).toMatchObject({ + complete: false, + omittedCount: 1, + omitted: [expect.objectContaining({ path: 'b.bin', reason: 'total-size', size: 1 })], + }); + }); + + it('applies an auditable snapshot policy without treating excluded files as omissions', async () => { + await Promise.all([ + fs.mkdir(path.join(projectRoot, 'src'), { recursive: true }), + fs.mkdir(path.join(projectRoot, 'data'), { recursive: true }), + ]); + await Promise.all([ + fs.writeFile(path.join(projectRoot, 'src', 'app.ts'), 'export {};\n'), + fs.writeFile(path.join(projectRoot, 'data', 'dataset.bin'), Buffer.alloc(1024, 0x61)), + ]); + + const manifest = await createNativeContentSnapshot(paths, { + policy: { + include: ['**/*'], + exclude: ['data/**'], + }, + limits: { + maxFileBytes: 2048, + maxTotalBytes: 2048, + maxDurationMs: 60_000, + }, + }); + + expect(manifest.complete).toBe(true); + expect(manifest.entries.map((entry) => entry.path)).toEqual(['src/app.ts']); + expect(manifest.omitted).toEqual([]); + expect(manifest.policy).toMatchObject({ + schema: 'comet.native.snapshot-policy.v1', + include: ['**/*'], + exclude: ['data/**'], + hash: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + }); + + it('matches snapshot globs without catastrophic backtracking', () => { + const matcher = compileNativeSnapshotPattern(`${'**/'.repeat(8)}target.ts`); + const startedAt = performance.now(); + + expect(matcher(`${'nested/'.repeat(80)}missing.ts`)).toBe(false); + expect(performance.now() - startedAt).toBeLessThan(100); + }); + + it('stops snapshot glob matching cooperatively when its budget expires', () => { + const matcher = compileNativeSnapshotPattern('**') as ( + relative: string, + hasBudget?: () => boolean, + ) => boolean; + let budgetChecks = 0; + + expect( + matcher('x'.repeat(4_096), () => { + budgetChecks += 1; + return budgetChecks === 1; + }), + ).toBe(false); + expect(budgetChecks).toBeGreaterThan(1); + }); + + it.each([ + ['src/**/*.ts', 'src/index.ts', true], + ['src/**/*.ts', 'src/nested/index.ts', true], + ['src/**/*.ts', 'src/nested/index.js', false], + ['assets/**', 'assets/icons/logo.svg', true], + ['?.md', 'a.md', true], + ['?.md', 'docs/a.md', false], + ])('matches snapshot glob %s against %s', (pattern, relativePath, expected) => { + expect(compileNativeSnapshotPattern(pattern)(relativePath)).toBe(expected); + }); + it('retains a deterministic hash/ref for omissions beyond the recorded output budget', async () => { await Promise.all( Array.from({ length: 1_003 }, (_, index) => diff --git a/test/domains/comet-native/native-spec-rebase.test.ts b/test/domains/comet-native/native-spec-rebase.test.ts index 5ac1fd56..1411bfa7 100644 --- a/test/domains/comet-native/native-spec-rebase.test.ts +++ b/test/domains/comet-native/native-spec-rebase.test.ts @@ -15,10 +15,10 @@ import { markNativeSpecRemoval, rebaseNativeSpecChanges, } from '../../../domains/comet-native/native-specs.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeProjectPaths } from '../../../domains/comet-native/native-types.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; import { readyNativeArchivePreflight } from '../../helpers/native-archive.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const brief = `# Outcome Ship the feature. diff --git a/test/domains/comet-native/native-specs.test.ts b/test/domains/comet-native/native-specs.test.ts index ae96d516..e1694c3a 100644 --- a/test/domains/comet-native/native-specs.test.ts +++ b/test/domains/comet-native/native-specs.test.ts @@ -15,8 +15,8 @@ import { reconcileNativeSpecChanges, } from '../../../domains/comet-native/native-specs.js'; import { nativeTransitionJournalFile } from '../../../domains/comet-native/native-transition-journal.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeProjectPaths } from '../../../domains/comet-native/native-types.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; const brief = `# Outcome Ship the feature. diff --git a/test/domains/comet-native/native-transition-recovery.test.ts b/test/domains/comet-native/native-transition-recovery.test.ts index bc2cf8cd..b347ba41 100644 --- a/test/domains/comet-native/native-transition-recovery.test.ts +++ b/test/domains/comet-native/native-transition-recovery.test.ts @@ -37,7 +37,6 @@ import { } from '../../../domains/comet-native/native-transition-journal.js'; import { appendNativeTrajectoryEvent } from '../../../domains/comet-native/native-trajectory.js'; import { repairNativeTrajectoryTail } from '../../../domains/comet-native/native-trajectory-recovery.js'; -import { advanceNativeChange } from '../../../domains/comet-native/native-transitions.js'; import type { NativeChangeState, NativeProjectPaths, @@ -45,6 +44,7 @@ import type { NativeTransitionHooks, NativeTransitionJournal, } from '../../../domains/comet-native/native-types.js'; +import { advanceNativeChange } from '../../helpers/native-confirmed-transition.js'; import { nativeVerificationFixtureReport } from '../../helpers/native-verification.js'; import { readyNativeArchivePreflight } from '../../helpers/native-archive.js'; import { @@ -941,7 +941,7 @@ describe('Native transition recovery', () => { name: 'recover-transition', evidence: { summary: 'must fail closed before doctor migration' }, }), - ).rejects.toThrow('requires doctor migration'); + ).rejects.toThrow('run comet native doctor recover-transition --repair before mutating it'); const inspected = await doctorNativeProject({ paths, name: 'recover-transition' }); expect(inspected.findings).toContainEqual( expect.objectContaining({ code: 'schema-migration-required', repair: 'migrate' }), diff --git a/test/domains/comet-native/native-transitions.test.ts b/test/domains/comet-native/native-transitions.test.ts index 12334454..3847eed6 100644 --- a/test/domains/comet-native/native-transitions.test.ts +++ b/test/domains/comet-native/native-transitions.test.ts @@ -10,8 +10,10 @@ import { createNativeChange, nativeChangeDir, readNativeChange, + writeNativeChange, } from '../../../domains/comet-native/native-change.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; +import { inspectNativeStatus } from '../../../domains/comet-native/native-diagnostics.js'; import { readNativeBaselineManifest, writeNativeBaselineManifest, @@ -115,14 +117,14 @@ describe('Native guarded transitions', () => { const first = await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape done' }, + evidence: { summary: 'shape done', confirmed: true }, runId: () => 'native-run-1', now: new Date('2026-07-14T01:00:00Z'), }); expect(first.change).toMatchObject({ revision: 2, phase: 'build', - approval: 'implicit', + approval: 'confirmed', approved_contract_hash: expect.stringMatching(/^[a-f0-9]{64}$/u), run_id: 'native-run-1', }); @@ -131,7 +133,7 @@ describe('Native guarded transitions', () => { const retry = await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape done' }, + evidence: { summary: 'shape done', confirmed: true }, }); expect(retry.change.phase).toBe('build'); expect(retry.change.revision).toBe(2); @@ -152,11 +154,103 @@ describe('Native guarded transitions', () => { expect(build.change.revision).toBe(3); }); + it.each(['sequential', 'batch'] as const)( + 'requires explicit shared-understanding confirmation in %s mode', + async (clarificationMode) => { + const blocked = await advanceNativeChange({ + paths, + name: 'advance-change', + evidence: { summary: 'shape is ready' }, + clarificationMode, + }); + expect(blocked).toMatchObject({ + next: 'manual', + change: { phase: 'shape', approval: null }, + findings: [ + expect.objectContaining({ + code: 'shape-confirmation-required', + requiredAction: 'confirm-shared-understanding', + retryCommand: 'comet native next advance-change --summary "" --confirmed', + requiresUserDecision: true, + }), + ], + }); + + const confirmed = await advanceNativeChange({ + paths, + name: 'advance-change', + evidence: { summary: 'shared understanding confirmed', confirmed: true }, + clarificationMode, + }); + expect(confirmed.change).toMatchObject({ + phase: 'build', + approval: 'confirmed', + }); + }, + ); + + it('requires a legacy implicit Build state to be confirmed before Verify', async () => { + const shaped = await advanceNativeChange({ + paths, + name: 'advance-change', + evidence: { summary: 'shared understanding confirmed', confirmed: true }, + clarificationMode: 'batch', + }); + await writeNativeChange(paths, { ...shaped.change, approval: 'implicit' }); + expect(await inspectNativeStatus(paths, 'advance-change')).toMatchObject({ + phase: 'build', + nextCommand: 'comet native next advance-change --summary "" --confirmed', + continuation: { + command: 'comet native next advance-change --summary "" --confirmed', + requiredInputs: [ + 'summary', + 'artifact-or-no-code-reason', + 'shared-understanding-confirmation', + ], + }, + }); + await fs.writeFile(path.join(projectRoot, 'feature.ts'), 'export const feature = true;\n'); + + const blocked = await advanceNativeChange({ + paths, + name: 'advance-change', + evidence: { summary: 'implemented', artifacts: ['feature.ts'] }, + clarificationMode: 'batch', + }); + expect(blocked).toMatchObject({ + next: 'manual', + change: { phase: 'build', approval: 'implicit' }, + findings: [ + expect.objectContaining({ + code: 'approval-confirmation-required', + requiredAction: 'confirm-shared-understanding', + retryCommand: 'comet native next advance-change --summary "" --confirmed', + requiresUserDecision: true, + }), + ], + }); + + const confirmed = await advanceNativeChange({ + paths, + name: 'advance-change', + evidence: { + summary: 'implemented and confirmed', + artifacts: ['feature.ts'], + confirmed: true, + }, + clarificationMode: 'batch', + }); + expect(confirmed.change).toMatchObject({ + phase: 'verify', + approval: 'confirmed', + }); + }); + it('blocks a changed approved contract until Build explicitly re-confirms it', async () => { const shaped = await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape is approved' }, + evidence: { summary: 'shape is approved', confirmed: true }, }); const approvedHash = shaped.change.approved_contract_hash; expect(approvedHash).toMatch(/^[a-f0-9]{64}$/u); @@ -204,13 +298,13 @@ describe('Native guarded transitions', () => { expect(confirmed.change.approved_contract_hash).toBe(scope.scope.contractHash); }); - it('records explicit confirmation from Shape or an agile Build decision', async () => { + it('records explicit confirmation from Shape and rejects it in Verify', async () => { const shaped = await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape is ready' }, + evidence: { summary: 'shape is ready', confirmed: true }, }); - expect(shaped.change).toMatchObject({ phase: 'build', approval: 'implicit' }); + expect(shaped.change).toMatchObject({ phase: 'build', approval: 'confirmed' }); await fs.writeFile(path.join(projectRoot, 'feature.ts'), 'export const feature = true;\n'); const build = await advanceNativeChange({ @@ -248,7 +342,7 @@ describe('Native guarded transitions', () => { await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape is ready' }, + evidence: { summary: 'shape is ready', confirmed: true }, }); await fs.writeFile(path.join(projectRoot, 'feature.ts'), 'export const feature = true;\n'); await advanceNativeChange({ @@ -286,7 +380,7 @@ describe('Native guarded transitions', () => { await advanceNativeChange({ paths, name: 'advance-change', - evidence: { summary: 'shape done' }, + evidence: { summary: 'shape done', confirmed: true }, }); await fs.writeFile(path.join(projectRoot, 'feature.ts'), 'export const feature = true;\n'); await advanceNativeChange({ diff --git a/test/domains/skill/project-instructions.test.ts b/test/domains/skill/project-instructions.test.ts index 68af4969..3df6c64b 100644 --- a/test/domains/skill/project-instructions.test.ts +++ b/test/domains/skill/project-instructions.test.ts @@ -58,6 +58,28 @@ describe('Comet project instructions', () => { expect(content).not.toContain('`.comet.yaml`'); }); + it.each([ + [ + 'zh' as const, + '用户通过宿主明确调用任意 Comet Skill', + '不要运行 resume probe', + '当前请求未明确调用 Comet Skill', + ], + [ + 'en' as const, + 'user explicitly invokes any Comet Skill through the host', + 'do not run the resume probe', + 'current request did not explicitly invoke a Comet Skill', + ], + ])('gives explicit Comet Skill invocation precedence in %s', async (language, ...markers) => { + await installCometProjectInstructions(tmpDir, language); + + const content = await fs.readFile(path.join(tmpDir, 'AGENTS.md'), 'utf8'); + for (const marker of markers) { + expect(content).toContain(marker); + } + }); + it('removes only the managed block', async () => { const agents = path.join(tmpDir, 'AGENTS.md'); await fs.writeFile(agents, '# User\n\nKeep me.\n', 'utf8'); diff --git a/test/domains/skill/skills.test.ts b/test/domains/skill/skills.test.ts index b3aceca7..ee436a2a 100644 --- a/test/domains/skill/skills.test.ts +++ b/test/domains/skill/skills.test.ts @@ -1550,15 +1550,23 @@ describe('skills', () => { ); expect(zhVerify).toContain('不要在 verify 阶段处理、合并或丢弃分支'); expect(zhVerify).toContain('不要写入 `branch_status: handled`'); - expect(zhArchive).toContain('### 5. 归档提交后的分支处理'); + expect(zhArchive).toContain('### 5. 交付归档提交并完成'); expect(zhArchive).toContain('comet state set branch_status handled'); - expect(zhArchive).toContain('### 1. 归档前最终确认(阻塞点)'); - expect(zhArchive).toContain('不得在用户确认前运行 `comet archive ""`'); + expect(zhArchive).toContain('### 1. 归档与交付前最终确认(阻塞点)'); + expect(zhArchive).toContain( + '不得在用户确认前运行 `comet state transition archive-confirm` 或 `comet archive ""`', + ); expect(zhArchive).toContain('`comet/reference/decision-point.md`'); - expect(zhArchive).toContain('「确认归档」'); + expect(zhArchive).toContain('「确认归档并立即推送」'); + expect(zhArchive).toContain('「确认归档、立即推送并创建 PR」'); expect(zhArchive).toContain('「需要调整或重新验证」'); expect(zhArchive).toContain('「暂不归档」'); expect(zhArchive).toContain('`comet state transition archive-reopen`'); + expect(zhArchive).toContain( + '`handled` 只表示用户已经确认如何远端交付这次完整归档提交,不表示 push 或 PR 创建已经成功', + ); + expect(zhArchive).toContain('归档阶段不再调用 Superpowers `finishing-a-development-branch`'); + expect(zhArchive).not.toContain('使用 Skill 工具加载 Superpowers'); expect(zhArchive).toContain('调用 `/comet-classic` 或 `/comet-open`'); expect(zhArchive).not.toContain('调用 `/comet` 或 `/comet-open`'); expect(zhVerify).toContain('不得因为验证已通过就自动归档'); @@ -1938,7 +1946,7 @@ describe('skills', () => { ); expect(enVerify).toContain('Do not handle, merge, or discard branches in verify'); expect(enVerify).toContain('do not write `branch_status: handled`'); - expect(enArchive).toContain('### 5. Handle the Branch After the Archive Commit'); + expect(enArchive).toContain('### 5. Deliver the Archive Commit and Complete'); expect(enArchive).toContain('comet state set branch_status handled'); expect(enTweak).toContain('Use the Skill tool to load the `openspec-apply-change` skill'); expect(enTweak).toContain('This apply path belongs only to tweak'); @@ -1948,15 +1956,25 @@ describe('skills', () => { expect(enTweak).toContain('single OpenSpec change'); expect(enTweak).not.toContain('No new capability'); expect(enBuild).not.toContain('openspec-apply-change'); - expect(enArchive).toContain('### 1. Final Archive Confirmation (Blocking Point)'); expect(enArchive).toContain( - 'Must not run `comet archive ""` before user confirmation', + '### 1. Final Archive and Delivery Confirmation (Blocking Point)', + ); + expect(enArchive).toContain( + 'Must not run `comet state transition archive-confirm` or `comet archive ""` before user confirmation', ); expect(enArchive).toContain('`comet/reference/decision-point.md`'); - expect(enArchive).toContain('Confirm archive'); + expect(enArchive).toContain('"Confirm archive and push now"'); + expect(enArchive).toContain('"Confirm archive, push now, and create a PR"'); expect(enArchive).toContain('Needs adjustment or re-verification'); expect(enArchive).toContain('Do not archive yet'); expect(enArchive).toContain('`comet state transition archive-reopen`'); + expect(enArchive).toContain( + '`handled` means only that the user confirmed how to deliver this complete archive commit remotely. It does not mean that push or PR creation has succeeded', + ); + expect(enArchive).toContain( + 'Archive no longer invokes Superpowers `finishing-a-development-branch`', + ); + expect(enArchive).not.toContain('use the Skill tool to load Superpowers'); expect(enArchive).toContain('invoke `/comet-classic` or `/comet-open`'); expect(enArchive).not.toContain('invoke `/comet` or `/comet-open`'); expect(enVerify).toContain('Must not automatically archive just because verification passed'); @@ -3047,8 +3065,18 @@ describe('skills', () => { artifact_root: 'docs', language: 'en', clarification_mode: 'sequential', + snapshot: { + include: ['**/*'], + exclude: [], + max_files: 10_000, + max_total_bytes: 256 * 1024 * 1024, + max_duration_ms: 60_000, + }, }, }); + const source = await fs.readFile(configPath, 'utf-8'); + expect(source).toContain('# Controls the auditable project scope'); + expect(source).toContain('# Bounds the total file content hashed by one snapshot'); }); it('preserves batch clarification mode across idempotent config updates', async () => { diff --git a/test/domains/skill/workflow-optimization-contract.test.ts b/test/domains/skill/workflow-optimization-contract.test.ts index 7ed3911c..2353f222 100644 --- a/test/domains/skill/workflow-optimization-contract.test.ts +++ b/test/domains/skill/workflow-optimization-contract.test.ts @@ -101,19 +101,90 @@ describe('Comet workflow optimization contracts', () => { ['中文', zhSkillRoot, '接受所有偏差'], ['English', skillRoot, 'accept all deviations'], ])( - '%s verification keeps non-waivable failures in verify and moves branch handling after archive', + '%s verification keeps non-waivable failures in verify and lets archive own final delivery state', async (_language, root, forbiddenWaiver) => { const verify = await readSkill(root, 'comet-verify'); const archive = await readSkill(root, 'comet-archive'); expect(verify).not.toContain(forbiddenWaiver); expect(verify).not.toContain('finishing-a-development-branch'); - expect(archive).toContain('finishing-a-development-branch'); expect(archive).toContain('comet state set branch_status handled'); expect(archive).not.toContain('git add -A'); }, ); + it.each([ + [ + 'Chinese', + zhSkillRoot, + '### 1. 归档与交付前最终确认(阻塞点)', + '### 2. 执行归档', + '### 5. 交付归档提交并完成', + '「确认归档并立即推送」', + '「确认归档、立即推送并创建 PR」', + '不执行 `archive-confirm` 或归档命令', + '保留 active change、`phase: archive` 和 `branch_status: pending`', + '`handled` 只表示用户已经确认如何远端交付这次完整归档提交,不表示 push 或 PR 创建已经成功', + '归档阶段不再调用 Superpowers `finishing-a-development-branch`', + '使用 Skill 工具加载 Superpowers', + ], + [ + 'English', + skillRoot, + '### 1. Final Archive and Delivery Confirmation (Blocking Point)', + '### 2. Execute Archive', + '### 5. Deliver the Archive Commit and Complete', + '"Confirm archive and push now"', + '"Confirm archive, push now, and create a PR"', + 'do not run `archive-confirm` or the archive command', + 'keep the active change, `phase: archive`, and `branch_status: pending`', + '`handled` means only that the user confirmed how to deliver this complete archive commit remotely. It does not mean that push or PR creation has succeeded', + 'Archive no longer invokes Superpowers `finishing-a-development-branch`', + 'use the Skill tool to load Superpowers', + ], + ])( + '%s archive persists the confirmed delivery choice in its only commit', + async ( + _language, + root, + confirmationHeading, + executionHeading, + deliveryHeading, + pushChoice, + prChoice, + deferMarker, + activeMarker, + handledMeaning, + noFinishingMarker, + forbiddenLoadMarker, + ) => { + const archive = await readSkill(root, 'comet-archive'); + const confirmation = archive.indexOf(confirmationHeading); + const execution = archive.indexOf(executionHeading); + const handled = archive.indexOf( + 'comet state set branch_status handled', + execution, + ); + const commit = archive.indexOf('git commit -m "chore: archive "', handled); + const delivery = archive.indexOf(deliveryHeading, commit); + const clearSelection = archive.indexOf('comet state clear-selection', delivery); + + expect(confirmation).toBeGreaterThan(-1); + expect(confirmation).toBeLessThan(execution); + expect(archive).toContain(pushChoice); + expect(archive).toContain(prChoice); + expect(archive).toContain(deferMarker); + expect(archive).toContain(activeMarker); + expect(handled).toBeGreaterThan(execution); + expect(handled).toBeLessThan(commit); + expect(commit).toBeLessThan(delivery); + expect(clearSelection).toBeGreaterThan(delivery); + expect(archive).toContain(handledMeaning); + expect(archive).toContain(noFinishingMarker); + expect(archive).not.toContain(forbiddenLoadMarker); + }, + ); + it.each([ ['中文', zhSkillRoot], ['English', skillRoot], diff --git a/test/helpers/native-confirmed-transition.ts b/test/helpers/native-confirmed-transition.ts new file mode 100644 index 00000000..5abfdd03 --- /dev/null +++ b/test/helpers/native-confirmed-transition.ts @@ -0,0 +1,35 @@ +import { readNativeChange } from '../../domains/comet-native/native-change.js'; +import { advanceNativeChange as advanceRuntimeNativeChange } from '../../domains/comet-native/native-transitions.js'; +import type { NativeClarificationMode } from '../../domains/comet-native/native-types.js'; + +type AdvanceOptions = Omit< + Parameters[0], + 'clarificationMode' +> & { + clarificationMode?: NativeClarificationMode; +}; + +/** + * Advance fixtures that are not testing clarification itself through the + * mandatory shared-understanding confirmation at Shape. + */ +export async function advanceNativeChange(options: AdvanceOptions) { + let state; + try { + state = await readNativeChange(options.paths, options.name); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + return advanceRuntimeNativeChange({ + ...options, + clarificationMode: options.clarificationMode ?? 'sequential', + }); + } + return advanceRuntimeNativeChange({ + ...options, + clarificationMode: options.clarificationMode ?? 'sequential', + evidence: + state.phase === 'shape' && options.evidence.confirmed === undefined + ? { ...options.evidence, confirmed: true } + : options.evidence, + }); +} diff --git a/test/platform/race-safe-read.test.ts b/test/platform/race-safe-read.test.ts index 3137ae5d..1f9f9bab 100644 --- a/test/platform/race-safe-read.test.ts +++ b/test/platform/race-safe-read.test.ts @@ -32,18 +32,21 @@ describe('race-safe bounded file read', () => { expect(result.stat.isFile()).toBe(true); }); - it('rejects a pre-existing symlink before opening it', async () => { - const target = path.join(root, 'target.txt'); - await fs.writeFile(target, 'secret'); - const file = path.join(root, 'link.txt'); - await fs.symlink(target, file); - - await expect(readFileRaceSafe(file, 1024, { label: 'test file' })).rejects.toMatchObject({ - name: 'RaceSafeReadError', - reason: 'not-regular-file', - message: 'test file must be a regular file', - }); - }); + it.skipIf(process.platform === 'win32')( + 'rejects a pre-existing symlink before opening it', + async () => { + const target = path.join(root, 'target.txt'); + await fs.writeFile(target, 'secret'); + const file = path.join(root, 'link.txt'); + await fs.symlink(target, file); + + await expect(readFileRaceSafe(file, 1024, { label: 'test file' })).rejects.toMatchObject({ + name: 'RaceSafeReadError', + reason: 'not-regular-file', + message: 'test file must be a regular file', + }); + }, + ); it('rejects a directory path', async () => { const dir = path.join(root, 'subdir'); @@ -110,46 +113,52 @@ describe('race-safe bounded file read', () => { ).rejects.toThrow('chain violated'); }); - it('rejects a same-path replacement made while the file is open', async () => { - const file = path.join(root, 'data.txt'); - await fs.writeFile(file, 'original'); - const replacement = path.join(root, 'replacement.txt'); - await fs.writeFile(replacement, 'replaced'); - - await expect( - readFileRaceSafe(file, 1024, { - label: 'test file', - hooks: { - afterOpen: async () => { - await fs.rename(replacement, file); + it.skipIf(process.platform === 'win32')( + 'rejects a same-path replacement made while the file is open', + async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'original'); + const replacement = path.join(root, 'replacement.txt'); + await fs.writeFile(replacement, 'replaced'); + + await expect( + readFileRaceSafe(file, 1024, { + label: 'test file', + hooks: { + afterOpen: async () => { + await fs.rename(replacement, file); + }, }, - }, - }), - ).rejects.toMatchObject({ - reason: 'changed', - message: 'test file changed while reading', - }); - }); - - it('does not follow a symlink swapped in after the pre-open check', async () => { - const file = path.join(root, 'data.txt'); - await fs.writeFile(file, 'original'); - const outside = path.join(root, 'outside.txt'); - await fs.writeFile(outside, 'outside-content'); - - // Start the read (its first lstat is dispatched asynchronously), then swap - // in a symlink synchronously before yielding back to the event loop. - const pending = readFileRaceSafe(file, 1024); - unlinkSync(file); - symlinkSync(outside, file); - - const result = await pending.catch((error: unknown) => error as Error); - if (result instanceof Error) { - expect(result.message).toMatch(/regular file|changed/); - } else { - expect(result.bytes.toString('utf8')).not.toBe('outside-content'); - } - }); + }), + ).rejects.toMatchObject({ + reason: 'changed', + message: 'test file changed while reading', + }); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not follow a symlink swapped in after the pre-open check', + async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'original'); + const outside = path.join(root, 'outside.txt'); + await fs.writeFile(outside, 'outside-content'); + + // Start the read (its first lstat is dispatched asynchronously), then swap + // in a symlink synchronously before yielding back to the event loop. + const pending = readFileRaceSafe(file, 1024); + unlinkSync(file); + symlinkSync(outside, file); + + const result = await pending.catch((error: unknown) => error as Error); + if (result instanceof Error) { + expect(result.message).toMatch(/regular file|changed/); + } else { + expect(result.bytes.toString('utf8')).not.toBe('outside-content'); + } + }, + ); it('enforces the byte limit against growth through the same descriptor after open', async () => { const file = path.join(root, 'grow.txt'); diff --git a/website b/website index 35551f87..3f717004 160000 --- a/website +++ b/website @@ -1 +1 @@ -Subproject commit 35551f87cf3a5f32a676e130e18d34d71a66ecfb +Subproject commit 3f7170045fe6d61b5785bc3aaf2c921d1db89d3f