diff --git a/.gitignore b/.gitignore index 52bbe36..4680353 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ docs/* !docs/platform-adapters.md !docs/engineering.md !docs/12-lifecycle.md +!docs/man-module-delivery-plan.md # Node.js node_modules/ diff --git a/docs/man-module-delivery-plan.md b/docs/man-module-delivery-plan.md new file mode 100644 index 0000000..078ca97 --- /dev/null +++ b/docs/man-module-delivery-plan.md @@ -0,0 +1,499 @@ +# man 轻量模块交付与初始化规则优化方案 + +状态:测试候选已实现,确定性验证通过;待真实宿主及用户项目试用。日期:2026-09-03。 + +用户已通过“开始”确认本方案及实施范围。本轮交付源码和构建候选,不自动升级现有宿主配置,不授权包发布或生产部署。真实测试项目由用户在候选实现完成后提供。 + +## 1. 目标与成功标准 + +让 `/man` 在模块交付时能够检查:目标有没有真正完成、方向有没有偏离、程序有没有具体缺陷,以及防御和抽象是否过度。同时控制流程、文档和验证成本。 + +需要解决的典型问题: + +- 写出的代码本身没有明显错误,但只完成辅助工作,没有推进约定目标。 +- 必需入口、调用链或接口衔接缺失,局部测试通过却无法使用。 +- 实现者回写“已完成”,审核者未经核实便接受该结论。 +- 审核提出无依据的加固、抽象或扩展,迫使实现不断变重。 +- 一个模块结束后既没有可靠收尾,也没有继续已经授权的后续工作。 +- 计划只存在聊天或本地运行时中,远程工作区无法接续。 + +成功不以新增步骤、报告数量、代码行数或 finding 数量衡量。验收同时观察:真实问题发现、误报和无关扩张、交付完整性、重复验证及时间成本。 + +## 2. 范围及不做事项 + +### 2.1 本次范围 + +1. 当前 Continuity `/man` 的规划、实施指引、模块审核和收尾。 +2. `mancode init` 生成的 `AGENTS.md` / `CLAUDE.md` 中,限定用于 `/man` 的文档交接与工程效率规则。 +3. 对应的计划源文件绑定、验证适用性、审核结果、交付检查及契约测试。 +4. 可选 `项目进度.html` 的稳定数据契约及安全降级。 +5. 现有未提交改动的修正:`src/installers/v3-adapter.ts` 与 `tests/v3-adapter-contracts.test.ts`。实施时保留与本任务无关的用户改动。 + +### 2.2 明确不做 + +- 不改变普通 Solo、`manba`、`manteam`、`manps`、`mansolo` 的流程、权限或完成条件;不改变已有 Solo handoff 语义。 +- 不新增模式、通用调度器、另一套任务状态系统或强制多代理流水线。 +- 不要求每段代码独立审核、每个小任务拆分,或每次都创建 worktree。 +- 不强制需求、设计、执行计划分成三份文档。 +- 不规定固定代码行数、工具次数、finding 配额或每个任务必须产生代码。 +- 不改造 legacy 流程;旧模板仅作为能力对照,不能直接复活旧命令或重新安装旧教练组。 +- 不自动初始化 Git、变更 remote、切换任务分支、强推、合并 PR 或部署生产。 +- 不把“通过内部验证”宣称为“已保证任意生产项目效果”。 + +新增通用 helper 可以位于共享代码中,但新增行为必须由 `/man` 的任务策略明确启用。其他模式的已有输入与输出行为要有回归保护。 + +## 3. 已核实的现状与缺口 + +| 现状 | 本方案的处理 | +|---|---| +| 旧 `/man` 已要求自测、风险分级审查和修复后验证 | 恢复到当前入口可执行的指引,不复制旧命令 | +| 当前生成入口对需求、范围及计划较详细,对验证与审核主要提示 apply ledger | 补充审核输入、目标覆盖、证据要求和结束条件 | +| 旧 reviewer 要求改动追溯到验收,但缺漏问题也被要求引用改动行 | 增加计划到实现的反向检查,缺漏可引用验收项及缺失链路 | +| 当前计划 revision 比较整份计划内容,变化会使审核与验证失效 | 分离批准基线和交付记录,不因状态回写修改计划权威 | +| 当前验证包含命令、退出码、人工确认等;单条证据未直接绑定被测内容和环境 | 增加适用性绑定,保留真实运行证据与人工确认边界 | +| 当前 apply review 会使旧验证失效 | 新 `/man` 策略区分纯审查记录变化与实际实现变化 | +| 完成门禁已有结构化审核、验收、计划和一致性检查 | 扩展现有门禁,不另造一个 Markdown 状态判定器 | +| 之前两处未提交改动把新规则无条件加入所有平台 bootstrap | 改为正确措辞、限定 `/man`、测试非 man 路径不受影响 | + +证据入口:`src/installers/v3-adapter.ts`、`src/templates/skills/man.ts`、`src/templates/agents/film-analyst-offense.ts`、`src/context/plan-revision.ts`、`src/context/review-remediation.ts`、`src/context/verification-ledger.ts`、`src/context/aggregate.ts`。 + +这些事实证明存在执行指引和审核视角的缺口,尚不能证明它们是用户历史任务失效的唯一原因。真实任务复盘放在测试阶段。 + +## 4. 默认流程与轻量化约束 + +```text +读取目标与相关项目约束 + → 简短计划落入项目计划目录,确认本次执行授权 + → 实现模块,进行相关自测;必要时局部审核 + → 回写交付记录,标记待审核 + → 一次模块总审:目标兑现 / 正确性与漏洞 / 复杂度与防御 + → 有问题则针对性修复、验证和复核 + → 验收通过,完成版本化交付 + → 按已有授权继续下一模块,或结束本次任务 +``` + +### 4.1 什么算一个模块 + +模块是可独立验收的交付结果,不等同于目录、文件或开发时长。计划中提前写明完成边界。 + +- 常规小功能:一个 `/man` 任务、一份计划、一次模块总审。 +- 多个独立交付物:总计划说明依赖和整体目标,各模块复用现有 TaskRef 机制独立交付;不为函数或临时步骤创建任务。 +- 总计划可以是现有计划中的索引,不强制另建文档或引入新父子调度系统。 +- 只有明确批准了后续模块的实施,审核通过后才可顺序继续;“计划已写好”不等于执行授权。 +- 涉及多个模块的最终用户链路,应在计划中分配明确的集成验收责任。可以由最后一个模块承担,不重复审核所有历史代码。 + +例如,完成“用户从页面下载符合约定的数据文件”与完成“供其他模块调用的导出接口”是两个不同交付边界,必须在实施前区分。不得把计划明确延期的前端入口误报为当前接口任务的缺陷。 + +### 4.2 何时增加检查 + +| 触发事实 | 增加的动作 | +|---|---| +| 交付物可以独立实施和验收,整体任务已难以可靠处理 | 拆分模块,保留依赖与整体验收 | +| 接口、数据归属、兼容方案或关键依赖存在实质不确定性 | 在计划中补充设计检查;必要时先验证可行性 | +| 实际改动跨越权限、外部输入、数据迁移等风险边界 | 增加相应测试或定向安全检查 | +| 新证据要求改变批准目标、范围、核心行为或架构选择 | 停止受影响实现,说明差异并获取必要确认 | +| 修复改变已审核代码或直接依赖 | 复核修复范围和相关回归 | +| 本次交付明确包含上线 | 检查配置、兼容、恢复及运行诊断准备;部署仍需相应授权 | + +正常实现不定时重复审查。纯代码整理、文档回写或 reviewer 更新报告,不自动触发全量测试。 + +## 5. 计划目录、基线与回写契约 + +### 5.1 目录解析 + +规则用“项目指定的计划基线目录,默认 `doc/`”,不硬编码所有项目使用 `doc/`。 + +解析顺序:项目明确配置或用户指定目录 → 仓库已有明确计划约定 → 新项目默认 `doc/`。本仓库明确选择 `docs/`。存在多个目录且用途不明确时只澄清一次,不能同时维护两份计划。 + +通过现有项目事实/配置机制保存解析结果;任务必须绑定实际 repo-relative 计划路径,不能只保存一段脱离文件来源的文本。远程工作区缺少本地配置时,应从显式计划路径接续,不能另建默认目录替代它。 + +新增路径输入需沿用现有安全路径校验:规范化后仍在获授权项目范围内,不经符号链接逃逸,不覆盖其他任务或敏感资料。目录约定与可写授权分开检查,不能因为配置了一个目录便获得任意写入权限。 + +计划目录要能正常版本化。遇到忽略规则时,说明并仅对已授权的计划文件/目录增加精确放行项;禁止用强制添加绕过敏感目录的排除规则。不得为此公开其他历史私有文档。 + +### 5.2 一份文件,两种内容 + +默认每个交付任务一份 Markdown,包含两个稳定区块;具体标记作为新契约随测试固定: + +```markdown + +# 已确认计划 +目标、包含/排除范围、相关架构依据、必要技术决策。 +阶段与交付物、验收 ID 和可观察结果、验证方法、未决事项。 + + + +## 实际交付记录 +验收项到实现入口和证据的对应关系。 +未完成项、偏差、审核结论、必要修复、下一步。 + +``` + +标记已在本次候选实现中固定;发布前仍以内部验证和真实项目试用为准。只识别正文中独占一行的托管标记,代码示例中的标记不能被当作实际区块。解析拒绝缺失、重复、交叉或无法唯一定位的标记,不猜测正文边界。 + +- 批准基线只包含做什么、为什么和怎样验收;正常回写只更新交付区。 +- 不允许事后删改未实现的验收项,使现有代码看起来符合计划。 +- 基线变化走显式 plan revision;改变已确认需求或语义时走现有 reframe 路径。交付区变化不递增批准计划版本。 +- 旧任务继续使用原文档协议;没有标记不自动拆分、不重解释旧 digest。升级需显式绑定与确认。 +- 引用规范文档的具体相关章节,不要求复制整份架构。实施需要的非敏感接口结论应写入可交接计划。 + +### 5.3 文档与运行时的职责 + +`doc/` 或 `docs/` 是可提交、可远程交接的设计与计划源。现有 Task Aggregate 继续负责机器可校验的批准状态、revision、审核与验证权威。 + +确认时把计划路径、批准基线和相应版本绑定到现有计划权威;它是同一份计划的已批准快照,不是另一套独立需求。磁盘基线与快照不符时提示待确认,不能任选一个继续,也不能自动互相覆盖。 + +交付区由既有审核、验证结果及必要事实摘要更新,只维护一份面向人的记录;不要求 agent 手动把相同内容再抄进三份报告。自动更新必须限定托管区,保护用户手写内容;文件被外部修改时做冲突检查。 + +远程只有计划文档、没有本地 Task Aggregate 时,允许读取与接续规划,但不能把文档中的“通过”当作已恢复的运行时批准或审核身份。已有 transport 按其协议恢复;否则需要显式绑定,不伪造历史证据。 + +## 6. 模块审核契约 + +### 6.1 输入 + +一次总审使用:批准目标及验收项、相关架构/规范、模块开始到待审版本的完整改动、直接相关入口与调用链、已有验证结果、实际交付记录。 + +不能只看最后一次 commit,也不能只看实现者总结。基线 diff 应区分任务开始前已有改动、本任务变更及他人变更;规划和实施期间不要求整个仓库干净,但最终验证与完成前必须把范围外未提交文件移出当前 checkout、stash 或单独提交,避免它参与证据后却不进入版本化交付。 + +优先由未参与实现的 reviewer 一次完成三个方向,不按维度拆成三个 agent。平台不支持独立 reviewer 时明确标注自审及能力限制;不得伪造独立审查。`reviewer` 字段只是调用者自述,不绑定另一 actor/session,不能单独作为身份认证。需要独立审查的高风险任务未满足条件时,报告具体未验证项。 + +### 6.2 三个方向与双向追踪 + +| 方向 | 审核问题 | 合格证据 | +|---|---|---| +| 目标兑现与方向 | 有没有漏做、多做、只做辅助代码、入口未接通、偏离已确认架构? | 验收 ID → 实现/调用路径 → 行为证据;实际改动 → 计划依据 | +| 正确性与漏洞 | 必需主链路、直接相关失败路径、权限、数据和兼容处理是否存在具体缺陷? | 可复现行为、代码因果链、真实命令/输出或明确人工确认 | +| 复杂度与防御 | 校验、重试、兜底、配置和抽象是否有依据?是否重复已有机制、吞错或妨碍正常行为? | 对应实际风险或现有契约,并说明新增/删除处理的必要性 | + +缺漏问题允许引用验收项与缺失的入口/链路,不强求存在一行错误代码。不能仅因未找到字符串便断定功能缺失,需要检查实际实现路径。 + +必要的安全边界不能因“看起来啰嗦”被删除。已有代码、范围外行为和假想未来需求,不因本轮审核自动进入实施范围。 + +### 6.3 结论及问题处置 + +- 通过:必需目标已满足且证据适用,没有未解决的必修问题。 +- 未通过/待修复:存在已确认范围内的缺陷或遗漏。实施者继续处理,不自动把整个业务任务标成等待用户。 +- 待确认/未验证:缺少会改变判断的决定或必需证据,列明具体缺口;无法确认不等于通过。 +- 可选建议:不影响约定交付,不阻止完成,不自动成为新需求。 + +通过时允许零 finding。不要求评分或凑建议。可以摘要展示最重要的三项,但不得丢弃已发现的必修问题。review waiver/skip 必须沿用明确授权和审计,不能由实现者为赶进度自行设置。 + +一次模块总审替代或整合现有交付前审查,不在 Step 7/8 后再叠加第三次总审。保留现有 quality/security 领域表示;同一次审核可以分别给出两个领域结论,高风险专审仅在能力或风险确实需要时增加。 + +### 6.4 修复与收敛 + +默认执行一次必要修复及定向复核;后续仅在仍有具体必修问题且有新的诊断依据时继续。复核只看原问题处置、修复差异及其直接回归,不重新全面扫描。 + +同一问题重复出现且没有新证据时,停止重复编辑/重跑,回到根因诊断、任务拆分或必要方案确认。达到次数或时间提醒不代表缺陷已解决,也不授权跳过必需验收。 + +## 7. 验证、证据和机器门禁 + +### 7.1 验证先于完成声明 + +实施前为必需验收项选择适当方法。一般修改使用相关测试和定向自查;需要真实集成、浏览器、设备或人工判断的目标,不能用 mock、截图、页面加载或代码阅读代替。 + +已有证明覆盖当前目标、当前内容和当前环境时复用;只补缺口。TDD/失败先行适用于可复现缺陷及适合的行为测试,不强制文档修改或所有任务做同一种测试仪式。 + +### 7.2 证据适用性 + +新 `/man` 策略需在既有证据模型中关联:验收 ID、批准计划版本、被测内容标识、覆盖范围、命令及运行目录、非敏感环境说明、结果/报告引用和时间;人工证据保留真实来源及明确确认。 + +被测内容不能只用 HEAD:同一 HEAD 下的 staged、unstaged 和本任务新增未追踪文件也可能变化。采用覆盖任务代码、测试、依赖锁定、相关配置及已声明直接依赖的内容快照;不保存或输出敏感配置值。可复用 Git 对象/内容指纹,不创建复杂的自动依赖图。 + +- 内容快照仅用于识别证据适用对象,不能证明功能正确,也不能作为“实现已取得进展”的唯一证据。 +- 普通提交导致 HEAD 改变、但被测内容不变时,不为此重跑测试。 +- 仅交付记录或审核文字变化,不使测试失效;批准基线发生实质变化仍需重新确认及相应验证。 +- 代码变化使受影响证据失效。不能可靠区分影响范围时,采用模块级保守验证,不假装能精确复用所有证据。 +- 外部服务或环境变化会影响结论时需重新验证;过去某次 exit code 为 0 不是永久证明。 +- 纯 review apply 不再无条件废弃新 `/man` 的验证;此变化不能传递到其他模式或旧策略任务。 + +自动化命令证据优先由执行入口直接记录退出码和输出引用。录入型外部/人工证据标明来源,机器只能验证其结构、关联及授权,不能声称已认证其内容真实性。 + +### 7.3 完成检查的边界 + +机器应检查: + +1. 绑定计划文件存在、可读取、可版本化,批准基线与任务权威一致。 +2. 本模块必需验收项都有适用且通过的证据。 +3. 审核已覆盖必需领域和双向目标追踪,没有未经处置的必修问题;跳过/豁免具有有效授权。 +4. 交付记录已回写且指向本次有效结果,未借回写修改目标。 +5. 任务相关变更已按交付阶段处理,未混入他人变更或被禁止版本化的敏感文件。 +6. 现有 session、revision、scope、claim、operation、repair、task-head fence 等一致性要求继续成立。 + +机器不能仅凭“文件存在”“报告非空”“三个维度都填了通过”证明产品正确。语义判断由审核和真实验收承担。没有覆盖代码编辑工具的宿主能力时,完成门禁也不能宣称可以阻止所有未经批准的中途编辑;必须通过宿主行为测试核实执行纪律。 + +## 8. 完成、提交和发布分离 + +采用三个独立结论,不重定义其他模式现有 workflow 状态: + +- 验收结果:待审核 / 待修复 / 未验证 / 已通过。 +- 版本化交付:待提交 / 已提交。 +- 发布结果:未发布 / 已发布 / 状态未知。 + +验证和审核通过后,提交本任务应版本化的代码与文档。只选择本任务 diff,不能 `git add .` 混入用户改动;同一文件存在无法可靠分离的改动时,需要明确处理边界。 + +推送仅在当前任务分支已有明确上游、且具备本次工作授权时执行。没有上游、不具备权限或 push 失败,报告“交付未发布”及原因,不改写为业务“阻塞”,不自行设置远程或强推。无 Git 仓库时报告无法完成版本化交付,不自动初始化仓库。 + +为避免“回写→提交→回写提交号→再次提交”的循环:交付文档提交验收事实、证据引用和下一步;当前提交/推送状态由 Git 与运行时交付结果提供。完成命令在提交后检查,不再修改已提交的计划正文来填入该提交自己的 SHA。 + +Git 检查只影响交付判断,不能拒绝继续修复已知问题。已验收但尚未提交不算完成版本化交付;未发布不否定已完成的实现与验收。 + +shared `/man` 保留现有 Git/transport 分开同步与 fence 规则;业务分支 push 不等同于 `team sync push`。新功能不能替用户同步额外的远程权威。 + +## 9. 初始化生成规则的最终方向 + +以下规则是生成器的目标语义,实施时转换成与现有 bootstrap 一致的精简措辞。`AGENTS.md` / `CLAUDE.md` 只放行为规则和导航,不放当前计划、任务状态或完整操作手册。 + +### 9.1 明确适用范围 + +新增区块必须首先说明:仅在 `/man` 规划和获授权的完整执行中生效;普通 Solo、其他模式及已有 Solo handoff 保持原有约定。共享 bootstrap 的其他入口不能因此强制建立计划、身份、会话、提交或审核流程。 + +### 9.2 文档与交接规则 + +- 新增或调整计划时,目标、范围、阶段、验收和未决事项写入项目指定计划目录,默认 `doc/`,已有 `docs/` 约定优先保留。 +- 开始实现前读取已批准计划和相关架构,不得只凭聊天摘要开工。未批准实施时停在规划,不把“讨论”“看看方案”当作执行许可。 +- 区分 `架构/` 不存在、存在但被 Git 忽略、存在但不可读。被忽略不妨碍本地读取;缺失或不可读时先判断计划是否足够,仅在影响实现决策且无法从可用基线得出时索取文档或确认。 +- 保护被忽略的 `架构/`、`项目接口/` 等本地资料,禁止强行纳入 Git。可提炼经确认的非敏感接口/设计结论,禁止复制凭据、账号、密钥等敏感内容。不得无依据断言任意项目已经有这些忽略规则。 +- 模块完成后回写实际交付事实,先待审核,审核与必需验收通过后才标完成;仅推进已获执行授权的后续工作。 +- 验证后完成任务变更的提交;按第 8 节区分提交与推送,不把未发布误标为业务阻塞。 + +### 9.3 工程执行与效率规则 + +- 验证与实际风险和验收目标相称,复用仍适用证据,不重复确认刚获得且没有变化的事实。 +- 优先根因修复。只在可恢复、补充有效上下文或必要清理时处理异常,保留原始错误及因果链,禁止吞错或用层层兜底掩盖缺陷。 +- 新增防御、抽象、配置和依赖须有当前需求、现有契约或具体风险依据;不能依据假想未来需求扩张。 +- 出现明显无关扩张、重复操作或异常耗时时,收敛问题并调整做法;不以代码行数或工具次数设置硬阈值。 +- 对 GPT:哈希不作为产物变化、完成或正确性的唯一证明;完整性校验、缓存、证据绑定和发布溯源允许使用。 +- 对 GPT:不为再次确认而重读刚写入且未被外部修改的完整文件;必要时检查关键片段或运行相关验证。 +- 无需变更、应删除冗余、需要确认或尚未完成时如实说明,不为了体现进展而增加代码。 + +### 9.4 渲染与升级 + +修正当前两个无条件公共区块及其测试,避免多模式扩散。生成规则和 `/man` 阶段说明各维护一处源,通过现有平台适配器输出;非 `AGENTS.md` / `CLAUDE.md` 的 bootstrap 不无条件复制这两个区块。 + +安装/升级继续使用既有 managed block 机制,保护用户自定义内容。测试初始化输出、重复初始化的幂等性,以及实际平台入口能否读到规则,不能只断言几个中文字符串存在。 + +## 10. 可选本地进度页面 + +不创建新页面、不设计 UI、不猜 HTML。只有页面具有明确契约且对应任务能唯一定位时,才更新允许的字段。 + +建议的数据契约示意: + +```html + +``` + +绑定计划中的稳定模块 ID 与页面 `taskId`;不能靠标题近似匹配。只修改该 JSON 数据块中的对应记录,保留其他 HTML、脚本和用户内容。序列化需正确处理 HTML script 结束符,不能把文档文本当脚本执行。 + +同步时点:任务开始、转待审核、验收通过、确认必要外部决策阻塞。 + +| 实际情况 | 页面状态 | +|---|---| +| 尚未开始、后续未开发模块 | 未完成 | +| 已开始、普通缺陷正在修复 | 进行中 | +| 交付记录已回写,尚未通过总审 | 待审核 | +| 模块审核与必需验收通过 | 已完成 | +| 缺少必要用户/甲方确认,导致相关工作无法继续 | 阻塞,并说明需要哪项确认 | + +不能直接把 review ledger 的 `blocked` 映射为页面业务“阻塞”。工具故障、测试失败、没有 remote 或 push 失败按实际原因报告。 + +页面不存在、契约缺失/损坏、任务 ID 缺失或冲突、页面不在允许写入范围时,只提示人工同步,不阻止开发或验收。页面不是计划或运行时权威。若页面已被用户版本化,不擅自取消追踪;需要同步的变更在提交前统一处理,避免交付完成后再次弄脏工作树。 + +## 11. 实现分层与兼容原则 + +| 层 | 职责 | 预期修改位置 | +|---|---|---| +| bootstrap | 条件规则、权限边界、计划与模式导航 | `src/installers/v3-adapter.ts`,相应 adapter 测试 | +| man 执行指引 | 模块边界、交付记录、总审输入、收敛与继续条件 | 当前 `/man` mode definition;必要时提取 man 专用模板 | +| 计划及上下文 | 源文件绑定、基线解析、相关材料引用、批准快照 | `src/commands/workflow.ts`、`src/context/plan-revision.ts`、Context Pack/resolver | +| 审核与验证 | 双向追踪结果、证据适用性、定向修复复核 | 现有 review/verification ledger 与相应 mutation | +| 完成与交付 | 现有门禁扩展、任务级 Git 状态与发布结果 | `src/context/aggregate.ts`、workflow 命令及窄范围 helper | +| 可选投影 | 文档交付区和有契约的本地页面更新 | 复用原子文件/投影能力,限定路径与托管区 | + +逻辑上需要新增的契约是:计划源绑定、被测/被审内容引用,以及可审计的模块交付结论。最终字段与序列化版本在实现第一阶段设计并由契约测试固定;不能给现有严格 parser 偷塞字段。 + +沿用任务级 planning/review/verification policy 分派。新行为仅对显式启用新策略的 `/man` 生效;旧任务保留创建时策略,不自动重新批准或伪造新证据。发布前明确 reader/writer capability 与旧客户端拒绝不支持任务的行为。 + +不得通过改公共默认值使其他模式承担新门禁。即使底层 schema/helper 扩展,非 man 路径也必须保持原有语义。共享任务沿用 operation journal、revision、隐私筛查及 transport 协议。 + +文档投影与运行时权威写入分开处理:权威成功而文档更新失败时报告“交付记录待同步”,允许幂等重试,不伪造已同步,也不为投影失败回滚已经有效的业务实现。正式交付前需要补齐必需文档;可选 HTML 失败不阻止交付。 + +## 12. 分阶段实施计划 + +用户确认本方案后才进入下列阶段。阶段是本次产品改造的工作划分,不是强制加到每个使用者功能上的步骤。 + +| 阶段 | 交付内容 | 阶段验收 | +|---|---|---| +| P1:固定契约与样例 | 用现有实现确认模式分派、版本/证据失效规则;定义基线区块、源绑定、结果格式及升级边界;先建立正反样例 | 无已知权威冲突;非 man 隔离路径明确;纯回写不改批准基线的测试可运行 | +| P2:入口和计划交接 | 修正之前两处改动;生成条件规则和 man 指引;实现目录解析、源绑定、交付区写入及相关上下文 | 初始化 AGENTS/CLAUDE 正确且幂等;计划可版本化;无凭据复制;旧任务和其他模式不被启用新流程 | +| P3:模块审核与交付门禁 | 集成一次模块总审、双向追踪、内容/环境证据关联、按变更失效、提交与发布区分 | 能拒绝伪完成/陈旧证据;无问题时一次通过;修复只做相关复核;未发布不误标业务阻塞 | +| P4:可选页面与内部候选验证 | 实现固定 JSON 契约、局部安全更新及缺失降级;完成故障和兼容回归;形成可安装候选 | 无页面也能工作;有页面不破坏用户内容;各内部场景有结果,不以文案断言代替行为测试 | +| P5:用户真实项目试用 | 用户提供测试位置后,按授权运行代表任务、记录成本和失效样例,再修正候选 | 用户确认交付有效性和负担可接受;未验证的宿主/场景明确保留,不提前宣传生产保障 | + +P5 的测试位置现在不需要提供。其缺失不阻止 P1–P4 的设计与实现,但在实际提供并完成测试前,不能把 P5 标记完成。 + +已有 `MANCODE_ADAPTER_CONTENT_STALE` 记录说明当前未提交 renderer 与已安装入口不一致。实施启动时应通过批准后的正常构建/适配器升级解决,不能为了登记本方案先安装未经确认的新规则。本文尚未在 Continuity 中形成已批准实施任务。 + +## 13. 验收清单 + +| ID | 必需验收目标 | 主要验证方式 | +|---|---|---| +| AC-01 | 只讨论、只规划时不修改业务代码;确认执行后才实施 | 宿主行为场景及命令契约 | +| AC-02 | 新规则只影响目标 man 策略,其他模式和 Solo handoff 不变 | 分派契约、非 man 回归、输出差异检查 | +| AC-03 | 初始化 AGENTS/CLAUDE 的规则限定范围、无重复、可升级且保护用户内容 | 初始化/升级 fixture | +| AC-04 | 指定 docs 时不另建 doc;计划被忽略时给出精确处理,不强加敏感目录 | 目录与 Git ignore fixture | +| AC-05 | 只回写交付区不改计划版本或废弃有效证据;改批准基线必须重新确认 | plan/ledger/mutation 契约 | +| AC-06 | 辅助代码已写但必需入口缺失时不能把模块判为完成 | 已知缺漏的模块行为样例 | +| AC-07 | 正确实现、合理分期与必要防御能通过,不为凑意见增加代码 | 无缺陷及反误报样例 | +| AC-08 | 具体逻辑/接口/权限缺陷和无依据兜底能够被解释并处置 | 植入缺陷、冗余分支与真实链路样例 | +| AC-09 | 同 HEAD 内容变化使对应证据失效;仅提交且被测内容不变不重测 | staged/unstaged/untracked 内容场景 | +| AC-10 | mock/截图不能替代指定真实验收;缺少证据不得自动通过 | 混合验证及人工确认契约 | +| AC-11 | 已完成的局部检查可复用,模块总审不默认拆多轮;修复定向复核 | 运行记录与审核调度场景 | +| AC-12 | 本任务提交不混入用户变更;无上游/push 失败为未发布而非业务阻塞 | 隔离 Git 仓库与失败注入 | +| AC-13 | 页面缺失/无契约/ID 冲突不阻止工作;有效契约只改对应记录 | HTML 数据块与文件写入测试 | +| AC-14 | 审核后只继续已获授权的任务;未授权后续、plan_only 和整个任务终点正确停止 | 宿主行为场景 | +| AC-15 | 旧 reader/writer、旧任务、崩溃/重试、外部文档修改与 shared fence 不被绕过 | 兼容及 operation/projection 回归 | + +行为样例用于衡量审核质量,不意味着机器门禁能自动证明所有语义验收。 + +## 14. 如何解决尚无把握的部分 + +### 14.1 内部可控场景先测机制 + +建立小而明确的样例,包含:只讨论却试图开工、接口存在但入口未接入、写了无关 helper、已批准分期被误判遗漏、真实漏洞、必要校验、重复兜底、验证后再改代码、回写修改目标、无 remote、无架构目录、已有脏工作树、审核通过后该继续或该停止。 + +测试分三层: + +1. 确定性契约:文件解析、状态/策略、版本绑定、Git 和安全边界,不依赖模型运气。 +2. 宿主行为:真实 agent 是否遵守讨论/批准/审核/继续边界,是否能发现已知缺漏,是否产生误报。 +3. 用户项目:真实交付是否可用、上下文是否足够、额外成本是否可接受。 + +已知缺陷和无缺陷样例先固定预期结论,以实际测试和人工核对判断漏报、误报;不能让同一审核 agent 通过自评分证明自己的判断正确。 + +### 14.2 对照方式 + +保留当前流程作为对照,在隔离工作区使用相同任务起点、验收要求、模型/宿主配置和可用工具。候选流程不继承对照运行的答案或修复。 + +记录: + +- 已知问题发现与遗漏,误报及导致的无关改动。 +- 用户可观察的交付结果、未验证项和人工确认次数。 +- 总耗时、审核耗时、审核启动次数、重复验证、工具调用;可获得时记录 token/成本。 +- 哪个环节产生停顿或范围偏离,而不只统计最终是否报错。 + +模型样例不能靠一次通过证明稳定。内部关键行为样例至少重复运行,报告每次结果;确定性安全/权限边界出现失败不得被平均分掩盖。不给没有基线的数据编造“效率提高百分之多少”。 + +### 14.3 发布前的判断原则 + +- 如果找错能力没有改善,却显著增加无关操作,应收缩指引或检查,不继续堆规则。 +- 若同一误解导致实现和审核共同漏判,应修正验收表达或补真实行为证据,不能只再加一个 reviewer。 +- 若模块太大导致漏看,应按可验收交付拆分;若简单任务维护成本高,应合并文档/记录及复用证据,不降低必需验收。 +- 自动化内部测试通过后可交付测试候选;生产适用性与成本结论等 P5 实测,不提前替用户验收。 + +## 15. 验证命令与回退 + +实施时先运行受影响模块对应的窄契约测试,重点覆盖现有 adapter、plan/review/verification、aggregate、workflow 及必要 operation/projection 测试,再按改动风险运行 typecheck、lint、相关集成与构建。新增解析器/交付 helper 配同名测试。 + +涉及初始化输出的修改要验证实际生成物和安装后入口。发布候选遵守 `docs/engineering.md` 的现有发布门禁,不用本方案取代它。 + +新策略应允许停止对新任务启用;已经创建的新策略任务保留其证据语义,不能通过降级抹掉未通过项。旧运行时不能解析的新任务要明确拒绝或保留只读能力,不允许猜测字段后继续写。 + +修复本次功能的代码可通过普通可审计提交回退;不重置用户工作树、不丢弃 Task Aggregate,不自动删除其计划文档。 + +## 16. 已确认决策与本轮交付记录 + +本方案建议整体采用的默认决策: + +- 当前仓库计划目录为 `docs/`;其他项目可配置,默认 `doc/`。 +- 每个独立交付模块默认一次总审,局部审核按实际风险触发。 +- 原计划与实际交付记录同文件分区,既有 runtime 保持机器权威。 +- 新规则仅适用于目标 `/man` 策略;其他模式、旧任务、Solo handoff 不强制升级。 +- 验收、提交、发布分开报告;HTML 是可选本地视图。 +- 内部契约和宿主场景先完成,再使用用户提供的真实测试位置。 + +用户已通过“开始”确认本方案和实施范围。任何扩展模式范围、改变既有权威或额外授权部署的变化仍须重新确认。 + +### 实施决策与可追溯入口 + +- 新 `/man` 使用显式 `--delivery` 与 planning policy 3;不改变项目默认策略,不批量升级旧任务,也不允许降级逃避未通过项。 +- 计划源路径与首次绑定的 Git 基线保存在既有 canonical plan artifact 中,不新增权威文件。目录选择复用显式路径和项目约定,不添加全局目录配置。代码入口为 `src/context/man-delivery-plan.ts`、`plan-revision.ts`。 +- `workflow delivery` 提供 inspect、verify、confirm、review、sync、check 和 publication。ID、digest、验收槽位由 CLI 生成;写入仍走原有 verification/review journal,完成复用原门禁。 +- 内容适用性采用保守工作区快照,覆盖 tracked/staged/unstaged/untracked;忽略本地 authority 和进度视图,计划仅覆盖批准基线。外部服务变化和同文件内的变更归属仍需实际判断,不宣称自动依赖分析或完全所有权证明。 +- 回写记录使用原子文件替换和写前冲突检查,保护标记外内容。可选页面只处理精确 JSON 契约;显式 baseline progress-task 标记绑定模块 ID,未提供则使用完整 TaskRef。 +- 生成器只更新新 man 指引和 AGENTS/CLAUDE 的条件规则,未安装到本仓库现有宿主文件。既有适配器内容漂移没有被绕过或用手改 authority 掩盖。 + +### 当前验证记录 + +P1–P3 已实现。P4 的确定性候选验证已通过,真实宿主行为实验仍未执行。实际临时 Git 仓库与公共 workflow 命令已覆盖完整路径:零 finding 审核、自动和人工验收、文档回写、提交、无上游完成;也验证了失败退出码、同 HEAD 内容变化、缺少验收覆盖、修复后复核及越界提交拦截。相关测试:`tests/man-delivery.test.ts`、`man-delivery-runtime.test.ts`、`man-delivery-plan.test.ts`、`man-delivery-evidence.test.ts`、`man-progress.test.ts`。 + +最终验证: + +- `npm test -- --silent --reporter=dot`:132 个测试文件、989 个用例通过。 +- 末轮环境适用性投影修正后,交付与 Git 运行时的 13 个针对性用例通过;仅回写及仅提交不废弃有效证据,环境变化不能仍显示通过。 +- `npm run typecheck`、`npm run lint`、`npm run build` 通过;`npm run test:dist` 验证 10 个分发适配器。 +- `git diff --check` 通过。初始化/升级托管块、其他模式、旧策略、原有 journal/recovery 与 transport 回归均在全量套件中。 +- 实现使用单次命令绑定多个明确验收 ID,避免为逐项登记重复运行同一测试;有实际执行计数用例覆盖。 + +本次自审结论:目标到实现的入口已连通,没有新增通用编排器、强制多代理流程或逐小段审核。修正了实际测试暴露的验收槽位生成、内容失效后的状态转换和历史未知策略用例;还检查了原始 apply 不能通过删除 finding 绕过新门禁、JSON 示例不能误写到页面脚本/注释、以及跨环境证据投影。审核由本次执行者完成,不冒称独立 reviewer,也不把结构检查视为语义正确性证明。 + +版本化交付范围为本方案、源码、相关契约测试及中英文使用说明。提交和上游推送结果以本次 Git 回执为准,不在文档内回写自身提交 SHA,避免提交后再次改脏交付记录。没有执行 npm 包发布、自动安装或生产部署。 + +### 保留的验证边界 + +- AC-01、AC-06–08、AC-11、AC-14 中涉及真实 agent 决策、漏审/误报、审核次数及继续/停止行为的部分,尚未通过宿主实验验证。已存在的确定性契约不能代替这些结论。 +- AC-12 的机器检查限于文件范围、提交及真实上游关系;同一文件中的他人修改、语义上的范围扩张仍由人工/审核判断。只修改范围与证据不能授权混入他人代码。 +- 内容快照尚不支持子模块及仓库外符号链接;外部服务、环境配置的语义变化也无法仅凭 Node/平台标识证明。需要真实环境证据时明确标出缺口,不使用猜测的证明。 +- 新 shared man 的完整真实宿主/远程协作场景尚未实测;原 transport/fence 契约保持,并通过既有回归。未把这些回归称作新场景的端到端证明。 + +P5 未开始。当前测试证明确定性机制,不证明 agent 在真实宿主中的漏审率、误报率、停止/继续判断或时间成本。真实 agent 对照与重复运行、用户项目验收仍待测试位置及环境授权;这不是业务阻塞,不提前宣称“已达生产保障”。 + +### 真实宿主试验反馈修订(2026-09-03) + +用户提供的隔离测试项目 `mancode测试`,结果记录于该项目的 `docs/results.md`。候选和旧流程的业务实现均通过真实 HTTP oracle 10/10,但候选仍暴露出以下生产交付缺口。本节修订不扩大到 Solo、`manba`、`manteam`、`manps` 或 `mansolo`。 + +1. **审核进程结束不等于模块审核完成。** 植入跨租户泄漏和吞错的样例中,reviewer 发现一个问题后提前退出且宿主进程返回成功,但 review ledger 仍为 `stale`。公共运行时必须把它明确显示为 `review_incomplete`,给出下一步;不能让进程退出码或自然语言总结冒充 review passed。 +2. **验收要求和验证证据必须分别声明期望与实际观察层级。** 新 `/man` 的必需 acceptance criterion 通过 `verificationSurfaces` 为 automated/manual 槽位记录精确期望;实际交付证据记录 `unit`、`component`、`handler`、`real_http`、`browser`、`device`、`external_service` 或 `manual_observation`。发生环境降级时必须记录实际层级;实际值与期望值不一致时保持未验证,不能把 handler 测试标成真实 HTTP。机器不根据命令字符串猜测语义,也不把声明本身当作真实性证明。 +3. **scope 错误要能直接修复。** `implementationScope.include` / `exclude` 只接受 repo-relative path 或 glob;语义范围继续写入 requirements。计划文件未被 include 覆盖时,错误必须指出实际路径,并提示添加该精确路径或覆盖 glob。不能用难以证明正确的词表把所有中文根文件或无扩展名文件一律判非法。 +4. **轻量化只合并重复动作,不削弱门禁。** 每次 delivery mutation 应返回最新 revision、当前收尾阻塞项和准确 next action,减少重复 status/context/inspect。一个真实验证命令仍可覆盖多个明确验收 ID;模块完成后仍只做一次总审。治理动作 7–10 次是优化目标而非硬阈值,本轮不新增大型 finalize 编排器。 + +本轮修复的实施范围: + +- `src/commands/man-delivery.ts`:验证层级输入、mutation 后收尾状态和可执行结果。 +- `src/context/man-delivery-evidence.ts`、`src/context/verification-ledger.ts`、`src/context/man-delivery-runtime.ts`:层级契约、结构化收尾阻塞项、具体完成错误、交付记录投影。 +- `src/installers/v3-adapter.ts`:只更新新 `/man` 指引,明确 reviewer 早退和 path/glob 边界。 +- 对应 `tests/man-delivery*.test.ts`、`tests/v3-adapter-contracts.test.ts`:先固定失败样例,再实现。 + +本轮新增验收: + +- `delivery inspect` 对 review/verification/record/commit 的未完成状态返回稳定的结构化 blocker 和 next action;`delivery check` 使用对应具体错误码。 +- reviewer 进程即使 exit 0,只要 ledger 仍为 `pending`、`stale`、`in_review` 或 `blocked` 就不能显示 ready,也不能完成任务。 +- 新 `/man` 的必需验收槽位必须在 requirements 声明 `verificationSurfaces`,通过的验证证据必须包含相同的合法 observation surface;旧 requirements、旧验证和非 man ledger 可继续解析没有该字段的历史记录。 +- `delivery verify` / `confirm` 拒绝缺失或非法 surface,失效证据清空旧 surface,交付记录和 inspect 可看到实际 surface。 +- 计划路径未纳入 implementation scope 时,错误包含未覆盖的 repo-relative 路径和可执行修正建议。 +- 不增加逐片段审核、重复验证、强制 reviewer 数量或通用模式门禁;其他模式契约保持通过。 + +### 本轮修复交付记录 + +已按上述边界完成候选修复:`delivery inspect` 及 delivery mutation 回执现在返回结构化 `finalization`;完成检查分别报告 review、verification、交付记录、scope commit 和未提交变更。reviewer 进程返回成功但 ledger 未通过时仍显示 `review_incomplete`。requirements 保存每个必需槽位的期望 observation surface,交付证据保存实际 surface;历史 ledger 可继续读取缺失字段,但新 `/man` 不能用缺失或不匹配的 passed evidence 完成交付。具体文件使用真实 glob 匹配,范围外未提交文件会单独阻塞,计划文件不在 scope 时会回报具体路径及修正方式。 + +实现没有新增逐段审核、额外 reviewer、固定命令次数或大型 finalize 编排器。结构化收尾状态集中在现有 delivery runtime;其他模式未启用新门禁。权威 mutation 后的投影或 finalization 读取失败仍返回 mutation 成功及 `inspection_failed` diagnostic,不把确定性检查失败误报为 delivery record stale,也不把后置投影失败伪装成写入失败。 + +验证结果: + +- 针对性 `/man`、delivery runtime、evidence 和 adapter 契约:60 个用例通过。 +- 全量:132 个测试文件、1004 个用例通过。 +- `npm run typecheck`、`npm run lint`、`npm run build`、`npm run test:dist` 和 `git diff --check` 通过;分发产物验证覆盖 10 个 adapter。 + +本轮自审检查了目标覆盖、完成门禁次序、历史 ledger 兼容、投影失败语义和模式隔离。没有发现需要新增防御层或通用抽象的问题。当前结论仍是测试候选,不把结构化证据声明等同于真实性证明,也不宣称已经覆盖所有生产项目和宿主权限差异。 + +### 调研依据 + +前序调研使用官方仓库当时的 `main` 流程文件,不代表这些机制已完成同项目效果对照,也不把其全部仪式引入 man: + +- [Superpowers:计划编写](https://github.com/obra/superpowers/blob/main/skills/writing-plans/SKILL.md):可验证任务与计划覆盖。 +- [Superpowers:模块执行与审查](https://github.com/obra/superpowers/blob/main/skills/subagent-driven-development/SKILL.md):任务审查与修复范围复核。 +- [Superpowers:完成前验证](https://github.com/obra/superpowers/blob/main/skills/verification-before-completion/SKILL.md):真实证据与完成声明。 +- [Trellis:工作流](https://github.com/mindfold-ai/Trellis/blob/main/packages/cli/src/templates/trellis/workflow.md):任务交付边界、相关规范与最终集成检查。 +- [Trellis:实施上下文](https://github.com/mindfold-ai/Trellis/blob/main/packages/cli/src/templates/claude/agents/trellis-implement.md):上下文注入及失效后的显式读取。 diff --git a/docs/platform-adapters.md b/docs/platform-adapters.md index 4ee24ba..10d7048 100644 --- a/docs/platform-adapters.md +++ b/docs/platform-adapters.md @@ -57,6 +57,10 @@ adapter upgrade 先在 staging 中生成预览,用户确认后再通过 journa ## Legacy hooks +初始化生成的 `AGENTS.md` / `CLAUDE.md` 现在包含限定于新 `/man` 模块交付策略的文档交接和执行效率规则。项目计划目录优先沿用明确约定,默认 `doc/`;不强制其他模式建计划、审核或提交。规则不保存任务状态,也不授权修改未批准的业务代码。 + +新 man 入口显式使用 `workflow create man --delivery`,引导一次模块总审、真实验证、文档回写和提交/发布分离。`manba`、`manteam`、`manps`、`mansolo` 的入口流程不变。详细数据格式与限制见 [新 man 模块交付](./workflows.md#新-man一次模块审核与文档交付)。仅更新源码不会改写已安装文件;现有项目仍走上文的 adapter upgrade 预览和确认流程。 + 只有 `mancode init --legacy` 安装读取 `.mancode/state.json` 的旧 Claude Code hooks。Continuity adapter 不应创建、读取或刷新 legacy authority。 Continuity 的 Claude Code bootstrap 位于根目录 `CLAUDE.md` 的 `mancode:continuity:claude` 托管区,确保普通 Solo 请求也会加载;原有 mode skills 仍位于 `.claude/skills/`。Cursor bootstrap 位于 `.cursor/rules/mancode-continuity.mdc`,其他嵌入式托管区同样使用 `mancode:continuity:*` 标记。升级时只自动移除带 mancode 旧管理标记的 `mancode-v3`/旧 Continuity bootstrap 或托管区;用户在 `CLAUDE.md` 和同名旧文件中的自写内容会保留。 diff --git a/docs/workflows.md b/docs/workflows.md index 0df1bc5..a7fa017 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -46,10 +46,107 @@ draft 的 `blockingUnknowns` 必须列出开放决定;scope、coverage、techn 6. 运行验证并确定 targeted/full 审查范围。 7. 质量审查。 8. 仅在 full 深度执行安全与边界审查。 -9. 最多一轮 blocker 修复、复验、summary 和完成。 +9. 必要问题修复、复验、交付记录和完成。新模块交付策略按下节收敛复核,不叠加审核流水线;旧任务仍遵循原策略。 需求未 ready、计划未确认、执行任务缺少非空 implementation scope、验证失败、审查 blocker 未清零、存在活动子任务或未完成 repair 时,任务不能完成。升级前已进入执行阶段的本地 Man 任务可在用户确认完整边界后,用内容不变的当前 plan 和 `--scope-file` 执行一次兼容 plan revision;它只补绑 scope,并使旧 review/verification 失效。 +### 新 `/man`:一次模块审核与文档交付 + +新入口创建任务时传 `--delivery`,显式选用 planning policy 3。省略该选项仍沿用项目默认策略。只有 `man` 可启用;旧任务不能静默升级或降级,其他模式及 Solo handoff 不受影响。更新运行时后,还需按 adapter upgrade 协议更新宿主入口,不能只手改 skill。 + +policy 3 的每个必需验收项还要按证据槽位声明精确的期望观察面。例如自动化真实 HTTP 验收使用 `{"id":"AC-1","description":"真实 HTTP 返回约定结果","required":true,"method":"automated","verificationSurfaces":{"automated":"real_http"}}`;manual 使用 `manual`,hybrid 同时声明 `automated` 和 `manual`。历史 requirements 和非 delivery 任务仍可读取缺少该字段的记录。 + +默认路径:一份模块计划 → 实现与相关验证 → 回写待审 → 一次总审 → 必要修复与定向复核 → 提交与完成。只讨论/规划不授权实现。模块以可独立验收的结果划分,不以文件或函数划分。审核既检查“目标到实现”的缺漏,也检查“改动到目标”的偏离,并检查具体缺陷和不必要的复杂度。允许零 finding;可选改进不阻止交付。 + +#### 绑定一份计划 + +优先用用户指定或项目已有的计划目录,新项目默认 `doc/`。本项目使用 `docs/`。选定路径随计划权威保存,远程接续不需要再猜目录;没有新增全局目录配置或第二份计划。文件须可正常版本化,不能强制添加被忽略的私有资料。架构资料不可用时,只为会改变实现且无法从计划/现有契约得出的细节请求确认。 + +```markdown + +# 导出模块 +目标、包含/排除范围、相关架构依据、阶段、验收 ID 与验证方法、未决问题。 + + + +尚未实现。 + +``` + +四个区块标记必须独占一行且唯一、有序;代码围栏内示例不参与解析。进度任务标记可省略。基线变化需重新确认;交付区回写不增加计划版本、不改变批准目标。运行时只更新交付区,不覆盖外部手写内容。 + +```bash +mancode workflow create man "导出模块" --delivery --session --client --json +# 按已有 requirements 协议完成澄清和 finalize 后: +mancode workflow plan revise --file docs/export.md --scope-file scope.json --expected-revision --session --client --json +mancode workflow plan confirm --plan-decision governed_execution --expected-revision --session --client --json +``` + +每步使用上一结果的新 revision。`scope.json` 的 include 要覆盖计划文件及获授权的进度页面;exclude 仍优先。无 Git 仍能规划和绑定文档,但不能声称版本化交付完成。 + +#### 验证与总审 + +JSON 临时输入放在 `.mancode/local/drafts/`,避免把审核输入本身计入被测源码。自动化输入示例为 `{"argv":["npm","test"],"surface":"component"}`;`surface` 必须是实际观察面并与该验收槽位的 `verificationSurfaces` 精确一致。实际选择与验收相称的命令,不为同一事实反复全量测试。 + +```bash +mancode workflow delivery sync --expected-revision --session --client --json +mancode workflow delivery verify --acceptance AC-1 --file .mancode/local/drafts/check.json --expected-revision --session --client --json +mancode workflow delivery inspect --json +``` + +`verify` 无 shell 地执行 argv,返回实际 stdout、stderr 和 exitCode,并通过原 journal 写证据。一个命令确实覆盖多个验收项时,可用 `--acceptance AC-1,AC-2` 一次运行并关联多个槽位,不能为逐项登记重复执行同一套测试。CLI 返回 0 表示录入成功,不表示测试通过;查看 `commandResult.exitCode` 和 finalization 状态。命令运行期间源码改变时不记录“通过”。手动/hybrid 验收使用 `confirm` 替代 `verify`,输入 `{"confirmed":true,"surface":"manual_observation","summary":"真实观察或用户确认的来源、结果与非敏感环境"}`;返回 manualConfirmation、记录当前 actor,不能把自述冒充独立认证。实际 surface 缺失或与 requirements 不一致时,即使底层 verification ledger 已记录 passed,最终门禁仍保持 `verification_incomplete`。 + +实现完整模块后,一名 reviewer 尽可能在获授权的独立上下文审核;无法独立时明确自审。使用批准计划、相关架构、`inspect.source.baseHead` 以来完整 diff、入口调用链与验证证据,不只阅读实现者总结。审核输入: + +```json +{ + "subject": { "contentDigest": "从 inspect.subject 原样复制", "environment": "从 inspect.subject 原样复制" }, + "reviewer": "self", + "direction": "各验收如何落到入口/调用链;全部改动为何属于计划", + "correctness": "主链路和相关失败路径的实际证据、具体风险", + "proportionality": "抽象和防御对应哪些真实约束,有无冗余", + "nextAction": "仅继续已经授权的下一模块,否则结束", + "coverage": [{ "acceptanceId": "AC-1", "status": "met", "evidence": "实现入口与真实验证结果" }], + "findings": [], + "resolved": [] +} +``` + +subject 占位文字不是有效摘要,须替换为 `inspect` 结果。`reviewer` 可为 `self` 或 `independent`,但它只是调用者自述的审核元数据,不绑定另一 actor/session,不能单独证明独立身份;coverage 状态为 `met`、`missing` 或 `unverified`。必修 finding 形如 `{"id":"R-1","domain":"quality","severity":"p1","summary":"因果证据及影响"}`;domain 为 quality/security,severity 为 p0/p1/p2。修复后在 resolved 列出原 finding ID,不通过删掉问题记录来放行。 + +```bash +mancode workflow delivery review --file .mancode/local/drafts/review.json --review-depth targeted --expected-revision --session --client --json +``` + +涉及实质安全风险时使用 full;同一次总审可给出 quality/security 结论,不拆成三轮审核。复核只覆盖修复及直接回归,无新诊断依据时停止重复操作。每次 verify、confirm、review 自动回写交付记录;投影失败会返回 `deliveryRecord.status=pending` 及原始原因,已成功的账本写入不回滚,用 sync 重试即可。 + +内容摘要用于证据适用性,不证明功能正确。当前实现保守覆盖 Git 索引、工作区、非忽略未追踪文件,排除 `.mancode/` 和进度页;计划只计批准基线。仅提交或回写记录不废弃测试;源码改变会让旧证据过期,重新验证时清空不适用的其他槽位。外部依赖/环境变化不能仅靠本机 Node/平台标识检测,需主动重新验证。仓库内子模块及外部符号链接尚不支持自动证明,错误必须明确处理,不能猜测适用性。 + +#### 完成、发布与本地视图 + +先 sync,再提交本任务的源码、计划和必要页面变更。不要全库 add,不混入他人改动。 + +```bash +mancode workflow delivery check --json +mancode workflow complete --expected-revision --session --client --json +# 若已获授权且当前分支已有上游,正常推送后,可只读核实真实上游: +mancode workflow delivery publication --json +``` + +check 检查证据、计划回写、任务文件提交及范围,complete 仍重新执行原有 authority/子任务/repair/claim 门禁。范围外未提交文件不要求加入本任务提交,但必须先移出当前 checkout、stash 或单独提交,因为它可能参与本次验证;同一文件内的他人改动仍需人工区分。基线之后的范围外提交会被拒绝,需处理或重新确认范围,不能静默归为本任务。后置读取失败返回 `inspection_failed` 及原始 diagnostic,不再误报为 delivery record stale。 + +`publication` 只查询现有 upstream,不 fetch、push 或设置 remote;结果为 published、unpublished 或 unverified。没有 remote/上游或 push 失败不属于业务阻塞,报告“交付未发布”;查询失败不能冒称已发布。shared 的业务分支发布不等同于 Continuity transport 同步,保留既有显式同步和 fence 协议。 + +可选 `项目进度.html` 只识别以下精确数据契约,不解析/猜测 UI: + +```html + +``` + +taskId 来自基线中的显式 progress-task 标记,未提供时为完整 TaskRef。sync 只更新唯一匹配记录的 status/reason;保留其他内容并转义 script 终止符。页面不存在、契约损坏/缺失、ID 不唯一或不在写入范围时返回 absent/manual_sync,不阻止开发。普通修复为“进行中”,已验证待审为“待审核”,审核和验收通过为“已完成”;只有业务状态 blocked 且存在未决外部确认时显示“阻塞”。未开发任务不更新。页面状态不是发布状态,也不替代运行时权威。 + ## 状态与 revision 工作流状态为 `in_progress`、`planned`、`blocked`、`completed` 或 `abandoned`。终态不可恢复;`blocked` 只能在阻塞条件被显式处理后回到 `in_progress`。 @@ -113,6 +210,17 @@ mancode workflow checkpoint local: show --json archive 输出会校验归档摘要,并返回 reframe 前的 requirements 与 plan;checkpoint 输出返回该次 reframe 的完整 checkpoint。这两个命令不修改 workflow,也不需要 `--session`。 +新版本会在 reframe 创建 journal 或修改业务 authority 前拒绝已存在的 checkpoint ID。仅当旧版本已经留下一个 `repair_required` reframe,且普通 `operation repair` 明确因为该 ID 被另一 operation 的 checkpoint 占用而无法前向恢复时,才使用定向替换: + +```bash +mancode operation show --json +mancode operation repair \ + --replacement-checkpoint-id \ + --session --client --json +``` + +该命令只重绑定原 reframe 的 checkpoint、最终 metadata、aggregate 与 task-head fence 目标,然后继续原 operation;它不会删除或覆盖占用旧 ID 的 checkpoint。非 reframe、非 `repair_required`、存在 secondary reservation、资源已漂移或 replacement ID 已占用时都会拒绝。若换绑后再次中断,非终态恢复必须携带同一个 replacement ID 精确重试;终态 operation 不带替换参数时仍返回通用终态结果,携带替换参数时只接受原 replacement ID。其他中断仍使用不带该参数的 `operation repair`;不得通过删除 operation journal、recovery payload、checkpoint 或任务状态解除 adapter upgrade 门禁。 + ## Session 与 Context Pack session 是 checkout-local 的调用身份,不决定任务是否完成。没有真实宿主传播证据时,mutating command 必须显式传 `--session`。 diff --git a/package-lock.json b/package-lock.json index 0794453..f21c6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "vitest": "^3.2.6" }, "engines": { - "node": ">=22" + "node": ">=22.5.0" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index f6c8de2..020e483 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "zcode" ], "engines": { - "node": ">=22" + "node": ">=22.5.0" }, "bin": { "mancode": "dist/cli.js" diff --git a/src/cli.ts b/src/cli.ts index b485530..eb85505 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -276,6 +276,10 @@ export function createCliProgram(): Command { 'Policy v2: clarification only; use workflow review skip for review', ) .option('--review-depth ', 'Review depth: targeted or full') + .option( + '--delivery', + 'Opt in a new man task to document-bound module delivery', + ) .option('--review-domain ', 'Review domain: quality or security') .option( '--report ', @@ -577,6 +581,10 @@ export function createCliProgram(): Command { operationProgram .command('repair ') .description('Repair an operation using its original actor and session') + .option( + '--replacement-checkpoint-id ', + 'Replace only a conflicted reframe checkpoint target', + ) .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') .option('--client ', 'Client identity (default: mancode-cli)') .option('--json', 'Output as JSON (for scripts)') diff --git a/src/commands/man-delivery.ts b/src/commands/man-delivery.ts new file mode 100644 index 0000000..acd3f68 --- /dev/null +++ b/src/commands/man-delivery.ts @@ -0,0 +1,494 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { assertTaskCompletionGate } from '../context/aggregate.js'; +import { createUlid } from '../context/ids.js'; +import { + parseManReviewEvidence, + parseManVerificationSurface, +} from '../context/man-delivery-evidence.js'; +import { + type ManDeliveryFinalization, + assertManDeliveryReady, + captureManSubject, + inspectManDelivery, + inspectManPublication, + isManDelivery, + manInspectionFailureFinalization, + syncManDeliveryRecord, +} from '../context/man-delivery-runtime.js'; +import { + type ReviewLedgerV1, + deriveReviewLedgerStatus, + reviewLedgerDigest, +} from '../context/review-ledger.js'; +import { applyV3ReviewLedger } from '../context/review-remediation.js'; +import type { StoredTaskSnapshot } from '../context/store.js'; +import { parseTaskRef } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import type { VerificationComponentEvidence } from '../context/verification-ledger.js'; +import { + deriveVerificationLedgerStatus, + verificationLedgerDigest, +} from '../context/verification-ledger.js'; +import { recordV3Verification } from '../context/verification-record.js'; +import { openV3TaskOperation } from '../runtime/task-operation.js'; +import { + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; +import type { WorkflowOptions } from './workflow.js'; + +const execFile = promisify(execFileCallback); + +/** Thin CLI orchestration: canonical evidence still goes through existing journaled writers. */ +export async function manDeliveryCommand( + root: string, + args: string[], + options: WorkflowOptions, +): Promise { + try { + const [ref, action] = args; + if ( + !ref || + args.length !== 2 || + ![ + 'inspect', + 'check', + 'publication', + 'sync', + 'verify', + 'confirm', + 'review', + ].includes(action ?? '') + ) { + throw new Error( + 'MANCODE_MAN_DELIVERY_ARGUMENT_INVALID: delivery ', + ); + } + const project = await readV3CommandProject(root); + const taskRef = parseTaskRef(ref); + let task = await project.store.readTaskSnapshot(taskRef); + if (!isManDelivery(task.metadata)) + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + if (action === 'publication') + return printV3Result( + options.json, + await inspectManPublication(project.projectRoot), + ); + if (action === 'inspect') + return printV3Result(options.json, { + ...(await inspectManDelivery(project.projectRoot, task)), + acceptanceCriteria: task.requirements.acceptanceCriteria, + review: task.review, + verification: task.verification, + }); + if (action === 'check') { + await assertManDeliveryReady(project.projectRoot, task); + assertTaskCompletionGate( + { ...task, planDigest: task.plan?.digest ?? null }, + { + activeChildTaskRefs: + await project.store.listActiveChildTaskRefs(taskRef), + hasPendingRepairOperation: false, + activeClaimCount: 0, + }, + ); + return printV3Result(options.json, { + delivery: 'ready_for_complete', + publication: 'not_checked', + note: 'complete rechecks operation authority; upstream publication requires an actual push result', + }); + } + const expectedTaskRevision = Number(options.expectedRevision); + if (!Number.isSafeInteger(expectedTaskRevision) || expectedTaskRevision < 1) + throw new Error('MANCODE_EXPECTED_REVISION_REQUIRED'); + if (options.sync) throw new Error('MANCODE_GIT_REF_DEFERRED_SYNC_REQUIRED'); + const session = await resolveV3CommandSession(project, options); + // Validate authority before executing a command or writing a document, not after it. + const context = await openV3TaskOperation({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + }); + const actorId = context.session.actorId; + try { + task = context.task; + if (action === 'sync') { + const progress = await syncManDeliveryRecord(project.projectRoot, task); + const synced = await project.store.readTaskSnapshot(taskRef); + const finalization = await inspectFinalization( + project.projectRoot, + synced, + ); + return printV3Result(options.json, { + deliveryRecord: 'synced', + progress, + revision: synced.metadata.revision, + finalization, + }); + } + if ( + task.metadata.governance.planDecision !== 'governed_execution' || + task.metadata.currentStep < 5 || + task.metadata.status !== 'in_progress' + ) { + throw new Error('MANCODE_MAN_DELIVERY_EXECUTION_REQUIRED'); + } + } finally { + await context.release(); + } + + if (!options.file) throw new Error('MANCODE_MAN_DELIVERY_INPUT_REQUIRED'); + const input: unknown = JSON.parse( + await readFile(path.resolve(project.projectRoot, options.file), 'utf8'), + ); + let output: unknown; + if (action === 'verify' || action === 'confirm') { + assertRecord(input, 'verification command'); + assertKnownKeys( + input, + action === 'verify' + ? ['argv', 'surface'] + : ['confirmed', 'summary', 'surface'], + 'verification command', + ); + if ( + action === 'verify' && + (!Array.isArray(input.argv) || + !input.argv.length || + input.argv.some( + (arg) => typeof arg !== 'string' || arg.includes('\0'), + ) || + !input.argv[0]) + ) + throw new Error('MANCODE_MAN_VERIFY_ARGV_INVALID'); + if ( + action === 'confirm' && + (input.confirmed !== true || + typeof input.summary !== 'string' || + !input.summary.trim()) + ) + throw new Error('MANCODE_MAN_EXPLICIT_CONFIRMATION_REQUIRED'); + const surface = parseManVerificationSurface(input.surface); + const checks = verificationChecks(task); + const acceptanceIds = + options.acceptance?.split(',').map((id) => id.trim()) ?? []; + const component = action === 'verify' ? 'automated' : 'manual'; + if ( + !acceptanceIds.length || + new Set(acceptanceIds).size !== acceptanceIds.length || + acceptanceIds.some( + (id) => + !checks.some( + (check) => check.displayId === id && check[component] !== null, + ), + ) + ) + throw new Error('MANCODE_MAN_ACCEPTANCE_SLOT_REQUIRED'); + const subject = await captureManSubject(project.projectRoot, task); + let result: { stdout: string; stderr: string; exitCode: number } = { + stdout: '', + stderr: '', + exitCode: 0, + }; + if (action === 'verify') { + const argv = input.argv as string[]; + try { + const run = await execFile(argv[0] as string, argv.slice(1), { + cwd: project.projectRoot, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + }); + result = { ...run, exitCode: 0 }; + } catch (error) { + const failed = error as { + code?: unknown; + stdout?: string; + stderr?: string; + }; + if (typeof failed.code !== 'number') throw error; + result = { + exitCode: failed.code, + stdout: failed.stdout ?? '', + stderr: failed.stderr ?? '', + }; + } + } + if ( + (await captureManSubject(project.projectRoot, task)).contentDigest !== + subject.contentDigest + ) + throw new Error('MANCODE_MAN_VERIFICATION_CHANGED_DURING_RUN'); + const timestamp = new Date().toISOString(); + const invalidate = ( + evidence: VerificationComponentEvidence | null, + ): VerificationComponentEvidence | null => { + if ( + !evidence || + (evidence.subject?.contentDigest === subject.contentDigest && + evidence.subject.environment === subject.environment) + ) + return evidence; + const { + subject: _oldSubject, + surface: _oldSurface, + ...rest + } = evidence; + return { + ...rest, + status: 'pending', + summary: null, + command: null, + exitCode: null, + artifactRef: null, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: timestamp, + }; + }; + const draft = { + ...task.verification, + requirementsDigest: task.requirements.contentDigest, + planVersion: task.metadata.governance.planVersion, + remediationRound: task.review.remediationRound, + checks: checks.map((item) => { + const next = { + ...item, + automated: invalidate(item.automated), + manual: invalidate(item.manual), + }; + const slot = item[component]; + if (acceptanceIds.includes(item.displayId) && slot) + next[component] = { + ...slot, + subject, + surface, + status: result.exitCode === 0 ? 'passed' : 'failed', + command: action === 'verify' ? JSON.stringify(input.argv) : null, + exitCode: action === 'verify' ? result.exitCode : null, + summary: + action === 'verify' + ? `Executed argv in project root; captured exit code ${result.exitCode}.` + : (input.summary as string), + confirmedByActorId: action === 'confirm' ? actorId : null, + confirmationSource: action === 'confirm' ? 'actor' : null, + updatedAt: timestamp, + }; + return next; + }), + }; + const current = { + ...draft, + status: deriveVerificationLedgerStatus(draft), + }; + output = { + ...(await recordV3Verification({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + verification: { + ...current, + contentDigest: verificationLedgerDigest(current), + }, + })), + ...(action === 'verify' + ? { commandResult: result } + : { manualConfirmation: { actorId, summary: input.summary } }), + }; + } else { + output = await applyV3ReviewLedger({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + review: reviewInput(task, input, options.reviewDepth), + }); + } + const updated = await project.store.readTaskSnapshot(taskRef); + // A projection failure must not masquerade as a failed authority mutation. + let deliveryRecord: { status: string; error?: string; progress?: unknown } = + { status: 'synced' }; + try { + deliveryRecord.progress = await syncManDeliveryRecord( + project.projectRoot, + updated, + ); + } catch (error) { + deliveryRecord = { + status: 'pending', + error: error instanceof Error ? error.message : String(error), + }; + } + const finalization = await inspectFinalization( + project.projectRoot, + updated, + ); + return printV3Result(options.json, { + result: output, + deliveryRecord, + revision: updated.metadata.revision, + finalization, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_MAN_DELIVERY_FAILED'), + error instanceof Error ? error.message : String(error), + ); + } +} + +async function inspectFinalization( + root: string, + task: StoredTaskSnapshot, +): Promise { + try { + return (await inspectManDelivery(root, task)).finalization; + } catch (error) { + return manInspectionFailureFinalization(error); + } +} + +function verificationChecks(task: StoredTaskSnapshot) { + const emptySlot = (): VerificationComponentEvidence => ({ + evidenceId: createUlid(), + status: 'pending', + summary: null, + command: null, + exitCode: null, + artifactRef: null, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: null, + }); + return task.requirements.acceptanceCriteria.map((criterion) => { + const previous = task.verification.checks.find( + (check) => + check.criterionId === criterion.criterionId && + check.verificationRequirement === criterion.verificationRequirement, + ); + return ( + previous ?? { + displayId: criterion.displayId, + legacyId: criterion.legacyId, + checkId: createUlid(), + criterionId: criterion.criterionId, + required: criterion.required, + verificationRequirement: criterion.verificationRequirement, + automated: + criterion.verificationRequirement === 'manual' ? null : emptySlot(), + manual: + criterion.verificationRequirement === 'automated' + ? null + : emptySlot(), + } + ); + }); +} + +function reviewInput( + task: StoredTaskSnapshot, + input: unknown, + depth: string | undefined, +): ReviewLedgerV1 { + assertRecord(input, 'module review'); + const { findings = [], resolved = [], ...report } = input; + const delivery = parseManReviewEvidence(report); + if ( + !Array.isArray(findings) || + !Array.isArray(resolved) || + resolved.some((id) => typeof id !== 'string') + ) + throw new Error('MANCODE_MAN_REVIEW_FINDINGS_INVALID'); + if ( + resolved.some( + (id) => !task.review.blockers.some((blocker) => blocker.displayId === id), + ) + ) + throw new Error('MANCODE_MAN_REVIEW_UNKNOWN_FINDING'); + const blockers = task.review.blockers.map((blocker) => + resolved.includes(blocker.displayId) + ? { ...blocker, status: 'resolved' as const } + : blocker, + ); + const ids = new Set(); + for (const finding of findings) { + assertRecord(finding, 'module review finding'); + assertKnownKeys( + finding, + ['id', 'domain', 'severity', 'summary'], + 'module review finding', + ); + if ( + typeof finding.id !== 'string' || + !finding.id.trim() || + ids.has(finding.id) || + typeof finding.summary !== 'string' || + !finding.summary.trim() || + (finding.domain !== 'quality' && finding.domain !== 'security') || + !['p0', 'p1', 'p2'].includes(String(finding.severity)) + ) + throw new Error('MANCODE_MAN_REVIEW_FINDINGS_INVALID'); + ids.add(finding.id); + const previous = blockers.find((item) => item.displayId === finding.id); + const entry: ReviewLedgerV1['blockers'][number] = { + displayId: finding.id, + legacyId: null, + blockerId: previous?.blockerId ?? createUlid(), + domain: finding.domain, + severity: finding.severity as 'p0' | 'p1' | 'p2', + status: 'open' as const, + summary: finding.summary, + waiver: null, + }; + if (previous) blockers[blockers.indexOf(previous)] = entry; + else blockers.push(entry); + } + if (depth !== undefined && depth !== 'targeted' && depth !== 'full') + throw new Error('MANCODE_MAN_REVIEW_DEPTH_INVALID'); + const full = + depth === 'full' || + task.review.depth === 'full' || + blockers.some((item) => item.domain === 'security'); + const requiredDomains = full + ? (['quality', 'security'] as const) + : (['quality'] as const); + const covered = task.requirements.acceptanceCriteria + .filter((item) => item.required) + .every((criterion) => + delivery.coverage.some( + (row) => + row.acceptanceId === criterion.displayId && row.status === 'met', + ), + ); + const draft: ReviewLedgerV1 = { + ...task.review, + delivery, + depth: full ? 'full' : 'targeted', + requiredDomains: [...requiredDomains], + requirementsDigest: task.requirements.contentDigest, + planVersion: task.metadata.governance.planVersion, + domains: requiredDomains.map((domain) => ({ + domain, + status: + blockers.some( + (item) => item.domain === domain && item.status === 'open', + ) || !covered + ? 'blocked' + : 'passed', + reportRef: null, + })), + blockers, + skip: null, + remediationRound: task.review.remediationRound + (resolved.length ? 1 : 0), + }; + const current = { ...draft, status: deriveReviewLedgerStatus(draft) }; + return { ...current, contentDigest: reviewLedgerDigest(current) }; +} diff --git a/src/commands/operation.ts b/src/commands/operation.ts index 8429600..d86a0b6 100644 --- a/src/commands/operation.ts +++ b/src/commands/operation.ts @@ -1,4 +1,4 @@ -import { assertUlid } from '../context/ids.js'; +import { assertUlid, isUlid } from '../context/ids.js'; import { executeOperationRecovery, inspectOperationRecovery, @@ -19,6 +19,7 @@ export interface OperationShowOptions { export interface OperationMutationOptions extends OperationShowOptions { session?: string; client?: string; + replacementCheckpointId?: string; } export async function operationShow( @@ -81,8 +82,22 @@ async function runOperationMutation( EXIT_V3_INVALID_ARGUMENT, ); } + if ( + options.replacementCheckpointId !== undefined && + !isUlid(options.replacementCheckpointId) + ) { + return printV3Error( + options.json, + 'MANCODE_REPLACEMENT_CHECKPOINT_ID_INVALID', + 'replacement checkpoint ID must be a canonical ULID.', + EXIT_V3_INVALID_ARGUMENT, + ); + } try { assertUlid(operationId, 'operationId'); + if (options.replacementCheckpointId !== undefined) { + assertUlid(options.replacementCheckpointId, 'replacementCheckpointId'); + } const project = await readV3CommandProject(rootDir); const session = await resolveV3CommandSession(project, options); const result = await executeOperationRecovery({ @@ -91,6 +106,7 @@ async function runOperationMutation( actorId: session.actorId, sessionId: session.sessionId, mode, + replacementCheckpointId: options.replacementCheckpointId, }); return printV3Result(options.json, { schemaVersion: 1, ...result }); } catch (error) { diff --git a/src/commands/requirements-input.ts b/src/commands/requirements-input.ts index 18b130c..828db91 100644 --- a/src/commands/requirements-input.ts +++ b/src/commands/requirements-input.ts @@ -77,6 +77,9 @@ function buildCanonicalRequirements( statement: item.description, required: item.required, verificationRequirement: item.method, + ...(item.verificationSurfaces === undefined + ? {} + : { verificationSurfaces: item.verificationSurfaces }), })), blockingUnknowns: semantic.blockingUnknowns.map((statement, index) => ({ displayId: `U-${index + 1}`, diff --git a/src/commands/workflow-subcommands.ts b/src/commands/workflow-subcommands.ts index 13b2ff1..1bfbc0f 100644 --- a/src/commands/workflow-subcommands.ts +++ b/src/commands/workflow-subcommands.ts @@ -15,6 +15,7 @@ export const WORKFLOW_SUBCOMMANDS = [ 'child', 'promote', 'handoff', + 'delivery', ] as const; export type WorkflowSubcommand = (typeof WORKFLOW_SUBCOMMANDS)[number]; diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 9b1cf3e..891dd29 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -2,6 +2,7 @@ import { access, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { mergeV3ChildResult } from '../context/child-result-merge.js'; import { type Ulid, assertUlid } from '../context/ids.js'; +import { isManDelivery } from '../context/man-delivery-runtime.js'; import { parseSchemaManifest } from '../context/manifest.js'; import { reviseV3Plan } from '../context/plan-revision.js'; import { @@ -94,6 +95,7 @@ import { completeGitRefTask, updateGitRefWorkflow, } from '../team/git-ref-workflow-operation.js'; +import { manDeliveryCommand } from './man-delivery.js'; import { normalizeRequirementsInput } from './requirements-input.js'; import { commandClient, @@ -116,6 +118,7 @@ export const EXIT_NOT_INITIALIZED = 1; export const EXIT_INVALID_ARG = 2; export interface WorkflowOptions { + delivery?: boolean; dryRun?: boolean; olderThan?: string; json?: boolean; @@ -183,6 +186,13 @@ export async function workflow( if (v3Activation === 'v3_active') { return workflowV3(rootDir, subcommand, args, options); } + if (options.delivery || subcommand === 'delivery') { + return printV3Error( + options.json, + 'MANCODE_MAN_DELIVERY_REQUIRES_CONTINUITY', + 'Man delivery requires Continuity; legacy workflows are unchanged.', + ); + } if (!(await pathExists(path.join(rootDir, '.mancode', 'state.json')))) { if (v3Activation !== null) { return printV3Error( @@ -247,6 +257,7 @@ type WorkflowV3Handler = ( ) => number | Promise; const WORKFLOW_V3_HANDLERS = { + delivery: manDeliveryCommand, create: workflowCreateV3, list: workflowListV3, show: workflowShowV3, @@ -1311,6 +1322,11 @@ async function workflowPlanV3( sessionId: session.sessionId, expectedTaskRevision, plan, + planSource: + action === 'revise' && + isManDelivery((await project.store.readTaskSnapshot(taskRef)).metadata) + ? options.file + : undefined, implementationScope, planDecision: parseV3PlanDecision(options.planDecision), }); @@ -1539,6 +1555,7 @@ async function workflowCreateV3( evaluatedAt: new Date().toISOString(), }); const result = await createV3Workflow({ + delivery: options.delivery, projectRoot: project.projectRoot, task, workflowMode: parsedWorkflowMode, diff --git a/src/context/man-delivery-evidence.ts b/src/context/man-delivery-evidence.ts new file mode 100644 index 0000000..48a0e96 --- /dev/null +++ b/src/context/man-delivery-evidence.ts @@ -0,0 +1,133 @@ +import { assertKnownKeys, assertRecord } from './validation.js'; + +export interface ManEvidenceSubject { + contentDigest: string; + environment: string; +} + +export type ManVerificationSurface = + | 'unit' + | 'component' + | 'handler' + | 'real_http' + | 'browser' + | 'device' + | 'external_service' + | 'manual_observation'; + +const MAN_VERIFICATION_SURFACES = new Set([ + 'unit', + 'component', + 'handler', + 'real_http', + 'browser', + 'device', + 'external_service', + 'manual_observation', +]); + +export interface ManReviewEvidence { + subject: ManEvidenceSubject; + reviewer: 'independent' | 'self'; + direction: string; + correctness: string; + proportionality: string; + nextAction: string; + coverage: Array<{ + acceptanceId: string; + status: 'met' | 'missing' | 'unverified'; + evidence: string; + }>; +} + +export function parseManEvidenceSubject(value: unknown): ManEvidenceSubject { + assertRecord(value, 'man evidence subject'); + assertKnownKeys( + value, + ['contentDigest', 'environment'], + 'man evidence subject', + ); + if ( + typeof value.contentDigest !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(value.contentDigest) + ) { + throw new Error('MANCODE_MAN_EVIDENCE_SUBJECT_INVALID'); + } + return { + contentDigest: value.contentDigest, + environment: text(value.environment), + }; +} + +export function parseManVerificationSurface( + value: unknown, +): ManVerificationSurface { + if ( + typeof value !== 'string' || + !MAN_VERIFICATION_SURFACES.has(value as ManVerificationSurface) + ) + throw new Error('MANCODE_MAN_VERIFICATION_SURFACE_INVALID'); + return value as ManVerificationSurface; +} + +export function parseManReviewEvidence(value: unknown): ManReviewEvidence { + assertRecord(value, 'man review evidence'); + assertKnownKeys( + value, + [ + 'subject', + 'reviewer', + 'direction', + 'correctness', + 'proportionality', + 'nextAction', + 'coverage', + ], + 'man review evidence', + ); + if (value.reviewer !== 'independent' && value.reviewer !== 'self') + throw new Error('MANCODE_MAN_REVIEWER_INVALID'); + if (!Array.isArray(value.coverage)) + throw new Error('MANCODE_MAN_REVIEW_COVERAGE_INVALID'); + const ids = new Set(); + const coverage = value.coverage.map( + (item): ManReviewEvidence['coverage'][number] => { + assertRecord(item, 'man review coverage'); + assertKnownKeys( + item, + ['acceptanceId', 'status', 'evidence'], + 'man review coverage', + ); + const acceptanceId = text(item.acceptanceId); + if ( + ids.has(acceptanceId) || + (item.status !== 'met' && + item.status !== 'missing' && + item.status !== 'unverified') + ) { + throw new Error('MANCODE_MAN_REVIEW_COVERAGE_INVALID'); + } + ids.add(acceptanceId); + return { + acceptanceId, + status: item.status, + evidence: text(item.evidence), + }; + }, + ); + return { + subject: parseManEvidenceSubject(value.subject), + reviewer: value.reviewer, + direction: text(value.direction), + correctness: text(value.correctness), + proportionality: text(value.proportionality), + nextAction: text(value.nextAction), + coverage, + }; +} + +function text(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) + throw new Error('MANCODE_MAN_EVIDENCE_TEXT_REQUIRED'); + return value; +} diff --git a/src/context/man-delivery-plan.ts b/src/context/man-delivery-plan.ts new file mode 100644 index 0000000..0afe970 --- /dev/null +++ b/src/context/man-delivery-plan.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export interface ManPlanSource { + version: 1; + path: string; + baseHead: string | null; +} + +const SOURCE_PREFIX = '', + '', + '', + '', +] as const; + +/** Only unfenced, full-line markers define authority; examples are ordinary text. */ +export function parseManPlanDocument(document: string): { + baseline: string; + record: string; + recordStart: number; + recordEnd: number; +} { + const markers = unfencedLines(document).filter((line) => + (MARKERS as readonly string[]).includes(line.marker), + ); + if ( + markers.length !== 4 || + markers.some((value, index) => value.marker !== MARKERS[index]) + ) { + throw new Error('MANCODE_MAN_PLAN_MARKERS_INVALID'); + } + const [baselineStart, baselineEnd, recordStart, recordEnd] = markers; + if (!baselineStart || !baselineEnd || !recordStart || !recordEnd) { + throw new Error('MANCODE_MAN_PLAN_MARKERS_INVALID'); + } + const baseline = document.slice(baselineStart.end, baselineEnd.start).trim(); + if (!baseline) throw new Error('MANCODE_MAN_PLAN_BASELINE_REQUIRED'); + return { + baseline, + record: document.slice(recordStart.end, recordEnd.start).trim(), + recordStart: recordStart.end, + recordEnd: recordEnd.start, + }; +} + +export function manProgressTaskId(baseline: string): string | null { + const bindings = unfencedLines(baseline) + .map( + (line) => + /^$/.exec( + line.marker, + )?.[1], + ) + .filter((id): id is string => id !== undefined); + if (bindings.length > 1) + throw new Error('ambiguous plan progress task binding'); + return bindings[0] ?? null; +} + +function unfencedLines( + document: string, +): Array<{ marker: string; start: number; end: number }> { + let offset = 0; + let fence: { character: string; length: number } | null = null; + const markers: Array<{ marker: string; start: number; end: number }> = []; + for (const line of document.match(/[^\n]*(?:\n|$)/g) ?? []) { + const text = line.replace(/\r?\n$/, ''); + const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(text); + if (fence !== null) { + if ( + delimiter && + delimiter[1]?.[0] === fence.character && + delimiter[1].length >= fence.length && + delimiter[2]?.trim() === '' + ) + fence = null; + } else if (delimiter?.[1]) { + fence = { + character: delimiter[1][0] as string, + length: delimiter[1].length, + }; + } else { + markers.push({ marker: text, start: offset, end: offset + line.length }); + } + offset += line.length; + } + return markers; +} + +export function replaceManDeliveryRecord( + document: string, + record: string, +): string { + const parsed = parseManPlanDocument(document); + const next = `${document.slice(0, parsed.recordStart)}${record.trim()}\n${document.slice(parsed.recordEnd)}`; + const checked = parseManPlanDocument(next); + if (checked.baseline !== parsed.baseline) + throw new Error('MANCODE_MAN_PLAN_BASELINE_CHANGED'); + return next; +} + +export function assertManPlanPath(file: string): void { + if ( + !file || + path.posix.isAbsolute(file) || + /[\\\r\n\0<>:]/.test(file) || + file.split('/').some((part) => !part || part === '.' || part === '..') || + /^(?:\.git|\.mancode|架构|项目接口)(?:\/|$)/.test(file) || + !file.endsWith('.md') || + !file.includes('/') + ) + throw new Error('MANCODE_MAN_PLAN_PATH_INVALID'); +} + +function parseSource(value: unknown): ManPlanSource { + assertRecord(value, 'man plan source'); + assertKnownKeys(value, ['version', 'path', 'baseHead'], 'man plan source'); + if (value.version !== 1 || typeof value.path !== 'string') { + throw new Error('MANCODE_MAN_PLAN_SOURCE_INVALID'); + } + assertManPlanPath(value.path); + if ( + value.baseHead !== null && + (typeof value.baseHead !== 'string' || + !/^[a-f0-9]{40,64}$/.test(value.baseHead)) + ) { + throw new Error('MANCODE_MAN_PLAN_BASE_INVALID'); + } + return { version: 1, path: value.path, baseHead: value.baseHead }; +} + +/** Store the source binding in the existing canonical plan artifact, not a second state file. */ +export function compileManDeliveryPlan( + source: ManPlanSource, + document: string, +): string { + return `${SOURCE_PREFIX}${JSON.stringify(parseSource(source))} -->\n${parseManPlanDocument(document).baseline}`; +} + +export function parseManDeliveryPlan( + plan: string, +): { source: ManPlanSource; baseline: string } | null { + if (!plan.startsWith(SOURCE_PREFIX)) return null; + const end = plan.indexOf(' -->\n'); + if (end < 0) throw new Error('MANCODE_MAN_PLAN_SOURCE_INVALID'); + const source = parseSource(JSON.parse(plan.slice(SOURCE_PREFIX.length, end))); + const baseline = plan.slice(end + ' -->\n'.length); + if (!baseline.trim()) throw new Error('MANCODE_MAN_PLAN_BASELINE_REQUIRED'); + return { source, baseline }; +} diff --git a/src/context/man-delivery-runtime.ts b/src/context/man-delivery-runtime.ts new file mode 100644 index 0000000..f7ead9c --- /dev/null +++ b/src/context/man-delivery-runtime.ts @@ -0,0 +1,819 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + lstat, + readFile, + readlink, + realpath, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { readCheckoutCodeHead } from '../runtime/project-runtime.js'; +import { digestCanonicalJson } from './canonical.js'; +import type { ManEvidenceSubject } from './man-delivery-evidence.js'; +import { + assertManPlanPath, + compileManDeliveryPlan, + manProgressTaskId, + parseManDeliveryPlan, + parseManPlanDocument, + replaceManDeliveryRecord, +} from './man-delivery-plan.js'; +import { type ManProgressStatus, syncManProgressPage } from './man-progress.js'; +import { scanSharedText } from './privacy.js'; +import type { ReviewLedgerV1 } from './review-ledger.js'; +import type { StoredTaskSnapshot } from './store.js'; +import type { VerificationLedgerV1 } from './verification-ledger.js'; +import type { WorkflowMetadataV3 } from './workflow-metadata.js'; + +const execFile = promisify(execFileCallback); +export const MAN_DELIVERY_POLICY = 3; + +export type ManFinalizationBlockerCode = + | 'plan_execution_required' + | 'review_incomplete' + | 'verification_incomplete' + | 'delivery_record_stale' + | 'inspection_failed' + | 'committed_outside_scope' + | 'uncommitted_outside_scope' + | 'uncommitted_changes'; + +export interface ManDeliveryFinalization { + status: 'ready' | 'incomplete'; + blockers: Array<{ + code: ManFinalizationBlockerCode; + status: string; + nextAction: string; + files?: string[]; + diagnostic?: string; + }>; +} + +export function isManDelivery(metadata: WorkflowMetadataV3): boolean { + return ( + metadata.workflowMode === 'man' && + metadata.governance.policyVersions.planning === MAN_DELIVERY_POLICY && + metadata.governance.planDecision !== 'solo_handoff' + ); +} + +export async function manGit(root: string, args: string[]): Promise { + return ( + await execFile('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) + ).stdout; +} + +async function hasGitWorktree(root: string): Promise { + try { + return ( + (await manGit(root, ['rev-parse', '--is-inside-work-tree'])).trim() === + 'true' + ); + } catch (error) { + if ( + (error as { code?: unknown }).code === 128 && + String((error as { stderr?: unknown }).stderr).includes( + 'not a git repository', + ) + ) + return false; + throw error; + } +} + +export async function readManPlanFile( + root: string, + file: string, +): Promise { + assertManPlanPath(file); + const resolvedRoot = await realpath(root); + const resolved = await realpath(path.join(root, file)); + const relative = path + .relative(resolvedRoot, resolved) + .split(path.sep) + .join('/'); + assertManPlanPath(relative); + if (relative !== file) throw new Error('MANCODE_MAN_PLAN_USE_REAL_PATH'); + if (!(await lstat(resolved)).isFile()) + throw new Error('MANCODE_MAN_PLAN_FILE_REQUIRED'); + // Planning remains available without Git; verified versioned delivery does not. + if (!(await hasGitWorktree(root))) return readFile(resolved, 'utf8'); + // --no-index also catches an already tracked file covered by a private ignore rule. + try { + await manGit(root, ['check-ignore', '--no-index', '-q', '--', relative]); + } catch (error) { + if ((error as { code?: unknown }).code === 1) + return readFile(resolved, 'utf8'); + throw error; + } + throw new Error('MANCODE_MAN_PLAN_IGNORED'); +} + +export async function bindManPlan( + root: string, + file: string, + document: string, + previous: string | null, +): Promise { + if ((await readManPlanFile(root, file)) !== document) + throw new Error('MANCODE_MAN_PLAN_FILE_CHANGED'); + const existing = previous === null ? null : parseManDeliveryPlan(previous); + return compileManDeliveryPlan( + { + version: 1, + path: file, + baseHead: existing + ? existing.source.baseHead + : await readCheckoutCodeHead(root), + }, + document, + ); +} + +export async function readBoundManPlan( + root: string, + task: Pick, +) { + const plan = task.plan && parseManDeliveryPlan(task.plan.content); + if (!plan) throw new Error('MANCODE_MAN_PLAN_SOURCE_REQUIRED'); + const document = await readManPlanFile(root, plan.source.path); + if (parseManPlanDocument(document).baseline !== plan.baseline) + throw new Error('MANCODE_MAN_PLAN_BASELINE_CHANGED'); + return { ...plan, document }; +} + +/** Conservative checkout content identity; no command output or credentials are stored in it. */ +export async function captureManSubject( + root: string, + task: Pick, +): Promise { + const bound = await readBoundManPlan(root, task); + if (!(await hasGitWorktree(root))) + throw new Error( + 'MANCODE_MAN_DELIVERY_GIT_REQUIRED: planning is available; versioned delivery is not', + ); + const files = [ + ...new Set( + ( + await manGit(root, [ + 'ls-files', + '-z', + '--cached', + '--others', + '--exclude-standard', + ]) + ) + .split('\0') + .filter(Boolean), + ), + ].sort(); + const hashes: Array<[string, string]> = []; + for (const file of files) { + if (file.startsWith('.mancode/') || file === '项目进度.html') continue; + const absolute = path.join(root, file); + let contents: Buffer | string; + let kind = 'deleted'; + try { + const stat = await lstat(absolute); + kind = `file:${stat.mode & 0o111}`; + if (stat.isSymbolicLink()) { + kind = 'symlink'; + const target = path.relative( + await realpath(root), + await realpath(absolute), + ); + if (target.startsWith(`..${path.sep}`) || path.isAbsolute(target)) + throw new Error('MANCODE_MAN_EXTERNAL_DEPENDENCY_UNVERIFIED'); + contents = `${await readlink(absolute)}\0${await readFile(absolute, 'base64')}`; + } else if (stat.isFile()) { + contents = + file === bound.source.path + ? bound.baseline + : await readFile(absolute); + } else throw new Error('MANCODE_MAN_CONTENT_NODE_UNSUPPORTED'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + kind = 'deleted'; + contents = ''; + } + hashes.push([ + file, + createHash('sha256').update(`${kind}\0`).update(contents).digest('hex'), + ]); + } + return { + contentDigest: digestCanonicalJson(hashes), + environment: `${process.platform}/${process.arch};node=${process.version}`, + }; +} + +export function assertManReviewCoverage( + task: StoredTaskSnapshot, + review: ReviewLedgerV1, + subject: ManEvidenceSubject, +): void { + if (!isManDelivery(task.metadata)) { + if (review.delivery) throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + return; + } + for (const blocker of task.review.blockers.filter( + (item) => item.status === 'open', + )) { + if (!review.blockers.some((item) => item.blockerId === blocker.blockerId)) + throw new Error( + `MANCODE_MAN_REVIEW_FINDING_DROPPED: ${blocker.displayId}`, + ); + } + if (review.status === 'skipped') return; // Existing explicit skip authorization remains authoritative. + if (!review.delivery && review.status !== 'passed') return; + if ( + !review.delivery || + review.delivery.subject.contentDigest !== subject.contentDigest || + review.delivery.subject.environment !== subject.environment + ) { + throw new Error('MANCODE_MAN_REVIEW_SUBJECT_STALE'); + } + const criteria = task.requirements.acceptanceCriteria; + if ( + review.delivery.coverage.some( + (row) => + !criteria.some((criterion) => criterion.displayId === row.acceptanceId), + ) + ) + throw new Error('MANCODE_MAN_REVIEW_UNKNOWN_ACCEPTANCE'); + if (review.status !== 'passed') return; + for (const criterion of criteria.filter((item) => item.required)) { + if ( + !review.delivery.coverage.some( + (row) => + row.acceptanceId === criterion.displayId && row.status === 'met', + ) + ) { + throw new Error( + `MANCODE_MAN_REVIEW_ACCEPTANCE_MISSING: ${criterion.displayId}`, + ); + } + } +} + +export function assertManVerificationSubjects( + task: StoredTaskSnapshot, + verification: VerificationLedgerV1, + subject: ManEvidenceSubject, +): void { + for (const check of verification.checks) { + for (const evidence of [check.automated, check.manual]) { + if (!isManDelivery(task.metadata)) { + if (evidence?.subject) + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + } else if ( + evidence?.status === 'passed' && + (evidence.subject?.contentDigest !== subject.contentDigest || + evidence.subject.environment !== subject.environment) + ) { + throw new Error( + `MANCODE_MAN_VERIFICATION_SUBJECT_STALE: ${check.displayId}`, + ); + } + } + } +} + +export function manScopeContains( + metadata: WorkflowMetadataV3, + file: string, +): boolean { + return ( + metadata.implementationScope.include.some((include) => + path.matchesGlob(file, include), + ) && + !metadata.implementationScope.exclude.some((exclude) => + path.matchesGlob(file, exclude), + ) + ); +} + +export function assertManPlanInScope( + metadata: WorkflowMetadataV3, + file: string, +): void { + if (!manScopeContains(metadata, file)) + throw new Error( + `MANCODE_MAN_PLAN_OUTSIDE_SCOPE: ${file} is not covered by implementationScope.include; add that exact repo-relative path or a covering glob`, + ); +} + +function manDeliveryFinalization( + task: StoredTaskSnapshot, + subject: ManEvidenceSubject, + recordCurrent: boolean, + outsideScope: string[], + outsideScopeDirty: string[], + pendingCommit: string[], +): ManDeliveryFinalization { + const blockers: ManDeliveryFinalization['blockers'] = []; + if (task.metadata.governance.planDecision !== 'governed_execution') + blockers.push({ + code: 'plan_execution_required', + status: task.metadata.governance.planDecision ?? 'unconfirmed', + nextAction: 'Confirm the bound plan for governed execution.', + }); + + const reviewSubjectCurrent = + task.review.status === 'skipped' || + (task.review.status === 'passed' && + task.review.delivery?.subject.contentDigest === subject.contentDigest && + task.review.delivery?.subject.environment === subject.environment); + if (!reviewSubjectCurrent) + blockers.push({ + code: 'review_incomplete', + status: task.review.status === 'passed' ? 'stale' : task.review.status, + nextAction: + task.review.status === 'blocked' + ? 'Fix the recorded findings, verify the changed module, and submit the targeted review result.' + : 'Complete one module review for the current subject and apply its review ledger.', + }); + + const verification = assessManVerification(task, subject); + if (verification.status !== 'passed') + blockers.push({ + code: 'verification_incomplete', + status: verification.status, + nextAction: verification.nextAction, + }); + + if (!recordCurrent) + blockers.push({ + code: 'delivery_record_stale', + status: 'stale', + nextAction: 'Sync the delivery record after the latest evidence change.', + }); + if (outsideScope.length) + blockers.push({ + code: 'committed_outside_scope', + status: 'blocked', + nextAction: + 'Remove the unrelated committed changes or obtain an approved scope realignment.', + files: outsideScope, + }); + if (outsideScopeDirty.length) + blockers.push({ + code: 'uncommitted_outside_scope', + status: 'blocked', + nextAction: + 'Move, stash, or separately commit unrelated working-tree changes before recording final delivery evidence; do not add them to this task commit.', + files: outsideScopeDirty, + }); + if (pendingCommit.length) + blockers.push({ + code: 'uncommitted_changes', + status: 'pending', + nextAction: 'Commit only the task-owned versionable changes.', + files: pendingCommit, + }); + return { + status: blockers.length ? 'incomplete' : 'ready', + blockers, + }; +} + +export function manInspectionFailureFinalization( + error: unknown, +): ManDeliveryFinalization { + const diagnostic = error instanceof Error ? error.message : String(error); + let nextAction = + 'Resolve the reported inspection diagnostic, then run delivery inspect again.'; + if (diagnostic.includes('MANCODE_MAN_PLAN_BASELINE_CHANGED')) { + nextAction = + 'Restore the approved plan baseline or explicitly reframe the task before inspecting delivery again.'; + } else if (diagnostic.includes('MANCODE_MAN_PLAN_IGNORED')) { + nextAction = + 'Remove the ignore rule for the approved plan file, then inspect delivery again.'; + } else if (diagnostic.includes('MANCODE_MAN_DELIVERY_GIT_REQUIRED')) { + nextAction = + 'Use a Git worktree for versioned delivery, then inspect the task again.'; + } else if (diagnostic.includes('MANCODE_MAN_CONTENT_NODE_UNSUPPORTED')) { + nextAction = + 'Remove the unsupported repository content node from the delivery subject or use a supported versioned file, then inspect again.'; + } + return { + status: 'incomplete', + blockers: [ + { + code: 'inspection_failed', + status: 'failed', + nextAction, + diagnostic, + }, + ], + }; +} + +function assessManVerification( + task: StoredTaskSnapshot, + subject?: ManEvidenceSubject, +): { status: string; nextAction: string } { + const missingConfiguredCriteria = task.requirements.acceptanceCriteria + .filter( + (criterion) => + criterion.required && criterion.verificationSurfaces === undefined, + ) + .map((criterion) => criterion.displayId); + if (missingConfiguredCriteria.length) { + return { + status: 'surface_requirement_missing', + nextAction: `Reframe or re-finalize requirements with verificationSurfaces for ${missingConfiguredCriteria.join(', ')}.`, + }; + } + if (task.verification.status !== 'passed') { + return { + status: task.verification.status, + nextAction: + 'Run or confirm the missing acceptance evidence against the current subject.', + }; + } + const criteria = new Map( + task.requirements.acceptanceCriteria.map((criterion) => [ + criterion.criterionId, + criterion, + ]), + ); + const missingRequirements: string[] = []; + const missingActual: string[] = []; + const stale: string[] = []; + const mismatches: string[] = []; + for (const check of task.verification.checks) { + if (!check.required) continue; + const criterion = criteria.get(check.criterionId); + for (const slot of ['automated', 'manual'] as const) { + const evidence = check[slot]; + if (evidence?.status !== 'passed') continue; + const expected = criterion?.verificationSurfaces?.[slot]; + const label = `${check.displayId}:${slot}`; + if (!expected) { + missingRequirements.push(label); + continue; + } + if (!evidence.surface) { + missingActual.push(label); + continue; + } + if ( + subject !== undefined && + (evidence.subject?.contentDigest !== subject.contentDigest || + evidence.subject.environment !== subject.environment) + ) { + stale.push(label); + continue; + } + if (evidence.surface !== expected) { + mismatches.push( + `${label} requires ${expected}, recorded ${evidence.surface}`, + ); + } + } + } + if (missingRequirements.length) { + return { + status: 'surface_requirement_missing', + nextAction: `Reframe or re-finalize requirements with verificationSurfaces for ${missingRequirements.join(', ')}.`, + }; + } + if (missingActual.length) { + return { + status: 'surface_required', + nextAction: `Record the actual observation surface for ${missingActual.join(', ')}.`, + }; + } + if (stale.length) { + return { + status: 'stale', + nextAction: `Re-run or confirm evidence against the current subject for ${stale.join(', ')}.`, + }; + } + if (mismatches.length) { + return { + status: 'surface_mismatch', + nextAction: `Record evidence at the required observation surface: ${mismatches.join('; ')}.`, + }; + } + return { status: 'passed', nextAction: 'No verification action required.' }; +} + +export async function inspectManDelivery( + root: string, + task: StoredTaskSnapshot, +) { + if (!isManDelivery(task.metadata)) + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + const bound = await readBoundManPlan(root, task); + const subject = await captureManSubject(root, task); + const head = await readCheckoutCodeHead(root); + const trackedDirty = ( + await manGit( + root, + head === null + ? ['ls-files', '-z', '--cached'] + : ['diff', '--name-only', '--relative', '-z', 'HEAD', '--', '.'], + ) + ) + .split('\0') + .filter(Boolean); + const untracked = ( + await manGit(root, ['ls-files', '--others', '--exclude-standard', '-z']) + ) + .split('\0') + .filter(Boolean); + const inTask = (file: string) => + file === bound.source.path || manScopeContains(task.metadata, file); + const dirtyFiles = [...new Set([...trackedDirty, ...untracked])].filter( + (file) => !file.startsWith('.mancode/'), + ); + const pendingCommit = dirtyFiles.filter(inTask); + const outsideScopeDirty = dirtyFiles.filter((file) => !inTask(file)); + const committedChanges = + head === null + ? [] + : bound.source.baseHead === null + ? (await manGit(root, ['ls-tree', '-r', '--name-only', '-z', 'HEAD'])) + .split('\0') + .filter(Boolean) + : ( + await manGit(root, [ + 'diff', + '--name-only', + '--relative', + '-z', + bound.source.baseHead, + 'HEAD', + '--', + '.', + ]) + ) + .split('\0') + .filter(Boolean); + const outsideScope = committedChanges.filter( + (file) => !file.startsWith('.mancode/') && !inTask(file), + ); + const upstream = + ( + await manGit(root, [ + 'for-each-ref', + '--format=%(upstream:short)', + `refs/heads/${(await manGit(root, ['branch', '--show-current'])).trim()}`, + ]) + ).trim() || null; + const recordCurrent = + parseManPlanDocument(bound.document).record === + renderManDeliveryRecord(task, subject).trim(); + return { + subject, + source: bound.source, + pendingCommit, + outsideScopeDirty, + outsideScope, + upstream, + publication: upstream === null ? 'unpublished' : 'unknown', + finalization: manDeliveryFinalization( + task, + subject, + recordCurrent, + outsideScope, + outsideScopeDirty, + pendingCommit, + ), + }; +} + +/** Read the actual upstream ref, never equate a cached tracking ref with publication. */ +export async function inspectManPublication(root: string) { + const head = await readCheckoutCodeHead(root); + const branch = (await manGit(root, ['branch', '--show-current'])).trim(); + if (!head || !branch) + return { status: 'unpublished', reason: 'no committed task branch' }; + const [remote, ref] = ( + await manGit(root, [ + 'for-each-ref', + '--format=%(upstream:remotename)%00%(upstream:remoteref)', + `refs/heads/${branch}`, + ]) + ) + .trim() + .split('\0'); + if (!remote || !ref) + return { status: 'unpublished', reason: 'no upstream configured' }; + try { + const remoteHead = ( + await manGit(root, ['ls-remote', '--exit-code', '--', remote, ref]) + ) + .trim() + .split(/\s+/)[0]; + if (!remoteHead) + return { status: 'unpublished', reason: 'upstream ref absent' }; + if (remoteHead === head) return { status: 'published', head, remoteHead }; + // Do not fetch or update refs just to decide this. Unknown remote objects remain unverified. + try { + await manGit(root, ['merge-base', '--is-ancestor', head, remoteHead]); + return { status: 'published', head, remoteHead }; + } catch (error) { + if ((error as { code?: unknown }).code === 1) + return { + status: 'unpublished', + head, + remoteHead, + reason: 'upstream does not contain this commit', + }; + throw error; + } + } catch (error) { + return { + status: 'unverified', + reason: + 'upstream publication could not be verified; no fetch, push or business-state change was performed', + diagnostic: error instanceof Error ? error.message : String(error), + }; + } +} + +export function renderManDeliveryRecord( + task: StoredTaskSnapshot, + subject?: ManEvidenceSubject, +): string { + const reviewStale = + subject && + task.review.delivery && + (task.review.delivery.subject.contentDigest !== subject.contentDigest || + task.review.delivery.subject.environment !== subject.environment); + const verificationStatus = assessManVerification(task, subject).status; + return [ + `Task: ${task.metadata.taskRef.namespace}:${task.metadata.taskRef.taskId}`, + `Plan version: ${task.metadata.governance.planVersion}`, + `Review: ${reviewStale ? 'stale' : task.review.status}`, + `Verification: ${verificationStatus}`, + '', + ...(task.review.delivery + ? [ + `Reviewer declaration: ${task.review.delivery.reviewer}`, + `Direction: ${task.review.delivery.direction}`, + `Correctness: ${task.review.delivery.correctness}`, + `Proportionality: ${task.review.delivery.proportionality}`, + `Next: ${task.review.delivery.nextAction}`, + ] + : [ + 'Next: finish relevant verification, then review the complete module.', + ]), + ...task.review.blockers.map( + (item) => `- ${item.displayId}: ${item.status} — ${item.summary}`, + ), + ...(task.review.delivery?.coverage.map( + (row) => `- ${row.acceptanceId}: ${row.status} — ${row.evidence}`, + ) ?? []), + ...task.verification.checks.map((check) => { + const component = ( + item: VerificationLedgerV1['checks'][number]['automated'], + ) => + item === null + ? 'n/a' + : `${item.status}(surface=${item.surface ?? 'unspecified'})`; + return `- ${check.displayId}: automated=${component(check.automated)}; manual=${component(check.manual)}; ${check.automated?.summary ?? check.manual?.summary ?? 'No evidence yet.'}`; + }), + ].join('\n'); +} + +export async function syncManDeliveryRecord( + root: string, + task: StoredTaskSnapshot, +) { + const bound = await readBoundManPlan(root, task); + if ( + task.metadata.governance.planDecision === 'governed_execution' && + !manScopeContains(task.metadata, bound.source.path) + ) + assertManPlanInScope(task.metadata, bound.source.path); + const subject = (await hasGitWorktree(root)) + ? await captureManSubject(root, task) + : undefined; + const next = replaceManDeliveryRecord( + bound.document, + renderManDeliveryRecord(task, subject), + ); + if ( + scanSharedText(next).some((finding) => + ['authorization', 'cookie', 'private_key', 'secret'].includes( + finding.kind, + ), + ) + ) + throw new Error( + 'MANCODE_MAN_DOCUMENT_SENSITIVE: redact credential-like text before versioning', + ); + const target = await realpath(path.join(root, bound.source.path)); + if (next !== bound.document) { + const temporary = `${target}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, next, { + flag: 'wx', + mode: (await lstat(target)).mode, + }); + if ((await readFile(target, 'utf8')) !== bound.document) + throw new Error('MANCODE_MAN_PLAN_FILE_CHANGED'); + await replaceFileAtomically(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + } + let taskId: string; + try { + taskId = + manProgressTaskId(bound.baseline) ?? + `${task.metadata.taskRef.namespace}:${task.metadata.taskRef.taskId}`; + } catch (error) { + return { + status: 'manual_sync' as const, + reason: error instanceof Error ? error.message : String(error), + }; + } + const unresolved = task.requirements.blockingUnknowns.filter( + (item) => item.status === 'open', + ); + const reason = + task.metadata.status === 'blocked' && unresolved.length + ? unresolved.map((item) => item.statement).join('; ') + : null; + const verified = + subject !== undefined && + assessManVerification(task, subject).status === 'passed'; + const reviewed = + task.review.status === 'skipped' || + (task.review.status === 'passed' && + task.review.delivery?.subject.contentDigest === subject?.contentDigest && + task.review.delivery?.subject.environment === subject?.environment); + const status: ManProgressStatus = reason + ? '阻塞' + : verified && reviewed + ? '已完成' + : verified && task.review.status !== 'blocked' + ? '待审核' + : task.metadata.currentStep >= 5 + ? '进行中' + : '未完成'; + return syncManProgressPage( + root, + taskId, + status, + reason, + manScopeContains(task.metadata, '项目进度.html'), + ); +} + +export async function assertManDeliveryReady( + root: string, + task: StoredTaskSnapshot, +): Promise { + if (!isManDelivery(task.metadata)) return; + const result = await inspectManDelivery(root, task); + const blocker = (code: ManFinalizationBlockerCode) => + result.finalization.blockers.find((item) => item.code === code); + const plan = blocker('plan_execution_required'); + if (plan) + throw new Error( + `MANCODE_MAN_DELIVERY_EXECUTION_REQUIRED: ${plan.nextAction}`, + ); + const verification = blocker('verification_incomplete'); + if (verification) + throw new Error( + `MANCODE_MAN_VERIFICATION_INCOMPLETE: ${verification.status}; ${verification.nextAction}`, + ); + const review = blocker('review_incomplete'); + if (review) + throw new Error( + `MANCODE_MAN_REVIEW_INCOMPLETE: ${review.status}; ${review.nextAction}`, + ); + assertManReviewCoverage(task, task.review, result.subject); + assertManVerificationSubjects(task, task.verification, result.subject); + if (blocker('delivery_record_stale')) + throw new Error('MANCODE_MAN_DELIVERY_RECORD_STALE'); + const outside = blocker('committed_outside_scope'); + if (outside) + throw new Error( + `MANCODE_MAN_COMMIT_OUTSIDE_SCOPE: ${outside.files?.join(', ')}`, + ); + const outsideDirty = blocker('uncommitted_outside_scope'); + if (outsideDirty) + throw new Error( + `MANCODE_MAN_UNCOMMITTED_OUTSIDE_SCOPE: ${outsideDirty.files?.join(', ')}`, + ); + const pending = blocker('uncommitted_changes'); + if (pending) + throw new Error( + `MANCODE_MAN_DELIVERY_UNCOMMITTED: ${pending.files?.join(', ')}`, + ); +} diff --git a/src/context/man-progress.ts b/src/context/man-progress.ts new file mode 100644 index 0000000..5db5d55 --- /dev/null +++ b/src/context/man-progress.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; + +export type ManProgressStatus = + | '未完成' + | '进行中' + | '待审核' + | '已完成' + | '阻塞'; +export interface ManProgressResult { + status: 'synced' | 'absent' | 'manual_sync'; + reason?: string; +} +const START = ''; + +/** Only this exact, documented data contract is writable; this is not an HTML editor. */ +export function updateManProgressHtml( + html: string, + taskId: string, + status: ManProgressStatus, + reason: string | null, +): string { + if ( + html.split(START).length !== 2 || + [...html.matchAll(/\bid\s*=\s*["']mancode-progress-data["']/gi)].length !== + 1 + ) + throw new Error('progress data block is missing or ambiguous'); + // Skip comments and entire raw-text elements, including JavaScript strings + // containing a sample block. Unknown/malformed markup is never repaired here. + const tokens = [ + ...html.matchAll( + /|<(script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)\b(?:[^"'<>]|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>|<(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, + ), + ]; + if ( + !tokens.some( + (token) => + token.index === html.indexOf(START) && token[0].startsWith(START), + ) + ) + throw new Error('progress data must be an active standalone script block'); + const start = html.indexOf(START) + START.length; + const end = html.indexOf(END, start); + if (end < start) throw new Error('progress data block is unterminated'); + const data: unknown = JSON.parse(html.slice(start, end)); + if ( + !data || + typeof data !== 'object' || + !('schemaVersion' in data) || + data.schemaVersion !== 1 || + !('tasks' in data) || + !Array.isArray(data.tasks) + ) + throw new Error('unsupported progress data'); + const matches = data.tasks.filter( + (item) => item && typeof item === 'object' && item.taskId === taskId, + ); + if (matches.length !== 1) + throw new Error('progress taskId is missing or ambiguous'); + Object.assign(matches[0], { status, reason }); + return `${html.slice(0, start)}\n${JSON.stringify(data).replaceAll('<', '\\u003c')}\n${html.slice(end)}`; +} + +export async function syncManProgressPage( + root: string, + taskId: string, + status: ManProgressStatus, + reason: string | null, + allowed: boolean, +): Promise { + const target = path.join(root, '项目进度.html'); + let temporary: string | undefined; + try { + const stat = await lstat(target); + if (!allowed) + return { + status: 'manual_sync', + reason: 'progress page is outside the approved write scope', + }; + if ( + !stat.isFile() || + (await realpath(target)) !== + path.join(await realpath(root), '项目进度.html') + ) + return { + status: 'manual_sync', + reason: 'progress page must be a regular project file', + }; + const previous = await readFile(target, 'utf8'); + const next = updateManProgressHtml(previous, taskId, status, reason); + if (next !== previous) { + temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, next, { flag: 'wx', mode: stat.mode }); + if ((await readFile(target, 'utf8')) !== previous) + throw new Error('progress page changed during sync'); + await replaceFileAtomically(temporary, target); + } + return { status: 'synced' }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !temporary) + return { status: 'absent' }; + // A local view must not roll back valid authority; expose the actual diagnostic. + return { + status: 'manual_sync', + reason: error instanceof Error ? error.message : String(error), + }; + } finally { + if (temporary) await rm(temporary, { force: true }); + } +} diff --git a/src/context/plan-revision.ts b/src/context/plan-revision.ts index 85f8760..077ef8f 100644 --- a/src/context/plan-revision.ts +++ b/src/context/plan-revision.ts @@ -25,6 +25,13 @@ import { } from './aggregate.js'; import { digestCanonicalJson } from './canonical.js'; import type { Ulid } from './ids.js'; +import { parseManDeliveryPlan } from './man-delivery-plan.js'; +import { + assertManPlanInScope, + bindManPlan, + isManDelivery, + readBoundManPlan, +} from './man-delivery-runtime.js'; import { assertManteamPlanContent } from './manteam-plan.js'; import { assertSharedTextSafe } from './privacy.js'; import { @@ -60,6 +67,7 @@ export interface ReviseV3PlanInput { sessionId: Ulid; expectedTaskRevision: number; plan: string; + planSource?: string; /** User-visible file/module boundary confirmed with this plan revision. */ implementationScope?: unknown; /** Omitting the decision leaves the workflow at the step-four plan gate. */ @@ -86,7 +94,7 @@ export async function reviseV3Plan( input: ReviseV3PlanInput, ): Promise { const taskRef = parseTaskRefValue(input.taskRef); - const plan = requirePlan(input.plan); + let plan = requirePlan(input.plan); const planDecision = parsePlanDecision(input.planDecision); const submittedScope = input.implementationScope === undefined @@ -107,6 +115,21 @@ export async function reviseV3Plan( }); let journal: OperationJournalV1 | null = null; try { + if (isManDelivery(context.task.metadata)) { + if (input.planSource) { + plan = await bindManPlan( + input.projectRoot, + input.planSource, + plan, + context.task.plan?.content ?? null, + ); + } else { + await readBoundManPlan(input.projectRoot, context.task); + if (plan !== context.task.plan?.content) + throw new Error('MANCODE_MAN_PLAN_SOURCE_REQUIRED'); + } + } else if (input.planSource) + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); const planChanged = context.task.plan?.content !== plan; const executionScopeBinding = assertExecutionScopeBindingAttempt({ metadata: context.task.metadata, @@ -129,6 +152,17 @@ export async function reviseV3Plan( context.task.metadata.implementationScope.digest; if (planDecision === 'governed_execution') { assertExecutableImplementationScope(implementationScope); + if (isManDelivery(context.task.metadata)) { + const source = parseManDeliveryPlan(plan)?.source; + if (!source) + throw new Error( + 'MANCODE_MAN_PLAN_OUTSIDE_SCOPE: bound plan source path is missing; provide a repo-relative plan path', + ); + assertManPlanInScope( + { ...context.task.metadata, implementationScope }, + source.path, + ); + } } if (executionScopeBinding) { assertExecutableImplementationScope(implementationScope); diff --git a/src/context/reframe.ts b/src/context/reframe.ts index b3f7e35..ed88dd4 100644 --- a/src/context/reframe.ts +++ b/src/context/reframe.ts @@ -24,6 +24,7 @@ import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; import { type OpenedV3TaskOperation, advanceTaskOperation, + assertTaskCheckpointIdAvailableAtRoot, commitTaskOperation, createTaskOperationJournal, handleTaskOperationFailure, @@ -132,6 +133,10 @@ export async function reframeV3Workflow( try { assertReframeEligible(context, activeClaims, openHandoffs, activeChildren); assertReframeClaimsFresh(context, activeClaims); + await assertTaskCheckpointIdAvailableAtRoot( + context.task.location.taskRoot, + input.checkpointId, + ); const timestamp = context.now.toISOString(); const archivedRequirementsContent = await readTaskAuthorityFileAtRoot( diff --git a/src/context/requirements-finalize.ts b/src/context/requirements-finalize.ts index 4a6e13d..d42a3e7 100644 --- a/src/context/requirements-finalize.ts +++ b/src/context/requirements-finalize.ts @@ -22,6 +22,7 @@ import { import type { Ulid } from './ids.js'; import { type RequirementsLedgerV1, + assertManDeliveryVerificationSurfaces, assertRequirementsLedgerTransition, assertRequirementsScopeConsistent, parseRequirementsLedger, @@ -127,6 +128,14 @@ async function writeV3Requirements( let journal: OperationJournalV1 | null = null; try { assertRequirementsFinalizeEligible(context.task.metadata); + if ( + action === 'finalize' && + context.task.metadata.workflowMode === 'man' && + context.task.metadata.governance.policyVersions.planning === 3 && + context.task.metadata.governance.planDecision !== 'solo_handoff' + ) { + assertManDeliveryVerificationSurfaces(submitted); + } const timestamp = context.now.toISOString(); const requirements = createReplacementRequirements( context.task.requirements, diff --git a/src/context/requirements-ledger.ts b/src/context/requirements-ledger.ts index 48e2742..84cc924 100644 --- a/src/context/requirements-ledger.ts +++ b/src/context/requirements-ledger.ts @@ -1,5 +1,9 @@ import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; import { type Ulid, assertUlid } from './ids.js'; +import { + type ManVerificationSurface, + parseManVerificationSurface, +} from './man-delivery-evidence.js'; import { assertSharedTextSafe } from './privacy.js'; import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; import { assertKnownKeys, assertRecord } from './validation.js'; @@ -58,6 +62,10 @@ export interface RequirementsLedgerV1 { statement: string; required: boolean; verificationRequirement: VerificationRequirement; + verificationSurfaces?: { + automated?: ManVerificationSurface; + manual?: ManVerificationSurface; + }; } >; blockingUnknowns: Array< @@ -489,6 +497,7 @@ function parseAcceptanceCriteria( 'statement', 'required', 'verificationRequirement', + 'verificationSurfaces', ], 'requirements ledger acceptance criterion', ); @@ -506,6 +515,12 @@ function parseAcceptanceCriteria( 'requirements ledger criterion verificationRequirement is invalid', ); } + const verificationRequirement = + item.verificationRequirement as VerificationRequirement; + const verificationSurfaces = parseVerificationSurfaces( + item.verificationSurfaces, + verificationRequirement, + ); return { ...identity, criterionId: item.criterionId, @@ -518,12 +533,61 @@ function parseAcceptanceCriteria( 'requirements ledger criterion statement', ), required: item.required, - verificationRequirement: - item.verificationRequirement as VerificationRequirement, + verificationRequirement, + ...(verificationSurfaces === undefined ? {} : { verificationSurfaces }), }; }); } +export function assertManDeliveryVerificationSurfaces( + ledger: RequirementsLedgerV1, +): void { + for (const criterion of ledger.acceptanceCriteria) { + if (criterion.required && criterion.verificationSurfaces === undefined) { + throw new Error( + `MANCODE_MAN_ACCEPTANCE_SURFACE_REQUIRED: ${criterion.displayId}`, + ); + } + } +} + +function parseVerificationSurfaces( + value: unknown, + requirement: VerificationRequirement, +): RequirementsLedgerV1['acceptanceCriteria'][number]['verificationSurfaces'] { + if (value === undefined) return undefined; + assertRecord(value, 'requirements ledger acceptance verificationSurfaces'); + assertKnownKeys( + value, + ['automated', 'manual'], + 'requirements ledger acceptance verificationSurfaces', + ); + const automated = + value.automated === undefined + ? undefined + : parseManVerificationSurface(value.automated); + const manual = + value.manual === undefined + ? undefined + : parseManVerificationSurface(value.manual); + if ( + (requirement === 'automated' && + (automated === undefined || manual !== undefined)) || + (requirement === 'manual' && + (automated !== undefined || manual === undefined)) || + (requirement === 'hybrid' && + (automated === undefined || manual === undefined)) + ) { + throw new Error( + 'requirements ledger acceptance verificationSurfaces must match verificationRequirement slots', + ); + } + return { + ...(automated === undefined ? {} : { automated }), + ...(manual === undefined ? {} : { manual }), + }; +} + function parseBlockingUnknowns( value: unknown, ): RequirementsLedgerV1['blockingUnknowns'] { diff --git a/src/context/review-ledger.ts b/src/context/review-ledger.ts index 43d51a0..cd27681 100644 --- a/src/context/review-ledger.ts +++ b/src/context/review-ledger.ts @@ -5,6 +5,10 @@ import { } from './artifact-ref.js'; import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; import { type Ulid, assertUlid } from './ids.js'; +import { + type ManReviewEvidence, + parseManReviewEvidence, +} from './man-delivery-evidence.js'; import { assertSharedTextSafe } from './privacy.js'; import type { ItemIdentity } from './requirements-ledger.js'; import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; @@ -33,6 +37,8 @@ export interface ReviewLedgerContext { } export interface ReviewLedgerV1 { + /** Present only on explicitly enabled man delivery tasks. */ + delivery?: ManReviewEvidence; schemaVersion: 1; canonicalizationVersion: 'mancode-jcs-v1'; taskRef: TaskRef; @@ -130,6 +136,7 @@ export function parseReviewLedger(value: unknown): ReviewLedgerV1 { 'contentDigest', 'lastOperationId', 'updatedAt', + 'delivery', ], 'review ledger', ); @@ -144,6 +151,9 @@ export function parseReviewLedger(value: unknown): ReviewLedgerV1 { } const taskRef = parseTaskRefValue(value.taskRef); const ledger: ReviewLedgerV1 = { + ...(value.delivery === undefined + ? {} + : { delivery: parseManReviewEvidence(value.delivery) }), schemaVersion: 1, canonicalizationVersion: 'mancode-jcs-v1', taskRef, @@ -181,6 +191,8 @@ export function parseReviewLedger(value: unknown): ReviewLedgerV1 { updatedAt: parseTimestamp(value.updatedAt, 'review ledger updatedAt'), }; assertReviewLedgerShape(ledger); + if (taskRef.namespace === 'shared' && ledger.delivery) + assertSharedTextSafe(JSON.stringify(ledger.delivery), 'man module review'); if (ledger.contentDigest !== reviewLedgerDigest(ledger)) { throw new Error( 'review ledger contentDigest does not match canonical content', @@ -192,6 +204,7 @@ export function parseReviewLedger(value: unknown): ReviewLedgerV1 { export function reviewLedgerDigest(ledger: ReviewLedgerV1): string { return digestCanonicalJson({ + ...(ledger.delivery === undefined ? {} : { delivery: ledger.delivery }), schemaVersion: ledger.schemaVersion, canonicalizationVersion: ledger.canonicalizationVersion, taskRef: ledger.taskRef, diff --git a/src/context/review-remediation.ts b/src/context/review-remediation.ts index 636cf3d..9267a7a 100644 --- a/src/context/review-remediation.ts +++ b/src/context/review-remediation.ts @@ -20,6 +20,11 @@ import { buildTaskAggregateManifest, } from './aggregate.js'; import type { Ulid } from './ids.js'; +import { + assertManReviewCoverage, + captureManSubject, + isManDelivery, +} from './man-delivery-runtime.js'; import { type ReviewLedgerV1, assertReviewLedgerAgainstContext, @@ -34,6 +39,12 @@ import { taskMutationExpectedRevisions, } from './task-mutation.js'; import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { + assertVerificationLedgerTransition, + deriveVerificationLedgerStatus, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; import type { VerificationLedgerV1 } from './verification-ledger.js'; import { type WorkflowMetadataV3, @@ -62,8 +73,8 @@ export interface AppliedV3ReviewLedger { } /** - * Applies a current review result and invalidates all prior verification - * evidence. Review input is a complete ledger so the persisted digest covers + * Applies a current review result. Only opted-in man delivery can retain + * verification bound to unchanged content. The persisted digest covers * the exact domains, blockers, reports, skips, and remediation round audited. */ export async function applyV3ReviewLedger( @@ -86,6 +97,14 @@ export async function applyV3ReviewLedger( }); let journal: OperationJournalV1 | null = null; try { + const subject = isManDelivery(context.task.metadata) + ? await captureManSubject(input.projectRoot, context.task) + : null; + assertManReviewCoverage( + context.task, + submitted, + subject ?? { contentDigest: '', environment: '' }, + ); assertReviewEligible(context.task.metadata, context.task.plan !== null); const timestamp = context.now.toISOString(); const review = createCurrentReview( @@ -95,11 +114,43 @@ export async function applyV3ReviewLedger( context.operationId, timestamp, ); - const verification = markTaskVerificationStale( + let verification = markTaskVerificationStale( context.task.verification, context.operationId, timestamp, ); + const previous = context.task.verification; + if ( + subject && + previous.requirementsDigest === + context.task.metadata.governance.requirementsDigest && + previous.planVersion === context.task.metadata.governance.planVersion && + previous.checks.every((check) => + [check.automated, check.manual].every( + (item) => + item?.status !== 'passed' || + (item.subject?.contentDigest === subject.contentDigest && + item.subject.environment === subject.environment), + ), + ) + ) { + const current = { + ...previous, + revision: previous.revision + 1, + remediationRound: review.remediationRound, + lastOperationId: context.operationId, + updatedAt: timestamp, + }; + const draft = { + ...current, + status: deriveVerificationLedgerStatus(current), + }; + verification = parseVerificationLedger({ + ...draft, + contentDigest: verificationLedgerDigest(draft), + }); + assertVerificationLedgerTransition(previous, verification); + } const metadata = updateMetadata( context.task.metadata, review, diff --git a/src/context/task-complete.ts b/src/context/task-complete.ts index 2c666bf..1719caf 100644 --- a/src/context/task-complete.ts +++ b/src/context/task-complete.ts @@ -37,6 +37,7 @@ import { buildTaskAggregateManifest, } from './aggregate.js'; import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { assertManDeliveryReady } from './man-delivery-runtime.js'; import { V3ContextStore } from './store.js'; import { assertTaskCodeHeadUnchanged, @@ -101,6 +102,7 @@ export async function completeV3Task( throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); } const activeChildren = await context.store.listActiveChildTaskRefs(taskRef); + await assertManDeliveryReady(input.projectRoot, context.task); assertTaskCompletionGate( { metadata: context.task.metadata, diff --git a/src/context/verification-ledger.ts b/src/context/verification-ledger.ts index ff417c5..f28b33f 100644 --- a/src/context/verification-ledger.ts +++ b/src/context/verification-ledger.ts @@ -5,6 +5,12 @@ import { } from './artifact-ref.js'; import { digestCanonicalJson } from './canonical.js'; import { type Ulid, assertUlid } from './ids.js'; +import { + type ManEvidenceSubject, + type ManVerificationSurface, + parseManEvidenceSubject, + parseManVerificationSurface, +} from './man-delivery-evidence.js'; import { assertSharedTextSafe } from './privacy.js'; import type { ItemIdentity, @@ -35,6 +41,9 @@ export interface VerificationLedgerContext { } export interface VerificationComponentEvidence { + subject?: ManEvidenceSubject; + /** Optional for historical ledgers; required for passed policy-3 man evidence. */ + surface?: ManVerificationSurface; evidenceId: Ulid; status: VerificationComponentStatus; summary: string | null; @@ -280,6 +289,7 @@ export function assertVerificationLedgerRequirements( export function assertVerificationLedgerTransition( previous: VerificationLedgerV1, next: VerificationLedgerV1, + contentInvalidated = false, ): void { if (next.revision !== previous.revision + 1) { throw new Error( @@ -304,7 +314,11 @@ export function assertVerificationLedgerTransition( if (previous.legacySource === null && next.legacySource !== null) { throw new Error('verification ledger cannot introduce a legacy source'); } - if (!allowedVerificationTransitions(previous.status).has(next.status)) { + if ( + !allowedVerificationTransitions( + contentInvalidated ? 'stale' : previous.status, + ).has(next.status) + ) { throw new Error( `invalid verification ledger status transition: ${previous.status} -> ${next.status}`, ); @@ -458,6 +472,8 @@ function parseEvidence( 'artifactRef', 'confirmedByActorId', 'confirmationSource', + 'subject', + 'surface', 'updatedAt', ], `verification ledger ${kind} evidence`, @@ -523,6 +539,11 @@ function parseEvidence( `verification ledger ${kind} evidence command`, ); if (taskRef.namespace === 'shared') { + if (value.subject !== undefined) + assertSharedTextSafe( + parseManEvidenceSubject(value.subject).environment, + 'man verification environment', + ); if (summary !== null) { assertSharedTextSafe( summary, @@ -538,6 +559,12 @@ function parseEvidence( } return { evidenceId: value.evidenceId, + ...(value.subject === undefined + ? {} + : { subject: parseManEvidenceSubject(value.subject) }), + ...(value.surface === undefined + ? {} + : { surface: parseManVerificationSurface(value.surface) }), status, summary, command, diff --git a/src/context/verification-record.ts b/src/context/verification-record.ts index 8ff0c2e..972a009 100644 --- a/src/context/verification-record.ts +++ b/src/context/verification-record.ts @@ -20,6 +20,11 @@ import { buildTaskAggregateManifest, } from './aggregate.js'; import type { Ulid } from './ids.js'; +import { + assertManVerificationSubjects, + captureManSubject, + isManDelivery, +} from './man-delivery-runtime.js'; import { assertTaskCodeHeadUnchanged, nextTaskHeadFence, @@ -80,6 +85,14 @@ export async function recordV3Verification( }); let journal: OperationJournalV1 | null = null; try { + const subject = isManDelivery(context.task.metadata) + ? await captureManSubject(input.projectRoot, context.task) + : null; + assertManVerificationSubjects( + context.task, + submitted, + subject ?? { contentDigest: '', environment: '' }, + ); assertVerificationEligible( context.task.metadata, context.task.plan !== null, @@ -93,6 +106,15 @@ export async function recordV3Verification( context.task.requirements, context.operationId, timestamp, + subject !== null && + context.task.verification.checks.some((check) => + [check.automated, check.manual].some( + (item) => + item?.status === 'passed' && + (item.subject?.contentDigest !== subject.contentDigest || + item.subject.environment !== subject.environment), + ), + ), ); const metadata = updateMetadata( context.task.metadata, @@ -233,6 +255,7 @@ function createCurrentVerification( requirements: Parameters[1], operationId: Ulid, updatedAt: string, + contentInvalidated: boolean, ): VerificationLedgerV1 { if ( submitted.requirementsDigest !== metadata.governance.requirementsDigest || @@ -256,7 +279,7 @@ function createCurrentVerification( }, requirements, ); - assertVerificationLedgerTransition(previous, next); + assertVerificationLedgerTransition(previous, next, contentInvalidated); assertVerificationLedgerRequirements(next, requirements); assertVerificationLedgerAgainstContext(next, { requirementsDigest: metadata.governance.requirementsDigest, diff --git a/src/context/workflow-create.ts b/src/context/workflow-create.ts index 1bee34d..6dd905f 100644 --- a/src/context/workflow-create.ts +++ b/src/context/workflow-create.ts @@ -119,6 +119,8 @@ export interface WorkflowCreateScope { } export interface CreateV3WorkflowInput { + /** Opt in a new man task; never silently upgrade existing tasks or other modes. */ + delivery?: boolean; projectRoot: string; task: string; workflowMode: 'man' | 'manba' | 'manteam'; @@ -181,6 +183,9 @@ export async function createV3Workflow( const timestamp = now.toISOString(); assertUlid(input.sessionId, 'workflow sessionId'); const workflowMode = parseWorkflowMode(input.workflowMode); + if (input.delivery && (workflowMode !== 'man' || input.parentTaskRef)) { + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + } const session = await readSession(projectRoot, input.sessionId); if (session === null || session.status !== 'active') { throw new Error('MANCODE_SESSION_NOT_FOUND'); @@ -274,12 +279,15 @@ export async function createV3Workflow( ? 0 : 1, timestamp, - planningPolicyVersion: - parent === null + planningPolicyVersion: input.delivery + ? 3 + : parent === null ? project.manifest.manifestVersion === 2 && workflowMode === 'man' ? project.manifest.workflowPolicyDefaults.planning : 1 - : parent.metadata.governance.policyVersions.planning, + : parent.metadata.governance.policyVersions.planning === 3 + ? 2 + : parent.metadata.governance.policyVersions.planning, }); const homeStore = resolveTaskEntityHomeStore( runtime.entityHomeStoreContext, diff --git a/src/context/workflow-metadata.ts b/src/context/workflow-metadata.ts index 90ed047..82af9c6 100644 --- a/src/context/workflow-metadata.ts +++ b/src/context/workflow-metadata.ts @@ -52,7 +52,7 @@ export type PlanDecision = export type WorkflowPolicyComponent = 'planning' | 'review' | 'verification'; export const SUPPORTED_WORKFLOW_POLICY_VERSIONS = { - planning: [1, 2], + planning: [1, 2, 3], review: [1, 2], verification: [1], } as const; @@ -375,6 +375,14 @@ export function assertWorkflowMetadataTransition( 'workflow metadata revision must increase exactly once per mutation', ); } + if ( + (previous.governance.policyVersions.planning === 3 || + next.governance.policyVersions.planning === 3) && + previous.governance.policyVersions.planning !== + next.governance.policyVersions.planning + ) { + throw new Error('MANCODE_MAN_DELIVERY_POLICY_IMMUTABLE'); + } if ( previous.workflowMode !== next.workflowMode || previous.visibility !== next.visibility || @@ -739,6 +747,12 @@ function parseLegacyCompatibility( } function assertWorkflowMetadataShape(metadata: WorkflowMetadataV3): void { + if ( + metadata.governance.policyVersions.planning === 3 && + metadata.workflowMode !== 'man' + ) { + throw new Error('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + } if (metadata.workflowMode === 'man') { if (metadata.coordination !== 'single' || metadata.parent !== null) { throw new Error( diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts index 00048f4..28046c1 100644 --- a/src/installers/v3-adapter.ts +++ b/src/installers/v3-adapter.ts @@ -129,6 +129,31 @@ const RETRIABLE_ADAPTER_READ_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); const ADAPTER_READ_MAX_ATTEMPTS = 4; const ADAPTER_READ_RETRY_DELAY_MS = 25; +const PROJECT_DOCUMENTATION_HANDOFF_POLICY = [ + '', + '# man 项目文档与交接基线', + '以下两组补充规则仅用于显式启用模块交付策略的新 `/man` 任务,不改变普通 Solo、其他模式、旧策略任务或 Solo handoff。', + '- 需要新增或调整计划时,把目标、范围、阶段、验收标准和未决问题写入项目指定的计划基线目录,默认 `doc/`;已有 `docs/` 等明确约定时沿用它,不另建副本。绑定实际计划路径;计划不得只留在聊天记录、`AGENTS.md` 或 `CLAUDE.md`。只要求讨论或规划不授权修改业务代码。', + '- 开始实现前,以绑定的已确认计划为准,并阅读 `架构/` 中与改动有关的设计文档。分别识别目录不存在、被 `.gitignore` 排除和存在但不可读;被忽略不代表本地不可读。远程仅有计划时继续可确定部分;只有架构细节确实影响实现且不能从计划或现有契约得出时,暂停受影响部分并索取文档或确认,不自行补造。', + '- 如根目录存在 `项目进度.html`,只通过唯一的 `mancode-progress-data` JSON 契约和稳定 taskId 更新任务开始、待审核、验收通过或必要外部决策阻塞状态;缺少契约、映射或写入权限时提示人工同步,不猜 HTML、不阻止开发。未开发保持“未完成”,普通修复、测试或推送失败不属于业务“阻塞”。页面不替代计划和运行时权威。', + '- 完成计划或代码变更后,先做相称验证,将本任务应版本化的变更(含计划)提交到当前任务分支,不混入他人改动。已有上游且已获推送授权时才推送;无 remote、无上游或 push 失败报告“交付未发布”,不冒充业务阻塞或擅自配置远程。无 Git 可继续规划,不能声称版本化交付完成。不得强制加入被忽略的 `架构/`、`项目接口/`,也不得复制其中的凭据、账号和密钥。', + '', +] as const; + +const ENGINEERING_EXECUTION_QUALITY_POLICY = [ + '', + '# man 工程执行与效率准则', + '- 验证应与变更风险和验收目标相称;不滥用校验,不为低风险、可直接观察的事实叠加重复检查。', + '- 不得以 `catch` 吞掉、伪装或笼统改写错误来遮掩根因;仅在能够恢复、补充上下文或完成必要清理时处理异常,并保留可诊断的原始错误信息与因果链。', + '- 优先定位并修复根因,避免治标不治本;不要以无依据的增量分支、补丁或层层兜底代替正确的设计与实现。', + '- 在执行中主动自检:出现明显无关扩张、重复操作或无依据的层层防御时,先收敛问题、减少操作,再继续;代码行数和工具次数只是风险信号,不是硬阈值,也不能据此删除必要安全边界。', + '- 任务耗时异常时反思是否由自己的重复检查、无效调用、过度设计或偏离目标造成,并及时调整做法。', + '- 对 GPT 模型:不能把哈希作为“产物已变化”或功能正确的唯一证明;已有构建输出足以说明时不额外计算。完整性校验、缓存键、证据适用性和发布溯源仍可使用哈希。', + '- 对 GPT 模型:不要仅为再次确认而重读刚刚写入且未被外部修改的文件;直接使用已知写入结果,必要时仅核验关键片段或运行相关验证。', + '- 并非每次迭代都必须落为增量代码。代码是实现目标的手段而非最终目的;当结论是无需变更、应删除冗余或先澄清问题时,应如实处理。', + '', +] as const; + export interface V3AdapterCapabilities { nativeModeEntry: boolean; sessionHook: false; @@ -1075,6 +1100,17 @@ export function renderV3Bootstrap(platform: PlatformName): string { '- If the goal and decision-changing requirements are clear, consistent with project evidence, and low risk, proceed with the narrowest useful change without ceremonial questions. Resolve repository-answerable unknowns yourself.', '- When the goal is clear but requirements are incomplete, classify each remaining unknown as blocking, recommendable, or defaultable. Ask and wait only for blocking decisions that can materially change behavior, scope, acceptance, architecture, data, security, compatibility, or semantic ownership. For recommendable decisions, give bounded options and a clear recommendation. Use a default only when it is low-impact, reversible, consistent with repository conventions, and stated explicitly.', "- If an explicit request conflicts with repository evidence or introduces a hard-risk change involving authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure, or another irreversible effect, stop before editing. Show the concrete conflict or impact, recommend the safer path, ask a focused confirmation or choice, and wait. Clarity never overrides safety or the operator's actual goal.", + '', + ...(['AGENTS.md', 'CLAUDE.md'].includes( + path.basename(v3AdapterTargetPath('', primaryFileTarget(platform))), + ) + ? [ + ...PROJECT_DOCUMENTATION_HANDOFF_POLICY, + '', + ...ENGINEERING_EXECUTION_QUALITY_POLICY, + '', + ] + : []), '- A natural-language request explicitly asking for research, a plan, architecture, migration design, or formal acceptance authorizes the `man` planning path without a separate mode-confirmation question. For an ordinary implementation request whose blocking decision crosses modules or requires architecture, migration, semantic owner/source-of-truth, team coordination, or formal acceptance, recommend `/man`, explain why, and wait; never switch authority silently.', '- For governed task work only, if status has no `identity.actorId`, ask for a display name and run `mancode team identity create --name ""` before creating a session.', '- If status reports `session`, reuse it. `task: null` and `MANCODE_TASK_REQUIRED` do not make a session stale.', @@ -1235,7 +1271,7 @@ const V3_MODE_DEFINITIONS: Record< contextPurpose: 'plan', actions: [ '- For a read-only project orientation, inspect and answer directly; do not create governance records.', - '- For a new task, run `mancode workflow create man "" --session `.', + '- For a new task, run `mancode workflow create man "" --delivery --session `. This explicitly enables document-bound module delivery (planning policy 3); never silently upgrade an existing task. Other modes and Solo handoff retain their contracts.', '- Read `.mancode/shared/context/glossary.json` when it exists and prefer its confirmed terms in clarification, requirements, plans, reports, and naming.', "- Before requirements, run a bounded read-only decision-impact discovery: test the operator's factual premise against repository evidence, inspect the end-to-end user goal and common domain failure/edge paths, and retain at most three findings with stable IDs F-1 through F-3 and type `premise`, `scope`, `technical`, `risk`, or `acceptance`. Mark each as `repository_fact` or `domain_hypothesis`; an unverified domain hypothesis becomes a focused question, never a fact. Discovery produces evidence and recommendations, never execution authority.", '- Before writing requirements, inspect the relevant project facts and implementation, then run a decision-readiness gate covering both clarity and soundness. Treat the request as ready only when the goal, in-scope/out-of-scope behavior, acceptance boundary, semantic owner/source of truth, and decision-changing constraints are supplied and consistent with evidence, verifiable from the repository, or explicitly recorded as safe defaults. A supplied instruction is not automatically correct. Do not ask ceremonial questions or manufacture alternatives when the request is already clear and sound.', @@ -1246,10 +1282,12 @@ const V3_MODE_DEFINITIONS: Record< '- After the user answers, summarize the resolved requirements and any remaining defaults. Continue only when no decision-changing blocking unknown remains; otherwise keep the task in clarification and ask again.', '- Write requirements as semantic JSON with `version: 1`, a non-empty `goal`, non-empty `confirmedScope`, and the arrays `excludedScope`, `technicalDecisions`, `defaults`, and `blockingUnknowns`. Every array item must be a non-empty string; an array may be empty except `confirmedScope`, and `technicalDecisions` must be non-empty whenever `technical_stack` applies.', '- `coverage` must contain exactly one item for each dimension: `platform`, `core_scope`, `technical_stack`, `data_and_persistence`, `performance`, `compatibility`, and `security`. Each item has the shape `{ "dimension": "platform", "status": "confirmed", "rationale": "..." }`; `status` is exactly `confirmed`, `defaulted`, or `not_applicable`, and `rationale` is non-empty.', - '- `acceptanceCriteria` must contain at least one required item shaped as `{ "id": "AC-1", "description": "...", "required": true, "method": "automated" }`; `method` is exactly `automated`, `manual`, or `hybrid`.', + '- `acceptanceCriteria` must contain at least one required item shaped as `{ "id": "AC-1", "description": "...", "required": true, "method": "automated", "verificationSurfaces": { "automated": "component" } }`; `method` is exactly `automated`, `manual`, or `hybrid`. New delivery tasks must declare one exact expected surface for every required slot: automated criteria use `automated`, manual criteria use `manual`, and hybrid criteria use both.', '- Finalize requirements with `mancode workflow requirements finalize --file --expected-revision --session `.', '- Let mancode assign internal IDs and digests; do not invent canonical IDs or digests in the semantic input.', - '- Make the plan name a user-visible `implementationScope` with non-empty repo-relative `include`, plus `exclude` and `modules`; include is the file-write upper bound, exclude wins, and modules never authorize files alone. Bind plan and scope atomically with `mancode workflow plan revise --expected-revision --file --scope-file --session `.', + '- For delivery tasks, when one real verification command covers several acceptance criteria, use `--acceptance AC-1,AC-2` to record that single run for those criteria; do not rerun the same suite only to fill separate slots. After an authorized upstream push, `mancode workflow delivery publication --json` can query the actual remote ref without fetching or changing it; unavailable evidence remains unverified.', + '- Write one module plan in the explicitly selected project plan directory, otherwise the established convention, otherwise `doc/`. Keep approved goals, scope, stages, architecture references, acceptance IDs and unresolved decisions between standalone `` / `` markers; put actual delivery only between `` / ``. Examples inside code fences are not markers. Do not copy private architecture credentials. Only decision-changing missing architecture requires confirmation; planning permission is not implementation permission.', + '- Make the plan name a user-visible `implementationScope` with non-empty repo-relative `include`, plus `exclude` and `modules`; include/exclude entries accept repo-relative path or glob only, while semantic scope belongs in requirements. Include is the file-write upper bound, exclude wins, and modules never authorize files alone. Include the plan and any authorized progress page. Bind plan and scope atomically with `mancode workflow plan revise --expected-revision --file --scope-file --session `. A progress-only edit must not become a plan revision; changed approved goals require realignment, not rewriting history to fit the code.', '- Confirm the current plan with `mancode workflow plan confirm --expected-revision --plan-decision --session `.', '- Before editing in governed execution, read the confirmed plan and `activeTask.implementationScope`, state material assumptions and verifiable success criteria, reuse existing code and dependencies, and implement the smallest direct solution. Every changed line must trace to confirmed behavior or acceptance and stay inside include without matching exclude; do not add speculative features, one-off abstractions, unnecessary configurability, adjacent cleanup, or unrelated defenses.', '- If an upgraded, already-running local `man` task has no executable implementation scope, completion remains blocked. Show the complete replacement boundary and wait for explicit operator approval, then rerun plan revise with the exact unchanged current plan and `--scope-file `. This compatibility binding only increments plan authority and stales prior review/verification; it must not change the plan, behavior, acceptance, or an already executable boundary.', @@ -1257,7 +1295,14 @@ const V3_MODE_DEFINITIONS: Record< "- Confirming with `--plan-decision plan_only` keeps the plan as planned authority and clears this session's active workflow pointer. Resume the TaskRef explicitly before any later governed mutation.", '- When new evidence materially invalidates confirmed requirements and the operator explicitly chooses to realign the same local task, resume its TaskRef if needed, generate a fresh canonical checkpoint ULID, and run `mancode workflow reframe --expected-revision --checkpoint-id --summary "" --next-action "" --session `. Reframe archives the confirmed requirements and plan, clears the plan decision, and stops at Step 2 with draft requirements. Do not substitute plan revise, scope-change, or workflow update for reframe.', '- Read reframe evidence without opening private authority files: `mancode workflow archive show --json` and `mancode workflow checkpoint show --json`.', - '- Apply verification and review ledgers with their mancode `apply --file` commands, then use `mancode workflow complete --expected-revision --session `.', + '- If an existing reframe operation is already `repair_required` only because its checkpoint ID is occupied by a checkpoint from another operation, preserve every journal and checkpoint, generate a fresh canonical ULID, and run `mancode operation repair --replacement-checkpoint-id --session `. This exception is limited to that proven reframe checkpoint conflict. If recovery stops after rebinding, retry only with the exact same replacement ID; never request a second replacement. Use ordinary operation repair for every other interruption and never delete authority files to unblock an adapter upgrade.', + '- For existing tasks without delivery policy 3, keep their current verification/review apply and completion protocol. The following module actions apply only to new delivery tasks. Keep transient JSON inputs in `.mancode/local/drafts/`, not among the source files being verified. Use `mancode workflow delivery inspect --json` for the current subject, acceptance slots and evidence. Content identity is not proof of correct behavior; external-service or environment changes require renewed evidence.', + '- Implement the authorized module and run proportionate checks. To capture an automated result, write `{ "argv": ["npm", "test"], "surface": "component" }` with the actual relevant command and run `mancode workflow delivery verify --acceptance --file --expected-revision --session `. `surface` is the actual observation layer and must exactly match that acceptance slot\'s `verificationSurfaces` entry: `unit|component|handler|real_http|browser|device|external_service|manual_observation`; for a true HTTP acceptance the requirement uses `real_http` and the evidence uses `"surface": "real_http"`. It must never be inferred from the command name. The command runs without a shell; inspect its captured output and exit code. Do not substitute a trivial successful command for the acceptance behavior. Manual/hybrid slots require a real observation or explicit user confirmation: record `{ "confirmed": true, "surface": "manual_observation", "summary": "..." }` via the same command with `confirm` instead of `verify`; never fabricate confirmation. Missing or downgraded evidence remains unverified.', + '- Run `mancode workflow delivery sync --expected-revision --session ` at implementation start or before module review. Verification/review commands also project results into the delivery block. Optional progress uses the full TaskRef as taskId by default, or an explicit baseline marker ``; a missing/invalid contract only requests manual sync.', + '- After the whole module is implemented, perform one total review (not one per snippet, nor an extra review after existing quality/security review). Prefer one independent reviewer when available and authorized; otherwise label self-review honestly. The `reviewer` field is self-declared audit metadata, not authenticated actor/session proof, so do not claim independently verified identity from that field alone. Read the approved baseline, relevant architecture, the complete module diff since its bound baseHead, actual entry/call chains, and verification evidence. Check goal → implementation for omissions and diff → goal for scope drift, plus concrete correctness/security defects and unjustified abstraction, fallback or defensive code. Respect intentional phasing and necessary boundaries; zero findings is valid, optional suggestions never block. A reviewer process exit code 0 or a natural-language summary is not proof that the review was applied: re-run `delivery inspect --json` after the reviewer returns, and if the ledger is still `pending`, `in_review`, `stale`, or `blocked`, report `review_incomplete` and continue the required review or repair instead of claiming success.', + '- Submit one module review JSON: `{ "subject": , "reviewer": "self|independent", "direction": "goal coverage and diff justification", "correctness": "observed behavior and concrete risks", "proportionality": "why complexity/defenses are warranted", "nextAction": "authorized next module or stop", "coverage": [{ "acceptanceId": "AC-1", "status": "met|missing|unverified", "evidence": "implementation/call path and observed evidence" }], "findings": [], "resolved": [] }`. Every required acceptance must be covered; findings contain only required repairs as `{ "id": "R-1", "domain": "quality|security", "severity": "p0|p1|p2", "summary": "causal evidence and consequence" }`. Apply with `mancode workflow delivery review --file --review-depth --expected-revision --session `; full is required for material security risk. The command receipt includes the current finalization blockers; do not stop at a successful process exit while `review_incomplete` remains.', + '- Fix concrete findings, verify the changed module and recheck the repair plus direct regression; use resolved finding IDs instead of dropping issues. Unchanged reviewed content retains applicable tests, while changed content conservatively invalidates module evidence. Do not loop without new diagnostic evidence, invent findings, or expand scope to hypothetical improvements. Require explicit audited approval for any existing review skip/waiver.', + '- Before completion, sync the record and optional page, verify, then commit only task-owned versionable changes on the current task branch. Any uncommitted outside-scope file blocks final delivery because it could have influenced verification; move, stash, or separately commit it, but never add it to this task commit. Use `mancode workflow delivery check --json`, then `mancode workflow complete --expected-revision --session `. Push only to an existing authorized upstream; report no upstream or failed push as unpublished, never business-blocked. Do not auto-init Git, configure remotes, force-add private files, merge or deploy. Continue another module only when already authorized; otherwise report the result and next action.', '- When a new high-frequency domain term emerges, propose it to the operator; only after explicit confirmation register it with `mancode context glossary add --term "" --definition "" --expected-revision --session `. Never write to the glossary without operator confirmation.', ], }, diff --git a/src/runtime/operation-journal.ts b/src/runtime/operation-journal.ts index 1c7b41a..dcba45e 100644 --- a/src/runtime/operation-journal.ts +++ b/src/runtime/operation-journal.ts @@ -81,6 +81,11 @@ export interface OperationJournalV1 { export interface OperationTransitionOptions { /** True only before any external write, or after verified compensation. */ canAbort: boolean; + /** Narrow repair-only rebinding for a conflicted reframe checkpoint target. */ + reframeCheckpointReplacement?: { + fromCheckpointId: Ulid; + toCheckpointId: Ulid; + }; } const OPERATION_TYPES = new Set([ @@ -220,7 +225,11 @@ export function assertOperationJournalTransition( next: OperationJournalV1, options: OperationTransitionOptions, ): void { - assertJournalIdentityIsStable(previous, next); + assertJournalIdentityIsStable( + previous, + next, + options.reframeCheckpointReplacement, + ); assertStepProgresses(previous.steps, next.steps); if (previous.state === next.state) return; if (!allowedOperationTransitions(previous.state).has(next.state)) { @@ -433,6 +442,7 @@ function parseTimestamp(value: unknown, label: string): string { function assertJournalIdentityIsStable( previous: OperationJournalV1, next: OperationJournalV1, + replacement: OperationTransitionOptions['reframeCheckpointReplacement'], ): void { if ( previous.operationId !== next.operationId || @@ -443,16 +453,75 @@ function assertJournalIdentityIsStable( previous.sessionId !== next.sessionId || JSON.stringify(previous.authorizationBasis) !== JSON.stringify(next.authorizationBasis) || - previous.recoveryPayloadDigest !== next.recoveryPayloadDigest || previous.startedAt !== next.startedAt || JSON.stringify(previous.secondaryReservations) !== - JSON.stringify(next.secondaryReservations) || - JSON.stringify(previous.entityLocks) !== JSON.stringify(next.entityLocks) || - JSON.stringify(previous.expectedRevisions) !== - JSON.stringify(next.expectedRevisions) + JSON.stringify(next.secondaryReservations) ) { throw new Error('operation journal identity fields are immutable'); } + if (replacement === undefined) { + if ( + previous.recoveryPayloadDigest !== next.recoveryPayloadDigest || + JSON.stringify(previous.entityLocks) !== + JSON.stringify(next.entityLocks) || + JSON.stringify(previous.expectedRevisions) !== + JSON.stringify(next.expectedRevisions) + ) { + throw new Error('operation journal identity fields are immutable'); + } + return; + } + assertReframeCheckpointReplacement(previous, next, replacement); +} + +function assertReframeCheckpointReplacement( + previous: OperationJournalV1, + next: OperationJournalV1, + replacement: NonNullable< + OperationTransitionOptions['reframeCheckpointReplacement'] + >, +): void { + assertUlid(replacement.fromCheckpointId, 'reframe replacement checkpointId'); + assertUlid(replacement.toCheckpointId, 'reframe replacement checkpointId'); + if ( + replacement.fromCheckpointId === replacement.toCheckpointId || + previous.type !== 'reframe' || + next.type !== 'reframe' || + previous.state !== 'repair_required' || + next.state !== 'repair_required' || + previous.secondaryReservations.length !== 0 || + previous.recoveryPayloadDigest === undefined || + next.recoveryPayloadDigest === undefined || + previous.recoveryPayloadDigest === next.recoveryPayloadDigest || + JSON.stringify(previous.steps) !== JSON.stringify(next.steps) + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } + const fromKey = `checkpoint:${replacement.fromCheckpointId}`; + const toKey = `checkpoint:${replacement.toCheckpointId}`; + if ( + previous.entityLocks.filter((key) => key === fromKey).length !== 1 || + previous.entityLocks.includes(toKey) || + JSON.stringify(next.entityLocks) !== + JSON.stringify( + previous.entityLocks.map((key) => (key === fromKey ? toKey : key)), + ) || + previous.expectedRevisions[fromKey] !== 0 || + previous.expectedRevisions[toKey] !== undefined + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } + const expectedRevisions = Object.fromEntries( + Object.entries(previous.expectedRevisions).map(([key, revision]) => [ + key === fromKey ? toKey : key, + revision, + ]), + ); + if ( + JSON.stringify(next.expectedRevisions) !== JSON.stringify(expectedRevisions) + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } } function assertStepProgresses( diff --git a/src/runtime/operation-recovery-executor.ts b/src/runtime/operation-recovery-executor.ts index 153314a..50777e3 100644 --- a/src/runtime/operation-recovery-executor.ts +++ b/src/runtime/operation-recovery-executor.ts @@ -9,6 +9,10 @@ import { writeFile, } from 'node:fs/promises'; import path from 'node:path'; +import { + buildTaskAggregateManifest, + taskAggregateDigest, +} from '../context/aggregate.js'; import { digestCanonicalJson } from '../context/canonical.js'; import { type Ulid, assertUlid } from '../context/ids.js'; import { @@ -17,14 +21,19 @@ import { parseSchemaManifest, } from '../context/manifest.js'; import { parseMigrationStage } from '../context/migrate.js'; +import { parseRequirementsLedger } from '../context/requirements-ledger.js'; +import { parseReviewLedger } from '../context/review-ledger.js'; import { V3ContextStore } from '../context/store.js'; import { taskRootPath } from '../context/task-locator.js'; import { type TaskRef, sameTaskRef } from '../context/task-ref.js'; +import { parseVerificationLedger } from '../context/verification-ledger.js'; +import { parseWorkflowMetadata } from '../context/workflow-metadata.js'; import { applyV3AdapterFilePlan, assertV3AdapterTargetSafe, v3AdapterTargetPath, } from '../installers/v3-adapter.js'; +import { parseCheckpoint } from '../team/checkpoints.js'; import { assertProjectConfigTransition, assertTeamPolicyTransition, @@ -43,8 +52,15 @@ import { type LocalLockHandle, acquireOperationEntityLocks, } from './local-lock.js'; -import { getOperationDefinition } from './operation-definition.js'; -import type { OperationJournalV1 } from './operation-journal.js'; +import { throwIfOperationCrashInjected } from './operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from './operation-definition.js'; +import { + type OperationJournalV1, + parseOperationJournal, +} from './operation-journal.js'; import { type OperationRecoveryActionV1, type OperationRecoveryPayloadV1, @@ -60,7 +76,11 @@ import { taskAuthorityContentDigest, workflowTaskDirectoryDigest, } from './operation-recovery-payload.js'; -import { readOperationRecoveryPayload } from './operation-recovery-store.js'; +import { + readOperationRecoveryPayload, + readOperationRecoveryPayloadForDigest, + writeOperationRecoveryPayloadVersion, +} from './operation-recovery-store.js'; import { readOperationReservation, removeOperationReservation, @@ -74,11 +94,13 @@ import { readProjectRuntimeContext } from './project-runtime.js'; import { inspectOperationProjectionState } from './projection-outbox.js'; import { assertRecoveryActor, planOperationRecovery } from './reconciler.js'; import { readSession } from './session.js'; +import { parseTaskHeadFence } from './task-head-fence.js'; import { readTaskHeadFence, replaceTaskHeadFence } from './task-head-store.js'; import { readTaskArchiveDigestAtRoot, readTaskAuthorityFileAtRoot, readTaskCheckpointAtRoot, + serializeTaskAuthority, writeTaskArchiveAtRoot, writeTaskAuthorityFileAtRoot, writeTaskCheckpointAtRoot, @@ -96,6 +118,7 @@ export interface ExecuteOperationRecoveryInput { actorId: Ulid; sessionId: Ulid; mode?: 'repair' | 'abort'; + replacementCheckpointId?: Ulid; now?: Date; } @@ -103,6 +126,10 @@ export interface ExecutedOperationRecovery { state: OperationRecoveryExecutionState; journal: OperationJournalV1; reason: string; + checkpointReplacement?: { + previousCheckpointId: Ulid; + replacementCheckpointId: Ulid; + }; } export interface InspectedOperationRecovery { @@ -178,8 +205,17 @@ export async function executeOperationRecovery( assertUlid(input.operationId, 'operation recovery operationId'); assertUlid(input.actorId, 'operation recovery actorId'); assertUlid(input.sessionId, 'operation recovery sessionId'); + if (input.replacementCheckpointId !== undefined) { + assertUlid( + input.replacementCheckpointId, + 'operation recovery replacementCheckpointId', + ); + } const now = input.now ?? new Date(); const mode = input.mode ?? 'repair'; + if (mode === 'abort' && input.replacementCheckpointId !== undefined) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } const stores = await knownOperationStores(input.projectRoot); const located = await locateJournal(stores, input.operationId); if (located === null) throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); @@ -200,24 +236,33 @@ export async function executeOperationRecovery( located.journal.state === 'committed' || located.journal.state === 'aborted' ) { + await assertTerminalCheckpointReplacementRetry( + located.store, + located.journal, + input.replacementCheckpointId, + ); return { state: 'already_terminal', journal: located.journal, reason: 'terminal', }; } + assertCheckpointReplacementSupported( + located.journal, + input.replacementCheckpointId, + ); const locks = await acquireRecoveryOperationLocks( located.store, located.journal, stores, now, + input.replacementCheckpointId === undefined + ? [] + : [`checkpoint:${input.replacementCheckpointId}`], ); try { - const journal = await readOperationJournal( - located.store, - input.operationId, - ); + let journal = await readOperationJournal(located.store, input.operationId); if (journal === null) throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); const plan = planOperationRecovery({ @@ -226,9 +271,18 @@ export async function executeOperationRecovery( }); assertRecoveryActor(plan, input.actorId, input.sessionId); if (journal.state === 'committed' || journal.state === 'aborted') { + await assertTerminalCheckpointReplacementRetry( + located.store, + journal, + input.replacementCheckpointId, + ); return { state: 'already_terminal', journal, reason: 'terminal' }; } - const payload = await loadBoundPayload(located.store, journal); + assertCheckpointReplacementSupported( + journal, + input.replacementCheckpointId, + ); + let payload = await loadBoundPayload(located.store, journal); if (payload === null) { if (plan.action === 'safe_abort') { const aborted = await abortOperation( @@ -251,6 +305,34 @@ export async function executeOperationRecovery( reason: 'MANCODE_OPERATION_RECOVERY_PAYLOAD_REQUIRED', }; } + if ( + input.replacementCheckpointId === undefined && + payload.type === 'reframe' && + (await reframeCheckpointReplacementProvenance( + located.store, + journal, + payload, + )) !== null + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_REQUIRED'); + } + let checkpointReplacement: + | ExecutedOperationRecovery['checkpointReplacement'] + | undefined; + if (input.replacementCheckpointId !== undefined) { + const amended = await replaceReframeRecoveryCheckpoint({ + projectRoot: input.projectRoot, + primaryStore: located.store, + stores, + journal, + payload, + replacementCheckpointId: input.replacementCheckpointId, + now, + }); + journal = amended.journal; + payload = amended.payload; + checkpointReplacement = amended.checkpointReplacement; + } assertOperationRecoveryPayloadCoversJournal(journal, payload); try { await removePrivateWorkflowStaging(input.projectRoot, journal, payload); @@ -276,6 +358,7 @@ export async function executeOperationRecovery( } if (mode === 'abort') throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); try { + await assertReframeRecoveryPlanCurrent(input.projectRoot, payload); const repaired = await applyPayload( input.projectRoot, located.store, @@ -298,6 +381,9 @@ export async function executeOperationRecovery( state: 'repaired', journal: repaired, reason: 'forward_repair', + ...(checkpointReplacement === undefined + ? {} + : { checkpointReplacement }), }; } catch (error) { const blocked = await markRepairRequired(located.store, journal, now); @@ -309,6 +395,9 @@ export async function executeOperationRecovery( error.message === 'MANCODE_OPERATION_RECOVERY_CONFLICT' ? 'MANCODE_OPERATION_RECOVERY_CONFLICT' : 'MANCODE_OPERATION_RECOVERY_FAILED', + ...(checkpointReplacement === undefined + ? {} + : { checkpointReplacement }), }; } } finally { @@ -372,6 +461,7 @@ async function acquireRecoveryOperationLocks( journal: OperationJournalV1, stores: EntityHomeStore[], now: Date, + extraPrimaryEntityLocks: string[] = [], ): Promise { const byId = new Map(stores.map((store) => [store.storeId, store])); const secondaryTargets = journal.secondaryReservations.map((reservation) => { @@ -384,7 +474,12 @@ async function acquireRecoveryOperationLocks( return acquireOperationEntityLocks( journal.operationId, [ - { store: primaryStore, entityLockKeys: journal.entityLocks }, + { + store: primaryStore, + entityLockKeys: [ + ...new Set([...journal.entityLocks, ...extraPrimaryEntityLocks]), + ], + }, ...secondaryTargets, ], { now }, @@ -396,9 +491,10 @@ async function loadBoundPayload( journal: OperationJournalV1, ): Promise { if (journal.recoveryPayloadDigest === undefined) return null; - const payload = await readOperationRecoveryPayload( + const payload = await readOperationRecoveryPayloadForDigest( store, journal.operationId, + journal.recoveryPayloadDigest, ); if (payload === null) { throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISSING'); @@ -415,6 +511,433 @@ async function loadBoundPayload( return parsed; } +function assertCheckpointReplacementSupported( + journal: OperationJournalV1, + replacementCheckpointId: Ulid | undefined, +): void { + if (replacementCheckpointId === undefined) return; + if ( + journal.type !== 'reframe' || + journal.state !== 'repair_required' || + journal.recoveryPayloadDigest === undefined || + journal.secondaryReservations.length !== 0 + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + } +} + +async function assertTerminalCheckpointReplacementRetry( + store: EntityHomeStore, + journal: OperationJournalV1, + replacementCheckpointId: Ulid | undefined, +): Promise { + if (replacementCheckpointId === undefined) return; + if ( + journal.type !== 'reframe' || + journal.state !== 'committed' || + journal.recoveryPayloadDigest === undefined || + journal.secondaryReservations.length !== 0 + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + } + const payload = await loadBoundPayload(store, journal); + if (payload === null) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_REQUIRED'); + } + assertOperationRecoveryPayloadCoversJournal(journal, payload); + const replacement = await reframeCheckpointReplacementProvenance( + store, + journal, + payload, + ); + if (replacement?.replacementCheckpointId !== replacementCheckpointId) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + } +} + +type CheckpointRecoveryAction = Extract< + OperationRecoveryActionV1, + { kind: 'checkpoint' } +>; +type TaskAuthorityRecoveryAction = Extract< + OperationRecoveryActionV1, + { kind: 'task_authority_file' } +>; +type TaskArchiveRecoveryAction = Extract< + OperationRecoveryActionV1, + { kind: 'task_archive' } +>; + +async function replaceReframeRecoveryCheckpoint(input: { + projectRoot: string; + primaryStore: EntityHomeStore; + stores: EntityHomeStore[]; + journal: OperationJournalV1; + payload: OperationRecoveryPayloadV1; + replacementCheckpointId: Ulid; + now: Date; +}): Promise<{ + journal: OperationJournalV1; + payload: OperationRecoveryPayloadV1; + checkpointReplacement?: NonNullable< + ExecutedOperationRecovery['checkpointReplacement'] + >; +}> { + const checkpointAction = singleReframeCheckpointAction(input.payload); + const previousCheckpointId = checkpointAction.checkpoint.checkpointId; + const priorReplacement = await reframeCheckpointReplacementProvenance( + input.primaryStore, + input.journal, + input.payload, + ); + if (priorReplacement !== null) { + if ( + priorReplacement.replacementCheckpointId !== input.replacementCheckpointId + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + } + await assertRecoveryPayloadState( + input.projectRoot, + input.stores, + input.payload, + ); + return { + journal: input.journal, + payload: input.payload, + checkpointReplacement: priorReplacement, + }; + } + if (previousCheckpointId === input.replacementCheckpointId) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + } + const taskRootPath = await taskRoot( + input.projectRoot, + checkpointAction.checkpoint.taskRef, + ); + const currentCheckpoint = await readTaskCheckpointAtRoot( + taskRootPath, + previousCheckpointId, + ); + + if ( + !reframeCheckpointStepReached(input.journal) || + currentCheckpoint === null || + currentCheckpoint.operationId === input.journal.operationId || + !sameTaskRef( + currentCheckpoint.taskRef, + checkpointAction.checkpoint.taskRef, + ) || + digestCanonicalJson(currentCheckpoint) === + digestCanonicalJson(checkpointAction.checkpoint) + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + } + + await assertRecoveryPayloadState( + input.projectRoot, + input.stores, + input.payload, + checkpointAction, + ); + await assertReframeRecoveryPlanCurrent(input.projectRoot, input.payload); + const payload = buildReframeCheckpointReplacementPayload( + input.payload, + checkpointAction, + input.replacementCheckpointId, + ); + const replacementAction = singleReframeCheckpointAction(payload); + const existingReplacement = await readTaskCheckpointAtRoot( + taskRootPath, + input.replacementCheckpointId, + ); + if ( + existingReplacement !== null && + digestCanonicalJson(existingReplacement) !== + digestCanonicalJson(replacementAction.checkpoint) + ) { + throw new Error('MANCODE_REPLACEMENT_CHECKPOINT_ID_CONFLICT'); + } + await assertRecoveryPayloadState(input.projectRoot, input.stores, payload); + + const fromKey = `checkpoint:${previousCheckpointId}`; + const toKey = `checkpoint:${input.replacementCheckpointId}`; + const payloadDigest = operationRecoveryPayloadDigest(payload); + const journal = parseOperationJournal({ + ...input.journal, + recoveryPayloadDigest: payloadDigest, + entityLocks: input.journal.entityLocks.map((key) => + key === fromKey ? toKey : key, + ), + expectedRevisions: Object.fromEntries( + Object.entries(input.journal.expectedRevisions).map(([key, revision]) => [ + key === fromKey ? toKey : key, + revision, + ]), + ), + updatedAt: input.now.toISOString(), + }); + assertOperationJournalMatchesDefinition(journal); + assertOperationRecoveryPayloadCoversJournal(journal, payload); + await writeOperationRecoveryPayloadVersion(input.primaryStore, payload); + const updatedJournal = await updateOperationJournal( + input.primaryStore, + journal, + { + canAbort: false, + reframeCheckpointReplacement: { + fromCheckpointId: previousCheckpointId, + toCheckpointId: input.replacementCheckpointId, + }, + }, + ); + throwIfOperationCrashInjected('reframe', 'rebind-reframe-checkpoint'); + return { + journal: updatedJournal, + payload, + checkpointReplacement: { + previousCheckpointId, + replacementCheckpointId: input.replacementCheckpointId, + }, + }; +} + +function reframeCheckpointStepReached(journal: OperationJournalV1): boolean { + const checkpointIndex = journal.steps.findIndex( + (step) => step.id === 'write-reframe-checkpoint', + ); + return ( + checkpointIndex >= 0 && + journal.steps + .slice(0, checkpointIndex) + .every((step) => step.state === 'completed') + ); +} + +async function reframeCheckpointReplacementProvenance( + store: EntityHomeStore, + journal: OperationJournalV1, + payload: OperationRecoveryPayloadV1, +): Promise | null> { + if (journal.type !== 'reframe') return null; + const canonicalValue = await readOperationRecoveryPayload( + store, + journal.operationId, + ); + if (canonicalValue === null) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISSING'); + } + const canonical = parseOperationRecoveryPayload(canonicalValue); + if ( + canonical.operationId !== journal.operationId || + canonical.type !== journal.type || + canonical.primaryStoreId !== journal.primaryStoreId + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISMATCH'); + } + if ( + operationRecoveryPayloadDigest(canonical) === + operationRecoveryPayloadDigest(payload) + ) { + return null; + } + const previousCheckpointId = + singleReframeCheckpointAction(canonical).checkpoint.checkpointId; + const replacementCheckpointId = + singleReframeCheckpointAction(payload).checkpoint.checkpointId; + if (previousCheckpointId === replacementCheckpointId) return null; + const expected = buildReframeCheckpointReplacementPayload( + canonical, + singleReframeCheckpointAction(canonical), + replacementCheckpointId, + ); + if ( + operationRecoveryPayloadDigest(expected) !== + operationRecoveryPayloadDigest(payload) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISMATCH'); + } + return { previousCheckpointId, replacementCheckpointId }; +} + +async function assertReframeRecoveryPlanCurrent( + projectRoot: string, + payload: OperationRecoveryPayloadV1, +): Promise { + if (payload.type !== 'reframe') return; + const checkpointAction = singleReframeCheckpointAction(payload); + const archiveAction = singleTaskArchiveAction(payload); + const task = await new V3ContextStore(projectRoot).readTaskSnapshot( + checkpointAction.checkpoint.taskRef, + ); + if ((task.plan?.digest ?? null) !== archiveAction.sourcePlanDigest) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } +} + +function buildReframeCheckpointReplacementPayload( + payload: OperationRecoveryPayloadV1, + checkpointAction: CheckpointRecoveryAction, + replacementCheckpointId: Ulid, +): OperationRecoveryPayloadV1 { + const finalMetadataAction = singleTaskAuthorityAction( + payload, + 'commit-reframed-metadata', + 'metadata.json', + ); + const requirementsAction = singleTaskAuthorityAction( + payload, + 'write-requirements-draft', + 'requirements.json', + ); + const reviewAction = singleTaskAuthorityAction( + payload, + 'mark-review-verification-stale', + 'review-ledger.json', + ); + const verificationAction = singleTaskAuthorityAction( + payload, + 'mark-review-verification-stale', + 'verification-ledger.json', + ); + const archiveAction = singleTaskArchiveAction(payload); + const metadata = parseWorkflowMetadata( + JSON.parse(finalMetadataAction.targetContent), + ); + if ( + metadata.lastOperationId !== payload.operationId || + metadata.latestCheckpointRef?.kind !== 'checkpoint' || + metadata.latestCheckpointRef.artifactId !== + checkpointAction.checkpoint.checkpointId + ) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } + const checkpoint = parseCheckpoint({ + ...checkpointAction.checkpoint, + checkpointId: replacementCheckpointId, + }); + const replacementMetadata = parseWorkflowMetadata({ + ...metadata, + latestCheckpointRef: { + ...metadata.latestCheckpointRef, + artifactId: replacementCheckpointId, + }, + }); + const aggregate = buildTaskAggregateManifest({ + metadata: replacementMetadata, + requirements: parseRequirementsLedger( + JSON.parse(requirementsAction.targetContent), + ), + review: parseReviewLedger(JSON.parse(reviewAction.targetContent)), + verification: parseVerificationLedger( + JSON.parse(verificationAction.targetContent), + ), + planDigest: archiveAction.sourcePlanDigest, + latestCheckpoint: checkpoint, + }); + const fenceActions = payload.actions.filter( + (action) => action.kind === 'task_head_fence', + ); + if (fenceActions.length > 1) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + } + return parseOperationRecoveryPayload({ + ...payload, + actions: payload.actions.map((action) => { + if (action === checkpointAction) { + return { ...action, checkpoint }; + } + if (action === finalMetadataAction) { + return { + ...action, + targetContent: serializeTaskAuthority(replacementMetadata), + }; + } + if (action.kind === 'task_head_fence') { + return { + ...action, + fence: parseTaskHeadFence({ + ...action.fence, + aggregateDigest: taskAggregateDigest(aggregate), + }), + }; + } + return action; + }), + }); +} + +function singleReframeCheckpointAction( + payload: OperationRecoveryPayloadV1, +): CheckpointRecoveryAction { + const matches = payload.actions.filter( + (action): action is CheckpointRecoveryAction => + action.kind === 'checkpoint' && + action.stepId === 'write-reframe-checkpoint' && + action.beforeDigest === null, + ); + if (matches.length !== 1 || matches[0] === undefined) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + } + return matches[0]; +} + +function singleTaskAuthorityAction( + payload: OperationRecoveryPayloadV1, + stepId: string, + fileName: TaskAuthorityRecoveryAction['fileName'], +): TaskAuthorityRecoveryAction { + const matches = payload.actions.filter( + (action): action is TaskAuthorityRecoveryAction => + action.kind === 'task_authority_file' && + action.stepId === stepId && + action.fileName === fileName, + ); + if (matches.length !== 1 || matches[0] === undefined) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + } + return matches[0]; +} + +function singleTaskArchiveAction( + payload: OperationRecoveryPayloadV1, +): TaskArchiveRecoveryAction { + const matches = payload.actions.filter( + (action): action is TaskArchiveRecoveryAction => + action.kind === 'task_archive' && + action.stepId === 'archive-requirements-plan', + ); + if (matches.length !== 1 || matches[0] === undefined) { + throw new Error('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + } + return matches[0]; +} + +async function assertRecoveryPayloadState( + projectRoot: string, + stores: EntityHomeStore[], + payload: OperationRecoveryPayloadV1, + ignoredAction?: OperationRecoveryActionV1, +): Promise { + for (const [index, action] of payload.actions.entries()) { + if (action === ignoredAction) continue; + const current = await currentActionDigest(projectRoot, stores, action); + const target = recoveryActionTargetDigest(action); + const laterTarget = payload.actions + .slice(index + 1) + .some( + (candidate) => + recoveryActionResourceKey(candidate) === + recoveryActionResourceKey(action) && + recoveryActionTargetDigest(candidate) === current, + ); + if (current !== target && current !== action.beforeDigest && !laterTarget) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + } +} + async function allActionsAtInitialState( projectRoot: string, stores: EntityHomeStore[], diff --git a/src/runtime/operation-recovery-store.ts b/src/runtime/operation-recovery-store.ts index f90ace1..1720a68 100644 --- a/src/runtime/operation-recovery-store.ts +++ b/src/runtime/operation-recovery-store.ts @@ -24,6 +24,18 @@ export function operationRecoveryPayloadPath( return path.join(operationRecoveryDirectory(store), `${operationId}.json`); } +export function operationRecoveryPayloadVersionPath( + store: EntityHomeStore, + operationId: string, + payloadDigest: string, +): string { + assertUlid(operationId, 'operation recovery payload operationId'); + return path.join( + operationRecoveryDirectory(store), + `${operationId}.${payloadDigestToken(payloadDigest)}.json`, + ); +} + /** * Persists recovery targets before the prepared journal is made durable. A * retry may observe the same target bundle, never a substitute for it. @@ -64,15 +76,70 @@ export async function writeOperationRecoveryPayload( } } +/** Writes an immutable content-addressed payload before rebinding a journal. */ +export async function writeOperationRecoveryPayloadVersion( + store: EntityHomeStore, + value: OperationRecoveryPayloadV1, +): Promise { + const payload = parseOperationRecoveryPayload(value); + if (payload.primaryStoreId !== store.storeId) { + throw new Error( + 'operation recovery payload primaryStoreId does not match its home store', + ); + } + const digest = operationRecoveryPayloadDigest(payload); + const directory = operationRecoveryDirectory(store); + const target = operationRecoveryPayloadVersionPath( + store, + payload.operationId, + digest, + ); + await mkdir(directory, { recursive: true }); + try { + await writeFile(target, serialize(payload), { + encoding: 'utf8', + flag: 'wx', + }); + return payload; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readOperationRecoveryPayloadAtPath(target); + if ( + existing !== null && + operationRecoveryPayloadDigest(existing) === digest + ) { + return existing; + } + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_CONFLICT'); + } +} + export async function readOperationRecoveryPayload( store: EntityHomeStore, operationId: string, +): Promise { + return readOperationRecoveryPayloadAtPath( + operationRecoveryPayloadPath(store, operationId), + ); +} + +export async function readOperationRecoveryPayloadForDigest( + store: EntityHomeStore, + operationId: string, + payloadDigest: string, +): Promise { + const versioned = await readOperationRecoveryPayloadAtPath( + operationRecoveryPayloadVersionPath(store, operationId, payloadDigest), + ); + if (versioned !== null) return versioned; + return readOperationRecoveryPayload(store, operationId); +} + +async function readOperationRecoveryPayloadAtPath( + target: string, ): Promise { try { - const raw = await readFile( - operationRecoveryPayloadPath(store, operationId), - 'utf8', - ); + const raw = await readFile(target, 'utf8'); return parseOperationRecoveryPayload(JSON.parse(raw)); } catch (error) { if (isNotFound(error)) return null; @@ -83,6 +150,14 @@ export async function readOperationRecoveryPayload( } } +function payloadDigestToken(payloadDigest: string): string { + const match = /^sha256:([a-f0-9]{64})$/.exec(payloadDigest); + if (match?.[1] === undefined) { + throw new Error('operation recovery payload digest is invalid'); + } + return match[1]; +} + function serialize(payload: OperationRecoveryPayloadV1): string { return `${JSON.stringify(payload, null, 2)}\n`; } diff --git a/src/runtime/retention.ts b/src/runtime/retention.ts index e4b64ab..7c93c23 100644 --- a/src/runtime/retention.ts +++ b/src/runtime/retention.ts @@ -19,7 +19,11 @@ import { type OperationJournalV1, parseOperationJournal, } from './operation-journal.js'; -import { operationRecoveryPayloadPath } from './operation-recovery-store.js'; +import { + operationRecoveryDirectory, + operationRecoveryPayloadPath, + operationRecoveryPayloadVersionPath, +} from './operation-recovery-store.js'; import { readProjectRuntimeContext } from './project-runtime.js'; import { parseSessionState } from './session.js'; @@ -166,14 +170,14 @@ export async function applyContextCompaction( ): Promise { const deleted: string[] = []; for (const candidate of plan.candidates) { - await removeRegularFile(candidate.target); - deleted.push(candidate.target); for (const target of candidate.relatedTargets) { if (await regularFileExists(target)) { await removeRegularFile(target); deleted.push(target); } } + await removeRegularFile(candidate.target); + deleted.push(candidate.target); } return { ...plan, deleted }; } @@ -287,6 +291,7 @@ async function planOperationRetention( for (const store of stores) { const directory = operationDirectory(store); const entries = await readDirectoryOrEmpty(directory); + let recoveryEntries: string[] | null = null; for (const entry of entries) { if (!entry.endsWith('.json')) continue; const target = path.join(directory, entry); @@ -304,7 +309,12 @@ async function planOperationRetention( } } } else if (Date.parse(journal.updatedAt) < threshold) { - candidates.push(operationRetentionCandidate(store, journal, target)); + recoveryEntries ??= await readDirectoryOrEmpty( + operationRecoveryDirectory(store), + ); + candidates.push( + operationRetentionCandidate(store, journal, target, recoveryEntries), + ); } } } @@ -348,19 +358,50 @@ function operationRetentionCandidate( store: EntityHomeStore, journal: OperationJournalV1, target: string, + recoveryEntries: string[], ): RetentionCandidate { + const relatedTargets = + journal.recoveryPayloadDigest === undefined + ? [] + : operationRecoveryTargetsForRetention(store, journal, recoveryEntries); return { kind: 'terminal_operation', target, reason: `${journal.state} operation exceeds ${TERMINAL_JOURNAL_RETENTION_DAYS} day retention`, taskRef: null, - relatedTargets: - journal.recoveryPayloadDigest === undefined - ? [] - : [operationRecoveryPayloadPath(store, journal.operationId)], + relatedTargets, }; } +function operationRecoveryTargetsForRetention( + store: EntityHomeStore, + journal: OperationJournalV1, + recoveryEntries: string[], +): string[] { + if (journal.recoveryPayloadDigest === undefined) return []; + const directory = operationRecoveryDirectory(store); + const versionPrefix = `${journal.operationId}.`; + const discoveredVersions = recoveryEntries + .filter((entry) => { + if (!entry.startsWith(versionPrefix)) return false; + return /^[a-f0-9]{64}\.json$/.test(entry.slice(versionPrefix.length)); + }) + .map((entry) => path.join(directory, entry)); + return [ + ...new Set([ + operationRecoveryPayloadPath(store, journal.operationId), + operationRecoveryPayloadVersionPath( + store, + journal.operationId, + journal.recoveryPayloadDigest, + ), + ...discoveredVersions, + ]), + ].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); +} + function operationEntityKeys(journal: OperationJournalV1): string[] { return [ ...journal.entityLocks, diff --git a/src/runtime/task-operation.ts b/src/runtime/task-operation.ts index 3a860e8..da06cbd 100644 --- a/src/runtime/task-operation.ts +++ b/src/runtime/task-operation.ts @@ -829,6 +829,35 @@ export async function writeTaskCheckpointAtRoot( } } +/** Rejects a reused immutable checkpoint ID before an operation journals writes. */ +export async function assertTaskCheckpointIdAvailableAtRoot( + taskRoot: string, + checkpointId: Ulid, +): Promise { + assertUlid(checkpointId, 'checkpointId'); + await assertSafeTaskDirectory(taskRoot); + const directory = path.join(taskRoot, 'checkpoints'); + try { + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return; + throw error; + } + try { + const entry = await lstat(path.join(directory, `${checkpointId}.json`)); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return; + throw error; + } + throw new Error('MANCODE_CHECKPOINT_ID_CONFLICT'); +} + /** Reads a fixed checkpoint path without permitting a linked task subtree. */ export async function readTaskCheckpointAtRoot( taskRoot: string, @@ -847,9 +876,13 @@ export async function readTaskCheckpointAtRoot( throw error; } try { - return await readImmutableCheckpoint( + const checkpoint = await readImmutableCheckpoint( path.join(directory, `${checkpointId}.json`), ); + if (checkpoint.checkpointId !== checkpointId) { + throw new Error('MANCODE_CHECKPOINT_CORRUPT'); + } + return checkpoint; } catch (error) { if ( error instanceof Error && diff --git a/src/system/requirements-ledger.ts b/src/system/requirements-ledger.ts index 8923068..964ba02 100644 --- a/src/system/requirements-ledger.ts +++ b/src/system/requirements-ledger.ts @@ -3,6 +3,15 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; export type VerificationMethod = 'automated' | 'manual' | 'hybrid'; +export type VerificationSurface = + | 'unit' + | 'component' + | 'handler' + | 'real_http' + | 'browser' + | 'device' + | 'external_service' + | 'manual_observation'; export type RequirementCoverageStatus = | 'confirmed' | 'defaulted' @@ -27,6 +36,10 @@ export interface AcceptanceCriterion { description: string; required: boolean; method: VerificationMethod; + verificationSurfaces?: { + automated?: VerificationSurface; + manual?: VerificationSurface; + }; } export interface RequirementsLedger { @@ -44,6 +57,16 @@ export interface RequirementsLedger { const REQUIREMENTS_FILE = 'requirements.json'; const REQUIREMENTS_MARKDOWN_FILE = 'requirements.md'; const ACCEPTANCE_ID_PATTERN = /^AC-[A-Z0-9][A-Z0-9-]{0,27}$/; +const VERIFICATION_SURFACES = new Set([ + 'unit', + 'component', + 'handler', + 'real_http', + 'browser', + 'device', + 'external_service', + 'manual_observation', +]); export const REQUIREMENT_DIMENSIONS: RequirementDimension[] = [ 'platform', 'core_scope', @@ -157,11 +180,16 @@ export function parseRequirementsLedger( throw new Error(`duplicate acceptance criterion: ${item.id}`); } ids.add(item.id); + const verificationSurfaces = parseVerificationSurfaces( + item.verificationSurfaces, + item.method, + ); return { id: item.id, description: item.description.trim(), required: item.required, method: item.method, + ...(verificationSurfaces === undefined ? {} : { verificationSurfaces }), }; }); if ( @@ -239,10 +267,14 @@ export function renderRequirementsMarkdown(ledger: RequirementsLedger): string { const section = (title: string, items: string[]) => `## ${title}\n\n${items.length > 0 ? items.map((item) => `- ${item}`).join('\n') : '- 无'}\n`; const criteria = ledger.acceptanceCriteria - .map( - (item) => - `- **${item.id}** [${item.required ? '必需' : '可选'} / ${item.method}] ${item.description}`, - ) + .map((item) => { + const surfaces = item.verificationSurfaces + ? ` / ${Object.entries(item.verificationSurfaces) + .map(([slot, surface]) => `${slot}=${surface}`) + .join(', ')}` + : ''; + return `- **${item.id}** [${item.required ? '必需' : '可选'} / ${item.method}${surfaces}] ${item.description}`; + }) .join('\n'); const coverage = ledger.coverage .map((item) => `- **${item.dimension}** [${item.status}] ${item.rationale}`) @@ -273,6 +305,48 @@ function isVerificationMethod(value: unknown): value is VerificationMethod { return value === 'automated' || value === 'manual' || value === 'hybrid'; } +function parseVerificationSurfaces( + value: unknown, + method: VerificationMethod, +): AcceptanceCriterion['verificationSurfaces'] { + if (value === undefined) return undefined; + if (!isRecord(value)) + throw new Error('invalid acceptance verificationSurfaces'); + const unknown = Object.keys(value).filter( + (key) => key !== 'automated' && key !== 'manual', + ); + if (unknown.length) + throw new Error('invalid acceptance verificationSurfaces'); + const automated = parseVerificationSurface(value.automated); + const manual = parseVerificationSurface(value.manual); + if ( + (method === 'automated' && + (automated === undefined || manual !== undefined)) || + (method === 'manual' && + (automated !== undefined || manual === undefined)) || + (method === 'hybrid' && (automated === undefined || manual === undefined)) + ) { + throw new Error('invalid acceptance verificationSurfaces'); + } + return { + ...(automated === undefined ? {} : { automated }), + ...(manual === undefined ? {} : { manual }), + }; +} + +function parseVerificationSurface( + value: unknown, +): VerificationSurface | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== 'string' || + !VERIFICATION_SURFACES.has(value as VerificationSurface) + ) { + throw new Error('invalid acceptance verification surface'); + } + return value as VerificationSurface; +} + function isRequirementDimension(value: unknown): value is RequirementDimension { return REQUIREMENT_DIMENSIONS.some((dimension) => dimension === value); } diff --git a/src/team/git-ref-workflow-operation.ts b/src/team/git-ref-workflow-operation.ts index afcc45f..ea738a6 100644 --- a/src/team/git-ref-workflow-operation.ts +++ b/src/team/git-ref-workflow-operation.ts @@ -6,6 +6,7 @@ import { } from '../context/aggregate.js'; import { digestCanonicalJson } from '../context/canonical.js'; import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertManDeliveryReady } from '../context/man-delivery-runtime.js'; import { assertManteamPlanContent } from '../context/manteam-plan.js'; import { normalizeImplementationScope, @@ -299,6 +300,7 @@ export async function completeGitRefTask( try { assertNoPendingHandoff(opened.manifest, taskRef); assertCompletionOutcome(opened.context.task.metadata, input.outcome); + await assertManDeliveryReady(input.projectRoot, opened.context.task); const activeClaims = activeRemoteClaims(opened.manifest, taskRef); const activeChildren = await opened.context.store.listActiveChildTaskRefs(taskRef); diff --git a/src/templates/skills/man.ts b/src/templates/skills/man.ts index 65d39a5..9bf4d77 100644 --- a/src/templates/skills/man.ts +++ b/src/templates/skills/man.ts @@ -11,6 +11,10 @@ export const MAN_SKILL: SkillSpec = { 用 \`mancode workflow create man "" --json\` 创建 workflow 并读取返回的 taskId;不得直接创建或改写 metadata.json。随后只用 workflow CLI 更新需求、验证、step/status/planVersion/skippedSteps。通用 \`--skipped\` 只用于 Step 1–2 的 \`clarification\`;用户明确跳过整个 review 时必须在 Step 6 使用专用 \`workflow review ... skip --reason\`,不能写数字、\`film-1\` 或 \`film-2\`。state 的 \`currentMode\`、\`currentTask\` 和 \`currentWorkflowMode\` 指向本 task。 +当任务显式使用 document-bound delivery(\`mancode workflow create man "" --delivery\`,planning policy 3)时,以下交付规则只对该新 \`/man\` 任务生效;普通 \`/man\`、旧任务、\`/manba\`、\`/manteam\`、\`/manps\`、\`/mansolo\` 和 Solo handoff 保持原协议。计划必须绑定项目指定的计划文件(默认 \`doc/\`,已有 \`docs/\` 约定优先),模块完成后把实际交付回写到同一文件的 delivery record,再做一次模块总审。总审读取已批准计划、相关架构、完整模块 diff、入口/调用链和验证证据,检查目标覆盖、真实正确性/漏洞,以及无依据的防御和抽象;它不能只听实现者总结。reviewer 进程退出码为 0 或自然语言说“通过”都不代表 review 已写入:必须重新运行 \`mancode workflow delivery inspect --json\`,看到 ledger 仍为 \`pending\`、\`in_review\`、\`stale\` 或 \`blocked\` 时,报告 \`review_incomplete\` 并继续审核或修复。\`reviewer: independent\` 只是调用者自述的审核元数据,不是 actor/session 身份认证,不能据此宣称系统已证明独立审核。 + +delivery requirements 的每个必需验收项都必须用 \`verificationSurfaces\` 按 automated/manual slot 声明期望 observation surface;delivery 验证输入再声明实际 surface:\`unit\`、\`component\`、\`handler\`、\`real_http\`、\`browser\`、\`device\`、\`external_service\` 或 \`manual_observation\`。例如真实 HTTP 的期望和实际都必须是 \`real_http\`;不能从命令名猜测,也不能把 handler/mock/截图当成真实 HTTP。缺少或不匹配的证据保持未验证。完成前以 \`delivery check --json\` 的结构化 finalization blockers 为准,先解决 review、verification、delivery record、scope 和未提交变更,再运行 \`workflow complete\`;范围外未提交文件必须先移出、stash 或单独提交,不能混入本任务提交。没有 upstream 或 push 失败表示未发布,不是业务阻塞。include/exclude 只接收 repo-relative path 或 glob,语义范围写在 requirements;计划路径越界时按 CLI 给出的具体路径修正,不要靠猜测词表扩大防御。 + ## 计划职责与技术选择关卡 把 \`/man\` 作为正式计划入口:先对齐需求并产出可确认的计划,不因计划完成而自动进入完整实施。计划关卡必须让用户选择只保留计划、交给默认 \`solo\` 轻量实施、继续完整 \`/man\` 或修改计划。 @@ -34,7 +38,7 @@ export const MAN_SKILL: SkillSpec = { 按任务实际适用范围检查:用户目标与平台、核心流程、首期范围、排除项、技术与运行约束、数据/状态/集成、性能/兼容性/安全、可验证完成标准。连续澄清没有减少 blocking 项时,不散问;让用户选择缩小首期范围、接受列明的推荐默认值或暂停。 -把结论写入结构化需求输入,至少包含 \`goal\`、非空 \`confirmedScope\`、\`excludedScope\`、\`technicalDecisions\`、\`defaults\`、\`blockingUnknowns\`、\`coverage\` 和 \`acceptanceCriteria\`。coverage 必须逐项说明 platform、core_scope、technical_stack、data_and_persistence、performance、compatibility、security 是 \`confirmed\`、\`defaulted\` 或带理由的 \`not_applicable\`,不能用空数组假装已经考虑。每个核心用户行为必须有稳定验收 ID(\`AC-1\` 等)、描述、\`required\` 和验证方式 \`automated\` / \`manual\` / \`hybrid\`;至少一个验收项必需。运行 \`mancode workflow requirements finalize --file \`,由 CLI 校验并生成权威 \`requirements.json\` 与 \`requirements.md\`,不要手工制造两份可能冲突的结论。无须提问时可把 \`clarification\` 记入累计 skippedSteps,但不能跳过需求摘要与就绪判断。仍有 blocking 项时停在 Step 2;只有 CLI 返回 ready 才运行 \`mancode workflow update --step 3\`。 +把结论写入结构化需求输入,至少包含 \`goal\`、非空 \`confirmedScope\`、\`excludedScope\`、\`technicalDecisions\`、\`defaults\`、\`blockingUnknowns\`、\`coverage\` 和 \`acceptanceCriteria\`。coverage 必须逐项说明 platform、core_scope、technical_stack、data_and_persistence、performance、compatibility、security 是 \`confirmed\`、\`defaulted\` 或带理由的 \`not_applicable\`,不能用空数组假装已经考虑。每个核心用户行为必须有稳定验收 ID(\`AC-1\` 等)、描述、\`required\` 和验证方式 \`automated\` / \`manual\` / \`hybrid\`;document-bound delivery 的必需项还要提供与 method 槽位一致的 \`verificationSurfaces\`,例如 \`{ "automated": "real_http" }\`,hybrid 同时提供 automated 和 manual。至少一个验收项必需。运行 \`mancode workflow requirements finalize --file \`,由 CLI 校验并生成权威 \`requirements.json\` 与 \`requirements.md\`,不要手工制造两份可能冲突的结论。无须提问时可把 \`clarification\` 记入累计 skippedSteps,但不能跳过需求摘要与就绪判断。仍有 blocking 项时停在 Step 2;只有 CLI 返回 ready 才运行 \`mancode workflow update --step 3\`。 ### Step 3: Plan Coach 出计划 diff --git a/tests/cli-v3-surface-contracts.test.ts b/tests/cli-v3-surface-contracts.test.ts index e6b5c69..3c8dd13 100644 --- a/tests/cli-v3-surface-contracts.test.ts +++ b/tests/cli-v3-surface-contracts.test.ts @@ -42,6 +42,9 @@ describe('V3 CLI command surface', () => { commandAt(cliProgram, 'context', 'glossary').helpInformation(), ).toContain('--expected-revision'); expect(commandAt(cliProgram, 'operation', 'repair')).toBeDefined(); + expect( + commandAt(cliProgram, 'operation', 'repair').helpInformation(), + ).toContain('--replacement-checkpoint-id'); expect(commandAt(cliProgram, 'operation', 'abort')).toBeDefined(); expect(commandAt(cliProgram, 'adapter', 'status')).toBeDefined(); expect(commandAt(cliProgram, 'adapter', 'upgrade')).toBeDefined(); diff --git a/tests/context-resolver-contracts.test.ts b/tests/context-resolver-contracts.test.ts index cf3f35a..9ecfa19 100644 --- a/tests/context-resolver-contracts.test.ts +++ b/tests/context-resolver-contracts.test.ts @@ -229,7 +229,7 @@ describe('V3 Context Resolver', () => { policyVersions: { planning: number | null }; }; }; - metadata.governance.policyVersions.planning = 3; + metadata.governance.policyVersions.planning = 4; await writeJson(metadataPath, metadata); const request = { diff --git a/tests/man-delivery-evidence.test.ts b/tests/man-delivery-evidence.test.ts new file mode 100644 index 0000000..f944692 --- /dev/null +++ b/tests/man-delivery-evidence.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + parseManEvidenceSubject, + parseManReviewEvidence, + parseManVerificationSurface, +} from '../src/context/man-delivery-evidence.js'; + +const subject = { + contentDigest: `sha256:${'a'.repeat(64)}`, + environment: 'linux/x64;node=v22', +}; +const report = { + subject, + reviewer: 'self', + direction: 'AC-1 reaches the entry', + correctness: 'Covered real input/output', + proportionality: 'No speculative defenses', + nextAction: 'Stop', + coverage: [ + { + acceptanceId: 'AC-1', + status: 'met', + evidence: 'app.run and test result', + }, + ], +}; +describe('man semantic evidence input', () => { + it('accepts explicit self-review without inventing findings or independence', () => { + expect(parseManReviewEvidence(report)).toEqual(report); + expect(parseManEvidenceSubject(subject)).toEqual(subject); + expect(parseManVerificationSurface('real_http')).toBe('real_http'); + }); + it.each([ + 'unit', + 'component', + 'handler', + 'real_http', + 'browser', + 'device', + 'external_service', + 'manual_observation', + ])('accepts the declared verification surface %s', (surface) => { + expect(parseManVerificationSurface(surface)).toBe(surface); + }); + it.each(['http', 'integration', '', null, 1])( + 'rejects an invented verification surface %s', + (surface) => { + expect(() => parseManVerificationSurface(surface)).toThrow( + 'MANCODE_MAN_VERIFICATION_SURFACE_INVALID', + ); + }, + ); + it.each([ + { ...report, coverage: [...report.coverage, ...report.coverage] }, + { ...report, reviewer: 'guaranteed' }, + { ...report, correctness: '' }, + { ...report, subject: { ...subject, contentDigest: 'HEAD' } }, + { ...report, arbitraryApproval: true }, + { + ...report, + coverage: [ + { acceptanceId: 'AC-1', status: 'probably', evidence: 'guess' }, + ], + }, + ])('rejects ambiguous or invented evidence shape', (input) => { + expect(() => parseManReviewEvidence(input)).toThrow(); + }); +}); diff --git a/tests/man-delivery-plan.test.ts b/tests/man-delivery-plan.test.ts new file mode 100644 index 0000000..98fad6c --- /dev/null +++ b/tests/man-delivery-plan.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + compileManDeliveryPlan, + parseManDeliveryPlan, + parseManPlanDocument, + replaceManDeliveryRecord, +} from '../src/context/man-delivery-plan.js'; + +const document = [ + '# Export module', + '', + 'AC-1: users can download their own records.', + '', + 'User notes are preserved.', + '', + 'Not started.', + '', + '', +].join('\n'); + +describe('man plan baseline and delivery record', () => { + it('binds the source path and only the approved baseline', () => { + const source = { + version: 1 as const, + path: 'docs/export.md', + baseHead: null, + }; + const plan = compileManDeliveryPlan(source, document); + expect(parseManDeliveryPlan(plan)).toEqual({ + source, + baseline: 'AC-1: users can download their own records.', + }); + const updated = replaceManDeliveryRecord(document, 'Awaiting review.'); + expect(compileManDeliveryPlan(source, updated)).toBe(plan); + expect(updated).toContain('User notes are preserved.'); + expect(updated).not.toContain('Not started.'); + expect(parseManPlanDocument(updated).record).toBe('Awaiting review.'); + }); + + it('detects a changed approved target instead of laundering it as progress', () => { + const source = { + version: 1 as const, + path: 'doc/export.md', + baseHead: null, + }; + expect( + compileManDeliveryPlan(source, document.replace('their own', 'all')), + ).not.toBe(compileManDeliveryPlan(source, document)); + }); + + it('ignores marker examples inside backtick and tilde code fences', () => { + const examples = `\`\`\`markdown\n${document}\`\`\`\n~~~html\n${document}~~~\n`; + expect(parseManPlanDocument(examples + document).baseline).toBe( + 'AC-1: users can download their own records.', + ); + expect(() => parseManPlanDocument(examples)).toThrow( + 'MANCODE_MAN_PLAN_MARKERS_INVALID', + ); + }); + + it.each([ + document.replace('', ''), + document + document, + document.replace( + '', + '', + ), + document.replace('AC-1: users can download their own records.', ''), + ])('rejects ambiguous, incomplete or empty baseline documents', (text) => { + expect(() => parseManPlanDocument(text)).toThrow(); + }); + + it.each([ + '../outside.md', + '/tmp/plan.md', + '.mancode/plan.md', + '架构/design.md', + '项目接口/api.md', + 'docs/../secret.md', + ])('rejects unsafe or private source paths: %s', (file) => { + expect(() => + compileManDeliveryPlan( + { version: 1, path: file, baseHead: null }, + document, + ), + ).toThrow(); + }); + + it('does not reinterpret a legacy plan as a bound plan', () => { + expect(parseManDeliveryPlan('# Existing legacy plan')).toBeNull(); + }); + + it('does not permit record text to introduce another managed block', () => { + expect(() => replaceManDeliveryRecord(document, document)).toThrow(); + }); +}); diff --git a/tests/man-delivery-runtime.test.ts b/tests/man-delivery-runtime.test.ts new file mode 100644 index 0000000..a7da875 --- /dev/null +++ b/tests/man-delivery-runtime.test.ts @@ -0,0 +1,200 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { replaceManDeliveryRecord } from '../src/context/man-delivery-plan.js'; +import { + bindManPlan, + captureManSubject, + inspectManPublication, + manInspectionFailureFinalization, + readManPlanFile, +} from '../src/context/man-delivery-runtime.js'; +import type { StoredTaskSnapshot } from '../src/context/store.js'; + +const execFile = promisify(execFileCallback); +const document = + '\nAC-1: export records\n\n\nPending\n\n'; + +describe('man delivery content and file contracts', () => { + let root: string; + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-delivery-')); + await execFile('git', ['init', '-q'], { cwd: root }); + await execFile('git', ['config', 'user.name', 'Contract'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'contract@example.test'], { + cwd: root, + }); + await mkdir(path.join(root, 'docs')); + await writeFile(path.join(root, 'docs/export.md'), document); + await writeFile(path.join(root, 'app.js'), 'export const value = 1;'); + await commit(); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + async function commit() { + await execFile('git', ['add', 'docs/export.md', 'app.js'], { cwd: root }); + await execFile('git', ['commit', '-qm', 'fixture'], { cwd: root }); + } + async function task(): Promise> { + return { + plan: { + artifactRef: { + taskRef: { namespace: 'local', taskId: '01JZ4B6W5Z0A1B2C3D4E5F6G7H' }, + kind: 'plan', + }, + digest: '', + content: await bindManPlan(root, 'docs/export.md', document, null), + }, + }; + } + + it('ignores delivery prose and authority files, but not changed approved targets', async () => { + const bound = await task(); + const first = await captureManSubject(root, bound); + await writeFile( + path.join(root, 'docs/export.md'), + replaceManDeliveryRecord(document, 'Awaiting review'), + ); + await mkdir(path.join(root, '.mancode/local/drafts'), { recursive: true }); + await writeFile(path.join(root, '.mancode/local/drafts/review.json'), '{}'); + expect(await captureManSubject(root, bound)).toEqual(first); + await writeFile( + path.join(root, 'docs/export.md'), + document.replace('export records', 'skip export'), + ); + await expect(captureManSubject(root, bound)).rejects.toThrow( + 'BASELINE_CHANGED', + ); + }); + + it('detects unstaged, staged and untracked code while retaining evidence across a content-only commit', async () => { + const bound = await task(); + const first = await captureManSubject(root, bound); + await writeFile(path.join(root, 'app.js'), 'export const value = 2;'); + const changed = await captureManSubject(root, bound); + expect(changed).not.toEqual(first); + await execFile('git', ['add', 'app.js'], { cwd: root }); + expect(await captureManSubject(root, bound)).toEqual(changed); + await commit(); + expect(await captureManSubject(root, bound)).toEqual(changed); + await writeFile(path.join(root, 'new.js'), 'export const feature = true;'); + expect(await captureManSubject(root, bound)).not.toEqual(changed); + }); + + it('rejects an ignored plan without forcing it into Git', async () => { + await writeFile(path.join(root, '.gitignore'), 'docs/\n'); + await expect(readManPlanFile(root, 'docs/export.md')).rejects.toThrow( + 'PLAN_IGNORED', + ); + expect(await readFile(path.join(root, '.gitignore'), 'utf8')).toBe( + 'docs/\n', + ); + }); + + it('rejects a plan symlink escaping the approved project', async () => { + const external = await mkdtemp(path.join(tmpdir(), 'mancode-private-')); + try { + await writeFile(path.join(external, 'plan.md'), document); + await symlink( + path.join(external, 'plan.md'), + path.join(root, 'docs/linked.md'), + ); + await expect(readManPlanFile(root, 'docs/linked.md')).rejects.toThrow( + 'PATH_INVALID', + ); + } finally { + await rm(external, { recursive: true, force: true }); + } + }); + + it('allows planning without Git, but never claims versioned evidence without it', async () => { + const unversioned = await mkdtemp( + path.join(tmpdir(), 'mancode-unversioned-'), + ); + try { + await mkdir(path.join(unversioned, 'docs')); + await writeFile(path.join(unversioned, 'docs/export.md'), document); + const plan = await bindManPlan( + unversioned, + 'docs/export.md', + document, + null, + ); + expect(plan).toContain('"baseHead":null'); + await expect( + captureManSubject(unversioned, { + plan: { + ...((await task()).plan as NonNullable), + content: plan, + }, + }), + ).rejects.toThrow('GIT_REQUIRED'); + } finally { + await rm(unversioned, { recursive: true, force: true }); + } + }); + + it('reports deterministic inspection failures without calling the delivery record stale', () => { + expect( + manInspectionFailureFinalization( + new Error('MANCODE_MAN_PLAN_BASELINE_CHANGED'), + ), + ).toEqual({ + status: 'incomplete', + blockers: [ + expect.objectContaining({ + code: 'inspection_failed', + status: 'failed', + nextAction: expect.stringContaining('approved plan baseline'), + diagnostic: 'MANCODE_MAN_PLAN_BASELINE_CHANGED', + }), + ], + }); + }); + + it('checks actual upstream publication independently from delivery without configuring or pushing anything itself', async () => { + expect(await inspectManPublication(root)).toMatchObject({ + status: 'unpublished', + reason: 'no upstream configured', + }); + const remote = await mkdtemp(path.join(tmpdir(), 'mancode-upstream-')); + try { + await execFile('git', ['init', '--bare', '-q', remote]); + await execFile('git', ['remote', 'add', 'origin', remote], { cwd: root }); + await execFile('git', ['push', '-u', 'origin', 'HEAD'], { cwd: root }); + expect(await inspectManPublication(root)).toMatchObject({ + status: 'published', + }); + await writeFile(path.join(root, 'app.js'), 'export const value=3;'); + await commit(); + expect(await inspectManPublication(root)).toMatchObject({ + status: 'unpublished', + }); + await execFile('git', ['push'], { cwd: root }); + expect(await inspectManPublication(root)).toMatchObject({ + status: 'published', + }); + await execFile( + 'git', + ['remote', 'set-url', 'origin', path.join(remote, 'absent')], + { cwd: root }, + ); + expect(await inspectManPublication(root)).toMatchObject({ + status: 'unverified', + }); + } finally { + await rm(remote, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/man-delivery.test.ts b/tests/man-delivery.test.ts new file mode 100644 index 0000000..b2613b7 --- /dev/null +++ b/tests/man-delivery.test.ts @@ -0,0 +1,622 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { normalizeRequirementsInput } from '../src/commands/requirements-input.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { workflow } from '../src/commands/workflow.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { + captureManSubject, + inspectManDelivery, + manScopeContains, + renderManDeliveryRecord, + syncManDeliveryRecord, +} from '../src/context/man-delivery-runtime.js'; +import { reviseV3Plan } from '../src/context/plan-revision.js'; +import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { REQUIREMENT_DIMENSIONS } from '../src/context/requirements-ledger.js'; +import { reviewLedgerDigest } from '../src/context/review-ledger.js'; +import { applyV3ReviewLedger } from '../src/context/review-remediation.js'; +import { V3ContextStore } from '../src/context/store.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { createSession } from '../src/runtime/session.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const plan = + '\n# Export\nAC-1, AC-2: callable export returns 2. AC-3: operator accepts behavior.\n\n\n\nNot started.\n\n'; +describe('opted-in man module delivery through the public workflow command', () => { + let root: string; + let sessionId: Ulid; + let taskRef: TaskRef; + let store: V3ContextStore; + const logs = () => vi.spyOn(console, 'log').mockImplementation(() => {}); + const snapshot = () => store.readTaskSnapshot(taskRef); + const git = (args: string[]) => execFile('git', args, { cwd: root }); + async function command(action: string, input?: unknown, acceptance?: string) { + if (input) + await writeFile( + path.join(root, '.mancode/local/drafts/input.json'), + JSON.stringify(input), + ); + const output = logs(); + try { + const code = await workflow( + root, + 'delivery', + [`${taskRef.namespace}:${taskRef.taskId}`, action], + { + json: true, + session: sessionId, + client: 'vitest', + expectedRevision: String((await snapshot()).metadata.revision), + ...(input ? { file: '.mancode/local/drafts/input.json' } : {}), + acceptance, + }, + ); + return { + code, + output: output.mock.calls.map((call) => String(call[0])).join('\n'), + }; + } finally { + output.mockRestore(); + } + } + const verify = (id = 'AC-1') => + command( + 'verify', + { + surface: 'component', + argv: [ + process.execPath, + '-e', + "require('node:assert').equal(require('./app.cjs').run(),2)", + ], + }, + id, + ); + async function review(extra: Record = {}) { + return command('review', { + subject: await captureManSubject(root, await snapshot()), + reviewer: 'self', + direction: + 'AC-1/2 reach app.cjs run; diff only implements the approved export.', + correctness: + 'The real entry returns 2; automated checks and operator observation cover the required boundary.', + proportionality: + 'No speculative guards or abstractions; existing output contract reused.', + nextAction: + 'Stop after this authorized module; no subsequent module authorized.', + coverage: ['AC-1', 'AC-2', 'AC-3'].map((acceptanceId) => ({ + acceptanceId, + status: 'met', + evidence: + 'app.cjs run plus captured check/explicit operator observation', + })), + findings: [], + resolved: [], + ...extra, + }); + } + async function finishEvidence() { + const first = await verify('AC-1,AC-2'); + expect(first.code, first.output).toBe(0); + expect( + ( + await command( + 'confirm', + { + confirmed: true, + surface: 'manual_observation', + summary: + 'Fixture operator explicitly confirms the required behavior in the local Node environment.', + }, + 'AC-3', + ) + ).code, + ).toBe(0); + } + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-module-')); + await git(['init', '-q']); + await git(['config', 'user.name', 'Fixture']); + await git(['config', 'user.email', 'fixture@example.test']); + await mkdir(path.join(root, 'docs')); + await writeFile(path.join(root, '.gitignore'), '.mancode/\n'); + await writeFile(path.join(root, 'app.cjs'), 'exports.run=()=>2;'); + await writeFile(path.join(root, 'docs/export.md'), plan); + await writeFile( + path.join(root, '项目进度.html'), + '

keep

keep
', + ); + await git(['add', '.']); + await git(['commit', '-qm', 'fixture baseline']); + await initializeV3Project({ projectRoot: root }); + const actorId = createUlid(); + await createLocalActor(root, { actorId, displayName: 'Fixture' }); + const session = await createSession(root, { + actorId, + client: 'vitest', + identitySource: 'explicit', + }); + sessionId = session.sessionId; + const created = await createV3Workflow({ + projectRoot: root, + task: 'Export module', + workflowMode: 'man', + delivery: true, + sessionId, + client: 'vitest', + }); + taskRef = created.taskRef; + store = new V3ContextStore(root); + const requirements = normalizeRequirementsInput( + { + version: 1, + goal: 'Export module', + confirmedScope: ['Callable export returns 2'], + excludedScope: ['UI'], + technicalDecisions: [], + defaults: [], + blockingUnknowns: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension) => ({ + dimension, + status: + dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: 'Bounded local fixture.', + })), + acceptanceCriteria: ['AC-1', 'AC-2', 'AC-3'].map((id) => ({ + id, + description: + id === 'AC-3' + ? 'Operator accepts result' + : 'Callable export returns 2', + required: true, + method: id === 'AC-3' ? 'manual' : 'automated', + verificationSurfaces: + id === 'AC-3' + ? { manual: 'manual_observation' } + : { automated: 'component' }, + })), + }, + taskRef, + ); + const ready = await finalizeV3Requirements({ + projectRoot: root, + taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + requirements, + }); + await reviseV3Plan({ + projectRoot: root, + taskRef, + sessionId, + expectedTaskRevision: ready.metadata.revision, + plan, + planSource: 'docs/export.md', + implementationScope: { + include: ['app.cjs', 'docs/export.md', '项目进度.html'], + exclude: [], + modules: [], + }, + planDecision: 'governed_execution', + }); + await mkdir(path.join(root, '.mancode/local/drafts'), { recursive: true }); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('requires a module review, accepts zero findings, preserves tests, commits then completes without an upstream', async () => { + const initial = await snapshot(); + expect(initial.metadata.governance.policyVersions.planning).toBe(3); + expect((await command('sync')).code).toBe(0); + expect(await readFile(path.join(root, '项目进度.html'), 'utf8')).toContain( + '进行中', + ); + const initialCheck = await command('check'); + expect(initialCheck.code).not.toBe(0); + expect(initialCheck.output).toContain('VERIFICATION_INCOMPLETE'); + await finishEvidence(); + const awaitingReview = await command('inspect'); + expect(JSON.parse(awaitingReview.output).finalization).toMatchObject({ + status: 'incomplete', + blockers: expect.arrayContaining([ + expect.objectContaining({ + code: 'review_incomplete', + status: 'stale', + }), + ]), + }); + expect((await command('check')).output).toContain('REVIEW_INCOMPLETE'); + expect(await readFile(path.join(root, '项目进度.html'), 'utf8')).toContain( + '待审核', + ); + expect(await readFile(path.join(root, 'docs/export.md'), 'utf8')).toContain( + 'surface=component', + ); + expect(await readFile(path.join(root, 'docs/export.md'), 'utf8')).toContain( + 'surface=manual_observation', + ); + const evidence = (await snapshot()).verification.checks; + expect((await review()).code).toBe(0); + const reviewed = await snapshot(); + const differentEnvironment = { + ...(await captureManSubject(root, reviewed)), + environment: 'another host environment', + }; + expect(renderManDeliveryRecord(reviewed, differentEnvironment)).toContain( + 'Review: stale', + ); + expect(renderManDeliveryRecord(reviewed, differentEnvironment)).toContain( + 'Verification: stale', + ); + expect(reviewed.verification.status).toBe('passed'); + expect(reviewed.verification.checks).toEqual(evidence); + expect(reviewed.metadata.governance.planVersion).toBe( + initial.metadata.governance.planVersion, + ); + expect(await readFile(path.join(root, '项目进度.html'), 'utf8')).toContain( + '已完成', + ); + const withoutSurface = { + ...reviewed, + verification: { + ...reviewed.verification, + checks: reviewed.verification.checks.map((check, index) => { + if (index !== 0 || !check.automated) return check; + const { surface: _surface, ...automated } = check.automated; + return { ...check, automated }; + }), + }, + }; + await syncManDeliveryRecord(root, withoutSurface); + expect( + await readFile(path.join(root, '项目进度.html'), 'utf8'), + ).not.toContain('已完成'); + expect(await readFile(path.join(root, 'docs/export.md'), 'utf8')).toContain( + 'Verification: surface_required', + ); + await syncManDeliveryRecord(root, reviewed); + const uncommitted = await command('inspect'); + expect(JSON.parse(uncommitted.output).finalization).toMatchObject({ + status: 'incomplete', + blockers: [expect.objectContaining({ code: 'uncommitted_changes' })], + }); + expect((await command('check')).output).toContain('UNCOMMITTED'); + await git(['add', 'docs/export.md', '项目进度.html']); + await git(['commit', '-qm', 'module delivery']); + expect((await command('check')).code).toBe(0); + const ready = JSON.parse((await command('inspect')).output); + expect(ready.publication).toBe('unpublished'); + expect(ready.finalization).toEqual({ status: 'ready', blockers: [] }); + const output = logs(); + expect( + await workflow( + root, + 'complete', + [`${taskRef.namespace}:${taskRef.taskId}`], + { + json: true, + session: sessionId, + client: 'vitest', + expectedRevision: String((await snapshot()).metadata.revision), + }, + ), + ).toBe(0); + output.mockRestore(); + expect((await snapshot()).metadata.status).toBe('completed'); + }); + + it('records command failures and rejects stale evidence after same-HEAD changes, then resets other stale slots', async () => { + await finishEvidence(); + expect((await review()).code).toBe(0); + await writeFile(path.join(root, 'app.cjs'), 'exports.run=()=>3;'); + expect((await command('check')).output).toContain( + 'VERIFICATION_INCOMPLETE', + ); + const failed = await verify(); + expect(failed.code).toBe(0); // The recording operation succeeded, the check did not. + expect(failed.output).toContain('exitCode'); + expect((await snapshot()).verification.status).toBe('failed'); + expect((await snapshot()).verification.checks[1]?.automated?.status).toBe( + 'pending', + ); + await writeFile( + path.join(root, 'app.cjs'), + 'exports.run=()=>2; // repaired\n', + ); + await finishEvidence(); + expect((await review()).code).toBe(0); + expect((await snapshot()).verification.status).toBe('passed'); + }); + + it('does not accept missing goal coverage or unknown acceptance IDs and keeps repairs out of business blocked state', async () => { + await finishEvidence(); + expect( + ( + await review({ + coverage: [ + { + acceptanceId: 'AC-1', + status: 'missing', + evidence: + 'Required entry is absent; helper-only code is not delivery.', + }, + ], + }) + ).code, + ).toBe(0); + expect((await snapshot()).review.status).toBe('blocked'); + expect((await snapshot()).metadata.status).not.toBe('blocked'); + expect(await readFile(path.join(root, '项目进度.html'), 'utf8')).toContain( + '进行中', + ); + expect((await command('check')).code).not.toBe(0); + expect( + ( + await review({ + coverage: [ + { + acceptanceId: 'AC-999', + status: 'missing', + evidence: 'Unknown target', + }, + ], + }) + ).output, + ).toContain('UNKNOWN_ACCEPTANCE'); + expect((await review()).code).toBe(0); + }); + + it('records required repairs and blocks both uncommitted and committed scope expansion', async () => { + await finishEvidence(); + expect( + ( + await review({ + findings: [ + { + id: 'R-1', + domain: 'quality', + severity: 'p1', + summary: 'Required entry needs the documented result.', + }, + ], + }) + ).code, + ).toBe(0); + const blocked = await snapshot(); + const erased = { ...blocked.review, blockers: [] }; + await expect( + applyV3ReviewLedger({ + projectRoot: root, + taskRef, + sessionId, + expectedTaskRevision: blocked.metadata.revision, + review: { ...erased, contentDigest: reviewLedgerDigest(erased) }, + }), + ).rejects.toThrow('FINDING_DROPPED'); + await writeFile( + path.join(root, 'app.cjs'), + 'exports.run=()=>2; // verified repair\n', + ); + await finishEvidence(); + expect((await review({ resolved: ['R-1'] })).code).toBe(0); + expect((await snapshot()).verification.status).toBe('passed'); + await writeFile( + path.join(root, 'unrelated.cjs'), + 'exports.unrelated=true;', + ); + // Unrelated code added before a new check is not silently attributed to the module. + await finishEvidence(); + expect((await review()).code).toBe(0); + expect((await command('check')).output).toContain( + 'UNCOMMITTED_OUTSIDE_SCOPE', + ); + await git(['add', '.']); + await git(['commit', '-qm', 'mixed scope']); + expect((await command('check')).output).toContain('OUTSIDE_SCOPE'); + }); + + it('never records successful evidence when the command changes the tested code', async () => { + const before = (await snapshot()).metadata.revision; + const result = await command( + 'verify', + { + surface: 'component', + argv: [ + process.execPath, + '-e', + "require('node:fs').writeFileSync('app.cjs','exports.run=()=>3;')", + ], + }, + 'AC-1', + ); + expect(result.output).toContain('CHANGED_DURING_RUN'); + expect((await snapshot()).metadata.revision).toBe(before); + }); + + it('runs one relevant command once for multiple explicitly covered acceptance criteria', async () => { + const result = await command( + 'verify', + { + surface: 'component', + argv: [ + process.execPath, + '-e', + "require('node:assert').equal(require('./app.cjs').run(),2);require('node:fs').appendFileSync('.mancode/local/drafts/runs','run\\n')", + ], + }, + 'AC-1,AC-2', + ); + expect(result.code, result.output).toBe(0); + expect( + await readFile(path.join(root, '.mancode/local/drafts/runs'), 'utf8'), + ).toBe('run\n'); + const task = await snapshot(); + expect( + task.verification.checks + .slice(0, 2) + .map((check) => check.automated?.status), + ).toEqual(['passed', 'passed']); + expect(task.verification.checks[2]?.manual?.status).toBe('pending'); + }); + + it('requires an explicit verification surface and returns the next finalization state with the mutation receipt', async () => { + const before = (await snapshot()).metadata.revision; + const missing = await command( + 'verify', + { + argv: [process.execPath, '-e', 'process.exit(0)'], + }, + 'AC-1', + ); + expect(missing.output).toContain('VERIFICATION_SURFACE_INVALID'); + expect((await snapshot()).metadata.revision).toBe(before); + + const verified = await verify(); + expect(verified.code, verified.output).toBe(0); + expect(JSON.parse(verified.output)).toMatchObject({ + revision: before + 1, + finalization: { + status: 'incomplete', + blockers: expect.arrayContaining([ + expect.objectContaining({ code: 'verification_incomplete' }), + expect.objectContaining({ code: 'review_incomplete' }), + ]), + }, + }); + expect((await snapshot()).verification.checks[0]?.automated?.surface).toBe( + 'component', + ); + }); + + it('keeps evidence below the required observation surface incomplete', async () => { + expect( + ( + await command( + 'verify', + { + surface: 'unit', + argv: [ + process.execPath, + '-e', + "require('node:assert').equal(require('./app.cjs').run(),2)", + ], + }, + 'AC-1,AC-2', + ) + ).code, + ).toBe(0); + expect( + ( + await command( + 'confirm', + { + confirmed: true, + surface: 'manual_observation', + summary: 'Fixture operator confirms the observed result.', + }, + 'AC-3', + ) + ).code, + ).toBe(0); + expect((await snapshot()).verification.status).toBe('passed'); + expect((await review()).code).toBe(0); + + const inspection = JSON.parse((await command('inspect')).output); + expect(inspection.finalization).toMatchObject({ + status: 'incomplete', + blockers: expect.arrayContaining([ + expect.objectContaining({ + code: 'verification_incomplete', + status: 'surface_mismatch', + }), + ]), + }); + expect( + await readFile(path.join(root, '项目进度.html'), 'utf8'), + ).not.toContain('已完成'); + expect(await readFile(path.join(root, 'docs/export.md'), 'utf8')).toContain( + 'Verification: surface_mismatch', + ); + }); + + it('matches concrete task files with standard globs and sibling excludes', async () => { + const current = await snapshot(); + const metadata = { + ...current.metadata, + implementationScope: { + ...current.metadata.implementationScope, + include: ['docs/*.md', 'src/**'], + exclude: ['src/generated/**'], + }, + }; + expect(manScopeContains(metadata, 'docs/export.md')).toBe(true); + expect(manScopeContains(metadata, 'src/index.ts')).toBe(true); + expect(manScopeContains(metadata, 'src/generated/client.ts')).toBe(false); + + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/index.ts'), 'export const value=1;'); + expect( + (await inspectManDelivery(root, { ...current, metadata })).pendingCommit, + ).toContain('src/index.ts'); + }); + + it('accepts an extension glob that covers the bound plan path', async () => { + const current = await snapshot(); + await expect( + syncManDeliveryRecord(root, { + ...current, + metadata: { + ...current.metadata, + implementationScope: { + ...current.metadata.implementationScope, + include: ['app.cjs', 'docs/*.md'], + }, + }, + }), + ).resolves.toBeDefined(); + }); + + it('reports the exact plan path needed by implementation scope', async () => { + const current = await snapshot(); + await expect( + syncManDeliveryRecord(root, { + ...current, + metadata: { + ...current.metadata, + implementationScope: { + ...current.metadata.implementationScope, + include: ['app.cjs'], + }, + }, + }), + ).rejects.toThrow( + 'MANCODE_MAN_PLAN_OUTSIDE_SCOPE: docs/export.md is not covered by implementationScope.include; add that exact repo-relative path or a covering glob', + ); + }); + + it('rejects the delivery opt-in for other workflow modes before any mutation', async () => { + const before = (await snapshot()).metadata.revision; + for (const workflowMode of ['manba', 'manteam'] as const) { + await expect( + createV3Workflow({ + projectRoot: root, + task: 'Not a man delivery', + workflowMode, + delivery: true, + sessionId, + client: 'vitest', + }), + ).rejects.toThrow('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + } + expect((await snapshot()).metadata.revision).toBe(before); + }); +}); diff --git a/tests/man-progress.test.ts b/tests/man-progress.test.ts new file mode 100644 index 0000000..73bb40a --- /dev/null +++ b/tests/man-progress.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { manProgressTaskId } from '../src/context/man-delivery-plan.js'; +import { + syncManProgressPage, + updateManProgressHtml, +} from '../src/context/man-progress.js'; + +const page = + '

Keep me

\n\n'; +describe('optional man progress view', () => { + it('ignores example bindings inside Markdown fences', () => { + expect( + manProgressTaskId( + '```html\n\n```\n', + ), + ).toBe('actual'); + expect(() => + manProgressTaskId( + '\n', + ), + ).toThrow('ambiguous'); + }); + let root: string; + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-progress-')); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + it('updates only the mapped data, escapes script terminators and preserves other tasks and HTML', () => { + const next = updateManProgressHtml( + page, + 'export', + '阻塞', + 'Need user decision: ', + ); + expect(next.startsWith('

Keep me

\n')).toBe(true); + expect(next.endsWith('\n')).toBe(true); + expect(next).toContain('\\u003c/script>'); + expect(next).toContain('"taskId":"later","status":"未完成","reason":null'); + expect(next).toContain('"label":"Export"'); + expect( + updateManProgressHtml( + next, + 'export', + '阻塞', + 'Need user decision: ', + ), + ).toBe(next); + }); + it('never guesses a missing, duplicate, malformed or unmapped contract', () => { + for (const html of [ + '

unstructured

', + page + page, + page.replace('"schemaVersion":1', '"schemaVersion":2'), + page.replace('"tasks":', '"tasks":broken'), + page.replace('"taskId":"later"', '"taskId":"export"'), + ]) { + expect(() => + updateManProgressHtml(html, 'export', '进行中', null), + ).toThrow(); + } + expect(() => + updateManProgressHtml(page, 'unknown', '进行中', null), + ).toThrow(); + expect(() => + updateManProgressHtml(``, 'export', '进行中', null), + ).toThrow(); + expect(() => + updateManProgressHtml( + ``, + 'export', + '进行中', + null, + ), + ).toThrow(); + expect(() => + updateManProgressHtml( + `${page}
`, + 'export', + '进行中', + null, + ), + ).toThrow(); + }); + it('degrades absent, invalid or out-of-scope pages without creating or changing them', async () => { + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toEqual({ status: 'absent' }); + const file = path.join(root, '项目进度.html'); + await writeFile(file, page); + expect( + await syncManProgressPage(root, 'export', '进行中', null, false), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(file, 'utf8')).toBe(page); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toEqual({ status: 'synced' }); + await writeFile(file, '

unstructured

'); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(file, 'utf8')).toBe('

unstructured

'); + }); + it('does not follow a symlink to any other file', async () => { + await writeFile(path.join(root, 'other.html'), page); + await symlink( + path.join(root, 'other.html'), + path.join(root, '项目进度.html'), + ); + expect( + await syncManProgressPage(root, 'export', '进行中', null, true), + ).toMatchObject({ status: 'manual_sync' }); + expect(await readFile(path.join(root, 'other.html'), 'utf8')).toBe(page); + }); +}); diff --git a/tests/operation-crash-matrix-contracts.test.ts b/tests/operation-crash-matrix-contracts.test.ts index b6906df..6ad5c87 100644 --- a/tests/operation-crash-matrix-contracts.test.ts +++ b/tests/operation-crash-matrix-contracts.test.ts @@ -98,6 +98,10 @@ describe('operation crash recovery matrix', () => { it('executes safe abort or forward repair at every declared crash point', async () => { let exercised = 0; for (const definition of Object.values(OPERATION_DEFINITIONS)) { + // Reframe repair validates its semantic archive, plan, checkpoint, and + // aggregate bundle. Its real payload matrix is covered separately by + // v3-reframe-recovery-contracts rather than this synthetic plan writer. + if (definition.type === 'reframe') continue; for (const fixture of OPERATION_CRASH_FIXTURES[definition.type]) { exercised += 1; const operationId = nextId(); diff --git a/tests/operation-journal-contracts.test.ts b/tests/operation-journal-contracts.test.ts index d17f035..a6fc791 100644 --- a/tests/operation-journal-contracts.test.ts +++ b/tests/operation-journal-contracts.test.ts @@ -6,7 +6,10 @@ import { } from '../src/runtime/operation-journal.js'; const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const CHECKPOINT_A = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECKPOINT_B = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; const DIGEST = `sha256:${'a'.repeat(64)}`; +const NEXT_DIGEST = `sha256:${'b'.repeat(64)}`; describe('operation journal contract', () => { it('rejects malformed entity locks, reservations, and steps before they can become durable', () => { @@ -132,6 +135,81 @@ describe('operation journal contract', () => { }), ).toThrow(/cannot abort/); }); + + it('allows only the exact checkpoint lock and payload rebind for a repair-required reframe', () => { + const previous = parseOperationJournal({ + ...journal(), + type: 'reframe', + state: 'repair_required', + recoveryPayloadDigest: DIGEST, + entityLocks: ['task:local:01JZ', `checkpoint:${CHECKPOINT_A}`], + expectedRevisions: { + 'task:local:01JZ': 7, + [`checkpoint:${CHECKPOINT_A}`]: 0, + }, + steps: [ + { id: 'validate', state: 'completed' }, + { id: 'write', state: 'pending' }, + ], + }); + const next = parseOperationJournal({ + ...previous, + recoveryPayloadDigest: NEXT_DIGEST, + entityLocks: ['task:local:01JZ', `checkpoint:${CHECKPOINT_B}`], + expectedRevisions: { + 'task:local:01JZ': 7, + [`checkpoint:${CHECKPOINT_B}`]: 0, + }, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + const replacement = { + canAbort: false, + reframeCheckpointReplacement: { + fromCheckpointId: CHECKPOINT_A, + toCheckpointId: CHECKPOINT_B, + }, + } as const; + + expect(() => + assertOperationJournalTransition(previous, next, replacement), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(previous, next, { canAbort: false }), + ).toThrow(/identity fields are immutable/); + expect(() => + assertOperationJournalTransition( + previous, + parseOperationJournal({ + ...next, + steps: next.steps.map((step) => ({ + ...step, + state: 'completed' as const, + })), + }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + expect(() => + assertOperationJournalTransition( + previous, + parseOperationJournal({ + ...next, + expectedRevisions: { + ...next.expectedRevisions, + 'task:local:01JZ': 8, + }, + }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + expect(() => + assertOperationJournalTransition( + parseOperationJournal({ ...previous, type: 'handoff_accept' }), + parseOperationJournal({ ...next, type: 'handoff_accept' }), + replacement, + ), + ).toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_INVALID'); + }); }); function journal(): OperationJournalV1 { diff --git a/tests/operation-recovery-executor-contracts.test.ts b/tests/operation-recovery-executor-contracts.test.ts index 3fce163..5a42242 100644 --- a/tests/operation-recovery-executor-contracts.test.ts +++ b/tests/operation-recovery-executor-contracts.test.ts @@ -155,6 +155,21 @@ describe('operation recovery executor', () => { }); }); + it('rejects replacement checkpoint recovery for non-reframe operations', async () => { + const operationId = id(12); + await prepareInterruptedPlan(operationId, '# Interrupted plan\n', true); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + replacementCheckpointId: id(13), + }), + ).rejects.toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED'); + }); + it('removes only the abandoned private workflow staging directory before a safe abort', async () => { const operationId = id(10); const recoveryTaskId = id(11); diff --git a/tests/requirements-ledger-v3-contracts.test.ts b/tests/requirements-ledger-v3-contracts.test.ts index a7dc601..68e6eb7 100644 --- a/tests/requirements-ledger-v3-contracts.test.ts +++ b/tests/requirements-ledger-v3-contracts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { type RequirementsLedgerV1, + assertManDeliveryVerificationSurfaces, parseRequirementsLedger, requirementsAreReady, requirementsLedgerDigest, @@ -90,6 +91,59 @@ describe('requirements ledger V3 contract', () => { outOfScope: ['Limit repeated login attempts.'], }); }); + + it('keeps historical criteria readable but requires explicit slot surfaces for new man delivery', () => { + const current = ledger(); + const [criterion] = current.acceptanceCriteria; + if (!criterion) throw new Error('missing fixture criterion'); + const { verificationSurfaces: _surfaces, ...historicalCriterion } = + criterion; + const historical = withDigest({ + ...current, + acceptanceCriteria: [historicalCriterion], + }); + + expect( + parseRequirementsLedger(historical).acceptanceCriteria[0] + ?.verificationSurfaces, + ).toBeUndefined(); + expect(() => + assertManDeliveryVerificationSurfaces( + parseRequirementsLedger(historical), + ), + ).toThrow('MANCODE_MAN_ACCEPTANCE_SURFACE_REQUIRED: AC-1'); + expect(() => + assertManDeliveryVerificationSurfaces(parseRequirementsLedger(current)), + ).not.toThrow(); + }); + + it('rejects invalid or verification-requirement-incompatible surfaces', () => { + const current = ledger(); + const incompatible = { + ...current, + acceptanceCriteria: current.acceptanceCriteria.map((criterion) => ({ + ...criterion, + verificationSurfaces: { automated: 'real_http' }, + })), + } as RequirementsLedgerV1; + const invalid = { + ...current, + acceptanceCriteria: current.acceptanceCriteria.map((criterion) => ({ + ...criterion, + verificationSurfaces: { + automated: 'mock_http', + manual: 'manual_observation', + }, + })), + } as unknown as RequirementsLedgerV1; + + expect(() => parseRequirementsLedger(withDigest(incompatible))).toThrow( + /verificationSurfaces must match verificationRequirement slots/, + ); + expect(() => parseRequirementsLedger(withDigest(invalid))).toThrow( + 'MANCODE_MAN_VERIFICATION_SURFACE_INVALID', + ); + }); }); function ledger(): RequirementsLedgerV1 { @@ -150,6 +204,10 @@ function ledger(): RequirementsLedgerV1 { statement: 'A repeated failed login receives a rate-limit response.', required: true, verificationRequirement: 'hybrid', + verificationSurfaces: { + automated: 'real_http', + manual: 'manual_observation', + }, }, ], blockingUnknowns: [], diff --git a/tests/requirements-ledger.test.ts b/tests/requirements-ledger.test.ts index 9e385c8..8160091 100644 --- a/tests/requirements-ledger.test.ts +++ b/tests/requirements-ledger.test.ts @@ -24,6 +24,7 @@ describe('requirements ledger', () => { description: 'Pointer lock movement works', required: true, method: 'manual', + verificationSurfaces: { manual: 'browser' }, }, ], }), @@ -32,6 +33,10 @@ describe('requirements ledger', () => { expect(requirementsAreReady(ledger)).toBe(true); expect(renderRequirementsMarkdown(ledger)).toContain('READY'); expect(renderRequirementsMarkdown(ledger)).toContain('AC-1'); + expect(renderRequirementsMarkdown(ledger)).toContain('manual=browser'); + expect(ledger.acceptanceCriteria[0]?.verificationSurfaces).toEqual({ + manual: 'browser', + }); }); it('rejects duplicate ids and manifests with no required acceptance', () => { @@ -108,6 +113,41 @@ describe('requirements ledger', () => { ).toThrow(/coverage is missing/); }); + it('rejects invalid or method-incompatible verification surfaces', () => { + const base = { + version: 1, + goal: 'Build it', + confirmedScope: ['Confirmed first release'], + excludedScope: [], + technicalDecisions: ['Use the existing stack'], + defaults: [], + blockingUnknowns: [], + coverage: completeCoverage(), + }; + const parseWithSurfaces = (verificationSurfaces: unknown) => + parseRequirementsLedger( + JSON.stringify({ + ...base, + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'The confirmed behavior works', + required: true, + method: 'manual', + verificationSurfaces, + }, + ], + }), + ); + + expect(() => parseWithSurfaces({ automated: 'component' })).toThrow( + /invalid acceptance verificationSurfaces/, + ); + expect(() => parseWithSurfaces({ manual: 'mock_http' })).toThrow( + /invalid acceptance verification surface/, + ); + }); + it('reads old contradictory scope but rejects it as a new confirmation', () => { const ledger = parseRequirementsLedger( JSON.stringify({ diff --git a/tests/retention-contracts.test.ts b/tests/retention-contracts.test.ts index df14346..dc0731b 100644 --- a/tests/retention-contracts.test.ts +++ b/tests/retention-contracts.test.ts @@ -14,6 +14,10 @@ import { operationDirectory, resolveLocalEntityHomeStore, } from '../src/runtime/entity-home-store.js'; +import { + operationRecoveryPayloadPath, + operationRecoveryPayloadVersionPath, +} from '../src/runtime/operation-recovery-store.js'; import { readOperationJournal } from '../src/runtime/operation-store.js'; import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; import { @@ -192,6 +196,123 @@ describe('V3 retention and compaction', () => { }); }); + it('deletes every recovery payload version with an aged terminal operation', async () => { + const runtime = await readProjectRuntimeContext(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const operationId = id(7); + const journal = await readOperationJournal(localStore, operationId); + if (journal?.recoveryPayloadDigest === undefined) { + throw new Error('missing terminal operation recovery payload'); + } + const journalTarget = path.join( + operationDirectory(localStore), + `${operationId}.json`, + ); + await writeFile( + journalTarget, + `${JSON.stringify( + { + ...journal, + startedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T01:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + const canonicalPayload = operationRecoveryPayloadPath( + localStore, + operationId, + ); + const boundVersion = operationRecoveryPayloadVersionPath( + localStore, + operationId, + journal.recoveryPayloadDigest, + ); + const orphanVersion = operationRecoveryPayloadVersionPath( + localStore, + operationId, + `sha256:${'f'.repeat(64)}`, + ); + await Promise.all([ + writeFile(boundVersion, '{}\n'), + writeFile(orphanVersion, '{}\n'), + ]); + + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + const candidate = plan.candidates.find( + (entry) => entry.target === journalTarget, + ); + expect(candidate?.relatedTargets).toEqual( + expect.arrayContaining([canonicalPayload, boundVersion, orphanVersion]), + ); + + const applied = await applyContextCompaction(plan); + expect(applied.deleted).toEqual( + expect.arrayContaining([ + journalTarget, + canonicalPayload, + boundVersion, + orphanVersion, + ]), + ); + await Promise.all( + [journalTarget, canonicalPayload, boundVersion, orphanVersion].map( + (target) => expect(readFile(target, 'utf8')).rejects.toThrow(), + ), + ); + }); + + it('keeps the operation journal when recovery payload deletion is interrupted', async () => { + const runtime = await readProjectRuntimeContext(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const operationId = id(7); + const journal = await readOperationJournal(localStore, operationId); + if (journal?.recoveryPayloadDigest === undefined) { + throw new Error('missing terminal operation recovery payload'); + } + const journalTarget = path.join( + operationDirectory(localStore), + `${operationId}.json`, + ); + await writeFile( + journalTarget, + `${JSON.stringify( + { + ...journal, + startedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T01:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + const candidate = plan.candidates.find( + (entry) => entry.target === journalTarget, + ); + const failingTarget = candidate?.relatedTargets[0]; + if (candidate === undefined || failingTarget === undefined) { + throw new Error('missing operation recovery retention target'); + } + await rm(failingTarget, { force: true }); + await mkdir(failingTarget); + + await expect( + applyContextCompaction({ + ...plan, + candidates: [candidate], + }), + ).rejects.toThrow('MANCODE_RETENTION_PATH_UNSAFE'); + await expect(readFile(journalTarget, 'utf8')).resolves.toContain( + operationId, + ); + }); + it('keeps a repair-required journal and the session and task artifacts it protects', async () => { const taskRef = { namespace: 'local' as const, taskId }; const checkpointPaths = await completeTaskWithDiagnosticCheckpoints( diff --git a/tests/skills.test.ts b/tests/skills.test.ts index 8706ac2..01e0b7c 100644 --- a/tests/skills.test.ts +++ b/tests/skills.test.ts @@ -89,6 +89,13 @@ describe('mvp-2 skills', () => { expect(MAN_SKILL.body).toMatch(/明确排除.*excludedScope/); expect(MAN_SKILL.body).toMatch(/未接受.*自动塞入.*excludedScope/); expect(MAN_SKILL.body).toMatch(/implementationScope/); + expect(MAN_SKILL.body).toContain('document-bound delivery'); + expect(MAN_SKILL.body).toContain('review_incomplete'); + expect(MAN_SKILL.body).toContain('observation surface'); + expect(MAN_SKILL.body).toContain('verificationSurfaces'); + expect(MAN_SKILL.body).toContain('自述'); + expect(MAN_SKILL.body).toContain('finalization blockers'); + expect(MAN_SKILL.body).toContain('repo-relative path 或 glob'); }); it('keeps solo review bounded and lightweight', () => { diff --git a/tests/v3-adapter-contracts.test.ts b/tests/v3-adapter-contracts.test.ts index d82d30c..6bea061 100644 --- a/tests/v3-adapter-contracts.test.ts +++ b/tests/v3-adapter-contracts.test.ts @@ -208,6 +208,22 @@ describe('V3 adapter bootstrap integration', () => { expect(bootstrap).toContain( 'hard-risk change involving authentication, payment, sensitive data, deletion, migration, public APIs, untrusted input, concurrency, infrastructure', ); + if (['AGENTS.md', 'CLAUDE.md'].includes(path.basename(target))) { + expect(bootstrap).toContain( + '仅用于显式启用模块交付策略的新 `/man` 任务', + ); + expect(bootstrap).toContain('项目指定的计划基线目录,默认 `doc/`'); + expect(bootstrap).toContain('已有 `docs/` 等明确约定时沿用它'); + expect(bootstrap).toContain('mancode-progress-data'); + expect(bootstrap).toContain('被忽略不代表本地不可读'); + expect(bootstrap).toContain('交付未发布'); + expect(bootstrap).toContain('优先定位并修复根因,避免治标不治本'); + expect(bootstrap).toContain( + '完整性校验、缓存键、证据适用性和发布溯源仍可使用哈希', + ); + expect(bootstrap).not.toContain('扩大到十几行'); + } else + expect(bootstrap).not.toContain('project:documentation-handoff-policy'); expect(bootstrap).toContain( 'explicitly asking for research, a plan, architecture, migration design, or formal acceptance authorizes the `man` planning path', ); @@ -255,6 +271,16 @@ describe('V3 adapter bootstrap integration', () => { if (platform === 'claude-code') { expect(installed.target).toBe('CLAUDE.md'); expect(bootstrap).toContain('mancode:continuity:claude:start'); + expect(bootstrap).toContain( + '', + ); + } + if (platform === 'codex') { + expect(installed.target).toBe('AGENTS.md'); + expect(bootstrap).toContain('mancode:continuity:codex:start'); + expect(bootstrap).toContain( + '', + ); } if (platform === 'dsh') { expect(installed.target).toBe('AGENTS.md'); @@ -329,6 +355,7 @@ describe('V3 adapter bootstrap integration', () => { expect(entry).toContain('"rationale": "..."'); expect(entry).toContain('acceptanceCriteria'); expect(entry).toContain('"method": "automated"'); + expect(entry).toContain('"verificationSurfaces"'); expect(entry).toContain( "clears this session's active workflow pointer", ); @@ -427,6 +454,12 @@ describe('V3 adapter bootstrap integration', () => { 'upgraded, already-running local `man` task has no executable implementation scope', ); expect(entry).toContain('exact unchanged current plan'); + expect(entry).toContain('repo-relative path or glob'); + expect(entry).toContain('"surface": "real_http"'); + expect(entry).toContain('self-declared'); + expect(entry).toContain('uncommitted outside-scope'); + expect(entry).toContain('exit code 0'); + expect(entry).toContain('review_incomplete'); } if (mode === 'manteam') { expect(entry).toContain( @@ -592,7 +625,7 @@ describe('V3 adapter bootstrap integration', () => { const man = await readFile(v3ModeEntryPath(root, 'codex', 'man'), 'utf8'); expect(man).toContain( - 'revise --expected-revision --file --scope-file --session --client ', + 'revise --expected-revision --file --scope-file --session --client ', ); expect(man).toContain( 'confirm --expected-revision --plan-decision --session --client ', @@ -610,6 +643,22 @@ describe('V3 adapter bootstrap integration', () => { expect(man).toContain( 'workflow checkpoint show --json', ); + expect(man).toContain( + 'operation repair --replacement-checkpoint-id --session --client ', + ); + expect(man).toContain('limited to that proven reframe checkpoint conflict'); + expect(man).toContain('--delivery --session --client '); + expect(man).toContain('one total review (not one per snippet'); + expect(man).toContain('goal → implementation'); + expect(man).toContain('diff → goal'); + for (const mode of V3_MODE_NAMES.filter((mode) => mode !== 'man')) { + const other = await readFile( + v3ModeEntryPath(root, 'codex', mode), + 'utf8', + ); + expect(other).not.toContain('--delivery'); + expect(other).not.toContain('mancode:plan-baseline'); + } }); it('refuses to overwrite a user-authored original mode entry', async () => { diff --git a/tests/v3-command-contracts.test.ts b/tests/v3-command-contracts.test.ts index 175e5dd..27b26b2 100644 --- a/tests/v3-command-contracts.test.ts +++ b/tests/v3-command-contracts.test.ts @@ -500,6 +500,30 @@ describe('V3 CLI command contracts', () => { expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ state: 'already_terminal', }); + expect( + await operationRepair(root, operationId, { + session: sessionId, + client: 'fixture', + replacementCheckpointId: id(620), + json: true, + }), + ).toBe(3); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { + code: 'MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_UNSUPPORTED', + }, + }); + expect( + await operationRepair(root, operationId, { + session: sessionId, + client: 'fixture', + replacementCheckpointId: 'not-a-ulid', + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_REPLACEMENT_CHECKPOINT_ID_INVALID' }, + }); expect(await contextDoctor(root, { json: true })).toBe(0); expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ operations: [], diff --git a/tests/v3-reframe-contracts.test.ts b/tests/v3-reframe-contracts.test.ts index f36eb2a..e18f3d7 100644 --- a/tests/v3-reframe-contracts.test.ts +++ b/tests/v3-reframe-contracts.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { initializeV3Project } from '../src/commands/v3-init.js'; import { digestCanonicalJson } from '../src/context/canonical.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; import { type Ulid, createUlid } from '../src/context/ids.js'; import { reviseV3Plan } from '../src/context/plan-revision.js'; import { reframeV3Workflow } from '../src/context/reframe.js'; @@ -248,6 +249,51 @@ describe('V3 local workflow reframe', () => { }); }); + it('rejects a reused checkpoint ID before creating a journal or changing authority', async () => { + const actors = await bootstrap(root, { git: true, joined: true }); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Reject a checkpoint ID already owned by another operation.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['governance'] }, + taskId: id(500), + operationId: id(501), + now: NOW, + }); + const confirmed = await confirmManteamPlan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const checkpointId = id(502); + const existing = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: confirmed.taskRevision, + kind: 'diagnostic_started', + summary: 'This checkpoint ID already belongs to another operation.', + checkpointId, + operationId: id(503), + now: NOW, + }); + + await expectRejectedWithoutAuthorityChange({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: existing.metadata.revision, + checkpointId, + operationId: id(504), + error: 'MANCODE_CHECKPOINT_ID_CONFLICT', + }); + }); + it('rejects active child, open handoff, and active solo before writing authority', async () => { const childRoot = await caseRoot(root, 'active-child'); const childActors = await bootstrap(childRoot); diff --git a/tests/v3-reframe-recovery-contracts.test.ts b/tests/v3-reframe-recovery-contracts.test.ts index 1df9d6f..493283f 100644 --- a/tests/v3-reframe-recovery-contracts.test.ts +++ b/tests/v3-reframe-recovery-contracts.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; import { type Ulid, createUlid } from '../src/context/ids.js'; import { reframeV3Workflow } from '../src/context/reframe.js'; import { V3ContextStore } from '../src/context/store.js'; @@ -12,7 +13,10 @@ import { taskRootPath } from '../src/context/task-locator.js'; import type { TaskRef } from '../src/context/task-ref.js'; import { createV3Workflow } from '../src/context/workflow-create.js'; import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; -import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { + createOperationLockPauseForTesting, + withOperationCrashInjectionForTesting, +} from '../src/runtime/operation-crash-injection.js'; import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; import { @@ -21,16 +25,24 @@ import { type TaskAuthorityFileName, taskArchiveManifest, } from '../src/runtime/operation-recovery-payload.js'; -import { readOperationRecoveryPayload } from '../src/runtime/operation-recovery-store.js'; +import { + readOperationRecoveryPayload, + readOperationRecoveryPayloadForDigest, +} from '../src/runtime/operation-recovery-store.js'; import { readOperationJournal } from '../src/runtime/operation-store.js'; import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; import { createSession } from '../src/runtime/session.js'; +import { + readTaskCheckpointAtRoot, + writeTaskCheckpointAtRoot, +} from '../src/runtime/task-operation.js'; import { createLocalActor, createSharedActorProfile, publishSharedActorProfile, readLocalActor, } from '../src/team/actor.js'; +import { parseCheckpoint } from '../src/team/checkpoints.js'; import { acquireV3Claim } from '../src/team/claim-acquisition.js'; import { confirmManteamPlan } from './helpers/manteam-plan.js'; @@ -186,6 +198,478 @@ describe('V3 reframe crash recovery', () => { }); } }, 120_000); + + it('rebinds a foreign checkpoint conflict to a fresh ID and completes the original reframe', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Recover a reframe whose checkpoint ID belongs to another operation.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/context/**'], + modules: ['governance'], + }, + taskId: id(500), + operationId: id(501), + now: NOW, + }); + const confirmed = await confirmManteamPlan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const existing = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: confirmed.taskRevision, + kind: 'diagnostic_started', + summary: 'Existing checkpoint from another operation.', + checkpointId: id(502), + operationId: id(503), + now: NOW, + }); + const beforeReframe = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + const operationId = id(504); + const conflictedCheckpointId = id(505); + const taskRoot = taskRootPath(root, created.taskRef); + const foreignCheckpoint = parseCheckpoint({ + ...existing.checkpoint, + checkpointId: conflictedCheckpointId, + summary: 'Foreign checkpoint occupying the interrupted reframe ID.', + }); + await expect( + withOperationCrashInjectionForTesting( + { + operationType: 'reframe', + crashAfter: 'mark-review-verification-stale', + }, + () => + reframeV3Workflow({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: beforeReframe.metadata.revision, + checkpointId: conflictedCheckpointId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect( + readOperationJournal(home, operationId), + ).resolves.toMatchObject({ + state: 'repair_required', + steps: expect.arrayContaining([ + { id: 'write-reframe-checkpoint', state: 'pending' }, + ]), + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId: conflictedCheckpointId, + now: NOW, + }), + ).rejects.toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + + await writeTaskCheckpointAtRoot(taskRoot, foreignCheckpoint); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + }), + ).resolves.toMatchObject({ + state: 'repair_required', + reason: 'MANCODE_OPERATION_RECOVERY_CONFLICT', + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + mode: 'abort', + }), + ).rejects.toThrow('MANCODE_OPERATION_ABORT_UNSAFE'); + + const journalBeforeReplacement = await readOperationJournal( + home, + operationId, + ); + if (journalBeforeReplacement === null) { + throw new Error('missing conflicted reframe journal'); + } + const planPath = path.join(taskRoot, 'plan.md'); + const planContent = await readFile(planPath, 'utf8'); + await writeFile(planPath, `${planContent}\nExternal plan drift.\n`); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId: id(506), + now: NOW, + }), + ).rejects.toThrow('MANCODE_OPERATION_RECOVERY_CONFLICT'); + await writeFile(planPath, planContent); + await expect(readOperationJournal(home, operationId)).resolves.toEqual( + journalBeforeReplacement, + ); + + const occupiedReplacementId = id(506); + await writeTaskCheckpointAtRoot( + taskRoot, + parseCheckpoint({ + ...existing.checkpoint, + checkpointId: occupiedReplacementId, + summary: 'Foreign checkpoint occupying the proposed replacement ID.', + }), + ); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId: occupiedReplacementId, + now: NOW, + }), + ).rejects.toThrow('MANCODE_REPLACEMENT_CHECKPOINT_ID_CONFLICT'); + await expect(readOperationJournal(home, operationId)).resolves.toEqual( + journalBeforeReplacement, + ); + + const replacementCheckpointId = id(507); + await expect( + withOperationCrashInjectionForTesting( + { + operationType: 'reframe', + crashAfter: 'rebind-reframe-checkpoint', + }, + () => + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const reboundJournal = await readOperationJournal(home, operationId); + expect(reboundJournal).toMatchObject({ + state: 'repair_required', + entityLocks: expect.arrayContaining([ + `checkpoint:${replacementCheckpointId}`, + ]), + }); + expect(reboundJournal?.recoveryPayloadDigest).not.toBe( + journalBeforeReplacement.recoveryPayloadDigest, + ); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + now: NOW, + }), + ).rejects.toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_REQUIRED'); + + await writeFile(planPath, `${planContent}\nDrift after payload rebind.\n`); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId, + now: NOW, + }), + ).resolves.toMatchObject({ + state: 'repair_required', + reason: 'MANCODE_OPERATION_RECOVERY_CONFLICT', + }); + await writeFile(planPath, planContent); + + const recovered = await executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId, + now: NOW, + }); + expect(recovered).toMatchObject({ + state: 'repaired', + reason: 'forward_repair', + checkpointReplacement: { + previousCheckpointId: conflictedCheckpointId, + replacementCheckpointId, + }, + journal: { + state: 'committed', + entityLocks: expect.arrayContaining([ + `checkpoint:${replacementCheckpointId}`, + ]), + expectedRevisions: { + [`checkpoint:${replacementCheckpointId}`]: 0, + }, + }, + }); + expect(recovered.journal.entityLocks).not.toContain( + `checkpoint:${conflictedCheckpointId}`, + ); + expect(recovered.journal.expectedRevisions).not.toHaveProperty( + `checkpoint:${conflictedCheckpointId}`, + ); + + const store = new V3ContextStore(root); + const task = await store.readTaskSnapshot(created.taskRef); + expect(task.metadata).toMatchObject({ + transitionState: 'stable', + lastOperationId: operationId, + latestCheckpointRef: { artifactId: replacementCheckpointId }, + }); + expect(task.requirements.status).toBe('draft'); + expect(task.review.status).toBe('stale'); + expect(task.verification.status).toBe('stale'); + expect(task.latestCheckpoint).toMatchObject({ + checkpointId: replacementCheckpointId, + operationId, + kind: 'requirements_reframed', + }); + expect(task.aggregateError).toBeNull(); + await expect( + readTaskCheckpointAtRoot(taskRoot, conflictedCheckpointId), + ).resolves.toEqual(foreignCheckpoint); + + if (recovered.journal.recoveryPayloadDigest === undefined) { + throw new Error('missing replacement recovery payload digest'); + } + const replacementPayload = await readOperationRecoveryPayloadForDigest( + home, + operationId, + recovered.journal.recoveryPayloadDigest, + ); + if (replacementPayload === null) { + throw new Error('missing replacement recovery payload'); + } + expect(singleAction(replacementPayload, 'checkpoint').checkpoint).toEqual( + task.latestCheckpoint, + ); + expect(authorityTarget(replacementPayload, 'metadata.json')).toEqual( + task.metadata, + ); + expect(singleAction(replacementPayload, 'task_head_fence').fence).toEqual( + (await store.readCoordinationSnapshot(created.taskRef, home)) + .taskHeadFence, + ); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + }), + ).resolves.toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId, + now: NOW, + }), + ).resolves.toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId: id(508), + now: NOW, + }), + ).rejects.toThrow('MANCODE_REFRAME_CHECKPOINT_REPLACEMENT_NOT_APPLICABLE'); + }); + + it('repairs the legacy conflict after the checkpoint step was completed', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Recover the legacy completed-step checkpoint conflict.', + workflowMode: 'man', + sessionId: actors.sessionId, + client: 'vitest', + implementationScope: { + include: ['src/context/**'], + modules: ['governance'], + }, + taskId: id(510), + operationId: id(511), + now: NOW, + }); + const confirmed = await confirmManteamPlan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + requirements: created.requirements, + now: NOW, + }); + const existing = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: confirmed.taskRevision, + kind: 'diagnostic_started', + summary: 'Source for a foreign legacy checkpoint.', + checkpointId: id(512), + operationId: id(513), + now: NOW, + }); + const beforeReframe = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + const operationId = id(514); + const conflictedCheckpointId = id(515); + const replacementCheckpointId = id(516); + const taskRoot = taskRootPath(root, created.taskRef); + const pause = createOperationLockPauseForTesting({ + operationId, + pauseAfter: 'entity_locks_held', + }); + const attempt = pause.run(() => + reframeV3Workflow({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: beforeReframe.metadata.revision, + checkpointId: conflictedCheckpointId, + operationId, + now: NOW, + }), + ); + await pause.reached; + await writeTaskCheckpointAtRoot( + taskRoot, + parseCheckpoint({ + ...existing.checkpoint, + checkpointId: conflictedCheckpointId, + summary: 'Foreign checkpoint occupying the legacy reframe ID.', + }), + ); + pause.release(); + await expect(attempt).rejects.toThrow('MANCODE_CHECKPOINT_ID_CONFLICT'); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect( + readOperationJournal(home, operationId), + ).resolves.toMatchObject({ + state: 'repair_required', + steps: expect.arrayContaining([ + { id: 'write-reframe-checkpoint', state: 'completed' }, + ]), + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + now: NOW, + }), + ).resolves.toMatchObject({ + state: 'repair_required', + reason: 'MANCODE_OPERATION_RECOVERY_CONFLICT', + }); + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + replacementCheckpointId, + now: NOW, + }), + ).resolves.toMatchObject({ + state: 'repaired', + checkpointReplacement: { + previousCheckpointId: conflictedCheckpointId, + replacementCheckpointId, + }, + }); + }); + + it('rejects checkpoint files whose path ID and body ID disagree', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Reject mismatched checkpoint path authority.', + workflowMode: 'man', + sessionId: actors.sessionId, + client: 'vitest', + taskId: id(520), + operationId: id(521), + now: NOW, + }); + const checkpoint = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedTaskRevision: created.metadata.revision, + kind: 'diagnostic_started', + summary: 'Canonical checkpoint body.', + checkpointId: id(522), + operationId: id(523), + now: NOW, + }); + const mismatchedPathId = id(524); + const taskRoot = taskRootPath(root, created.taskRef); + await writeFile( + path.join(taskRoot, 'checkpoints', `${mismatchedPathId}.json`), + `${JSON.stringify(checkpoint.checkpoint, null, 2)}\n`, + ); + + await expect( + readTaskCheckpointAtRoot(taskRoot, mismatchedPathId), + ).rejects.toThrow('MANCODE_CHECKPOINT_CORRUPT'); + }); }); async function expectAbortedAuthority(input: { diff --git a/tests/v3-requirements-finalize-contracts.test.ts b/tests/v3-requirements-finalize-contracts.test.ts index 447074c..16371ac 100644 --- a/tests/v3-requirements-finalize-contracts.test.ts +++ b/tests/v3-requirements-finalize-contracts.test.ts @@ -168,6 +168,36 @@ describe('V3 requirements finalization operation', () => { ).rejects.toThrow('MANCODE_EXPECTED_REVISION_CONFLICT'); }); + it('requires explicit verification slot surfaces for policy-3 man delivery', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Require declared delivery observation boundaries.', + workflowMode: 'man', + delivery: true, + sessionId, + client: 'vitest', + taskId: id(14), + operationId: id(15), + now: NOW, + }); + + await expect( + finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(16), + now: NOW, + }), + ).rejects.toThrow('MANCODE_MAN_ACCEPTANCE_SURFACE_REQUIRED: AC-1'); + }); + it('rejects a newly confirmed exact scope contradiction without making old ledgers unreadable', async () => { const { sessionId } = await bootstrap(root, false, false); const created = await createV3Workflow({ diff --git a/tests/workflow-metadata-contracts.test.ts b/tests/workflow-metadata-contracts.test.ts index 5bba01e..f5a3fea 100644 --- a/tests/workflow-metadata-contracts.test.ts +++ b/tests/workflow-metadata-contracts.test.ts @@ -14,6 +14,35 @@ const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; const DIGEST = `sha256:${'a'.repeat(64)}`; describe('workflow metadata V3 contract', () => { + it('keeps delivery policy opt-in immutable and exclusive to man', () => { + const draft = localMetadata(); + const enabled = parseWorkflowMetadata({ + ...draft, + workflowMode: 'man', + governance: { + ...draft.governance, + policyVersions: { planning: 3, review: 1, verification: 1 }, + }, + }); + expect(enabled.governance.policyVersions.planning).toBe(3); + expect(() => + parseWorkflowMetadata({ ...enabled, workflowMode: 'manba' }), + ).toThrow('MANCODE_MAN_DELIVERY_MODE_REQUIRED'); + expect(() => + assertWorkflowMetadataTransition( + enabled, + { + ...enabled, + revision: enabled.revision + 1, + governance: { + ...enabled.governance, + policyVersions: { planning: 2, review: 1, verification: 1 }, + }, + }, + 'ordinary', + ), + ).toThrow('POLICY_IMMUTABLE'); + }); it('enforces the V3 workflow dimensions without accepting legacy mamba or non-workflow modes', () => { const metadata = parseWorkflowMetadata(rawMetadata()); expect(metadata.workflowMode).toBe('manteam'); @@ -140,11 +169,11 @@ describe('workflow metadata V3 contract', () => { ...rawMetadata(), governance: { ...rawMetadata().governance, - policyVersions: { planning: 3, review: 1, verification: 1 }, + policyVersions: { planning: 4, review: 1, verification: 1 }, }, }), ).toThrow( - /MANCODE_POLICY_VERSION_UNSUPPORTED: component=planning observed=3 supported=1,2 requiredWriter=>0.4.0/, + /MANCODE_POLICY_VERSION_UNSUPPORTED: component=planning observed=4 supported=1,2,3 requiredWriter=>0.4.0/, ); expect(() => parseWorkflowMetadata({ diff --git a/website/docs.html b/website/docs.html index b09e8e1..f68f7c0 100644 --- a/website/docs.html +++ b/website/docs.html @@ -257,6 +257,7 @@

Complete public command index

mancode workflow review · mancode workflow verify · mancode workflow complete · + mancode workflow delivery · mancode workflow scope · mancode workflow reframe · mancode workflow archive · @@ -394,6 +395,8 @@

The Step 4 decision

Plan only

Stop

Use when the requested deliverable is the confirmed plan, not code.

$ mancode workflow plan <local:ULID> confirm --plan-decision plan_only --expected-revision <revision> --session <session-id>

All three choices require an active man workflow, confirmed requirements, and plan.md. Revise the plan with workflow plan ... revise; every successful mutation returns the revision required by the next command.

+

Newly generated man entries create tasks with --delivery (planning policy 3). This opt-in binds an approved baseline and a separate delivery record in one versionable plan, using the project's directory convention or doc/ by default. Other modes, older tasks, and Solo handoffs keep their existing contracts.

+

workflow delivery <TaskRef> inspect|verify|confirm|review|sync|check|publication supports one module-level review, real command evidence, manual confirmation, document writeback, and separate commit/publication checks. Mutating actions require the active session and latest expected revision. A relevant command can cover multiple criteria through --acceptance AC-1,AC-2 without repeated runs. Review checks goal coverage, concrete defects, and unnecessary complexity; zero findings is valid. Missing upstreams or failed pushes mean unpublished delivery, not a business blocker. This mechanism does not certify an agent's semantic judgment or production readiness.

A valid Continuity sequence

Continuity uses explicit TaskRefs and expected revisions. The CLI owns every durable mutation; do not edit metadata files or use the legacy --step protocol.

diff --git a/website/docs.zh-CN.html b/website/docs.zh-CN.html index a8c6c5d..0703922 100644 --- a/website/docs.zh-CN.html +++ b/website/docs.zh-CN.html @@ -257,6 +257,7 @@

完整公开命令索引

mancode workflow review · mancode workflow verify · mancode workflow complete · + mancode workflow delivery · mancode workflow scope · mancode workflow reframe · mancode workflow archive · @@ -394,6 +395,8 @@

Step 4 的三种决策

只保留计划

停止

用户需要的交付物是计划,而不是代码。

$ mancode workflow plan <local:ULID> confirm --plan-decision plan_only --expected-revision <revision> --session <session-id>

三种选择都要求活动中的 man 工作流、已确认需求和 plan.md。使用 workflow plan ... revise 修订计划;每次成功写入都会返回下一个命令需要的 revision。

+

新生成的 man 入口通过 --delivery 显式启用 planning policy 3。它在同一份可版本化计划中区分批准基线与交付记录,沿用项目计划目录约定,新项目默认 doc/。其他模式、旧任务和 Solo handoff 保持原契约。

+

workflow delivery <TaskRef> inspect|verify|confirm|review|sync|check|publication 支持一次模块总审、实际命令证据、人工确认、文档回写,以及提交与发布分离检查。写操作需要活动 session 和最新 expected revision。一个命令确实覆盖多个验收项时,可用 --acceptance AC-1,AC-2 一次登记,不重复运行。总审检查目标兑现、具体缺陷与不必要的复杂度,允许零 finding。无上游或推送失败是交付未发布,不是业务阻塞;这些机制不等于已经证明 agent 的语义判断或生产适用性。

一条合法的 Continuity 顺序

Continuity 使用显式 TaskRef 和 expected revision。所有持久化变更都由 CLI 完成;不要手工编辑 metadata,也不要使用 legacy --step 协议。