diff --git a/README.md b/README.md index 4eb57ad..a7bc740 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.6.0-beta.31 +npm install -g research-trellis@0.7.0-beta.0 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.6.0-beta.31 +npm install -g research-trellis@0.7.0-beta.0 research-trellis --version ``` @@ -148,10 +148,12 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update → 初始化或更新 Research Trellis → 用自然语言提出一个小任务 → 让 AI 创建任务并补齐 planning +→ 用 closure plan/validate 生成 1–4 个结果型工作包 → 检查 .trellis/tasks/ 下的 prd/design/implement 产物 → 派发一个低风险实现或检查子任务 → 查看 Hermes worker records、报告和门禁结果 → 运行项目自己的测试 +→ 执行 closure audit/close,确认不能提前结束 → 决定保留、回滚或继续调整 ``` @@ -187,7 +189,15 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update 如果配置指向普通目录、无关仓库或不可识别的 worktree,流程会拒绝继续。 -### 5. 预检不是形式检查 +### 5. Lean Research Closure 防止提前结束 + +新任务默认使用一个 Trellis task,并在内部安排 1–4 个结果型工作包。`task.json` 保存当前状态,`hermes/task-events.jsonl` 追加重要变化;每轮 hook 优先注入紧凑 Task Capsule,减少完整 PRD、历史报告和全部规范的重复加载。 + +只有 `closure.py audit`(收口审计)通过后,`closure.py close`(关闭任务)才会写入 `completed`(已完成)。`lean`(轻量)、`standard`(标准)和 `publication`(发表)按需增加证据门禁;有限 repair(修复)不会扩大任务范围或改写已完成工作包。 + +详见[中文手册:Lean Research Closure](docs/manual/17-lean-research-closure.md)。 + +### 6. 预检不是形式检查 `hermes:preflight`(Hermes 预检)会检查模板文件、Python 编译、Hermes hook、门禁文档、沙箱配置、模板测试、类型检查和构建。 @@ -208,6 +218,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update - Hermes 实验记录; - 子代理记录门禁; - planning 产物约束; +- Lean Research Closure、Task Capsule 和有限修复; - 共享 worktree 检查; - 发布前预检。 diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index f38178d..1bda518 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,13 +11,13 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.6.0-beta.31`(测试版)。 +- 接受当前版本为 `0.7.0-beta.0`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 1. 把它理解为叠加在现有项目和人工智能工具上的本地工作流,而不是新的编辑器或网页界面。 -2. 区分两套约定:普通任务使用 `planning`(规划中)、`in_progress`(进行中)等实际状态;`planning -> running -> review -> claim_ready -> approved`(规划、运行、复核、待审批、已审批)是科研流程协议模板,当前没有通用运行时强制每一次迁移。 +2. 区分三层状态:原版任务仍使用 `planning / in_progress / completed`(规划、进行中、已完成);v0.7 closure 使用 `planning / ready / running / review / blocked / closed`(规划、就绪、运行、复核、阻塞、关闭);`claim_ready / approved`(主张待审批、已批准)仍属于科研证据和人工审批协议,不能由任务关闭代替。 3. 按以下边界决定是否采用: | 状态 | 当前含义 | @@ -30,6 +30,7 @@ - `.trellis/spec/`(项目规范目录)保存真实项目约定; - `.trellis/tasks/`(任务目录)保存需求、设计、实施计划和上下文清单; +- `task.json`(任务状态)和 `task-events.jsonl`(任务事件)保存当前 closure 状态与追加式历史; - 每个任务下的 `hermes/`(科研记录目录)保存实验配置、运行清单、证据、主张、比较和报告; - 平台目录保存技能、命令、代理和钩子模板。 @@ -44,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 5c41a14..aac99e7 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.6.0-beta.31 +npm install --global research-trellis@0.7.0-beta.0 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.6.0-beta.31 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.0-beta.0 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.6.0-beta.31 +npm install --global research-trellis@0.7.0-beta.0 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.6.0-beta.31`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.0-beta.0`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.6.0-beta.31`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.0-beta.0`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index 4e6d62d..4d293f5 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -2,7 +2,7 @@ ## 目标 -在一个可回滚项目中完成初始化、创建科研任务、填写实验配置并进入执行阶段。 +在一个可回滚项目中完成初始化、适度规划、执行、审计和关闭。 ## 适用范围 @@ -43,9 +43,15 @@ printf '%s\n' "$TASK" - `.trellis/tasks/$TASK/prd.md`(任务需求); - `.trellis/tasks/$TASK/hermes/experiment.yaml`(实验配置)。 -实验配置至少要给出问题、假设、数据、被测对象、指标、随机种子、环境、允许命令和产物目录。完成后校验: +实验配置至少要给出问题、假设、数据、被测对象、指标、随机种子、环境、允许命令和产物目录。然后把任务目标、范围和完成条件写入 closure(收口计划)。这个简单任务只需要一个工作包: ```bash +python3 ./.trellis/scripts/closure.py plan --task "$TASK" \ + --intent "在固定样例上得到可重复的两种清洗方法比较结果" \ + --in-scope "固定本地样例" \ + --out-of-scope "论文级泛化结论" \ + --done-when "两种方法均运行成功且比较结果经过检查" +python3 ./.trellis/scripts/closure.py validate --task "$TASK" python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" python3 ./.trellis/scripts/task.py validate "$TASK" ``` @@ -56,27 +62,40 @@ python3 ./.trellis/scripts/task.py validate "$TASK" export TRELLIS_CONTEXT_ID=manual-first-task python3 ./.trellis/scripts/task.py start "$TASK" python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" ``` -6. 在已配置的平台中用自然语言要求代理按 `Hermes`(科研工作流)执行该任务,并先审阅任务卡、允许文件和证据标准。任务进入运行后,继续阅读[实验配置](09-experiments.md)和[运行与复查](10-runs-and-replay.md)。 +6. 在已配置的平台中要求代理只处理 capsule(紧凑上下文)里的当前工作包。实际检查通过后登记证据并关闭: + +```bash +python3 ./.trellis/scripts/closure.py package-check --task "$TASK" +python3 ./.trellis/scripts/closure.py package-done --task "$TASK" --evidence "比较测试与结果文件" +python3 ./.trellis/scripts/closure.py audit --task "$TASK" +python3 ./.trellis/scripts/closure.py close --task "$TASK" +``` + +任务进入运行后,继续阅读[实验配置](09-experiments.md)、[运行与复查](10-runs-and-replay.md)和[Lean Research Closure](17-lean-research-closure.md)。 ## 预期结果 - 任务目录包含 `task.json`(任务元数据)、`prd.md`(需求)、`implement.jsonl`(实现上下文)、`check.jsonl`(检查上下文)以及 `hermes/`(科研记录目录)。 - 实验校验输出 `valid`(有效)。 -- 启动后任务状态从 `planning`(规划中)变为 `in_progress`(进行中),当前任务命令显示会话来源。 +- 启动后原版状态从 `planning`(规划中)变为 `in_progress`(进行中),当前工作包变为 `running`(运行中)。 +- 关闭后 `closure_state`(收口状态)为 `closed`(已关闭),原版状态才变为 `completed`(已完成)。 ## 失败恢复 - 初始化中断:修正报错后再次运行同一命令;已存在文件默认不会因 `-y`(自动确认)而被覆盖。 - 实验校验失败:按报错补齐字段;`task.py start`(启动任务)也会先校验实验并在失败时停止。 +- 启动提示 closure 未准备:先运行 `closure.py plan`(规划)和 `closure.py validate`(校验)。 +- 关闭提示缺口:按 `audit`(审计)给出的 package、missing 和 action 处理,不要直接修改完成状态。 - 缺少会话身份:在人工智能会话中重试,或设置唯一的 `TRELLIS_CONTEXT_ID`(会话标识)。 - 生成内容不适合项目:先查看 `git diff`(文件差异),再用项目原有回滚方式恢复;不要立即运行卸载。 ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 f68f489..f676ec5 100644 --- a/docs/manual/04-how-it-works.md +++ b/docs/manual/04-how-it-works.md @@ -19,8 +19,8 @@ 1. `CLI`(命令行界面)层:`research-trellis init`(初始化)复制模板,`research-trellis update`(更新)比较模板哈希,`workflow`(工作流)切换流程文件。 2. 项目层:`.trellis/spec/`(项目规范)和 `.trellis/workflow.md`(工作流说明)保存长期规则。 -3. 任务层:`.trellis/tasks//`(任务目录)保存需求、设计、实施计划和代理上下文清单。 -4. 平台层:技能、代理和钩子读取当前会话身份、活动任务及上下文清单,再把信息交给人工智能工具。 +3. 任务层:`.trellis/tasks//`(任务目录)以 `task.json`(任务状态)保存当前 closure 状态,并保存需求、设计、实施计划和代理上下文清单。 +4. 平台层:技能、代理和钩子优先读取 Task Capsule(紧凑上下文)及当前工作包,再按需读取完整材料。 5. `Hermes`(科研工作流)层:任务内 `hermes/`(科研记录目录)保存追加式记录、实验配置、运行清单、比较和报告。 实际流程可以简化为: @@ -28,10 +28,12 @@ ```text 初始化项目 -> 创建任务并写 planning 产物 - -> 启动任务,状态变为 in_progress - -> 平台读取活动任务和上下文 + -> plan/validate 生成并校验 1–4 个工作包 + -> 启动任务,状态变为 in_progress,首个工作包变为 running + -> 平台每轮读取 capsule 和当前工作包 -> 工作代理按 task_card 边界执行 -> runner 写运行清单,reviewer 检查记录 + -> package done 后执行 closure audit/close -> 证据支持 claim_ready 主张 -> human/root 记录批准后才算 approved ``` @@ -39,11 +41,13 @@ 需要特别区分: - `task.py finish`(结束当前任务)只清除当前会话的活动任务指针,不把任务标记为完成; -- `task.py archive`(归档任务)才会把状态写为 `completed`(已完成)并移动目录; +- `closure.py close`(关闭任务)通过审计后才把新任务状态写为 `completed`(已完成); +- `task.py archive`(归档任务)只移动已关闭的新任务;旧任务没有 closure 字段时保留原版兼容行为; +- `run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)互不等同; - `Hermes`(科研工作流)的 `approved`(已批准)是主张审批状态,不等同于任务归档; - 聊天内容不是科研事实来源,任务目录中的记录才是后续门禁读取对象。 -`.trellis/hermes/state_machine.yaml`(科研状态协议)是角色和记录应遵循的协议模板,不是已接入所有命令的状态机引擎。当前实现只在具体校验器中检查部分边界,例如待审批主张必须引用证据,已审批需要真实人工记录。 +`.trellis/hermes/state_machine.yaml`(科研状态协议)仍是角色和科研记录协议,不是通用 claim 状态机引擎。v0.7 只把 task closure(任务收口)的紧凑阶段接入 CLI;claim 审批继续由科研记录和人工门禁处理。 ## 预期结果 @@ -58,7 +62,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 034eab0..0f8c61b 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -79,7 +79,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 6a5e675..2cf3675 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -2,7 +2,7 @@ ## 目标 -用任务目录保存需求和计划,并正确区分创建、启动、结束当前会话和归档。 +用任务目录保存需求和计划,通过 1–4 个工作包适度拆解,并正确区分运行、工作包完成、任务关闭和归档。 ## 适用范围 @@ -28,47 +28,58 @@ python3 ./.trellis/scripts/task.py list - 轻量任务可以只有 `prd.md`(需求文档); - 复杂任务应在启动前补齐 `prd.md`(需求文档)、`design.md`(设计文档)和 `implement.md`(实施计划); - 需要工作代理读取额外规范或研究材料时,整理 `implement.jsonl`(实现上下文)和 `check.jsonl`(检查上下文)。 +- 默认只保留一个 task(任务),内部使用 1–4 个 work packages(工作包);读取文件、修改文件和运行命令不是独立工作包。 +- 只有独立产物、独立验证、独立执行环境、可并行或独立审查等条件中至少满足两项时,才建立独立工作包或真正的 subtask(子任务)。 -3. 检查上下文并启动: +3. 生成候选工作包并校验: ```bash TASK="$(find .trellis/tasks -maxdepth 1 -type d -name '*-fixed-eval' -printf '%f\n' | sort | tail -n 1)" +python3 ./.trellis/scripts/closure.py plan --task "$TASK" \ + --intent "在固定数据切分上得到经过验证的评估结果" \ + --in-scope "固定数据与固定指标" \ + --out-of-scope "论文级主张" \ + --done-when "评估运行成功并记录基础验证证据" +python3 ./.trellis/scripts/closure.py validate --task "$TASK" python3 ./.trellis/scripts/task.py validate "$TASK" python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" python3 ./.trellis/scripts/task.py start "$TASK" ``` -4. 查看当前任务和来源: +4. 查看当前任务、来源和紧凑上下文: ```bash python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" ``` -5. 工作结束后的两个动作含义不同: +5. 工作结束后的状态含义不同: ```bash +python3 ./.trellis/scripts/closure.py package-check --task "$TASK" +python3 ./.trellis/scripts/closure.py package-done --task "$TASK" --evidence "验证命令或报告引用" +python3 ./.trellis/scripts/closure.py audit --task "$TASK" +python3 ./.trellis/scripts/closure.py close --task "$TASK" python3 ./.trellis/scripts/task.py finish python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ``` -第一条只清除当前会话指针;第二条把任务标为已完成并移动到按月份组织的归档目录。去掉 `--no-commit`(不自动提交)后,默认配置可能自动提交归档改动。 +`package-done`(工作包完成)只处置当前结果;`close`(关闭)先做收口审计,成功后才把任务改为 `completed`(已完成);`finish`(结束会话)只清除当前会话指针;`archive`(归档)移动已关闭任务。closure 未关闭时归档会被拒绝,旧任务没有 closure 字段时继续走原版兼容流程。 -6. 执行中需要改动需求、实验配置或评估基线时,使用追加式计划变更流程: +6. 执行中需要改动计划时,使用 closure 变更命令: ```bash -NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -PROPOSAL="pc-$(date -u +%Y%m%d-%H%M%S)-proposal" -python3 ./.trellis/scripts/hermes/record.py append --task "$TASK" --record-type plan_change --json "{\"type\":\"plan_change\",\"id\":\"$PROPOSAL\",\"timestamp\":\"$NOW\",\"plan_ref\":\"implement.md\",\"change_summary\":\"将评估限定为固定切分\",\"reason\":\"原范围超出任务时间\",\"requested_by\":\"coordinator\",\"decision_state\":\"proposed\",\"evidence_refs\":[],\"supersedes\":[]}" -python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind plan_change +python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ + --field work_packages.WP1.done_when \ + --value '["固定切分评估通过"]' \ + --reason "验收条件需要明确固定切分" ``` -审阅者先读取 `plan_ref`(计划引用)、变更理由和证据,再用新编号追加 `accepted`(已接受)或 `rejected`(已拒绝)记录,并令 `supersedes`(替代对象)指向 `$PROPOSAL`。只有接受后才修改计划文件;新方案取代已接受方案时,再追加 `superseded`(已被替代)记录指向旧决定,然后为新方案重复提出和审阅。最后重新运行 `plan_change`(计划变更)、任务和实验校验。 - -校验器只检查必填字段、决定值、唯一编号和引用列表的结构;它不验证审阅者身份、证据编号是否存在、决定链是否完整,也不会自动修改计划文件。 +数据集、研究假设、数据切分、指标定义、基线、主张范围和任务范围属于高风险字段,必须增加 `--approved-by human/root`(人工批准)。变更直接写入 `task.json`(任务状态),同时向 `task-events.jsonl`(任务事件)追加 `plan_amended`(计划已修改)记录;变更后重新运行 closure 校验。正式研究仍可额外使用 Hermes `plan_change`(研究计划变更记录)保存更完整的提案和审阅链。 ## 预期结果 -任务创建时状态为 `planning`(规划中);启动后变为 `in_progress`(进行中);归档后变为 `completed`(已完成)并移动到 `.trellis/tasks/archive//`(月度归档目录)。 +任务创建时状态为 `planning`(规划中);启动后变为 `in_progress`(进行中);只有 close gate(关闭门禁)通过后才变为 `completed`(已完成),随后可移动到 `.trellis/tasks/archive//`(月度归档目录)。 ## 失败恢复 @@ -80,7 +91,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind plan_change ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 0bfda74..9fdfd72 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -23,8 +23,11 @@ ```bash python3 ./.trellis/scripts/get_context.py --mode packages python3 ./.trellis/scripts/task.py list-context "$TASK" +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" ``` +新任务每轮优先使用 capsule(紧凑上下文):目标、范围、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。完整 PRD、报告、事件、证据账本、历史任务和非当前规范只在需要时读取。这个优化不删除原始文件。 + 4. 向实现和检查清单追加需要的规范或研究材料: ```bash @@ -45,7 +48,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 预期结果 -上下文校验显示两个清单均有效,代理只加载与任务有关的规范和研究材料,规划文档仍保留在任务目录中。 +上下文校验显示两个清单均有效,代理只加载当前工作包需要的规范和研究材料,规划文档和完整历史仍保留在任务目录中。 ## 失败恢复 @@ -57,7 +60,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 91c7ffe..8131900 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.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 3eb1462..82a329a 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -74,7 +74,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands|sandbox" 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 c020012..eeed2e5 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.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 b55fa07..5e0cfa2 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.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 12d2934..fafe902 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.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 ca4b9c9..93736bf 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -70,7 +70,7 @@ python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:`Claude Code`(Claude 代码工具)和 `Codex`(代码代理)的门禁注册、主代理防火墙和结束检查测试均可定位。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index d00287e..5f16b0c 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -69,19 +69,21 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变工作流提示路由,不会绕过已启用的 `PreToolUse`(工具使用前)门禁。门禁活跃时不要使用它要求主会话直接写入。 +v0.7 的 closure 任务优先注入 Task Capsule(紧凑任务上下文),不默认注入完整 PRD、历史报告、事件和全部规范。Claude Code、Codex 和 OpenCode 使用相同字段与长度上限;Gemini、Qoder、CodeBuddy、Copilot 和 Factory Droid 通过共享每轮 hook 获得相同 Python 实现;Cursor 只在会话开始获得 capsule。Kiro、Kilo、Antigravity、Windsurf 和没有触发主会话 hook 的环境需要显式运行 `closure.py capsule`(生成紧凑上下文)。 + 6. 逐平台能力和限制如下。“子代理”表示仓库会安装对应代理模板;“门禁”专指 `hermes-runtime-guard.py`(Hermes 运行时门禁),不包括只做上下文注入的同名钩子事件。 | 平台 | 主会话上下文 | 子代理与上下文 | `PreToolUse / Stop`(门禁) | 启用条件与限制 | | --- | --- | --- | --- | --- | | `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 支持;`Task / Agent`(代理工具)前推送任务上下文 | 两者都有 | 需平台读取项目 `settings.json`(设置);环境开关可关闭 | -| `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | -| `OpenCode`(开放代码工具) | 无自动钩子 | 支持;代理按派发首行自行读取 | 无 | 依赖手动派发和代理前置说明 | +| `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)注入 capsule 和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 没有每轮 capsule;只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | +| `OpenCode`(开放代码工具) | `chat.message`(会话消息)注入会话摘要、每轮状态和 capsule | 支持;代理按派发首行自行读取 | 无 | 依赖 OpenCode 插件被实际加载;会话检索功能仍未实现 | | `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 支持;代理前置说明按派发首行拉取 | 两者都有 | 需用户级开关、项目信任和 `/hooks`(钩子审阅);默认子代理 | -| `Kilo CLI`(Kilo 命令行) | 无 | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | +| `Kilo CLI`(Kilo 命令行) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | | `Kiro Code`(Kiro 代码工具) | 无通用主会话注入 | 支持;`agentSpawn`(代理创建)时推送 | 无 | 只注册代理创建钩子,无会话开始和每轮主会话钩子 | | `Gemini CLI`(Gemini 命令行) | `SessionStart + BeforeAgent`(会话开始和代理前) | 支持;代理前置说明拉取 | 无 | 依赖 `.gemini/settings.json`(Gemini 设置)的当前钩子协议 | -| `Antigravity`(Antigravity 工作流) | 无 | 无,主会话执行模板 | 无 | 依赖工作流和技能文件 | -| `Windsurf`(Windsurf 编辑器) | 无 | 无,主会话执行模板 | 无 | 依赖工作流和技能文件 | +| `Antigravity`(Antigravity 工作流) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流和技能文件 | +| `Windsurf`(Windsurf 编辑器) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流和技能文件 | | `Qoder`(Qoder 工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 支持;代理前置说明拉取 | 无 | 平台钩子失效时必须依赖派发首行 | | `CodeBuddy`(CodeBuddy 工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 支持;`Task`(代理工具)前推送 | 无 | 其 `PreToolUse`(工具前)只用于上下文,不是 `Hermes`(科研工作流)门禁 | | `GitHub Copilot`(GitHub 编程助手) | `SessionStart + userPromptSubmitted`(会话开始和用户提交) | 支持;代理前置说明拉取 | 无 | `VS Code`(代码编辑器)代理钩子仍属预览能力,是否消费注入取决于安装版本 | @@ -102,7 +104,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:14 个平台的上下文注入、子代理、门禁和启用条件已与注册表及钩子配置对齐。 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index 75fad03..ab88d87 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -64,7 +64,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 2bdd6a7..f1af1f7 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.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`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 new file mode 100644 index 0000000..2c8292d --- /dev/null +++ b/docs/manual/17-lean-research-closure.md @@ -0,0 +1,172 @@ +# 17 Lean Research Closure + +## 目标 + +用一个 Trellis 任务完成适度拆解、状态记录、缺口修复和关闭检查,避免计划过宽或部分完成后提前结束。 + +## 一项任务如何拆 + +新任务默认使用 `lean`(轻量)模式。一般先保留一个 task,在 task 内安排 1–4 个 work packages(工作包)。工作包描述可观察结果,不描述命令步骤。 + +例如,“生成可安装包并通过安装验证”可以是一个工作包;“读取配置、修改文件、运行构建、查看输出”不能拆成四个工作包。 + +只有下面五项中至少满足两项,才适合单独建立工作包: + +1. 产生独立产物; +2. 有独立验证标准; +3. 依赖前一结果; +4. 可以独立失败或阻塞; +5. 需要不同代理、环境或权限。 + +出现 5 个以上工作包时,工具只提示考虑拆成多个 task,不会自动拆分。真正的 subtask(子任务)用于独立交付、独立审查或并行工作;work package 仍属于同一任务并共享范围。 + +## 最短流程 + +```bash +python3 ./.trellis/scripts/task.py create "验证本地分析脚本" --slug verify-analysis +TASK="$(find .trellis/tasks -maxdepth 1 -type d -name '*-verify-analysis' -printf '%f\n' | tail -n 1)" + +python3 ./.trellis/scripts/closure.py plan --task "$TASK" \ + --intent "得到可重复运行且经过验证的分析结果" \ + --in-scope "本地固定样例" \ + --out-of-scope "正式论文结论" \ + --done-when "分析脚本在固定样例上运行成功" +python3 ./.trellis/scripts/closure.py validate --task "$TASK" + +export TRELLIS_CONTEXT_ID=manual-closure-example +python3 ./.trellis/scripts/task.py start "$TASK" +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" +``` + +完成当前工作包后: + +```bash +python3 ./.trellis/scripts/closure.py package-check --task "$TASK" +python3 ./.trellis/scripts/closure.py package-done --task "$TASK" \ + --evidence "tests/test_analysis.py: passed" +python3 ./.trellis/scripts/closure.py audit --task "$TASK" +python3 ./.trellis/scripts/closure.py close --task "$TASK" +python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit +``` + +`task.json`(任务状态)是当前状态的唯一事实源。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。重要变化追加到 `hermes/task-events.jsonl`(任务事件历史)。 + +## Task Capsule + +`capsule`(紧凑上下文)默认控制在约 500–1000 个字符,包含任务目标、范围、模式、阶段、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。 + +每轮自动注入优先使用 capsule,不自动读取完整 `prd.md`(需求文档)、全部报告、全部事件、全部 ledger(账本)、历史任务和无关规范。这些文件不会删除,仍可按需读取。 + +Claude Code、Codex 和 OpenCode 使用同一 capsule 语义。其他具有共享每轮 hook 的平台也会收到 capsule;只有会话开始注入或没有主会话 hook 的平台,应在恢复任务时显式运行 `closure.py capsule`。 + +## 三种模式 + +| 模式 | 适合场景 | 关闭前增加的检查 | +| --- | --- | --- | +| `lean`(轻量) | 日常代码、探索、文档、小规模试验 | 完成定义、工作包处置、基础验证、无阻塞、关闭报告 | +| `standard`(标准) | 需要可重复运行记录的正式项目阶段 | 成功运行清单、产物 hash、指标、证据、主张限制 | +| `publication`(发表) | 正式论文证据和可公开主张 | 统计比较、主张处置、证据引用、`STATE.md`、`CLAIMS.md` 和人工批准 | + +`run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)是不同状态。运行成功只能作为证据,不能代替任务关闭;任务关闭也不能代替论文主张批准。 + +## Audit 与有限修复 + +```bash +python3 ./.trellis/scripts/closure.py audit --task "$TASK" +``` + +审计只输出紧凑缺口和下一动作。例如: + +```yaml +gaps: + - package: WP2 + missing: "seed 44 metrics" + action: "补跑 seed 44 并登记 metrics" +``` + +`repair`(有限修复)只重新打开审计指出的未完成工作包,不修改已完成工作包,也不会自动运行实验或扩大范围。lean 默认 1 轮,standard 和 publication 默认 2 轮;再次超过上限时任务进入 `blocked`(阻塞)并生成 `HANDOFF.md`(交接文件)。 + +```bash +python3 ./.trellis/scripts/closure.py repair --task "$TASK" +``` + +人工审阅后确需增加一次修复机会时,显式批准上限变更: + +```bash +python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ + --field max_repair_count --value 2 \ + --reason "人工审阅后允许一次额外修复" \ + --approved-by 'human/root' +python3 ./.trellis/scripts/closure.py validate --task "$TASK" +``` + +数据集、研究假设、数据切分、指标定义、基线、主张范围、任务范围、closure mode 和修复上限变化不能由 repair 自动处理。 + +## 计划变更 + +低风险变更直接更新 `task.json` 并记录 `plan_amended`(计划已修改)事件: + +```bash +python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ + --field work_packages.WP2.done_when \ + --value '["固定测试通过"]' \ + --reason "验收条件需要可执行" +``` + +高风险研究契约变更必须显式记录人工批准: + +```bash +python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ + --field dataset --value 'dataset-v2' \ + --reason "数据来源修订" --approved-by 'human/root' +``` + +变更后任务回到 planning,需重新运行 `closure.py validate`。工具不会重写已经完成的工作包。 + +## Handoff + +```bash +python3 ./.trellis/scripts/closure.py handoff --task "$TASK" +``` + +`HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 + +## 常见失败 + +- `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 +- `package-done` 提示缺少证据:先运行实际验证,再通过 `--evidence` 登记命令、报告或产物引用。 +- `close` 提示工作包未处置:完成、defer 或 waive 对应工作包;不要直接改 JSON 状态。 +- `repair` 达到上限:查看 `HANDOFF.md` 和阻塞原因,由人工决定停止任务,或批准提高 `max_repair_count` 后重新校验。 +- `archive` 被拒绝:先运行 `closure.py audit` 和 `closure.py close`。旧任务没有 closure 字段时仍走原版兼容归档。 + +## 示例 + +- `examples/lean-closure/`(单工作包轻量示例); +- `examples/standard-ai4chem/`(固定数据和指标契约的标准 AI4Chem 示例)。 + +## 已知限制 + +- 自动规划只根据 intent 和完成定义生成候选工作包,研究者仍需审阅拆解质量。 +- JSONL 事件便于 Git 审阅,但不是防篡改数据库。 +- repair 只调整安全的任务状态,不会自动补跑实验、伪造证据或批准主张。 +- publication 的人工批准真实性依赖外部流程,本项目只校验记录字段。 + +## 验证记录 + +- 日期:2026-07-15。 +- 版本:`0.7.0-beta.0`(测试版)。 +- 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 +- 结果:适度拆解、依赖环、capsule 长度、证据门禁、有限 repair、高风险 amend、三种模式、关闭和归档兼容均有自动化覆盖。 +- 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 + +## 来源 + +[S6、S9、S12、S14](sources.md) + +## 相关页面 + +- [第一个科研任务](03-first-research-task.md) +- [任务与规划](06-tasks-and-planning.md) +- [规范与上下文](07-spec-and-context.md) +- [命令参考](appendix-a-cli-reference.md) +- [排障](appendix-e-troubleshooting.md) diff --git a/docs/manual/README.md b/docs/manual/README.md index fb11d1e..15406ca 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.6.0-beta.31`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.0-beta.0`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 @@ -24,7 +24,7 @@ python3 ./.trellis/scripts/task.py list | 认识与安装 | [01 项目概览](01-overview.md) · [02 安装](02-installation.md) · [03 第一个科研任务](03-first-research-task.md) · [04 工作原理](04-how-it-works.md) | | 项目与任务 | [05 项目初始化](05-project-setup.md) · [06 任务与规划](06-tasks-and-planning.md) · [07 规范与上下文](07-spec-and-context.md) | | 科研记录 | [08 Hermes 记录](08-hermes-records.md) · [09 实验配置](09-experiments.md) · [10 运行与复查](10-runs-and-replay.md) · [11 指标与比较](11-metrics-and-comparison.md) · [12 报告与主张](12-reports-and-claims.md) | -| 协作与维护 | [13 工作代理与门禁](13-workers-and-gates.md) · [14 工作流与平台](14-workflows-and-platforms.md) · [15 频道与会话记忆](15-channel-and-memory.md) · [16 更新、恢复与卸载](16-update-recovery-and-uninstall.md) | +| 协作与维护 | [13 工作代理与门禁](13-workers-and-gates.md) · [14 工作流与平台](14-workflows-and-platforms.md) · [15 频道与会话记忆](15-channel-and-memory.md) · [16 更新、恢复与卸载](16-update-recovery-and-uninstall.md) · [17 Lean Research Closure](17-lean-research-closure.md) | | 参考 | [命令参考](appendix-a-cli-reference.md) · [目录参考](appendix-b-directory-reference.md) · [配置参考](appendix-c-configuration-reference.md) · [记录格式](appendix-d-record-schema.md) · [排障](appendix-e-troubleshooting.md) · [功能状态、安全与许可证](appendix-f-feature-status.md) · [事实来源](sources.md) | ## 使用边界 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 97f36b4..099f5f3 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.6.0-beta.31`(测试版)。 +适用于 `0.7.0-beta.0`(测试版)。 ## 前置条件 @@ -39,6 +39,26 @@ python3 ./.trellis/scripts/task.py finish python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ``` +Closure 命令: + +```bash +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 next --task "$TASK" +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" +python3 ./.trellis/scripts/closure.py package-start --task "$TASK" +python3 ./.trellis/scripts/closure.py package-check --task "$TASK" +python3 ./.trellis/scripts/closure.py package-done --task "$TASK" --evidence "验证引用" +python3 ./.trellis/scripts/closure.py package-block --task "$TASK" --reason "阻塞原因" +python3 ./.trellis/scripts/closure.py amend --task "$TASK" --field --value --reason +python3 ./.trellis/scripts/closure.py audit --task "$TASK" +python3 ./.trellis/scripts/closure.py repair --task "$TASK" +python3 ./.trellis/scripts/closure.py handoff --task "$TASK" +python3 ./.trellis/scripts/closure.py close --task "$TASK" +``` + 科研命令: ```bash @@ -53,7 +73,7 @@ python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" ``` -高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)和 `task.py archive`(归档任务,默认可能提交)。 +高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 ## 预期结果 @@ -66,7 +86,7 @@ python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "\.command\(|task.py|hermes/" packages/cli/src/cli packages/cli/src/templates/trellis/scripts`(常用入口核对)。 - 结果:本页列出的主命令、任务脚本和科研脚本入口均可定位。 diff --git a/docs/manual/appendix-b-directory-reference.md b/docs/manual/appendix-b-directory-reference.md index c0a871e..1eede20 100644 --- a/docs/manual/appendix-b-directory-reference.md +++ b/docs/manual/appendix-b-directory-reference.md @@ -27,7 +27,10 @@ │ ├── prd.md │ ├── implement.jsonl │ ├── check.jsonl +│ ├── HANDOFF.md +│ ├── closure-report.md │ └── hermes/ +│ └── task-events.jsonl ├── workspace/ ├── hermes/ └── scripts/ @@ -36,6 +39,8 @@ - `.trellis/hermes/`(全局科研模板)只保存约定和模板。 - `.trellis/tasks//hermes/`(任务科研记录)保存任务事实。 +- `task.json`(任务状态)保存当前 closure 状态;`task-events.jsonl`(任务事件)只追加重要历史。 +- `HANDOFF.md`(交接)按条件生成;`closure-report.md`(关闭报告)由通过的收口审计生成。 - `.trellis/.runtime/`(会话运行状态)保存活动任务指针,通常不提交。 - `.codex/`(Codex 配置)、`.claude/`(Claude 配置)等目录由平台选择决定。 @@ -50,7 +55,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "tasks|hermes|workflow" packages/cli/src/constants packages/cli/src/configurators packages/cli/src/templates/trellis`(目录来源核对)。 - 结果:项目、任务和科研记录目录均与当前常量及模板对齐。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index 9f8fcee..e0ea771 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -44,7 +44,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|session_auto_commit|allowed_commands" packages/cli/src/templates/trellis`(配置模板核对)。 - 结果:会话、`Codex`(代码代理)调度和实验命令配置均可定位,默认子代理语义已对齐。 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index 79a3222..469d7e1 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -49,7 +49,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "REQUIRED_FIELDS|PLAN_CHANGE_DECISION_STATES" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(记录结构核对)。 - 结果:必填字段表和计划变更决定值与运行时定义一致;`recordbus.md`(记录总线说明)已标为全局协议路径。 diff --git a/docs/manual/appendix-e-troubleshooting.md b/docs/manual/appendix-e-troubleshooting.md index 2af8798..68e8ce4 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -19,7 +19,13 @@ | 初始化提示 Python 不可用 | 确认 `python3 --version`(Python 版本)至少为 3.9,必要时设置 `TRELLIS_PYTHON_CMD`(Python 命令覆盖) | | 不允许在主目录初始化 | 进入真实项目根目录,不要绕过保护 | | 任务启动提示缺少会话身份 | 在人工智能会话运行,或设置唯一 `TRELLIS_CONTEXT_ID`(会话标识) | +| 任务启动提示 closure 未准备 | 先填写 intent 和完成定义,再运行 `closure.py plan` 与 `closure.py validate` | | 任务启动提示实验无效 | 填完 `experiment.yaml`(实验配置)后单独运行实验校验 | +| 工作包不能标记 done | 先运行 `package-check`,再用 `package-done --evidence` 登记真实验证引用 | +| audit 显示 has_gaps | 按第一条 package、missing 和 action 补齐;不要自动生成新计划 | +| repair 后仍有缺口 | 完成 repair 打开的工作包;超过上限后由人工停止任务,或批准 amend `max_repair_count` 并重新 validate | +| amend 提示需要批准 | 数据集、假设、切分、指标、基线、主张范围和 scope 变化需真实 `human/root` 批准 | +| archive 提示 closure 未关闭 | 先运行 `closure.py audit` 和 `closure.py close`;旧任务无 closure 字段时不受此限制 | | 运行命令被拒绝 | 核对允许命令和任务卡,不要扩大为笼统命令 | | 工作代理记录无效 | 先校验必填字段、唯一任务卡和文件范围 | | 质量门禁缺少统计字段 | 见[指标与比较](11-metrics-and-comparison.md)中的当前限制 | @@ -33,6 +39,8 @@ research-trellis --version python3 ./.trellis/scripts/task.py current --source python3 ./.trellis/scripts/task.py list +python3 ./.trellis/scripts/closure.py status --task "$TASK" +python3 ./.trellis/scripts/closure.py capsule --task "$TASK" ``` ## 预期结果 @@ -46,7 +54,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality gate|missing evidence|approval" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(失败关闭分支核对)。 - 结果:质量门禁、缺失证据和审批失败分支都有实现或测试依据。 diff --git a/docs/manual/appendix-f-feature-status.md b/docs/manual/appendix-f-feature-status.md index e0e100b..96f70a7 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.6.0-beta.31`(测试版),后续版本应重新核对。 +适用于 `0.7.0-beta.0`(测试版),后续版本应重新核对。 ## 前置条件 @@ -18,6 +18,9 @@ | --- | --- | --- | | 安装、初始化、更新、卸载预览 | 已实现 | 卸载实际执行会删除整个工作流目录 | | 任务、规范、上下文清单 | 已实现 | 规划质量仍需人工审阅 | +| Lean Research Closure | 已实现、实验性 | 默认 1–4 个工作包;自动候选仍需人工审阅 | +| Task Capsule 与条件式 Handoff | 已实现 | 平台没有主会话 hook 时需手动调用 | +| Closure Audit 与有限 Repair | 已实现 | 只处理缺口和状态,不自动运行实验或批准主张 | | 科研记录与结构校验 | 已实现、实验性 | 追加式约定,不防篡改 | | 科研状态流程 | 协议模板 | `state_machine.yaml`(状态协议)未作为通用运行时迁移引擎强制执行 | | 实验运行与运行清单 | 已实现、实验性 | 默认无隔离,允许命令不是强沙箱 | @@ -60,7 +63,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node packages/cli/scripts/release-preflight.js check-licenses`(许可证元数据与权利说明一致性检查);`rg -n -m 1 "COPYRIGHT|LICENSE|files" packages/cli/package.json packages/core/package.json packages/cli/scripts/release-preflight.js`(打包权利文件核对)。 - 结果:项目和两个包的 `SPDX`(许可证标识)均为 `AGPL-3.0-or-later`(AGPL 第 3 版或任何后续版本);两个包均声明 `LICENSE`(许可证)和 `COPYRIGHT`(版权声明),发布预检也将其列为必需文件。 diff --git a/docs/manual/sources.md b/docs/manual/sources.md index 30cd699..d570af9 100644 --- a/docs/manual/sources.md +++ b/docs/manual/sources.md @@ -27,6 +27,7 @@ - **S11**:[会话记忆命令](https://github.com/Explero/research-trellis/blob/main/packages/cli/src/commands/mem.ts)和 `packages/core/src/mem/`(会话记忆核心)。 - **S12**:`packages/cli/test/`(命令与模板测试)、`packages/core/test/`(核心测试)和[科研预检脚本](https://github.com/Explero/research-trellis/blob/main/packages/cli/scripts/hermes-preflight.js)。 - **S13**:[上游官方中文手册](https://docs.trytrellis.app/zh):参考“概览、安装、首个任务、工作原理、日常使用、高级内容、附录”的教学顺序;访问日期为 2026-07-14。本手册不是该网站的官方翻译或镜像,科研功能仍以 S1 至 S12 为准。 +- **S14**:`packages/cli/src/templates/trellis/scripts/closure.py`(收口命令)、`common/closure.py`(收口状态与审计)、共享 hook(上下文钩子)、`packages/core/src/task/schema.ts`(任务结构)以及对应集成测试。 ## 预期结果 @@ -39,7 +40,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.6.0-beta.31`(测试版)。 +- 版本:`0.7.0-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "^## |^# " docs/manual/*.md`(手册结构核对)。 - 结果:所有手册页的标题结构可读,来源索引仍指向当前仓库文件。 diff --git a/examples/lean-closure/README.md b/examples/lean-closure/README.md new file mode 100644 index 0000000..878cc44 --- /dev/null +++ b/examples/lean-closure/README.md @@ -0,0 +1,14 @@ +# Lean Closure Example + +这是一个单工作包 `lean`(轻量)任务快照,适合本地脚本、文档或小型工程验证。 + +把 `task.json`(任务状态)放入 `.trellis/tasks//` 后,先运行: + +```bash +python3 ./.trellis/scripts/closure.py validate --task +export TRELLIS_CONTEXT_ID=lean-example +python3 ./.trellis/scripts/task.py start +python3 ./.trellis/scripts/closure.py capsule --task +``` + +实际验证完成后,使用 `package-check`(工作包检查)、`package-done --evidence`(记录验证证据)、`audit`(收口审计)和 `close`(关闭任务)。示例没有预填证据,避免把示例记录误认为真实结果。 diff --git a/examples/lean-closure/task.json b/examples/lean-closure/task.json new file mode 100644 index 0000000..8b74279 --- /dev/null +++ b/examples/lean-closure/task.json @@ -0,0 +1,51 @@ +{ + "id": "lean-local-analysis", + "name": "lean-local-analysis", + "title": "验证本地分析脚本", + "description": "在固定样例上得到可重复的分析结果", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "researcher", + "assignee": "researcher", + "createdAt": "2026-07-15", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": ["tests/test_analysis.py"], + "notes": "", + "meta": {}, + "hermes_phase": "planning", + "closure_state": "open", + "closure_mode": "lean", + "intent": "在固定样例上得到可重复运行并经过测试的分析结果", + "in_scope": ["本地固定样例", "分析脚本基础测试"], + "out_of_scope": ["正式论文主张", "外部数据集泛化"], + "definition_of_done": ["分析脚本运行成功且固定样例测试通过"], + "work_packages": [ + { + "id": "WP1", + "title": "可重复的本地分析结果", + "outcome": "分析脚本在固定样例上产生经过验证的结果", + "done_when": ["分析脚本运行成功且固定样例测试通过", "分析脚本退出码为 0", "固定样例测试通过"], + "evidence_required": [], + "depends_on": [], + "status": "pending", + "evidence_refs": [], + "blocker": null + } + ], + "current_work_package": null, + "next_action": "Run closure.py validate before starting work.", + "blockers": [], + "repair_count": 0, + "max_repair_count": 1 +} diff --git a/examples/standard-ai4chem/README.md b/examples/standard-ai4chem/README.md new file mode 100644 index 0000000..e7f0257 --- /dev/null +++ b/examples/standard-ai4chem/README.md @@ -0,0 +1,12 @@ +# Standard AI4Chem Closure Example + +这是一个 `standard`(标准)AI4Chem 任务规划快照:固定分子数据、切分、指标和基线,比较候选模型并形成有证据限制的结果。 + +示例故意停留在 planning(规划)阶段,不包含伪造的运行、指标、产物 hash 或证据。复制到真实任务后应先核对 `task.json`(任务状态)和 `hermes/experiment.yaml`(实验契约),再运行: + +```bash +python3 ./.trellis/scripts/closure.py validate --task +python3 ./.trellis/scripts/hermes/experiment.py validate --task +``` + +standard 关闭前需要真实的成功 `run_manifest.jsonl`(运行清单)、带 hash 的产物、指标、证据和 claim limitations(主张限制)。数据集、切分、指标定义或基线变化必须通过高风险 `amend --approved-by human/root`(人工批准变更)记录。 diff --git a/examples/standard-ai4chem/hermes/experiment.yaml b/examples/standard-ai4chem/hermes/experiment.yaml new file mode 100644 index 0000000..6802856 --- /dev/null +++ b/examples/standard-ai4chem/hermes/experiment.yaml @@ -0,0 +1,18 @@ +question: "候选模型能否在固定 scaffold-v1 测试集上降低溶解度 RMSE?" +hypothesis: "候选模型的测试集 RMSE 低于 baseline-rf-v1。" +dataset: "local-solubility-v1" +model: "candidate-gnn-v1" +metrics: + - "test_rmse" +seed: 44 +environment: + os: "ubuntu-24.04" + shell: "bash" +allowed_commands: + - "python3" +sandbox: + mode: "none" + required: false + image: "" + command: "" +artifact_dir: ".trellis/tasks/{task}/hermes/experiments" diff --git a/examples/standard-ai4chem/task.json b/examples/standard-ai4chem/task.json new file mode 100644 index 0000000..6d09235 --- /dev/null +++ b/examples/standard-ai4chem/task.json @@ -0,0 +1,86 @@ +{ + "id": "standard-ai4chem-solubility", + "name": "standard-ai4chem-solubility", + "title": "固定切分溶解度模型比较", + "description": "在固定分子数据切分上比较基线与候选模型", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "researcher", + "assignee": "researcher", + "createdAt": "2026-07-15", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": ["configs/solubility.yaml", "src/train.py", "src/evaluate.py"], + "notes": "示例仅用于规划,不代表真实科研结果。", + "meta": { + "research_contract": { + "hypothesis": "候选模型在固定测试集上的 RMSE 低于基线", + "dataset": "local-solubility-v1", + "split": "scaffold-v1", + "metric_definition": "test RMSE, lower_is_better", + "baseline": "baseline-rf-v1", + "claim_scope": "仅限固定数据版本和固定切分" + } + }, + "hermes_phase": "planning", + "closure_state": "open", + "closure_mode": "standard", + "intent": "在固定数据和指标契约下得到可重复的候选模型比较结果", + "in_scope": ["local-solubility-v1", "scaffold-v1", "测试集 RMSE"], + "out_of_scope": ["跨数据集泛化", "临床或工业部署", "正式论文主张"], + "definition_of_done": [ + "数据、切分和指标契约通过检查", + "基线与候选模型运行成功并登记产物", + "比较结果、证据和限制已记录" + ], + "work_packages": [ + { + "id": "WP1", + "title": "锁定 AI4Chem 实验契约", + "outcome": "数据版本、切分、指标和基线定义可审阅且固定", + "done_when": ["数据、切分和指标契约通过检查", "实验配置校验通过", "数据和切分引用已确认"], + "evidence_required": ["experiment validation", "dataset provenance"], + "depends_on": [], + "status": "pending", + "evidence_refs": [], + "blocker": null + }, + { + "id": "WP2", + "title": "产生可重复的模型运行", + "outcome": "基线和候选模型均完成可重复运行并登记产物", + "done_when": ["基线与候选模型运行成功并登记产物", "两次运行退出码为 0", "产物 hash 已登记", "测试集 RMSE 已记录"], + "evidence_required": ["run manifest", "artifact hash", "metrics"], + "depends_on": ["WP1"], + "status": "pending", + "evidence_refs": [], + "blocker": null + }, + { + "id": "WP3", + "title": "形成受限的比较结论", + "outcome": "比较结果可追溯到运行和证据,并明确适用限制", + "done_when": ["比较结果、证据和限制已记录", "compare 记录存在", "evidence 记录存在", "claim limitations 已填写"], + "evidence_required": ["compare record", "evidence ledger", "claim limitations"], + "depends_on": ["WP2"], + "status": "pending", + "evidence_refs": [], + "blocker": null + } + ], + "current_work_package": null, + "next_action": "Run closure.py validate before starting work.", + "blockers": [], + "repair_count": 0, + "max_repair_count": 2 +} diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 7ce7c0a..3ab4ee2 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -51,6 +51,14 @@ python3 ./.trellis/scripts/task.py archive # 移动到 archive/{ye python3 ./.trellis/scripts/task.py list [--mine] [--status ] python3 ./.trellis/scripts/task.py list-archive +# Lean Research Closure(新任务默认启用) +python3 ./.trellis/scripts/closure.py plan --task +python3 ./.trellis/scripts/closure.py validate --task +python3 ./.trellis/scripts/closure.py capsule --task +python3 ./.trellis/scripts/closure.py status --task +python3 ./.trellis/scripts/closure.py audit --task +python3 ./.trellis/scripts/closure.py close --task + # 代码规范上下文(通过 JSONL 注入 implement/check 子代理)。 # `implement.jsonl` / `check.jsonl` 会在 `task create` 时为支持子代理的平台预置种子行; # 之后由 AI 在规划阶段补成真实 spec / research 条目。 @@ -73,7 +81,34 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] > 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。`task.py start` 会写入同一个指针(如果已存在则幂等),并把 `task.json.status` 从 `planning` 切换为 `in_progress`。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务,`task.py start` 会提示缺少会话身份。`task.py finish` 只删除当前会话文件(不改任务状态)。`task.py archive ` 会写入 `status=completed`、移动目录到 `archive/`,并删除仍指向该任务的运行时会话文件。 +**当前任务机制**:`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 + +默认仍然只创建一个 Trellis task。task 内部用 1–4 个 work package 表示适度的结果分组:每个 package 描述可观察结果和完成条件,不把读文件、改文件、运行命令、查看输出拆成 package。只有独立产物、独立验证、独立执行环境、可并行执行或独立审查等条件中至少满足两项时,才考虑分成独立 package。5 个以上只给出拆成多个 task 的建议,不自动拆分。 + +work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 + +```bash +# 规划与验证 +python3 ./.trellis/scripts/closure.py plan --task \ + --intent "可观察目标" --done-when "完成条件" +python3 ./.trellis/scripts/closure.py validate --task + +# 每次只处理当前 package +python3 ./.trellis/scripts/closure.py package-start --task +python3 ./.trellis/scripts/closure.py package-check --task +python3 ./.trellis/scripts/closure.py package-done --task --evidence "验证记录" + +# 收口 +python3 ./.trellis/scripts/closure.py audit --task +python3 ./.trellis/scripts/closure.py repair --task +python3 ./.trellis/scripts/closure.py close --task +``` + +每轮 hook 优先注入约 500–1000 字符的 Task Capsule,只包含 intent、范围、当前 package、done_when、next action、blockers 和最多 3 个相关引用。完整 PRD、报告、事件、ledger、历史任务和非当前 spec 均保留,但只在需要时读取。Claude Code、Codex 与 OpenCode 使用相同 capsule 语义;没有每轮 hook 的平台需要显式运行 `closure.py capsule`。 + +阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 @@ -153,7 +188,7 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 ### Parent / Child Task Trees -当一个用户请求包含多个可独立验证的交付物时,使用父任务。父任务负责源需求集合、任务地图、跨子任务验收标准以及最终集成评审;除非它本身也有直接工作,否则通常不应作为实现目标。This means one request can cover several independently verifiable deliverables. +默认先使用一个 task 和 1–4 个内部 work packages。只有交付物可以独立产出、验证、执行、并行或审查时才使用父/子任务。父任务负责源需求集合、任务地图、跨子任务验收标准以及最终集成评审;除非它本身也有直接工作,否则通常不应作为实现目标。This means one request can cover several independently verifiable deliverables. 对子任务,应保证它们都可以独立地完成 planning、implement、check 与 archive。父子结构不是依赖系统:如果某个子任务必须等待另一个子任务,应该把这个顺序写在子任务自己的 `prd.md` / `implement.md` 里,并保持各子任务的验收标准可独立验证。Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child artifacts instead. @@ -180,6 +215,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni [workflow-state:planning] +Hermes closure: use the injected Task Capsule first. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. Load `trellis-brainstorm`; stay in planning. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. @@ -199,6 +235,7 @@ Before `task.py start`, record the development strategy decisions in the task do into a sub-agent. --> [workflow-state:planning-inline] +Hermes closure: use the injected Task Capsule first. Run `closure.py plan` and `closure.py validate`; do not treat commands or file operations as packages. Load `trellis-brainstorm`; stay in planning. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. @@ -221,6 +258,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] +Hermes closure: follow only the current package in the Task Capsule. Move running -> review with `package-check`, record validation with `package-done`, then run `audit`; use at most the configured bounded `repair` rounds, and run `close` before archive. Do not load all reports, events, ledgers, history, or specs by default. Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. @@ -242,6 +280,7 @@ If the user asks to archive the current task, do not block on commit; archive is instead of dispatching sub-agents. --> [workflow-state:in_progress-inline] +Hermes closure: handle only the current package from the Task Capsule; run package check/done, audit, bounded repair when needed, and close before archive. Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> archive or commit as needed -> `/trellis:finish-work`. Do not dispatch implement/check sub-agents in inline mode. Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills. @@ -265,7 +304,7 @@ If the user asks to archive the current task, do not block on commit; archive is channel as the live blocks. --> [workflow-state:completed] -Task archived or code committed. Run `/trellis:finish-work`; if more validation or wrap-up is needed, do that before closing the work. +Task closure passed. Keep task closed distinct from claim approval, then run `/trellis:finish-work` or archive. [/workflow-state:completed] ### Rules @@ -301,6 +340,9 @@ Task archived or code committed. Run `/trellis:finish-work`; if more validation - 用户同意创建任务,不等于同意开始实现;只有在产物 review 完成并执行 `task.py start` 后,才能进入实现。 - 轻量任务允许只有 PRD;复杂任务必须有 `design.md` + `implement.md`。 - planning 必须落盘到任务产物;完成前必须跑检查。 +- closure 任务不能靠自然语言声明完成;状态迁移必须通过 closure 命令写入 `task.json` 和事件日志。 +- `audit` 有缺口时只修缺口,不扩大 scope,不重写 done package;高风险研究契约变化必须人工批准。 +- lean 是默认低 token 模式;standard 增加运行、产物、指标和证据检查;publication 再增加统计比较、claim、STATE/CLAIMS 和人工审批门禁。 ### Loading Step Detail diff --git a/mkdocs.yml b/mkdocs.yml index a827331..051b75a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,7 @@ nav: - 工作流与平台: 14-workflows-and-platforms.md - 频道与会话记忆: 15-channel-and-memory.md - 更新、恢复与卸载: 16-update-recovery-and-uninstall.md + - Lean Research Closure: 17-lean-research-closure.md - 附录: - 常用命令: appendix-a-cli-reference.md - 目录参考: appendix-b-directory-reference.md diff --git a/packages/cli/README.md b/packages/cli/README.md index 4eb57ad..a7bc740 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.6.0-beta.31 +npm install -g research-trellis@0.7.0-beta.0 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.6.0-beta.31 +npm install -g research-trellis@0.7.0-beta.0 research-trellis --version ``` @@ -148,10 +148,12 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update → 初始化或更新 Research Trellis → 用自然语言提出一个小任务 → 让 AI 创建任务并补齐 planning +→ 用 closure plan/validate 生成 1–4 个结果型工作包 → 检查 .trellis/tasks/ 下的 prd/design/implement 产物 → 派发一个低风险实现或检查子任务 → 查看 Hermes worker records、报告和门禁结果 → 运行项目自己的测试 +→ 执行 closure audit/close,确认不能提前结束 → 决定保留、回滚或继续调整 ``` @@ -187,7 +189,15 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update 如果配置指向普通目录、无关仓库或不可识别的 worktree,流程会拒绝继续。 -### 5. 预检不是形式检查 +### 5. Lean Research Closure 防止提前结束 + +新任务默认使用一个 Trellis task,并在内部安排 1–4 个结果型工作包。`task.json` 保存当前状态,`hermes/task-events.jsonl` 追加重要变化;每轮 hook 优先注入紧凑 Task Capsule,减少完整 PRD、历史报告和全部规范的重复加载。 + +只有 `closure.py audit`(收口审计)通过后,`closure.py close`(关闭任务)才会写入 `completed`(已完成)。`lean`(轻量)、`standard`(标准)和 `publication`(发表)按需增加证据门禁;有限 repair(修复)不会扩大任务范围或改写已完成工作包。 + +详见[中文手册:Lean Research Closure](docs/manual/17-lean-research-closure.md)。 + +### 6. 预检不是形式检查 `hermes:preflight`(Hermes 预检)会检查模板文件、Python 编译、Hermes hook、门禁文档、沙箱配置、模板测试、类型检查和构建。 @@ -208,6 +218,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update - Hermes 实验记录; - 子代理记录门禁; - planning 产物约束; +- Lean Research Closure、Task Capsule 和有限修复; - 共享 worktree 检查; - 发布前预检。 diff --git a/packages/cli/package.json b/packages/cli/package.json index 1b249ed..7aa9dea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis", - "version": "0.6.0-beta.31", + "version": "0.7.0-beta.0", "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 d5a8223..71086b5 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -26,6 +26,8 @@ const REQUIRED_FILES = [ "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", "packages/cli/src/templates/trellis/hermes/metrics/metrics_schema.yaml", "packages/cli/src/templates/trellis/hermes/reports/report.md", + "packages/cli/src/templates/trellis/scripts/closure.py", + "packages/cli/src/templates/trellis/scripts/common/closure.py", "packages/cli/src/templates/trellis/scripts/hermes/runner.py", "packages/cli/src/templates/trellis/scripts/hermes/report.py", "packages/cli/src/templates/trellis/scripts/hermes/service.py", @@ -41,6 +43,7 @@ const TEMPLATE_TEST_ARGS = [ "test/templates/claude.test.ts", "test/templates/codex.test.ts", "test/templates/shared-hooks.test.ts", + "test/scripts/closure.integration.test.ts", ]; function result(name, ok, messages = []) { diff --git a/packages/cli/src/migrations/manifests/0.7.0-beta.0.json b/packages/cli/src/migrations/manifests/0.7.0-beta.0.json new file mode 100644 index 0000000..a027fb4 --- /dev/null +++ b/packages/cli/src/migrations/manifests/0.7.0-beta.0.json @@ -0,0 +1,9 @@ +{ + "version": "0.7.0-beta.0", + "description": "新增 Lean Research Closure,以轻量工作包、紧凑上下文和关闭门禁防止任务提前结束。", + "breaking": false, + "recommendMigrate": false, + "changelog": "**Lean Research Closure:**\n- feat(closure): 新任务默认使用 lean closure,并在一个 Trellis task 内安排 1–4 个结果型工作包。\n- feat(closure): 新增 plan、validate、capsule、package、audit、repair、amend、handoff 和 close 命令。\n- feat(workflow): Task Capsule 成为 closure 任务的默认每轮上下文,完整历史和无关规范改为按需读取。\n- feat(gates): closure 未通过时拒绝归档;standard 和 publication 模式按需增加科研证据门禁。\n- fix(types): 修正 Hermes 容器镜像类型收窄和记录校验未使用返回值,Python 静态检查恢复通过。\n- docs: 新增中文手册章节、lean 示例和 standard AI4Chem 示例。\n\n**兼容性:**\n- 原版 task.json.status 仍只使用 planning、in_progress 和 completed。\n- 旧任务没有 closure 字段时继续使用原版启动和归档兼容行为。\n- 现有 task、workflow 和平台目录保持兼容。", + "migrations": [], + "notes": "运行 research-trellis update 后,新模板会加入 closure.py 和 compact capsule hook。旧任务不自动改写;新任务需要先 plan/validate,close 后才能 archive。" +} diff --git a/packages/cli/src/templates/common/commands/continue.md b/packages/cli/src/templates/common/commands/continue.md index d0639e1..92e72bf 100644 --- a/packages/cli/src/templates/common/commands/continue.md +++ b/packages/cli/src/templates/common/commands/continue.md @@ -12,6 +12,14 @@ Resume work on the current task — pick up at the right phase/step in `.trellis Confirms: current task, git state, recent commits. +If the active task has Hermes closure fields, immediately load the compact capsule: + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task +``` + +Use the current work package as the execution boundary. Full PRD, reports, events, ledgers, historical tasks, and unrelated specs are read only when needed. + ## Step 2: Load the Phase Index ```bash @@ -32,6 +40,7 @@ Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. - `status=in_progress` + implementation done, not yet checked → **2.2** - `status=in_progress` + check passed → **3.1** - `status=completed` (rare; usually archived immediately) → archive flow +- `hermes_phase=planning` → plan/validate; `ready` → start the next package; `running` → work only on the current package; `review` → package check or closure audit; `blocked` → follow handoff/human gate; `closed` → finish/archive. Phase rules (full detail in `.trellis/workflow.md`): diff --git a/packages/cli/src/templates/common/commands/finish-work.md b/packages/cli/src/templates/common/commands/finish-work.md index 817ca8e..1917d9e 100644 --- a/packages/cli/src/templates/common/commands/finish-work.md +++ b/packages/cli/src/templates/common/commands/finish-work.md @@ -42,18 +42,30 @@ Then route: > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." - **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. -## Step 3: Run Hermes quality gates +## Step 3: Run Hermes closure gates -Use the current active task name for ``. Before archiving anything, run: +Use the current active task name for ``. Every closure task runs audit and close: + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py audit --task +{{PYTHON_CMD}} ./.trellis/scripts/closure.py close --task +``` + +For `standard` and `publication`, also validate the existing Hermes records: ```bash -{{PYTHON_CMD}} ./.trellis/scripts/hermes/report.py quality-gate --task {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind audit {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind provenance {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind service_queue ``` -Do not archive any task if any command fails. Fix the Hermes quality gate, audit, provenance, or service queue issue first, then re-run this step and only continue after all commands exit 0. +For `publication` only, additionally run the formal comparison gate: + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/hermes/report.py quality-gate --task +``` + +Run only the gates required by `closure_mode`. Do not archive while `closure_state` is not `closed`. Legacy tasks without closure fields retain the original compatibility path. ## Step 4: Archive task(s) diff --git a/packages/cli/src/templates/common/commands/start.md b/packages/cli/src/templates/common/commands/start.md index 15c4c74..606957a 100644 --- a/packages/cli/src/templates/common/commands/start.md +++ b/packages/cli/src/templates/common/commands/start.md @@ -36,12 +36,17 @@ Index files list the specific guideline docs to read when you actually start cod ## Step 4: Decide next action From Step 1 you know the current task and status. Check the task directory: +For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task ` first. Use only the current package and its related references; load full PRD, reports, event history, ledgers, old tasks, and unrelated specs on demand. + - **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. - **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Lightweight tasks can be PRD-only; complex tasks need `design.md` + `implement.md`. For the Claude Code path, keep requirement clarification in `prd.md`, continue repository-first follow-up questioning by entering `trellis-grill-me`, and treat it as a required planning gate. Before `trellis-grill-me` is complete, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. Before `task.py start`, record the decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, plus the task-level review-gate selection. Ask these in one `A.` / `B.` / `C.` style strategy block. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Complex tasks should keep that record in `implement.md` together with the enabled/disabled review gates and the preserved order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists for the selectable review gates (`trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, `trellis-merge-review`). If the user leaves all optional gates off, still record all five in the disabled list while keeping `trellis-check` fixed outside that set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. On Claude Code, that shared worktree is different from host `Agent(..., isolation: "worktree")`; when both collide, the hook removes the host isolation input before dispatch. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. - **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: ```bash {{PYTHON_CMD}} ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform {{CLI_FLAG}} ``` +- **Hermes phase `review`** → run `closure.py audit`; use `closure.py repair` only for listed gaps and within the configured limit. +- **Hermes phase `blocked`** → read the capsule and `HANDOFF.md`; do not widen scope or bypass human approval. +- **Hermes phase `closed`** → the task may be finished or archived; this does not imply claim approval. - **No active task** → classify first. For simple conversation / small task, ask only whether this turn should create a Trellis task. For complex work, ask whether you may create a Trellis task and enter planning. If the user says no, skip Trellis for this session. --- diff --git a/packages/cli/src/templates/common/skills/brainstorm.md b/packages/cli/src/templates/common/skills/brainstorm.md index b593193..8dff091 100644 --- a/packages/cli/src/templates/common/skills/brainstorm.md +++ b/packages/cli/src/templates/common/skills/brainstorm.md @@ -51,6 +51,7 @@ Use a concise title from the user's request. Use a slug without a date prefix. ` 8. On the Claude Code path, `trellis-grill-me` is a required planning gate. Before it is complete, do not switch into development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. 9. Before implementation starts on the Claude Code path, record the development strategy decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, and the task-level review-gate selection. Ask these in a single `A.` / `B.` / `C.` style option block. New tasks must stamp that strategy block with `Review-gate contract: explicit-selection-v1`. Lightweight tasks may keep that record in `prd.md`; complex tasks should keep it in `implement.md` together with the enabled/disabled review gates and the preserved execution order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. The selectable review gates are `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user leaves all optional gates off, still record all five in the disabled list; `trellis-check` stays fixed outside this set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`, so do not record or accept the deep-review gate without that prerequisite. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. 10. For complex tasks, create or update `design.md` and `implement.md` only after `trellis-grill-me` is complete and implementation is about to start. +11. For a Hermes closure task, copy the final intent, scope boundaries, and acceptance criteria into `task.json` through `closure.py plan`. Keep 1-4 observable-result work packages by default; commands and file operations belong in `done_when`, not separate packages. Run `closure.py validate` before `task.py start`. Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. @@ -108,5 +109,7 @@ Before declaring planning ready: - Remaining open questions are genuinely about user intent or scope. - Complex tasks have `design.md` and `implement.md`. - The user has reviewed the final planning artifacts or explicitly approved proceeding. +- The closure plan has an intent, definition of done, and 1-4 appropriately sized work packages; 5 or more only carries a task-split warning. +- `closure.py validate` passes before `task.py start` for new Hermes tasks. Do not start implementation until the user approves or asks for implementation. diff --git a/packages/cli/src/templates/common/skills/check.md b/packages/cli/src/templates/common/skills/check.md index 86d8568..59f9da1 100644 --- a/packages/cli/src/templates/common/skills/check.md +++ b/packages/cli/src/templates/common/skills/check.md @@ -13,7 +13,13 @@ git status ## Step 2: Read Task Artifacts and Applicable Specs -Read the current task artifacts in order: +For a Hermes closure task, start with the compact capsule and current package: + +```bash +python3 ./.trellis/scripts/closure.py capsule --task +``` + +Read full task artifacts only when the capsule or changed files require them: - `prd.md` - `design.md` if present @@ -37,16 +43,22 @@ Run the project's lint, type-check, and test commands. Fix any failures before p ## Step 4: Run Hermes Quality Gates -Use the current active task name for ``. Run all Hermes checks below in addition to the normal project checks: +Use the current active task name for ``. First finish the current package checks and record its evidence. Run closure audit only after all packages are disposed. Gate depth follows `closure_mode`; lean does not require publication evidence: ```bash +# after all packages are done/deferred/waived +{{PYTHON_CMD}} ./.trellis/scripts/closure.py audit --task + +# standard and publication {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind audit {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind provenance {{PYTHON_CMD}} ./.trellis/scripts/hermes/validate.py --task --kind service_queue + +# publication only {{PYTHON_CMD}} ./.trellis/scripts/hermes/report.py quality-gate --task ``` -Fix any Hermes failures before proceeding. Do not mark `trellis-check` complete until all four commands exit 0. +Fix any Hermes failures before proceeding, but run only the commands required by the active mode. Do not promote a lean engineering check into scientific claim approval. ## Step 5: Review Against Checklist @@ -104,3 +116,5 @@ Skip this step if your change is confined to a single layer. ## Step 7: Report and Fix Report violations found and fix them directly. Re-run project checks after fixes. + +For a running Hermes package, move it to review with `closure.py package-check`. Mark it done only after the checks above pass and provide concrete `--evidence` references. When all packages are disposed, run `closure.py audit`; do not substitute a successful test run for task closure or claim approval. diff --git a/packages/cli/src/templates/opencode/lib/session-utils.js b/packages/cli/src/templates/opencode/lib/session-utils.js index c32f687..39755fa 100644 --- a/packages/cli/src/templates/opencode/lib/session-utils.js +++ b/packages/cli/src/templates/opencode/lib/session-utils.js @@ -80,6 +80,10 @@ function getTaskStatus(ctx, platformInput = null) { const taskTitle = taskData.title || taskRef const taskStatus = taskData.status || "unknown" + if (["closure_state", "hermes_phase", "work_packages"].some(field => field in taskData)) { + return `Status: HERMES CLOSURE\n${buildTaskCapsule(taskData, taskRef, taskDir)}` + } + if (taskStatus === "completed") { return `Status: COMPLETED\nTask: ${taskTitle}\nNext-Action: Run /trellis:finish-work. If the working tree is dirty, return to Phase 3.4 first.` } @@ -127,6 +131,52 @@ function getTaskStatus(ctx, platformInput = null) { ) } +function compactValue(value, limit) { + const text = String(value || "").replace(/\s+/g, " ").trim() + return text.length <= limit ? text : `${text.slice(0, limit - 3).trim()}...` +} + +function compactValues(value, count, limit = 180) { + return compactValue(Array.isArray(value) ? value.slice(0, count).join("; ") : "", limit) +} + +function closureContextRefs(data, taskDir) { + const refs = [] + for (const filename of ["implement.jsonl", "check.jsonl"]) { + try { + const content = readFileSync(join(taskDir, filename), "utf-8") + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue + const row = JSON.parse(line) + if (typeof row?.file === "string" && row.file.trim()) refs.push(row.file.trim()) + } + } catch { + // Missing or malformed optional context is skipped. + } + } + if (Array.isArray(data.relatedFiles)) refs.push(...data.relatedFiles) + return [...new Set(refs.filter(value => typeof value === "string" && value.trim()))].slice(0, 3) +} + +function buildTaskCapsule(data, taskRef, taskDir) { + const packages = Array.isArray(data.work_packages) ? data.work_packages : [] + const current = packages.find(item => item?.id === data.current_work_package) + const lines = [ + `Task: ${data.id || taskRef} | ${data.title || taskRef}`, + `Intent: ${compactValue(data.intent || data.description || "", 180)}`, + `Scope: ${compactValues(data.in_scope, 2) || "-"} | Out: ${compactValues(data.out_of_scope, 2) || "-"}`, + `Mode/Phase: ${data.closure_mode || "lean"} / ${data.hermes_phase || "planning"}`, + `Current: ${data.current_work_package || "-"}${current ? ` - ${compactValue(current.outcome, 140)}` : ""}`, + ] + if (current) lines.push(`Done when: ${compactValues(current.done_when, 3, 240)}`) + lines.push(`Next: ${compactValue(data.next_action || "-", 180)}`) + lines.push(`Blockers: ${compactValues(data.blockers, 2) || "-"}`) + const refs = closureContextRefs(data, taskDir) + if (refs.length > 0) lines.push(`Refs: ${refs.join(", ")}`) + const capsule = lines.join("\n") + return capsule.length <= 1000 ? capsule : `${capsule.slice(0, 997).trim()}...` +} + function loadTrellisConfig(directory, contextKey = null) { const scriptPath = join(directory, ".trellis", "scripts", "get_context.py") if (!existsSync(scriptPath)) { @@ -442,9 +492,9 @@ Trellis compact SessionStart context. Use it to orient the session; load details parts.push("") parts.push( - "Task context order for implementation/check: jsonl entries -> `prd.md` -> " + - "`design.md if present` -> `implement.md if present`. Missing optional artifacts " + - "are skipped for lightweight tasks.\n" + "Task context order for Hermes closure tasks: Task Capsule -> current package -> related jsonl/spec entries. " + + "Load full PRD, reports, events, ledgers, history, and unrelated specs only when needed. " + + "Legacy task context remains jsonl -> PRD -> optional design/implement files.\n" ) if (paths.length > 0) { diff --git a/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js b/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js index 888fb59..59e3d48 100644 --- a/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js +++ b/packages/cli/src/templates/opencode/plugins/inject-workflow-state.js @@ -76,12 +76,62 @@ function getActiveTask(ctx, platformInput = null) { const status = typeof data.status === "string" ? data.status : "" if (!status) return null const id = data.id || taskRef.split("/").pop() - return { id, status, source: active.source } + return { id, status, source: active.source, data, taskDir } } catch { return null } } +function clip(value, limit) { + const compact = String(value || "").replace(/\s+/g, " ").trim() + return compact.length <= limit ? compact : `${compact.slice(0, limit - 3).trim()}...` +} + +function compactList(value, count, limit = 180) { + return clip(Array.isArray(value) ? value.slice(0, count).join("; ") : "", limit) +} + +function contextRefs(task) { + const refs = [] + for (const filename of ["implement.jsonl", "check.jsonl"]) { + try { + const content = readFileSync(join(task.taskDir, filename), "utf-8") + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue + const row = JSON.parse(line) + if (typeof row?.file === "string" && row.file.trim()) refs.push(row.file.trim()) + } + } catch { + // Missing or malformed optional context is skipped. + } + } + if (Array.isArray(task.data.relatedFiles)) refs.push(...task.data.relatedFiles) + return [...new Set(refs.filter(value => typeof value === "string" && value.trim()))].slice(0, 3) +} + +function buildTaskCapsule(task) { + const data = task?.data + if (!data || !["closure_state", "hermes_phase", "work_packages"].some(field => field in data)) { + return "" + } + const packages = Array.isArray(data.work_packages) ? data.work_packages : [] + const current = packages.find(item => item?.id === data.current_work_package) + const lines = [ + `Task: ${data.id || task.id} | ${data.title || task.id}`, + `Intent: ${clip(data.intent || data.description || "", 180)}`, + `Scope: ${compactList(data.in_scope, 2) || "-"} | Out: ${compactList(data.out_of_scope, 2) || "-"}`, + `Mode/Phase: ${data.closure_mode || "lean"} / ${data.hermes_phase || "planning"}`, + `Current: ${data.current_work_package || "-"}${current ? ` - ${clip(current.outcome, 140)}` : ""}`, + ] + if (current) lines.push(`Done when: ${compactList(current.done_when, 3, 240)}`) + lines.push(`Next: ${clip(data.next_action || "-", 180)}`) + lines.push(`Blockers: ${compactList(data.blockers, 2) || "-"}`) + const refs = contextRefs(task) + if (refs.length > 0) lines.push(`Refs: ${refs.join(", ")}`) + const capsule = lines.join("\n") + return capsule.length <= 1000 ? capsule : `${capsule.slice(0, 997).trim()}...` +} + /** * Build the ... block. * - Known status (tag present in workflow.md) → detailed body @@ -98,6 +148,10 @@ function buildBreadcrumb(id, status, templates) { return `\n${header}\n${body}\n` } +function buildClosureBreadcrumb(task) { + return `\nTask: ${task.id} (${task.status})\nHermes closure: use only the Task Capsule and its next action. Plan/validate before start; execute only the current package; audit in review; use bounded repair for listed gaps; close before archive. Load full artifacts on demand.\n` +} + // OpenCode 1.2.x expects plugins to be factory functions (see inject-subagent-context.js comment). export default async ({ directory }) => { const ctx = new TrellisContext(directory) @@ -126,9 +180,11 @@ export default async ({ directory }) => { } const templates = loadBreadcrumbs(directory) const task = getActiveTask(ctx, input) - const breadcrumb = task - ? buildBreadcrumb(task.id, task.status, templates, task.source) + const capsule = buildTaskCapsule(task) + let breadcrumb = task + ? (capsule ? buildClosureBreadcrumb(task) : buildBreadcrumb(task.id, task.status, templates, task.source)) : buildBreadcrumb(null, "no_task", templates) + if (capsule) breadcrumb += `\n\n\n${capsule}\n` const parts = output?.parts || [] const textPartIndex = parts.findIndex( 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 2e1d054..6da1c90 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -169,6 +169,36 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st return task_id, status, active.source +def get_task_capsule(root: Path, input_data: dict) -> str | None: + """Build the compact Hermes capsule for the active closure task.""" + active = _resolve_active_task(root, input_data) + if not active.task_path or active.stale: + return None + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + task_json = task_dir / "task.json" + if not task_json.is_file(): + return None + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(data, dict) or not any( + field in data for field in ("closure_state", "hermes_phase", "work_packages") + ): + return None + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.closure import build_capsule # type: ignore[import-not-found] + + return build_capsule(task_dir, data, root) + except Exception: + return None + + # --------------------------------------------------------------------------- # Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults # --------------------------------------------------------------------------- @@ -307,6 +337,17 @@ def build_breadcrumb( return f"\n{header}\n{body}\n" +def build_closure_breadcrumb(task_id: str, status: str) -> str: + return ( + "\n" + f"Task: {task_id} ({status})\n" + "Hermes closure: use only the Task Capsule and its next action. " + "Plan/validate before start; execute only the current package; audit in review; " + "use bounded repair for listed gaps; close before archive. Load full artifacts on demand.\n" + "" + ) + + # --------------------------------------------------------------------------- # Entry # --------------------------------------------------------------------------- @@ -356,6 +397,7 @@ def main() -> int: config = _read_trellis_config(root) platform = _detect_platform(data) task = get_active_task(root, data) + capsule = get_task_capsule(root, data) if task is None: # No active task — still emit a breadcrumb nudging AI toward # trellis-brainstorm + task.py create when user describes real work. @@ -370,6 +412,8 @@ def main() -> int: breadcrumb = build_breadcrumb( task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key ) + if capsule: + breadcrumb = build_closure_breadcrumb(task_id, status) if platform == "codex": parts: list[str] = [] boot_guard_notice = _build_codex_hermes_boot_guard_notice(root) @@ -379,7 +423,11 @@ def main() -> int: parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) parts.append(_codex_mode_banner(config)) parts.append(breadcrumb) + if capsule: + parts.append(f"\n{capsule}\n") breadcrumb = "\n\n".join(parts) + elif capsule: + breadcrumb = f"{breadcrumb}\n\n\n{capsule}\n" # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ diff --git a/packages/cli/src/templates/shared-hooks/session-start.py b/packages/cli/src/templates/shared-hooks/session-start.py index f81d418..21ce6f4 100644 --- a/packages/cli/src/templates/shared-hooks/session-start.py +++ b/packages/cli/src/templates/shared-hooks/session-start.py @@ -498,6 +498,19 @@ def _get_task_status(trellis_dir: Path, input_data: dict) -> str: task_title = task_data.get("title", task_ref) task_status = task_data.get("status", "unknown") + if isinstance(task_data, dict) and any( + field in task_data for field in ("closure_state", "hermes_phase", "work_packages") + ): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.closure import build_capsule # type: ignore[import-not-found] + + capsule = build_capsule(task_dir, task_data, trellis_dir.parent) + return f"Status: HERMES CLOSURE\n{capsule}" + except Exception: + pass artifact_names = ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") present = [name for name in artifact_names if (task_dir / name).is_file()] if (task_dir / "research").is_dir(): @@ -944,9 +957,10 @@ def main(): output.write("\n") output.write( - "Task context order for implementation/check: jsonl entries -> `prd.md` -> " - "`design.md if present` -> `implement.md if present`. Missing optional artifacts " - "are skipped for lightweight tasks.\n\n" + "Task context order for Hermes closure tasks: Task Capsule -> current work package -> " + "related jsonl/spec entries. Load `prd.md`, reports, task-events.jsonl, ledgers, " + "historical tasks, and unrelated specs only when needed. Legacy task context order " + "remains jsonl entries -> `prd.md` -> optional design/implement files.\n\n" ) if spec_index_paths: diff --git a/packages/cli/src/templates/trellis/index.ts b/packages/cli/src/templates/trellis/index.ts index 8402933..ac2aaec 100644 --- a/packages/cli/src/templates/trellis/index.ts +++ b/packages/cli/src/templates/trellis/index.ts @@ -80,6 +80,7 @@ export const commonTypes = readTemplate("scripts/common/types.py"); 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 commonSessionContext = readTemplate( "scripts/common/session_context.py", ); @@ -107,6 +108,7 @@ export const initDeveloperScript = readTemplate("scripts/init_developer.py"); export const taskScript = readTemplate("scripts/task.py"); export const getContextScript = readTemplate("scripts/get_context.py"); export const addSessionScript = readTemplate("scripts/add_session.py"); +export const closureScript = readTemplate("scripts/closure.py"); // Configuration files export const workflowMdTemplate = readTemplate("workflow.md"); @@ -139,6 +141,7 @@ export function getAllScripts(): Map { scripts.set("common/tasks.py", commonTasks); scripts.set("common/task_context.py", commonTaskContext); scripts.set("common/task_store.py", commonTaskStore); + scripts.set("common/closure.py", commonClosure); scripts.set("common/session_context.py", commonSessionContext); scripts.set("common/packages_context.py", commonPackagesContext); scripts.set("common/workflow_phase.py", commonWorkflowPhase); @@ -152,6 +155,7 @@ export function getAllScripts(): Map { scripts.set("task.py", taskScript); scripts.set("get_context.py", getContextScript); scripts.set("add_session.py", addSessionScript); + scripts.set("closure.py", closureScript); scripts.set("trellis_switch.py", trellisSwitch); scripts.set("assert_trellis_enabled.py", assertTrellisEnabled); diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py new file mode 100644 index 0000000..c71e0bd --- /dev/null +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Lean Research Closure CLI for one Trellis task and 1-4 work packages.""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from common.closure import ( + CLOSURE_MODES, + ClosureError, + actor_name, + amend_plan, + audit_closure, + block_package, + build_capsule, + check_package, + close_task, + complete_package, + format_audit_yaml, + is_closure_task, + package_by_id, + plan_closure, + repair_closure, + resolve_closure_task, + start_package, + validate_and_ready, + write_handoff, +) + + +def add_common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--task", help="Task name/path; defaults to active task") + parser.add_argument("--actor", help="Actor written to task-events.jsonl") + + +def package_spec(value: str) -> dict[str, Any]: + """Parse TITLE::OUTCOME::DONE[|DONE]::EVIDENCE[|EVIDENCE].""" + parts = value.split("::") + if len(parts) < 3: + raise argparse.ArgumentTypeError( + "package must be TITLE::OUTCOME::DONE_WHEN[|DONE_WHEN]" + ) + return { + "title": parts[0].strip(), + "outcome": parts[1].strip(), + "done_when": _pipe_values(parts[2]), + "evidence_required": _pipe_values(parts[3]) if len(parts) > 3 else [], + } + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + description="Plan, execute, audit, and close a lean research task.", + ) + subparsers = root.add_subparsers(dest="command", required=True) + + plan = subparsers.add_parser("plan", help="Create a lean closure plan") + add_common(plan) + plan.add_argument("--intent") + plan.add_argument("--in-scope", action="append", default=None) + plan.add_argument("--out-of-scope", action="append", default=None) + plan.add_argument("--done-when", action="append", default=None) + plan.add_argument("--package", action="append", type=package_spec, default=None) + plan.add_argument("--mode", choices=sorted(CLOSURE_MODES)) + + validate = subparsers.add_parser("validate", help="Validate plan and enter ready") + add_common(validate) + + status = subparsers.add_parser("status", help="Show compact closure status") + add_common(status) + status.add_argument("--json", action="store_true") + + next_command = subparsers.add_parser("next", help="Show the bounded next action") + add_common(next_command) + + capsule = subparsers.add_parser("capsule", help="Print compact task context") + add_common(capsule) + + package_start = subparsers.add_parser("package-start", help="Start a ready package") + add_common(package_start) + package_start.add_argument("--package-id") + package_start.add_argument("--reason", default="") + + package_check = subparsers.add_parser("package-check", help="Move running package to review") + add_common(package_check) + package_check.add_argument("--package-id") + + package_done = subparsers.add_parser("package-done", help="Dispose a reviewed package") + add_common(package_done) + package_done.add_argument("--package-id") + package_done.add_argument("--evidence", action="append", default=[]) + package_done.add_argument("--disposition", choices=["done", "deferred", "waived"], default="done") + package_done.add_argument("--reason", default="") + + package_block = subparsers.add_parser("package-block", help="Block the current package") + add_common(package_block) + package_block.add_argument("--package-id") + package_block.add_argument("--reason", required=True) + + amend = subparsers.add_parser("amend", help="Record and apply a bounded plan change") + add_common(amend) + amend.add_argument("--field", required=True) + 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") + + audit = subparsers.add_parser("audit", help="Audit closure without making a new plan") + add_common(audit) + audit.add_argument("--json", action="store_true") + audit.add_argument("--no-report", action="store_true") + + repair = subparsers.add_parser("repair", help="Run one bounded audit-gap repair round") + add_common(repair) + repair.add_argument("--json", action="store_true") + + handoff = subparsers.add_parser("handoff", help="Write a conditional HANDOFF.md") + add_common(handoff) + + close = subparsers.add_parser("close", help="Audit and close the task") + add_common(close) + close.add_argument("--json", action="store_true") + + return root + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + 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"}: + 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 args.command == "plan": + warnings = plan_closure( + task_dir, + data, + intent=args.intent, + in_scope=args.in_scope, + out_of_scope=args.out_of_scope, + definition_of_done=args.done_when, + packages=args.package, + mode=args.mode, + ) + print(f"planned {len(data.get('work_packages') or [])} work package(s)") + for warning in warnings: + print(f"warning: {warning}", file=sys.stderr) + return 0 + if args.command == "validate": + errors, warnings = validate_and_ready(task_dir, data, actor=actor) + for warning in warnings: + print(f"warning: {warning}", file=sys.stderr) + if errors: + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 1 + print("valid closure plan; phase=ready") + return 0 + if args.command == "status": + if args.json: + print(json.dumps(_status_record(data), ensure_ascii=False, indent=2)) + else: + print(build_capsule(task_dir, data, repo_root)) + return 0 + if args.command == "next": + print(data.get("next_action") or "Run closure.py plan or validate.") + return 0 + if args.command == "capsule": + print(build_capsule(task_dir, data, repo_root)) + return 0 + if args.command == "package-start": + package = start_package( + task_dir, + data, + args.package_id, + actor=actor, + reason=args.reason, + ) + print(f"started {package['id']}: {package['outcome']}") + return 0 + if args.command == "package-check": + package = check_package(task_dir, data, args.package_id, actor=actor) + print(f"review {package['id']}: validate done_when and evidence") + return 0 + if args.command == "package-done": + package = complete_package( + task_dir, + data, + args.package_id, + actor=actor, + evidence_refs=args.evidence, + disposition=args.disposition, + reason=args.reason, + ) + print(f"{package['id']} -> {package['status']}") + return 0 + if args.command == "package-block": + package = block_package( + task_dir, + data, + args.package_id, + actor=actor, + reason=args.reason, + ) + print(f"blocked {package['id']}: {package['blocker']}") + return 0 + if args.command == "amend": + result = amend_plan( + task_dir, + data, + field=args.field, + new_value=_json_or_text(args.value), + reason=args.reason, + actor=actor, + affected_packages=args.affected_package, + approved_by=args.approved_by, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "audit": + result = audit_closure(task_dir, data, write_report=not args.no_report) + _print_audit(result, args.json) + return 0 if result["status"] == "all_met" else 1 + if args.command == "repair": + result = repair_closure(task_dir, data, actor=actor) + _print_audit(result, args.json) + return 0 if result["status"] == "all_met" else 1 + if args.command == "handoff": + path = write_handoff(task_dir, data, repo_root) + print(path.relative_to(repo_root).as_posix()) + return 0 + if args.command == "close": + result = close_task(task_dir, data, actor=actor) + _print_audit(result, args.json) + return 0 if result["status"] == "all_met" else 1 + return 2 + except ClosureError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +def _pipe_values(value: str) -> list[str]: + return [item.strip() for item in value.split("|") if item.strip()] + + +def _json_or_text(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _status_record(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 { + "task_status": data.get("status"), + "hermes_phase": data.get("hermes_phase"), + "closure_state": data.get("closure_state"), + "closure_mode": data.get("closure_mode"), + "current_work_package": current_package, + "next_action": data.get("next_action"), + "blockers": data.get("blockers") or [], + "repair_count": data.get("repair_count", 0), + "max_repair_count": data.get("max_repair_count", 1), + } + + +def _print_audit(result: dict[str, Any], as_json: bool) -> None: + if as_json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(format_audit_yaml(result)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py new file mode 100644 index 0000000..c1a0c21 --- /dev/null +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -0,0 +1,1531 @@ +"""Lean Research Closure state, audit, capsule, and report helpers.""" + +from __future__ import annotations + +import json +import re +import subprocess +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .io import read_json, write_json +from .paths import FILE_TASK_JSON, get_current_task, get_developer, get_repo_root +from .task_utils import resolve_task_dir + + +CLOSURE_MODES = {"lean", "standard", "publication"} +HERMES_PHASES = {"planning", "ready", "running", "review", "blocked", "closed"} +PACKAGE_STATUSES = { + "pending", + "ready", + "running", + "review", + "done", + "blocked", + "deferred", + "waived", +} +DISPOSED_PACKAGE_STATUSES = {"done", "deferred", "waived"} +HIGH_RISK_FIELDS = { + "closure_mode", + "hypothesis", + "dataset", + "split", + "metric_definition", + "baseline", + "claim_scope", + "in_scope", + "out_of_scope", + "max_repair_count", +} +AMENDABLE_CLOSURE_FIELDS = { + "closure_mode", + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "max_repair_count", +} +AMENDABLE_PACKAGE_FIELDS = { + "title", + "outcome", + "done_when", + "evidence_required", + "depends_on", +} +EVENT_TYPES = { + "phase_changed", + "package_started", + "package_completed", + "package_blocked", + "plan_amended", + "repair_started", + "task_closed", +} +MICROSTEP_RE = re.compile( + r"^(?:read|open|inspect|view|modify (?:a )?file|edit (?:a )?file|" + r"run (?:a |the )?(?:command|script)|check (?:the )?output|" + r"读取(?:文件)?|打开(?:文件)?|查看(?:文件|输出)?|修改文件|运行(?:命令|脚本))(?:\b|$)", + re.IGNORECASE, +) +PRD_DONE_HEADINGS = re.compile( + r"^(?:definition of done|acceptance criteria|验收标准|完成定义|完成条件)\s*$", + re.IGNORECASE, +) +VALIDATION_ONLY_RE = re.compile( + r"^(?:(?:unit|integration|e2e|smoke|regression)\s+)?tests?\s+(?:pass|passes)|" + r"^(?:lint|typecheck|type-check|build|format(?:ting)?)\s+(?:pass|passes)|" + r"^(?:测试|单元测试|集成测试|回归测试|构建|类型检查|代码检查)(?:全部)?通过$", + re.IGNORECASE, +) + + +class ClosureError(ValueError): + """Raised for a user-correctable closure contract violation.""" + + +def now_utc() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def default_repair_limit(mode: str) -> int: + return 1 if mode == "lean" else 2 + + +def is_closure_task(data: dict[str, Any] | None) -> bool: + return bool(data) and ( + "closure_state" in data + or "hermes_phase" in data + or "work_packages" in data + ) + + +def closure_defaults(mode: str = "lean") -> dict[str, Any]: + return { + "hermes_phase": "planning", + "closure_state": "open", + "closure_mode": mode, + "intent": "", + "in_scope": [], + "out_of_scope": [], + "definition_of_done": [], + "work_packages": [], + "current_work_package": None, + "next_action": None, + "blockers": [], + "repair_count": 0, + "max_repair_count": default_repair_limit(mode), + } + + +def ensure_closure_defaults(data: dict[str, Any], mode: str | None = None) -> None: + selected_mode = mode or str(data.get("closure_mode") or "lean") + if selected_mode not in CLOSURE_MODES: + selected_mode = "lean" + defaults = closure_defaults(selected_mode) + for key, value in defaults.items(): + if key not in data: + data[key] = list(value) if isinstance(value, list) else value + data["closure_mode"] = selected_mode + if not isinstance(data.get("max_repair_count"), int): + data["max_repair_count"] = default_repair_limit(selected_mode) + + +def resolve_closure_task( + task_ref: str | None, + repo_root: Path | None = None, +) -> tuple[Path, dict[str, Any], Path]: + root = repo_root or get_repo_root() + resolved_ref = task_ref or get_current_task(root) + if not resolved_ref: + raise ClosureError("no task supplied and no active task is available") + task_dir = resolve_task_dir(resolved_ref, root) + task_json = task_dir / FILE_TASK_JSON + if not task_dir.is_dir() or not task_json.is_file(): + raise ClosureError(f"task not found: {resolved_ref}") + data = read_json(task_json) + if not isinstance(data, dict): + raise ClosureError(f"cannot read {task_json}") + return task_dir, data, root + + +def save_task(task_dir: Path, data: dict[str, Any]) -> None: + if not write_json(task_dir / FILE_TASK_JSON, data): + raise ClosureError("failed to write task.json") + + +def append_event( + task_dir: Path, + event_type: str, + *, + actor: str, + old_state: str = "", + new_state: str = "", + package_id: str | None = None, + reason: str = "", + evidence_refs: list[str] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + if event_type not in EVENT_TYPES: + raise ClosureError(f"unsupported closure event: {event_type}") + record: dict[str, Any] = { + "event_id": f"evt-{uuid.uuid4().hex[:16]}", + "event_type": event_type, + "timestamp": now_utc(), + "actor": actor or "unknown", + "old_state": old_state, + "new_state": new_state, + "package_id": package_id, + "reason": reason, + "evidence_refs": list(evidence_refs or []), + } + if extra: + record.update(extra) + path = task_dir / "hermes" / "task-events.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + return record + + +def actor_name(root: Path, explicit: str | None = None) -> str: + return explicit or get_developer(root) or "unknown" + + +def parse_prd_definition_of_done(prd_path: Path) -> list[str]: + if not prd_path.is_file(): + return [] + try: + lines = prd_path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + in_section = False + values: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("#"): + heading = stripped.lstrip("#").strip() + if PRD_DONE_HEADINGS.match(heading): + in_section = True + continue + if in_section: + break + if not in_section: + continue + match = re.match(r"^[-*]\s+\[[ xX]\]\s+(.+)$", stripped) + if match: + values.append(match.group(1).strip()) + return values + + +def is_microstep(value: str) -> bool: + return bool(MICROSTEP_RE.search(value.strip())) + + +def is_validation_only(value: str) -> bool: + return bool(VALIDATION_ONLY_RE.search(value.strip())) + + +def _short_title(value: str, index: int) -> str: + compact = re.sub(r"\s+", " ", value).strip().rstrip("。.") + if not compact: + return f"Work package {index}" + return compact if len(compact) <= 60 else compact[:57].rstrip() + "..." + + +def candidate_work_packages( + intent: str, + definition_of_done: list[str], +) -> list[dict[str, Any]]: + criteria = [item.strip() for item in definition_of_done if item.strip()] + outcomes = [ + item for item in criteria + if not is_microstep(item) and not is_validation_only(item) + ] + if not outcomes: + outcome = intent.strip() or "Complete the task's observable result" + outcomes = [outcome] + packages: list[dict[str, Any]] = [] + for index, outcome in enumerate(outcomes, start=1): + packages.append( + { + "id": f"WP{index}", + "title": _short_title(outcome, index), + "outcome": outcome, + "done_when": [outcome], + "evidence_required": [], + "depends_on": [], + "status": "pending", + "evidence_refs": [], + "blocker": None, + } + ) + current_index = 0 + for criterion in criteria: + if criterion in outcomes: + current_index = outcomes.index(criterion) + continue + package = packages[current_index] + package["done_when"] = _dedupe([*package["done_when"], criterion]) + return packages + + +def normalize_package(raw: dict[str, Any], index: int) -> dict[str, Any]: + return { + "id": str(raw.get("id") or f"WP{index}"), + "title": str(raw.get("title") or f"Work package {index}"), + "outcome": str(raw.get("outcome") or ""), + "done_when": _string_list(raw.get("done_when")), + "evidence_required": _string_list(raw.get("evidence_required")), + "depends_on": _string_list(raw.get("depends_on")), + "status": str(raw.get("status") or "pending"), + "evidence_refs": _string_list(raw.get("evidence_refs")), + "blocker": raw.get("blocker") if isinstance(raw.get("blocker"), str) else None, + } + + +def plan_closure( + task_dir: Path, + data: dict[str, Any], + *, + intent: str | None = None, + in_scope: list[str] | None = None, + out_of_scope: list[str] | None = None, + definition_of_done: list[str] | None = None, + packages: list[dict[str, Any]] | None = None, + mode: str | None = None, +) -> list[str]: + old_phase = str(data.get("hermes_phase") or "planning") + 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) + if intent is not None: + data["intent"] = intent.strip() + if not str(data.get("intent") or "").strip(): + data["intent"] = str(data.get("description") or data.get("title") or "").strip() + if in_scope is not None: + data["in_scope"] = _dedupe(in_scope) + if out_of_scope is not None: + data["out_of_scope"] = _dedupe(out_of_scope) + if definition_of_done is not None: + data["definition_of_done"] = _dedupe(definition_of_done) + if not _string_list(data.get("definition_of_done")): + data["definition_of_done"] = parse_prd_definition_of_done(task_dir / "prd.md") + + selected = packages + if selected is None and not isinstance(data.get("work_packages"), list): + selected = [] + if selected is None and not data.get("work_packages"): + selected = candidate_work_packages( + str(data.get("intent") or ""), + _string_list(data.get("definition_of_done")), + ) + if selected is not None: + data["work_packages"] = [normalize_package(item, i) for i, item in enumerate(selected, start=1)] + + _reset_incomplete_packages(data) + data["status"] = "planning" + data["hermes_phase"] = "planning" + data["closure_state"] = "open" + data["current_work_package"] = None + data["next_action"] = "Run closure.py validate before starting work." + if mode: + data["max_repair_count"] = default_repair_limit(str(data["closure_mode"])) + save_task(task_dir, data) + count = len(data.get("work_packages") or []) + return [ + f"{count} work packages exceed the lean default; consider multiple Trellis tasks." + ] if count >= 5 else [] + + +def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + if data.get("closure_mode") not in CLOSURE_MODES: + errors.append("closure_mode must be lean, standard, or publication") + phase = data.get("hermes_phase") + task_status = data.get("status") + closure_state = data.get("closure_state") + if phase not in HERMES_PHASES: + errors.append("hermes_phase is invalid") + if closure_state not in {"open", "closed"}: + errors.append("closure_state must be open or closed") + expected_status = { + "planning": "planning", + "ready": "planning", + "running": "in_progress", + "review": "in_progress", + "blocked": "in_progress", + "closed": "completed", + }.get(str(phase)) + if expected_status and task_status != expected_status: + errors.append(f"task status {task_status} conflicts with hermes_phase {phase}") + if phase == "closed" and closure_state != "closed": + errors.append("closed phase requires closure_state=closed") + if phase != "closed" and closure_state == "closed": + errors.append("closure_state=closed requires hermes_phase=closed") + if phase in {"planning", "ready", "closed"} and data.get("current_work_package") is not None: + errors.append(f"hermes_phase {phase} cannot have a current work package") + current_id = data.get("current_work_package") + current_package = next( + ( + package for package in data.get("work_packages") or [] + if isinstance(package, dict) and package.get("id") == current_id + ), + None, + ) + if current_id is not None and current_package is None: + errors.append(f"current_work_package references unknown package {current_id}") + expected_current_status = { + "running": "running", + "review": "review", + "blocked": "blocked", + }.get(str(phase)) + if phase == "running" and current_package is None and not any( + isinstance(package, dict) and package.get("status") in {"pending", "ready"} + for package in data.get("work_packages") or [] + ): + errors.append("running phase without a current package requires pending or ready work") + if current_package is not None and expected_current_status: + if current_package.get("status") != expected_current_status: + errors.append( + f"current package status {current_package.get('status')} conflicts with hermes_phase {phase}" + ) + for field in ("repair_count", "max_repair_count"): + value = data.get(field) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + errors.append(f"{field} must be a non-negative integer") + if not str(data.get("intent") or "").strip(): + errors.append("intent is required") + if not _string_list(data.get("definition_of_done")): + errors.append("definition_of_done is required") + if _string_list(data.get("blockers")): + errors.append("unresolved blockers must be resolved before validation") + + packages = data.get("work_packages") + if not isinstance(packages, list) or not packages: + errors.append("at least one work package is required") + packages = [] + if len(packages) >= 5: + warnings.append("5 or more work packages: consider splitting into multiple Trellis tasks") + + ids: list[str] = [] + normalized_packages: list[dict[str, Any]] = [] + for index, raw in enumerate(packages, start=1): + if not isinstance(raw, dict): + errors.append(f"WP{index} must be an object") + continue + package = normalize_package(raw, index) + normalized_packages.append(package) + package_id = package["id"] + if not package_id: + errors.append(f"WP{index} id is required") + elif package_id in ids: + errors.append(f"duplicate work package id: {package_id}") + ids.append(package_id) + if not package["outcome"].strip(): + errors.append(f"{package_id}: outcome is required") + if not package["title"].strip(): + errors.append(f"{package_id}: title is required") + if not package["done_when"]: + errors.append(f"{package_id}: done_when is required") + if package["status"] not in PACKAGE_STATUSES: + errors.append(f"{package_id}: invalid status {package['status']}") + if is_microstep(package["outcome"]): + errors.append(f"{package_id}: outcome describes a microstep, not an observable result") + if is_validation_only(package["outcome"]): + errors.append(f"{package_id}: validation belongs in done_when, not a separate package") + + id_set = set(ids) + graph: dict[str, list[str]] = {} + for package in normalized_packages: + package_id = package["id"] + deps = package["depends_on"] + graph[package_id] = deps + for dependency in deps: + if dependency not in id_set: + errors.append(f"{package_id}: unknown dependency {dependency}") + if dependency == package_id: + errors.append(f"{package_id}: package cannot depend on itself") + if _has_dependency_cycle(graph): + errors.append("work package dependencies contain a cycle") + + in_scope = _string_list(data.get("in_scope")) + out_scope = _string_list(data.get("out_of_scope")) + for package in normalized_packages: + text = f"{package['title']} {package['outcome']}".casefold() + for excluded in out_scope: + excluded_text = excluded.casefold().strip() + if excluded_text and _scope_phrase_in_text(excluded_text, text) and not any( + _scope_phrase_in_text(excluded_text, item.casefold()) for item in in_scope + ): + errors.append(f"{package['id']}: conflicts with out_of_scope item '{excluded}'") + return _dedupe(errors), _dedupe(warnings) + + +def validate_and_ready( + task_dir: Path, + data: dict[str, Any], + *, + actor: str, +) -> tuple[list[str], list[str]]: + 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) + if errors: + return errors, warnings + old_phase = str(data.get("hermes_phase") or "planning") + _refresh_ready_packages(data) + data["hermes_phase"] = "ready" + data["status"] = "planning" + data["next_action"] = _next_package_action(data) + save_task(task_dir, data) + if old_phase != "ready": + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state="ready", + reason="closure plan validated", + ) + return [], warnings + + +def package_by_id(data: dict[str, Any], package_id: str) -> dict[str, Any]: + for package in data.get("work_packages") or []: + if isinstance(package, dict) and package.get("id") == package_id: + return package + raise ClosureError(f"unknown work package: {package_id}") + + +def next_ready_package_id(data: dict[str, Any]) -> str | None: + """Return the next dependency-ready package without persisting state.""" + _refresh_ready_packages(data) + package = next( + ( + item for item in data.get("work_packages") or [] + if isinstance(item, dict) and item.get("status") == "ready" + ), + None, + ) + return str(package.get("id")) if package else None + + +def start_package( + task_dir: Path, + data: dict[str, Any], + package_id: str | None, + *, + actor: str, + reason: str = "", +) -> dict[str, Any]: + ensure_closure_defaults(data) + errors, _ = validate_closure(data) + if errors: + raise ClosureError("plan is not valid: " + "; ".join(errors)) + if data.get("hermes_phase") == "planning": + raise ClosureError("run closure.py validate before starting a package") + current = data.get("current_work_package") + if current: + current_package = package_by_id(data, str(current)) + if current_package.get("status") in {"running", "review", "blocked"}: + raise ClosureError(f"{current} is still {current_package.get('status')}") + _refresh_ready_packages(data) + if package_id: + package = package_by_id(data, package_id) + else: + package = next( + (item for item in data.get("work_packages") or [] if item.get("status") == "ready"), + None, + ) + if package is None: + raise ClosureError("no ready work package") + if package.get("status") != "ready": + raise ClosureError(f"{package.get('id')} is not ready") + old_phase = str(data.get("hermes_phase") or "ready") + package["status"] = "running" + package["blocker"] = None + data["current_work_package"] = package["id"] + data["hermes_phase"] = "running" + data["status"] = "in_progress" + data["next_action"] = f"Complete {package['id']} outcome, then run package-check." + save_task(task_dir, data) + if old_phase != "running": + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state="running", + package_id=package["id"], + reason=reason or "work package started", + ) + append_event( + task_dir, + "package_started", + actor=actor, + old_state="ready", + new_state="running", + package_id=package["id"], + reason=reason, + ) + return package + + +def check_package( + task_dir: Path, + data: dict[str, Any], + package_id: str | None, + *, + actor: str, +) -> dict[str, Any]: + resolved = package_id or str(data.get("current_work_package") or "") + if not resolved: + raise ClosureError("no current work package") + package = package_by_id(data, resolved) + if package.get("status") != "running": + raise ClosureError(f"{resolved} must be running before review") + old_phase = str(data.get("hermes_phase") or "running") + package["status"] = "review" + data["current_work_package"] = resolved + data["hermes_phase"] = "review" + data["status"] = "in_progress" + data["next_action"] = f"Validate {resolved} done_when and record evidence before package-done." + save_task(task_dir, data) + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state="review", + package_id=resolved, + reason="work package entered review", + ) + return package + + +def complete_package( + task_dir: Path, + data: dict[str, Any], + package_id: str | None, + *, + actor: str, + evidence_refs: list[str] | None = None, + disposition: str = "done", + reason: str = "", +) -> dict[str, Any]: + 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 "") + if not resolved: + raise ClosureError("no current work package") + package = package_by_id(data, resolved) + old_status = str(package.get("status") or "") + if old_status in DISPOSED_PACKAGE_STATUSES: + return package + current_id = data.get("current_work_package") + if current_id and current_id != resolved: + raise ClosureError(f"{current_id} is still the current work package") + refs = _dedupe([*package.get("evidence_refs", []), *(evidence_refs or [])]) + if disposition == "done": + if old_status != "review": + raise ClosureError(f"{resolved} must be in review before done") + if not refs: + raise ClosureError(f"{resolved} requires validation evidence before done") + elif not reason.strip(): + raise ClosureError(f"{disposition} requires a reason") + package["status"] = disposition + package["evidence_refs"] = refs + package["blocker"] = None + data["current_work_package"] = None + data["blockers"] = [ + item for item in _string_list(data.get("blockers")) + if resolved not in item + ] + _refresh_ready_packages(data) + if all( + item.get("status") in DISPOSED_PACKAGE_STATUSES + for item in data.get("work_packages") or [] + ): + data["hermes_phase"] = "review" + data["next_action"] = "Run closure.py audit, repair bounded gaps, then close." + else: + data["hermes_phase"] = "running" + data["next_action"] = _next_package_action(data) + data["status"] = "in_progress" + save_task(task_dir, data) + new_phase = str(data.get("hermes_phase") or "running") + old_phase = "review" + append_event( + task_dir, + "package_completed", + actor=actor, + old_state=old_status, + new_state=disposition, + package_id=resolved, + reason=reason, + evidence_refs=refs, + ) + if new_phase != old_phase: + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state=new_phase, + package_id=resolved, + reason="work package disposed", + ) + return package + + +def block_package( + task_dir: Path, + data: dict[str, Any], + package_id: str | None, + *, + actor: str, + reason: str, +) -> dict[str, Any]: + resolved = package_id or str(data.get("current_work_package") or "") + if not resolved: + raise ClosureError("no current work package") + if not reason.strip(): + raise ClosureError("block reason is required") + package = package_by_id(data, resolved) + old_phase = str(data.get("hermes_phase") or "running") + old_status = str(package.get("status") or "") + if old_status in DISPOSED_PACKAGE_STATUSES: + raise ClosureError("completed packages cannot be blocked") + package["status"] = "blocked" + package["blocker"] = reason.strip() + data["current_work_package"] = resolved + blocker = f"{resolved}: {reason.strip()}" + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + data["hermes_phase"] = "blocked" + data["status"] = "in_progress" + data["next_action"] = f"Resolve blocker for {resolved}; do not widen task scope." + save_task(task_dir, data) + append_event( + task_dir, + "package_blocked", + actor=actor, + old_state=old_status, + new_state="blocked", + package_id=resolved, + reason=reason, + ) + if old_phase != "blocked": + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state="blocked", + package_id=resolved, + reason=reason, + ) + write_handoff(task_dir, data, get_repo_root(task_dir)) + return package + + +def audit_closure( + task_dir: Path, + data: dict[str, Any], + *, + write_report: bool = False, +) -> dict[str, Any]: + gaps: list[dict[str, Any]] = [] + errors, _ = validate_closure(data) + 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)] + if data.get("hermes_phase") == "planning": + gaps.append(_gap(None, "validated closure plan", "运行 closure.py validate")) + if data.get("hermes_phase") == "blocked": + gaps.append(_gap(None, "blocked closure phase", "解决阻塞或取得人工批准")) + for package in packages: + package_id = str(package.get("id") or "") + status = package.get("status") + if status not in DISPOSED_PACKAGE_STATUSES: + gaps.append(_gap(package_id, f"package status is {status}", "完成、defer 或 waive 该工作包")) + if status == "done" and not _string_list(package.get("evidence_refs")): + gaps.append(_gap(package_id, "validation evidence", "登记基础验证 evidence_refs")) + if status == "done" and package.get("evidence_required"): + required_count = len(_string_list(package.get("evidence_required"))) + if len(_string_list(package.get("evidence_refs"))) < required_count: + gaps.append(_gap(package_id, "required evidence", "补齐 evidence_required 对应引用")) + + done_clauses = { + clause.casefold().strip() + for package in packages + if package.get("status") == "done" + for clause in _string_list(package.get("done_when")) + } + for item in _string_list(data.get("definition_of_done")): + normalized = item.casefold().strip() + if normalized and not any( + normalized == clause or normalized in clause or clause in normalized + for clause in done_clauses + ): + gaps.append(_gap(None, item, "由已完成工作包覆盖并登记验证证据")) + + blockers = _string_list(data.get("blockers")) + for blocker in blockers: + gaps.append(_gap(None, blocker, "解决 blocker 或经批准调整计划")) + + mode = str(data.get("closure_mode") or "lean") + if mode in {"standard", "publication"}: + gaps.extend(_standard_gaps(task_dir)) + if mode == "publication": + gaps.extend(_publication_gaps(task_dir)) + + gaps = _dedupe_gaps(gaps) + if not gaps and write_report: + write_closure_report(task_dir, data) + if not gaps and not (task_dir / "closure-report.md").is_file() and write_report: + gaps.append(_gap(None, "closure-report.md", "生成简短 closure report")) + + status = "all_met" if not gaps else "blocked" if blockers or data.get("hermes_phase") == "blocked" else "has_gaps" + return {"status": status, "gaps": gaps, "mode": mode} + + +def repair_closure( + task_dir: Path, + data: dict[str, Any], + *, + actor: str, +) -> dict[str, Any]: + ensure_closure_defaults(data) + before = audit_closure(task_dir, data, write_report=False) + if before["status"] == "all_met": + return before + has_package_gap = any(gap.get("package") for gap in before.get("gaps") or []) + if before["status"] == "blocked" and not has_package_gap: + data["hermes_phase"] = "blocked" + data["status"] = "in_progress" + data["next_action"] = "Resolve the recorded blocker or obtain required human approval." + save_task(task_dir, data) + write_handoff(task_dir, data, get_repo_root(task_dir)) + return before + count = int(data.get("repair_count") or 0) + limit = int(data.get("max_repair_count") or default_repair_limit(str(data.get("closure_mode")))) + if count >= limit: + return _block_repair_limit(task_dir, data, actor, before, count, limit) + data["repair_count"] = count + 1 + old_phase = str(data.get("hermes_phase") or "review") + repair_package = _prepare_repair_package(data, before) + if repair_package and repair_package.get("status") == "running": + data["hermes_phase"] = "running" + elif repair_package and repair_package.get("status") == "review": + data["hermes_phase"] = "review" + else: + data["hermes_phase"] = "review" + data["status"] = "in_progress" + repair_gap = next( + ( + gap for gap in before.get("gaps") or [] + if repair_package and gap.get("package") == repair_package.get("id") + ), + before["gaps"][0] if before.get("gaps") else None, + ) + data["next_action"] = str(repair_gap["action"]) if repair_gap else "Re-run audit." + save_task(task_dir, data) + append_event( + task_dir, + "repair_started", + actor=actor, + old_state=old_phase, + new_state=str(data["hermes_phase"]), + package_id=str(repair_package.get("id")) if repair_package else None, + reason="; ".join(str(item["missing"]) for item in before["gaps"][:3]), + ) + after = audit_closure(task_dir, data, write_report=True) + return after + + +def amend_plan( + task_dir: Path, + data: dict[str, Any], + *, + field: str, + new_value: Any, + reason: str, + actor: str, + affected_packages: list[str] | None = None, + approved_by: str | None = None, +) -> dict[str, Any]: + if not field or not reason.strip(): + raise ClosureError("field and change reason are required") + _validate_amend_field(data, field) + for package_id in affected_packages or []: + package_by_id(data, package_id) + root_field = field.split(".", 1)[0] + risk_field = field.split(".")[-1] + high_risk = root_field in HIGH_RISK_FIELDS or risk_field in HIGH_RISK_FIELDS + old_phase = str(data.get("hermes_phase") or "planning") + old_value = _read_change_value(data, field) + incomplete_ids = [ + str(package.get("id")) + for package in data.get("work_packages") or [] + if isinstance(package, dict) + and package.get("status") not in DISPOSED_PACKAGE_STATUSES + and package.get("id") + ] + semantic_ids = list(affected_packages or []) + if not semantic_ids and field.startswith("work_packages."): + 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"} + applied = not high_risk or human_approved + if applied: + _write_change_value(data, field, new_value) + _reset_incomplete_packages(data) + data["closure_state"] = "open" + data["hermes_phase"] = "planning" + data["status"] = "planning" + data["next_action"] = "Run closure.py validate after the plan amendment." + approval_blocker = f"high-risk plan change requires human approval: {field}" + data["blockers"] = [ + item for item in _string_list(data.get("blockers")) + if item != approval_blocker + ] + if field == "closure_mode" and str(new_value) in CLOSURE_MODES: + data["max_repair_count"] = default_repair_limit(str(new_value)) + if field == "max_repair_count": + data["blockers"] = [ + item for item in _string_list(data.get("blockers")) + if not item.startswith("repair limit reached") + ] + else: + blocker = f"high-risk plan change requires human approval: {field}" + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + 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={ + "affected_packages": affected_ids, + "field": field, + "approval_requirement": "human" if high_risk else "none", + "approved_by": approved_by, + "applied": applied, + }, + ) + 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}", + ) + if not applied: + write_handoff(task_dir, data, get_repo_root(task_dir)) + if high_risk and not approved_by: + raise ClosureError(f"high-risk change {field} requires --approved-by human/root") + return {"field": field, "old_value": old_value, "new_value": new_value, "high_risk": high_risk} + + +def close_task( + task_dir: Path, + data: dict[str, Any], + *, + actor: str, +) -> dict[str, Any]: + if data.get("closure_state") == "closed" and data.get("hermes_phase") == "closed": + return audit_closure(task_dir, data, write_report=True) + result = audit_closure(task_dir, data, write_report=True) + if result["status"] != "all_met": + return result + old_phase = str(data.get("hermes_phase") or "review") + data["hermes_phase"] = "closed" + data["closure_state"] = "closed" + data["status"] = "completed" + data["completedAt"] = datetime.now().strftime("%Y-%m-%d") + data["current_work_package"] = None + data["next_action"] = "Archive the closed Trellis task when ready." + save_task(task_dir, data) + append_event( + task_dir, + "task_closed", + actor=actor, + old_state=old_phase, + new_state="closed", + reason="closure audit passed", + ) + write_handoff(task_dir, data, get_repo_root(task_dir)) + return {"status": "all_met", "gaps": [], "mode": data.get("closure_mode")} + + +def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> str: + current_id = data.get("current_work_package") + current = None + if current_id: + try: + current = package_by_id(data, str(current_id)) + except ClosureError: + current = None + scope = _compact_list(_string_list(data.get("in_scope")), 2) + excluded = _compact_list(_string_list(data.get("out_of_scope")), 2) + blockers = _compact_list(_string_list(data.get("blockers")), 2) + 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')}", + 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)) + lines.append(f"Next: {_clip(str(data.get('next_action') or '-'), 180)}") + lines.append(f"Blockers: {blockers or '-'}") + refs = _context_refs(task_dir, data, root or get_repo_root())[:3] + if refs: + lines.append("Refs: " + ", ".join(refs)) + capsule = "\n".join(lines) + return capsule if len(capsule) <= 1000 else capsule[:997].rstrip() + "..." + + +def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: + repo_root = root or get_repo_root(task_dir) + packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] + completed = [str(item.get("id")) for item in packages if item.get("status") == "done"] + evidence = _dedupe([ + ref + for item in packages + for ref in _string_list(item.get("evidence_refs")) + ]) + changed = _git_changed_files(repo_root) + failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) + content = "\n".join( + [ + "# Task Handoff", + "", + "## Current Goal", + str(data.get("intent") or data.get("title") or "-"), + "", + "## Current Phase", + str(data.get("hermes_phase") or "-"), + "", + "## Current Package", + str(data.get("current_work_package") or "-"), + "", + "## Completed Packages", + _markdown_items(completed), + "", + "## Files Changed", + _markdown_items(changed), + "", + "## Artifacts/Evidence", + _markdown_items(evidence), + "", + "## Failed Attempts", + _markdown_items(failed_attempts), + "", + "## Blockers", + _markdown_items(_string_list(data.get("blockers"))), + "", + "## Next Action", + str(data.get("next_action") or "-"), + "", + "## Do Not Do", + "- Do not widen scope or change research contracts without an approved amend event.", + "", + ] + ) + path = task_dir / "HANDOFF.md" + path.write_text(content, encoding="utf-8") + return path + + +def write_closure_report(task_dir: Path, data: dict[str, Any]) -> Path: + packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] + results = [ + f"- {item.get('id')}: {item.get('status')} - {item.get('outcome')}" + for item in packages + ] + evidence = _dedupe([ + ref + for item in packages + for ref in _string_list(item.get("evidence_refs")) + ]) + deferred = [ + f"{item.get('id')}: {item.get('status')} - {item.get('blocker') or 'recorded disposition'}" + for item in packages + if item.get("status") in {"deferred", "waived"} + ] + content = "\n".join( + [ + "# Closure Report", + "", + "## Intent", + str(data.get("intent") or "-"), + "", + "## Scope", + _markdown_items(_string_list(data.get("in_scope"))), + "", + "## Definition of Done", + _markdown_items(_string_list(data.get("definition_of_done"))), + "", + "## Work Package Results", + "\n".join(results) if results else "- None", + "", + "## Evidence and Artifacts", + _markdown_items(evidence), + "", + "## Deferred or Waived Work", + _markdown_items(deferred), + "", + "## Open Limitations", + _markdown_items(_string_list(data.get("blockers"))) if data.get("blockers") else "- None recorded", + "", + "## Final Verdict", + "Closure audit passed." if not data.get("blockers") else "Blocked pending listed limitations.", + "", + "## Reproduction / Validation Commands", + "- See evidence_refs and Hermes run_manifest.jsonl where applicable.", + "", + ] + ) + path = task_dir / "closure-report.md" + path.write_text(content, encoding="utf-8") + return path + + +def format_audit_yaml(result: dict[str, Any]) -> str: + lines = [f"status: {result['status']}", f"mode: {result['mode']}"] + gaps = result.get("gaps") or [] + if not gaps: + lines.append("gaps: []") + return "\n".join(lines) + lines.append("gaps:") + for gap in gaps: + lines.append(f" - package: {gap.get('package') or 'task'}") + lines.append(f" missing: {_yaml_scalar(str(gap.get('missing') or ''))}") + lines.append(f" action: {_yaml_scalar(str(gap.get('action') or ''))}") + return "\n".join(lines) + + +def _standard_gaps(task_dir: Path) -> list[dict[str, Any]]: + hermes = task_dir / "hermes" + run_records = _read_jsonl_values(hermes / "run_manifest.jsonl") + artifact_records = _read_jsonl_values(hermes / "artifact_ledger.jsonl") + evidence_records = _read_jsonl_values(hermes / "evidence_ledger.jsonl") + claim_records = _read_jsonl_values(hermes / "claim_ledger.jsonl") + compare_records = _read_jsonl_values(hermes / "compare.jsonl") + gaps: list[dict[str, Any]] = [] + if not run_records or not any(item.get("exit_code") == 0 for item in run_records): + gaps.append(_gap(None, "successful run manifest", "登记至少一次成功运行")) + if not artifact_records or not all(_valid_artifact_hash(item.get("hash")) for item in artifact_records): + gaps.append(_gap(None, "artifact hash", "登记产物路径与 hash")) + has_metrics = bool(compare_records) or any(isinstance(item.get("metrics"), dict) and item.get("metrics") for item in run_records) + if not has_metrics: + gaps.append(_gap(None, "metrics", "在 run manifest 或 compare 中登记指标")) + if not evidence_records: + gaps.append(_gap(None, "evidence", "登记可追溯 evidence")) + if not claim_records: + gaps.append(_gap(None, "claim limitations", "登记结果适用范围和限制")) + elif any(not str(item.get("limits") or "").strip() for item in claim_records): + gaps.append(_gap(None, "claim limitations", "为每个 claim 记录限制")) + return gaps + + +def _publication_gaps(task_dir: Path) -> list[dict[str, Any]]: + hermes = task_dir / "hermes" + compare_records = _read_jsonl_values(hermes / "compare.jsonl") + claim_records = _read_jsonl_values(hermes / "claim_ledger.jsonl") + approval_records = _read_jsonl_values(hermes / "approval_records.jsonl") + evidence_ids = { + str(item.get("id")) for item in _read_jsonl_values(hermes / "evidence_ledger.jsonl") if item.get("id") + } + claim_ids = {str(item.get("id")) for item in claim_records if item.get("id")} + gaps: list[dict[str, Any]] = [] + quality_ok = bool(compare_records) and all( + item.get("passed") is True + and isinstance(item.get("sample_count"), int) + and (item.get("variance") is not None or item.get("confidence_interval") is not None) + and bool(_string_list(item.get("evidence_refs"))) + and all(ref in evidence_ids for ref in _string_list(item.get("evidence_refs"))) + and bool(_string_list(item.get("claim_refs"))) + and all(ref in claim_ids for ref in _string_list(item.get("claim_refs"))) + for item in compare_records + ) + if not quality_ok: + gaps.append(_gap(None, "statistical compare quality gate", "补齐通过的 compare、样本数和方差或置信区间")) + for claim in claim_records: + claim_id = str(claim.get("id") or "claim") + refs = _string_list(claim.get("evidence_ids")) + if not refs or any(ref not in evidence_ids for ref in refs): + gaps.append(_gap(None, f"claim {claim_id} evidence refs", "补齐有效证据引用")) + approved = any( + item.get("claim_id") == claim.get("id") + and item.get("decision") == "approved" + and str(item.get("approver") or "").casefold() in {"human", "root", "human/root"} + for item in approval_records + ) + if not approved: + gaps.append(_gap(None, f"claim {claim_id} human approval", "由 human/root 完成 claim disposition")) + if not claim_records: + gaps.append(_gap(None, "claim disposition", "登记并处置 publication claim")) + for filename in ("STATE.md", "CLAIMS.md"): + if not (task_dir / filename).is_file() and not (hermes / filename).is_file(): + gaps.append(_gap(None, filename, f"更新 {filename}")) + return gaps + + +def _block_repair_limit( + task_dir: Path, + data: dict[str, Any], + actor: str, + audit: dict[str, Any], + count: int, + limit: int, +) -> dict[str, Any]: + blocker = f"repair limit reached ({count}/{limit})" + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + old_phase = str(data.get("hermes_phase") or "running") + data["hermes_phase"] = "blocked" + data["status"] = "in_progress" + data["next_action"] = "Human review is required before another repair or scope change." + current_id = data.get("current_work_package") + if current_id: + current = package_by_id(data, str(current_id)) + if current.get("status") not in DISPOSED_PACKAGE_STATUSES: + current["status"] = "blocked" + current["blocker"] = blocker + save_task(task_dir, data) + if old_phase != "blocked": + append_event( + task_dir, + "phase_changed", + actor=actor, + old_state=old_phase, + new_state="blocked", + reason=blocker, + ) + write_handoff(task_dir, data, get_repo_root(task_dir)) + return {**audit, "status": "blocked"} + + +def _prepare_repair_package( + data: dict[str, Any], + audit: dict[str, Any], +) -> dict[str, Any] | None: + """Open only the first incomplete package named by the audit.""" + package_id = next( + ( + str(gap.get("package")) + for gap in audit.get("gaps") or [] + if gap.get("package") + ), + None, + ) + if not package_id: + return None + package = package_by_id(data, package_id) + status = str(package.get("status") or "pending") + if status in DISPOSED_PACKAGE_STATUSES: + return package + if status == "blocked": + package["status"] = "pending" + package["blocker"] = None + data["blockers"] = [ + item for item in _string_list(data.get("blockers")) + if not item.startswith(f"{package_id}:") + ] + _refresh_ready_packages(data) + if package.get("status") == "ready": + package["status"] = "running" + if package.get("status") in {"running", "review"}: + data["current_work_package"] = package_id + return package + + +def _reset_incomplete_packages(data: dict[str, Any]) -> None: + reset_ids: set[str] = set() + for package in data.get("work_packages") or []: + if not isinstance(package, dict) or package.get("status") in DISPOSED_PACKAGE_STATUSES: + continue + package["status"] = "pending" + package["blocker"] = None + reset_ids.add(str(package.get("id") or "")) + data["current_work_package"] = None + data["blockers"] = [ + item for item in _string_list(data.get("blockers")) + if not any(item.startswith(f"{package_id}:") for package_id in reset_ids if package_id) + ] + + +def _refresh_ready_packages(data: dict[str, Any]) -> None: + packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] + status_by_id = {str(item.get("id")): item.get("status") for item in packages} + for package in packages: + if package.get("status") not in {"pending", "ready"}: + continue + dependencies = _string_list(package.get("depends_on")) + package["status"] = "ready" if all( + status_by_id.get(dep) in {"done", "waived"} for dep in dependencies + ) else "pending" + + +def _next_package_action(data: dict[str, Any]) -> str: + for package in data.get("work_packages") or []: + if package.get("status") == "ready": + return f"Start {package.get('id')}: {package.get('outcome')}" + return "Resolve dependencies or run closure.py audit." + + +def _has_dependency_cycle(graph: dict[str, list[str]]) -> bool: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(node: str) -> bool: + if node in visiting: + return True + if node in visited: + return False + visiting.add(node) + for dependency in graph.get(node, []): + if dependency in graph and visit(dependency): + return True + visiting.remove(node) + visited.add(node) + return False + + return any(visit(node) for node in graph) + + +def _scope_phrase_in_text(phrase: str, text: str) -> bool: + if re.fullmatch(r"[a-z0-9_.-]+", phrase): + return re.search(rf"(? Any: + parts = field.split(".") + if parts[0] == "work_packages" and len(parts) == 3: + return package_by_id(data, parts[1]).get(parts[2]) + if len(parts) == 1 and parts[0] in AMENDABLE_CLOSURE_FIELDS: + return data.get(field) + meta = data.get("meta") + if not isinstance(meta, dict): + return None + current: Any = meta.get("research_contract") + if not isinstance(current, dict): + return None + research_parts = _research_field_parts(field) + for part in research_parts[:-1]: + if not isinstance(current, dict): + return None + current = current.get(part) + return current.get(research_parts[-1]) if isinstance(current, dict) else None + + +def _write_change_value(data: dict[str, Any], field: str, value: Any) -> None: + parts = field.split(".") + if parts[0] == "work_packages" and len(parts) == 3: + package = package_by_id(data, parts[1]) + if package.get("status") in DISPOSED_PACKAGE_STATUSES: + raise ClosureError("repair/amend cannot rewrite a disposed package") + _validate_package_amend_value(parts[2], value) + package[parts[2]] = value + return + if len(parts) == 1 and parts[0] in AMENDABLE_CLOSURE_FIELDS: + _validate_closure_amend_value(parts[0], value) + data[parts[0]] = value + return + meta = data.setdefault("meta", {}) + if not isinstance(meta, dict): + raise ClosureError("task.meta must be an object before amending research fields") + current = meta.setdefault("research_contract", {}) + if not isinstance(current, dict): + raise ClosureError("task.meta.research_contract must be an object") + research_parts = _research_field_parts(field) + for part in research_parts[:-1]: + current = current.setdefault(part, {}) + if not isinstance(current, dict): + raise ClosureError(f"cannot set nested field: {field}") + current[research_parts[-1]] = value + + +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 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") + package_by_id(data, parts[1]) + return + if len(parts) == 1 and field in data and field not in AMENDABLE_CLOSURE_FIELDS: + raise ClosureError(f"task state field cannot be amended directly: {field}") + if not _research_field_parts(field): + raise ClosureError("research amend field path is invalid") + + +def _research_field_parts(field: str) -> list[str]: + parts = field.split(".") + if parts[:2] == ["meta", "research_contract"]: + return parts[2:] + if parts[:1] == ["research_contract"]: + return parts[1:] + return parts + + +def _validate_closure_amend_value(field: str, value: Any) -> None: + if field == "closure_mode" and value not in CLOSURE_MODES: + raise ClosureError("closure_mode must be lean, standard, or publication") + if field in {"intent"} and not isinstance(value, str): + raise ClosureError(f"{field} must be a string") + if field in {"in_scope", "out_of_scope", "definition_of_done"} and not ( + isinstance(value, list) and all(isinstance(item, str) for item in value) + ): + raise ClosureError(f"{field} must be an array of strings") + if field == "max_repair_count" and ( + not isinstance(value, int) or isinstance(value, bool) or value < 0 + ): + raise ClosureError("max_repair_count must be a non-negative integer") + + +def _validate_package_amend_value(field: str, value: Any) -> None: + if field in {"title", "outcome"} and not isinstance(value, str): + raise ClosureError(f"package {field} must be a string") + if field in {"done_when", "evidence_required", "depends_on"} and not ( + isinstance(value, list) and all(isinstance(item, str) for item in value) + ): + raise ClosureError(f"package {field} must be an array of strings") + + +def _context_refs(task_dir: Path, data: dict[str, Any], root: Path) -> list[str]: + refs: list[str] = [] + for filename in ("implement.jsonl", "check.jsonl"): + for item in _read_jsonl_values(task_dir / filename): + value = item.get("file") + if isinstance(value, str) and value.strip(): + refs.append(value.strip()) + refs.extend(_string_list(data.get("relatedFiles"))) + return _dedupe(refs) + + +def _read_jsonl_values(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + return [] + values: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + for line in lines: + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and "_example" not in value: + values.append(value) + return values + + +def _valid_artifact_hash(value: Any) -> bool: + if not isinstance(value, str): + return False + return re.fullmatch(r"(?:sha256:)?[0-9a-fA-F]{64}", value.strip()) is not None + + +def _recent_event_reasons(task_dir: Path, types: set[str]) -> list[str]: + return [ + str(item.get("reason")) + for item in _read_jsonl_values(task_dir / "hermes" / "task-events.jsonl")[-20:] + if item.get("event_type") in types and item.get("reason") + ][-5:] + + +def _git_changed_files(root: Path) -> list[str]: + try: + result = subprocess.run( + ["git", "status", "--short", "--untracked-files=all"], + cwd=root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return [] + files: list[str] = [] + for line in result.stdout.splitlines(): + value = line[3:].strip() if len(line) > 3 else "" + if " -> " in value: + value = value.split(" -> ", 1)[1] + lowered = value.casefold() + if value and not any( + marker in lowered + for marker in (".env", "credentials", "secret", "token", "api_key") + ): + files.append(value) + return files[:20] + + +def _gap(package: str | None, missing: str, action: str) -> dict[str, Any]: + return {"package": package, "missing": missing, "action": action} + + +def _dedupe_gaps(gaps: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[tuple[Any, Any, Any]] = set() + result: list[dict[str, Any]] = [] + for gap in gaps: + key = (gap.get("package"), gap.get("missing"), gap.get("action")) + if key not in seen: + seen.add(key) + result.append(gap) + return result + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if isinstance(item, str) and item.strip()] + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value).strip() + if text and text not in seen: + seen.add(text) + result.append(text) + return result + + +def _clip(value: str, limit: int) -> str: + compact = re.sub(r"\s+", " ", value).strip() + return compact if len(compact) <= limit else compact[: max(0, limit - 3)].rstrip() + "..." + + +def _compact_list(values: list[str], limit: int, char_limit: int = 180) -> str: + return _clip("; ".join(values[:limit]), char_limit) + + +def _markdown_items(values: list[str]) -> str: + return "\n".join(f"- {item}" for item in values) if values else "- None" + + +def _yaml_scalar(value: str) -> str: + return json.dumps(value, ensure_ascii=False) 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 86de9f7..5241579 100644 --- a/packages/cli/src/templates/trellis/scripts/common/task_store.py +++ b/packages/cli/src/templates/trellis/scripts/common/task_store.py @@ -53,6 +53,7 @@ resolve_task_dir, run_task_hooks, ) +from .closure import closure_defaults, is_closure_task # ============================================================================= @@ -255,6 +256,7 @@ def cmd_create(args: argparse.Namespace) -> int: "relatedFiles": [], "notes": "", "meta": {}, + **closure_defaults(), } write_json(task_json_path, task_data) @@ -320,6 +322,7 @@ def cmd_create(args: argparse.Namespace) -> int: print("", file=sys.stderr) print(colored("Next steps:", Colors.BLUE), file=sys.stderr) print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr) + print(" - Run closure.py plan, then closure.py validate", file=sys.stderr) print(" - Lightweight task: PRD-only is valid", file=sys.stderr) print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr) if seeded_jsonl: @@ -375,9 +378,27 @@ def cmd_archive(args: argparse.Namespace) -> int: if task_json_path.is_file(): data = read_json(task_json_path) if data: - data["status"] = "completed" - data["completedAt"] = today - write_json(task_json_path, data) + if is_closure_task(data): + if data.get("closure_state") != "closed": + print( + colored( + "Error: Hermes closure task is not closed; run closure.py audit/close before archive.", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + else: + print( + colored( + "Compatibility mode: legacy non-closure task; archive will mark it completed.", + Colors.YELLOW, + ), + file=sys.stderr, + ) + data["status"] = "completed" + data["completedAt"] = today + write_json(task_json_path, data) # Handle subtask relationships on archive. # Keep this task in its parent's children list so progress diff --git a/packages/cli/src/templates/trellis/scripts/common/types.py b/packages/cli/src/templates/trellis/scripts/common/types.py index 5802e10..c601db1 100644 --- a/packages/cli/src/templates/trellis/scripts/common/types.py +++ b/packages/cli/src/templates/trellis/scripts/common/types.py @@ -49,6 +49,19 @@ class TaskData(TypedDict, total=False): relatedFiles: list[str] notes: str meta: dict + hermes_phase: str + closure_state: str + closure_mode: str + intent: str + in_scope: list[str] + out_of_scope: list[str] + definition_of_done: list[str] + work_packages: list[dict] + current_work_package: str | None + next_action: str | None + blockers: list[str] + repair_count: int + max_repair_count: int # ============================================================================= diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runner.py b/packages/cli/src/templates/trellis/scripts/hermes/runner.py index 9736061..a47ec01 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runner.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runner.py @@ -265,6 +265,8 @@ def sandbox_execution_settings(experiment: dict[str, Any]) -> tuple[dict[str, An image_error = container_image_error(image) if image_error is not None: return settings, [image_error] + if not isinstance(image, str): + return settings, ["sandbox mode container requires sandbox.image"] settings["image"] = image.strip() return settings, [] return settings, [ diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index ede10fc..65bf2e3 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -1309,7 +1309,7 @@ def validate_records(path: Path, kind: str) -> list[str]: else: for entry in records: errors.extend(validate_required_fields(entry.value, entry.line_number)) - artifact_records, artifact_errors, artifact_index = load_artifact_context(path) + _artifact_records, artifact_errors, artifact_index = load_artifact_context(path) if kind in {"evidence", "claim", "approval"}: if artifact_errors: errors.extend(artifact_errors) diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index 64927dd..f8e3107 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -205,6 +205,34 @@ 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 + closure_package_id = None + + if isinstance(task_data, dict): + from common.closure import is_closure_task, next_ready_package_id, validate_closure + + if is_closure_task(task_data): + phase = task_data.get("hermes_phase") + if phase == "planning": + errors, _ = validate_closure(task_data) + detail = "; ".join(errors[:3]) if errors else "plan has not been validated" + print(colored( + f"Error: Closure task is not ready: {detail}", + Colors.RED, + )) + print("Run closure.py validate before task.py start.") + return 1 + if phase == "ready": + closure_package_id = next_ready_package_id(task_data) + if closure_package_id is None: + print(colored( + "Error: Closure task has no dependency-ready work package.", + Colors.RED, + )) + return 1 + if phase == "closed" or task_data.get("closure_state") == "closed": + print(colored("Error: Closed closure task cannot be started.", Colors.RED)) + return 1 if not resolve_context_key(): print(colored( @@ -218,16 +246,38 @@ def cmd_start(args: argparse.Namespace) -> int: )) return 1 + previous_active = resolve_active_task(repo_root) active = set_active_task(task_dir, repo_root) if active: print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) print(f"Source: {active.source}") - if task_json_path.is_file(): - data = read_json(task_json_path) - if data and data.get("status") == "planning": - data["status"] = "in_progress" - if write_json(task_json_path, data): + if isinstance(task_data, dict): + from common.closure import actor_name, is_closure_task, start_package + + if is_closure_task(task_data): + if task_data.get("hermes_phase") == "ready": + try: + package = start_package( + full_path, + task_data, + closure_package_id, + actor=actor_name(repo_root), + reason="task.py start", + ) + except ValueError as exc: + clear_active_task(repo_root) + if previous_active.task_path: + set_active_task(previous_active.task_path, repo_root) + print(colored(f"Error: {exc}", Colors.RED)) + return 1 + print(colored( + f"✓ Closure package started: {package['id']}", + Colors.GREEN, + )) + elif task_data.get("status") == "planning": + task_data["status"] = "in_progress" + if write_json(task_json_path, task_data): print(colored("✓ Status: planning → in_progress", Colors.GREEN)) print() @@ -257,6 +307,20 @@ def cmd_finish(args: argparse.Namespace) -> int: print(f"Source: {active.source}") if task_json_path.is_file(): + task_data = read_json(task_json_path) + if isinstance(task_data, dict): + from common.closure import is_closure_task, write_handoff + + if is_closure_task(task_data) and task_data.get("closure_state") != "closed": + handoff_path = write_handoff( + task_json_path.parent, + task_data, + repo_root, + ) + print(colored( + f"✓ Handoff updated: {handoff_path.relative_to(repo_root)}", + Colors.GREEN, + )) run_task_hooks("after_finish", task_json_path, repo_root) return 0 diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 7ce7c0a..3ab4ee2 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -51,6 +51,14 @@ python3 ./.trellis/scripts/task.py archive # 移动到 archive/{ye python3 ./.trellis/scripts/task.py list [--mine] [--status ] python3 ./.trellis/scripts/task.py list-archive +# Lean Research Closure(新任务默认启用) +python3 ./.trellis/scripts/closure.py plan --task +python3 ./.trellis/scripts/closure.py validate --task +python3 ./.trellis/scripts/closure.py capsule --task +python3 ./.trellis/scripts/closure.py status --task +python3 ./.trellis/scripts/closure.py audit --task +python3 ./.trellis/scripts/closure.py close --task + # 代码规范上下文(通过 JSONL 注入 implement/check 子代理)。 # `implement.jsonl` / `check.jsonl` 会在 `task create` 时为支持子代理的平台预置种子行; # 之后由 AI 在规划阶段补成真实 spec / research 条目。 @@ -73,7 +81,34 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] > 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。`task.py start` 会写入同一个指针(如果已存在则幂等),并把 `task.json.status` 从 `planning` 切换为 `in_progress`。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务,`task.py start` 会提示缺少会话身份。`task.py finish` 只删除当前会话文件(不改任务状态)。`task.py archive ` 会写入 `status=completed`、移动目录到 `archive/`,并删除仍指向该任务的运行时会话文件。 +**当前任务机制**:`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 + +默认仍然只创建一个 Trellis task。task 内部用 1–4 个 work package 表示适度的结果分组:每个 package 描述可观察结果和完成条件,不把读文件、改文件、运行命令、查看输出拆成 package。只有独立产物、独立验证、独立执行环境、可并行执行或独立审查等条件中至少满足两项时,才考虑分成独立 package。5 个以上只给出拆成多个 task 的建议,不自动拆分。 + +work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 + +```bash +# 规划与验证 +python3 ./.trellis/scripts/closure.py plan --task \ + --intent "可观察目标" --done-when "完成条件" +python3 ./.trellis/scripts/closure.py validate --task + +# 每次只处理当前 package +python3 ./.trellis/scripts/closure.py package-start --task +python3 ./.trellis/scripts/closure.py package-check --task +python3 ./.trellis/scripts/closure.py package-done --task --evidence "验证记录" + +# 收口 +python3 ./.trellis/scripts/closure.py audit --task +python3 ./.trellis/scripts/closure.py repair --task +python3 ./.trellis/scripts/closure.py close --task +``` + +每轮 hook 优先注入约 500–1000 字符的 Task Capsule,只包含 intent、范围、当前 package、done_when、next action、blockers 和最多 3 个相关引用。完整 PRD、报告、事件、ledger、历史任务和非当前 spec 均保留,但只在需要时读取。Claude Code、Codex 与 OpenCode 使用相同 capsule 语义;没有每轮 hook 的平台需要显式运行 `closure.py capsule`。 + +阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 @@ -153,7 +188,7 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 ### Parent / Child Task Trees -当一个用户请求包含多个可独立验证的交付物时,使用父任务。父任务负责源需求集合、任务地图、跨子任务验收标准以及最终集成评审;除非它本身也有直接工作,否则通常不应作为实现目标。This means one request can cover several independently verifiable deliverables. +默认先使用一个 task 和 1–4 个内部 work packages。只有交付物可以独立产出、验证、执行、并行或审查时才使用父/子任务。父任务负责源需求集合、任务地图、跨子任务验收标准以及最终集成评审;除非它本身也有直接工作,否则通常不应作为实现目标。This means one request can cover several independently verifiable deliverables. 对子任务,应保证它们都可以独立地完成 planning、implement、check 与 archive。父子结构不是依赖系统:如果某个子任务必须等待另一个子任务,应该把这个顺序写在子任务自己的 `prd.md` / `implement.md` 里,并保持各子任务的验收标准可独立验证。Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child artifacts instead. @@ -180,6 +215,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni [workflow-state:planning] +Hermes closure: use the injected Task Capsule first. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. Load `trellis-brainstorm`; stay in planning. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. @@ -199,6 +235,7 @@ Before `task.py start`, record the development strategy decisions in the task do into a sub-agent. --> [workflow-state:planning-inline] +Hermes closure: use the injected Task Capsule first. Run `closure.py plan` and `closure.py validate`; do not treat commands or file operations as packages. Load `trellis-brainstorm`; stay in planning. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. @@ -221,6 +258,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] +Hermes closure: follow only the current package in the Task Capsule. Move running -> review with `package-check`, record validation with `package-done`, then run `audit`; use at most the configured bounded `repair` rounds, and run `close` before archive. Do not load all reports, events, ledgers, history, or specs by default. Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. @@ -242,6 +280,7 @@ If the user asks to archive the current task, do not block on commit; archive is instead of dispatching sub-agents. --> [workflow-state:in_progress-inline] +Hermes closure: handle only the current package from the Task Capsule; run package check/done, audit, bounded repair when needed, and close before archive. Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> archive or commit as needed -> `/trellis:finish-work`. Do not dispatch implement/check sub-agents in inline mode. Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills. @@ -265,7 +304,7 @@ If the user asks to archive the current task, do not block on commit; archive is channel as the live blocks. --> [workflow-state:completed] -Task archived or code committed. Run `/trellis:finish-work`; if more validation or wrap-up is needed, do that before closing the work. +Task closure passed. Keep task closed distinct from claim approval, then run `/trellis:finish-work` or archive. [/workflow-state:completed] ### Rules @@ -301,6 +340,9 @@ Task archived or code committed. Run `/trellis:finish-work`; if more validation - 用户同意创建任务,不等于同意开始实现;只有在产物 review 完成并执行 `task.py start` 后,才能进入实现。 - 轻量任务允许只有 PRD;复杂任务必须有 `design.md` + `implement.md`。 - planning 必须落盘到任务产物;完成前必须跑检查。 +- closure 任务不能靠自然语言声明完成;状态迁移必须通过 closure 命令写入 `task.json` 和事件日志。 +- `audit` 有缺口时只修缺口,不扩大 scope,不重写 done package;高风险研究契约变化必须人工批准。 +- lean 是默认低 token 模式;standard 增加运行、产物、指标和证据检查;publication 再增加统计比较、claim、STATE/CLAIMS 和人工审批门禁。 ### Loading Step Detail diff --git a/packages/cli/test/commands/init.integration.test.ts b/packages/cli/test/commands/init.integration.test.ts index 656bb76..437bb53 100644 --- a/packages/cli/test/commands/init.integration.test.ts +++ b/packages/cli/test/commands/init.integration.test.ts @@ -1062,9 +1062,9 @@ describe("init() integration", () => { expect(Array.isArray(taskJson.subtasks)).toBe(true); expect(taskJson.subtasks).toEqual([]); - // Canonical shape: legacy current_phase / next_action must NOT appear + // Legacy current_phase stays absent; v0.7 closure owns next_action. expect(taskJson.current_phase).toBeUndefined(); - expect(taskJson.next_action).toBeUndefined(); + expect(taskJson.next_action).toBeNull(); // relatedFiles point to spec// expect(taskJson.relatedFiles).toContain(".trellis/spec/core/"); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index 0a95676..9004b1e 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -1662,6 +1662,22 @@ describe("regression: current-task path normalization", () => { }; expect(beforeStart.status).toBe("planning"); + // New v0.7 tasks require a validated lean closure plan before start. + const closureScriptPath = path.join( + tmpDir, + ".trellis", + "scripts", + "closure.py", + ); + execSync( + `${pythonCmd} ${JSON.stringify(closureScriptPath)} plan --task ${JSON.stringify(relTaskDir)} --intent "Idempotent task start" --done-when "Task start is recorded"`, + { cwd: tmpDir, encoding: "utf-8" }, + ); + execSync( + `${pythonCmd} ${JSON.stringify(closureScriptPath)} validate --task ${JSON.stringify(relTaskDir)}`, + { cwd: tmpDir, encoding: "utf-8" }, + ); + // Now run start with the same session — must not error. let startStatus = 0; let startOutput = ""; @@ -1680,7 +1696,7 @@ describe("regression: current-task path normalization", () => { startOutput = (e.stdout ?? "") + (e.stderr ?? ""); } expect(startStatus).toBe(0); - expect(startOutput).toContain("planning → in_progress"); + expect(startOutput).toContain("Closure package started: WP1"); // Status flipped to in_progress. const afterStart = JSON.parse(fs.readFileSync(taskJsonPath, "utf-8")) as { @@ -2952,10 +2968,10 @@ describe("regression: current-task path normalization", () => { }); // ------------------------------------------------------------ - // Legacy current_phase / next_action field removal (FP round 3 cleanup) + // Legacy current_phase removal + Hermes next_action introduction // ------------------------------------------------------------ - it("[workflow-v2] task.py create does NOT write legacy current_phase / next_action fields", () => { + it("[workflow-v2] task.py create omits current_phase and initializes closure next_action", () => { setupTaskRepo(); const taskScriptPath = path.join(tmpDir, ".trellis", "scripts", "task.py"); execSync( @@ -2974,7 +2990,7 @@ describe("regression: current-task path normalization", () => { next_action?: unknown; }; expect(data.current_phase).toBeUndefined(); - expect(data.next_action).toBeUndefined(); + expect(data.next_action).toBeNull(); }); // ------------------------------------------------------------ diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts new file mode 100644 index 0000000..b05a081 --- /dev/null +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -0,0 +1,607 @@ +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", +); + +interface TestWorkPackage { + id: string; + status: string; + done_when: string[]; + [key: string]: unknown; +} + +interface TestTask { + status: string; + hermes_phase: string; + closure_state: string; + work_packages: TestWorkPackage[]; + current_work_package: string | null; + repair_count: number; + max_repair_count: number; + blockers: string[]; + meta: { research_contract?: { dataset?: string } }; +} + +function hasPython(): boolean { + try { + execFileSync(PYTHON, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function baseTask(overrides: Record = {}) { + return { + id: "demo", + name: "demo", + title: "Demo research task", + description: "Produce a verified research result", + status: "planning", + priority: "P2", + createdAt: "2026-07-15", + assignee: "tester", + creator: "tester", + subtasks: [], + children: [], + relatedFiles: [], + meta: {}, + hermes_phase: "planning", + closure_state: "open", + closure_mode: "lean", + intent: "", + in_scope: [], + out_of_scope: [], + definition_of_done: [], + work_packages: [], + current_work_package: null, + next_action: null, + blockers: [], + repair_count: 0, + max_repair_count: 1, + ...overrides, + }; +} + +function setupRepo(root: string, task = baseTask()): string { + const scripts = path.join(root, ".trellis", "scripts"); + const taskDir = path.join(root, ".trellis", "tasks", "demo"); + fs.mkdirSync(scripts, { recursive: true }); + fs.cpSync(TEMPLATE_SCRIPTS, scripts, { recursive: true }); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(root, ".trellis", "config.yaml"), + "session_auto_commit: false\n", + ); + fs.writeFileSync(path.join(root, ".trellis", ".developer"), "name=tester\n"); + fs.writeFileSync( + path.join(taskDir, "task.json"), + `${JSON.stringify(task, null, 2)}\n`, + ); + return taskDir; +} + +function run(root: string, ...args: string[]) { + return spawnSync( + PYTHON, + [".trellis/scripts/closure.py", ...args, "--task", "demo"], + { cwd: root, encoding: "utf-8" }, + ); +} + +function runTask(root: string, ...args: string[]) { + return spawnSync(PYTHON, [".trellis/scripts/task.py", ...args], { + cwd: root, + encoding: "utf-8", + env: { ...process.env, TRELLIS_CONTEXT_ID: "closure-test-session" }, + }); +} + +function readTask(taskDir: string): TestTask { + return JSON.parse( + fs.readFileSync(path.join(taskDir, "task.json"), "utf-8"), + ) as TestTask; +} + +function writeTask(taskDir: string, value: Record): void { + fs.writeFileSync( + path.join(taskDir, "task.json"), + `${JSON.stringify(value, null, 2)}\n`, + ); +} + +function plan(root: string, doneWhen: string[], extra: string[] = []) { + const args = ["plan", "--intent", "Produce a verified result"]; + for (const item of doneWhen) args.push("--done-when", item); + args.push(...extra); + return run(root, ...args); +} + +function completeCurrent(root: string, evidence = "test:pass"): void { + expect(run(root, "package-start").status).toBe(0); + expect(run(root, "package-check").status).toBe(0); + expect(run(root, "package-done", "--evidence", evidence).status).toBe(0); +} + +function appendJsonl( + taskDir: string, + filename: string, + records: unknown[], +): void { + const hermes = path.join(taskDir, "hermes"); + fs.mkdirSync(hermes, { recursive: true }); + fs.writeFileSync( + path.join(hermes, filename), + `${records.map((item) => JSON.stringify(item)).join("\n")}\n`, + ); +} + +describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { + let root: string; + let taskDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-closure-")); + taskDir = setupRepo(root); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("plans one package for a simple task", () => { + expect(plan(root, ["CLI prints a verified result"]).status).toBe(0); + expect(readTask(taskDir).work_packages).toHaveLength(1); + }); + + it("plans two to four packages for ordinary observable outcomes", () => { + expect( + plan(root, [ + "Dataset is prepared", + "Model is evaluated", + "Report is reviewed", + ]).status, + ).toBe(0); + expect(readTask(taskDir).work_packages).toHaveLength(3); + }); + + it("keeps command and file microsteps inside done_when", () => { + expect( + plan(root, [ + "CLI produces the requested artifact", + "读取文件", + "运行命令", + ]).status, + ).toBe(0); + const packages = readTask(taskDir).work_packages; + expect(packages).toHaveLength(1); + expect(packages[0].done_when).toEqual([ + "CLI produces the requested artifact", + "读取文件", + "运行命令", + ]); + }); + + it("keeps tests and build checks as package validation", () => { + expect( + plan(root, ["CLI artifact exists", "Unit tests pass", "Build passes"]) + .status, + ).toBe(0); + const packages = readTask(taskDir).work_packages; + expect(packages).toHaveLength(1); + expect(packages[0].done_when).toEqual([ + "CLI artifact exists", + "Unit tests pass", + "Build passes", + ]); + }); + + it("warns instead of auto-splitting five explicit packages", () => { + const packages = Array.from({ length: 5 }, (_, index) => [ + "--package", + `Result ${index + 1}::Observable result ${index + 1}::Result ${index + 1} verified`, + ]).flat(); + const result = plan(root, [], packages); + expect(result.status).toBe(0); + expect(result.stderr).toContain("consider multiple Trellis tasks"); + expect(readTask(taskDir).work_packages).toHaveLength(5); + }); + + it("rejects validation without definition_of_done", () => { + expect(run(root, "plan", "--intent", "Intent only").status).toBe(0); + const result = run(root, "validate"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("definition_of_done is required"); + }); + + it("detects dependency cycles", () => { + const task = baseTask({ + intent: "Cycle test", + definition_of_done: ["A and B complete"], + work_packages: [ + { + id: "WP1", + title: "A", + outcome: "A complete", + done_when: ["A complete"], + evidence_required: [], + depends_on: ["WP2"], + status: "pending", + evidence_refs: [], + blocker: null, + }, + { + id: "WP2", + title: "B", + outcome: "B complete", + done_when: ["B complete"], + evidence_required: [], + depends_on: ["WP1"], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ], + }); + writeTask(taskDir, task); + const result = run(root, "validate"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("dependencies contain a cycle"); + }); + + it("keeps the capsule compact and focused on the current package", () => { + const longScope = `Scope ${"x".repeat(400)}`; + expect( + plan(root, ["Artifact exists"], ["--in-scope", longScope]).status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const result = run(root, "capsule"); + expect(result.status).toBe(0); + expect(result.stdout.trim().length).toBeLessThanOrEqual(1000); + expect(result.stdout).toContain("Current: WP1"); + expect(result.stdout).toContain("Done when:"); + }); + + it("does not complete a package without validation evidence", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + expect(run(root, "package-check").status).toBe(0); + const result = run(root, "package-done"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("requires validation evidence"); + }); + + it("does not dispose another package while one is current", () => { + expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const result = run( + root, + "package-done", + "--package-id", + "WP2", + "--disposition", + "deferred", + "--reason", + "not needed", + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("WP1 is still the current work package"); + }); + + it("starts the next package after completing the previous package", () => { + expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "test:a"); + + const between = readTask(taskDir); + expect(between.hermes_phase).toBe("running"); + expect(between.current_work_package).toBeNull(); + expect(between.work_packages[1].status).toBe("ready"); + + expect(run(root, "package-start").status).toBe(0); + const after = readTask(taskDir); + expect(after.current_work_package).toBe("WP2"); + expect(after.work_packages[1].status).toBe("running"); + expect(after.repair_count).toBe(0); + }); + + it("refuses close while part of the plan is unfinished", () => { + expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root); + const result = run(root, "close"); + expect(result.status).toBe(1); + expect(result.stdout).toContain("package: WP2"); + const task = readTask(taskDir); + expect(task.status).toBe("in_progress"); + expect(task.hermes_phase).toBe("running"); + expect(task.closure_state).toBe("open"); + expect(fs.existsSync(path.join(taskDir, "closure-report.md"))).toBe(false); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).not.toContain('"event_type":"task_closed"'); + }); + + it("refuses archive before closure", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const result = runTask(root, "archive", "demo", "--no-commit"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("closure task is not closed"); + expect(fs.existsSync(taskDir)).toBe(true); + }); + + it("writes a handoff when an open task session is finished", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const sessions = path.join(root, ".trellis", ".runtime", "sessions"); + fs.mkdirSync(sessions, { recursive: true }); + fs.writeFileSync( + path.join(sessions, "closure-test-session.json"), + `${JSON.stringify({ current_task: ".trellis/tasks/demo" })}\n`, + ); + const result = runTask(root, "finish"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Handoff updated"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(true); + }); + + it("prints a compact actionable audit gap", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const result = run(root, "audit", "--no-report"); + expect(result.status).toBe(1); + expect(result.stdout).toContain("gaps:"); + expect(result.stdout).toContain("package: WP1"); + expect(result.stdout).toContain("action:"); + expect(result.stdout.length).toBeLessThan(800); + }); + + it("repairs only the first gap and preserves completed packages", () => { + expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "test:a"); + const before = structuredClone(readTask(taskDir).work_packages[0]); + const repair = run(root, "repair"); + expect(repair.status).toBe(1); + const task = readTask(taskDir); + expect(task.work_packages[0]).toEqual(before); + expect(task.work_packages[1].status).toBe("running"); + expect(task.repair_count).toBe(1); + expect(task.hermes_phase).toBe("running"); + }); + + it("runs audit gap, one repair, and final close end to end", () => { + expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "test:a"); + expect(run(root, "audit", "--no-report").status).toBe(1); + expect(run(root, "repair").status).toBe(1); + expect(run(root, "package-check").status).toBe(0); + expect(run(root, "package-done", "--evidence", "test:b").status).toBe(0); + expect(run(root, "audit").status).toBe(0); + expect(run(root, "close").status).toBe(0); + expect(readTask(taskDir).closure_state).toBe("closed"); + }); + + it("blocks a second lean repair attempt after the limit", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "repair").status).toBe(1); + const second = run(root, "repair"); + expect(second.status).toBe(1); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(true); + }); + + it("requires human approval to extend a repair limit", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "repair").status).toBe(1); + expect(run(root, "repair").status).toBe(1); + expect( + run( + root, + "amend", + "--field", + "max_repair_count", + "--value", + "2", + "--reason", + "one reviewed retry", + ).status, + ).toBe(1); + expect( + run( + root, + "amend", + "--field", + "max_repair_count", + "--value", + "2", + "--reason", + "one reviewed retry", + "--approved-by", + "human/root", + ).status, + ).toBe(0); + const task = readTask(taskDir); + expect(task.max_repair_count).toBe(2); + expect(task.hermes_phase).toBe("planning"); + expect(task.current_work_package).toBeNull(); + expect(task.work_packages[0].status).toBe("pending"); + expect(task.blockers).toEqual([]); + expect(run(root, "validate").status).toBe(0); + }); + + it("requires explicit human approval for high-risk amendments", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const denied = run( + root, + "amend", + "--field", + "dataset", + "--value", + "dataset-v2", + "--reason", + "new source", + ); + expect(denied.status).toBe(1); + expect(readTask(taskDir).meta.research_contract?.dataset).toBeUndefined(); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + + const approved = run( + root, + "amend", + "--field", + "dataset", + "--value", + "dataset-v2", + "--reason", + "approved source", + "--approved-by", + "human/root", + ); + expect(approved.status).toBe(0); + const task = readTask(taskDir); + expect(task.meta.research_contract.dataset).toBe("dataset-v2"); + expect(task.hermes_phase).toBe("planning"); + expect(task.blockers).toEqual([]); + }); + + it("does not allow amend to bypass task state commands", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const result = run( + root, + "amend", + "--field", + "hermes_phase", + "--value", + "closed", + "--reason", + "bypass close", + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("cannot be amended directly"); + expect(readTask(taskDir).hermes_phase).toBe("planning"); + }); + + it("closes a valid lean task and then allows archive", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "pnpm test:pass"); + expect(run(root, "close").status).toBe(0); + const task = readTask(taskDir); + expect(task.status).toBe("completed"); + expect(task.hermes_phase).toBe("closed"); + expect(task.closure_state).toBe("closed"); + expect(fs.existsSync(path.join(taskDir, "closure-report.md"))).toBe(true); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).toContain('"event_type":"package_started"'); + expect(events).toContain('"event_type":"package_completed"'); + expect(events).toContain('"event_type":"task_closed"'); + expect(runTask(root, "archive", "demo", "--no-commit").status).toBe(0); + const archiveMonth = new Date().toISOString().slice(0, 7); + expect( + fs.existsSync( + path.join(root, ".trellis", "tasks", "archive", archiveMonth, "demo"), + ), + ).toBe(true); + }); + + it("closes standard mode when run, artifact, metrics, and evidence exist", () => { + expect( + plan(root, ["Standard result verified"], ["--mode", "standard"]).status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "ev-standard"); + appendJsonl(taskDir, "run_manifest.jsonl", [ + { id: "run-standard", exit_code: 0, metrics: { rmse: 0.2 } }, + ]); + appendJsonl(taskDir, "artifact_ledger.jsonl", [ + { id: "ar-standard", hash: `sha256:${"b".repeat(64)}` }, + ]); + appendJsonl(taskDir, "evidence_ledger.jsonl", [{ id: "ev-standard" }]); + appendJsonl(taskDir, "claim_ledger.jsonl", [ + { id: "cl-standard", limits: "fixed fixture only" }, + ]); + expect(run(root, "close").status).toBe(0); + expect(readTask(taskDir).closure_state).toBe("closed"); + }); + + it("rejects publication close without claim approval", () => { + expect( + plan(root, ["Publication result verified"], ["--mode", "publication"]) + .status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "ev-1"); + appendJsonl(taskDir, "run_manifest.jsonl", [{ id: "run-1", exit_code: 0 }]); + appendJsonl(taskDir, "artifact_ledger.jsonl", [ + { id: "ar-1", hash: `sha256:${"a".repeat(64)}` }, + ]); + appendJsonl(taskDir, "evidence_ledger.jsonl", [{ id: "ev-1" }]); + appendJsonl(taskDir, "claim_ledger.jsonl", [ + { id: "cl-1", evidence_ids: ["ev-1"], limits: "single dataset" }, + ]); + appendJsonl(taskDir, "compare.jsonl", [ + { + id: "cmp-1", + passed: true, + sample_count: 3, + variance: 0.01, + evidence_refs: ["ev-1"], + claim_refs: ["cl-1"], + }, + ]); + fs.writeFileSync(path.join(taskDir, "STATE.md"), "# State\n"); + fs.writeFileSync(path.join(taskDir, "CLAIMS.md"), "# Claims\n"); + + const result = run(root, "close"); + expect(result.status).toBe(1); + expect(result.stdout).toContain("claim cl-1 human approval"); + expect(readTask(taskDir).closure_state).toBe("open"); + }); + + it("keeps legacy non-Hermes archive behavior", () => { + const legacyRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "trellis-legacy-"), + ); + try { + setupRepo(legacyRoot, { + id: "demo", + name: "demo", + title: "Legacy task", + status: "in_progress", + priority: "P2", + createdAt: "2026-07-15", + assignee: "tester", + creator: "tester", + subtasks: [], + children: [], + relatedFiles: [], + meta: {}, + }); + const result = runTask(legacyRoot, "archive", "demo", "--no-commit"); + expect(result.status).toBe(0); + expect(result.stderr).toContain("Compatibility mode"); + } finally { + fs.rmSync(legacyRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/test/scripts/hermes-preflight.test.ts b/packages/cli/test/scripts/hermes-preflight.test.ts index e0ed553..05b7fa5 100644 --- a/packages/cli/test/scripts/hermes-preflight.test.ts +++ b/packages/cli/test/scripts/hermes-preflight.test.ts @@ -42,6 +42,7 @@ describe("hermes-preflight checks", () => { expect(result.ok).toBe(false); expect(result.messages.join("\n")).toContain("experiment.yaml"); expect(result.messages.join("\n")).toContain("runner.py"); + expect(result.messages.join("\n")).toContain("scripts/closure.py"); }); }); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 5ede282..bbf35c7 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -239,6 +239,36 @@ function runSessionStart(worktreeRoot: string): string { } } +function runWorkflowState(repoRoot: string, contextId: string): string { + const hook = getSharedHookScripts().find( + (item) => item.name === "inject-workflow-state.py", + ); + if (!hook) throw new Error("inject-workflow-state.py template missing"); + const scriptDir = fs.mkdtempSync( + path.join(os.tmpdir(), "trellis-workflow-state-"), + ); + const scriptPath = path.join(scriptDir, "inject-workflow-state.py"); + fs.writeFileSync(scriptPath, hook.content); + try { + const result = spawnSync(PYTHON, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + CLAUDE_PROJECT_DIR: repoRoot, + TRELLIS_CONTEXT_ID: contextId, + }, + input: JSON.stringify({ cwd: repoRoot, session_id: contextId }), + }); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || "workflow-state failed"); + } + return result.stdout; + } finally { + fs.rmSync(scriptDir, { recursive: true, force: true }); + } +} + function runPreToolUseHook( cwd: string, input: Record, @@ -483,6 +513,69 @@ describe("shared-hooks capability table", () => { expect(content).not.toContain(""); }); + it("injects a compact closure capsule without full task artifacts", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-capsule-hook-")); + try { + setupMainRepo( + repoRoot, + "capsule-task", + "FULL_PRD_MUST_NOT_BE_INJECTED\n", + ); + const taskDir = path.join(repoRoot, ".trellis", "tasks", "capsule-task"); + const taskJsonPath = path.join(taskDir, "task.json"); + const task = JSON.parse(fs.readFileSync(taskJsonPath, "utf-8")); + Object.assign(task, { + closure_state: "open", + closure_mode: "lean", + hermes_phase: "running", + intent: "Verify one bounded result", + in_scope: ["current package"], + out_of_scope: ["unrelated history"], + definition_of_done: ["Result is tested"], + current_work_package: "WP1", + next_action: "Run the focused test", + blockers: [], + repair_count: 0, + max_repair_count: 1, + work_packages: [ + { + id: "WP1", + title: "Tested result", + outcome: "Result is tested", + done_when: ["Focused test passes"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }, + ], + }); + fs.writeFileSync(taskJsonPath, `${JSON.stringify(task, null, 2)}\n`); + const contextId = setSessionActiveTask( + repoRoot, + "capsule-task", + "capsule-hook", + ); + const raw = runWorkflowState(repoRoot, contextId); + const payload = JSON.parse(raw) as { + hookSpecificOutput: { additionalContext: string }; + }; + const context = payload.hookSpecificOutput.additionalContext; + expect(context).toContain(""); + expect(context).toContain("Current: WP1"); + expect(context).toContain("Done when: Focused test passes"); + expect(context).toContain("use only the Task Capsule"); + expect(context).not.toContain("Claude review gates are read-only gates"); + expect(context).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); + const capsule = + /\n([\s\S]*?)\n<\/task-capsule>/.exec(context)?.[1] ?? ""; + expect(capsule.length).toBeLessThanOrEqual(1000); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("shared session-start.py injects only a compact Hermes main-agent boot guard", () => { const sessionStart = getSharedHookScripts().find( (h) => h.name === "session-start.py", diff --git a/packages/core/package.json b/packages/core/package.json index f5cde5d..e888c68 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis-core", - "version": "0.6.0-beta.31", + "version": "0.7.0-beta.0", "description": "Research Trellis core SDK for channels, tasks, memory, and auditable research records", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/src/task/schema.ts b/packages/core/src/task/schema.ts index df48480..0ffcf8b 100644 --- a/packages/core/src/task/schema.ts +++ b/packages/core/src/task/schema.ts @@ -10,6 +10,36 @@ * services) should depend on this type instead of redefining their own * task.json shape. */ +export type ClosureMode = "lean" | "standard" | "publication"; +export type HermesPhase = + | "planning" + | "ready" + | "running" + | "review" + | "blocked" + | "closed"; +export type ClosureWorkPackageStatus = + | "pending" + | "ready" + | "running" + | "review" + | "done" + | "blocked" + | "deferred" + | "waived"; + +export interface ClosureWorkPackage { + id: string; + title: string; + outcome: string; + done_when: string[]; + evidence_required: string[]; + depends_on: string[]; + status: ClosureWorkPackageStatus; + evidence_refs: string[]; + blocker: string | null; +} + export interface TrellisTaskRecord { id: string; name: string; @@ -35,6 +65,19 @@ export interface TrellisTaskRecord { relatedFiles: string[]; notes: string; meta: Record; + hermes_phase?: HermesPhase; + closure_state?: "open" | "closed"; + closure_mode?: ClosureMode; + intent?: string; + in_scope?: string[]; + out_of_scope?: string[]; + definition_of_done?: string[]; + work_packages?: ClosureWorkPackage[]; + current_work_package?: string | null; + next_action?: string | null; + blockers?: string[]; + repair_count?: number; + max_repair_count?: number; } /** @@ -66,6 +109,19 @@ export const TASK_RECORD_FIELD_ORDER = [ "relatedFiles", "notes", "meta", + "hermes_phase", + "closure_state", + "closure_mode", + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "work_packages", + "current_work_package", + "next_action", + "blockers", + "repair_count", + "max_repair_count", ] as const satisfies readonly (keyof TrellisTaskRecord)[]; export type TaskRecordField = (typeof TASK_RECORD_FIELD_ORDER)[number]; @@ -81,6 +137,10 @@ const STRING_FIELDS: ReadonlySet = new Set([ "assignee", "createdAt", "notes", + "hermes_phase", + "closure_state", + "closure_mode", + "intent", ]); const NULLABLE_STRING_FIELDS: ReadonlySet = new Set([ @@ -94,13 +154,50 @@ const NULLABLE_STRING_FIELDS: ReadonlySet = new Set([ "commit", "pr_url", "parent", + "current_work_package", + "next_action", ]); const STRING_ARRAY_FIELDS: ReadonlySet = new Set([ "subtasks", "children", "relatedFiles", + "in_scope", + "out_of_scope", + "definition_of_done", + "blockers", +]); + +const OPTIONAL_CLOSURE_FIELDS: ReadonlySet = new Set([ + "hermes_phase", + "closure_state", + "closure_mode", + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "work_packages", + "current_work_package", + "next_action", + "blockers", + "repair_count", + "max_repair_count", +]); + +const CLOSURE_MODES: ReadonlySet = new Set([ + "lean", + "standard", + "publication", +]); +const HERMES_PHASES: ReadonlySet = new Set([ + "planning", + "ready", + "running", + "review", + "blocked", + "closed", ]); +const CLOSURE_STATES: ReadonlySet = new Set(["open", "closed"]); /** * Lightweight runtime schema for {@link TrellisTaskRecord}. Zero-dep on @@ -108,11 +205,11 @@ const STRING_ARRAY_FIELDS: ReadonlySet = new Set([ * record, throwing on shape violations; `taskRecordSchema.safeParse` * returns a result discriminated by `success`. * - * All canonical fields are required; older partial records are rejected rather - * than backfilled with defaults. Unknown fields on the input are intentionally - * omitted from this structured output. `writeTaskRecord` preserves unknown - * fields already present on disk by merging canonical updates over the existing - * JSON object. + * Original Trellis fields remain required. Optional Hermes closure fields are + * parsed when present so legacy records keep working unchanged. Unknown fields + * on the input are intentionally omitted from this structured output. + * `writeTaskRecord` preserves unknown fields already present on disk by merging + * canonical updates over the existing JSON object. */ export const taskRecordSchema = { parse(input: unknown): TrellisTaskRecord { @@ -138,9 +235,12 @@ function parseTaskRecord(input: unknown): TrellisTaskRecord { if (!isPlainObject(input)) { throw new Error("task record must be a JSON object"); } - const out = emptyTaskRecord(); + const out = {} as TrellisTaskRecord; for (const field of TASK_RECORD_FIELD_ORDER) { if (!(field in input)) { + if (OPTIONAL_CLOSURE_FIELDS.has(field)) { + continue; + } throw new Error(`task.${field} is required`); } const value = (input as Record)[field]; @@ -159,6 +259,15 @@ function assignField( if (typeof value !== "string") { throw new Error(`task.${field} must be a string`); } + if (field === "closure_mode" && !CLOSURE_MODES.has(value)) { + throw new Error("task.closure_mode is invalid"); + } + if (field === "hermes_phase" && !HERMES_PHASES.has(value)) { + throw new Error("task.hermes_phase is invalid"); + } + if (field === "closure_state" && !CLOSURE_STATES.has(value)) { + throw new Error("task.closure_state is invalid"); + } bag[field] = value; return; } @@ -183,6 +292,22 @@ function assignField( record.meta = cloneJsonObject(value, "task.meta"); return; } + if (field === "work_packages") { + if (!Array.isArray(value)) { + throw new Error("task.work_packages must be an array"); + } + record.work_packages = value.map((item, index) => + parseClosureWorkPackage(item, index), + ); + return; + } + if (field === "repair_count" || field === "max_repair_count") { + if (!Number.isInteger(value) || (value as number) < 0) { + throw new Error(`task.${field} must be a non-negative integer`); + } + record[field] = value as number; + return; + } // Should be unreachable given the field sets cover every canonical field. /* c8 ignore next */ throw new Error(`unknown canonical task field: ${field}`); @@ -191,7 +316,7 @@ function assignField( /** * Produce a fully-populated canonical-shape {@link TrellisTaskRecord}. * - * All 24 fields are present in canonical order. `overrides` shallow-merges + * All canonical fields are present in canonical order. `overrides` shallow-merges * over the defaults — callers supply per-task values (id, name, title, * assignee, createdAt, etc.) and leave null-default fields untouched * unless they have a real value. @@ -225,6 +350,19 @@ export function emptyTaskRecord( relatedFiles: [], notes: "", meta: {}, + hermes_phase: "planning", + closure_state: "open", + closure_mode: "lean", + intent: "", + in_scope: [], + out_of_scope: [], + definition_of_done: [], + work_packages: [], + current_work_package: null, + next_action: null, + blockers: [], + repair_count: 0, + max_repair_count: 1, }; const record = { ...base, ...overrides }; if (overrides.subtasks !== undefined) { @@ -236,12 +374,96 @@ export function emptyTaskRecord( if (overrides.relatedFiles !== undefined) { record.relatedFiles = [...overrides.relatedFiles]; } + if (overrides.in_scope !== undefined) { + record.in_scope = [...overrides.in_scope]; + } + if (overrides.out_of_scope !== undefined) { + record.out_of_scope = [...overrides.out_of_scope]; + } + if (overrides.definition_of_done !== undefined) { + record.definition_of_done = [...overrides.definition_of_done]; + } + if (overrides.blockers !== undefined) { + record.blockers = [...overrides.blockers]; + } + if (overrides.work_packages !== undefined) { + record.work_packages = overrides.work_packages.map((item) => ({ + ...item, + done_when: [...item.done_when], + evidence_required: [...item.evidence_required], + depends_on: [...item.depends_on], + evidence_refs: [...item.evidence_refs], + })); + } if (overrides.meta !== undefined) { record.meta = cloneJsonObject(overrides.meta, "task.meta"); } return record; } +const WORK_PACKAGE_STATUSES: ReadonlySet = new Set([ + "pending", + "ready", + "running", + "review", + "done", + "blocked", + "deferred", + "waived", +]); + +function parseClosureWorkPackage( + input: unknown, + index: number, +): ClosureWorkPackage { + if (!isPlainObject(input)) { + throw new Error(`task.work_packages[${index}] must be an object`); + } + const readString = (field: "id" | "title" | "outcome"): string => { + const value = input[field]; + if (typeof value !== "string") { + throw new Error(`task.work_packages[${index}].${field} must be a string`); + } + return value; + }; + const readStringArray = ( + field: "done_when" | "evidence_required" | "depends_on" | "evidence_refs", + ): string[] => { + const value = input[field]; + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== "string") + ) { + throw new Error( + `task.work_packages[${index}].${field} must be an array of strings`, + ); + } + return [...value] as string[]; + }; + + const status = input.status; + if (typeof status !== "string" || !WORK_PACKAGE_STATUSES.has(status)) { + throw new Error(`task.work_packages[${index}].status is invalid`); + } + const blocker = input.blocker; + if (blocker !== null && typeof blocker !== "string") { + throw new Error( + `task.work_packages[${index}].blocker must be a string or null`, + ); + } + return { + id: readString("id"), + title: readString("title"), + outcome: readString("outcome"), + done_when: readStringArray("done_when"), + evidence_required: readStringArray("evidence_required"), + depends_on: readStringArray("depends_on"), + status: status as ClosureWorkPackageStatus, + evidence_refs: readStringArray("evidence_refs"), + blocker, + }; +} + export function isPlainObject(value: unknown): value is Record { return ( typeof value === "object" && diff --git a/packages/core/test/task/schema.test.ts b/packages/core/test/task/schema.test.ts index 11e6f99..209ab11 100644 --- a/packages/core/test/task/schema.test.ts +++ b/packages/core/test/task/schema.test.ts @@ -21,6 +21,11 @@ describe("emptyTaskRecord", () => { expect(record.children).toEqual([]); expect(record.relatedFiles).toEqual([]); expect(record.meta).toEqual({}); + expect(record.hermes_phase).toBe("planning"); + expect(record.closure_state).toBe("open"); + expect(record.closure_mode).toBe("lean"); + expect(record.work_packages).toEqual([]); + expect(record.max_repair_count).toBe(1); expect(record.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); @@ -71,6 +76,54 @@ describe("taskRecordSchema", () => { expect(parsed).not.toBe(input); }); + it("parses compact closure work packages", () => { + const parsed = taskRecordSchema.parse( + emptyTaskRecord({ + work_packages: [ + { + id: "WP1", + title: "Verified result", + outcome: "A result exists", + done_when: ["Tests pass"], + evidence_required: ["test output"], + depends_on: [], + status: "ready", + evidence_refs: [], + blocker: null, + }, + ], + }), + ); + expect(parsed.work_packages?.[0]?.status).toBe("ready"); + expect(parsed.work_packages?.[0]?.done_when).toEqual(["Tests pass"]); + }); + + it("accepts legacy task records without closure fields", () => { + const closureFields = new Set([ + "hermes_phase", + "closure_state", + "closure_mode", + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "work_packages", + "current_work_package", + "next_action", + "blockers", + "repair_count", + "max_repair_count", + ]); + const legacy = Object.fromEntries( + Object.entries(emptyTaskRecord()).filter( + ([field]) => !closureFields.has(field), + ), + ); + const parsed = taskRecordSchema.parse(legacy); + expect(parsed.hermes_phase).toBeUndefined(); + expect(parsed.work_packages).toBeUndefined(); + }); + it("rejects non-object inputs", () => { expect(() => taskRecordSchema.parse("nope")).toThrow(/must be a JSON object/); expect(() => taskRecordSchema.parse(null)).toThrow(); @@ -93,6 +146,36 @@ describe("taskRecordSchema", () => { meta: { nested: new Date() }, }), ).toThrow(/task.meta.nested must contain only JSON values/); + expect(() => + taskRecordSchema.parse({ + ...emptyTaskRecord(), + work_packages: [ + { + id: "WP1", + title: "Invalid", + outcome: "Invalid status", + done_when: ["never"], + evidence_required: [], + depends_on: [], + status: "complete", + evidence_refs: [], + blocker: null, + }, + ], + }), + ).toThrow(/status is invalid/); + expect(() => + taskRecordSchema.parse({ + ...emptyTaskRecord(), + closure_mode: "fast", + }), + ).toThrow(/closure_mode is invalid/); + expect(() => + taskRecordSchema.parse({ + ...emptyTaskRecord(), + hermes_phase: "complete", + }), + ).toThrow(/hermes_phase is invalid/); }); it("rejects records missing canonical fields", () => {