diff --git a/README.md b/README.md index 1e53fef..f4320cb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - 项目规范和工作流规则放进 `.trellis/spec/` 与 `.trellis/workflow.md`; - 子代理执行记录、实验配置、指标结构和报告模板放进 `.trellis/hermes/` 相关目录; - 关键门禁采用“缺记录就失败”的方式,减少 AI 在流程里跳步。 +- 内置任务生命周期协调默认启用,在创建、启动、子代理返回、结束、关闭和归档边界修复可确定的状态偏差,并记录修订历史。 它的目标不是让 AI 更快地直接写代码,而是让科研项目里的 AI 协作更可追踪、可复盘、可审查。 @@ -97,7 +98,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -107,7 +108,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index 2d075b3..faab496 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,7 +11,7 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.7.1-beta.1`(测试版)。 +- 接受当前版本为 `0.7.1-beta.2`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 @@ -45,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "AGPL|Hermes|未实现" README.md docs/manual/appendix-f-feature-status.md`(静态核对)。 - 结果:项目定位、上游归属、实验性与未实现边界已对齐。 diff --git a/docs/manual/02-installation.md b/docs/manual/02-installation.md index 07210f7..a1a0e99 100644 --- a/docs/manual/02-installation.md +++ b/docs/manual/02-installation.md @@ -28,7 +28,7 @@ git --version 新包发布后,推荐固定安装本手册对应的测试版: ```bash -npm install --global research-trellis@0.7.1-beta.1 +npm install --global research-trellis@0.7.1-beta.2 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.7.1-beta.1 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.1-beta.2 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.7.1-beta.1 +npm install --global research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.7.1-beta.1`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.1-beta.2`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.7.1-beta.1`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.1-beta.2`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index a12948e..f3d21ff 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "experiment.py|task.py start|--codex" packages/cli/src packages/cli/test`(实现与测试核对)。 - 结果:初始化、任务创建、实验校验和启动步骤与当前入口一致。 diff --git a/docs/manual/04-how-it-works.md b/docs/manual/04-how-it-works.md index b427ed7..23b6175 100644 --- a/docs/manual/04-how-it-works.md +++ b/docs/manual/04-how-it-works.md @@ -62,7 +62,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "state_machine|approval-gate|task.py" packages/cli/src/templates`(协议与实现核对)。 - 结果:任务生命周期有实现支持;科研状态文件已准确标为协议模板。 diff --git a/docs/manual/05-project-setup.md b/docs/manual/05-project-setup.md index 8ec97d2..dc4c41f 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "options.yes|registry|selectedTemplate" packages/cli/src/commands/init.ts packages/cli/test/commands/init.integration.test.ts`(分支核对)。 - 结果:默认、交互、指定模板、自定义源和 `-y`(自动确认)的失败行为已分开记录。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index b35f914..410f53a 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ --decision-ref design.md ``` -引用必须位于仓库或当前任务内。记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 +引用必须位于仓库或当前任务内。讨论记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。已经校验计划的 `amend`(计划变更)还必须补充 `Original Commitment`(原承诺)、`Failure Evidence`(失败证据)和 `Failed Attempts`(已尝试路径),避免把新方案写成原方案自然完成。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 ## 预期结果 @@ -104,7 +104,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "plan_change|decision_state|supersedes" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(变更协议核对)。 - 结果:提出、审阅、接受或拒绝、替代和校验流程已补全,并明示了结构校验边界。 diff --git a/docs/manual/07-spec-and-context.md b/docs/manual/07-spec-and-context.md index be8aede..d560489 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -62,7 +62,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "implement.jsonl|check.jsonl|context" packages/cli/src/templates/trellis/scripts packages/cli/test`(上下文实现核对)。 - 结果:上下文清单、重复检查和平台读取路径与当前模板一致。 diff --git a/docs/manual/08-hermes-records.md b/docs/manual/08-hermes-records.md index 835c282..3eed7ec 100644 --- a/docs/manual/08-hermes-records.md +++ b/docs/manual/08-hermes-records.md @@ -72,7 +72,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind evidence ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "validate_artifact_file|artifact_ref_errors|has_symlink_component" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(路径与引用边界核对)。 - 结果:示例已改为先创建产物和摘要,再写产物与证据账本;路径检查范围已明示。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 239b7dd..0cc11a7 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -78,7 +78,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 - 结果:命令允许列表和本地运行记录均可定位。 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index 47c0a38..51adae6 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/runner.py replay --task "$TASK" --run-id "$RUN ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "run_manifest|replay" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(运行清单与复查核对)。 - 结果:运行清单、输出摘要和复查失败分支均有实现与测试依据。 diff --git a/docs/manual/11-metrics-and-comparison.md b/docs/manual/11-metrics-and-comparison.md index ba241cb..238fb13 100644 --- a/docs/manual/11-metrics-and-comparison.md +++ b/docs/manual/11-metrics-and-comparison.md @@ -77,7 +77,7 @@ python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality-gate|evidence_refs|claim_refs" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(门禁和引用核对)。 - 结果:质量门禁已有账本编号存在性检查以及有效、缺失引用测试。 diff --git a/docs/manual/12-reports-and-claims.md b/docs/manual/12-reports-and-claims.md index f8a9529..f72d30a 100644 --- a/docs/manual/12-reports-and-claims.md +++ b/docs/manual/12-reports-and-claims.md @@ -69,7 +69,7 @@ python3 ./.trellis/scripts/hermes/report.py approval-gate --task "$TASK" --claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "claim-review|approval-gate" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(主张与审批边界核对)。 - 结果:主张审查和审批门禁入口可定位,报告与人工批准保持分离。 diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 5834bdd..d080a3f 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -179,7 +179,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index e75ff6f..947019a 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -71,7 +71,7 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 -`v0.7.1-beta.1`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`v0.7.1-beta.2`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 @@ -124,7 +124,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index ee450f2..8638011 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "OpenCode|channel|mem" packages/cli/src/commands packages/core/src packages/cli/test packages/core/test`(频道与会话读取核对)。 - 结果:频道、会话读取和 `OpenCode`(开放代码工具)未实现分支均有当前代码与测试依据。 diff --git a/docs/manual/16-update-recovery-and-uninstall.md b/docs/manual/16-update-recovery-and-uninstall.md index bdce79a..0e80a97 100644 --- a/docs/manual/16-update-recovery-and-uninstall.md +++ b/docs/manual/16-update-recovery-and-uninstall.md @@ -69,7 +69,7 @@ research-trellis uninstall --yes ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dry-run|create-new|allow-downgrade" packages/cli/src/commands packages/cli/test/commands`(更新与恢复核对)。 - 结果:预览、冲突副本、降级拒绝和卸载预览分支均可定位。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 0176b42..e8cebf7 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -51,6 +51,45 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit `task.json`(任务状态)是当前状态的唯一事实源。每次 Closure 语义写入都会原子替换文件并递增 `hermes_revision`(Hermes 修订号)。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。 +## 默认生命周期协调 + +内置任务生命周期协调默认启用。任务创建、启动、Closure 命令、每轮任务续接、子代理返回、会话结束检查和归档都会读取同一份 `task.json`,并只修复能够从现有状态确定的偏差,例如: + +- 原版任务状态与 Hermes 阶段映射不一致; +- 当前工作包引用不存在,或遗漏了唯一处于运行、审核、阻塞状态的工作包; +- 派发失败已阻塞工作包,但任务阶段尚未同步; +- 下一动作为空,或因结构修复需要重新计算; +- 关闭标记只写入了一半。 + +协调器重复运行不会重复写事件或递增修订号。发生实际修复时,才写入 `lifecycle_reconciled`(生命周期已校正)事件。归档成功后写入 `task_archived`(任务已归档)事件。 + +生命周期协调不会接受证据、处置工作包、批准主张或关闭任务。`runner`(运行代理)成功仍只代表运行结束;工作包必须经过 `package-check`(工作包检查)和 `package-done`(工作包处置),任务必须经过 `audit`(审计)和 `close`(关闭)。这样可以修复“模型忘记同步状态”,但不会把不完整结果误判为完成。 + +需要诊断时可以在 `.trellis/config.yaml` 中设置 `task_lifecycle.enabled: false`,或临时设置 `TRELLIS_TASK_LIFECYCLE=0`(关闭内置任务生命周期协调)。自定义 Shell 生命周期钩子仍默认留空。 + +## 计划锚点与方案漂移 + +每次 `validate`(校验)成功都会在 `task.json`(任务状态)生成 `plan_anchor`(计划锚点)。它固定当时的 intent(目标)、范围、完成定义、工作包结果与验收、证据要求、研究路线、关键约束,以及 `prd.md`(需求文档)、`design.md`(设计方案)、`implement.md`(实施计划)中方案契约部分和当前决策记录的哈希。`Notes`(说明)、`Progress`(进度)、`Experiment Log`(实验记录)等专门章节用于追加执行记录,不会改变锚点;其他方案正文仍受保护。完整锚点还随 `plan_validated`(计划已校验)事件追加保存,因此重新校验不会抹掉旧方案。它不是第二份当前状态;当前状态仍以 `task.json` 为准,锚点只是“哪一版方案得到批准”的基线。 + +已有的 Hermes 任务若已在 ready、running、review 或 blocked 阶段但没有锚点,首次生命周期续接会生成一次 `legacy_migration`(旧任务迁移)锚点并记录事件;原版非 Hermes 任务不受影响。 + +在 ready、running 或 review 阶段,如果这些内容被直接改动,生命周期协调会写入 `plan_drift_detected`(计划漂移已发现)事件并进入 `blocked`(阻塞)。`package-check`(工作包检查)、`package-done`(工作包处置)、正式 agent 派发、`audit`(审计)、`close`(关闭)都会拒绝继续。这样“当前报错所以换一个方案”的判断不会悄悄替换原计划。 + +遇到困难时先在原计划内复现和定位;有限 repair(修复)仍只处理审计缺口。需要改变目标、完成定义、工作包 outcome(结果)、`done_when`(完成条件)、证据要求、范围、研究协议或方案正文时,使用 `amend`(计划变更)。方案文件先修改,再用 `amend --field plan_documents --value current`(接受当前方案文件)确认这一版。已经校验的验收、研究协议和方案文件变化必须同时提供人工批准与 `decision_ref`(决策记录引用);记录必须有 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Original Commitment`(原承诺)、`Failure Evidence`(失败证据)、`Failed Attempts`(已尝试路径)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。完成变更后任务回到 planning,再次 `validate` 才会生成新锚点。 + +模型架构、新假设、数据、切分、预处理、指标、基线或主张范围变化属于探索性研究;其中模型架构变化必须创建独立探索任务,不会覆盖旧任务结果。旧任务应明确保留为完成、延期、阻塞或失败,而不是把新路线写成原方案已经完成。 + +## 状态可见性与回放 + +`status --json`(状态 JSON)会从最新未应用的 `plan_amended`(计划变更)事件显示待处理事项:变更字段、当前 `hermes_revision`(Hermes 修订号)、仍缺少的人工批准或决策记录,以及恢复条件。它不会把聊天中的“已同意”当作批准。 + +```bash +python3 ./.trellis/scripts/closure.py status --task "$TASK" --json +python3 ./.trellis/scripts/closure.py timeline --task "$TASK" +``` + +`timeline`(时间线)只汇总 `task-events.jsonl`(任务事件记录)和 `dispatch_audit.jsonl`(派发审计记录)末尾的最近 12 条短摘要。它不运行生命周期校正、不修改 `task.json`(任务状态)或事件记录;用于快速回看当前任务如何进入此状态,不读取原始终端日志、完整差异、搜索过程或聊天内容。 + ## Task Capsule `capsule`(紧凑上下文)默认控制在约 500–1000 个字符,包含任务目标、范围、模式、阶段、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。 @@ -132,13 +171,15 @@ python3 ./.trellis/scripts/closure.py validate --task "$TASK" ## 计划变更 -低风险变更直接更新 `task.json` 并记录 `plan_amended`(计划已修改)事件: +尚未 `validate`(校验)的 planning(规划)任务可以记录低风险变更;已经校验的验收变化必须使用人工批准和决策记录: ```bash python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ --field work_packages.WP2.done_when \ --value '["固定测试通过"]' \ - --reason "验收条件需要可执行" + --reason "验收条件需要可执行" \ + --approved-by 'human/root' \ + --decision-ref design.md ``` 高风险研究契约变更必须显式记录人工批准: @@ -161,6 +202,8 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ 上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 +在 `compact`(压缩)或 `clear`(清空)后,共享 hook 会再次注入当前 Task Capsule 和短续接提示。交接摘要只在修订号匹配时给出文件路径;其正文、完整需求文档和历史聊天不会自动重新注入,因此恢复时先以当前 `task.json`(任务状态)为准。 + ## 常见失败 - `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 @@ -186,7 +229,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index 50083ea..ac924c1 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.1`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.2`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 098d804..9233a33 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1-beta.1`(测试版)。 +适用于 `0.7.1-beta.2`(测试版)。 ## 前置条件 @@ -58,6 +58,7 @@ python3 ./.trellis/scripts/closure.py --help python3 ./.trellis/scripts/closure.py plan --task "$TASK" python3 ./.trellis/scripts/closure.py validate --task "$TASK" python3 ./.trellis/scripts/closure.py status --task "$TASK" +python3 ./.trellis/scripts/closure.py timeline --task "$TASK" python3 ./.trellis/scripts/closure.py next --task "$TASK" python3 ./.trellis/scripts/closure.py capsule --task "$TASK" python3 ./.trellis/scripts/closure.py package-start --task "$TASK" @@ -100,6 +101,8 @@ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id ``` +`status --json`(状态 JSON)会显示尚未满足的人工批准与决策记录条件。`timeline`(时间线)只从任务事件和派发审计末尾读取默认最近 12 条短摘要,不修改任务状态,也不显示原始日志、完整差异或聊天内容。 + `create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)在当前项目工作区使用紧凑派发协议;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 @@ -115,7 +118,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 +**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。默认启用的内置生命周期协调器会在任务命令、每轮续接和代理返回边界幂等修复可推导状态;它不接受证据、不处置工作包、不批准主张,也不关闭任务。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 ### Lean Research Closure @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案契约部分哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;`Notes`(说明)、`Progress`(进度)和 `Experiment Log`(实验记录)可追加执行记录而不改变方案;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案正文或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/README.md b/packages/cli/README.md index e60c4f5..f4320cb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,6 +30,7 @@ - 项目规范和工作流规则放进 `.trellis/spec/` 与 `.trellis/workflow.md`; - 子代理执行记录、实验配置、指标结构和报告模板放进 `.trellis/hermes/` 相关目录; - 关键门禁采用“缺记录就失败”的方式,减少 AI 在流程里跳步。 +- 内置任务生命周期协调默认启用,在创建、启动、子代理返回、结束、关闭和归档边界修复可确定的状态偏差,并记录修订历史。 它的目标不是让 AI 更快地直接写代码,而是让科研项目里的 AI 协作更可追踪、可复盘、可审查。 @@ -97,7 +98,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -107,7 +108,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -203,7 +204,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/packages/cli/package.json b/packages/cli/package.json index b462451..5e78085 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis", - "version": "0.7.1-beta.1", + "version": "0.7.1-beta.2", "description": "Auditable AI-assisted research workflow tooling built on Trellis", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/scripts/hermes-preflight.js b/packages/cli/scripts/hermes-preflight.js index b1feb23..fd7fb45 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -34,6 +34,7 @@ const REQUIRED_FILES = [ "packages/cli/src/templates/trellis/hermes/roles/reviewer.md", "packages/cli/src/templates/trellis/scripts/closure.py", "packages/cli/src/templates/trellis/scripts/common/closure.py", + "packages/cli/src/templates/trellis/scripts/common/lifecycle.py", "packages/cli/src/templates/trellis/scripts/common/roles.py", "packages/cli/src/templates/trellis/scripts/common/dispatch.py", "packages/cli/src/templates/trellis/scripts/common/firewall.py", diff --git a/packages/cli/src/migrations/manifests/0.7.1-beta.1.json b/packages/cli/src/migrations/manifests/0.7.1-beta.1.json new file mode 100644 index 0000000..248588c --- /dev/null +++ b/packages/cli/src/migrations/manifests/0.7.1-beta.1.json @@ -0,0 +1,9 @@ +{ + "version": "0.7.1-beta.1", + "description": "完善科研协作工作流、项目上下文恢复和轻量角色治理。", + "breaking": false, + "recommendMigrate": false, + "changelog": "**科研协作工作流:**\n- feat(workflow): 主代理按任务复杂度规划和派发,子代理只接收当前工作所需的最小上下文。\n- feat(context): 初始化与更新流程补齐项目背景、研究方案、项目约束和渐进式上下文引用。\n- feat(handoff): 增加阶段交接命令与紧凑交接记录,支持长对话和跨会话恢复。\n- feat(roles): Claude Code 与 Codex 使用五个正式角色及 Profile,并保持旧角色别名兼容。\n- fix(gates): 收紧任务关闭、子代理记录、上下文恢复和平台配置的治理契约。\n\n**兼容性:**\n- 旧任务、旧角色名称和非 Hermes Trellis 项目继续可读。\n- 更新只补齐缺失模板,不覆盖用户已有的项目背景、研究方案或约束。", + "migrations": [], + "notes": "运行 research-trellis update 更新 Claude Code、Codex、Hermes hooks、工作流与项目模板。现有研究资料和任务记录保持不变。" +} diff --git a/packages/cli/src/migrations/manifests/0.7.1-beta.2.json b/packages/cli/src/migrations/manifests/0.7.1-beta.2.json new file mode 100644 index 0000000..880395c --- /dev/null +++ b/packages/cli/src/migrations/manifests/0.7.1-beta.2.json @@ -0,0 +1,9 @@ +{ + "version": "0.7.1-beta.2", + "description": "强化默认任务生命周期、方案锚点、人工批准可见性和紧凑只读时间线。", + "breaking": false, + "recommendMigrate": false, + "changelog": "**任务闭环与方案防偏移:**\n- feat(lifecycle): 默认启用轻量任务生命周期,要求任务先规划、执行、验证,再关闭和归档。\n- feat(anchors): 在任务状态中保存已确认方案锚点和修订号,检测执行过程中的计划偏移与过期派发。\n- feat(approval): 高风险计划变更进入待批准状态,并在状态、审计和关闭结果中显示未决批准。\n- feat(timeline): 提供紧凑只读时间线,按尾部有限读取事件,避免重复注入完整历史。\n- fix(closure): 关闭流程采用先校验后提交,失败时不留下 completed 或 closed 的部分状态。\n- fix(gates): 未处置工作包、未解决阻塞或未批准变更时拒绝提前关闭。\n\n**兼容性:**\n- 旧 closure 任务按需补齐可选生命周期字段。\n- 旧版非 closure 任务继续使用原 Trellis 行为。", + "migrations": [], + "notes": "运行 research-trellis update 更新生命周期脚本、hooks、工作流和文档模板。该版本不引入新的角色、状态机、数据库或隔离运行环境。" +} diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 30acf61..edb80bc 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -1363,6 +1363,34 @@ def is_hermes_closure_task(task_data: dict[str, Any]) -> bool: ) +def reconcile_active_task_data( + root: Path, + task: str | None, + task_data: dict[str, Any], + event_name: str, + platform: str | None, +) -> dict[str, Any]: + """Apply safe lifecycle repairs without making hook availability brittle.""" + if not task or not is_hermes_closure_task(task_data): + return task_data + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.lifecycle import reconcile_task_lifecycle # type: ignore[import-not-found] + reconcile_task_lifecycle( + root / ".trellis" / "tasks" / task, + task_data, + root=root, + actor=f"hook/{platform or 'unknown'}", + reason=f"{event_name or 'hook'} lifecycle check", + initialize_legacy_anchor=False, + ) + except Exception: + pass + return task_data + + def record_hook_heartbeat( root: Path, platform: str | None, @@ -1546,6 +1574,14 @@ def handle_agent_result_event( if state.get("status") not in {"blocked", "failed", "stale"}: return block_payload(event_name, "Hermes Context Firewall: final Agent result is missing.") + reconcile_active_task_data( + root, + task, + task_data, + event_name, + detect_platform(data), + ) + if event_name == "PostToolUse": tool_response = data.get("tool_response") if isinstance(tool_response, dict) and tool_response.get("status") == "async_launched": @@ -1641,6 +1677,7 @@ def main() -> int: runtime_root = root / ".trellis" / "scripts" / "hermes" task = resolve_active_task(root, data) task_data = read_active_task_data(root, task) + task_data = reconcile_active_task_data(root, task, task_data, event_name, platform) record_hook_heartbeat( root, platform, diff --git a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py index 13dfa10..bb50533 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -325,10 +325,24 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None research_route_summary, skill_route_guidance, ) + from common.lifecycle import reconcile_task_lifecycle # type: ignore[import-not-found] except Exception: return None if not isinstance(data, dict) or not is_closure_task(data): return None + try: + reconcile_task_lifecycle( + task_dir, + data, + root=root, + actor=f"hook/{_detect_platform(input_data) or 'unknown'}", + reason="user prompt lifecycle check", + initialize_legacy_anchor=False, + ) + except Exception: + # Per-turn context injection must remain available even when a damaged + # task requires explicit repair through the closure CLI. + pass task_id = str(data.get("id") or task_dir.name) revision = data.get("hermes_revision", 0) phase = str(data.get("hermes_phase") or "planning") diff --git a/packages/cli/src/templates/trellis/config.yaml b/packages/cli/src/templates/trellis/config.yaml index f27077c..db27252 100644 --- a/packages/cli/src/templates/trellis/config.yaml +++ b/packages/cli/src/templates/trellis/config.yaml @@ -36,6 +36,13 @@ max_journal_lines: 2000 # Task Lifecycle Hooks #------------------------------------------------------------------------------- +# Deterministic Hermes task-state reconciliation is enabled by default. +# It repairs only derived state and never accepts evidence, completes a work +# package, approves a claim, or closes a task. Set enabled to false only when +# diagnosing lifecycle behavior; custom shell hooks below remain opt-in. +task_lifecycle: + enabled: true + # Shell commands to run after task lifecycle events. # Each hook receives TASK_JSON_PATH environment variable pointing to task.json. # Hook failures print a warning but do not block the main operation. diff --git a/packages/cli/src/templates/trellis/hermes/config.yaml b/packages/cli/src/templates/trellis/hermes/config.yaml index aad6338..b6b19b3 100644 --- a/packages/cli/src/templates/trellis/hermes/config.yaml +++ b/packages/cli/src/templates/trellis/hermes/config.yaml @@ -1,6 +1,6 @@ # Hermes research overlay config. -hermes_version: "0.7.1-beta.1" +hermes_version: "0.7.1-beta.2" runtime_scope: "lightweight research collaboration" runtime_boundary: "runs directly in the project workspace" diff --git a/packages/cli/src/templates/trellis/index.ts b/packages/cli/src/templates/trellis/index.ts index 7fa11ee..f7d760b 100644 --- a/packages/cli/src/templates/trellis/index.ts +++ b/packages/cli/src/templates/trellis/index.ts @@ -81,6 +81,7 @@ export const commonTasks = readTemplate("scripts/common/tasks.py"); export const commonTaskContext = readTemplate("scripts/common/task_context.py"); export const commonTaskStore = readTemplate("scripts/common/task_store.py"); export const commonClosure = readTemplate("scripts/common/closure.py"); +export const commonLifecycle = readTemplate("scripts/common/lifecycle.py"); export const commonRoles = readTemplate("scripts/common/roles.py"); export const commonDispatch = readTemplate("scripts/common/dispatch.py"); export const commonFirewall = readTemplate("scripts/common/firewall.py"); @@ -153,6 +154,7 @@ export function getAllScripts(): Map { scripts.set("common/task_context.py", commonTaskContext); scripts.set("common/task_store.py", commonTaskStore); scripts.set("common/closure.py", commonClosure); + scripts.set("common/lifecycle.py", commonLifecycle); scripts.set("common/roles.py", commonRoles); scripts.set("common/dispatch.py", commonDispatch); scripts.set("common/firewall.py", commonFirewall); diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index 1c86aa4..6ce3791 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -18,13 +18,16 @@ block_package, build_capsule, check_package, + closure_timeline, closure_next_action, close_task, complete_package, + format_closure_timeline, format_audit_yaml, is_closure_task, mark_grill_complete, package_by_id, + pending_approval_summaries, plan_closure, repair_closure, resolve_closure_task, @@ -33,6 +36,7 @@ validate_and_ready, write_handoff, ) +from common.lifecycle import reconcile_task_lifecycle def add_common(parser: argparse.ArgumentParser) -> None: @@ -40,6 +44,16 @@ def add_common(parser: argparse.ArgumentParser) -> None: parser.add_argument("--actor", help="Actor written to task-events.jsonl") +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a positive integer") from exc + if parsed < 1 or parsed > 100: + raise argparse.ArgumentTypeError("must be between 1 and 100") + return parsed + + def package_spec(value: str) -> dict[str, Any]: """Parse TITLE::OUTCOME::DONE[|DONE]::EVIDENCE[|EVIDENCE].""" parts = value.split("::") @@ -91,6 +105,11 @@ def parser() -> argparse.ArgumentParser: add_common(status) status.add_argument("--json", action="store_true") + timeline = subparsers.add_parser("timeline", help="Show compact closure event timeline") + add_common(timeline) + timeline.add_argument("--limit", type=positive_int, default=12) + timeline.add_argument("--json", action="store_true") + next_command = subparsers.add_parser("next", help="Show the bounded next action") add_common(next_command) @@ -124,7 +143,11 @@ def parser() -> argparse.ArgumentParser: amend.add_argument("--value", required=True, help="JSON value or plain string") amend.add_argument("--reason", required=True) amend.add_argument("--affected-package", action="append", default=[]) - amend.add_argument("--approved-by", help="Required for high-risk research contract changes") + amend.add_argument("--approved-by", help="Required for high-risk or validated acceptance changes") + amend.add_argument( + "--decision-ref", + help="Required for approved post-validation acceptance or research-contract changes", + ) audit = subparsers.add_parser("audit", help="Audit closure without making a new plan") add_common(audit) @@ -151,10 +174,18 @@ def main(argv: list[str] | None = None) -> int: task_dir, data, repo_root = resolve_closure_task(args.task) actor = actor_name(repo_root, args.actor) if not is_closure_task(data) and args.command != "plan": - if args.command in {"status", "next", "capsule"}: + if args.command in {"status", "next", "capsule", "timeline"}: print("Compatibility mode: legacy non-Hermes task; run closure.py plan to opt in.") return 0 raise ClosureError("legacy non-Hermes task; run closure.py plan before closure commands") + if is_closure_task(data) and args.command not in {"plan", "timeline"}: + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=actor, + reason=f"closure.py {args.command}", + ) if args.command == "plan": warnings = plan_closure( task_dir, @@ -211,10 +242,17 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "status": if args.json: - print(json.dumps(_status_record(data), ensure_ascii=False, indent=2)) + print(json.dumps(_status_record(task_dir, data), ensure_ascii=False, indent=2)) else: print(build_capsule(task_dir, data, repo_root)) return 0 + if args.command == "timeline": + timeline = closure_timeline(task_dir, data, limit=args.limit) + if args.json: + print(json.dumps(timeline, ensure_ascii=False, indent=2)) + else: + print(format_closure_timeline(timeline)) + return 0 if args.command == "next": print(closure_next_action(data)) return 0 @@ -267,6 +305,7 @@ def main(argv: list[str] | None = None) -> int: actor=actor, affected_packages=args.affected_package, approved_by=args.approved_by, + decision_ref=args.decision_ref, ) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 @@ -303,7 +342,7 @@ def _json_or_text(value: str) -> Any: return value -def _status_record(data: dict[str, Any]) -> dict[str, Any]: +def _status_record(task_dir, data: dict[str, Any]) -> dict[str, Any]: current = data.get("current_work_package") current_package = package_by_id(data, str(current)) if current else None return { @@ -314,6 +353,7 @@ def _status_record(data: dict[str, Any]) -> dict[str, Any]: "current_work_package": current_package, "next_action": closure_next_action(data), "blockers": data.get("blockers") or [], + "pending_approvals": pending_approval_summaries(task_dir, data), "repair_count": data.get("repair_count", 0), "max_repair_count": data.get("max_repair_count", 1), } diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index bcf480e..ccbfad8 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -49,6 +49,19 @@ "evidence": "Evidence", "alternatives": "Alternatives", "failure conditions": "Failure Conditions", + "original commitment": "Original Commitment", + "failure evidence": "Failure Evidence", + "failed attempts": "Failed Attempts", +} +PLAN_CHANGE_DECISION_SECTION_HEADINGS = { + "decision": "Decision", + "rationale": "Rationale", + "evidence": "Evidence", + "original commitment": "Original Commitment", + "failure evidence": "Failure Evidence", + "failed attempts": "Failed Attempts", + "alternatives": "Alternatives", + "failure conditions": "Failure Conditions", } PACKAGE_STATUSES = { "pending", @@ -61,6 +74,30 @@ "waived", } DISPOSED_PACKAGE_STATUSES = {"done", "deferred", "waived"} +PLAN_ANCHOR_SCHEMA = "hermes-plan-anchor/v3" +LEGACY_PLAN_ANCHOR_SCHEMAS = {"hermes-plan-anchor/v1", "hermes-plan-anchor/v2"} +PLAN_DOCUMENT_NAMES = ("prd.md", "design.md", "implement.md") +PLAN_DRIFT_BLOCKER_PREFIX = "plan anchor drift:" +PENDING_PLAN_PROJECTION_FIELD = "pending_plan_projection" +NON_CONTRACT_PLAN_SECTION_HEADINGS = { + "notes", + "progress", + "experiment log", + "experiments", + "run log", + "results", + "observations", + "changelog", + "status", + "备注", + "说明", + "进度", + "实验记录", + "运行记录", + "结果记录", + "观察记录", + "变更记录", +} HIGH_RISK_FIELDS = { "closure_mode", "hypothesis", @@ -77,6 +114,7 @@ "in_scope", "out_of_scope", "max_repair_count", + "plan_documents", } AMENDABLE_CLOSURE_FIELDS = { "closure_mode", @@ -95,6 +133,9 @@ "depends_on", } EVENT_TYPES = { + "plan_validated", + "plan_anchor_initialized", + "plan_drift_detected", "phase_changed", "package_started", "package_completed", @@ -104,6 +145,8 @@ "grill_completed", "repair_started", "task_closed", + "task_archived", + "lifecycle_reconciled", } MICROSTEP_RE = re.compile( r"^(?:read|open|inspect|view|modify (?:a )?file|edit (?:a )?file|" @@ -173,6 +216,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "research_change_fields": [], "grill_completed": False, "decision_ref": None, + "plan_change_ref": None, "constraints": { "excluded_platforms": [], "excluded_paths": [], @@ -185,6 +229,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "repair_count": 0, "max_repair_count": default_repair_limit(mode), "hermes_revision": 0, + "plan_anchor": None, "confirmed_dispatches": [], } @@ -310,6 +355,47 @@ def append_event( return record +def _save_task_with_events( + task_dir: Path, + data: dict[str, Any], + events: list[dict[str, Any]], +) -> None: + """Persist one semantic task update and its events, rolling back together.""" + with task_state_lock(task_dir): + original = read_json(task_dir / FILE_TASK_JSON) + if not isinstance(original, dict): + raise ClosureError("cannot read task.json before semantic update") + event_path = task_dir / "hermes" / "task-events.jsonl" + event_state = "file" if event_path.is_file() else "other" if event_path.exists() else "missing" + try: + event_before = event_path.read_text(encoding="utf-8") if event_state == "file" else "" + except OSError as exc: + raise ClosureError("failed to read task event log before semantic update") from exc + try: + save_task(task_dir, data, lock_held=True) + for event in events: + payload = dict(event) + event_type = str(payload.pop("event_type")) + append_event(task_dir, event_type, lock_held=True, **payload) + except Exception as exc: + task_restored = write_json_atomic(task_dir / FILE_TASK_JSON, original) + event_restored = True + if event_state == "file": + event_restored = write_text_atomic(event_path, event_before) + elif event_state == "missing" and event_path.is_file(): + try: + event_path.unlink() + except OSError: + event_restored = False + data.clear() + data.update(original) + if not task_restored or not event_restored: + raise ClosureError( + "semantic event failed and state rollback also failed; manual recovery required" + ) from exc + raise + + def actor_name(root: Path, explicit: str | None = None) -> str: return explicit or get_developer(root) or "unknown" @@ -365,6 +451,458 @@ def closure_constraints(data: dict[str, Any]) -> dict[str, Any]: } +def plan_anchor_status( + data: dict[str, Any], + task_dir: Path | None = None, +) -> dict[str, Any]: + """Compare the current task contract with its last validated baseline.""" + anchor = data.get("plan_anchor") + if anchor is None: + return {"status": "missing", "id": None, "hash": None, "differences": []} + if not isinstance(anchor, dict): + return { + "status": "invalid", + "id": None, + "hash": None, + "differences": ["plan_anchor"], + } + anchor_id = anchor.get("id") if isinstance(anchor.get("id"), str) else None + anchor_hash = anchor.get("hash") if isinstance(anchor.get("hash"), str) else None + projection = anchor.get("projection") + schema = anchor.get("schema") + if ( + not anchor_id + or not anchor_hash + or not isinstance(projection, dict) + or _plan_anchor_hash(projection) != anchor_hash + ): + return { + "status": "invalid", + "id": anchor_id, + "hash": anchor_hash, + "differences": ["plan_anchor"], + "upgrade_required": False, + } + incomplete_current_schema = schema == PLAN_ANCHOR_SCHEMA and ( + "research_contract" not in projection + or "max_repair_count" not in projection + ) + if schema in LEGACY_PLAN_ANCHOR_SCHEMAS or incomplete_current_schema: + current = plan_anchor_projection(data, task_dir) + if schema == "hermes-plan-anchor/v2": + current["plan_documents"] = _plan_document_hashes( + task_dir, + data.get("decision_ref"), + data.get("plan_change_ref"), + legacy_full_text=True, + ) + if schema == "hermes-plan-anchor/v1": + current.pop("plan_documents", None) + if "research_contract" not in projection and not current.get("research_contract"): + current.pop("research_contract", None) + if "max_repair_count" not in projection: + inferred_limit = default_repair_limit(str(projection.get("closure_mode") or "lean")) + if current.get("max_repair_count") == inferred_limit: + current.pop("max_repair_count", None) + differences = _plan_anchor_differences(projection, current) + return { + "status": "clean" if not differences else "drifted", + "id": anchor_id, + "hash": anchor_hash, + "differences": differences, + "upgrade_required": True, + } + if ( + schema != PLAN_ANCHOR_SCHEMA + ): + return { + "status": "invalid", + "id": anchor_id, + "hash": anchor_hash, + "differences": ["plan_anchor"], + } + current = plan_anchor_projection(data, task_dir) + if task_dir is None: + current["plan_documents"] = deepcopy(projection.get("plan_documents", [])) + differences = _plan_anchor_differences(projection, current) + return { + "status": "clean" if not differences else "drifted", + "id": anchor_id, + "hash": anchor_hash, + "differences": differences, + "upgrade_required": False, + } + + +def plan_anchor_projection( + data: dict[str, Any], + task_dir: Path | None = None, +) -> dict[str, Any]: + """Return the semantic task contract, excluding derived execution state.""" + packages: list[dict[str, Any]] = [] + for index, raw in enumerate(data.get("work_packages") or [], start=1): + if not isinstance(raw, dict): + continue + package = normalize_package(raw, index) + packages.append( + { + "id": package["id"], + "title": _normalize_contract_text(package["title"]), + "outcome": _normalize_contract_text(package["outcome"]), + "done_when": _normalized_contract_list(package["done_when"]), + "evidence_required": _normalized_contract_list(package["evidence_required"]), + "depends_on": _normalized_contract_list(package["depends_on"]), + } + ) + decision_ref = data.get("decision_ref") + plan_change_ref = data.get("plan_change_ref") + meta = data.get("meta") + research_contract = meta.get("research_contract") if isinstance(meta, dict) else None + return { + "intent": _normalize_contract_text(data.get("intent")), + "in_scope": _normalized_contract_list(data.get("in_scope")), + "out_of_scope": _normalized_contract_list(data.get("out_of_scope")), + "definition_of_done": _normalized_contract_list(data.get("definition_of_done")), + "closure_mode": str(data.get("closure_mode") or "lean"), + "max_repair_count": data.get("max_repair_count"), + "constraints": closure_constraints(data), + "research_contract": _normalize_contract_value(research_contract or {}), + "research_route": research_route(data), + "research_change_fields": _normalized_contract_list(_research_change_fields(data)), + "decision_ref": decision_ref.strip() if isinstance(decision_ref, str) and decision_ref.strip() else None, + "plan_change_ref": plan_change_ref.strip() if isinstance(plan_change_ref, str) and plan_change_ref.strip() else None, + "plan_documents": _plan_document_hashes(task_dir, decision_ref, plan_change_ref), + "work_packages": sorted(packages, key=lambda item: item["id"]), + } + + +def create_plan_anchor( + data: dict[str, Any], + task_dir: Path | None = None, + *, + origin: str = "validated", +) -> dict[str, Any]: + """Freeze the validated plan while keeping task.json as the live state.""" + projection = plan_anchor_projection(data, task_dir) + digest = _plan_anchor_hash(projection) + revision = data.get("hermes_revision", 0) + next_revision = revision + 1 if isinstance(revision, int) and not isinstance(revision, bool) else 0 + return { + "schema": PLAN_ANCHOR_SCHEMA, + "id": f"pa-{digest.removeprefix('sha256:')[:16]}", + "hash": digest, + "validated_at": now_utc(), + "origin": origin, + "source_revision": next_revision, + "projection": projection, + } + + +def _plan_anchor_hash(projection: dict[str, Any]) -> str: + serialized = json.dumps(projection, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _normalized_contract_list(value: Any) -> list[str]: + normalized = [_normalize_contract_text(item) for item in _string_list(value)] + return sorted(_dedupe([item for item in normalized if item])) + + +def _normalize_contract_text(value: Any) -> str: + return " ".join(str(value or "").split()) + + +def _normalize_contract_value(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): _normalize_contract_value(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, list): + return [_normalize_contract_value(item) for item in value] + if isinstance(value, str): + return _normalize_contract_text(value) + return deepcopy(value) + + +def _plan_document_hashes( + task_dir: Path | None, + *decision_refs: Any, + legacy_full_text: bool = False, +) -> list[dict[str, Any]]: + if task_dir is None: + return [] + task_root = task_dir.resolve() + refs: list[str] = list(PLAN_DOCUMENT_NAMES) + for decision_ref in decision_refs: + if isinstance(decision_ref, str) and decision_ref.strip(): + refs.append(decision_ref.strip()) + records: list[dict[str, Any]] = [] + root = get_repo_root(task_dir).resolve() + for ref in _dedupe(refs): + bases = [task_root, root] if not ref.startswith(".trellis/") else [root] + resolved: Path | None = None + for base in bases: + candidate = (base / ref).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file(): + resolved = candidate + break + if resolved is None: + if ref in PLAN_DOCUMENT_NAMES: + records.append({"path": ref, "status": "missing", "hash": None}) + continue + try: + content = resolved.read_text(encoding="utf-8") + try: + path = resolved.relative_to(task_root).as_posix() + except ValueError: + path = resolved.relative_to(root).as_posix() + except (OSError, UnicodeDecodeError, ValueError): + records.append({"path": ref, "status": "unreadable", "hash": None}) + continue + normalized = _normalize_plan_document( + content, + path=path, + legacy_full_text=legacy_full_text, + ) + records.append({ + "path": path, + "status": "present", + "hash": "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest(), + }) + return sorted(records, key=lambda item: str(item["path"])) + + +def _normalize_plan_document( + content: str, + *, + path: str, + legacy_full_text: bool = False, +) -> str: + """Hash plan commitments while allowing append-only work notes.""" + lines = [] + excluded_section_level: int | None = None + for raw in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw.rstrip() + if not legacy_full_text and path in PLAN_DOCUMENT_NAMES: + heading = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", line) + if heading: + level = len(line.lstrip()) - len(line.lstrip().lstrip("#")) + if excluded_section_level is not None and level <= excluded_section_level: + excluded_section_level = None + normalized_heading = " ".join(heading.group(1).casefold().split()) + if excluded_section_level is None and normalized_heading in NON_CONTRACT_PLAN_SECTION_HEADINGS: + excluded_section_level = level + if excluded_section_level is not None: + continue + if excluded_section_level is not None: + continue + line = re.sub(r"^(\s*[-*+]\s*)\[[ xX]\]", r"\1[ ]", line) + lines.append(line) + return "\n".join(lines).strip() + "\n" + + +def _plan_anchor_differences(expected: dict[str, Any], current: dict[str, Any]) -> list[str]: + differences: list[str] = [] + for field in ( + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "closure_mode", + "max_repair_count", + "research_route", + "research_change_fields", + "decision_ref", + "plan_change_ref", + "plan_documents", + ): + if expected.get(field) != current.get(field): + differences.append(field) + differences.extend( + _nested_contract_differences( + "constraints", + expected.get("constraints") or {}, + current.get("constraints") or {}, + ) + ) + differences.extend( + _nested_contract_differences( + "research_contract", + expected.get("research_contract") or {}, + current.get("research_contract") or {}, + ) + ) + expected_packages = { + str(item.get("id")): item + for item in expected.get("work_packages") or [] + if isinstance(item, dict) + } + current_packages = { + str(item.get("id")): item + for item in current.get("work_packages") or [] + if isinstance(item, dict) + } + if set(expected_packages) != set(current_packages): + differences.append("work_packages") + for package_id in sorted(set(expected_packages) & set(current_packages)): + for field in ("title", "outcome", "done_when", "evidence_required", "depends_on"): + if expected_packages[package_id].get(field) != current_packages[package_id].get(field): + differences.append(f"work_packages.{package_id}.{field}") + return _dedupe(differences) + + +def _nested_contract_differences(prefix: str, expected: Any, current: Any) -> list[str]: + if isinstance(expected, dict) and isinstance(current, dict): + differences: list[str] = [] + for key in sorted(set(expected) | set(current)): + child = f"{prefix}.{key}" + if key not in expected or key not in current: + differences.append(child) + else: + differences.extend( + _nested_contract_differences(child, expected[key], current[key]) + ) + return differences + return [] if expected == current else [prefix] + + +def _pending_plan_projection_errors( + task_dir: Path, + data: dict[str, Any], +) -> list[str]: + pending = data.get(PENDING_PLAN_PROJECTION_FIELD) + if pending is None: + return [] + if not isinstance(pending, dict): + return ["pending approved plan projection is invalid"] + differences = _plan_anchor_differences( + pending, + plan_anchor_projection(data, task_dir), + ) + if not differences: + return [] + return [ + "unapproved changes after plan amendment: " + ", ".join(differences[:3]) + ] + + +def _pending_grill_projection_errors( + task_dir: Path, + data: dict[str, Any], + decision_ref: str, +) -> list[str]: + pending = data.get(PENDING_PLAN_PROJECTION_FIELD) + if pending is None: + return [] + if not isinstance(pending, dict): + return ["pending approved plan projection is invalid"] + candidate = deepcopy(data) + candidate["decision_ref"] = decision_ref + current = plan_anchor_projection(candidate, task_dir) + differences = _plan_anchor_differences(pending, current) + unexpected = [ + item for item in differences if item not in {"decision_ref", "plan_documents"} + ] + if unexpected: + return [ + "unapproved changes before research decision: " + + ", ".join(unexpected[:3]) + ] + if "plan_documents" in differences: + expected_docs = { + str(item.get("path")): item + for item in pending.get("plan_documents") or [] + if isinstance(item, dict) + } + current_docs = { + str(item.get("path")): item + for item in current.get("plan_documents") or [] + if isinstance(item, dict) + } + changed_paths = { + path + for path in set(expected_docs) | set(current_docs) + if expected_docs.get(path) != current_docs.get(path) + } + allowed_path = _plan_reference_path(task_dir, decision_ref) + if allowed_path is None or changed_paths - {allowed_path}: + return [ + "unapproved plan document changes before research decision: " + + ", ".join(sorted(changed_paths)[:3]) + ] + return [] + + +def _set_pending_plan_projection(task_dir: Path, data: dict[str, Any]) -> None: + data[PENDING_PLAN_PROJECTION_FIELD] = plan_anchor_projection(data, task_dir) + + +def _plan_reference_path(task_dir: Path, reference: str) -> str | None: + root = get_repo_root(task_dir).resolve() + task_root = task_dir.resolve() + bases = [task_root, root] if not reference.startswith(".trellis/") else [root] + for base in bases: + candidate = (base / reference).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if not candidate.is_file(): + continue + try: + return candidate.relative_to(task_root).as_posix() + except ValueError: + try: + return candidate.relative_to(root).as_posix() + except ValueError: + return None + return None + + +def _allowed_existing_drift_for_amend(field: str) -> set[str]: + if field == "plan_documents": + return {"plan_documents"} + parts = field.split(".") + if parts[0] == "work_packages" and len(parts) == 3: + return {field} + if parts[0] == "constraints" and len(parts) == 2: + return {field} + if len(parts) == 1 and field in { + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "closure_mode", + "max_repair_count", + }: + return {field} + research_parts = _research_field_parts(field) + return {"research_contract." + ".".join(research_parts)} if research_parts else set() + + +def _require_clean_plan_anchor(task_dir: Path, data: dict[str, Any]) -> None: + status = plan_anchor_status(data, task_dir) + if status["status"] == "clean": + return + if status["status"] == "missing": + raise ClosureError("validated plan anchor is missing; run closure.py validate before continuing") + detail = ", ".join(status["differences"][:3]) or "plan contract" + raise ClosureError(f"plan anchor drift detected ({detail}); record an approved closure.py amend") + + +def _clear_plan_anchor_blockers(data: dict[str, Any]) -> None: + data["blockers"] = [ + item + for item in _string_list(data.get("blockers")) + if not item.startswith(PLAN_DRIFT_BLOCKER_PREFIX) + ] + + def research_route(data: dict[str, Any]) -> str: """Return the compatible route for a closure task without mutating it.""" value = data.get("research_route") @@ -598,6 +1136,10 @@ def plan_closure( research_change_fields: list[str] | None = None, ) -> list[str]: old_phase = str(data.get("hermes_phase") or "planning") + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + raise ClosureError( + "an approved amendment is pending validation; do not re-plan it" + ) if is_closure_task(data) and old_phase != "planning": raise ClosureError("validated or active plans must change through closure.py amend") ensure_closure_defaults(data, mode) @@ -688,6 +1230,10 @@ def set_research_route( if route is None and research_change_fields is None and not clear_research_changes: raise ClosureError("set --route, --research-change, or --clear-research-changes") ensure_closure_defaults(data) + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + raise ClosureError( + "an approved amendment is pending validation; change the route through amend" + ) if data.get("hermes_phase") != "planning": raise ClosureError("research route can only be marked during planning") if route is not None and route not in RESEARCH_ROUTES: @@ -762,24 +1308,33 @@ def mark_grill_complete( normalized_ref, decision_errors = _validate_decision_ref(task_dir, decision_ref) if decision_errors: raise ClosureError("; ".join(decision_errors)) + pending_errors = _pending_grill_projection_errors(task_dir, data, normalized_ref) + if pending_errors: + raise ClosureError("; ".join(pending_errors)) data["grill_completed"] = True data["decision_ref"] = normalized_ref data["next_action"] = closure_next_action(data) - save_task(task_dir, data) - append_event( + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + _set_pending_plan_projection(task_dir, data) + _save_task_with_events( task_dir, - "grill_completed", - actor=actor, - old_state="pending", - new_state="complete", - reason="exploration grill completed", - extra={"decision_ref": normalized_ref}, + data, + [{ + "event_type": "grill_completed", + "actor": actor, + "old_state": "pending", + "new_state": "complete", + "reason": "exploration grill completed", + "extra": {"decision_ref": normalized_ref}, + }], ) def validate_closure( data: dict[str, Any], task_dir: Path | None = None, + *, + check_plan_anchor: bool = True, ) -> tuple[list[str], list[str]]: errors: list[str] = [] warnings: list[str] = [] @@ -851,6 +1406,13 @@ def validate_closure( errors.append("definition_of_done is required") if _string_list(data.get("blockers")): errors.append("unresolved blockers must be resolved before validation") + if check_plan_anchor and phase != "planning": + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] == "missing": + errors.append("validated plan anchor is missing; run closure.py validate") + elif anchor["status"] != "clean": + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + errors.append(f"plan anchor drift detected: {changed}; use closure.py amend") raw_pins = data.get("context_pins") pins = _string_list(raw_pins) @@ -990,24 +1552,53 @@ def validate_and_ready( ensure_closure_defaults(data) if data.get("closure_state") == "closed": return ["closed tasks must be reopened through closure.py plan"], [] - errors, warnings = validate_closure(data, task_dir) + previous_anchor = plan_anchor_status(data, task_dir) + pending_present = data.get(PENDING_PLAN_PROJECTION_FIELD) is not None + pending_errors = _pending_plan_projection_errors(task_dir, data) + if previous_anchor["status"] == "invalid": + return ["plan anchor is invalid; restore it before validation"], [] + if previous_anchor["status"] == "drifted" and not pending_present: + changed = ", ".join(previous_anchor["differences"][:3]) or "plan contract" + return [f"plan anchor drift detected: {changed}; use closure.py amend"], [] + if pending_errors: + return pending_errors, [] + errors, warnings = validate_closure(data, task_dir, check_plan_anchor=False) if errors: return errors, warnings old_phase = str(data.get("hermes_phase") or "planning") + anchor_created = old_phase == "planning" or previous_anchor["status"] == "missing" + if anchor_created: + data.pop(PENDING_PLAN_PROJECTION_FIELD, None) + data["plan_anchor"] = create_plan_anchor(data, task_dir) + _clear_plan_anchor_blockers(data) _refresh_ready_packages(data) data["hermes_phase"] = "ready" data["status"] = "in_progress" data["next_action"] = closure_next_action(data) - save_task(task_dir, data) + events: list[dict[str, Any]] = [] if old_phase != "ready": - append_event( - task_dir, - "phase_changed", - actor=actor, - old_state=old_phase, - new_state="ready", - reason="closure plan validated", - ) + events.append({ + "event_type": "phase_changed", + "actor": actor, + "old_state": old_phase, + "new_state": "ready", + "reason": "closure plan validated", + }) + if anchor_created: + anchor = data["plan_anchor"] + events.append({ + "event_type": "plan_validated", + "actor": actor, + "old_state": previous_anchor["hash"] or "none", + "new_state": anchor["hash"], + "reason": "closure plan validated and anchored", + "extra": { + "plan_anchor_id": anchor["id"], + "source_revision": anchor["source_revision"], + "plan_anchor": anchor, + }, + }) + _save_task_with_events(task_dir, data, events) return [], warnings @@ -1099,6 +1690,7 @@ def check_package( *, actor: str, ) -> dict[str, Any]: + _require_clean_plan_anchor(task_dir, data) resolved = package_id or str(data.get("current_work_package") or "") if not resolved: raise ClosureError("no current work package") @@ -1139,6 +1731,7 @@ def complete_package( disposition: str = "done", reason: str = "", ) -> dict[str, Any]: + _require_clean_plan_anchor(task_dir, data) if disposition not in DISPOSED_PACKAGE_STATUSES: raise ClosureError("disposition must be done, deferred, or waived") resolved = package_id or str(data.get("current_work_package") or "") @@ -1280,7 +1873,19 @@ def audit_closure( ) -> dict[str, Any]: gaps: list[dict[str, Any]] = [] warnings: list[str] = [] - errors, _ = validate_closure(data, task_dir) + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] == "missing": + gaps.append(_gap(None, "validated plan anchor", "运行 closure.py validate 固定当前计划")) + elif anchor["status"] != "clean": + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + gaps.append( + _gap( + None, + f"plan anchor drift: {changed}", + "记录经批准的 closure.py amend,再重新 validate", + ) + ) + errors, _ = validate_closure(data, task_dir, check_plan_anchor=False) for error in errors: gaps.append(_gap(None, error, "修正 task.json closure plan 后重新 validate")) packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] @@ -1516,6 +2121,9 @@ def repair_closure( ) -> dict[str, Any]: ensure_closure_defaults(data) before = audit_closure(task_dir, data, write_report=False) + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] != "clean": + return before if before["status"] == "all_met": return before has_package_gap = any(gap.get("package") for gap in before.get("gaps") or []) @@ -1572,6 +2180,7 @@ def amend_plan( actor: str, affected_packages: list[str] | None = None, approved_by: str | None = None, + decision_ref: str | None = None, ) -> dict[str, Any]: if not field or not reason.strip(): raise ClosureError("field and change reason are required") @@ -1585,11 +2194,51 @@ def amend_plan( (part for part in research_parts if part in RESEARCH_CHANGE_FIELDS), None, ) + plan_document_change = field == "plan_documents" + if plan_document_change and new_value != "current": + raise ClosureError("plan_documents amendment value must be current") high_risk = ( root_field in HIGH_RISK_FIELDS or risk_field in HIGH_RISK_FIELDS or research_change is not None ) + anchor = plan_anchor_status(data, task_dir) + anchored = anchor["status"] != "missing" + pending_present = data.get(PENDING_PLAN_PROJECTION_FIELD) is not None + pending_errors = _pending_plan_projection_errors(task_dir, data) + if pending_errors: + raise ClosureError("; ".join(pending_errors)) + if anchor["status"] == "invalid": + raise ClosureError("plan anchor is invalid; restore it before amending") + if anchor["status"] == "drifted" and not pending_present: + allowed_drift = _allowed_existing_drift_for_amend(field) + unexpected_drift = [ + item for item in anchor["differences"] if item not in allowed_drift + ] + if unexpected_drift: + raise ClosureError( + "amendment contains unrelated plan drift: " + + ", ".join(unexpected_drift[:3]) + ) + acceptance_change = _is_acceptance_amendment(field) + requires_human_approval = high_risk or ( + anchored and (acceptance_change or plan_document_change) + ) + requires_decision_ref = anchored and ( + high_risk or acceptance_change or plan_document_change + ) + normalized_decision_ref: str | None = None + decision_errors: list[str] = [] + if decision_ref: + normalized_decision_ref, decision_errors = _validate_decision_ref( + task_dir, + decision_ref, + require_plan_change_context=requires_decision_ref, + ) + elif requires_decision_ref: + decision_errors = [ + f"approved plan change requires --decision-ref: {field}" + ] old_phase = str(data.get("hermes_phase") or "planning") old_value = _read_change_value(data, field) incomplete_ids = [ @@ -1604,12 +2253,13 @@ def amend_plan( semantic_ids = [field.split(".", 2)[1]] affected_ids = _dedupe([*semantic_ids, *incomplete_ids]) human_approved = str(approved_by or "").casefold() in {"human", "root", "human/root"} + approval_complete = human_approved and not decision_errors previous_route = research_route(data) previous_changes = _research_change_fields(data) requires_independent_task = ( - research_change == "model_architecture" and human_approved + research_change == "model_architecture" and approval_complete ) - applied = not high_risk or human_approved + applied = not requires_human_approval or approval_complete if requires_independent_task: applied = False data["research_route"] = "exploration" @@ -1626,8 +2276,12 @@ def amend_plan( "complete its grill before validation." ) elif applied: - _write_change_value(data, field, new_value) + if requires_decision_ref: + data["plan_change_ref"] = normalized_decision_ref + if not plan_document_change: + _write_change_value(data, field, new_value) _reset_incomplete_packages(data) + _clear_plan_anchor_blockers(data) data["closure_state"] = "open" data["hermes_phase"] = "planning" data["status"] = "planning" @@ -1642,9 +2296,12 @@ def amend_plan( else: data["next_action"] = "Run closure.py validate after the plan amendment." approval_blocker = f"high-risk plan change requires human approval: {field}" + acceptance_blocker = f"approved plan change requires human approval: {field}" + decision_blocker = f"approved plan change requires decision record: {field}" data["blockers"] = [ item for item in _string_list(data.get("blockers")) - if item != approval_blocker + if item not in {approval_blocker, acceptance_blocker, decision_blocker} + and item != f"approved plan change requires --decision-ref: {field}" ] if field == "closure_mode" and str(new_value) in CLOSURE_MODES: data["max_repair_count"] = default_repair_limit(str(new_value)) @@ -1653,35 +2310,55 @@ def amend_plan( item for item in _string_list(data.get("blockers")) if not item.startswith("repair limit reached") ] + if anchored: + _set_pending_plan_projection(task_dir, data) else: - blocker = f"high-risk plan change requires human approval: {field}" - data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + blockers: list[str] = [] + if requires_human_approval and not human_approved: + prefix = "high-risk" if high_risk else "approved" + blockers.append(f"{prefix} plan change requires human approval: {field}") + if decision_errors: + blockers.extend(decision_errors) + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), *blockers]) data["hermes_phase"] = "blocked" data["status"] = "in_progress" - data["next_action"] = f"Obtain human approval for {field} before applying the amendment." - save_task(task_dir, data) - append_event( - task_dir, - "plan_amended", - actor=actor, - old_state=json.dumps(old_value, ensure_ascii=False, separators=(",", ":")), - new_state=json.dumps(new_value, ensure_ascii=False, separators=(",", ":")), - reason=reason, - extra={ + data["next_action"] = f"Record the decision evidence and obtain human approval for {field} before applying the amendment." + events: list[dict[str, Any]] = [{ + "event_type": "plan_amended", + "actor": actor, + "old_state": json.dumps(old_value, ensure_ascii=False, separators=(",", ":")), + "new_state": json.dumps(new_value, ensure_ascii=False, separators=(",", ":")), + "reason": reason, + "extra": { "affected_packages": affected_ids, "field": field, - "approval_requirement": "human" if high_risk else "none", + "approval_requirement": "human" if requires_human_approval else "none", + "decision_requirement": "required" if requires_decision_ref else "none", "approved_by": approved_by, "applied": applied, "research_change": research_change, + "decision_ref": normalized_decision_ref, + "acceptance_change": acceptance_change, + "plan_anchor_differences": anchor["differences"], + "previous_plan_anchor_id": anchor["id"], + "previous_plan_anchor_hash": anchor["hash"], + "proposed_plan_documents": ( + _plan_document_hashes(task_dir, normalized_decision_ref) + if plan_document_change + else None + ), + "pending_projection_hash": ( + _plan_anchor_hash(data[PENDING_PLAN_PROJECTION_FIELD]) + if isinstance(data.get(PENDING_PLAN_PROJECTION_FIELD), dict) + else None + ), }, - ) + }] if requires_independent_task: - append_event( - task_dir, - "research_route_updated", - actor=actor, - old_state=json.dumps( + events.append({ + "event_type": "research_route_updated", + "actor": actor, + "old_state": json.dumps( { "research_route": previous_route, "research_change_fields": previous_changes, @@ -1689,7 +2366,7 @@ def amend_plan( ensure_ascii=False, separators=(",", ":"), ), - new_state=json.dumps( + "new_state": json.dumps( { "research_route": data["research_route"], "research_change_fields": data["research_change_fields"], @@ -1697,18 +2374,18 @@ def amend_plan( ensure_ascii=False, separators=(",", ":"), ), - reason="model architecture change requires an independent exploration task", - ) + "reason": "model architecture change requires an independent exploration task", + }) new_phase = str(data.get("hermes_phase") or old_phase) if new_phase != old_phase: - append_event( - task_dir, - "phase_changed", - actor=actor, - old_state=old_phase, - new_state=new_phase, - reason=f"plan amendment: {field}", - ) + events.append({ + "event_type": "phase_changed", + "actor": actor, + "old_state": old_phase, + "new_state": new_phase, + "reason": f"plan amendment: {field}", + }) + _save_task_with_events(task_dir, data, events) if not applied: write_handoff(task_dir, data, get_repo_root(task_dir)) if requires_independent_task: @@ -1716,8 +2393,11 @@ def amend_plan( "model architecture requires an independent exploration task; " "create a new task and plan it as exploration" ) - if high_risk and not human_approved: - raise ClosureError(f"high-risk change {field} requires --approved-by human/root") + if requires_human_approval and not human_approved: + prefix = "high-risk change" if high_risk else "approved plan change" + raise ClosureError(f"{prefix} {field} requires --approved-by human/root") + if decision_errors: + raise ClosureError("; ".join(decision_errors)) return {"field": field, "old_value": old_value, "new_value": new_value, "high_risk": high_risk} @@ -1815,16 +2495,32 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None excluded = _compact_list(_string_list(data.get("out_of_scope")), 2) blockers = _compact_list(_string_list(data.get("blockers")), 2) constraints = closure_constraints(data) + anchor = plan_anchor_status(data, task_dir) + anchor_label = anchor["id"] or "unvalidated" + anchor_state = anchor["status"] + pending_approvals = pending_approval_summaries(task_dir, data) lines = [ f"Task: {data.get('id') or task_dir.name} | {data.get('title') or task_dir.name}", f"Intent: {_clip(str(data.get('intent') or data.get('description') or ''), 180)}", f"Scope: {scope or '-'} | Out: {excluded or '-'}", f"Mode/Phase: {data.get('closure_mode', 'lean')} / {data.get('hermes_phase', 'planning')}", + ] + if pending_approvals: + pending = pending_approvals[0] + requirements = "+".join(pending["requirements"]) + lines.append( + "Pending approval: " + + f"{pending['field']} @ r{pending['task_revision']}; {requirements}; " + + _clip(pending["recovery_condition"], 180) + ) + lines.extend([ + f"Plan anchor: {anchor_label} / {anchor_state}", + "Definition: " + _compact_list(_string_list(data.get("definition_of_done")), 2, 220), research_route_summary(data), "Route rule: " + research_route_rule(data), f"Verify: {constraints['validation_level']} | Excluded platforms: {_compact_list(constraints['excluded_platforms'], 2) or '-'}", f"Current: {current_id or '-'}" + (f" - {_clip(str(current.get('outcome') or ''), 140)}" if current else ""), - ] + ]) if current: lines.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 240)) decision_ref = data.get("decision_ref") @@ -1839,6 +2535,166 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None return capsule if len(capsule) <= 1000 else capsule[:997].rstrip() + "..." +def pending_approval_summaries(task_dir: Path, data: dict[str, Any]) -> list[dict[str, Any]]: + """Return unresolved approval requirements from the latest amendment per field. + + The event log is historical evidence, while task.json remains authoritative. + Looking only at the latest amendment for each field prevents an old rejected + proposal from remaining visible after the same change was later approved. + """ + latest_by_field: dict[str, dict[str, Any]] = {} + for event in _read_jsonl_values(task_dir / "hermes" / "task-events.jsonl"): + if event.get("event_type") != "plan_amended": + continue + field = event.get("field") + if isinstance(field, str) and field.strip(): + latest_by_field[field] = event + + revision = data.get("hermes_revision", 0) + if not isinstance(revision, int) or isinstance(revision, bool): + revision = 0 + pending: list[dict[str, Any]] = [] + for event in latest_by_field.values(): + if event.get("applied") is not False: + continue + approval_required = event.get("approval_requirement") == "human" + approval_granted = str(event.get("approved_by") or "").casefold() in { + "human", + "root", + "human/root", + } + decision_required = event.get("decision_requirement") == "required" + if "decision_requirement" not in event: + field = str(event.get("field") or "") + decision_required = any( + f"requires --decision-ref: {field}" in str(blocker) + for blocker in _string_list(data.get("blockers")) + ) + decision_recorded = isinstance(event.get("decision_ref"), str) and bool( + str(event.get("decision_ref")).strip() + ) + needs_human = approval_required and not approval_granted + needs_decision = decision_required and not decision_recorded + if not needs_human and not needs_decision: + continue + requirements: list[str] = [] + if needs_decision: + requirements.append("decision record") + if needs_human: + requirements.append("human approval") + recovery = "Add " + " and ".join(requirements) + ", then rerun amend." + pending.append( + { + "field": str(event["field"]), + "task_revision": revision, + "requirements": requirements, + "requires_decision_record": needs_decision, + "requires_human_approval": needs_human, + "recovery_condition": recovery, + } + ) + return pending + + +def closure_timeline( + task_dir: Path, + data: dict[str, Any], + *, + limit: int = 12, +) -> dict[str, Any]: + """Return a short replay index without exposing raw logs or event payloads.""" + if limit < 1: + raise ClosureError("timeline limit must be at least 1") + entries: list[dict[str, Any]] = [] + event_labels = { + "plan_validated": "plan validated", + "plan_anchor_initialized": "plan anchor initialized", + "plan_drift_detected": "plan drift detected", + "phase_changed": "phase changed", + "package_started": "package started", + "package_completed": "package completed", + "package_blocked": "package blocked", + "plan_amended": "plan amendment recorded", + "research_route_updated": "research route updated", + "grill_completed": "exploration decision recorded", + "repair_started": "bounded repair started", + "task_closed": "task closed", + "task_archived": "task archived", + "lifecycle_reconciled": "lifecycle reconciled", + } + dispatch_labels = { + "dispatch_validated": "dispatch validated", + "dispatch_started": "dispatch started", + "result_confirmed": "result confirmed", + "result_rejected": "result rejected", + } + for event in _read_jsonl_tail_values( + task_dir / "hermes" / "task-events.jsonl", + limit, + ): + event_type = event.get("event_type") + timestamp = event.get("timestamp") + if not isinstance(event_type, str) or not isinstance(timestamp, str): + continue + summary = event_labels.get(event_type, "closure event recorded") + field = event.get("field") + if isinstance(field, str) and field.strip(): + summary += f": {field.strip()}" + entries.append( + { + "timestamp": timestamp, + "source": "task", + "event": event_type, + "package_id": event.get("package_id") if isinstance(event.get("package_id"), str) else None, + "summary": summary, + } + ) + for event in _read_jsonl_tail_values( + task_dir / "hermes" / "dispatch_audit.jsonl", + limit, + ): + event_name = event.get("event") + timestamp = event.get("timestamp") + if not isinstance(event_name, str) or not isinstance(timestamp, str): + continue + entries.append( + { + "timestamp": timestamp, + "source": "dispatch", + "event": event_name, + "job_id": event.get("job_id") if isinstance(event.get("job_id"), str) else None, + "summary": dispatch_labels.get(event_name, "dispatch audit recorded"), + } + ) + entries.sort(key=lambda item: (str(item["timestamp"]), str(item["source"]), str(item["event"]))) + revision = data.get("hermes_revision", 0) + return { + "task_id": data.get("id") or task_dir.name, + "hermes_revision": revision if isinstance(revision, int) and not isinstance(revision, bool) else 0, + "entries": entries[-min(limit, 100):], + } + + +def format_closure_timeline(value: dict[str, Any]) -> str: + lines = [ + f"Timeline: {value.get('task_id')} @ r{value.get('hermes_revision')}", + ] + entries = value.get("entries") if isinstance(value.get("entries"), list) else [] + if not entries: + lines.append("- no recorded closure events") + return "\n".join(lines) + for entry in entries: + if not isinstance(entry, dict): + continue + subject = entry.get("package_id") or entry.get("job_id") + suffix = f" [{subject}]" if subject else "" + lines.append( + f"- {entry.get('timestamp')} {entry.get('source')}:{entry.get('event')}{suffix}: " + f"{entry.get('summary')}" + ) + return "\n".join(lines) + + def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: revision = data.get("hermes_revision", 0) revision_text = str(revision) if isinstance(revision, int) and not isinstance(revision, bool) else "unknown" @@ -1851,6 +2707,7 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None ]) changed, integrity_warnings = _task_changed_file_records(task_dir, data) failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) + anchor = plan_anchor_status(data, task_dir) content = "\n".join( [ "# Task Handoff", @@ -1861,6 +2718,12 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None "## Current Goal", str(data.get("intent") or data.get("title") or "-"), "", + "## Plan Anchor", + f"{anchor['id'] or '-'} ({anchor['status']})", + "", + "## Definition of Done", + _markdown_items(_string_list(data.get("definition_of_done"))), + "", "## Current Phase", str(data.get("hermes_phase") or "-"), "", @@ -2142,6 +3005,10 @@ def _next_package_action(data: dict[str, Any]) -> str: def closure_next_action(data: dict[str, Any]) -> str: """Return the one valid next action for the authoritative Hermes phase.""" phase = str(data.get("hermes_phase") or "planning") + if phase != "planning": + anchor = plan_anchor_status(data) + if anchor["status"] in {"drifted", "invalid"}: + return "Plan anchor drift detected; record an approved closure.py amend before continuing." if phase == "planning": if research_route(data) == "exploration": if not _research_change_fields(data): @@ -2270,6 +3137,8 @@ def _validate_amend_field(data: dict[str, Any], field: str) -> None: parts = field.split(".") if any(not part for part in parts): raise ClosureError("amend field path is invalid") + if field == "plan_documents": + return if parts[0] == "work_packages": if len(parts) != 3 or parts[2] not in AMENDABLE_PACKAGE_FIELDS: raise ClosureError("only package title, outcome, done_when, evidence_required, or depends_on may be amended") @@ -2285,6 +3154,17 @@ def _validate_amend_field(data: dict[str, Any], field: str) -> None: raise ClosureError("research amend field path is invalid") +def _is_acceptance_amendment(field: str) -> bool: + if field in {"intent", "definition_of_done"}: + return True + parts = field.split(".") + return ( + len(parts) == 3 + and parts[0] == "work_packages" + and parts[2] in {"outcome", "done_when", "evidence_required"} + ) + + def _research_field_parts(field: str) -> list[str]: parts = field.split(".") if parts[:2] == ["meta", "research_contract"]: @@ -2348,6 +3228,8 @@ def _context_refs(task_dir: Path, data: dict[str, Any], root: Path) -> list[str] def _validate_decision_ref( task_dir: Path, reference: str, + *, + require_plan_change_context: bool = False, ) -> tuple[str, list[str]]: normalized = reference.replace("\\", "/").strip() if ( @@ -2378,22 +3260,32 @@ def _validate_decision_ref( except (OSError, UnicodeDecodeError): return normalized, [f"decision_ref is not readable text: {normalized}"] + required = ( + PLAN_CHANGE_DECISION_SECTION_HEADINGS + if require_plan_change_context + else { + key: value + for key, value in DECISION_SECTION_HEADINGS.items() + if key in {"decision", "rationale", "evidence", "alternatives", "failure conditions"} + } + ) + required_sections = set(required.values()) headings = list(re.finditer(r"(?mi)^#{1,6}\s+(.+?)\s*$", content)) present: set[str] = set() empty: list[str] = [] for index, match in enumerate(headings): key = match.group(1).strip().casefold() canonical = DECISION_SECTION_HEADINGS.get(key) - if canonical is None: + if canonical is None or canonical not in required_sections: continue - present.add(key) + present.add(canonical) end = headings[index + 1].start() if index + 1 < len(headings) else len(content) if not content[match.end():end].strip(): empty.append(canonical) missing = [ canonical - for key, canonical in DECISION_SECTION_HEADINGS.items() - if key not in present + for canonical in required.values() + if canonical not in present ] errors: list[str] = [] if missing: @@ -2423,6 +3315,51 @@ def _read_jsonl_values(path: Path) -> list[dict[str, Any]]: return values +def _read_jsonl_tail_values(path: Path, limit: int) -> list[dict[str, Any]]: + """Read only the newest valid JSONL records needed for a compact view.""" + if limit < 1 or not path.is_file(): + return [] + values: list[dict[str, Any]] = [] + try: + with path.open("rb") as handle: + handle.seek(0, 2) + position = handle.tell() + partial = b"" + while position > 0 and len(values) < limit: + chunk_size = min(8192, position) + position -= chunk_size + handle.seek(position) + partial = handle.read(chunk_size) + partial + lines = partial.split(b"\n") + partial = lines[0] + for line in reversed(lines[1:]): + value = _jsonl_line_value(line) + if value is not None: + values.append(value) + if len(values) >= limit: + break + if len(values) < limit: + value = _jsonl_line_value(partial) + if value is not None: + values.append(value) + except OSError: + return [] + values.reverse() + return values + + +def _jsonl_line_value(line: bytes) -> dict[str, Any] | None: + if not line.strip(): + return None + try: + value = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(value, dict) or "_example" in value: + return None + return value + + def _valid_artifact_hash(value: Any) -> bool: if not isinstance(value, str): return False diff --git a/packages/cli/src/templates/trellis/scripts/common/config.py b/packages/cli/src/templates/trellis/scripts/common/config.py index 93df643..247ebd5 100644 --- a/packages/cli/src/templates/trellis/scripts/common/config.py +++ b/packages/cli/src/templates/trellis/scripts/common/config.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -167,6 +168,7 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" DEFAULT_MAX_JOURNAL_LINES = 2000 DEFAULT_SESSION_AUTO_COMMIT = True +DEFAULT_TASK_LIFECYCLE_ENABLED = True CONFIG_FILE = "config.yaml" @@ -243,6 +245,40 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool: return DEFAULT_SESSION_AUTO_COMMIT +def get_task_lifecycle_enabled(repo_root: Path | None = None) -> bool: + """Return whether deterministic Hermes lifecycle reconciliation is enabled.""" + env_value = os.environ.get("TRELLIS_TASK_LIFECYCLE") + if env_value is not None: + normalized = env_value.strip().lower() + if normalized in ("true", "yes", "1", "on"): + return True + if normalized in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid TRELLIS_TASK_LIFECYCLE value: {env_value!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_TASK_LIFECYCLE_ENABLED + + config = _load_config(repo_root) + lifecycle = config.get("task_lifecycle") + if not isinstance(lifecycle, dict): + return DEFAULT_TASK_LIFECYCLE_ENABLED + raw = lifecycle.get("enabled", DEFAULT_TASK_LIFECYCLE_ENABLED) + if isinstance(raw, bool): + return raw + normalized = str(raw).strip().lower() + if normalized in ("true", "yes", "1", "on"): + return True + if normalized in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid task_lifecycle.enabled value: {raw!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_TASK_LIFECYCLE_ENABLED + + def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: """Get hook commands for a lifecycle event. diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index a2e5d2a..17a577c 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -19,6 +19,7 @@ is_closure_task, now_utc, package_by_id, + plan_anchor_status, save_task, task_state_lock, write_handoff, @@ -195,6 +196,10 @@ def create_dispatch( if not is_closure_task(task): raise DispatchError("not_hermes_task", "dispatch is available only for Hermes closure tasks") revision = _task_revision(task) + anchor = plan_anchor_status(task, task_dir) + if anchor["status"] in {"drifted", "invalid"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") objective = _required_text(spec.get("objective"), "objective") _check_sensitive_text(objective, "objective") try: @@ -211,6 +216,14 @@ def create_dispatch( role = normalized.role profile = normalized.profile + if anchor["status"] == "missing" and ( + task.get("hermes_phase") != "planning" + or role not in {"planner", "researcher"} + ): + raise DispatchError( + "plan_not_validated", + "formal execution or review requires closure.py validate and a plan anchor", + ) work_package = _normalize_optional_text(spec.get("work_package")) parent_job_id = _normalize_optional_text(spec.get("parent_job_id")) constraints = closure_constraints(task) @@ -293,6 +306,8 @@ def create_dispatch( "task_path": _repo_relative(task_dir, repo_root), "task_revision": revision, "hermes_revision": revision, + "plan_anchor_id": anchor["id"], + "plan_anchor_hash": anchor["hash"], "closure_mode": mode, "constraints": constraints, "role": role, @@ -460,6 +475,23 @@ def validate_dispatch( ) if dispatch.get("task_revision") != dispatch.get("hermes_revision"): errors.append("task_revision") + anchor = plan_anchor_status(task, task_dir) + if anchor["status"] in {"drifted", "invalid"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") + if before_execution and anchor["status"] == "missing" and ( + task.get("hermes_phase") != "planning" + or dispatch.get("role") not in {"planner", "researcher"} + ): + raise DispatchError( + "plan_not_validated", + "formal execution or review requires closure.py validate and a plan anchor", + ) + if anchor["status"] == "clean" and ( + dispatch.get("plan_anchor_id") != anchor["id"] + or dispatch.get("plan_anchor_hash") != anchor["hash"] + ): + raise DispatchError("stale_dispatch", "dispatch plan anchor does not match the approved plan") try: parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) if dispatch.get("parent_job_id") != parent_job_id: @@ -624,6 +656,11 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: "Hermes Agent Context Firewall dispatch", f"job_id: {dispatch.get('job_id')}", f"task: {dispatch.get('task_path')} @ revision {dispatch.get('hermes_revision')}", + "plan_anchor: " + ( + f"{dispatch.get('plan_anchor_id')} @ {dispatch.get('plan_anchor_hash')}" + if dispatch.get("plan_anchor_id") and dispatch.get("plan_anchor_hash") + else "legacy-unanchored" + ), f"role: {dispatch.get('role')}:{dispatch.get('profile')}", f"work_package: {dispatch.get('work_package') or 'null'}", f"parent_job_id: {dispatch.get('parent_job_id') or 'null'}", diff --git a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py new file mode 100644 index 0000000..810749d --- /dev/null +++ b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py @@ -0,0 +1,357 @@ +"""Deterministic, idempotent lifecycle reconciliation for Hermes tasks.""" + +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +from typing import Any + +from .closure import ( + ClosureError, + DISPOSED_PACKAGE_STATUSES, + append_event, + closure_next_action, + create_plan_anchor, + is_closure_task, + plan_anchor_status, + save_task, + task_state_lock, +) +from .config import get_task_lifecycle_enabled +from .io import read_json, write_json_atomic +from .paths import FILE_TASK_JSON, get_developer, get_repo_root + + +ACTIVE_PACKAGE_STATUSES = {"running", "review", "blocked"} +ANCHOR_HISTORY_EVENTS = { + "plan_validated", + "plan_anchor_initialized", + "plan_drift_detected", +} + + +def reconcile_task_lifecycle( + task_dir: Path, + data: dict[str, Any], + *, + root: Path | None = None, + actor: str | None = None, + reason: str = "lifecycle boundary", + force: bool = False, + initialize_legacy_anchor: bool = True, +) -> dict[str, Any]: + """Repair only state that can be derived from the current task record. + + This function never accepts evidence, disposes a package, approves a claim, + or closes a task. It is safe to call at every lifecycle boundary. + """ + repo_root = root or get_repo_root(task_dir) + if not force and not get_task_lifecycle_enabled(repo_root): + return _result(data, changed_fields=[], issues=[], enabled=False) + if not is_closure_task(data): + return _result(data, changed_fields=[], issues=[], compatibility=True) + + with task_state_lock(task_dir): + current = read_json(task_dir / FILE_TASK_JSON) + if not isinstance(current, dict): + raise ClosureError("cannot read task.json while reconciling lifecycle") + if not is_closure_task(current): + data.clear() + data.update(current) + return _result(data, changed_fields=[], issues=[], compatibility=True) + + original = deepcopy(current) + changed_fields, issues = _reconcile_in_memory( + current, + task_dir=task_dir, + initialize_legacy_anchor=initialize_legacy_anchor, + ) + if not changed_fields: + data.clear() + data.update(current) + return _result(data, changed_fields=[], issues=issues) + + old_phase = str(original.get("hermes_phase") or "planning") + new_phase = str(current.get("hermes_phase") or "planning") + save_task(task_dir, current, lock_held=True) + event_type = "lifecycle_reconciled" + if any(issue.startswith("plan anchor drift:") for issue in issues): + event_type = "plan_drift_detected" + elif any( + issue in {"legacy plan anchor initialized", "legacy plan anchor upgraded"} + for issue in issues + ): + event_type = "plan_anchor_initialized" + event_extra: dict[str, Any] = { + "changed_fields": changed_fields, + "issues": issues, + } + if event_type == "plan_anchor_initialized": + event_extra["plan_anchor"] = current.get("plan_anchor") + try: + append_event( + task_dir, + event_type, + actor=actor or get_developer(repo_root) or "hermes/lifecycle", + old_state=old_phase, + new_state=new_phase, + package_id=_package_id(current.get("current_work_package")), + reason=reason, + extra=event_extra, + lock_held=True, + ) + except ClosureError: + if not write_json_atomic(task_dir / FILE_TASK_JSON, original): + raise ClosureError( + "lifecycle event failed and task rollback also failed; manual recovery required" + ) + data.clear() + data.update(original) + raise + + data.clear() + data.update(current) + return _result(data, changed_fields=changed_fields, issues=issues) + + +def _reconcile_in_memory( + data: dict[str, Any], + *, + task_dir: Path, + initialize_legacy_anchor: bool, +) -> tuple[list[str], list[str]]: + before = deepcopy(data) + issues: list[str] = [] + packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] + package_by_id = { + str(item.get("id")): item + for item in packages + if isinstance(item.get("id"), str) and item.get("id") + } + active = [item for item in packages if item.get("status") in ACTIVE_PACKAGE_STATUSES] + current_id = _package_id(data.get("current_work_package")) + current = package_by_id.get(current_id) if current_id else None + + anchor = plan_anchor_status(data, task_dir) + phase_before_reconcile = str(data.get("hermes_phase") or "planning") + if ( + initialize_legacy_anchor + and anchor["status"] == "missing" + and phase_before_reconcile not in {"planning", "closed"} + ): + history_state = _anchor_history_state(task_dir) + if history_state == "none": + data["plan_anchor"] = create_plan_anchor( + data, + task_dir, + origin="legacy_migration", + ) + issues.append("legacy plan anchor initialized") + anchor = plan_anchor_status(data, task_dir) + else: + detail = ( + "plan anchor is missing after prior validation" + if history_state == "anchored" + else "plan anchor history is unreadable" + ) + _block_for_plan_integrity(data, current, detail) + issues.append(detail) + elif ( + initialize_legacy_anchor + and anchor.get("upgrade_required") is True + and anchor["status"] == "clean" + ): + data["plan_anchor"] = create_plan_anchor( + data, + task_dir, + origin="schema_upgrade", + ) + issues.append("legacy plan anchor upgraded") + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] in {"drifted", "invalid"} and phase_before_reconcile not in {"planning", "closed"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + blocker = f"plan anchor drift: {changed}" + if current is not None and current.get("status") not in DISPOSED_PACKAGE_STATUSES: + current["status"] = "blocked" + current["blocker"] = blocker + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + data["hermes_phase"] = "blocked" + data["status"] = "in_progress" + data["next_action"] = "Record an approved closure.py amend before continuing the task." + issues.append(blocker) + + structural_change = False + if current_id and (current is None or current.get("status") not in ACTIVE_PACKAGE_STATUSES): + data["current_work_package"] = None + current_id = None + current = None + structural_change = True + issues.append("cleared invalid current_work_package") + if current is None and len(active) == 1: + current = active[0] + current_id = str(current.get("id")) + data["current_work_package"] = current_id + structural_change = True + elif len(active) > 1: + issues.append("multiple active work packages require explicit human resolution") + + closed_markers = ( + data.get("closure_state") == "closed", + data.get("hermes_phase") == "closed", + ) + if closed_markers[0] != closed_markers[1]: + data["closure_state"] = "open" + data["hermes_phase"] = "review" + data["status"] = "in_progress" + data["current_work_package"] = None + structural_change = True + issues.append("inconsistent closed markers were reopened instead of auto-closing") + elif all(closed_markers): + if _has_task_closed_event(task_dir): + data["status"] = "completed" + data["current_work_package"] = None + else: + _block_for_plan_integrity( + data, + None, + "closed markers lack task_closed event", + phase="blocked", + next_action="Run closure.py audit and closure.py close to record the verified closure.", + blocker_prefix="closure integrity", + ) + issues.append("closed markers were reopened because task_closed event is missing") + else: + blocked_packages = [item for item in packages if item.get("status") == "blocked"] + blockers = _string_list(data.get("blockers")) + phase = str(data.get("hermes_phase") or "planning") + if blocked_packages or blockers: + expected_phase = "blocked" + elif current and current.get("status") == "review": + expected_phase = "review" + elif current and current.get("status") == "running": + expected_phase = "running" + elif packages and all( + item.get("status") in DISPOSED_PACKAGE_STATUSES for item in packages + ): + expected_phase = "review" + elif phase == "planning": + expected_phase = "planning" + elif phase == "ready": + expected_phase = "ready" + else: + expected_phase = "running" + if phase != expected_phase: + data["hermes_phase"] = expected_phase + structural_change = True + + expected_status = "planning" if data.get("hermes_phase") == "planning" else "in_progress" + if data.get("status") != expected_status: + data["status"] = expected_status + structural_change = True + + expected_action = closure_next_action(data) + current_action = data.get("next_action") + if structural_change or not isinstance(current_action, str) or not current_action.strip(): + data["next_action"] = expected_action + + changed_fields = sorted( + key for key in set(before) | set(data) if before.get(key) != data.get(key) + ) + return changed_fields, issues + + +def _result( + data: dict[str, Any], + *, + changed_fields: list[str], + issues: list[str], + enabled: bool = True, + compatibility: bool = False, +) -> dict[str, Any]: + return { + "enabled": enabled, + "compatibility": compatibility, + "changed": bool(changed_fields), + "changed_fields": changed_fields, + "issues": issues, + "hermes_revision": data.get("hermes_revision"), + "hermes_phase": data.get("hermes_phase"), + "current_work_package": data.get("current_work_package"), + "next_action": data.get("next_action"), + } + + +def _package_id(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + + +def _dedupe(values: list[str]) -> list[str]: + return list(dict.fromkeys(values)) + + +def _anchor_history_state(task_dir: Path) -> str: + """Distinguish a pre-anchor task from a lost modern anchor.""" + path = task_dir / "hermes" / "task-events.jsonl" + if not path.exists(): + return "none" + try: + events = [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + except (OSError, json.JSONDecodeError): + return "unreadable" + return ( + "anchored" + if any( + isinstance(event, dict) + and event.get("event_type") in ANCHOR_HISTORY_EVENTS + for event in events + ) + else "none" + ) + + +def _has_task_closed_event(task_dir: Path) -> bool: + path = task_dir / "hermes" / "task-events.jsonl" + try: + return any( + isinstance(event, dict) + and event.get("event_type") == "task_closed" + and event.get("new_state") == "closed" + for event in ( + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + ) + except (OSError, json.JSONDecodeError): + return False + + +def _block_for_plan_integrity( + data: dict[str, Any], + current: dict[str, Any] | None, + detail: str, + *, + phase: str = "blocked", + next_action: str = "Restore the plan record or record an approved closure.py amend before continuing.", + blocker_prefix: str = "plan anchor drift", +) -> None: + blocker = f"{blocker_prefix}: {detail}" + if current is not None and current.get("status") not in DISPOSED_PACKAGE_STATUSES: + current["status"] = "blocked" + current["blocker"] = blocker + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + data["closure_state"] = "open" + data["hermes_phase"] = phase + data["status"] = "in_progress" + data["next_action"] = next_action diff --git a/packages/cli/src/templates/trellis/scripts/common/task_store.py b/packages/cli/src/templates/trellis/scripts/common/task_store.py index 3d99541..1f719b3 100644 --- a/packages/cli/src/templates/trellis/scripts/common/task_store.py +++ b/packages/cli/src/templates/trellis/scripts/common/task_store.py @@ -18,6 +18,7 @@ import argparse import json import re +import shutil import sys from datetime import datetime from pathlib import Path @@ -53,7 +54,8 @@ resolve_task_dir, run_task_hooks, ) -from .closure import closure_defaults, is_closure_task +from .closure import ClosureError, append_event, closure_defaults, is_closure_task, plan_anchor_status +from .lifecycle import reconcile_task_lifecycle # ============================================================================= @@ -94,6 +96,10 @@ def _closure_archive_errors(task_dir: Path, data: dict[str, object]) -> list[str errors.append("task status is not completed") if data.get("current_work_package") is not None: errors.append("a work package is still current") + if data.get("plan_anchor") is not None: + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] != "clean": + errors.append(f"plan anchor is {anchor['status']}") packages = data.get("work_packages") if not isinstance(packages, list) or any( not isinstance(item, dict) or item.get("status") not in {"done", "deferred", "waived"} @@ -367,6 +373,13 @@ def cmd_create(args: argparse.Namespace) -> int: # Output relative path for script chaining print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + reconcile_task_lifecycle( + task_dir, + task_data, + root=repo_root, + actor=creator, + reason="task.py create", + ) run_task_hooks("after_create", task_json_path, repo_root) return 0 @@ -400,6 +413,10 @@ def cmd_archive(args: argparse.Namespace) -> int: dir_name = task_dir.name task_json_path = task_dir / FILE_TASK_JSON + try: + session_task_ref = task_dir.relative_to(repo_root).as_posix() + except ValueError: + session_task_ref = str(task_dir) # Update status before archiving today = datetime.now().strftime("%Y-%m-%d") @@ -410,6 +427,13 @@ def cmd_archive(args: argparse.Namespace) -> int: data = read_json(task_json_path) if data: if is_closure_task(data): + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=get_developer(repo_root) or "hermes/task", + reason="task.py archive", + ) closure_errors = _closure_archive_errors(task_dir, data) if closure_errors: print( @@ -452,10 +476,6 @@ def cmd_archive(args: argparse.Namespace) -> int: write_json(child_json, child_data) modified_children.append(child_dir_path.name) - # Clear any session that still points at this task before the path moves. - from .active_task import clear_task_from_sessions - clear_task_from_sessions(str(task_dir), repo_root) - # Archive result = archive_task_complete(task_dir, repo_root) if "archived_to" in result: @@ -463,6 +483,48 @@ def cmd_archive(args: argparse.Namespace) -> int: year_month = archive_dest.parent.name print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + archived_json = archive_dest / FILE_TASK_JSON + try: + append_event( + archive_dest, + "task_archived", + actor=get_developer(repo_root) or "hermes/task", + old_state="closed", + new_state="archived", + reason="task.py archive", + ) + except ClosureError as exc: + try: + shutil.move(str(archive_dest), str(task_dir)) + except (OSError, shutil.Error) as rollback_exc: + print( + colored( + "Error: archive event was not recorded and archive rollback failed: " + f"{rollback_exc}", + Colors.RED, + ), + file=sys.stderr, + ) + else: + print( + colored( + "Error: archive event was not recorded; task was restored to its active location: " + f"{exc}", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + + # Only clear session pointers after the task and its archive record + # have both been persisted. A failed archive leaves the active task intact. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(session_task_ref, repo_root) + # Existing session files may contain either the canonical relative + # path or the older short task name. Once the directory is archived, + # the latter can no longer resolve to its former active location. + clear_task_from_sessions(dir_name, repo_root) + # Auto-commit unless --no-commit if not getattr(args, "no_commit", False): _auto_commit_archive(dir_name, repo_root, modified_children) @@ -471,7 +533,6 @@ def cmd_archive(args: argparse.Namespace) -> int: print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON run_task_hooks("after_archive", archived_json, repo_root) return 0 diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index 68139f4..cdbe278 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -13,7 +13,8 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.closure import ClosureError, resolve_closure_task +from common.closure import ClosureError, actor_name, plan_anchor_status, resolve_closure_task +from common.lifecycle import reconcile_task_lifecycle from common.dispatch import ( DispatchError, accept_result_text, @@ -128,6 +129,14 @@ def main(argv: list[str] | None = None) -> int: try: task_dir, task, root = resolve_closure_task(args.task) + if args.command == "create" and plan_anchor_status(task, task_dir).get("upgrade_required") is True: + reconcile_task_lifecycle( + task_dir, + task, + root=root, + actor=actor_name(root), + reason="dispatch.py create plan-anchor upgrade", + ) if args.command == "create": spec = { "job_id": args.job_id, diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index c84568b..1cbe926 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -162,6 +162,26 @@ def _validate_hermes_experiment(repo_root, task_dir) -> bool: return False +def _reconcile_hermes_lifecycle(repo_root, task_dir, reason: str): + """Reconcile safe derived closure state and return the current task data.""" + task_json_path = task_dir / FILE_TASK_JSON + data = read_json(task_json_path) if task_json_path.is_file() else None + if not isinstance(data, dict): + return data + from common.closure import is_closure_task + if not is_closure_task(data): + return data + from common.lifecycle import reconcile_task_lifecycle + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=get_developer(repo_root) or "hermes/task", + reason=reason, + ) + return data + + def cmd_create(args: argparse.Namespace) -> int: """Create a task, then initialize Hermes when the local Hermes CLI exists.""" repo_root = get_repo_root() @@ -205,7 +225,7 @@ def cmd_start(args: argparse.Namespace) -> int: task_dir = str(full_path) task_json_path = full_path / FILE_TASK_JSON - task_data = read_json(task_json_path) if task_json_path.is_file() else None + task_data = _reconcile_hermes_lifecycle(repo_root, full_path, "task.py start") closure_package_id = None if isinstance(task_data, dict): @@ -302,6 +322,10 @@ def cmd_finish(args: argparse.Namespace) -> int: task_json_path = repo_root / current / FILE_TASK_JSON + task_dir = task_json_path.parent + if task_dir.is_dir(): + _reconcile_hermes_lifecycle(repo_root, task_dir, "task.py finish") + cleared = clear_active_task(repo_root) print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 959718d..3e23a44 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -81,7 +81,7 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] > 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 +**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。默认启用的内置生命周期协调器会在任务命令、每轮续接和代理返回边界幂等修复可推导状态;它不接受证据、不处置工作包、不批准主张,也不关闭任务。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 ### Lean Research Closure @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案契约部分哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;`Notes`(说明)、`Progress`(进度)和 `Experiment Log`(实验记录)可追加执行记录而不改变方案;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案正文或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts b/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts index 3aa385b..ceccfe4 100644 --- a/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts +++ b/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts @@ -34,6 +34,7 @@ vi.mock("node:child_process", () => ({ import { init } from "../../src/commands/init.js"; import { uninstall } from "../../src/commands/uninstall.js"; import { update } from "../../src/commands/update.js"; +import { VERSION } from "../../src/constants/version.js"; import { loadHashes, saveHashes } from "../../src/utils/template-hash.js"; import { agentsMdContent } from "../../src/templates/markdown/index.js"; @@ -49,6 +50,13 @@ describe("init + uninstall: manifest accuracy + homedir guard", () => { vi.spyOn(console, "log").mockImplementation(noop); vi.spyOn(console, "error").mockImplementation(noop); vi.mocked(inquirer.prompt).mockResolvedValue({ proceed: true }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: VERSION }), + }), + ); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true, @@ -58,6 +66,7 @@ describe("init + uninstall: manifest accuracy + homedir guard", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); fs.rmSync(tmpDir, { recursive: true, force: true }); delete process.env.TRELLIS_ALLOW_HOMEDIR; }); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index f03e3a5..4e89ddf 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -2991,7 +2991,7 @@ describe("regression: current-task path normalization", () => { next_action?: unknown; }; expect(data.current_phase).toBeUndefined(); - expect(data.next_action).toBeNull(); + expect(data.next_action).toBe("Set intent with closure.py plan."); }); // ------------------------------------------------------------ diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index 6b99aa1..e402e26 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -96,6 +96,7 @@ describe("Agent Context Firewall dispatch CLI", () => { } writeJson(path.join(taskDir, "task.json"), baseTask()); fs.writeFileSync(path.join(taskDir, "prd.md"), "# PRD\n", "utf-8"); + anchorTask(); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "src", "app.ts"), "export {};\n", "utf-8"); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); @@ -164,6 +165,27 @@ describe("Agent Context Firewall dispatch CLI", () => { return run(args); } + function anchorTask(): void { + const result = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor", + "path = Path('.trellis/tasks/demo/task.json')", + "task = json.loads(path.read_text(encoding='utf-8'))", + "task['plan_anchor'] = create_plan_anchor(task, path.parent)", + "path.write_text(json.dumps(task, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(result.status, result.stderr).toBe(0); + } + function apply(jobId: string, value: Record): CommandResult { return run([ "apply", @@ -250,6 +272,63 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.audit.metrics.ref_count).toBe(1); }); + it("requires a validated plan before execution dispatch", () => { + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.plan_anchor = null; + writeJson(path.join(taskDir, "task.json"), task); + + const rejected = create("job-unvalidated"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan_not_validated"); + }); + + it("rechecks the plan anchor before an existing dispatch executes", () => { + expect(create("job-anchor-removed").status).toBe(0); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.plan_anchor = null; + writeJson(path.join(taskDir, "task.json"), task); + + const rejected = run([ + "validate", "--task", "demo", "--job-id", "job-anchor-removed", + ]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan_not_validated"); + }); + + it("binds formal dispatches to the approved plan anchor", () => { + anchorTask(); + expect(create("job-anchor").status).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-anchor.dispatch.json"), + "utf-8", + ), + ) as { plan_anchor_id: string; plan_anchor_hash: string; body: string }; + expect(dispatch.plan_anchor_id).toMatch(/^pa-/); + expect(dispatch.plan_anchor_hash).toMatch(/^sha256:/); + expect(dispatch.body).toContain("plan_anchor:"); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.definition_of_done = ["A weaker replacement requirement"]; + writeJson(path.join(taskDir, "task.json"), task); + const rejected = run(["validate", "--task", "demo", "--job-id", "job-anchor"]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan anchor drift detected"); + }); + + it("rejects dispatch after a validated plan document changes", () => { + anchorTask(); + fs.appendFileSync(path.join(taskDir, "prd.md"), "\nUnapproved plan replacement.\n"); + + const rejected = create("job-document-drift"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan anchor drift detected"); + expect(rejected.stderr).toContain("plan_documents"); + expect(fs.existsSync( + path.join(taskDir, "hermes", "dispatches", "job-document-drift.dispatch.json"), + )).toBe(false); + }); + it("permits a coder configuration dispatch only for the current task handoff", () => { const result = run([ "create", @@ -474,6 +553,7 @@ describe("Agent Context Firewall dispatch CLI", () => { }, }), ); + anchorTask(); const excluded = create("job-excluded-platform", "coder", [ "--platform", "codex", @@ -503,6 +583,7 @@ describe("Agent Context Firewall dispatch CLI", () => { it("allows publication packet creation with the compact protocol", () => { writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); + anchorTask(); const result = run( [ "create", "--task", "demo", "--job-id", "job-publication", "--role", "planner", @@ -752,6 +833,7 @@ describe("Agent Context Firewall dispatch CLI", () => { ); writeJson(path.join(taskDir, "task.json"), baseTask()); + anchorTask(); expect(create("job-async").status).toBe(0); const asyncResult = runHook("inject-subagent-context.py", { cwd: root, diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 3d25b22..55184d1 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -208,6 +208,15 @@ function writeDecisionRecord( "## Evidence", "Repository evidence supports this bounded choice.", "", + "## Original Commitment", + "Preserve the validated task intent and acceptance contract.", + "", + "## Failure Evidence", + "The recorded audit gap or failed validation shows why the previous plan cannot continue unchanged.", + "", + "## Failed Attempts", + "The bounded repair was attempted before proposing this amendment.", + "", "## Alternatives", "Keep the previous protocol.", "", @@ -301,7 +310,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect( run(root, "grill", "--complete", "--decision-ref", decisionRef).status, ).toBe(0); - expect(run(root, "validate").status).toBe(0); + const revalidated = run(root, "validate"); + expect(revalidated.status, revalidated.stderr).toBe(0); const capsule = run(root, "capsule"); expect(capsule.stdout).toContain("Route: exploration"); expect(capsule.stdout).toContain("Research changes: model_architecture"); @@ -490,6 +500,306 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(capsule.stdout).toContain("Refs: .trellis/spec/guides/index.md"); }); + it("anchors a validated plan and exposes the contract in the capsule", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const anchored = readTask(taskDir) as TestTask & { + plan_anchor: { id: string; hash: string; projection: Record }; + }; + expect(anchored.plan_anchor.id).toMatch(/^pa-/); + expect(anchored.plan_anchor.projection.definition_of_done).toEqual(["Artifact exists"]); + expect(run(root, "capsule").stdout).toContain("Plan anchor:"); + expect(run(root, "capsule").stdout).toContain("Definition: Artifact exists"); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).toContain('"event_type":"plan_validated"'); + }); + + it("blocks unrecorded plan drift before package review or closure", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const mutated = readTask(taskDir); + mutated.work_packages[0].done_when = ["Only a file exists"]; + writeTask(taskDir, mutated); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("plan anchor drift detected"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).toContain('"event_type":"plan_drift_detected"'); + }); + + it("blocks direct changes to validated plan documents", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Original scope.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.writeFileSync( + prdPath, + fs.readFileSync(prdPath, "utf-8").replace("- Original scope.", "- Unapproved scope change."), + "utf-8", + ); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("plan anchor drift detected"); + expect(result.stderr).toContain("plan_documents"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + }); + + it("allows execution notes without changing the plan anchor", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Fixed scope.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.appendFileSync( + prdPath, + "\n### Bounded run\n\nObserved during execution: the first bounded run completed.\n", + ); + + const result = run(root, "package-check"); + expect(result.status, result.stderr).toBe(0); + }); + + it("blocks direct changes to the validated research contract", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const mutated = readTask(taskDir); + mutated.meta.research_contract = { dataset: "unapproved-dataset" }; + writeTask(taskDir, mutated); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("research_contract.dataset"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + }); + + it("allows implementation checklist progress without changing the plan anchor", () => { + fs.writeFileSync( + path.join(taskDir, "implement.md"), + "# Implementation\n\n- [ ] Run the bounded implementation.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.writeFileSync( + path.join(taskDir, "implement.md"), + "# Implementation\n\n- [x] Run the bounded implementation.\n", + "utf-8", + ); + + const result = run(root, "package-check"); + expect(result.status, result.stderr).toBe(0); + }); + + it("records an approved plan-document amendment and preserves anchor history", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Original acceptance.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = readTask(taskDir) as TestTask & { plan_anchor: { hash: string } }; + fs.writeFileSync( + prdPath, + fs.readFileSync(prdPath, "utf-8").replace("- Original acceptance.", "- Approved acceptance clarification."), + "utf-8", + ); + const decisionRef = writeDecisionRecord(taskDir, "document-amendment.md"); + + const denied = run( + root, + "amend", + "--field", + "plan_documents", + "--value", + "current", + "--reason", + "review evidence requires a documented clarification", + "--decision-ref", + decisionRef, + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("requires --approved-by"); + + const amended = run( + root, + "amend", + "--field", + "plan_documents", + "--value", + "current", + "--reason", + "review evidence requires a documented clarification", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ); + expect(amended.status, amended.stderr).toBe(0); + expect(run(root, "validate").status).toBe(0); + const after = readTask(taskDir) as TestTask & { + plan_anchor: { hash: string; projection: { plan_documents: unknown[] } }; + plan_change_ref?: string; + }; + expect(after.plan_anchor.hash).not.toBe(before.plan_anchor.hash); + expect(after.plan_anchor.projection.plan_documents.length).toBeGreaterThan(0); + expect(after.plan_change_ref).toBe(decisionRef); + + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const anchors = events.filter((event) => event.event_type === "plan_validated"); + expect(anchors).toHaveLength(2); + expect((anchors[0].plan_anchor as { hash: string }).hash).toBe(before.plan_anchor.hash); + expect((anchors[1].plan_anchor as { hash: string }).hash).toBe(after.plan_anchor.hash); + }); + + it("requires a decision record and human approval to change validated acceptance", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = (readTask(taskDir) as TestTask & { + plan_anchor: { hash: string }; + }).plan_anchor.hash; + + const denied = run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "strengthen acceptance after review", + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("approved plan change definition_of_done requires --approved-by"); + + const decisionRef = writeDecisionRecord(taskDir, "acceptance-amendment.md"); + expect( + run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "strengthen acceptance after review", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ).status, + ).toBe(0); + const acceptanceValidated = run(root, "validate"); + expect(acceptanceValidated.status, acceptanceValidated.stderr).toBe(0); + const after = readTask(taskDir) as TestTask & { plan_anchor: { hash: string } }; + expect(after.plan_anchor.hash).not.toBe(before); + }); + + it("rejects unrelated changes added after an approved amendment", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const decisionRef = writeDecisionRecord(taskDir, "acceptance-projection.md"); + expect( + run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "approved acceptance refinement", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ).status, + ).toBe(0); + + const tampered = readTask(taskDir) as TestTask & { in_scope?: string[] }; + tampered.in_scope = ["unapproved extra scope"]; + writeTask(taskDir, tampered); + const validation = run(root, "validate"); + expect(validation.status).toBe(1); + expect(validation.stderr).toContain("unapproved changes after plan amendment"); + expect(validation.stderr).toContain("in_scope"); + }); + + it("requires failure context before an approved validated plan change", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const decisionRef = "incomplete-amendment.md"; + fs.writeFileSync( + path.join(taskDir, decisionRef), + [ + "# Research Decision", + "", + "## Decision", + "Tighten the acceptance condition.", + "", + "## Rationale", + "Review found an untested behavior.", + "", + "## Evidence", + "The review report identifies the missing test.", + "", + "## Alternatives", + "Leave the acceptance condition unchanged.", + "", + "## Failure Conditions", + "The added validation still does not cover the required behavior.", + "", + ].join("\n"), + "utf-8", + ); + + const denied = run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "review exposed an acceptance gap", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("Original Commitment"); + expect(denied.stderr).toContain("Failure Evidence"); + expect(denied.stderr).toContain("Failed Attempts"); + expect(readTask(taskDir).definition_of_done).toEqual(["Artifact exists"]); + }); + it("keeps command and file microsteps inside done_when", () => { expect( plan(root, [ @@ -868,6 +1178,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "one reviewed retry", ).status, ).toBe(1); + const decisionRef = writeDecisionRecord(taskDir, "repair-limit-amendment.md"); expect( run( root, @@ -880,6 +1191,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "one reviewed retry", "--approved-by", "human/root", + "--decision-ref", + decisionRef, ).status, ).toBe(0); const task = readTask(taskDir); @@ -891,6 +1204,26 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(run(root, "validate").status).toBe(0); }); + it("requires a recorded decision for closure mode changes", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const denied = run( + root, + "amend", + "--field", + "closure_mode", + "--value", + "standard", + "--reason", + "formal validation is now required", + "--approved-by", + "human/root", + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("requires --decision-ref"); + expect(readTask(taskDir)).toMatchObject({ closure_mode: "lean" }); + }); + it("requires explicit human approval for high-risk amendments", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -908,6 +1241,38 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).meta.research_contract?.dataset).toBeUndefined(); expect(readTask(taskDir).hermes_phase).toBe("blocked"); + const pendingStatus = run(root, "status", "--json"); + expect(pendingStatus.status).toBe(0); + const pending = JSON.parse(pendingStatus.stdout).pending_approvals; + expect(pending).toEqual([ + expect.objectContaining({ + field: "dataset", + task_revision: readTask(taskDir).hermes_revision, + requires_decision_record: true, + requires_human_approval: true, + }), + ]); + expect(run(root, "capsule").stdout).toContain("Pending approval: dataset"); + const amendmentEvent = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(amendmentEvent).toContain('"decision_requirement":"required"'); + const legacyEvents = amendmentEvent.trim().split("\n").map((line) => JSON.parse(line)); + delete legacyEvents.at(-1).decision_requirement; + fs.writeFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + `${legacyEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + ); + expect(JSON.parse(run(root, "status", "--json").stdout).pending_approvals).toEqual([ + expect.objectContaining({ + requires_decision_record: true, + requires_human_approval: true, + }), + ]); + + const amendmentRef = writeDecisionRecord(taskDir, "dataset-amendment.md"); + const approved = run( root, "amend", @@ -919,6 +1284,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "approved source", "--approved-by", "human/root", + "--decision-ref", + amendmentRef, ); expect(approved.status).toBe(0); const task = readTask(taskDir); @@ -928,12 +1295,107 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.research_route).toBe("exploration"); expect(task.research_change_fields).toEqual(["dataset"]); expect(task.grill_completed).toBe(false); + expect(JSON.parse(run(root, "status", "--json").stdout).pending_approvals).toEqual([]); expect(run(root, "validate").status).toBe(1); const decisionRef = writeDecisionRecord(taskDir); - expect( - run(root, "grill", "--complete", "--decision-ref", decisionRef).status, - ).toBe(0); + const grilled = run(root, "grill", "--complete", "--decision-ref", decisionRef); + expect(grilled.status, grilled.stderr).toBe(0); + expect(run(root, "validate").status).toBe(0); + }); + + it("shows a compact timeline without raw dispatch audit payloads", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatch_audit.jsonl"), + `${JSON.stringify({ + type: "dispatch_audit", + timestamp: "2026-07-25T12:00:00+00:00", + job_id: "job-7", + event: "result_confirmed", + decision: "RAW_DECISION_MUST_NOT_APPEAR\n+++ a/dispatch.log", + metrics: { raw_log: "DISPATCH_LOG_MUST_NOT_APPEAR" }, + })}\n`, + ); + const result = run(root, "timeline", "--json", "--limit", "2"); + expect(result.status, result.stderr).toBe(0); + const timeline = JSON.parse(result.stdout); + expect(timeline.entries).toHaveLength(2); + expect(timeline.entries).toContainEqual(expect.objectContaining({ + source: "dispatch", + event: "result_confirmed", + job_id: "job-7", + summary: "result confirmed", + })); + expect(result.stdout).not.toContain("DISPATCH_LOG_MUST_NOT_APPEAR"); + expect(result.stdout).not.toContain("RAW_DECISION_MUST_NOT_APPEAR"); + expect(result.stdout.length).toBeLessThan(1600); + }); + + it("does not expose amendment reasons or accept an invalid timeline limit", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const beforeEvents = fs.readFileSync(eventPath, "utf-8"); + const rejected = run(root, "timeline", "--limit", "0"); + expect(rejected.status).toBe(2); + expect(readTask(taskDir)).toEqual(before); + expect(fs.readFileSync(eventPath, "utf-8")).toBe(beforeEvents); + + fs.appendFileSync( + eventPath, + `${JSON.stringify({ + event_type: "plan_amended", + timestamp: "2026-07-25T12:01:00+00:00", + field: "dataset", + reason: "RAW_DIFF_MUST_NOT_APPEAR\n+++ a/secret.log\nFULL_LOG_MUST_NOT_APPEAR", + })}\n`, + ); + const timeline = run(root, "timeline", "--json"); + expect(timeline.status, timeline.stderr).toBe(0); + expect(timeline.stdout).toContain("plan amendment recorded: dataset"); + expect(timeline.stdout).not.toContain("RAW_DIFF_MUST_NOT_APPEAR"); + expect(timeline.stdout).not.toContain("FULL_LOG_MUST_NOT_APPEAR"); + }); + + it("keeps a valid timeline read-only when lifecycle state is repairable", () => { + expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); + const inconsistent = readTask(taskDir); + inconsistent.current_work_package = "missing-package"; + writeTask(taskDir, inconsistent); + const beforeTask = fs.readFileSync(path.join(taskDir, "task.json"), "utf-8"); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const beforeEvents = fs.readFileSync(eventPath, "utf-8"); + + const result = run(root, "timeline", "--json"); + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")).toBe(beforeTask); + expect(fs.readFileSync(eventPath, "utf-8")).toBe(beforeEvents); + }); + + it("bounds timeline output to recent records from long event history", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const history = Array.from({ length: 160 }, (_, index) => { + const hour = String(Math.floor(index / 60)).padStart(2, "0"); + const minute = String(index % 60).padStart(2, "0"); + return JSON.stringify({ + event_type: "phase_changed", + timestamp: `2026-07-25T${hour}:${minute}:00+00:00`, + package_id: `WP${index}`, + reason: `old event ${index}`, + }); + }); + fs.writeFileSync(eventPath, `${history.join("\n")}\n`); + + const result = run(root, "timeline", "--json", "--limit", "3"); + expect(result.status, result.stderr).toBe(0); + const timeline = JSON.parse(result.stdout); + expect(timeline.entries).toHaveLength(3); + expect(timeline.entries.map((entry: { package_id: string }) => entry.package_id)) + .toEqual(["WP157", "WP158", "WP159"]); }); it("preserves the completed decision until a research amendment is applied", () => { @@ -989,6 +1451,10 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { grill_completed: true, }); + const restored = readTask(taskDir); + restored.meta.research_contract = {}; + writeTask(taskDir, restored); + const approved = run( root, "amend", @@ -1000,6 +1466,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "approved split change", "--approved-by", "human/root", + "--decision-ref", + writeDecisionRecord(taskDir, "split-amendment.md"), ); expect(approved.status).toBe(0); expect(readTask(taskDir)).toMatchObject({ @@ -1008,6 +1476,23 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); }); + it("keeps a drift-blocked package unchanged when repair is requested", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const drifted = readTask(taskDir); + drifted.work_packages[0].done_when = ["A weaker result exists"]; + writeTask(taskDir, drifted); + expect(run(root, "package-check").status).toBe(1); + const beforeRepair = structuredClone(readTask(taskDir).work_packages[0]); + + const repair = run(root, "repair"); + expect(repair.status).toBe(1); + const afterRepair = readTask(taskDir); + expect(afterRepair.hermes_phase).toBe("blocked"); + expect(afterRepair.work_packages[0]).toEqual(beforeRepair); + }); + it("treats functional model architecture changes as high risk", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -1042,6 +1527,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "new model behavior", "--approved-by", "human/root", + "--decision-ref", + writeDecisionRecord(taskDir, "architecture-amendment.md"), ); expect(result.status).toBe(1); expect(result.stderr).toContain("independent exploration task"); @@ -1098,6 +1585,54 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { path.join(root, ".trellis", "tasks", "archive", archiveMonth, "demo"), ), ).toBe(true); + const archivedEvents = fs.readFileSync( + path.join( + root, + ".trellis", + "tasks", + "archive", + archiveMonth, + "demo", + "hermes", + "task-events.jsonl", + ), + "utf-8", + ); + expect(archivedEvents).toContain('"event_type":"task_archived"'); + }); + + it("restores a task when its archive event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "archive-event-failure"); + expect(run(root, "close").status).toBe(0); + fs.chmodSync(path.join(taskDir, "hermes"), 0o500); + + const archive = runTask(root, "archive", "demo", "--no-commit"); + fs.chmodSync(path.join(taskDir, "hermes"), 0o700); + expect(archive.status).toBe(1); + expect(archive.stderr).toContain("task was restored to its active location"); + expect(fs.existsSync(taskDir)).toBe(true); + const archiveMonth = new Date().toISOString().slice(0, 7); + expect( + fs.existsSync( + path.join(root, ".trellis", "tasks", "archive", archiveMonth, "demo"), + ), + ).toBe(false); + }); + + it("refuses archive when a closed task no longer matches its plan anchor", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "anchored-close"); + expect(run(root, "close").status).toBe(0); + const changed = readTask(taskDir); + changed.definition_of_done = ["A weaker post-close requirement"]; + writeTask(taskDir, changed); + + const archive = runTask(root, "archive", "demo", "--no-commit"); + expect(archive.status).toBe(1); + expect(archive.stderr).toContain("plan anchor is drifted"); }); it("refuses archive when closure fields are forged without a close event", () => { @@ -1120,6 +1655,41 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); + it("rolls back validation when its semantic event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + const beforeValidation = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + fs.mkdirSync(eventPath, { recursive: true }); + + const result = run(root, "validate"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("failed to read task event log"); + expect(readTask(taskDir)).toEqual(beforeValidation); + }); + + it("rolls back an amendment when its semantic event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const beforeAmendment = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + fs.rmSync(eventPath); + fs.mkdirSync(eventPath); + + const result = run( + root, + "amend", + "--field", + "work_packages.WP1.title", + "--value", + "Renamed result package", + "--reason", + "clarify the package outcome", + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("failed to read task event log"); + expect(readTask(taskDir)).toEqual(beforeAmendment); + }); + it("rolls back task state when recording the close event fails", () => { expect(plan(root, ["Lean result verified"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); diff --git a/packages/cli/test/scripts/lifecycle.integration.test.ts b/packages/cli/test/scripts/lifecycle.integration.test.ts new file mode 100644 index 0000000..3fa01b8 --- /dev/null +++ b/packages/cli/test/scripts/lifecycle.integration.test.ts @@ -0,0 +1,320 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; +const TEMPLATE_SCRIPTS = path.resolve( + import.meta.dirname, + "../../src/templates/trellis/scripts", +); + +function hasPython(): boolean { + try { + execFileSync(PYTHON, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function task(overrides: Record = {}) { + return { + id: "demo", + title: "Lifecycle demo", + status: "in_progress", + hermes_phase: "running", + closure_state: "open", + closure_mode: "lean", + intent: "Produce a verified result", + in_scope: ["demo"], + out_of_scope: [], + definition_of_done: ["Result is verified"], + work_packages: [ + { + id: "WP1", + title: "Verified result", + outcome: "A verified result exists", + done_when: ["Result is verified"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }, + ], + current_work_package: "WP1", + next_action: "Complete WP1 outcome, then run closure.py package-check.", + blockers: [], + repair_count: 0, + max_repair_count: 1, + hermes_revision: 0, + ...overrides, + }; +} + +describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { + let root: string; + let taskDir: string; + let taskJson: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-lifecycle-")); + taskDir = path.join(root, ".trellis", "tasks", "demo"); + taskJson = path.join(taskDir, "task.json"); + fs.mkdirSync(path.join(root, ".trellis", "scripts"), { recursive: true }); + fs.cpSync(TEMPLATE_SCRIPTS, path.join(root, ".trellis", "scripts"), { + recursive: true, + }); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync(path.join(root, ".trellis", ".developer"), "name=tester\n"); + fs.writeFileSync( + path.join(root, ".trellis", "config.yaml"), + "task_lifecycle:\n enabled: true\nsession_auto_commit: false\n", + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeTask(value: Record): void { + fs.writeFileSync(taskJson, `${JSON.stringify(value, null, 2)}\n`); + } + + function readTask(): ReturnType { + return JSON.parse( + fs.readFileSync(taskJson, "utf-8"), + ) as ReturnType; + } + + function reconcile(env: Record = {}) { + return spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.io import read_json", + "from common.lifecycle import reconcile_task_lifecycle", + "task_dir = Path('.trellis/tasks/demo')", + "data = read_json(task_dir / 'task.json')", + "result = reconcile_task_lifecycle(task_dir, data, root=Path('.').resolve(), actor='test', reason='test boundary')", + "print(json.dumps(result))", + ].join("; "), + ], + { + cwd: root, + encoding: "utf-8", + env: { ...process.env, ...env }, + }, + ); + } + + it("repairs derived state once and remains idempotent", () => { + writeTask(task({ + status: "completed", + current_work_package: null, + next_action: null, + })); + + const first = reconcile(); + expect(first.status, first.stderr).toBe(0); + const repaired = readTask(); + expect(repaired.status).toBe("in_progress"); + expect(repaired.current_work_package).toBe("WP1"); + expect(repaired.next_action).toContain("package-check"); + expect( + (repaired as typeof repaired & { plan_anchor?: { origin?: string } }).plan_anchor, + ).toMatchObject({ origin: "legacy_migration" }); + expect(repaired.hermes_revision).toBe(1); + + const second = reconcile(); + expect(second.status, second.stderr).toBe(0); + expect(readTask().hermes_revision).toBe(1); + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n"); + expect(events).toHaveLength(1); + expect(events[0]).toContain('"event_type":"plan_anchor_initialized"'); + }); + + it("is enabled by default and can be explicitly disabled", () => { + writeTask(task({ status: "completed", current_work_package: null })); + fs.writeFileSync( + path.join(root, ".trellis", "config.yaml"), + "session_auto_commit: false\n", + ); + expect(reconcile().status).toBe(0); + expect(readTask().status).toBe("in_progress"); + + writeTask(task({ + status: "completed", + current_work_package: null, + hermes_revision: 0, + })); + expect(reconcile({ TRELLIS_TASK_LIFECYCLE: "0" }).status).toBe(0); + const disabled = readTask(); + expect(disabled.status).toBe("completed"); + expect(disabled.hermes_revision).toBe(0); + }); + + it("upgrades a clean legacy plan anchor once and records the full snapshot", () => { + writeTask(task()); + const legacy = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor, _plan_anchor_hash", + "path = Path('.trellis/tasks/demo/task.json')", + "data = json.loads(path.read_text(encoding='utf-8'))", + "anchor = create_plan_anchor(data)", + "anchor['schema'] = 'hermes-plan-anchor/v1'", + "anchor['projection'].pop('plan_documents', None)", + "anchor['projection'].pop('plan_change_ref', None)", + "anchor['hash'] = _plan_anchor_hash(anchor['projection'])", + "data['plan_anchor'] = anchor", + "path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(legacy.status, legacy.stderr).toBe(0); + + expect(reconcile().status).toBe(0); + const upgraded = readTask() as ReturnType & { + plan_anchor: { schema: string; origin: string; projection: { plan_documents: unknown[] } }; + }; + expect(upgraded.plan_anchor.schema).toBe("hermes-plan-anchor/v3"); + expect(upgraded.plan_anchor.origin).toBe("schema_upgrade"); + expect(upgraded.plan_anchor.projection.plan_documents).toBeDefined(); + expect(upgraded.hermes_revision).toBe(1); + const event = JSON.parse( + fs.readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8").trim(), + ) as { event_type: string; plan_anchor?: { schema?: string } }; + expect(event.event_type).toBe("plan_anchor_initialized"); + expect(event.plan_anchor?.schema).toBe("hermes-plan-anchor/v3"); + + expect(reconcile().status).toBe(0); + expect(readTask().hermes_revision).toBe(1); + }); + + it("upgrades a clean v2 full-document anchor without treating notes as drift", () => { + writeTask(task()); + fs.writeFileSync( + path.join(taskDir, "prd.md"), + "# PRD\n\n## Requirements\n\n- Fixed scope.\n\n## Notes\n\n- Execution detail.\n", + "utf-8", + ); + const legacy = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor, _plan_anchor_hash, _plan_document_hashes", + "path = Path('.trellis/tasks/demo/task.json')", + "data = json.loads(path.read_text(encoding='utf-8'))", + "anchor = create_plan_anchor(data, path.parent)", + "anchor['schema'] = 'hermes-plan-anchor/v2'", + "anchor['projection']['plan_documents'] = _plan_document_hashes(path.parent, legacy_full_text=True)", + "anchor['hash'] = _plan_anchor_hash(anchor['projection'])", + "data['plan_anchor'] = anchor", + "path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(legacy.status, legacy.stderr).toBe(0); + + expect(reconcile().status).toBe(0); + const upgraded = readTask() as ReturnType & { + plan_anchor: { schema: string; origin: string }; + }; + expect(upgraded.plan_anchor).toMatchObject({ + schema: "hermes-plan-anchor/v3", + origin: "schema_upgrade", + }); + }); + + it("never accepts evidence, disposes a package, or closes a task", () => { + writeTask(task({ + hermes_phase: "review", + current_work_package: "WP1", + work_packages: [ + { + ...task().work_packages[0], + status: "review", + evidence_refs: ["artifacts/result.json"], + confirmed_dispatches: ["job-1"], + }, + ], + })); + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.work_packages[0].status).toBe("review"); + expect(current.closure_state).toBe("open"); + expect(current.status).toBe("in_progress"); + }); + + it("does not recreate a plan anchor that disappeared after validation", () => { + writeTask(task()); + expect(reconcile().status).toBe(0); + const anchored = readTask() as ReturnType & { + plan_anchor?: unknown; + }; + delete anchored.plan_anchor; + writeTask(anchored); + + expect(reconcile().status).toBe(0); + const current = readTask() as ReturnType & { + plan_anchor?: unknown; + }; + expect(current.plan_anchor).toBeUndefined(); + expect(current.hermes_phase).toBe("blocked"); + expect(current.status).toBe("in_progress"); + expect(current.blockers.join(" ")).toContain("missing after prior validation"); + }); + + it("reopens inconsistent close markers instead of completing the task", () => { + writeTask(task({ + status: "completed", + closure_state: "closed", + hermes_phase: "review", + current_work_package: null, + work_packages: [{ ...task().work_packages[0], status: "done" }], + })); + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.closure_state).toBe("open"); + expect(current.hermes_phase).toBe("review"); + expect(current.status).toBe("in_progress"); + }); + + it("does not accept matching close markers without a close event", () => { + writeTask(task({ + status: "completed", + closure_state: "closed", + hermes_phase: "closed", + current_work_package: null, + work_packages: [{ ...task().work_packages[0], status: "done" }], + })); + + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.closure_state).toBe("open"); + expect(current.hermes_phase).toBe("blocked"); + expect(current.status).toBe("in_progress"); + expect(current.blockers.join(" ")).toContain("task_closed event"); + }); +}); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 31c3439..7fdaf06 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -109,6 +109,29 @@ function setupMainRepo( writeTaskArtifacts(repoRoot, taskName, prd, implementPlan); } +function anchorClosureTask(repoRoot: string, taskName: string): void { + const result = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor", + `path = Path('.trellis/tasks/${taskName}/task.json')`, + "task = json.loads(path.read_text(encoding='utf-8'))", + "task['plan_anchor'] = create_plan_anchor(task, path.parent)", + "path.write_text(json.dumps(task, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: repoRoot, encoding: "utf-8" }, + ); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || "plan anchor creation failed"); + } +} + function setSessionActiveTask( repoRoot: string, taskName: string, @@ -813,7 +836,8 @@ describe("shared-hooks capability table", () => { /\n([\s\S]*?)\n<\/task-resume>/.exec( longAction.hookSpecificOutput.additionalContext, )?.[1] ?? ""; - expect(resume).toContain("PACKAGE_HEAD"); + expect(resume).toContain("current package=WP1"); + expect(resume).not.toContain("PACKAGE_HEAD"); expect(resume).not.toContain("PACKAGE_TAIL"); expect(resume.length).toBeLessThanOrEqual(800); } finally { @@ -1224,6 +1248,7 @@ describe.skipIf(!hasPython())( for (const name of ["direct.md", "second.md", "third.md", "fourth.md"]) { fs.writeFileSync(path.join(repoRoot, "docs", name), name, "utf-8"); } + anchorClosureTask(repoRoot, taskName); const created = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "review-source", "--role", "researcher", "--profile", "codebase", @@ -1282,6 +1307,7 @@ describe.skipIf(!hasPython())( current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + anchorClosureTask(repoRoot, taskName); const source = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "legacy-source", "--role", "researcher", "--profile", "codebase", @@ -1615,6 +1641,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + anchorClosureTask(repoRoot, taskName); setSessionActiveTask(repoRoot, taskName); }); @@ -1857,6 +1884,20 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { task.hermes_phase = "closed"; task.status = "completed"; fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + const eventPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "task-events.jsonl", + ); + fs.mkdirSync(path.dirname(eventPath), { recursive: true }); + fs.appendFileSync( + eventPath, + `${JSON.stringify({ event_type: "task_closed", new_state: "closed" })}\n`, + "utf-8", + ); } it("blocks Stop when an active task has no Hermes worker records", () => { diff --git a/packages/core/README.md b/packages/core/README.md index e60c4f5..38163d6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` diff --git a/packages/core/package.json b/packages/core/package.json index 3709f51..20f0fa8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis-core", - "version": "0.7.1-beta.1", + "version": "0.7.1-beta.2", "description": "Research Trellis core SDK for channels, tasks, memory, and auditable research records", "type": "module", "main": "./dist/index.js",