diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c778d9..0d324fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,13 @@ jobs: "dist/templates/trellis/scripts/hermes/experiment.py", "dist/templates/trellis/scripts/hermes/runner.py", "dist/templates/trellis/scripts/hermes/report.py", + "dist/templates/trellis/scripts/common/closure.py", + "dist/templates/trellis/scripts/common/dispatch.py", + "dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "dist/templates/shared-hooks/hermes-runtime-guard.py", + "dist/templates/shared-hooks/inject-workflow-state.py", + "dist/templates/common/commands/status.md", + "dist/templates/common/commands/handoff.md", ]; const missingCliFiles = requiredCliFiles.filter( (file) => !fs.existsSync(path.join(cliRoot, file)), diff --git a/README.md b/README.md index 22bee76..51104a3 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/docs/hermes_deployment_preflight.md b/docs/hermes_deployment_preflight.md index e483dba..cf64929 100644 --- a/docs/hermes_deployment_preflight.md +++ b/docs/hermes_deployment_preflight.md @@ -1,48 +1,25 @@ -# Hermes Deployment Candidate Hardening Preflight +# Hermes 本地工作流预检 -这版是 `deployment candidate hardening`(部署候选加固),不是完整 -`production ready`(生产就绪)平台。 +Hermes 面向真实科研项目工作区,不提供容器、沙箱或隔离运行环境。它使用任务状态、最小派发、运行记录和关闭审计协助科研迭代。 -## 紧急部署步骤 - -1. 先运行 quick gate: +## 发布前检查 ```bash pnpm --filter research-trellis hermes:preflight -- --quick -``` - -2. 发布前补跑完整验证: - -```bash pnpm --filter research-trellis exec vitest run test/scripts/agent-context-firewall.integration.test.ts test/scripts/closure.integration.test.ts test/templates/hermes-runtime.test.ts test/templates/hermes-roles.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts -PYTHONPYCACHEPREFIX=/tmp/research-trellis-pycache python3 -m py_compile packages/cli/src/templates/trellis/scripts/common/*.py packages/cli/src/templates/trellis/scripts/hermes/*.py packages/cli/src/templates/shared-hooks/*.py pnpm --filter research-trellis typecheck pnpm --filter research-trellis build ``` -3. 若 Python 编译后出现 `__pycache__` 或 `.pyc` 在模板目录内,不能发布;用 `gio trash` 或 `trash` 清理后重跑 preflight。 - -## 安全 Gate - -- 正式上下文防火墙只支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。Claude 的 `Agent`(代理)工具必须启用项目钩子;Codex 原生代理仅是建议性入口,需要强制控制时必须使用 `dispatch.py run --platform codex --mode strict`(Codex 严格派发)。 -- `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会覆盖心跳判断。`lean`(轻量)模式会显示强警告;`standard`(标准)模式要求新鲜钩子或严格执行心跳;`publication`(发表)模式缺少该硬门禁时必须拒绝派发和关闭。 -- Claude Agent 调用只传 `job_id`(工作编号),不允许异步调用。Codex 严格包装使用 `codex exec --output-schema --json -o`(结构化执行)并把原始跟踪保存在 `.trellis/.runtime/hermes-traces/`(本地跟踪目录);发布包和聊天只使用校验后的净化结果。 -- `Bash` 写入守卫是最佳努力,不是完整 shell 沙箱。hook 会解析常见重定向和 `tee` 写入;遇到写入语义但无法明确解析目标时必须 `fail closed`。 -- `runner.py` 必须按任务 `experiment.yaml` 的 `allowed_commands` 执行命令白名单;未匹配命令直接失败。`allowed_commands` is not a strong command sandbox(不是强命令沙箱);如果允许 `python3`,仍能执行任意 Python 行为。需要更强控制时,应使用更严格 exact match(精确匹配)或外部 OS 级隔离。 -- `sandbox.required=true` 且 `mode=none` 必须拒绝运行;`mode=none` 只是本地执行,不是隔离环境。`container` 当前只提供 `Docker`(容器运行时)薄切片:通过 `docker run --rm`(一次性容器执行命令)挂载仓库,并在容器工作目录执行已通过白名单的命令;这仍是 `not an OS sandbox`(不是操作系统沙箱),也不是 `production ready`(生产就绪)的强隔离。`external` 当前仍只做可用性检查,不能声称已经提供 `OS sandbox`(操作系统沙箱)。 -- `runner.py --cwd`、`--input`、`--output` 必须留在仓库内;`report.py --output` 必须留在当前任务 Hermes 目录内。 -- runner 默认使用最小环境变量白名单,`run_manifest` 不记录完整环境,避免 secret env 泄露。 -- `approval_records` 必须依赖 `external human/root approval`(外部人工/根权限批准)流程写入。当前实现只能校验字段、claim/evidence 关联和决策值;它不是 OS 级不可写权限隔离。 -- `JSONL` 记录是 append-only(追加写)约定,不是 `not tamper-proof`(不可篡改)存储。需要防篡改审计时,应使用外部日志、权限或签名机制。 - -## 失败处理 +## 当前约定 -- 缺关键模板文件:补齐模板后重跑 `hermes:preflight`。 -- Bash gate 失败:改用受 Hermes runner 管理的命令,或把写入文件纳入任务卡 `allowed_files`。 -- 命令不在 `allowed_commands`:更新任务级 `experiment.yaml`,不要绕过 runner。 -- approval 失败:由外部 human/root 流程补写完整 approval 记录,agent 不能自行生成批准。 -- 防火墙状态异常:运行 `python3 ./.trellis/scripts/hermes/dispatch.py status --task `(派发状态),确认平台、环境变量和最新心跳;不能用手工改写心跳文件绕过 `standard`(标准)或 `publication`(发表)门禁。 +- `runner.py`(运行器)直接在当前项目环境执行。`allowed_commands`(允许命令)用于记录实验可复现的命令边界,不是安全隔离。 +- `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)都使用紧凑派发和结果摘要;完整日志、差异和搜索过程保留在文件中。 +- `task.json`(任务状态)是事实源。`HANDOFF.md`(交接摘要)只用于暂停、阶段切换和恢复上下文,不阻止结束任务或关闭任务。 +- `close`(关闭)仍需满足工作包、证据和审计条件;人工科研决策仍由项目负责人确认。 -## 已知边界 +## 常见问题 -这版是部署候选加固薄切片:它阻断已知高风险绕过,并为 `container`(容器)模式提供可验证的 `Docker`(容器运行时)最小执行路径,但不提供用户权限、文件系统 `ACL`(访问控制列表)或系统调用级隔离;也就是 `not an OS sandbox`(不是操作系统沙箱),更不是生产级强隔离。需要强隔离时,应在后续版本把 `Hermes runner`(Hermes 运行器)放入独立受限执行环境。 +- 命令不在 `allowed_commands`(允许命令)中:更新当前任务的 `experiment.yaml`(实验配置),并保持命令尽可能具体。 +- 任务暂停:生成 `HANDOFF.md`(交接摘要);恢复时先读取 `task.json`(任务状态),再按需读取交接摘要。 +- 模板目录出现 `__pycache__` 或 `.pyc`:使用 `trash`(回收站命令)清理后重跑预检。 diff --git a/docs/hermes_role_profile_migration.md b/docs/hermes_role_profile_migration.md index 46a606c..7d35b42 100644 --- a/docs/hermes_role_profile_migration.md +++ b/docs/hermes_role_profile_migration.md @@ -26,7 +26,7 @@ ## 派发上下文 -`v0.7.1`(当前版本)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: +`v0.7.1-beta.0`(当前测试版)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: 1. `job_id`(工作编号)和 `hermes_revision`(Hermes 修订号); 2. 当前角色与模式; @@ -45,7 +45,7 @@ | 两平台正式 Hermes 模板合计 | 7 个 / 9873 字符 | 10 个 / 8904 字符 | 新增 Codex 支持后仍减少 9.8% | | 单个正式模板平均长度 | 1410 字符 | 890 字符 | 减少 36.9% | -派发正文测试限制在 2000 字符内。`Claude Code`(Claude 代码工具)只向 `Agent`(代理)传 `job_id`(工作编号),`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)由统一 CLI 包装器执行。 +派发正文测试限制在 2000 字符内。`Claude Code`(Claude 代码工具)只向 `Agent`(代理)传 `job_id`(工作编号),`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,直接在当前项目工作区协作。 ## 兼容边界 diff --git a/docs/hermes_trellis_research_transformation_plan.md b/docs/hermes_trellis_research_transformation_plan.md index f275eed..9afca82 100644 --- a/docs/hermes_trellis_research_transformation_plan.md +++ b/docs/hermes_trellis_research_transformation_plan.md @@ -1,6 +1,6 @@ # Hermes on Trellis 科研化改造总计划 -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development`(子代理驱动开发) or `superpowers:executing-plans`(执行计划) to implement this plan task-by-task. Steps should be tracked with checkbox syntax in later implementation issues. +> **状态:历史设计记录。** 本文保留早期讨论,不是当前运行规范;其中关于容器、沙箱、严格隔离和额外安全门禁的内容已不再采用。当前行为以中文手册、任务胶囊、Lean Closure 和本地工作流预检为准。 **Goal:** 把 Trellis 从任务协作脚手架,改造成可审计、可复现、可评估的科研实验框架。 @@ -62,13 +62,13 @@ ### P7-P11:deployment candidate hardening(部署候选加固) -当前 P7-P11 只做可验证薄切片:sandbox 配置 fail-closed、provenance -ledger、local service queue、audit ledger、evaluation quality gate。这里不能 -声称完整 `production ready`(生产就绪)或 OS 级生产平台。 +以下 P7-P11 是历史方案,不再作为当前路线。当前版本保留 provenance +ledger、audit ledger 和 evaluation quality gate 等轻量记录能力,不继续实现 +sandbox、容器、远程服务或复杂本地队列。 边界约定: -- `not an OS sandbox`(不是操作系统级沙箱)。`container` 模式已有 `Docker`(容器运行时)薄切片,会通过 `docker run --rm`(一次性容器执行命令)挂载仓库并运行白名单命令;它仍不是生产级强隔离。`external` 模式当前只做可用性检查。 +- `container`(容器)和 `external sandbox`(外部沙箱)方案已放弃;运行器直接在真实项目环境执行登记命令。 - `approval_records` 依赖 `external human/root approval`(外部人工/根权限批准)。 - `JSONL` 不是不可篡改存储,只是 append-only(追加写)约定。 - `allowed_commands` 不是 strong command sandbox(强命令沙箱);允许 `python3` 时仍可执行任意 Python 行为。 @@ -330,9 +330,9 @@ P7 Agent Context Firewall: - [x] 新增统一 `dispatch.py`(派发命令),支持创建、校验、显示、运行、列表、应用和状态查询。 - [x] 派发绑定单调 `hermes_revision`(Hermes 修订号)、当前工作包、五角色模式和最多 3 个引用。 - [x] Claude Agent 钩子只接受 `job_id`(工作编号),拒绝异步,并在结束时校验和净化结果。 -- [x] Codex native(Codex 原生)明确为建议性;strict(严格)复用结构化执行参数,不增加 SDK、数据库或服务。 +- [x] Codex native(Codex 原生)明确为建议性;旧 strict(严格)参数只保留兼容提示,不再提供独立执行模式。 - [x] 原始跟踪只写入忽略发布的 `.trellis/.runtime/`(本地运行目录);主上下文只接收净化摘要。 -- [x] `lean`(轻量)、`standard`(标准)和 `publication`(发表)按钩子或严格模式心跳执行不同门禁。 +- [x] `lean`(轻量)、`standard`(标准)和 `publication`(发表)共享钩子状态提示;模式差异由运行、证据、统计、主张和审批审计决定。 最终验证记录: diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index 65dcbd1..c26a9ff 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -16,6 +16,10 @@ ## 操作步骤 +每个新的可执行任务都先由主代理分析 intent(意图)、scope(范围)、definition of done(完成定义)、科研协议影响、风险和最小工作包拆分。已有任务的后续请求先分成三类:范围内续接继续当前工作包;低风险计划变化使用 `amend`(修改计划);新假设、模型功能架构、数据、指标、基线或主张范围变化进入 exploration(探索)并重新校验。范围内补充不能重新生成整套工作包。 + +协作判断固定为三档:普通可逆任务直接执行,不增加讨论或记录步骤;存在有限不确定性时,明确关键假设后继续;只有高风险研究变化才暂停讨论并由用户批准科研取舍。判断以事实和证据为先,应保留不确定性和有依据的异议。证据冲突或关键假设失效时,把任务标记为 `blocked`(阻塞),再通过批准的 `amend`(修改计划)更新研究协议。提示词不能替代结构化记录,也不能让每个任务都进入 `grill`(方案讨论)。 + 1. 创建任务: ```bash @@ -77,6 +81,15 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ 数据集、研究假设、数据切分、指标定义、基线、主张范围和任务范围属于高风险字段,必须增加 `--approved-by human/root`(人工批准)。变更直接写入 `task.json`(任务状态),同时向 `task-events.jsonl`(任务事件)追加 `plan_amended`(计划已修改)记录;变更后重新运行 closure 校验。正式研究仍可额外使用 Hermes `plan_change`(研究计划变更记录)保存更完整的提案和审阅链。 +新完成的高风险 exploration(探索)讨论还需要引用已有决策记录: + +```bash +python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ + --decision-ref design.md +``` + +引用必须位于仓库或当前任务内。记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 + ## 预期结果 任务创建时状态为 `planning`(规划中);启动后变为 `in_progress`(进行中);只有 close gate(关闭门禁)通过后才变为 `completed`(已完成),随后可移动到 `.trellis/tasks/archive//`(月度归档目录)。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 5c940a0..75b356e 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -2,12 +2,14 @@ ## 目标 -为每个科研任务固定问题、假设、数据、被测对象、指标、环境、允许命令和产物目录。 +为每个科研任务固定问题、假设、数据、被测对象、指标、环境、允许命令和产物目录。运行器直接在当前项目环境执行命令,不提供容器或沙箱运行模式。 ## 适用范围 适用于本地脚本、测试、基准、数据处理和模型评估。纯文档任务仍会生成实验骨架,但启动前也必须把占位字段改成非空内容。 +数据预检不是通用步骤。只有 Hermes closure(科研闭环)任务属于 exploration(探索),且 `research_change_fields`(研究变化字段)包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时才启用;旧版非 closure 任务、普通任务、冻结协议执行和不涉及这三个字段的探索任务都不增加预检。 + ## 前置条件 - 已创建任务并设置 `TASK`(任务目录名)变量。 @@ -39,21 +41,23 @@ environment: shell: "bash" allowed_commands: - "python3 scripts/evaluate.py" -sandbox: - mode: "none" - required: false artifact_dir: ".trellis/tasks/任务名/hermes/runs" ``` -3. 尽量把 `allowed_commands`(允许命令)写成完整命令前缀,而不是只写 `python3`(Python 3 命令)。当前匹配支持可执行文件名、完整命令或命令前缀,但它不是强安全边界。 -4. 选择运行隔离方式: +3. 对需要数据预检的任务,增加 `data_preflight`(数据预检): + +```yaml +data_preflight: + source: "公开评估集" + version: "2026-07" + input_manifest: "data/eval-manifest.json" + hash: "sha256:实际文件的64位小写哈希" + checks_ref: "data/eval-checks.yaml" +``` -| 模式 | 当前状态 | -| --- | --- | -| `none`(无隔离) | 已实现且为默认值;直接在本机运行 | -| `container`(容器) | 实验性;需要 `docker`(容器命令)和有效镜像,运行器会挂载仓库 | -| `external`(外部) | 配置可解析,但当前运行器不提供外部沙箱执行,实际运行会失败 | +`input_manifest`(输入清单)与 `data_path`(数据路径)二选一,`hash`(哈希值)必须与所指文件一致。`checks_ref`(检查记录)必须位于仓库内,并把 `schema`(结构)、`missing`(缺失值)、`duplicates`(重复项)和 `split_leakage`(切分泄漏)分别记为 `checked`(已检查)或 `not_applicable`(不适用)。运行时还要用 `runner.py run --input`(声明运行输入)同时传入数据文件和检查记录,二者会带哈希写入运行清单。 +4. 尽量把 `allowed_commands`(允许命令)写成完整命令前缀,而不是只写 `python3`(Python 3 命令)。它用于实验复现和任务边界,不是安全隔离。 5. 校验配置: ```bash @@ -67,18 +71,17 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 失败恢复 - 字段为空或类型错误:按报错修正;指标和允许命令必须是非空列表,随机种子必须是整数。 -- `sandbox.required=true`(必须隔离)却使用 `mode: none`(无隔离):改用可用容器,或停止运行。 -- 容器命令不可用:安装并验证 `docker`(容器命令),或在明确接受风险后改回无隔离模式。 - 命令被拒绝:不要扩大到笼统的可执行文件;把确实需要的完整命令前缀加入配置并重新审阅。 +- 数据预检失败:修正仓库内路径、实际文件哈希或四项检查状态。校验通过前运行器不会执行命令,也不会写入成功运行状态。 +- 旧配置中的 `sandbox`(沙箱)字段可以保留,但运行器会忽略它并直接在项目环境执行。 ## 验证记录 - 日期:2026-07-15。 - 版本:`0.7.1-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`(实现与测试核对)。 -- 结果:命令允许列表、隔离模式和对应测试均可定位。 -- 未验证项:本轮未启动真实容器或外部隔离环境。 +- 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 +- 结果:命令允许列表和本地运行记录均可定位。 ## 来源 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index 2d094cb..fb60377 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -32,7 +32,7 @@ python3 ./.trellis/scripts/hermes/record.py append --task "$TASK" --record-type python3 ./.trellis/scripts/hermes/runner.py run --task "$TASK" --job-id "$JOB" --checkpoint evaluate --summary "运行固定数据评估" --input fixtures/input.jsonl --input scripts/evaluate.py --output results/metrics.json -- python3 scripts/evaluate.py ``` -运行器会写入检查点、心跳、标准输出日志、标准错误日志、运行清单,以及成功结果或失败拒绝记录。 +运行器会在命令启动前记录声明输入的路径、大小和 `SHA-256`(文件摘要),随后写入检查点、心跳、标准输出日志、标准错误日志、运行清单,以及成功结果或失败拒绝记录。即使命令会修改某个输入,清单保留的也是实际启动时读取到的版本。 3. 校验工作代理记录和运行清单: diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 4f875e2..02fb0fb 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -16,6 +16,14 @@ ## 操作步骤 +### 科研协作触发 + +主代理以事实和证据为先,不以同意用户为目标;应明确重要假设、不确定性和有依据的异议,科研取舍仍由用户批准。普通可逆任务直接执行,有限不确定性声明假设后执行,只有高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后继续。 + +高风险 exploration(探索)完成 `grill-me`(方案讨论)后,必须用 `decision_ref`(决策记录引用)指向已有 `prd.md`(需求文档)或 `design.md`(设计文档)。记录包含决定、理由、证据、备选方案和失效条件,派发事件与 Task Capsule(任务胶囊)只传引用。提示词不能替代该记录;旧完成标记没有引用时仅警告,普通任务不新增讨论门禁。 + +只有 exploration 且研究变化包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时,运行代理才检查任务级 `data_preflight`(数据预检)。其他任务的角色派发、运行和复核步骤保持不变。 + ### 五个角色 `Role`(角色)决定权限、工具和责任边界。`Profile`(模式)决定这个角色本次具体做什么。模式不是新的子代理,也没有单独的平台代理模板。 @@ -36,6 +44,24 @@ - `runner`(运行代理):`experiment`(实验)、`test`(测试)、`build`(构建)、`validation`(验证)。 - `reviewer`(复核代理):`quality`(质量)、`evidence`(证据)、`claim`(主张)、`safety`(安全)、`closure`(关闭)、`statistics`(统计)。 +### 科研技能路由 + +技能由主代理按实际需要触发,不设每轮固定数量,也不要求组成完整流水线。多个技能确有依赖时按顺序使用,前一步结果通过文件引用复用。 + +| 场景 | 主代理动作 | 执行角色 | +| --- | --- | --- | +| 新任务 | 分析意图、范围、完成定义、科研分流和 1–4 个结果型工作包 | 复杂设计可由 `planner`(规划代理)提供候选 | +| 需求不清楚 | 按需触发 `brainstorm`(问题梳理) | 主代理与用户讨论,`researcher`(检索代理)补资料 | +| 关键科研决策 | 按需触发 `grill-me`(方案讨论) | 主代理与用户讨论,`planner` 比较方案但不批准 | +| 代码工作包 | 触发 `before-dev`(开发前准备),任务明确选择时才使用 TDD | `coder`(编码代理)实现,`runner`(运行代理)执行测试 | +| 工程验收 | 触发代码检查 | `runner` 执行,`reviewer:quality`(质量复核)独立判断 | +| 正式实验 | 触发 `hermes-research`(科研记录) | `runner` 登记运行,证据或统计由对应 `reviewer` 复核 | +| 重复技术失败 | 触发 `break-loop`(失败归因) | `planner:root_cause`(根因分析)判断,`coder` 执行限定修复 | +| 交接或收尾 | 请求交接、关闭审查和最终验证 | `coder:configuration` 写交接,`reviewer:closure` 审计,`runner` 验证和归档 | +| 稳定知识 | 关闭前评估一次 `update-spec`(更新规范) | `coder:configuration` 写入,`reviewer` 核对证据 | + +纯实验、文献和证据任务不触发代码技能。代码测试通过不等于科研证据充分;正常的负实验结果也不进入技术调试循环。 + 三个最小示例: ```yaml @@ -55,7 +81,7 @@ profile: evidence ### 派发与结果防火墙 -1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包;任务级 `planner`(规划代理)和 `reviewer`(复核代理)可不绑定。 +1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。测试、构建或代码验证 runner 必须用 `parent_job_id`(父工作编号)绑定被检查的 coder;独立实验 runner 可以不绑定。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)也必须绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 ```bash JOB="job-$(date -u +%Y%m%d-%H%M%S)-coder" @@ -68,11 +94,11 @@ python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id " 2. 派发正文最多 2000 字符、直接引用最多 3 个。绝对用户路径、敏感信息、越界或不存在的引用会被拒绝。任务状态变化后,旧派发会返回 `stale_dispatch`(过期派发)。 -3. `Claude Code`(Claude 代码工具)的 `Agent`(代理)调用只传 `job_id`(工作编号),钩子会机械替换长提示,并禁止异步派发。`Codex`(代码代理平台)的 native(原生)代理仅为 advisory(建议性);需要硬门禁时运行: +3. `Claude Code`(Claude 代码工具)的 `Agent`(代理)调用只传 `job_id`(工作编号),钩子会机械替换长提示,并禁止异步派发。`Codex`(代码代理平台)的派发输入依赖同一协议和结构化文件,属于 advisory(建议性)约束;下面的命令只显示经过校验的紧凑派发,不会创建沙箱或升级成严格执行器: ```bash python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" \ - --job-id "$JOB" --platform codex --mode strict + --job-id "$JOB" --platform codex ``` 4. 返回必须是单个 `Result Envelope`(结果信封)JSON,包含 `uncertainties`(不确定项),其中 `conclusion`(结论)最多 1200 字符。非法 JSON、长日志、完整差异、搜索过程、敏感信息和越界改动会被拒绝;raw trace(原始跟踪)只保存在 `.trellis/.runtime/hermes-traces/`(本地运行目录)。 @@ -91,18 +117,21 @@ python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" ``` +测试、构建、实验和验证命令由 `runner`(运行代理)通过 `runner.py run`(登记运行)执行。直接运行 `pnpm test`(运行测试)等命令不会经过运行清单,因此在正式角色门禁下会被拒绝。 + 6. 当前工作代理校验还会检查: - 同一工作树不能同时存在多个未结束的 `coder`(编码代理)或 `runner`(运行代理); - 结果中的改动文件必须落在任务卡允许范围内,且不能命中禁止范围; +- `coder`(编码代理)可以按任务卡修改实现文件;规划、检索和复核角色只能写当前任务下各自的分析、研究或审查记录目录;`runner`(运行代理)不能通过编辑工具修改文件; - 配置了心跳间隔的结果需要已有检查点; - `runner`(运行代理)成功只接受已有 `run_refs`(运行引用),不能把成功输出写成证据; -- `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录; +- `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录;需要独立复核的记录必须显式绑定被审工作,旧记录只在同一工作包只有一个候选时兼容; - 经 `Dispatch Packet`(派发包)确认的编码结果进入 `review`(复核)或 `claim_ready`(待审批)前,需要 `runner:test/build/validation`(运行代理的测试、构建或验证模式)结果,以及 `reviewer:quality/safety`(复核代理的质量或安全模式)记录。证据、主张或关闭复核不能替代代码质量复核。这个派发结果门禁不替代 `package-check`(工作包复核)或 `close`(关闭)的各自收口审计;未经过正式派发的旧任务继续按兼容流程处理。 -7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。当前内置完整运行时门禁只在 `Claude Code`(Claude 代码工具)和已启用钩子的 `Codex`(代码代理)配置中注册。 +7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。Claude 可额外替换 Agent 派发输入并净化返回;Codex 当前只对写入、受控命令和结束条件做机械检查,不能声明为完整输入隔离。两者都不是操作系统沙箱。 -当前 `Stop`(结束)完成检查偏向代码任务:它要求编码结果覆盖当前非 `.trellis/`(工作流目录)差异,并有相关的成功运行清单和质量复核记录。纯研究任务也可能触发这个要求,这是测试版的已知限制。 +`Stop`(结束)按实际工作类型检查:存在实现代码改动时要求编码结果、成功运行清单和独立质量复核;没有代码改动的实验、检索或规划工作要求对应角色结果和独立复核,正式运行还必须引用成功的运行清单。它不会为了纯研究任务强制创建编码结果。 ### 证据整理工具 @@ -144,13 +173,13 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" - 钩子未触发:检查平台配置;环境变量 `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会直接关闭门禁。 - 角色或模式组合无效:只使用本页五个正式角色及其模式,不要把模式写成新角色。 - 旧角色出现弃用提示:按迁移表更新派发配置;历史 `JSONL`(逐行 JSON)记录不需要重写。 -- 子代理读取内容过多:确认 Claude 只传 `job_id`(工作编号),或 Codex 使用 strict(严格)包装器;引用不能超过 3 个。 +- 子代理读取内容过多:确认 Claude 只传 `job_id`(工作编号),Codex 使用紧凑派发;引用不能超过 3 个。 - 门禁与任务类型不匹配:保留失败记录并由人工决定调整流程,不要把门禁当作安全认证。 ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 2ed39be..16d3da6 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -67,11 +67,29 @@ trust_level = "trusted" 重启 `Codex`(代码代理)后运行 `/hooks`(钩子审阅),逐项检查并批准 `.codex/hooks.json`(Codex 钩子配置)中的工作流注入和 `Hermes`(科研工作流)门禁。旧版 `Codex`(代码代理)使用 `codex_hooks = true`(旧钩子开关)。项目未信任、功能开关未开、未经 `/hooks`(钩子审阅)批准,或设置 `TRELLIS_HOOKS=0`(关闭钩子)、`TRELLIS_DISABLE_HOOKS=1`(禁用钩子)时,注入和门禁都不生效。 -`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会把 Hermes 正式角色升级为硬门禁。 +项目模板为 `Codex 0.144.0`(代码代理 0.144.0 版)及以上版本设置默认模型:主代理以及 `planner/researcher/reviewer`(规划、检索和复核代理)使用 `gpt-5.6-sol`(推理模型)和 `high`(高推理强度),`coder/runner`(编码和运行代理)使用 `gpt-5.6-terra`(执行模型)和 `max`(最大推理强度)。初始化和更新只维护两个带 Trellis 标记的配置块,分别保存顶层默认值和平台表配置;未标记的用户配置会原样保留并与受管块合并,用户自行选择的项目模型在更新和卸载时都不会被当成 Trellis 默认值删除。旧版 `Codex`(代码代理)只显示兼容警告,不阻止初始化。 -`v0.7.1`(当前版本)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 -五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)依赖已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex native`(Codex 原生代理)仅为 `advisory`(建议性),`Codex strict`(Codex 严格模式)通过 `codex exec --output-schema --json -o`(结构化执行参数)执行。能力缺失时,`lean`(轻量)警告降级,`standard`(标准)必须有钩子或严格模式,`publication`(发表)拒绝派发和关闭。 +`v0.7.1-beta.0`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 + +五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 + +## 用户入口 + +科研版只保留少量用户可见入口。主代理先根据任务状态、当前工作包和自然语言请求自动路由;命令和技能只是明确入口或兼容入口,不能绕过状态、审计和人工决策门禁。 + +| 入口 | 用途 | 说明 | +| --- | --- | --- | +| `continue`(继续任务) | 恢复活动任务 | 读取紧凑任务状态并只推进下一动作 | +| `status`(查看状态) | 查看阶段、工作包、阻塞和下一动作 | 只读,不会改变任务状态 | +| `handoff`(写入交接) | 在暂停、切换会话或压缩上下文前生成交接摘要 | 推荐下发子代理写入 `HANDOFF.md`(交接摘要),不会关闭任务 | +| `finish-work`(完成收尾) | 审计、关闭并归档已完成任务 | closure 任务必须先通过审计和关闭门禁 | +| `start`(启动会话) | 兼容没有会话开始钩子的环境 | 支持钩子的主会话通常自动完成启动 | + +`Claude Code`(Claude 代码工具)和其他原生命令平台会把这些入口显示为平台的命令形式;`Codex`(代码代理平台)使用同名技能入口。命令前缀由平台决定,不应把某个平台的前缀复制到另一个平台。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。其他技能按科研阶段和实际工作触发,不设每轮固定数量:需求确有缺口才使用 `brainstorm`(问题梳理);代码工作包才使用 `before-dev`(开发前准备)和 `check`(代码检查);`tdd`(测试驱动)必须由用户或任务策略明确选择;`break-loop`(失败归因)只处理重复技术失败;软件架构技能不能代替科研模型设计;正式实验和证据记录使用 `hermes-research`(科研记录)。技能不替代 closure 的状态、审计和关闭门禁。 6. 逐平台能力和限制如下。“子代理”表示仓库会安装对应代理模板;“门禁”专指 `hermes-runtime-guard.py`(Hermes 运行时门禁),不包括只做上下文注入的同名钩子事件。 @@ -80,7 +98,7 @@ trust_level = "trusted" | `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 正式 Hermes Agent 只传 `job_id`(工作编号),钩子替换提示并净化结果 | 完整 | 需平台读取项目设置;异步 Hermes Agent 被拒绝;环境开关可关闭 | | `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)注入 capsule 和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 没有每轮 capsule;只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | | `OpenCode`(开放代码工具) | `chat.message`(会话消息)注入会话摘要、每轮状态和 capsule | 支持;代理按派发首行自行读取 | 无 | 依赖 OpenCode 插件被实际加载;会话检索功能仍未实现 | -| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | native(原生)仅建议;strict(严格)由统一派发 CLI 执行 | 写入和结束门禁;严格模式另有结果门禁 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅);发表模式必须使用硬门禁 | +| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 使用紧凑派发与结果文件,直接在当前工作区执行 | 写入、登记命令和结束条件 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅);派发输入仍为协议约束 | | `Kilo CLI`(Kilo 命令行) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | | `Kiro Code`(Kiro 代码工具) | 无通用主会话注入 | 支持;`agentSpawn`(代理创建)时推送 | 无 | 只注册代理创建钩子,无会话开始和每轮主会话钩子 | | `Gemini CLI`(Gemini 命令行) | `SessionStart + BeforeAgent`(会话开始和代理前) | 支持;代理前置说明拉取 | 无 | 依赖 `.gemini/settings.json`(Gemini 设置)的当前钩子协议 | @@ -106,10 +124,10 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 -- 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并区分 Codex native 与 strict。 +- 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 - 未验证项:本轮未启动 14 种平台主机,也未访问远程工作流市场。 ## 来源 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index e580b59..5840d3b 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-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/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 1969681..fc5efc0 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" `run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)是不同状态。运行成功只生成运行记录,不能直接成为已接受证据;任务关闭也不能代替论文主张批准。 -模式还会检查防火墙可用性:`lean`(轻量)在没有新鲜钩子或严格模式心跳时强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发和关闭。 +模式都会检查钩子心跳;缺少新鲜钩子时给出明确警告,并继续使用派发、结果、记录和审计协议。`standard`(标准)与 `publication`(发表)的额外关闭条件来自运行清单、证据、统计、主张和人工审批,不来自沙箱或已取消的 Codex 严格执行器。 ## Audit 与有限修复 @@ -153,12 +153,14 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## Handoff -```bash -python3 ./.trellis/scripts/closure.py handoff --task "$TASK" -``` +`handoff`(交接)通常下发给 `coder:configuration`(配置修改)子代理生成当前任务的 `HANDOFF.md`(交接摘要)。它也可由 closure 命令直接生成紧凑状态摘要。 `HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 +有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。新生成的交接会记录任务修订号;只有修订号仍匹配时,恢复提示才会给出 `HANDOFF.md`(交接摘要)路径。旧格式或过期交接只显示警告,任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理;`task.py finish`(结束会话)只清除会话指针,不会隐式生成交接。 + +上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 + ## 常见失败 - `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 @@ -184,7 +186,7 @@ python3 ./.trellis/scripts/closure.py handoff --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index 0bef677..f58667e 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -30,7 +30,7 @@ python3 ./.trellis/scripts/task.py list ## 使用边界 - **已实现**:初始化、更新、任务与规范文件、多个平台模板、科研记录脚本、实验运行清单、比较、报告、频道和会话检索命令均有当前代码与测试依据。 -- **实验性**:整个科研改造版仍是测试版;`Hermes`(科研工作流)门禁、代理协作、频道工作代理和容器运行都应先在低风险项目试用。 +- **实验性**:整个科研改造版仍是测试版;`Hermes`(科研工作流)门禁、代理协作和频道工作代理都应先在低风险项目试用。当前版本直接在真实项目环境运行,不提供容器或沙箱执行模式。 - **未实现**:操作系统级安全沙箱、防篡改记录库、人工审批界面、远程任务服务,以及当前版本的 `OpenCode`(开放代码工具)会话读取器。 手册只描述当前正式仓库中的代码、命令、模板和测试。各页的“验证记录”说明本次实际执行过的检查,“来源”统一指向[事实来源](sources.md)。 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index ef1b0d3..85abe23 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1`(测试版)。 +适用于 `0.7.1-beta.0`(测试版)。 ## 前置条件 @@ -39,6 +39,18 @@ python3 ./.trellis/scripts/task.py finish python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ``` +平台命令入口: + +| 入口 | 用途 | 任务状态影响 | +| --- | --- | --- | +| `continue`(继续) | 恢复活动任务 | 无直接写入 | +| `status`(状态) | 显示阶段、工作包、阻塞和下一动作 | 只读 | +| `handoff`(交接) | 生成或下发子代理编写 `HANDOFF.md`(交接摘要) | 只更新交接文件 | +| `finish-work`(收尾) | 审计、关闭并归档满足条件的任务 | 可能归档 | +| `start`(启动) | 兼容无自动启动钩子的环境 | 无直接写入 | + +平台决定命令前缀。`Claude Code`(Claude 代码工具)等原生命令平台使用斜杠命令;`Codex`(代码代理平台)使用同名技能入口。`status`(状态)不会运行修复、关闭或归档;`handoff`(交接)不会完成或关闭任务。 + Closure 命令: ```bash @@ -82,13 +94,13 @@ Agent Context Firewall(代理上下文防火墙)统一命令: python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" --role --profile --objective python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id python3 ./.trellis/scripts/hermes/dispatch.py show --task "$TASK" --job-id --prompt -python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" --job-id --platform codex python3 ./.trellis/scripts/hermes/dispatch.py apply --task "$TASK" --job-id --result result.json python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id ``` -`create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)的 `Codex native`(Codex 原生)为建议性,`strict`(严格)才执行结构化包装;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 +`create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)在当前项目工作区使用紧凑派发协议;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 @@ -103,7 +115,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id /hermes/`(任务科研记录)保存任务事实。 - `task.json`(任务状态)保存当前 closure 状态;`task-events.jsonl`(任务事件)只追加重要历史。 -- `HANDOFF.md`(交接)按条件生成;`closure-report.md`(关闭报告)由通过的收口审计生成。 +- `HANDOFF.md`(交接)按条件生成并记录对应任务修订号;旧格式或过期交接只用于恢复参考。`closure-report.md`(关闭报告)由通过的收口审计生成。 - `.trellis/.runtime/`(会话运行状态)保存活动任务指针,通常不提交。 - `.codex/`(Codex 配置)、`.claude/`(Claude 配置)等目录由平台选择决定。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index 261f943..a150988 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -25,20 +25,19 @@ | `packages`(包列表) | 多包项目路径和类型 | | `codex.dispatch_mode`(Codex 调度模式) | 旧 Trellis 实现/检查代理的路由设置;不会把 Hermes 原生派发升级为强门禁 | -`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令、审批边界和 `context_firewall`(上下文防火墙)。正式平台只有 `claude`(Claude)与 `codex`(Codex):Claude 依赖钩子强制替换,`Codex native`(Codex 原生派发)仅提供建议,`Codex strict`(Codex 严格模式)通过统一派发命令执行。 +`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令、审批边界和 `context_firewall`(上下文防火墙)。正式平台只有 `claude`(Claude)与 `codex`(Codex):Claude 可依赖钩子替换,Codex 使用同一套紧凑派发和结果文件。 防火墙相关环境变量: | 环境变量 | 含义 | | --- | --- | -| `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;`lean`(轻量)强警告,`standard`(标准)和 `publication`(发表)拒绝缺少强门禁的操作 | +| `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;派发和关闭时提示当前处于协议约束模式 | | `TRELLIS_DISABLE_HOOKS=1`(禁用钩子) | 与上一项相同,用于兼容现有启动方式 | | `TRELLIS_HOOKS_ACTIVE=1`(钩子活动标记) | 仅作状态提示;必须同时存在签名且新鲜的心跳,不能单独通过门禁 | | `TRELLIS_PLATFORM`(平台) | 仅接受 `claude`(Claude)或 `codex`(Codex)作为正式防火墙平台 | -| `TRELLIS_CODEX_STRICT=1`(Codex 严格标记) | 仅作状态提示;严格包装仍需实际隔离执行和签名心跳,手工设置无效 | | `TRELLIS_CONTEXT_FIREWALL_HEARTBEAT_TTL`(心跳有效期) | 新鲜心跳的秒数,默认 900 | -`SessionStart`(会话启动)、运行守卫和 Codex 严格包装会在 `.trellis/.runtime/context-firewall/`(防火墙运行目录)写入心跳。`lean`(轻量)缺少新鲜心跳时继续但强警告;`standard`(标准)必须有钩子或严格执行;`publication`(发表)缺少硬门禁时拒绝派发和关闭。原始跟踪只写入 `.trellis/.runtime/hermes-traces/`(原始跟踪目录),默认不进入模板发布包。 +会话和运行守卫会在 `.trellis/.runtime/context-firewall/`(防火墙运行目录)写入签名心跳。缺少新鲜心跳时工具会警告,三种 closure 模式仍以结构化派发、结果、运行清单、证据和审计记录判断是否完成。原始跟踪只写入 `.trellis/.runtime/hermes-traces/`(原始跟踪目录),默认不进入模板发布包。 `.trellis/tasks//hermes/experiment.yaml`(任务实验配置)定义问题、假设、数据、指标、随机种子、环境、允许命令、隔离模式和产物目录。 @@ -59,7 +58,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|session_auto_commit|allowed_commands" packages/cli/src/templates/trellis`(配置模板核对)。 - 结果:会话、Hermes 防火墙、`Codex`(代码代理)调度和实验命令配置均可定位,模式门禁与心跳设置已对齐。 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index bda7f7c..4da8a64 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -16,7 +16,7 @@ | 类型 | 必填字段摘要 | | --- | --- | -| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags`;`profile`(模式)可省略,写入时会补默认值 | +| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags`;`profile`(模式)可省略,正式质量、证据、主张和统计复核还要有 `parent_job_id`(父工作编号) | | `heartbeat`(心跳) | `type,id,timestamp,job_id,status,checkpoint,summary,next_check_at` | | `checkpoint`(检查点) | `type,id,timestamp,job_id,checkpoint,resume_from,evidence_refs,open_items` | | `result`(结果) | `type,id,timestamp,job_id,status,summary,changed_files,evidence_refs,risk_flags,handoff` | @@ -32,8 +32,8 @@ | 文件 | 关键字段 | | --- | --- | -| `.dispatch.json`(派发文件) | `schema_version,job_id,task_id,hermes_revision,role,profile,work_package,objective,refs,allowed_files,forbidden_files,status,body,audit` | -| `.result.json`(净化结果) | `schema_version,job_id,status,conclusion,uncertainties,changed_files,evidence_refs,artifact_refs,run_refs,risk_flags,next_action,audit` | +| `.dispatch.json`(派发文件) | `schema,schema_version,job_id,task_id,task_revision,hermes_revision,role,profile,work_package,parent_job_id,objective,refs,allowed_files,forbidden_files,status,body,audit` | +| `.result.json`(净化结果) | `schema,schema_version,job_id,task_revision,role,profile,parent_job_id,status,conclusion,uncertainties,changed_files,evidence_refs,artifact_refs,run_refs,risk_flags,next_action,audit` | 派发最多 3 个引用,正文最多 2000 字符;结果结论最多 1200 字符。`runner`(运行代理)结果的 `evidence_refs`(证据引用)必须为空,`reviewer:evidence/claim`(证据或主张复核)只能写 proposed judgment(提议判断)。raw trace(原始跟踪)位于 `.trellis/.runtime/hermes-traces/`(本地运行目录),不属于任务记录或发布包。 @@ -60,7 +60,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-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 75e278d..15ef918 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -36,7 +36,7 @@ | 派发提示 `stale_dispatch`(过期派发) | 当前 `hermes_revision`(Hermes 修订号)已变化,重新创建派发 | | 返回提示 `invalid_json`(非法 JSON)或 `long_log`(长日志) | 只返回结果信封 JSON;日志、差异和搜索过程保留在文件或本地原始跟踪中 | | 两次返回无效后 job 被阻塞 | 修正返回契约并创建替代派发;不要把整个科研任务改成完成 | -| `standard`(标准)或 `publication`(发表)提示防火墙不可用 | 启用并批准 Claude/Codex 钩子,或使用 `Codex strict`(Codex 严格模式);发表模式不能降级到原生建议模式 | +| 子代理派发没有收到最小上下文 | 检查 Claude/Codex 钩子是否启用,或显式读取当前派发文件;关闭仍以任务记录、证据和审计为准 | | `close`(关闭)提示结果未确认 | 先处理所有派发结果和工作包,再显式运行 `audit`(审计)与 `close`(关闭) | | 质量门禁缺少统计字段 | 见[指标与比较](11-metrics-and-comparison.md)中的当前限制 | | 审批门禁失败 | 等待真实 `human/root`(人工根权限)批准记录 | @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-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 bbd2105..91c0205 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1`(测试版),后续版本应重新核对。 +适用于 `0.7.1-beta.0`(测试版),后续版本应重新核对。 ## 前置条件 @@ -22,7 +22,7 @@ | Lean Research Closure | 已实现、实验性 | 默认 1–4 个工作包;自动候选仍需人工审阅 | | Task Capsule 与条件式 Handoff | 已实现 | 平台没有主会话 hook 时需手动调用 | | 五角色与 Profile | 已实现、实验性 | 正式平台代理模板当前只提供 Claude Code 和 Codex;旧任务卡可兼容读取 | -| Agent Context Firewall | 已实现、实验性 | 正式支持 Claude Code 和 Codex;Claude 依赖已批准钩子,Codex native 仅建议、strict 才强制 | +| Agent Context Firewall | 已实现、实验性 | Claude 支持派发替换与结果净化;Codex 支持写入、命令和结束门禁,子代理输入仍依赖协议 | | 结构化 Result Envelope | 已实现 | 拒绝日志、完整差异、搜索过程和敏感信息;原始跟踪只保存在忽略发布的本地运行目录 | | `hermes_revision`(Hermes 修订号) | 已实现 | Closure 语义写入原子递增;执行前拒绝过期派发 | | 确定性证据整理 | 已实现 | 校验和汇总引用,不判断证据质量或批准主张 | @@ -30,8 +30,7 @@ | 科研记录与结构校验 | 已实现、实验性 | 追加式约定,不防篡改 | | 科研状态流程 | 协议模板 | `state_machine.yaml`(状态协议)未作为通用运行时迁移引擎强制执行 | | 实验运行与运行清单 | 已实现、实验性 | 默认无隔离,允许命令不是强沙箱 | -| 容器运行 | 实验性 | 依赖本机 `docker`(容器命令)和镜像 | -| 外部沙箱运行 | 未实现 | 配置可解析,当前运行器拒绝执行 | +| 容器或外部沙箱运行 | 不提供 | 当前版本直接在真实项目环境运行,不把流程门禁描述为隔离环境 | | 比较命令 | 已实现但有限制 | 不写统计字段,无法直接通过质量门禁 | | 报告和主张审查 | 已实现 | 报告保持待审批,不自动批准 | | 人工审批界面与身份认证 | 未实现 | 只校验记录字段,真实性靠外部流程 | @@ -47,7 +46,7 @@ - `allowed_commands`(允许命令)和代理文件范围用于流程约束,不是操作系统级沙箱。 - `RecordBus`(记录总线)使用本地 `JSONL`(逐行 JSON)文件,便于审计,但不能防止有文件权限的进程篡改历史。 - 平台钩子可以未启用、被环境变量关闭或因平台差异失效。执行危险命令前仍需人工确认。 -- `lean`(轻量)缺少防火墙时只强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发与关闭。 +- 缺少新鲜钩子时三种 closure 模式都会提示降级;`standard`(标准)和 `publication`(发表)仍通过更严格的运行、证据、统计、主张和人工审批记录完成审计。 - 不要把 `.env`(环境变量文件)、访问令牌、密码、私有数据或未脱敏实验输入写入任务、记录和报告,也不要提交到 `Git`(版本控制系统)。 - 外部文献、数据、模型和网络服务需要单独核对许可证、隐私和访问权限。 @@ -70,7 +69,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-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/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index f2fd9ee..959718d 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -89,13 +89,15 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 +科研协作以证据和事实为先,不以迎合用户结论为目标。主代理应明确重要假设、不确定性和有证据的异议,科研取舍由用户批准。普通可逆任务直接执行;有限不确定性先声明假设再执行;只有改变研究问题、协议或主张边界的高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后再继续。提示词不能代替任务和实验中的结构化记录,也不能因此让每个任务都经过 grill。 + ```bash # 规划与验证 python3 ./.trellis/scripts/closure.py plan --task \ --intent "可观察目标" --done-when "完成条件" # 研究协议分流:探索任务需记录变化字段和已完成 grill python3 ./.trellis/scripts/closure.py route --task --route exploration --research-change hypothesis -python3 ./.trellis/scripts/closure.py grill --task --complete +python3 ./.trellis/scripts/closure.py grill --task --complete --decision-ref design.md python3 ./.trellis/scripts/closure.py validate --task # 每次只处理当前 package @@ -108,10 +110,10 @@ python3 ./.trellis/scripts/closure.py audit --task python3 ./.trellis/scripts/closure.py repair --task python3 ./.trellis/scripts/closure.py close --task -# 正式角色派发:Claude 只传 job_id;Codex strict 由 CLI 执行 +# 正式角色派发:Claude 只传 job_id;Codex 使用同一紧凑派发协议 python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective "任务" python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id -python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex ``` 每轮 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`。 @@ -122,7 +124,9 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - 科研任务按研究协议是否改变分流,不按任务大小。`delivery`(确定性交付)不改变研究协议,等价重构和性能优化留在这里;`execution`(已冻结协议的实验执行)只运行种子或预先批准的参数搜索;`exploration`(探索性研究)记录 hypothesis、model_architecture、dataset、split、preprocessing、objective/loss、metric_definition、baseline、claim_scope 中实际变化的字段,并在 validate 前记录已完成的 grill。新假设或模型功能架构变化属于高风险探索,必须创建独立探索任务(可挂同一父项目),不能伪装成普通 work package。 -Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex native 仅为 advisory,strict 才机械执行和净化结果。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 +新完成的高风险 grill 必须用 `decision_ref`(决策记录引用)指向仓库内或任务内的 `prd.md`(需求文档)、`design.md`(设计文档)等记录。该记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件);事件和 Task Capsule 只保存引用,不注入正文。旧任务只有 `grill_completed=true`(已完成讨论)而没有引用时继续兼容并提示警告。涉及 dataset、split 或 preprocessing 的 exploration 还必须在任务级 `experiment.yaml`(实验配置)填写 `data_preflight`(数据预检);其他 exploration、delivery 和 execution 不增加该步骤。 + +Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 @@ -213,13 +217,22 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 [workflow-state:no_task] -No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +No active task. First analyze every actionable request: intent, scope, observable completion, research-protocol impact, risk, and the smallest useful 1-4 package split. Then ask for task-creation consent before creating any Trellis task. Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +Do not load a planning skill by default. Use brainstorm only for unresolved requirements and grill-me only for a material unresolved research or scope decision. 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] +### 用户入口优先级 + +先根据活动任务、当前工作包、任务胶囊和用户的自然语言请求自动选择规划、执行、审核、交接或收尾流程;不要等待用户输入固定命令。`continue`(继续)、`status`(状态)、`handoff`(交接)、`finish-work`(收尾)和 `start`(启动)只是明确入口或兼容入口,不能绕过任何状态门禁。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 + +技能不按固定数量加载,也不构成一条每次都要执行的流水线。新任务由主代理完成轻量分析和 1–4 个结果型工作包;已有任务的普通补充继续当前工作包,只有范围或研究协议变化才 amend 并重新 validate。主代理负责触发和整合,具体执行按权限下发:`before-dev` 给 coder,测试/实验给 runner,质量/证据/统计/closure 给 reviewer,根因或方法分析给 planner,资料检索给 researcher,规范与 handoff 写入给 `coder:configuration`。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) @@ -238,7 +251,8 @@ Multi-deliverable scope: consider a parent task plus independently verifiable ch Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. -For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +Use three intervention levels: execute ordinary reversible work directly; state material assumptions and proceed under limited uncertainty; pause only for a high-risk research change, then record the approved decision. Evidence conflict or a failed critical assumption means blocked plus an approved amendment. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. A new completed high-risk exploration grill also records a repository- or task-relative decision_ref; prompts never replace this record, and ordinary tasks do not acquire a grill gate. When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; 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 does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional 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 the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, 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 same task-level review-gate set. @@ -252,7 +266,7 @@ Before `task.py start`, record the development strategy decisions in the task do [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. +Analyze every new task, but load `trellis-brainstorm` only for unresolved requirements and `trellis-grill-me` only for a material unresolved research decision. 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. Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. @@ -274,14 +288,15 @@ 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 the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -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`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Test/build/code-validation runners and quality/evidence/claim/statistics reviewers bind `parent_job_id` to the exact job under review; independent experiments, closure, and independent safety review may remain task-level. Only coder edits implementation files; planner, researcher, and reviewer may write only their task-scoped record directories, and runner executes through `runner.py`. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. +Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. 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. +Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. Dispatch prompt starts with `Active task: `. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. Claude review gates are read-only gates. They report PASS / FAIL, blocking issues, and suggested next actions; they do not modify code directly. -If a Claude review gate fails, the main agent repairs the code, then re-runs the same gate before advancing. +If a Claude code review gate fails, the main agent dispatches a bounded coder repair, then re-runs the same gate before advancing. For any enabled `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, and `trellis-merge-review`, count repeated failures per gate and per task. If the same gate blocks the same task more than 3 times in a row, the main agent must briefly report that status to the user, re-check whether the requirements have drifted, and ask whether to skip the current review gate. If the chosen strategy is `subagent + worktree`, all code-development subagents must use the same `./.trellis/trellis-worktrees/` path. If the chosen strategy is TDD, align implementation and review expectations to `trellis-tdd`. diff --git a/marketplace/workflows/tdd/workflow.md b/marketplace/workflows/tdd/workflow.md index 7a1086c..b6ae1cd 100644 --- a/marketplace/workflows/tdd/workflow.md +++ b/marketplace/workflows/tdd/workflow.md @@ -190,7 +190,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni [workflow-state:planning] -Load `trellis-brainstorm`; stay in planning. +Analyze the task first; load `trellis-brainstorm` only if requirements remain unresolved. TDD is already an explicit task strategy, not evidence for a scientific claim. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. TDD planning gate: record observable behavior slices, the public interface under test, and mock boundaries 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. @@ -204,7 +204,7 @@ Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research mani into a sub-agent. --> [workflow-state:planning-inline] -Load `trellis-brainstorm`; stay in planning. +Analyze the task first; load `trellis-brainstorm` only if requirements remain unresolved. TDD is already an explicit task strategy, not evidence for a scientific claim. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. TDD planning gate: record observable behavior slices, the public interface under test, and mock boundaries 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. diff --git a/packages/cli/scripts/hermes-preflight.js b/packages/cli/scripts/hermes-preflight.js index 2ead1fc..b1feb23 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -142,40 +142,13 @@ export function checkHookMatcherIncludes(repoRoot = DEFAULT_REPO_ROOT) { messages.push(`hook matcher missing ${matcher}`); } } - if (!content.includes("cannot safely parse Bash")) { - messages.push("Bash write parsing must fail closed when targets are unclear"); + if (!content.includes("BASH_EXECUTION_ROLES")) { + messages.push("Bash execution role boundary is missing"); } return result("Hermes hook matcher", messages.length === 0, messages); } -export function checkSecurityGateDocumentation(repoRoot = DEFAULT_REPO_ROOT) { - const docPath = path.join(repoRoot, "docs/hermes_deployment_preflight.md"); - if (!fs.existsSync(docPath)) { - return result("Hermes security gate docs", false, [ - "missing docs/hermes_deployment_preflight.md", - ]); - } - const content = fs.readFileSync(docPath, "utf-8"); - const required = [ - "deployment candidate hardening", - "Bash", - "fail closed", - "allowed_commands", - "not a strong command sandbox", - "approval_records", - "human/root", - "external human/root approval", - "JSONL", - "not tamper-proof", - "not an OS sandbox", - ]; - const messages = required - .filter((text) => !content.includes(text)) - .map((text) => `deployment docs must mention: ${text}`); - return result("Hermes security gate docs", messages.length === 0, messages); -} - -export function checkSandboxConfiguration(repoRoot = DEFAULT_REPO_ROOT) { +export function checkLocalRunnerConfiguration(repoRoot = DEFAULT_REPO_ROOT) { const configPath = path.join( repoRoot, "packages/cli/src/templates/trellis/hermes/config.yaml", @@ -184,39 +157,26 @@ export function checkSandboxConfiguration(repoRoot = DEFAULT_REPO_ROOT) { repoRoot, "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", ); - const docPath = path.join(repoRoot, "docs/hermes_deployment_preflight.md"); const messages = []; - for (const requiredPath of [configPath, experimentPath, docPath]) { + for (const requiredPath of [configPath, experimentPath]) { if (!fs.existsSync(requiredPath)) { - messages.push(`missing sandbox boundary file: ${rel(repoRoot, requiredPath)}`); + messages.push(`missing local runner file: ${rel(repoRoot, requiredPath)}`); } } const config = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : ""; const experiment = fs.existsSync(experimentPath) ? fs.readFileSync(experimentPath, "utf-8") : ""; - const docs = fs.existsSync(docPath) ? fs.readFileSync(docPath, "utf-8") : ""; - for (const [name, content] of [ - ["config.yaml", config], - ["experiment.yaml", experiment], - ]) { - for (const text of ["sandbox:", "mode:", "required:"]) { - if (!content.includes(text)) { - messages.push(`${name} must include sandbox ${text}`); - } - } + if (!config.includes("runs directly in the project workspace")) { + messages.push("config.yaml must describe direct project-workspace execution"); } - for (const text of [ - "sandbox.required=true", - "mode=none", - "container", - "external", - "not an OS sandbox", - "not a strong command sandbox", - ]) { - if (!docs.includes(text)) { - messages.push(`deployment docs must mention sandbox boundary: ${text}`); + for (const text of ["allowed_commands:", "artifact_dir:"]) { + if (!experiment.includes(text)) { + messages.push(`experiment.yaml must include ${text}`); } } - return result("Hermes sandbox configuration", messages.length === 0, messages); + if (config.includes("sandbox:") || experiment.includes("sandbox:")) { + messages.push("local runner templates must not configure a sandbox"); + } + return result("Hermes local runner configuration", messages.length === 0, messages); } export function checkPythonCompile(repoRoot = DEFAULT_REPO_ROOT) { @@ -303,7 +263,7 @@ function usage() { return [ "Usage: node packages/cli/scripts/hermes-preflight.js [--quick] [--skip-python-compile]", "", - "--quick skips template vitest, typecheck, and build, but keeps security/file/cache checks.", + "--quick skips template vitest, typecheck, and build, but keeps template/file/cache checks.", ].join("\n"); } @@ -314,8 +274,7 @@ export function runPreflight(options = {}) { checkHermesRequiredFiles(repoRoot), checkNoPythonCaches(repoRoot), checkHookMatcherIncludes(repoRoot), - checkSecurityGateDocumentation(repoRoot), - checkSandboxConfiguration(repoRoot), + checkLocalRunnerConfiguration(repoRoot), ]; if (options.skipPythonCompile) { @@ -374,16 +333,13 @@ async function main() { } console.log( - `Hermes deployment preflight${args.quick ? " (quick mode)" : ""}`, - ); - console.log( - "Scope: deployment candidate hardening, not production ready.", + `Hermes local workflow preflight${args.quick ? " (quick mode)" : ""}`, ); console.log( - "Security gate: Bash write parsing is best-effort and fails closed; allowed_commands is not a strong command sandbox.", + "Scope: lightweight local research collaboration.", ); console.log( - "Approval and storage: approval_records require external human/root approval; JSONL is not tamper-proof storage. This is not an OS sandbox.", + "Runner: commands execute in the current project workspace; allowed_commands records the experiment contract.", ); const checks = runPreflight(args); for (const check of checks) { diff --git a/packages/cli/scripts/release-preflight.js b/packages/cli/scripts/release-preflight.js index 0d802a3..2a89659 100644 --- a/packages/cli/scripts/release-preflight.js +++ b/packages/cli/scripts/release-preflight.js @@ -77,7 +77,11 @@ const HERMES_PYTHON_TEMPLATE_FILES = [ "packages/cli/src/templates/trellis/scripts/hermes/runner.py", "packages/cli/src/templates/trellis/scripts/hermes/runtime.py", "packages/cli/src/templates/trellis/scripts/hermes/validate.py", + "packages/cli/src/templates/trellis/scripts/common/closure.py", + "packages/cli/src/templates/trellis/scripts/common/dispatch.py", + "packages/cli/src/templates/trellis/scripts/common/firewall.py", "packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py", + "packages/cli/src/templates/shared-hooks/inject-workflow-state.py", ]; const PACKED_CLI_REQUIRED_FILES = [ "package/README.md", @@ -89,7 +93,13 @@ const PACKED_CLI_REQUIRED_FILES = [ "package/dist/templates/trellis/scripts/hermes/experiment.py", "package/dist/templates/trellis/scripts/hermes/runner.py", "package/dist/templates/trellis/scripts/hermes/report.py", + "package/dist/templates/trellis/scripts/common/closure.py", + "package/dist/templates/trellis/scripts/common/dispatch.py", + "package/dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "package/dist/templates/shared-hooks/hermes-runtime-guard.py", + "package/dist/templates/shared-hooks/inject-workflow-state.py", + "package/dist/templates/common/commands/status.md", + "package/dist/templates/common/commands/handoff.md", ]; const RED = "\x1b[31m"; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 883be3a..f8f3f7d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -638,7 +638,7 @@ function getBlankProjectBootstrapPrd(pythonCmd: string): string { ## 工作方式 -- 这是一次需要方案讨论的启动任务。先完成聚焦讨论,再记录 \`closure.py grill --complete\`。 +- 这是一次需要方案讨论的启动任务。先完成聚焦讨论,把 Decision、Rationale、Evidence、Alternatives 和 Failure Conditions 五项写入本任务的 \`prd.md\`,再记录 \`closure.py grill --complete --decision-ref prd.md\`。 - 讨论完成后,创建一个新的普通任务;它应有明确的 \`intent\`(目标)、\`definition_of_done\`(完成定义)和 1–4 个以结果为单位的工作包。 - 首个任务应验证一个最小结果,例如固定样例上的可重复输出、基线比较或数据读取链路;不要把“搭完整平台”作为首个任务。 - 启动任务只负责建立项目合同和首个任务,不负责实现该任务。 @@ -653,7 +653,7 @@ function getBlankProjectBootstrapPrd(pythonCmd: string): string { ## 完成方式 \`\`\`bash -${pythonCmd} ./.trellis/scripts/closure.py grill --task 00-bootstrap-guidelines --complete +${pythonCmd} ./.trellis/scripts/closure.py grill --task 00-bootstrap-guidelines --complete --decision-ref prd.md ${pythonCmd} ./.trellis/scripts/closure.py validate --task 00-bootstrap-guidelines ${pythonCmd} ./.trellis/scripts/closure.py package-start --task 00-bootstrap-guidelines --package-id WP1 ${pythonCmd} ./.trellis/scripts/closure.py package-check --task 00-bootstrap-guidelines --package-id WP1 diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index f836b96..88f9a42 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -33,6 +33,11 @@ import { compareVersions } from "../utils/compare-versions.js"; import { toPosix } from "../utils/posix.js"; import { setupProxy } from "../utils/proxy.js"; import { emptyTaskJson } from "../utils/task-json.js"; +import { + isTrellisCodexConfigMerge, + mergeTrellisCodexConfig, +} from "../utils/codex-config.js"; +import { warnCodexVersion } from "../utils/codex-version.js"; // Import templates for comparison import { @@ -687,6 +692,31 @@ function collectTemplateFiles( } } + const codexConfigPath = ".codex/config.toml"; + const codexConfig = files.get(codexConfigPath); + const existingCodexConfigPath = path.join(cwd, codexConfigPath); + if (codexConfig && fs.existsSync(existingCodexConfigPath)) { + try { + const existing = fs.readFileSync(existingCodexConfigPath, "utf-8"); + files.set( + codexConfigPath, + mergeTrellisCodexConfig(existing, codexConfig).content, + ); + } catch { + // The normal update conflict path handles unreadable project config. + } + } + + // A pre-existing root CLAUDE.md has no Trellis write receipt. Keep it + // outside update ownership even under --force. + const rootClaudePath = "CLAUDE.md"; + if ( + fs.existsSync(path.join(cwd, rootClaudePath)) && + !loadHashes(cwd)[rootClaudePath] + ) { + files.delete(rootClaudePath); + } + preserveExistingClaudeStatusLine(cwd, files); // Apply update.skip from config.yaml (unless bypassed for breaking release) @@ -808,10 +838,14 @@ function analyzeChanges( const storedHash = hashes[relativePath]; const currentHash = computeHash(existingContent); + const safeCodexConfigMerge = + relativePath === ".codex/config.toml" && + isTrellisCodexConfigMerge(existingContent, newContent); if ( (storedHash && storedHash === currentHash) || (!storedHash && - isKnownUntrackedTemplate(relativePath, existingContent)) + isKnownUntrackedTemplate(relativePath, existingContent)) || + safeCodexConfigMerge ) { // Either the tracked hash matches, or this is a known pristine template // from before the path was hash-tracked. Safe to auto-update. @@ -1005,7 +1039,7 @@ function backupFile( const BACKUP_DIRS = ALL_MANAGED_DIRS; /** Root-level managed files to include in update backups. */ -const BACKUP_FILES = [FILE_NAMES.AGENTS] as const; +const BACKUP_FILES = [FILE_NAMES.AGENTS, "CLAUDE.md"] as const; /** * Patterns to exclude from backup (user data that shouldn't be backed up) @@ -1872,6 +1906,9 @@ export async function update(options: UpdateOptions): Promise { // the prune step would otherwise consider orphans (codex hasn't been added // to configuredPlatforms yet at this point). const codexUpgradeNeeded = needsCodexUpgrade(cwd); + if (codexUpgradeNeeded || getConfiguredPlatforms(cwd).has("codex")) { + warnCodexVersion(); + } if (codexUpgradeNeeded) { console.log( chalk.yellow( diff --git a/packages/cli/src/configurators/claude.ts b/packages/cli/src/configurators/claude.ts index 0dcbf0a..195f0b1 100644 --- a/packages/cli/src/configurators/claude.ts +++ b/packages/cli/src/configurators/claude.ts @@ -1,9 +1,7 @@ -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { AI_TOOLS } from "../types/ai-tools.js"; -import { - getAllCommands as getClaudeCommands, -} from "../templates/claude/index.js"; +import { getAllCommands as getClaudeCommands } from "../templates/claude/index.js"; import { getClaudeTemplatePath } from "../templates/extract.js"; import { ensureDir, writeFile } from "../utils/file-writer.js"; import { @@ -16,6 +14,11 @@ import { replacePythonCommandLiterals, } from "./shared.js"; +export const CLAUDE_THIN_ENTRY = `# Claude Code + +Use \`AGENTS.md\` as the project instructions. Read \`.trellis/project/PROJECT_INDEX.md\`, then the current Task Capsule or \`HANDOFF.md\` only when needed. Do not duplicate those rules here. +`; + const EXCLUDE_PATTERNS = [ ".d.ts", ".d.ts.map", @@ -103,4 +106,11 @@ export async function configureClaude(cwd: string): Promise { resolveSkills(ctx), resolveBundledSkills(ctx), ); + + // Root CLAUDE.md is a Claude-only discovery pointer. Never claim or replace + // a project-owned file, including during force initialization. + const rootEntry = path.join(cwd, "CLAUDE.md"); + if (!existsSync(rootEntry)) { + await writeFile(rootEntry, CLAUDE_THIN_ENTRY); + } } diff --git a/packages/cli/src/configurators/codex.ts b/packages/cli/src/configurators/codex.ts index 86a37d3..512034e 100644 --- a/packages/cli/src/configurators/codex.ts +++ b/packages/cli/src/configurators/codex.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { AI_TOOLS } from "../types/ai-tools.js"; import { @@ -8,6 +9,8 @@ import { getHooksConfig, } from "../templates/codex/index.js"; import { ensureDir, writeFile } from "../utils/file-writer.js"; +import { mergeTrellisCodexConfig } from "../utils/codex-config.js"; +import { warnCodexVersion } from "../utils/codex-version.js"; import { resolvePlaceholders, resolveAllAsSkillsNeutral, @@ -26,10 +29,10 @@ import { * - .codex/agents/, hooks/, hooks.json, config.toml — platform-specific */ export async function configureCodex(cwd: string): Promise { + warnCodexVersion(); // Shared skills from common source → .agents/skills/ - // Uses the neutral placeholder resolver so the 5 shared workflow skills - // (brainstorm, before-dev, check, break-loop, update-spec) render to the - // same bytes regardless of which platform writes them — required because + // Uses the neutral placeholder resolver so shared workflow skills render to + // the same bytes regardless of which platform writes them — required because // Gemini CLI 0.40+ also targets `.agents/skills/` (last-writer-wins is // safe when both writers produce identical output). const sharedSkillsRoot = path.join(cwd, ".agents", "skills"); @@ -133,8 +136,19 @@ export async function configureCodex(cwd: string): Promise { // Config → .codex/config.toml const config = getConfigTemplate(); - await writeFile( - path.join(codexRoot, config.targetPath), - replacePythonCommandLiterals(config.content), - ); + const configPath = path.join(codexRoot, config.targetPath); + const templateContent = replacePythonCommandLiterals(config.content); + let configContent = templateContent; + if (fs.existsSync(configPath)) { + try { + const existing = fs.readFileSync(configPath, "utf-8"); + configContent = mergeTrellisCodexConfig( + existing, + templateContent, + ).content; + } catch { + // Keep writeFile's existing-file handling for an unreadable config. + } + } + await writeFile(configPath, configContent); } diff --git a/packages/cli/src/configurators/index.ts b/packages/cli/src/configurators/index.ts index b1d400a..63fa2ad 100644 --- a/packages/cli/src/configurators/index.ts +++ b/packages/cli/src/configurators/index.ts @@ -18,7 +18,7 @@ import { } from "../types/ai-tools.js"; // Platform configurators -import { configureClaude } from "./claude.js"; +import { CLAUDE_THIN_ENTRY, configureClaude } from "./claude.js"; import { configureCursor } from "./cursor.js"; import { configureOpenCode, collectOpenCodeTemplates } from "./opencode.js"; import { configureCodex } from "./codex.js"; @@ -182,6 +182,7 @@ const PLATFORM_FUNCTIONS: Record = { `.claude/${settings.targetPath}`, resolvePlaceholders(settings.content), ); + files.set("CLAUDE.md", CLAUDE_THIN_ENTRY); return files; }, }, diff --git a/packages/cli/src/configurators/shared.ts b/packages/cli/src/configurators/shared.ts index 9d81d8a..06c2361 100644 --- a/packages/cli/src/configurators/shared.ts +++ b/packages/cli/src/configurators/shared.ts @@ -162,7 +162,7 @@ export function resolvePlaceholders( * Identical to {@link resolvePlaceholders} except that {@link CMD_REF} is * rendered in a platform-neutral form (`` `name` (Trellis command) ``) * instead of substituting a platform-specific prefix. This is the only - * placeholder that varies between platforms in the 5 shared workflow skills + * placeholder that varies between platforms in the shared workflow skills * (`brainstorm`, `before-dev`, `check`, `break-loop`, `update-spec`), so * neutralizing it makes the rendered SKILL.md files byte-identical regardless * of which Trellis configurator wrote them — eliminating the @@ -196,7 +196,7 @@ export function resolvePlaceholdersNeutral( result = result.replace(RE_USER_ACTION_LABEL, context.userActionLabel); result = result.replace(RE_CLI_FLAG, context.cliFlag); - // Conditional blocks (resolved per platform — none of the 5 shared skills + // Conditional blocks (resolved per platform — none of the shared skills // use conditionals, but Codex-only command-as-skill files might in future). const flagValues: Record<(typeof CONDITIONAL_FLAGS)[number], boolean> = { AGENT_CAPABLE: context.agentCapable, @@ -229,6 +229,10 @@ const SKILL_DESCRIPTIONS: Record = { "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next.", "finish-work": "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session.", + status: + "Show the active task's compact phase, work package, blockers, and next action without changing task state. Use when the user asks for progress or wants to confirm what happens next.", + handoff: + "Write a compact HANDOFF.md checkpoint for the active Hermes closure task before a pause, context compaction, session switch, or explicit handoff request. It does not close or archive the task.", "before-dev": "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards.", brainstorm: @@ -237,8 +241,7 @@ const SKILL_DESCRIPTIONS: Record = { "Tightens a Trellis task's requirements after repository-first clarification. Use when factual questions are exhausted and the remaining gaps are about scope, product intent, trade-offs, or edge cases that must be resolved before implementation.", check: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions.", - tdd: - "Drives a test-first implementation flow by breaking work into observable behavior slices, defining public interfaces under test, and running red-green-refactor cycles. Use when the chosen development strategy is TDD or the user explicitly asks to work test-first.", + tdd: "Drives a test-first implementation flow by breaking work into observable behavior slices, defining public interfaces under test, and running red-green-refactor cycles. Use when the chosen development strategy is TDD or the user explicitly asks to work test-first.", "break-loop": "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs.", "improve-codebase-architecture": @@ -273,6 +276,8 @@ export function wrapWithSkillFrontmatter( const COMMAND_DESCRIPTIONS: Record = { start: "Initialize a Trellis development session.", continue: "Resume work on the current task at the correct phase.", + status: "Show the active task status without changing it.", + handoff: "Write a compact handoff for the active closure task.", "finish-work": "Wrap up the current session: quality gate, commit reminder, archive, journal.", }; @@ -292,7 +297,8 @@ export function wrapWithCommandFrontmatter( return `---\nname: ${name}\ndescription: ${description}\n---\n\n${content}`; } -const TRELLIS_SWITCH_GUARD_PATH = "./.trellis/scripts/assert_trellis_enabled.py"; +const TRELLIS_SWITCH_GUARD_PATH = + "./.trellis/scripts/assert_trellis_enabled.py"; const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---)((?:\r?\n)*)/; const TOP_LEVEL_HEADING_RE = /^(# [^\r\n]+)((?:\r?\n)+)/; @@ -427,7 +433,7 @@ export function resolveCommands(ctx: TemplateContext): ResolvedTemplate[] { } /** - * Resolve only the 5 skill templates with trellis- prefix + SKILL.md frontmatter. + * Resolve shared workflow skill templates with trellis- prefix + SKILL.md frontmatter. * Used by "both" platforms for the auto-triggered skills. */ export function resolveSkills(ctx: TemplateContext): ResolvedTemplate[] { @@ -465,7 +471,7 @@ export function resolveSkillsNeutral(ctx: TemplateContext): ResolvedTemplate[] { /** * Same as {@link resolveAllAsSkills} but uses - * {@link resolvePlaceholdersNeutral} for the 5 shared skills. The 2 command + * {@link resolvePlaceholdersNeutral} for the shared skills. The command * templates (continue, finish-work) folded into the skill set still resolve * `{{CLI_FLAG}}` / `{{PYTHON_CMD}}` per platform — only Codex writes those * files into `.agents/skills/`, so byte-identity isn't required there. @@ -539,7 +545,10 @@ export function resolveBundledSkills( relativePath: `${skill.name}/${file.relativePath}`, content: file.relativePath === "SKILL.md" - ? injectTrellisSwitchGuard(resolvePlaceholders(file.content, ctx), "skill") + ? injectTrellisSwitchGuard( + resolvePlaceholders(file.content, ctx), + "skill", + ) : resolvePlaceholders(file.content, ctx), })), ); diff --git a/packages/cli/src/templates/codex/agents/hermes-coder.toml b/packages/cli/src/templates/codex/agents/hermes-coder.toml index ecd9aa2..715f777 100644 --- a/packages/cli/src/templates/codex/agents/hermes-coder.toml +++ b/packages/cli/src/templates/codex/agents/hermes-coder.toml @@ -1,11 +1,12 @@ name = "hermes-coder" description = "Hermes coder for bounded implementation, tests, configuration, and repair." -sandbox_mode = "workspace-write" +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are the Hermes coder. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: coder:`, current work package, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: coder:`, current work package, objective, and at most three refs. Modify only `allowed_files`, avoid `forbidden_files`, and keep one active writer per worktree. Return exactly the Result Envelope JSON; never include logs, diffs, or search history. Do not approve evidence or claims, close the task, or widen scope without an amended dispatch. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-planner.toml b/packages/cli/src/templates/codex/agents/hermes-planner.toml index b56e32d..97e9cdf 100644 --- a/packages/cli/src/templates/codex/agents/hermes-planner.toml +++ b/packages/cli/src/templates/codex/agents/hermes-planner.toml @@ -1,11 +1,12 @@ name = "hermes-planner" description = "Hermes planner for research design, bounded task planning, root-cause analysis, and method selection." -sandbox_mode = "read-only" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes planner. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: planner:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: planner:`, objective, and at most three refs. Follow the active Profile and return exactly the Result Envelope JSON with a bounded conclusion, uncertainties, blockers, and next action. Do not edit implementation code, mark packages done, approve high-risk changes or claims, or close the task. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-researcher.toml b/packages/cli/src/templates/codex/agents/hermes-researcher.toml index ce4007c..52b77b3 100644 --- a/packages/cli/src/templates/codex/agents/hermes-researcher.toml +++ b/packages/cli/src/templates/codex/agents/hermes-researcher.toml @@ -1,11 +1,12 @@ name = "hermes-researcher" description = "Hermes researcher for literature, codebase, official documentation, and prior-art searches." -sandbox_mode = "workspace-write" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes researcher. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: researcher:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: researcher:`, objective, and at most three refs. Read only sources needed by the active Profile. Return exactly the Result Envelope JSON with traceable refs, applicability limits, and uncertainties. Do not modify code, results, claims, approvals, package state, or closure state. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-reviewer.toml b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml index ea24928..1251090 100644 --- a/packages/cli/src/templates/codex/agents/hermes-reviewer.toml +++ b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml @@ -1,11 +1,12 @@ name = "hermes-reviewer" description = "Hermes independent reviewer for quality, evidence, claims, safety, closure, and statistics." -sandbox_mode = "read-only" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes reviewer. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated blind-review dispatch body with `job_id`, bound revision, `role: reviewer:`, objective, and at most three refs. Do not read coder/runner explanations. For enforced execution, use `dispatch.py run --platform codex --mode strict`. +Require one validated blind-review dispatch body with `job_id`, bound revision, `role: reviewer:`, objective, and at most three refs. Do not read coder/runner explanations. Read only current artifacts, ledgers, criteria, and cited refs required by the active Profile. Return exactly the Result Envelope JSON. Evidence/claim profiles may propose a judgment but cannot approve facts, claims, or human records. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-runner.toml b/packages/cli/src/templates/codex/agents/hermes-runner.toml index 0710c4a..94dd116 100644 --- a/packages/cli/src/templates/codex/agents/hermes-runner.toml +++ b/packages/cli/src/templates/codex/agents/hermes-runner.toml @@ -1,11 +1,12 @@ name = "hermes-runner" description = "Hermes runner for experiments, tests, builds, and validation with reproducible records." -sandbox_mode = "workspace-write" +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are the Hermes runner. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: runner:`, current work package, objective, and at most three refs. For enforced execution, use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: runner:`, current work package, objective, and at most three refs. Run only authorized commands and register exact commands in run manifests, not in chat. Return exactly the Result Envelope JSON with existing `run_refs`; `evidence_refs` must be empty because runner success is not accepted evidence. """ diff --git a/packages/cli/src/templates/codex/agents/trellis-check.toml b/packages/cli/src/templates/codex/agents/trellis-check.toml index 34f4b49..c0b4623 100644 --- a/packages/cli/src/templates/codex/agents/trellis-check.toml +++ b/packages/cli/src/templates/codex/agents/trellis-check.toml @@ -1,7 +1,7 @@ name = "trellis-check" description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix. diff --git a/packages/cli/src/templates/codex/agents/trellis-implement.toml b/packages/cli/src/templates/codex/agents/trellis-implement.toml index 83cc3b7..560209a 100644 --- a/packages/cli/src/templates/codex/agents/trellis-implement.toml +++ b/packages/cli/src/templates/codex/agents/trellis-implement.toml @@ -1,7 +1,7 @@ name = "trellis-implement" description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work. diff --git a/packages/cli/src/templates/codex/agents/trellis-research.toml b/packages/cli/src/templates/codex/agents/trellis-research.toml index 1e0efeb..4ec97ec 100644 --- a/packages/cli/src/templates/codex/agents/trellis-research.toml +++ b/packages/cli/src/templates/codex/agents/trellis-research.toml @@ -1,7 +1,7 @@ name = "trellis-research" description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Trellis researcher agent. diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index e878787..8584abe 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -1,3 +1,4 @@ +# TRELLIS:CODEX_CONFIG:START # Project-scoped Codex defaults for Trellis workflows. # Codex merges this layer after the user-level config when the project # is marked as a trusted project. To trust this project, add it under @@ -8,6 +9,13 @@ # # Without trust, the [features] block below is loaded but disabled. +# Default coordinator model for this research workflow. Codex 0.144.0 or +# newer is required to recognize these model and reasoning-effort values. +# TRELLIS:CODEX_MODEL_DEFAULTS:START +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +# TRELLIS:CODEX_MODEL_DEFAULTS:END + # Keep AGENTS.md as the primary project instruction file. project_doc_fallback_filenames = ["AGENTS.md"] @@ -18,7 +26,9 @@ project_doc_fallback_filenames = ["AGENTS.md"] # config.toml cannot set feature flags; they must be user-level. # Codex 0.129+ additionally gates each installed hook behind a one-time # `/hooks` TUI review; until the user approves it, the hook stays inactive. +# TRELLIS:CODEX_CONFIG:END +# TRELLIS:CODEX_TABLES:START # multi_agent_v2 forces structured subagent orchestration with the # `wait` tool — parent must block on terminal status before acting, # instead of cancelling / re-spawning. Incompatible with @@ -33,11 +43,8 @@ enabled = true max_concurrent_threads_per_session = 3 min_wait_timeout_ms = 120000 -# Native custom-agent dispatch cannot mechanically replace or validate the -# agent prompt/output, so its Context Firewall authority is advisory only. -# Use the generated dispatch CLI strict wrapper for enforced Hermes work: -# python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +# Native custom-agent dispatch uses the validated Hermes dispatch and Result +# Envelope files. It works directly in the current project workspace. [hermes.context_firewall] -native_authority = "advisory" -strict_authority = "enforced" -strict_runtime = "codex exec --output-schema --json -o" +native_authority = "protocol" +# TRELLIS:CODEX_TABLES:END diff --git a/packages/cli/src/templates/codex/hooks/session-start.py b/packages/cli/src/templates/codex/hooks/session-start.py index 24a721a..2e7ae2d 100644 --- a/packages/cli/src/templates/codex/hooks/session-start.py +++ b/packages/cli/src/templates/codex/hooks/session-start.py @@ -157,7 +157,7 @@ def _build_hermes_main_agent_boot_guard(trellis_dir: Path) -> str: Source: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Read it before governance-sensitive work; keep this SessionStart payload compact. Role: Main Agent / Main Pilot coordinates Hermes state, bounded subagent routing, record validation, handoff, and human/PI stop points. Hard limits: do not directly modify source, metrics, dataset splits, baselines, official evaluation, claim_allowed=true, or main merge unless Hermes state and human/root authority allow it. -Formal Codex context policy: native is advisory; use validated_dispatch_only through dispatch.py strict mode for enforced work. Keep raw output out of chat. +Formal Codex context policy: use validated_dispatch_only with the current task dispatch. Keep raw output out of chat. Record policy: chat is not completion; completed, failed, blocked, stale, interrupted, or capacity-blocked work needs structured records. Claim boundary: keep engineering success, runner success, evidence review, proxy evidence, scientific evidence, claim approval, and merge approval separate. """ diff --git a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md index edb6dbf..331d0dc 100644 --- a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md +++ b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md @@ -7,11 +7,17 @@ description: "Use for Hermes research tasks that need append-only evidence, clai Use this skill when a task asks for Hermes research discipline, evidence ledgers, claim review, human approval records, or state-machine-aware research progress. +The main agent triggers it for formal experiments and structured research +records. `runner` owns commands, manifests, artifacts, and raw observations; +`reviewer:evidence` and `reviewer:statistics` judge sufficiency and quality; +`reviewer:claim` proposes claim disposition. Command success and a single run +never become accepted evidence or a durable claim by themselves. + ## Read First 1. Resolve the active Hermes task. 2. Create a validated dispatch bound to the current `hermes_revision`. -3. Pass only its `job_id` to Claude Agent, or use Codex strict `dispatch.py run`. +3. Pass only its `job_id` to Claude Agent; Codex uses the same compact dispatch protocol. 4. Read at most three direct references named by the canonical dispatch. 5. Accept only the validated Result Envelope; raw traces never enter chat. @@ -64,7 +70,7 @@ Runtime helpers are available under `.trellis/scripts/hermes/`: - `python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective ` - `python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id ` - `python3 ./.trellis/scripts/hermes/dispatch.py show --task --job-id --prompt` -- `python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict` +- `python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex` - `python3 ./.trellis/scripts/hermes/dispatch.py apply --task --job-id --result ` - `python3 ./.trellis/scripts/hermes/dispatch.py list --task ` - `python3 ./.trellis/scripts/hermes/record.py append --task --record-type worker --json ''` diff --git a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md index aa2e663..8483b85 100644 --- a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +++ b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md @@ -49,7 +49,7 @@ If the user wants only one platform to avoid sub-agents, first confirm whether t | `status` | Artifact state | Resume at | | --- | --- | --- | -| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | `prd.md` missing | Phase 1.1 (analyze first; load `trellis-brainstorm` only for unresolved requirements) | | `planning` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | | `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | | `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then run `task.py start` | diff --git a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md index 816c666..3590084 100644 --- a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +++ b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -11,7 +11,7 @@ Skills and commands are textual entry points for user interaction with Trellis. | prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | | workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | -Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. +Trellis workflow skills share one semantic set: research framing (`brainstorm`, `grill-me`), conditional engineering (`before-dev`, `tdd`, `check`, architecture review), technical diagnosis (`break-loop`), and durable knowledge (`update-spec`). Multi-file built-in skills such as `hermes-research` and `trellis-meta` use layered references. ## Common Paths diff --git a/packages/cli/src/templates/common/commands/continue.md b/packages/cli/src/templates/common/commands/continue.md index 92e72bf..5e0c91b 100644 --- a/packages/cli/src/templates/common/commands/continue.md +++ b/packages/cli/src/templates/common/commands/continue.md @@ -30,9 +30,9 @@ Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. ## Step 3: Decide Where You Are -`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. +`get_context.py` shows the active task's `status` field. First classify the new request as an in-scope continuation, a bounded plan change, or a new task. In-scope detail stays in the current work package and does not regenerate the plan. Route by `status` + artifact presence; this command does not itself approve implementation. -- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + no `prd.md` → **1.1** (analyze the task; load `trellis-brainstorm` only if requirements remain unresolved) - `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. - `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** - `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) diff --git a/packages/cli/src/templates/common/commands/finish-work.md b/packages/cli/src/templates/common/commands/finish-work.md index 1917d9e..9ef38a5 100644 --- a/packages/cli/src/templates/common/commands/finish-work.md +++ b/packages/cli/src/templates/common/commands/finish-work.md @@ -42,13 +42,15 @@ 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 closure gates +## Step 3: Review Hermes closure gates -Use the current active task name for ``. Every closure task runs audit and close: +Use the current active task name for ``. The main agent coordinates this +step but does not self-approve closure. First run the deterministic audit, then +create a validated `reviewer:closure` dispatch against `task.json`, the audit +result, package dispositions, blockers, and evidence references: ```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: @@ -65,15 +67,26 @@ For `publication` only, additionally run the formal comparison gate: {{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. +Run only the gates required by `closure_mode`. A reviewer records gaps but does +not modify task state. Do not advance while the independent closure verdict has +blocking gaps. Do not archive until that verdict and the deterministic gate both +pass. Legacy tasks without closure fields retain the original compatibility +path. + +## Step 4: Close and archive through a runner -## Step 4: Archive task(s) +After the reviewer passes closure, create a validated `runner:validation` +dispatch for the required final checks and these control commands: ```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py close --task {{PYTHON_CMD}} ./.trellis/scripts/task.py archive ``` -At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. +The runner reports command results and archive location; it does not reinterpret +evidence or approve claims. At minimum archive the current active task, plus any +extra tasks the user confirmed in Step 1. Each archive produces a +`chore(task): archive ...` commit via the script's auto-commit. If there is no active task and the user did not confirm any cleanup archives, skip this step. diff --git a/packages/cli/src/templates/common/commands/handoff.md b/packages/cli/src/templates/common/commands/handoff.md new file mode 100644 index 0000000..7c31c9c --- /dev/null +++ b/packages/cli/src/templates/common/commands/handoff.md @@ -0,0 +1,37 @@ +# Write Task Handoff + +Write a concise handoff for the active Hermes closure task before a pause, session switch, context compaction, or explicit user handoff request. + +## Step 1: Resolve the active task + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/task.py current --source +``` + +If there is no active task, report that fact and stop. + +## Step 2: Dispatch the handoff writer + +The main agent must not run the write command. Create a validated +`coder:configuration` dispatch with no work package, only this allowed file: + +```text +.trellis/tasks//HANDOFF.md +``` + +The worker receives the current `task.json` and runs +`closure.py handoff --task `. It returns a normal Result Envelope. + +Read the sanitized worker result, then report the generated `HANDOFF.md` path, +current phase, current work package, blockers, and next action. Keep the chat +response short; the file is the durable checkpoint. + +For a legacy task without closure fields, do not invent a Hermes handoff. Report that the task uses the original compatibility flow and identify the current task artifacts instead. + +## Boundaries + +- Writing a handoff does not complete, close, archive, approve, or amend the task. +- `task.json` remains the source of truth; a handoff is a recovery summary, not a gate. +- Do not load full chat history or unrelated task files to create the handoff. +- The dedicated handoff writer is the one exception: it may only write the + current task's `HANDOFF.md` and must not change task state or other files. diff --git a/packages/cli/src/templates/common/commands/start.md b/packages/cli/src/templates/common/commands/start.md index 1d3f66b..24b9a7c 100644 --- a/packages/cli/src/templates/common/commands/start.md +++ b/packages/cli/src/templates/common/commands/start.md @@ -38,8 +38,8 @@ 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. For a Lean Research Closure task, read the Task Capsule first, then use `closure.py plan` and `closure.py validate`; `trellis-grill-me` is optional and only needed for an unresolved decision. Lightweight tasks can otherwise be PRD-only. For legacy or explicitly complex Claude Code tasks, keep requirement clarification in `prd.md` and use `trellis-grill-me` as the required planning gate before development strategy decisions or `task.py start`. Complex tasks need `design.md` + `implement.md`. 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 `planning` + no `prd.md`** → Phase 1.1. Analyze intent, scope, completion criteria, research route, and a 1-4 package candidate split. Load `trellis-brainstorm` only if requirements remain unresolved after reading relevant project evidence. +- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Read the Task Capsule, keep existing work packages for an in-scope continuation, and use `closure.py amend` for a real plan change. `trellis-grill-me` is optional and only applies to an unresolved material research or scope decision; task complexity alone never makes it mandatory. Lightweight tasks can be PRD-only, while code-heavy complex tasks add `design.md` and `implement.md` with their development strategy and explicitly selected review gates before `task.py start`. - **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}} @@ -47,7 +47,7 @@ For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py cap - **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. +- **No active task** → analyze every actionable request first, then ask whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. --- @@ -55,10 +55,11 @@ For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py cap | User intent | Skill | |---|---| -| New feature / unclear requirements | `trellis-brainstorm` | -| About to write code | `trellis-before-dev` | -| Done coding / quality check | `trellis-check` | -| Stuck / fixed same bug multiple times | `trellis-break-loop` | -| Learned something worth capturing | `trellis-update-spec` | +| Research question, scope, or done criteria remain unclear | `trellis-brainstorm` | +| Material research-contract decision remains unresolved | `trellis-grill-me` | +| Current work package requires code | coder uses `trellis-before-dev` | +| Code work is complete | runner verifies; reviewer uses `trellis-check` | +| Repeated technical failure, not a negative research result | planner uses `trellis-break-loop` | +| Reviewed durable knowledge exists near closure | coder configuration uses `trellis-update-spec` | Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/packages/cli/src/templates/common/commands/status.md b/packages/cli/src/templates/common/commands/status.md new file mode 100644 index 0000000..7b92f37 --- /dev/null +++ b/packages/cli/src/templates/common/commands/status.md @@ -0,0 +1,30 @@ +# Show Task Status + +Show the current Trellis task without changing task, evidence, claim, or archive state. + +## Step 1: Resolve the active task + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/task.py current --source +``` + +If there is no active task, report that fact and stop. Do not create a task, start a task, or dispatch a subagent. + +## Step 2: Read the smallest authoritative status + +For a Hermes closure task, use: + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py status --task +{{PYTHON_CMD}} ./.trellis/scripts/closure.py next --task +``` + +Report only the task title, phase, current work package, next action, blockers, and whether `HANDOFF.md` exists. Do not load the handoff body, full PRD, reports, or event history unless the user asks for that specific detail. + +For a legacy task without closure fields, report its original task status and the available task artifacts. Preserve the original Trellis compatibility path. + +## Boundaries + +- This command is read-only. +- Do not run plan, validate, package-start, repair, close, finish, or archive. +- Do not turn a runner result into accepted evidence or a completed task. diff --git a/packages/cli/src/templates/common/skills/before-dev.md b/packages/cli/src/templates/common/skills/before-dev.md index f9d2a83..c5d7c12 100644 --- a/packages/cli/src/templates/common/skills/before-dev.md +++ b/packages/cli/src/templates/common/skills/before-dev.md @@ -1,4 +1,12 @@ -Read the relevant development guidelines before starting your task. +# Before Development + +Main triggers this skill only when the current work package requires code, +tests, or configuration changes. A validated `coder` dispatch uses it; pure +literature, experiment-only, evidence-review, and claim-review packages skip it. +It loads the smallest relevant engineering constraints and does not decide the +scientific hypothesis, model choice, metric, or claim. + +Read the relevant development guidelines before starting implementation. Execute these steps: diff --git a/packages/cli/src/templates/common/skills/brainstorm.md b/packages/cli/src/templates/common/skills/brainstorm.md index 7a49174..e8a988d 100644 --- a/packages/cli/src/templates/common/skills/brainstorm.md +++ b/packages/cli/src/templates/common/skills/brainstorm.md @@ -1,10 +1,16 @@ # Trellis Brainstorm -## Non-Negotiable Interview Contract +## Focused Research Framing Contract -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. +The main agent triggers this skill only when the research question, scope, or +observable completion criteria remain unclear after repository-first analysis. +It is not part of every task. Clear delivery and frozen-protocol execution tasks +go directly to a bounded closure plan. -Ask the questions one at a time. +Resolve only uncertainties that block planning. Ask one high-value question at +a time and include a recommended answer. A researcher may collect missing +sources; a planner may propose framing, but the main agent retains routing and +the user retains research decisions. ## Non-Negotiable Evidence Rule @@ -12,7 +18,9 @@ If a question can be answered by exploring the codebase, explore the codebase in This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. -Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, or decisions that remain ambiguous after inspection. +Do not ask the user to confirm facts that the repository can answer. Ask only +for research intent, scope, evidence expectations, preferences, risk tolerance, +or decisions that remain ambiguous after inspection. --- diff --git a/packages/cli/src/templates/common/skills/break-loop.md b/packages/cli/src/templates/common/skills/break-loop.md index 10f5c56..825d161 100644 --- a/packages/cli/src/templates/common/skills/break-loop.md +++ b/packages/cli/src/templates/common/skills/break-loop.md @@ -1,6 +1,13 @@ # Break the Loop - Deep Bug Analysis -When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. +The main agent triggers this skill for repeated technical failures, not for a +normal negative scientific result. Dispatch `planner:root_cause` to separate +code, environment, data-pipeline, and experiment-contract causes; dispatch a +coder only after a bounded repair is identified. Evidence or statistical doubt +goes to the corresponding reviewer instead. + +Use it to break the "fix bug -> forget -> repeat" cycle after failed technical +repairs or after a difficult technical defect is resolved. --- @@ -108,18 +115,13 @@ Three levels of insight: --- -## After Analysis: Immediate Actions - -**IMPORTANT**: After completing the analysis above, you MUST immediately: - -1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: - - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` - - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` - - If it's a code reuse issue → update `code-reuse-thinking-guide.md` - - If it's domain-specific → update `backend/*.md` or `frontend/*.md` - -2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` - -3. **Commit the spec updates** - This is the primary output, not just the analysis text +## After Analysis: Bounded Actions -> **The analysis is worthless if it stays in chat. The value is in the updated specs.** +1. Record the testable root cause, smallest diagnostic, and repair boundary in + the planner result. +2. Return implementation repair to a validated `coder:repair` dispatch. +3. If the result reveals reviewed, reusable engineering knowledge, let the main + agent evaluate `update-spec` near closure. A `coder:configuration` worker + writes it and a reviewer verifies it. +4. Do not edit specs, sync templates, commit files, or reinterpret a negative + research result from this analysis skill. diff --git a/packages/cli/src/templates/common/skills/check.md b/packages/cli/src/templates/common/skills/check.md index 59f9da1..ec1811b 100644 --- a/packages/cli/src/templates/common/skills/check.md +++ b/packages/cli/src/templates/common/skills/check.md @@ -1,5 +1,14 @@ # Code Quality Check +The main agent triggers this skill only after a code, test, or configuration +work package changes implementation. A `runner` executes tests, lint, typecheck, +and builds; an independent `reviewer:quality` judges correctness, scope, and +regressions. The coder's own success statement is not acceptance. + +Pure experiment and literature packages skip this skill. Code quality checks +do not approve evidence, statistics, claims, or task closure; those belong to +the matching reviewer profile. + Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. --- diff --git a/packages/cli/src/templates/common/skills/grill-me.md b/packages/cli/src/templates/common/skills/grill-me.md index d605cf5..c15569a 100644 --- a/packages/cli/src/templates/common/skills/grill-me.md +++ b/packages/cli/src/templates/common/skills/grill-me.md @@ -1,10 +1,17 @@ # Trellis Grill Me -Use this skill after the initial repository-first clarification pass, when the task still has requirement gaps that only the user can answer. +The main agent uses this skill after repository-first analysis only when a +material research decision remains unresolved. Typical triggers are a changed +hypothesis, functional model architecture, dataset, split, preprocessing, +objective/loss, metric definition, baseline, claim scope, or explicit user +request. Task size alone is not a trigger. ## Purpose -Drive a strict follow-up interview to tighten `prd.md` before implementation starts. +Resolve the smallest decision needed to make the research contract testable. +A `planner` may compare methods or experimental designs, but cannot approve a +high-risk change. The main agent leads user discussion and delegates every task +artifact update. This is the Trellis-built-in replacement for external `grill-me` dependency patterns. Do not rely on any local third-party skill path. @@ -13,7 +20,8 @@ This is the Trellis-built-in replacement for external `grill-me` dependency patt Use this skill only when: - a Trellis task already exists - repository-answerable questions have already been resolved through inspection -- the remaining uncertainty is about product intent, scope, preferences, trade-offs, or risk tolerance +- the remaining uncertainty is about research intent, scope, method trade-offs, + evidence standards, claim boundaries, preferences, or risk tolerance Do **not** use this skill for questions the codebase can answer directly. @@ -25,7 +33,9 @@ Do **not** use this skill for questions the codebase can answer directly. - why it matters - your recommended answer - what trade-off the user accepts if they choose differently -- After each answer, update `prd.md` before asking the next question. +- After each answer, record the proposed `prd.md` update for a validated planner + or coder dispatch before asking the next question. A main agent must not edit + `prd.md` directly. - Stop once `prd.md` has converged enough to enter development-strategy decisions. ## Questioning Style diff --git a/packages/cli/src/templates/common/skills/improve-codebase-architecture.md b/packages/cli/src/templates/common/skills/improve-codebase-architecture.md index d40fa06..acb85fc 100644 --- a/packages/cli/src/templates/common/skills/improve-codebase-architecture.md +++ b/packages/cli/src/templates/common/skills/improve-codebase-architecture.md @@ -5,6 +5,13 @@ description: "Active codebase architecture analysis and review. Three modes: (A) # Trellis Improve Codebase Architecture +This is an explicit software-architecture skill. The main agent does not trigger +it merely because a research task changes a neural-network or algorithmic model +architecture. Research model decisions belong to a focused grill and +`planner:research_design` or `planner:method_selection`; this skill applies only +when module boundaries, interfaces, dependencies, or maintainability need +guidance or independent review. + This skill operates in three modes depending on context. Read the dispatch prompt to determine which mode applies. --- diff --git a/packages/cli/src/templates/common/skills/tdd.md b/packages/cli/src/templates/common/skills/tdd.md index 639f9ff..c02dd71 100644 --- a/packages/cli/src/templates/common/skills/tdd.md +++ b/packages/cli/src/templates/common/skills/tdd.md @@ -2,6 +2,12 @@ Use this skill when the task's chosen development strategy is TDD, or when the user explicitly asks to work test-first. +The main agent triggers it only for deterministic software behavior inside a +code work package. A `coder` writes tests and implementation; a `runner` +executes them. TDD may verify data transforms, model interfaces, metrics code, +and reproducibility utilities, but passing tests do not validate a hypothesis +or establish experimental evidence. + ## Purpose Turn a task into observable behavior slices and drive implementation through failing tests first. diff --git a/packages/cli/src/templates/common/skills/update-spec.md b/packages/cli/src/templates/common/skills/update-spec.md index 19e4f1a..d09f836 100644 --- a/packages/cli/src/templates/common/skills/update-spec.md +++ b/packages/cli/src/templates/common/skills/update-spec.md @@ -1,5 +1,16 @@ # Update Code-Spec - Capture Executable Contracts +In a Hermes closure task, the main agent may identify that this process is +needed, but it must delegate every file update through a validated +`coder:configuration` dispatch. The main agent only checks the returned +result and does not edit a spec directly. + +Evaluate this once near closure or when a reviewed durable contract is +established. Do not run it after every work package. One experiment, one metric +improvement, an unreviewed interpretation, or a draft claim is not durable +project knowledge. A reviewer checks the proposed spec against evidence before +the main agent accepts it. + When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. **Timing**: After completing a task, fixing a bug, or discovering a new pattern diff --git a/packages/cli/src/templates/markdown/agents.md b/packages/cli/src/templates/markdown/agents.md index 3c9cc1f..d14ac51 100644 --- a/packages/cli/src/templates/markdown/agents.md +++ b/packages/cli/src/templates/markdown/agents.md @@ -16,6 +16,14 @@ If you're using Codex or another agent-capable tool, additional project-scoped h - `.agents/skills/` — reusable Trellis skills - `.codex/agents/` — optional custom subagents +## Research Collaboration Contract + +- Prefer truth and evidence over agreement. State material assumptions and uncertainty, and raise evidence-backed disagreement when it changes the conclusion. +- The user approves research trade-offs. Execute ordinary reversible work directly; with limited uncertainty, state the assumption and proceed. +- Pause for discussion only for high-risk research changes. A prompt cannot replace the structured decision or experiment record required by the workflow. +- If evidence conflicts or a critical assumption fails, mark the task blocked and use an approved amendment before continuing. +- Keep context progressive and minimal: start from the Task Capsule and direct references, then read larger records only when needed. + Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `research-trellis update`. diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 80a3eff..30acf61 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -9,7 +9,6 @@ import json import os import re -import shlex import subprocess import sys from fnmatch import fnmatchcase @@ -63,106 +62,7 @@ "hermes_claim_reviewer", "hermes_scientist", } -INLINE_SCRIPT_FLAGS = { - "python": {"-c"}, - "python3": {"-c"}, - "node": {"-e", "--eval", "-p", "--print"}, - "nodejs": {"-e", "--eval", "-p", "--print"}, - "perl": {"-e"}, - "ruby": {"-e"}, -} -INLINE_SCRIPT_FLAG_PREFIXES = { - "node": ("--eval=", "--print="), - "nodejs": ("--eval=", "--print="), -} -INLINE_SCRIPT_SHORT_EVAL_FLAGS = { - "python": {"c"}, - "python3": {"c"}, - "node": {"e", "p"}, - "nodejs": {"e", "p"}, - "perl": {"e", "E"}, - "ruby": {"e"}, -} -INLINE_SCRIPT_SHORT_COMBO_FLAGS = { - "node": {"e", "p"}, - "nodejs": {"e", "p"}, - "perl": {"e", "E", "w"}, - "ruby": {"e", "w"}, -} -INLINE_FILE_API_MARKERS = ( - "require('fs')", - 'require("fs")', - "require('node:fs')", - 'require("node:fs")', - "node:fs", - "from 'fs'", - 'from "fs"', - "from 'node:fs'", - 'from "node:fs"', - "from os import", - "import os", - "from shutil import", - "import shutil", - "from pathlib import", - "import pathlib", - "open(", - "Path(", - "write_text(", - "write_bytes(", - ".write(", - ".unlink(", - ".copyFile(", - ".rename(", - ".mkdir(", - "os.remove(", - "os.unlink(", - "os.rename(", - "os.mkdir(", - "os.makedirs(", - "shutil.copyfile(", - "shutil.copy(", - "shutil.move(", - "shutil.rmtree(", - "writeFile(", - "appendFile(", - "copyFile(", - "rename(", - "mkdir(", - "rm(", - "unlink(", - "unlink ", - "writeFileSync(", - "appendFileSync(", - "copyFileSync(", - "renameSync(", - "rename ", - "mkdirSync(", - "rmSync(", - "unlinkSync(", - "fs.rm(", - "fs.unlink(", - "fs.writeFile(", - "fs.appendFile(", - "fs.copyFile(", - "fs.rename(", - "fs.mkdir(", - "fs.promises.copyFile", - "fs.promises.rename", - "fs.promises.mkdir", - "FileUtils", - "FileUtils.cp(", - "FileUtils.copy(", - "FileUtils.mv(", - "FileUtils.mkdir(", - "FileUtils.mkdir_p(", - "FileUtils.rm(", - "FileUtils.rm_rf(", - "FileUtils.touch(", - "File.delete(", - "File.rename(", - "Dir.mkdir(", - "File::Copy", -) +BASH_EXECUTION_ROLES = {"coder", "runner"} def find_trellis_root(start: Path) -> Path | None: @@ -513,298 +413,205 @@ def is_main_agent_request(data: dict[str, Any]) -> bool: return True -def shell_tokens(command: str) -> list[str] | None: - try: - return shlex.split(command, posix=True) - except ValueError: - return None - - -def has_shell_control(command: str, tokens: list[str]) -> bool: - control_tokens = { - "|", - "||", - "&", - "&&", - "<", - ">", - ">>", - "1>", - "1>>", - "2>", - "2>>", - "&>", - "&>>", - } - if "\n" in command or ";" in command or "`" in command or "$(" in command: - return True - return any( - token in control_tokens - or token.startswith((">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", "<")) - for token in tokens - ) - +def is_main_bash_allowed(command: str) -> bool: + """Classify the small coordinator command surface without parsing shell. -def is_allowed_record_jsonl_path(raw_path: str) -> bool: - path = raw_path.strip().replace("\\", "/") - while path.startswith("./"): - path = path[2:] - if path.startswith("/") or ".." in path.split("/"): - return False - return ( - re.fullmatch(r"\.trellis/tasks/[^/]+/hermes/[^/]+\.jsonl", path) is not None - or re.fullmatch(r"\.ai/records/[^/]+\.jsonl", path) is not None - ) - - -def is_safe_git_pathspec(raw_path: str) -> bool: - path = raw_path.strip().replace("\\", "/") - while path.startswith("./"): - path = path[2:] - if not path or path == "." or path.startswith("/") or path.startswith(":"): + This is deliberately an explicit command matrix, not a shell-security + parser. Any shell composition is rejected before the command family is + checked. Main agents use Read for records; Bash is only for deterministic + task/closure control and narrowly scoped Git inspection. + """ + normalized = command.strip().replace("\\", "/") + if not normalized or any( + marker in normalized + for marker in (";", "&", "|", ">", "<", "`", "$", "\n", "\r") + ): return False - if any(char in path for char in "*?[]{}$~`()"): + tokens = normalized.split() + if not tokens: return False - parts = path.split("/") - if any(part in {"", ".", ".."} for part in parts): + if tokens[0] == "git": + return is_main_git_read_allowed(tokens) + if tokens[0] not in {"python", "python3"} or len(tokens) < 3: return False - for part in parts: - lower_part = part.lower() - if ( - lower_part == "env" - or ".env" in lower_part - or lower_part.endswith(".env") - ): - return False - return True + + script = tokens[1] + while script.startswith("./"): + script = script[2:] + subcommand = tokens[2] + arguments = tokens[3:] + if script == ".trellis/scripts/task.py": + return is_main_task_command_allowed(subcommand, arguments) + if script == ".trellis/scripts/hermes/dispatch.py": + return subcommand in { + "create", + "validate", + "show", + "run", + "apply", + "list", + "status", + "supersede", + } + if script == ".trellis/scripts/closure.py": + return subcommand in { + "plan", + "route", + "grill", + "validate", + "status", + "next", + "capsule", + "package-start", + "package-check", + "package-done", + "package-block", + "amend", + "repair", + "audit", + "close", + } + return False -def split_git_options_and_paths( - args: list[str], - allowed_options: set[str], - required_options: set[str] | None = None, - *, - require_paths: bool = False, -) -> tuple[set[str], list[str]] | None: - seen_separator = False - seen_options: set[str] = set() - paths: list[str] = [] - for arg in args: - if seen_separator: - paths.append(arg) - continue - if arg == "--": - seen_separator = True - continue - if arg in allowed_options: - seen_options.add(arg) - continue - return None +def is_main_task_command_allowed(subcommand: str, arguments: list[str]) -> bool: + """Allow deterministic task coordination, never archive auto-commit.""" + coordination_commands = { + "create", + "add-context", + "validate", + "list-context", + "start", + "current", + "finish", + "set-branch", + "set-base-branch", + "set-scope", + "list", + "list-archive", + "add-subtask", + "remove-subtask", + } + if subcommand in coordination_commands: + return True + # task.py archive can stage and commit unless this explicit no-write flag + # is present. Main may advance the task lifecycle, not write Git history. + return subcommand == "archive" and "--no-commit" in arguments - if required_options is not None and not (seen_options & required_options): - return None - if require_paths and not paths: - return None - if any(not is_safe_git_pathspec(path) for path in paths): - return None - return seen_options, paths +def is_main_git_read_allowed(tokens: list[str]) -> bool: + """Allow a small, non-writing Git inspection matrix. -def is_readonly_git_command(tokens: list[str]) -> bool: - if len(tokens) < 2 or tokens[0] != "git": + Git accepts many diff options and pathspec forms. We do not try to parse + them generally: only the read forms below are coordinator commands. + """ + if len(tokens) < 2: return False - subcommand = tokens[1] - args = tokens[2:] - if subcommand == "status": - allowed_status_options = { + command = tokens[1] + arguments = tokens[2:] + if any( + value == "--output" + or value.startswith("--output=") + or value in {"--ext-diff", "--no-index"} + or value.startswith("--ext-diff=") + or value.startswith("--no-index=") + for value in arguments + ): + return False + if command == "branch": + return arguments == ["--show-current"] + if command == "status": + allowed_options = { "--short", "-s", - "-sb", "--branch", "-b", "--porcelain", "--porcelain=v1", "--porcelain=v2", - "--show-stash", - "--ahead-behind", - "--no-ahead-behind", - } - short_status_options = { - "--short", - "-s", - "-sb", - "--porcelain", - "--porcelain=v1", - "--porcelain=v2", + "--untracked-files=no", + "--untracked-files=normal", + "--untracked-files=all", } + if "--" not in arguments: + return all(value in allowed_options for value in arguments) + separator = arguments.index("--") + options = arguments[:separator] + pathspecs = arguments[separator + 1:] return ( - split_git_options_and_paths( - args, - allowed_status_options, - short_status_options, - require_paths=True, - ) - is not None - ) - if subcommand == "diff": - allowed_diff_options = { - "--name-only", - "--stat", - "--cached", - "--staged", - "--no-renames", - } - return ( - split_git_options_and_paths( - args, - allowed_diff_options, - {"--name-only", "--stat"}, - require_paths=True, - ) - is not None - ) - if subcommand == "log": - allowed_log_options = {"--oneline", "--stat"} - return ( - split_git_options_and_paths( - args, - allowed_log_options, - {"--oneline", "--stat"}, - require_paths=True, - ) - is not None + bool(pathspecs) + and all(value in allowed_options for value in options) + and all(is_safe_main_git_pathspec(value) for value in pathspecs) ) + if command == "diff": + return is_main_git_diff_allowed(arguments) + if command == "log": + return is_main_git_log_allowed(arguments) return False -def is_readonly_record_cat(tokens: list[str]) -> bool: - if not tokens or tokens[0] != "cat": - return False - operands = [token for token in tokens[1:] if not token.startswith("-")] - return bool(operands) and all(is_allowed_record_jsonl_path(token) for token in operands) +def is_main_git_diff_allowed(arguments: list[str]) -> bool: + allowed_options = { + "--name-only", + "--name-status", + "--stat", + "--summary", + "--check", + "--cached", + "--staged", + "--no-ext-diff", + "--no-textconv", + } + if not arguments: + return True + if "--" not in arguments: + return all(value in allowed_options for value in arguments) + separator = arguments.index("--") + options = arguments[:separator] + pathspecs = arguments[separator + 1:] + return ( + bool(pathspecs) + and all(value in allowed_options for value in options) + and all(is_safe_main_git_pathspec(value) for value in pathspecs) + ) -def is_readonly_record_jq(tokens: list[str]) -> bool: - if not tokens or tokens[0] != "jq": - return False - file_reading_options = { - "-f", - "--from-file", - "--slurpfile", - "--rawfile", - "--argfile", +def is_main_git_log_allowed(arguments: list[str]) -> bool: + allowed_options = { + "--oneline", + "--decorate", + "--no-decorate", + "--stat", + "--name-only", + "--name-status", + "--no-ext-diff", + "--no-textconv", } - if any( - token in file_reading_options - or token.startswith(("--from-file=", "--slurpfile=", "--rawfile=", "--argfile=")) - for token in tokens[1:] - ): - return False - - positional: list[str] = [] - index = 1 - while index < len(tokens): - token = tokens[index] - if token == "--": - positional.extend(tokens[index + 1 :]) - break - if token in {"--arg", "--argjson"}: - index += 3 - continue - if token in {"--indent", "-L"}: - index += 2 + for value in arguments: + if value == "--": + return False + if value in allowed_options or value.startswith("--max-count="): continue - if token.startswith("-") and token != "-": - index += 1 + if value.startswith("-n") and value[2:].isdigit(): continue - positional.append(token) - index += 1 - - if len(positional) < 2: - return False - - file_operands = positional[1:] - return bool(file_operands) and all( - is_allowed_record_jsonl_path(token) for token in file_operands - ) - - -def is_main_control_bash(command: str) -> bool: - tokens = shell_tokens(command) - if not tokens or has_shell_control(command, tokens): - return False - index = 0 - executable = command_basename(tokens[index]).casefold() - if executable not in {"python", "python3", "py"}: return False - index += 1 - if executable == "py" and index < len(tokens) and tokens[index] == "-3": - index += 1 - if index + 1 < len(tokens) and tokens[index] == "-X": - index += 2 - if index >= len(tokens): - return False - script = tokens[index].replace("\\", "/") - while script.startswith("./"): - script = script[2:] - index += 1 - if index >= len(tokens): - return False - allowed: dict[str, set[str]] = { - ".trellis/scripts/hermes/dispatch.py": { - "create", "validate", "show", "run", "apply", "list", "status", "schema", "supersede", - }, - ".trellis/scripts/closure.py": { - "status", "next", "capsule", "validate", "package-start", "package-check", - "package-done", "package-block", "audit", "repair", "amend", "handoff", "close", - }, - ".trellis/scripts/task.py": {"current", "archive", "finish"}, - } - return tokens[index] in allowed.get(script, set()) + return True -def is_main_readonly_bash(command: str) -> bool: - tokens = shell_tokens(command) - if not tokens or has_shell_control(command, tokens): +def is_safe_main_git_pathspec(value: str) -> bool: + normalized = value.replace("\\", "/").strip() + if ( + not normalized + or normalized.startswith(("/", "../", "./../", ":")) + or ".." in Path(normalized).parts + ): return False - return ( - is_readonly_git_command(tokens) - or is_readonly_record_cat(tokens) - or is_readonly_record_jq(tokens) - or is_main_control_bash(command) - ) - - -def is_runner_bash_work(command: str) -> bool: - tokens = shell_tokens(command) or [] - if not tokens: - return True - command_name = tokens[0] - if command_name in {"npm", "pnpm", "yarn", "pytest", "rm"}: - return True - if command_name == "go" and len(tokens) > 1 and tokens[1] == "test": - return True - if command_name == "cargo" and len(tokens) > 1 and tokens[1] == "test": - return True - if command_name == "git" and len(tokens) > 1 and tokens[1] in {"add", "commit", "push"}: - return True - return "pytest" in tokens - - -def main_agent_bash_denial(command: str) -> str: - next_step = ( - "dispatch a runner subagent" - if is_runner_bash_work(command) - else "dispatch the appropriate researcher or planner subagent" - ) - return ( - "Hermes Runtime: main agent firewall denied Bash. Main agent is " - "coordinator only and may run only read-only routing commands: " - "git status --short -- , git diff --name-only/--stat -- , " - "git log --oneline/--stat -- , " - "cat/jq against RecordBus JSONL, or parameterized Hermes " - "dispatch/closure/task control-plane commands. " - f"Next step: {next_step} with a task_card." + name = Path(normalized).name.casefold() + return not ( + name.startswith(".env") + or "credential" in name + or "secret" in name + or name in {"id_rsa", "id_ed25519"} ) @@ -820,199 +627,77 @@ def main_agent_firewall_reason(data: dict[str, Any]) -> str | None: if tool_name != "Bash": return None command = bash_command_from_tool_input(data.get("tool_input", {})) - if command is None: - return ( - "Hermes Runtime: main agent firewall denied Bash because the " - "command could not be parsed. Next step: dispatch a runner " - "subagent with a task_card." - ) - if is_main_readonly_bash(command): + if command and is_main_bash_allowed(command): return None - return main_agent_bash_denial(command) - - -def no_active_task_tool_denial(tool_name: str) -> str: return ( - "Hermes Runtime: no active task is selected; denying " - f"{tool_name} by default. Select an active task and record a " - "task_card before running write or execution tools." - ) - - -def looks_like_bash_write(command: str) -> bool: - write_markers = ( - ">", - "tee ", - "write_text", - "write_bytes", - ".write(", - ".unlink(", - ".copyFile(", - ".rename(", - ".mkdir(", - "open(", - "os.remove(", - "os.unlink(", - "os.rename(", - "os.mkdir(", - "os.makedirs(", - "shutil.copyfile(", - "shutil.copy(", - "shutil.move(", - "shutil.rmtree(", - "writeFileSync(", - "appendFileSync(", - "copyFileSync(", - "renameSync(", - "mkdirSync(", - "copyFile(", - "rename(", - "mkdir(", - "rmSync(", - "unlinkSync(", - "FileUtils.cp(", - "FileUtils.copy(", - "FileUtils.mv(", - "FileUtils.mkdir(", - "FileUtils.mkdir_p(", - "FileUtils.rm(", - "FileUtils.rm_rf(", - "FileUtils.touch(", - "File.delete(", - "File.rename(", - "Dir.mkdir(", - "File::Copy", - "Path(", - "touch ", - "mkdir ", - "rm ", - "mv ", - "cp ", - "sed -i", - "perl -pi", - "cat <<", + "Hermes Runtime: main agent firewall denied Bash. Main agent may inspect " + "task state or use Hermes control commands, but implementation, tests, " + "and experiments must be delegated to a runner subagent or the appropriate " + "specialist subagent." ) - return any(marker in command for marker in write_markers) -def command_basename(token: str) -> str: - return Path(token).name - - -def inline_script_code(tokens: list[str], interpreter_index: int) -> str | None: - interpreter = command_basename(tokens[interpreter_index]) - flags = INLINE_SCRIPT_FLAGS.get(interpreter) - if flags is None: - return None - prefixes = INLINE_SCRIPT_FLAG_PREFIXES.get(interpreter, ()) - short_eval_flags = INLINE_SCRIPT_SHORT_EVAL_FLAGS.get(interpreter, set()) - short_combo_flags = INLINE_SCRIPT_SHORT_COMBO_FLAGS.get( - interpreter, - short_eval_flags, - ) - index = interpreter_index + 1 - while index < len(tokens): - token = tokens[index] - for prefix in prefixes: - if token.startswith(prefix): - return token[len(prefix) :] - for flag in flags: - if token == flag: - if index + 1 >= len(tokens): - return "" - return tokens[index + 1] - if token.startswith("-") and not token.startswith("--") and token != "-": - short_options = token[1:] - if ( - len(short_options) > 1 - and any(option in short_eval_flags for option in short_options) - and all(option in short_combo_flags for option in short_options) - ): - if index + 1 >= len(tokens): - return "" - return tokens[index + 1] - if short_options and short_options[0] in short_eval_flags: - return short_options[1:] - index += 1 +def declared_worker_role(data: dict[str, Any]) -> str | None: + aliases = { + "builder": "coder", + "coder": "coder", + "hermes_coder": "coder", + "trellis_implement": "coder", + "runner": "runner", + "hermes_runner": "runner", + "planner": "planner", + "hermes_planner": "planner", + "scientist": "planner", + "hermes_scientist": "planner", + "researcher": "researcher", + "hermes_researcher": "researcher", + "research_scout": "researcher", + "trellis_research": "researcher", + "reviewer": "reviewer", + "hermes_reviewer": "reviewer", + "claim_reviewer": "reviewer", + "trellis_check": "reviewer", + "trellis_spec_review": "reviewer", + "trellis_code_review": "reviewer", + "trellis_code_architecture_review": "reviewer", + "trellis_merge_review": "reviewer", + "trellis_improve_codebase_architecture": "reviewer", + } + for value in agent_identity_values(data): + normalized = normalized_agent_identity(value) + if normalized in aliases: + return aliases[normalized] return None -def inline_script_has_unparsed_file_api(tokens: list[str], interpreter_index: int) -> bool: - code = inline_script_code(tokens, interpreter_index) - if code is None: +def is_worker_bash_command_allowed(role: str, command: str) -> bool: + """Allow role-specific commands without attempting to parse a shell.""" + normalized = command.strip().replace("\\", "/") + if not normalized or any( + marker in normalized + for marker in (";", "&", "|", ">", "<", "`", "$", "\n", "\r") + ): return False - if not code.strip(): - return True - return any(marker in code for marker in INLINE_FILE_API_MARKERS) - - -def add_bash_target(targets: list[str], root: Path, value: str) -> None: - if value in {"-", "/dev/null"}: - return - if value.startswith("&"): - return - add_unique_target(targets, root, value) - - -def is_shell_separator(token: str) -> bool: - return token in {"|", "||", "&", "&&", ";"} or token.endswith(";") - - -def extract_bash_targets(root: Path, command: str) -> tuple[list[str], str | None]: - try: - tokens = shlex.split(command, posix=True) - except ValueError as exc: - return [], f"cannot safely parse Bash write targets: {exc}" + tokens = normalized.split() + if role == "coder": + return bool(tokens) and tokens[0] == "git" and is_main_git_read_allowed(tokens) + if role != "runner" or len(tokens) < 3 or tokens[0] not in {"python", "python3"}: + return False + script = tokens[1] + while script.startswith("./"): + script = script[2:] + return ( + script == ".trellis/scripts/hermes/runner.py" + and tokens[2] in {"run", "replay", "validate"} + ) - targets: list[str] = [] - redirect_tokens = {">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", ">|"} - redirect_prefix = re.compile(r"^(?:\d?>{1,2}|&>{1,2}|\|>)(.+)$") - write_like_but_unparsed = False - - index = 0 - while index < len(tokens): - token = tokens[index] - if token in redirect_tokens: - if index + 1 >= len(tokens): - return targets, "cannot safely parse Bash write targets: missing redirect target" - add_bash_target(targets, root, tokens[index + 1]) - index += 2 - continue - match = redirect_prefix.match(token) - if match: - add_bash_target(targets, root, match.group(1)) - index += 1 - continue - if token == "tee": - tee_index = index + 1 - found_tee_target = False - seen_tee_separator = False - while tee_index < len(tokens): - candidate = tokens[tee_index] - if is_shell_separator(candidate): - break - if not seen_tee_separator and candidate == "--": - seen_tee_separator = True - tee_index += 1 - continue - if not seen_tee_separator and candidate.startswith("-"): - tee_index += 1 - continue - add_bash_target(targets, root, candidate) - found_tee_target = True - tee_index += 1 - if not found_tee_target: - return targets, "cannot safely parse Bash write targets: missing tee target" - index = tee_index - continue - if command_basename(token) in INLINE_SCRIPT_FLAGS: - if inline_script_has_unparsed_file_api(tokens, index): - write_like_but_unparsed = True - index += 1 - if write_like_but_unparsed: - return targets, "cannot safely parse Bash write targets from write-like command" - return targets, None +def no_active_task_tool_denial(tool_name: str) -> str: + return ( + "Hermes Runtime: no active task is selected; denying " + f"{tool_name} by default. Select an active task and record a " + "task_card before running write or execution tools." + ) def extract_job_id_from_tool_input(value: Any) -> str | None: @@ -1138,6 +823,65 @@ def changed_files_from_record(record: dict[str, Any]) -> list[str]: return [item for item in changed if isinstance(item, str) and item.strip()] +def _parent_job_value(record: dict[str, Any]) -> tuple[bool, str | None]: + if "parent_job_id" not in record: + return False, None + value = record.get("parent_job_id") + if value is None: + return True, None + if isinstance(value, str) and value.strip(): + return True, value.strip() + return True, "" + + +def _legacy_parent_candidates( + cards: dict[str, dict[str, Any]], + child_job_id: str, + work_package: Any, +) -> set[str]: + return { + candidate_job_id + for candidate_job_id, candidate in cards.items() + if candidate_job_id != child_job_id + and candidate.get("role") != "reviewer" + and candidate.get("work_package") == work_package + } + + +def record_matches_parent( + record: dict[str, Any], + cards: dict[str, dict[str, Any]], + parent_job_id: str, +) -> bool: + child_job_id = record_job_id(record) + if child_job_id is None: + return False + card = cards.get(child_job_id) + parent_card = cards.get(parent_job_id) + if card is None or parent_card is None: + return False + explicit_card, card_parent = _parent_job_value(card) + explicit_record, record_parent = _parent_job_value(record) + if explicit_card: + return ( + card_parent == parent_job_id + and (not explicit_record or record_parent == card_parent) + and parent_card.get("work_package") == card.get("work_package") + ) + if explicit_record: + return ( + record_parent == parent_job_id + and parent_card.get("work_package") == card.get("work_package") + ) + # Legacy records can only use the one same-package candidate. This avoids + # inferring relationships from result ordering or timestamps. + return _legacy_parent_candidates( + cards, + child_job_id, + card.get("work_package"), + ) == {parent_job_id} + + def related_records( records: list[dict[str, Any]], cards: dict[str, dict[str, Any]], @@ -1145,6 +889,7 @@ def related_records( role: str, record_types: set[str], profiles: set[str] | None = None, + legacy_warnings: list[str] | None = None, ) -> list[dict[str, Any]]: matches: list[dict[str, Any]] = [] for record in records: @@ -1158,12 +903,19 @@ def related_records( continue if profiles is not None and card.get("profile") not in profiles: continue - if ( - job_id == coder_job_id - or card.get("parent_job_id") == coder_job_id - or record.get("parent_job_id") == coder_job_id - ): + if record_matches_parent(record, cards, coder_job_id): matches.append(record) + explicit_card, _ = _parent_job_value(card) + explicit_record, _ = _parent_job_value(record) + if legacy_warnings is not None and not explicit_card and not explicit_record: + package = card.get("work_package") or "task-level" + legacy_warnings.append( + "Hermes Runtime compatibility warning: legacy job " + f"{job_id} has no parent_job_id and was matched to " + f"{coder_job_id} only because it is the sole non-reviewer " + f"candidate in work_package {package}. New dispatches must " + "record parent_job_id explicitly." + ) return matches @@ -1187,6 +939,29 @@ def completed_coder_results( return results +def completed_noncode_results( + records: list[dict[str, Any]], + root: Path, +) -> list[dict[str, Any]]: + """Return completed research/configuration results when code work is absent.""" + cards = task_cards_by_job(records, root) + results: list[dict[str, Any]] = [] + for record in records: + if record.get("type") != "result" or not is_completion_handoff(record): + continue + job_id = record_job_id(record) + card = cards.get(job_id or "") + if card is None: + continue + role = card.get("role") + profile = card.get("profile") + if role in {"planner", "researcher", "runner"} or ( + role == "coder" and profile == "configuration" + ): + results.append(record) + return results + + def result_covers_changed_files(result: dict[str, Any], changed_files: list[str]) -> bool: if not changed_files: return True @@ -1227,7 +1002,61 @@ def stop_completion_reason( runtime_root: Path, task: str, records: list[dict[str, Any]], + legacy_warnings: list[str] | None = None, ) -> str | None: + changed_files = git_changed_files(root) + cards = task_cards_by_job(records, root) + code_cards = [ + card for card in cards.values() + if card.get("role") == "coder" + and card.get("profile") in {"implementation", "tests", "repair"} + ] + # The repository may already contain unrelated user changes. Route Stop by + # the current task's cards, not by global worktree cleanliness. + if not code_cards: + research_results = completed_noncode_results(records, root) + if not research_results: + return ( + "Hermes Runtime: Stop requires a completed planner, researcher, " + "runner, or configuration result before completion." + ) + run_manifest_path = root / ".trellis" / "tasks" / task / "hermes" / "run_manifest.jsonl" + if run_manifest_path.exists(): + validate_run_manifest = run_runtime( + root, + [str(runtime_root / "validate.py"), "--task", task, "--kind", "run_manifest"], + ) + if validate_run_manifest.returncode != 0: + return ( + "Hermes Runtime: run manifest is invalid; inspect task " + "Hermes logs locally." + ) + run_manifests = read_run_manifest_index(root, task) + for result in research_results: + job_id = record_job_id(result) + card = cards.get(job_id or "") + if job_id is None or card is None: + continue + if card.get("role") == "runner" and not runner_result_has_passing_test( + result, run_manifests + ): + continue + review_records = related_records( + records, + cards, + job_id, + "reviewer", + {"checkpoint", "result"}, + {"quality", "evidence", "claim", "safety", "closure", "statistics"}, + legacy_warnings, + ) + if review_records: + return None + return ( + "Hermes Runtime: Stop requires a related independent reviewer record; " + "formal runner work also requires a successful run manifest." + ) + coder_results = completed_coder_results(records, root) if not coder_results: return ( @@ -1235,7 +1064,6 @@ def stop_completion_reason( "worker_records.jsonl before completion." ) - changed_files = git_changed_files(root) matching_results = [ result for result in coder_results if result_covers_changed_files(result, changed_files) ] @@ -1260,7 +1088,6 @@ def stop_completion_reason( "Hermes logs locally." ) - cards = task_cards_by_job(records, root) run_manifests = read_run_manifest_index(root, task) for result in matching_results: job_id = record_job_id(result) @@ -1273,6 +1100,7 @@ def stop_completion_reason( "runner", {"result"}, {"test", "build", "validation"}, + legacy_warnings, ) has_passing_test = any( runner_result_has_passing_test(runner_result, run_manifests) @@ -1291,6 +1119,7 @@ def stop_completion_reason( "reviewer", {"checkpoint", "result"}, {"quality", "safety"}, + legacy_warnings, ) if not review_records: return ( @@ -1312,8 +1141,9 @@ def guard_tool_input_permissions( tool_input = data.get("tool_input", {}) if tool_name == "Bash": command = bash_command_from_tool_input(tool_input) - if command is None: - return "Hermes Runtime: cannot safely parse Bash tool_input; denying by default." + if not command: + return "Hermes Runtime: Bash command is missing." + role: str | None = None agent_id = data.get("agent_id") if isinstance(agent_id, str) and agent_id: scripts_dir = root / ".trellis" / "scripts" @@ -1329,17 +1159,16 @@ def guard_tool_input_permissions( except Exception: return "Hermes Runtime: Bash caller has no valid dispatch binding." role = str(bound_dispatch.get("role") or "") - if role in {"planner", "researcher"}: - return f"Hermes Runtime: {role} dispatch cannot execute Bash." - if role == "reviewer" and not is_readonly_git_command(shell_tokens(command) or []): - return "Hermes Runtime: reviewer Bash is limited to scoped read-only git inspection." - target_files, parse_error = extract_bash_targets(root, command) - if parse_error is not None: - return f"Hermes Runtime: {parse_error}; denying by default." - if not target_files and not looks_like_bash_write(command): - return None - if not target_files: - return "Hermes Runtime: cannot safely parse Bash write targets; denying by default." + else: + role = declared_worker_role(data) + if role not in BASH_EXECUTION_ROLES: + return f"Hermes Runtime: {role or 'unbound'} dispatch cannot execute Bash." + if not is_worker_bash_command_allowed(role, command): + return ( + "Hermes Runtime: Bash command is outside the role's bounded command surface. " + "Use runner.py for registered execution or delegate the work." + ) + return None else: target_files = collect_tool_target_files(root, tool_name, tool_input) if not target_files: @@ -1350,6 +1179,21 @@ def guard_tool_input_permissions( records = read_worker_records(worker_records) job_id = extract_job_id_from_tool_input(tool_input) + agent_id = data.get("agent_id") + if job_id is None and isinstance(agent_id, str) and agent_id: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import load_dispatch_for_agent # type: ignore[import-not-found] + + bound_dispatch = load_dispatch_for_agent( + root / ".trellis" / "tasks" / task, + agent_id, + ) + job_id = str(bound_dispatch.get("job_id") or "") or None + except Exception: + return "Hermes Runtime: write caller has no valid dispatch binding." if job_id is None: candidates = unresolved_task_card_job_ids(records) if len(candidates) == 1: @@ -1360,6 +1204,39 @@ def guard_tool_input_permissions( "does not include job_id and there is no unique active task_card." ) + cards = task_cards_by_job(records, root) + card = cards.get(job_id) + if card is None: + return f"Hermes Runtime: task_card for {job_id} is missing or invalid." + role = str(card.get("role") or "") + declared_role = declared_worker_role(data) + if declared_role is not None and declared_role != role: + return ( + f"Hermes Runtime: declared {declared_role} role does not match " + f"task_card role {role or 'unknown'}." + ) + role_roots = { + "planner": [f".trellis/tasks/{task}/hermes/analysis"], + "researcher": [ + f".trellis/tasks/{task}/research", + f".trellis/tasks/{task}/hermes/research", + ], + "reviewer": [f".trellis/tasks/{task}/hermes/reviews"], + "runner": [], + } + if role != "coder": + roots = role_roots.get(role, []) + invalid_targets = [ + target + for target in target_files + if not any(target == item or target.startswith(item + "/") for item in roots) + ] + if invalid_targets: + return ( + f"Hermes Runtime: {role or 'unknown'} cannot write implementation files; " + "use the role's task-scoped record directory or delegate to coder." + ) + guard = run_runtime( root, [ @@ -1826,10 +1703,21 @@ def main() -> int: print(json.dumps(block_payload(event_name, closure_reason), ensure_ascii=False)) return 0 records = read_worker_records(worker_records) - reason = stop_completion_reason(root, runtime_root, task, records) + legacy_warnings: list[str] = [] + reason = stop_completion_reason( + root, + runtime_root, + task, + records, + legacy_warnings, + ) if reason is not None: print(json.dumps(block_payload(event_name, reason), ensure_ascii=False)) return 0 + if legacy_warnings: + warning = " ".join(dict.fromkeys(legacy_warnings)) + print(json.dumps(context_payload(event_name, warning), ensure_ascii=False)) + return 0 if event_name == "PreToolUse": tool_name = str(data.get("tool_name") or "") 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 1f1d93b..13dfa10 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -71,7 +71,6 @@ ("Project constraints", "CONSTRAINTS.md", "fixed boundaries and approvals"), ) - def _build_project_context_index(root: Path) -> str: """List main-agent project inputs without loading the documents themselves.""" project_dir = root / ".trellis" / "project" @@ -215,7 +214,94 @@ def get_task_capsule(root: Path, input_data: dict) -> str | None: return build_capsule(task_dir, task, root) -def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None] | None: +def _build_task_resume_notice( + root: Path, + task_dir: Path, + data: dict, + *, + task_id: str, + phase: str, + next_action: str, + handoff_notice: str | None, +) -> str: + """Point the main agent at durable task state on every active-task turn.""" + def compact(value: object, limit: int) -> str: + text = " ".join(str(value or "-").split()) + return text if len(text) <= limit else text[: limit - 3].rstrip() + "..." + + try: + task_ref = task_dir.relative_to(root).as_posix() + except ValueError: + task_ref = ".trellis/tasks/" + task_dir.name + task_label = compact(task_id, 72) + phase_label = compact(phase, 32) + current_package = compact(data.get("current_work_package"), 48) + action = compact(next_action, 180) + lines = [ + "", + f"Active task: {task_label}; phase={phase_label}; current package={current_package}; next={action}", + f"Before planning, dispatching, or continuing work, read {task_ref}/task.json.", + "Classify this request as an in-scope continuation, a bounded plan change, or a new task. Do not regenerate existing work packages for an in-scope continuation.", + ] + if handoff_notice: + lines.append(handoff_notice) + lines.append( + "Read only the directly relevant referenced file for this request; do not preload project context, full task history, or unrelated artifacts." + ) + lines.append("") + return "\n".join(lines) + + +def _handoff_state(task_dir: Path, current_revision: int) -> tuple[str, str | None]: + """Classify an optional recovery summary without treating it as authority.""" + handoff = task_dir / "HANDOFF.md" + if not handoff.is_file(): + return "missing", None + try: + stamp = handoff.stat().st_mtime_ns + content = handoff.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "unreadable", None + match = re.search(r"(?m)^##\s+Task Revision\s*\n\s*(\d+)\s*$", content) + if match is None: + return ( + f"legacy:{stamp}", + "Legacy HANDOFF.md has no Task Revision. It is only a recovery hint; task.json and the Task Capsule remain authoritative.", + ) + handoff_revision = int(match.group(1)) + if handoff_revision != current_revision: + relation = "older than" if handoff_revision < current_revision else "newer than" + return ( + f"stale:{handoff_revision}:{stamp}", + "HANDOFF.md is stale: Task Revision " + f"{handoff_revision} is {relation} current revision {current_revision}. " + "Use task.json and the Task Capsule; do not read the handoff body as current state.", + ) + try: + handoff_ref = task_dir.relative_to(task_dir.parents[2]).as_posix() + except (ValueError, IndexError): + handoff_ref = f".trellis/tasks/{task_dir.name}" + return ( + f"fresh:{handoff_revision}:{stamp}", + f"If this session resumes after a pause or handoff, read {handoff_ref}/HANDOFF.md once if needed. task.json remains the source of truth.", + ) + + +def _context_reset_requested(input_data: dict) -> bool: + """Recognize compact/clear markers supplied by hook-capable hosts.""" + for key in ("source", "event", "event_type", "reason", "session_start_source"): + value = input_data.get(key) + if isinstance(value, str) and value.casefold() in { + "compact", + "compaction", + "context_compacted", + "clear", + }: + return True + return False + + +def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None, str] | None: """Return closure-specific, revision-aware per-turn context.""" active = _resolve_active_task(root, input_data) if not active.task_path or active.stale: @@ -237,6 +323,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None is_closure_task, research_route_rule, research_route_summary, + skill_route_guidance, ) except Exception: return None @@ -248,6 +335,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None next_action = closure_next_action(data) route_summary = research_route_summary(data) route_rule = research_route_rule(data) + skill_route = skill_route_guidance(data) key = getattr(active, "context_key", None) anchor_path = root / ".trellis" / ".runtime" / "sessions" / f"{key}.json" if isinstance(key, str) and key else None anchor: dict = {} @@ -257,12 +345,33 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None except (OSError, json.JSONDecodeError): anchor = {} previous = anchor.get("closure_context_anchor") if isinstance(anchor, dict) else None + context_reset = _context_reset_requested(input_data) + if context_reset: + previous = None + current_revision = revision if isinstance(revision, int) and not isinstance(revision, bool) else 0 + handoff_marker, handoff_problem = _handoff_state(task_dir, current_revision) + seen_handoff_marker = previous.get("handoff_marker") if isinstance(previous, dict) else None + if handoff_marker == seen_handoff_marker: + handoff_notice = None + else: + handoff_notice = handoff_problem + resume_notice = _build_task_resume_notice( + root, + task_dir, + data, + task_id=task_id, + phase=phase, + next_action=next_action, + handoff_notice=handoff_notice, + ) current = { "task": task_id, "revision": revision, "phase": phase, "next_action": next_action, "route": route_summary, + "skill_route": skill_route, + "handoff_marker": handoff_marker, } if anchor_path: try: @@ -271,10 +380,17 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None anchor_path.write_text(json.dumps(anchor, ensure_ascii=False) + "\n", encoding="utf-8") except OSError: pass - if isinstance(previous, dict) and previous.get("task") == task_id and previous.get("revision") == revision: + force_capsule = context_reset + if ( + isinstance(previous, dict) + and previous.get("task") == task_id + and previous.get("revision") == revision + and not force_capsule + ): return ( - f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\n", + f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\nSkill route: {skill_route}.\n", None, + resume_notice, ) if isinstance(previous, dict) and previous.get("task") == task_id: changes = [f"Hermes closure update: {task_id}; revision {previous.get('revision')} -> {revision}."] @@ -284,13 +400,16 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None if previous.get("route") != current["route"]: changes.append(route_summary) changes.append("Route rule: " + route_rule) + changes.append("Skill route: " + skill_route + ".") return ( "\n" + "\n".join(changes) + "\n", build_capsule(task_dir, data, root), + resume_notice, ) return ( - f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nNext: {next_action}\n", + f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nSkill route: {skill_route}.\nNext: {next_action}\n", build_capsule(task_dir, data, root), + resume_notice, ) # --------------------------------------------------------------------------- # Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults @@ -492,8 +611,9 @@ def main() -> int: closure_context = _closure_turn_context(root, data) task = get_active_task(root, data) capsule = None + resume_notice = None if closure_context is not None: - breadcrumb, capsule = closure_context + breadcrumb, capsule, resume_notice = closure_context elif task is None: # No active task — still emit a breadcrumb nudging AI toward # trellis-brainstorm + task.py create when user describes real work. @@ -520,11 +640,16 @@ def main() -> int: parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) parts.append(_codex_mode_banner(config)) parts.append(breadcrumb) + if resume_notice: + parts.append(resume_notice) if capsule: parts.append(f"\n{capsule}\n") breadcrumb = "\n\n".join(parts) - elif capsule: - breadcrumb = f"{breadcrumb}\n\n\n{capsule}\n" + else: + if resume_notice: + breadcrumb = f"{breadcrumb}\n\n{resume_notice}" + if 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 500eeb0..e77e44a 100644 --- a/packages/cli/src/templates/shared-hooks/session-start.py +++ b/packages/cli/src/templates/shared-hooks/session-start.py @@ -576,7 +576,7 @@ def _get_task_status(trellis_dir: Path, input_data: dict) -> str: return ( f"Status: PLANNING\nTask: {task_title}\n" f"Present: {present_line}\n" - "Next-Action: Load `trellis-brainstorm` and write `prd.md`. Stay in planning." + "Next-Action: Analyze intent, scope, done criteria and research route; load `trellis-brainstorm` only if requirements remain unresolved. Stay in planning." ) if task_status == "planning": diff --git a/packages/cli/src/templates/trellis/config.yaml b/packages/cli/src/templates/trellis/config.yaml index 707907b..f27077c 100644 --- a/packages/cli/src/templates/trellis/config.yaml +++ b/packages/cli/src/templates/trellis/config.yaml @@ -91,9 +91,9 @@ max_journal_lines: 2000 # Hermes Agent Context Firewall #------------------------------------------------------------------------------- # Claude uses approved Agent hooks for enforced prompt replacement and result -# sanitization. Codex native custom agents are advisory; strict mode invokes -# codex exec with the bundled result schema. Lean warns without a live -# heartbeat, standard requires hooks or strict, and publication fails closed. +# sanitization. Codex custom agents use the same compact dispatch and result +# contract in the current project workspace. Closure requirements remain based +# on recorded evidence and review, not an isolated execution mode. # # hermes: -# codex_mode: native # native (advisory) | strict (enforced) +# codex_mode: native diff --git a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md index b76827a..253c1ce 100644 --- a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md +++ b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md @@ -38,6 +38,46 @@ research-design, or scope decision remains unresolved, or when the user explicitly asks for one. Task size, task age, and the mere presence of multiple work packages are not reasons to start a grill. +Prefer truth and evidence over agreement. State material assumptions and +uncertainty, and raise evidence-backed disagreement when it affects the +conclusion. The user approves research trade-offs. A prompt is guidance, not a +replacement for the structured task, decision, experiment, or evidence record. + +Use three intervention levels without adding workflow stages: + +* ordinary reversible work: execute directly with the current task boundary; +* limited uncertainty: state the material assumption, then proceed; +* high-risk research change: pause for focused discussion and user approval, + then record the decision before validation. + +If evidence conflicts or a critical assumption fails, mark the task blocked +and continue only through an approved amendment. Do not apply this pause or a +grill gate to ordinary delivery or frozen-protocol execution. + +Treat each request as one of three cases before routing it: + +* a new actionable task: perform the full short analysis and record 1-4 + observable-result work packages after task-creation consent; +* an in-scope continuation: keep the current package and do not regenerate the + plan merely because the user added detail; +* a scope or research-contract change: use a bounded amendment, revalidate, and + require human approval for high-risk research fields. + +A newly completed high-risk exploration grill must carry a repository- or +task-relative `decision_ref` to an existing `prd.md`, `design.md`, or equivalent +record. That record contains Decision, Rationale, Evidence, Alternatives, and +Failure Conditions. Events and the Task Capsule carry only the reference, never +the decision body. Legacy tasks with `grill_completed=true` and no reference +remain valid with a warning; the next research amendment clears the old +reference and requires a fresh decision. + +Only an exploration task whose `research_change_fields` includes `dataset`, +`split`, or `preprocessing` requires `experiment.yaml.data_preflight`. Validate +its repository-local input manifest or data path and hash, plus a checks record +covering schema, missing values, duplicates, and split leakage. Runner declares +both files with `--input` before command execution. All other tasks have no new +data-preflight step. + At session start, use the project-context index for `.trellis/project/` before accepting or splitting a request. Read the relevant background, research-plan, and constraint document on demand; their contents must not be copied into the @@ -49,12 +89,58 @@ context for its role and profile only after explicit task refs and context pins. Use the remaining refs for the task-specific spec or evidence that the worker needs. Do not add all project documents merely because they exist. +## 1.1 Routing Priority + +Use this order for every request: + +1. current `task.json`, validated closure state, Task Capsule, and RecordBus; +2. the user's natural-language intent and the current work-package boundary; +3. explicit command or skill requests as a fallback or an override of the + requested action, never as a way to bypass task state or gates. + +Do not wait for the user to invoke a command before planning, dispatching, +running review, recording a plan change, requesting a required handoff, or +closing a task. Route those actions from the current state. The user-facing +`grill-me` and `update-spec` skills remain optional explicit entry points, but +the main agent must also trigger their underlying process when the state calls +for focused decision resolution or durable project knowledge. It may lead the +user discussion itself, but any task or spec write must be delegated through a +validated planner or coder dispatch; the main agent only verifies the result. +For pauses, phase changes, blocked work, or context compaction, request a +compact `HANDOFF.md` update. It supports recovery but never grants authority, +changes task state, or blocks task completion. + +## 1.2 Research Skill Ownership + +Skills are loaded on demand from the recorded phase and the actual work, with +no fixed per-turn count. When several are necessary, use them in dependency +order and reuse their recorded outputs by reference. + +* Main leads `brainstorm` only for unresolved requirements and `grill-me` only + for a material research decision. A planner may propose options but cannot + approve the user's research choice. +* Main triggers `before-dev` for code work; coder uses it. TDD is opt-in through + the recorded strategy, with coder implementing and runner executing tests. +* Main triggers code checking after implementation; runner executes tests and + builds, while an independent reviewer judges quality. Code checking never + establishes scientific evidence. +* Main triggers `hermes-research` for formal runs or evidence. Runner records + runs and artifacts; reviewer judges evidence, statistics, and claims. +* `break-loop` is for repeated technical failures. A negative scientific result + goes to planner/reviewer analysis and must not be treated as a software bug. +* Software architecture analysis is explicit and does not decide scientific + model architecture. +* Main may identify durable knowledge, but `update-spec` writes through + `coder:configuration` and is independently reviewed. One run is insufficient. +* Main requests handoff writing through `coder:configuration`. At closure, + reviewer checks closure and runner performs final validation/archive actions. +* `trellis-meta` is reserved for changes to Trellis itself. + ## 2. Main Agent Hard Limits As Main Agent, you must not directly: -* write files with `Edit`, `Write`, `MultiEdit`, `apply_patch`, shell - redirection, or write-like scripts; +* write files with `Edit`, `Write`, `MultiEdit`, `apply_patch`, or `Bash`; * modify source code; * modify metrics; * modify dataset splits; @@ -71,9 +157,10 @@ As Main Agent, you must not directly: * overwrite subagent records to make the state look cleaner; * rely on previous chat history as the source of truth. -Allowed main-agent shell inspection is intentionally narrow: `git status`, -`git diff ...`, `git log ...`, and `cat` / `jq` over RecordBus JSONL under -`.trellis/tasks//hermes/` or `.ai/records/`. +Allowed main-agent Bash is intentionally narrow: deterministic `task.py`, +`dispatch.py`, and listed `closure.py` control commands, plus limited +read-only `git status`, `git diff`, `git log`, and branch inspection. Prefer +`Read` for records; `cat` and `jq` are not part of the coordinator surface. If code must be changed, route the task to a **coder subagent** with explicit allowed files, forbidden files, task scope, and required record output. @@ -118,8 +205,8 @@ subagent_context_policy = validated_dispatch_only ``` The main agent creates a validated dispatch and passes only its `job_id` to -Claude Agent. Codex native dispatch is advisory; enforced Codex work uses the -strict `dispatch.py run` wrapper. The canonical body contains role/profile, +Claude Agent. Codex native dispatch is advisory; hooks and deterministic +records provide the supported gates. The canonical body contains role/profile, revision, package, objective, and at most three refs within 2000 characters. The subagent reads a referenced file only when the assignment needs it. Full @@ -203,9 +290,10 @@ safety, closure, or statistics. Evidence judgment must not patch code, set `PreToolUse` is the role firewall. For Claude Agent it accepts only a validated `job_id`, rejects async dispatch, and replaces the original prompt with the -canonical body. `SubagentStop` validates the Result Envelope; `PostToolUse` -returns only its sanitized summary. Explicit writers still pass through -`task_card` and file-boundary checks. +canonical body. `Edit` and `Write` are checked against `allowed_files` before +they run. Bash has only role and bounded-command authorization; `SubagentStop` +and `Stop` validate records and closure conditions afterward. These checks are +not an OS sandbox, malicious-process isolation, or arbitrary-shell defense. `Stop` is read-only. It reads RecordBus, git diff, and `run_manifest.jsonl`. It must not write records or run tests. diff --git a/packages/cli/src/templates/trellis/hermes/config.yaml b/packages/cli/src/templates/trellis/hermes/config.yaml index 772ed74..85d0c95 100644 --- a/packages/cli/src/templates/trellis/hermes/config.yaml +++ b/packages/cli/src/templates/trellis/hermes/config.yaml @@ -1,8 +1,8 @@ # Hermes research overlay config. -hermes_version: "0.1.0-mvp" -runtime_scope: "deployment candidate hardening" -runtime_boundary: "not an OS sandbox" +hermes_version: "0.7.1-beta.0" +runtime_scope: "lightweight research collaboration" +runtime_boundary: "runs directly in the project workspace" # Task-scoped Hermes records are the source of truth for research work. records_dir: ".trellis/tasks/{task}/hermes" @@ -66,7 +66,6 @@ context_firewall: raw_trace_publish: false claude_mode: "hooks-enforced" codex_native: "advisory" - codex_strict: "codex exec --output-schema --json -o" async_workers: heartbeat_interval: "5m" @@ -77,16 +76,12 @@ async_workers: runtime: scripts_dir: ".trellis/scripts/hermes" - sandbox: - mode: "none" - required: false - note: "Local fail-closed checks only; not an OS sandbox." commands: append_record: "python3 ./.trellis/scripts/hermes/record.py append --task {task} --record-type worker --json '{json}'" dispatch_create: "python3 ./.trellis/scripts/hermes/dispatch.py create --task {task} --role {role} --profile {profile} --objective {objective}" dispatch_validate: "python3 ./.trellis/scripts/hermes/dispatch.py validate --task {task} --job-id {job_id}" dispatch_show: "python3 ./.trellis/scripts/hermes/dispatch.py show --task {task} --job-id {job_id}" - dispatch_run_codex_strict: "python3 ./.trellis/scripts/hermes/dispatch.py run --task {task} --job-id {job_id} --platform codex --mode strict" + dispatch_run: "python3 ./.trellis/scripts/hermes/dispatch.py run --task {task} --job-id {job_id} --platform {platform}" dispatch_list: "python3 ./.trellis/scripts/hermes/dispatch.py list --task {task}" validate_worker: "python3 ./.trellis/scripts/hermes/validate.py --task {task} --kind worker" init_experiment: "python3 ./.trellis/scripts/hermes/experiment.py init --task {task}" diff --git a/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml b/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml index 13ee7d5..8c92ec2 100644 --- a/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml +++ b/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml @@ -12,9 +12,12 @@ environment: shell: "bash" allowed_commands: - "python3" -sandbox: - mode: "none" - required: false - image: "" - command: "" artifact_dir: ".trellis/tasks/{task}/hermes/experiments" + +# Exploration tasks changing dataset, split, or preprocessing add: +# data_preflight: +# source: "source name" +# version: "version" +# input_manifest: "repo/path/to/input-manifest.json" +# hash: "sha256:..." +# checks_ref: "repo/path/to/data-checks.yaml" diff --git a/packages/cli/src/templates/trellis/hermes/records/recordbus.md b/packages/cli/src/templates/trellis/hermes/records/recordbus.md index 781a580..f3614c5 100644 --- a/packages/cli/src/templates/trellis/hermes/records/recordbus.md +++ b/packages/cli/src/templates/trellis/hermes/records/recordbus.md @@ -4,8 +4,8 @@ RecordBus is the only trusted communication channel between agents. Chat messages can hint at work, but they are not facts. A task is not complete until the relevant record exists under `.trellis/tasks//hermes/`. -This is deployment candidate hardening, not a production-ready platform. JSONL -records are not tamper-proof storage, and Hermes is not an OS sandbox. +Hermes is a lightweight local collaboration tool. JSONL records preserve task +history but are not a security or isolation boundary. ## Role And Task Card Guide @@ -20,8 +20,8 @@ Formal Agent Context Firewall support is limited to Claude and Codex. A validated dispatch lives at `.trellis/tasks//hermes/dispatches/.dispatch.json`; its sanitized result lives beside it as `.result.json`. Claude hooks replace Agent input -and output. Codex native agents are advisory; strict mode uses `codex exec ---output-schema --json -o` through `dispatch.py run`. +and output. Codex uses the same compact dispatch and result protocol in the +current project workspace. Use task cards to assign one bounded worker at a time. Canonical roles are `planner`, `researcher`, `coder`, `runner`, and `reviewer`. The optional @@ -69,9 +69,8 @@ store task-specific evidence, claims, approvals, worker logs, or review results. - Provenance records go to `provenance_ledger.jsonl` and should include dataset, model, code, env, and artifact refs with hash, version, or source metadata. -- Audit records go to `audit_ledger.jsonl` for security gates, external write - boundaries, secret redaction, sandbox gate decisions, and approval boundary - events. +- Audit records go to `audit_ledger.jsonl` for evidence, review, approval, and + task-boundary events. - Plan change records go to `plan_change_log.jsonl` when a PRD, contract, experiment config, or other research plan changes. Append a new record instead of rewriting the old plan trail. @@ -100,10 +99,8 @@ Experiment work should keep its source config in `experiment.yaml` and append execution details to `run_manifest.jsonl` so the run stays traceable without mixing it into chat history. Each manifest entry should include command, cwd, env_summary, inputs, outputs, exit_code, started_at, and finished_at. -`allowed_commands` is a command allowlist, not a strong command sandbox. If -`python3` is allowed, arbitrary Python code can still be executed by that -command. Use exact command patterns and external OS-level isolation for stronger -controls. +`allowed_commands` is a reproducibility contract for the task, not a security +boundary. Use exact command patterns so later runs are easy to understand. Runner-managed commands should write stdout and stderr logs under the task Hermes directory and include those log paths in the manifest. Failed commands must keep the original exit code and append a rejection that points to the @@ -207,7 +204,7 @@ Append a `provenance` record to lock the refs used by an experiment. Append an `audit` record when a security or approval boundary is checked. ```json -{"type":"audit","id":"au-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","event":"security_gate|external_write_boundary|secret_redaction|sandbox_gate|approval_boundary","actor":"runner.py","boundary":"allowed_commands","decision":"blocked","summary":"short reason"} +{"type":"audit","id":"au-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","event":"evidence_check|review_check|approval_boundary","actor":"runner.py","boundary":"allowed_commands","decision":"recorded","summary":"short reason"} ``` ### plan_change diff --git a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md index 034fc43..a2f1f3a 100644 --- a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md +++ b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md @@ -14,7 +14,7 @@ waits for human/root approval instead of inventing a local decision. Before starting a worker, create and validate one dispatch with `python3 ./.trellis/scripts/hermes/dispatch.py create`. The command writes the dispatch file and its RecordBus `task_card`. Claude Agent calls receive only -the `job_id`; Codex strict work runs through the same CLI. +the `job_id`; Codex uses the same compact dispatch and result protocol. The validated dispatch defines: diff --git a/packages/cli/src/templates/trellis/hermes/roles/base.md b/packages/cli/src/templates/trellis/hermes/roles/base.md index ad6f6ae..95689a4 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/base.md +++ b/packages/cli/src/templates/trellis/hermes/roles/base.md @@ -3,6 +3,8 @@ - Read the Task Capsule before any larger task artifact. - Treat `task.json` and append-only Hermes records as state; chat statements do not change state. - Stay inside task-card file and command permissions. +- `Edit` and `Write` actions are checked against `allowed_files` before they run; this is a file-boundary check, not process isolation. +- `Bash` uses a small role and command allowlist plus later record and closure checks. It is not a sandbox or protection against a malicious process. - Load only the current assignment and at most three direct references by default. - Cite concrete records, artifacts, commands, or sources in the required output. - Stop and report a blocker for high-risk research changes or missing human approval. diff --git a/packages/cli/src/templates/trellis/hermes/roles/coder.md b/packages/cli/src/templates/trellis/hermes/roles/coder.md index 06329af..0b29c1a 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/coder.md +++ b/packages/cli/src/templates/trellis/hermes/roles/coder.md @@ -6,8 +6,9 @@ Modify code, tests, or configuration inside an explicit task-card boundary. ## Allowed actions -- Edit `allowed_files` while avoiding every `forbidden_files` path. -- Add focused tests and run targeted implementation checks. +- Edit only files matching `allowed_files` while avoiding every `forbidden_files` path. +- Add focused tests; route test, build, and experiment execution to a runner. +- Use Bash only for the bounded read-only Git inspection allowed to a coder. - Append checkpoints, implementation results, risks, and repair notes. ## Forbidden actions diff --git a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md index 0b7eea4..5f4623e 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md +++ b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md @@ -9,6 +9,7 @@ Independently review quality, evidence, claims, safety, statistics, or closure a - Read the current diff, task card, relevant records, artifacts, and at most the references needed for the review. - Append review findings, rejection records, and compact gap lists. - Recommend approval prerequisites while preserving `HumanGate`. +- For quality, evidence, claim, or statistics review, bind `parent_job_id` to the exact job being reviewed. Closure and independent safety review may remain task-level. ## Forbidden actions diff --git a/packages/cli/src/templates/trellis/hermes/roles/runner.md b/packages/cli/src/templates/trellis/hermes/roles/runner.md index 7615dc5..a0384c2 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/runner.md +++ b/packages/cli/src/templates/trellis/hermes/roles/runner.md @@ -6,7 +6,7 @@ Execute tests, experiments, builds, and validation with reproducible run records ## Allowed actions -- Run commands authorized by the task card. +- Run registered commands through `runner.py`; `allowed_commands` is a reproducibility boundary, not a security sandbox. - Register run manifests, artifact paths, checkpoints, results, and risks. - Report environment failures and request a coder when implementation changes are needed. diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index b134ca8..1c86aa4 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -18,6 +18,7 @@ block_package, build_capsule, check_package, + closure_next_action, close_task, complete_package, format_audit_yaml, @@ -81,6 +82,7 @@ def parser() -> argparse.ArgumentParser: grill = subparsers.add_parser("grill", help="Record a completed exploration grill") add_common(grill) grill.add_argument("--complete", action="store_true") + grill.add_argument("--decision-ref", required=True, help="Repository- or task-relative decision record") validate = subparsers.add_parser("validate", help="Validate plan and enter ready") add_common(validate) @@ -188,8 +190,13 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "grill": if not args.complete: - raise ClosureError("use grill --complete after the exploration grill") - mark_grill_complete(task_dir, data, actor=actor) + raise ClosureError("use grill --complete --decision-ref after the exploration grill") + mark_grill_complete( + task_dir, + data, + actor=actor, + decision_ref=args.decision_ref, + ) print("exploration grill recorded") return 0 if args.command == "validate": @@ -209,7 +216,7 @@ def main(argv: list[str] | None = None) -> int: 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.") + print(closure_next_action(data)) return 0 if args.command == "capsule": print(build_capsule(task_dir, data, repo_root)) @@ -305,7 +312,7 @@ def _status_record(data: dict[str, Any]) -> dict[str, Any]: "closure_state": data.get("closure_state"), "closure_mode": data.get("closure_mode"), "current_work_package": current_package, - "next_action": data.get("next_action"), + "next_action": closure_next_action(data), "blockers": data.get("blockers") or [], "repair_count": data.get("repair_count", 0), "max_repair_count": data.get("max_repair_count", 1), diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index fc4ba24..bcf480e 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -3,8 +3,8 @@ from __future__ import annotations import json +import hashlib import re -import subprocess import uuid from copy import deepcopy from datetime import datetime, timezone @@ -43,6 +43,13 @@ "execution": "execution", "exploration": "exploration", } +DECISION_SECTION_HEADINGS = { + "decision": "Decision", + "rationale": "Rationale", + "evidence": "Evidence", + "alternatives": "Alternatives", + "failure conditions": "Failure Conditions", +} PACKAGE_STATUSES = { "pending", "ready", @@ -165,6 +172,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "research_route": "delivery", "research_change_fields": [], "grill_completed": False, + "decision_ref": None, "constraints": { "excluded_platforms": [], "excluded_paths": [], @@ -384,6 +392,53 @@ def research_route_summary(data: dict[str, Any]) -> str: ) +def skill_route_guidance(data: dict[str, Any]) -> str: + """Return one compact, phase-aware skill and role routing hint.""" + phase = str(data.get("hermes_phase") or "planning") + route = research_route(data) + if phase == "planning": + if route == "exploration" and data.get("grill_completed") is not True: + return ( + "main leads a focused grill-me decision; planner may propose options, " + "but cannot approve the research change" + ) + return ( + "main analyzes intent, scope, done criteria and 1-4 outcome packages; " + "brainstorm only for unresolved requirements" + ) + if phase == "ready": + return ( + "main starts the next package and dispatches by permission; " + "do not preload unrelated skills" + ) + if phase == "running": + return ( + "coder uses before-dev for code work (TDD only when selected); " + "runner records formal experiments with hermes-research" + ) + if phase == "review": + if data.get("current_work_package"): + return ( + "runner verifies execution and reviewer checks independently; " + "trellis-check is only for code work" + ) + return ( + "reviewer:closure audits completion; main evaluates update-spec only " + "for reviewed durable knowledge" + ) + if phase == "blocked": + return ( + "main separates technical failure from a negative research result; " + "break-loop is only for repeated technical failure, handoff on pause" + ) + if phase == "closed": + return ( + "main coordinates finish-work; reviewer preserves closure independence " + "and runner performs final validation/archive commands" + ) + return "main routes only the skills justified by the current recorded state" + + def _research_change_fields(data: dict[str, Any]) -> list[str]: raw = data.get("research_change_fields") if not isinstance(raw, list): @@ -575,9 +630,11 @@ def plan_closure( raise ClosureError("research route must be delivery, execution, or exploration") data["research_route"] = research_route_name data["grill_completed"] = False + data["decision_ref"] = None if normalized_research_changes is not None: data["research_change_fields"] = normalized_research_changes data["grill_completed"] = False + data["decision_ref"] = None if not _string_list(data.get("definition_of_done")): data["definition_of_done"] = parse_prd_definition_of_done(task_dir / "prd.md") @@ -642,6 +699,7 @@ def set_research_route( "research_route": research_route(data), "research_change_fields": _research_change_fields(data), "grill_completed": data.get("grill_completed") is True, + "decision_ref": data.get("decision_ref"), } normalized_research_changes: list[str] | None = None if research_change_fields is not None: @@ -667,12 +725,14 @@ def set_research_route( ) if changed: data["grill_completed"] = False + data["decision_ref"] = None data["next_action"] = closure_next_action(data) save_task(task_dir, data) after = { "research_route": research_route(data), "research_change_fields": _research_change_fields(data), "grill_completed": data.get("grill_completed") is True, + "decision_ref": data.get("decision_ref"), } append_event( task_dir, @@ -690,6 +750,7 @@ def mark_grill_complete( data: dict[str, Any], *, actor: str, + decision_ref: str, ) -> None: ensure_closure_defaults(data) if data.get("hermes_phase") != "planning": @@ -698,9 +759,11 @@ def mark_grill_complete( raise ClosureError("grill completion is only required for exploration") if not _research_change_fields(data): raise ClosureError("exploration must record at least one research change field") - if data.get("grill_completed") is True: - return + normalized_ref, decision_errors = _validate_decision_ref(task_dir, decision_ref) + if decision_errors: + raise ClosureError("; ".join(decision_errors)) data["grill_completed"] = True + data["decision_ref"] = normalized_ref data["next_action"] = closure_next_action(data) save_task(task_dir, data) append_event( @@ -710,15 +773,28 @@ def mark_grill_complete( old_state="pending", new_state="complete", reason="exploration grill completed", + extra={"decision_ref": normalized_ref}, ) -def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: +def validate_closure( + data: dict[str, Any], + task_dir: Path | None = None, +) -> 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") errors.extend(_validate_research_route(data)) + if research_route(data) == "exploration" and data.get("grill_completed") is True: + decision_ref = data.get("decision_ref") + if not isinstance(decision_ref, str) or not decision_ref.strip(): + warnings.append( + "legacy completed grill has no decision_ref; record one at the next research amendment" + ) + elif task_dir is not None: + _, decision_errors = _validate_decision_ref(task_dir, decision_ref) + errors.extend(decision_errors) phase = data.get("hermes_phase") closure_state = data.get("closure_state") if phase not in HERMES_PHASES: @@ -776,8 +852,9 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: if _string_list(data.get("blockers")): errors.append("unresolved blockers must be resolved before validation") - pins = _string_list(data.get("context_pins")) - if isinstance(data.get("context_pins"), list) and len(pins) != len(data.get("context_pins")): + raw_pins = data.get("context_pins") + pins = _string_list(raw_pins) + if isinstance(raw_pins, list) and len(pins) != len(raw_pins): errors.append("context_pins must be an array of non-empty strings") if len(pins) > 3: errors.append("context_pins supports at most 3 entries") @@ -913,7 +990,7 @@ def validate_and_ready( ensure_closure_defaults(data) if data.get("closure_state") == "closed": return ["closed tasks must be reopened through closure.py plan"], [] - errors, warnings = validate_closure(data) + errors, warnings = validate_closure(data, task_dir) if errors: return errors, warnings old_phase = str(data.get("hermes_phase") or "planning") @@ -963,7 +1040,7 @@ def start_package( reason: str = "", ) -> dict[str, Any]: ensure_closure_defaults(data) - errors, _ = validate_closure(data) + errors, _ = validate_closure(data, task_dir) if errors: raise ClosureError("plan is not valid: " + "; ".join(errors)) if data.get("hermes_phase") == "planning": @@ -1203,7 +1280,7 @@ def audit_closure( ) -> dict[str, Any]: gaps: list[dict[str, Any]] = [] warnings: list[str] = [] - errors, _ = validate_closure(data) + errors, _ = validate_closure(data, task_dir) 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)] @@ -1527,19 +1604,18 @@ def amend_plan( semantic_ids = [field.split(".", 2)[1]] affected_ids = _dedupe([*semantic_ids, *incomplete_ids]) human_approved = str(approved_by or "").casefold() in {"human", "root", "human/root"} + previous_route = research_route(data) + previous_changes = _research_change_fields(data) requires_independent_task = ( research_change == "model_architecture" and human_approved ) applied = not high_risk or human_approved if requires_independent_task: applied = False - previous_route = research_route(data) - previous_changes = _research_change_fields(data) data["research_route"] = "exploration" data["research_change_fields"] = _dedupe( [*previous_changes, "model_architecture"] ) - data["grill_completed"] = False blocker = "model architecture requires an independent exploration task" data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) data["closure_state"] = "open" @@ -1556,11 +1632,12 @@ def amend_plan( data["hermes_phase"] = "planning" data["status"] = "planning" if research_change is not None: + data["decision_ref"] = None + data["grill_completed"] = False data["research_route"] = "exploration" data["research_change_fields"] = _dedupe( [*_research_change_fields(data), research_change] ) - data["grill_completed"] = False data["next_action"] = closure_next_action(data) else: data["next_action"] = "Run closure.py validate after the plan amendment." @@ -1750,6 +1827,9 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None ] if current: lines.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 240)) + decision_ref = data.get("decision_ref") + if isinstance(decision_ref, str) and decision_ref.strip(): + lines.append("Decision ref: " + _clip(decision_ref.strip(), 180)) lines.append(f"Next: {_clip(closure_next_action(data), 180)}") lines.append(f"Blockers: {blockers or '-'}") refs = _context_refs(task_dir, data, root or get_repo_root())[:3] @@ -1760,7 +1840,8 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: - repo_root = root or get_repo_root(task_dir) + revision = data.get("hermes_revision", 0) + revision_text = str(revision) if isinstance(revision, int) and not isinstance(revision, bool) else "unknown" 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([ @@ -1768,12 +1849,15 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None for item in packages for ref in _string_list(item.get("evidence_refs")) ]) - changed = _git_changed_files(repo_root) + changed, integrity_warnings = _task_changed_file_records(task_dir, data) failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) content = "\n".join( [ "# Task Handoff", "", + "## Task Revision", + revision_text, + "", "## Current Goal", str(data.get("intent") or data.get("title") or "-"), "", @@ -1789,6 +1873,9 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None "## Files Changed", _markdown_items(changed), "", + "## Integrity Warnings", + _markdown_items(integrity_warnings), + "", "## Artifacts/Evidence", _markdown_items(evidence), "", @@ -1799,7 +1886,7 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None _markdown_items(_string_list(data.get("blockers"))), "", "## Next Action", - str(data.get("next_action") or "-"), + closure_next_action(data), "", "## Do Not Do", "- Do not widen scope or change research contracts without an approved amend event.", @@ -1807,10 +1894,9 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None ] ) path = task_dir / "HANDOFF.md" - path.write_text(content, encoding="utf-8") + if not write_text_atomic(path, content): + raise ClosureError("cannot write HANDOFF.md") 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 = [ @@ -2061,7 +2147,7 @@ def closure_next_action(data: dict[str, Any]) -> str: if not _research_change_fields(data): return "Record research changes with closure.py route before validate." if data.get("grill_completed") is not True: - return "Record the completed grill with closure.py grill --complete." + return "Record the completed grill with closure.py grill --complete --decision-ref ." if not str(data.get("intent") or "").strip(): return "Set intent with closure.py plan." if not _string_list(data.get("definition_of_done")): @@ -2259,6 +2345,64 @@ def _context_refs(task_dir: Path, data: dict[str, Any], root: Path) -> list[str] return _dedupe(refs) +def _validate_decision_ref( + task_dir: Path, + reference: str, +) -> tuple[str, list[str]]: + normalized = reference.replace("\\", "/").strip() + if ( + not normalized + or normalized.startswith("/") + or re.match(r"^[A-Za-z]:/", normalized) + or ".." in Path(normalized).parts + ): + return normalized, ["decision_ref must be repository- or task-relative"] + + root = get_repo_root(task_dir).resolve() + bases = [root] if normalized.startswith(".trellis/") else [task_dir, root] + resolved: Path | None = None + for base in bases: + candidate = (base / normalized).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file(): + resolved = candidate + break + if resolved is None: + return normalized, [f"decision_ref does not exist inside the repository: {normalized}"] + + try: + content = resolved.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return normalized, [f"decision_ref is not readable text: {normalized}"] + + headings = list(re.finditer(r"(?mi)^#{1,6}\s+(.+?)\s*$", content)) + present: set[str] = set() + empty: list[str] = [] + for index, match in enumerate(headings): + key = match.group(1).strip().casefold() + canonical = DECISION_SECTION_HEADINGS.get(key) + if canonical is None: + continue + present.add(key) + end = headings[index + 1].start() if index + 1 < len(headings) else len(content) + if not content[match.end():end].strip(): + empty.append(canonical) + missing = [ + canonical + for key, canonical in DECISION_SECTION_HEADINGS.items() + if key not in present + ] + errors: list[str] = [] + if missing: + errors.append("decision record is missing sections: " + ", ".join(missing)) + if empty: + errors.append("decision record has empty sections: " + ", ".join(empty)) + return normalized, errors + + def _read_jsonl_values(path: Path) -> list[dict[str, Any]]: if not path.is_file(): return [] @@ -2293,31 +2437,61 @@ def _recent_event_reasons(task_dir: Path, types: set[str]) -> list[str]: ][-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 [] +def _task_changed_files(task_dir: Path, data: dict[str, Any]) -> list[str]: + """Return only files from successful dispatches confirmed by task state.""" + return _task_changed_file_records(task_dir, data)[0] + + +def _task_changed_file_records(task_dir: Path, data: dict[str, Any]) -> tuple[list[str], list[str]]: + """Return verified changed files and compact warnings for rejected result records.""" 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") + integrity_warnings: list[str] = [] + confirmed = set(_string_list(data.get("confirmed_dispatches"))) + current_revision = data.get("hermes_revision") + for result_path in sorted((task_dir / "hermes" / "dispatches").glob("*.result.json")): + value = read_json(result_path) + if not isinstance(value, dict): + continue + job_id = result_path.name.removesuffix(".result.json") + if value.get("job_id") != job_id or job_id not in confirmed: + continue + dispatch = read_json(task_dir / "hermes" / "dispatches" / f"{job_id}.dispatch.json") + confirmed_revision = dispatch.get("confirmed_revision") if isinstance(dispatch, dict) else None + if ( + not isinstance(dispatch, dict) + or dispatch.get("job_id") != job_id + or dispatch.get("status") != "confirmed" + or value.get("status") != "success" + or value.get("confirmed") is not True + or value.get("task_revision") != dispatch.get("hermes_revision") + or not isinstance(confirmed_revision, int) + or isinstance(confirmed_revision, bool) + or not isinstance(current_revision, int) + or isinstance(current_revision, bool) + or confirmed_revision > current_revision ): - files.append(value) - return files[:20] + continue + expected_hash = dispatch.get("result_sha256") + actual_hash = _result_sha256(value) + if not isinstance(expected_hash, str) or expected_hash != actual_hash: + integrity_warnings.append(f"{job_id}: confirmed result integrity check failed") + continue + for raw_path in _string_list(value.get("changed_files")): + normalized = raw_path.replace("\\", "/").removeprefix("./") + if ( + normalized + and not normalized.startswith("/") + and not re.match(r"^[A-Za-z]:/", normalized) + and ".." not in Path(normalized).parts + ): + files.append(normalized) + return _dedupe(files)[:20], _dedupe(integrity_warnings)[:5] + + +def _result_sha256(value: dict[str, Any]) -> str: + payload = {key: item for key, item in value.items() if key != "audit"} + serialized = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}" def _gap(package: str | None, missing: str, action: str) -> dict[str, Any]: diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index 885512b..a2e5d2a 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -6,9 +6,7 @@ import json import os import re -import shutil import subprocess -import tempfile from copy import deepcopy from datetime import datetime, timedelta, timezone from fnmatch import fnmatchcase @@ -23,6 +21,7 @@ package_by_id, save_task, task_state_lock, + write_handoff, ) from .firewall import closure_mode_gate, record_firewall_heartbeat from .io import path_has_symlink, read_json, write_json_atomic @@ -39,6 +38,7 @@ MAX_INVALID_RESULTS = 2 EXECUTION_ROLES = {"coder", "runner"} TASK_LEVEL_ROLES = {"planner", "researcher", "reviewer"} +INDEPENDENT_REVIEWER_PROFILES = {"closure", "safety"} PROJECT_CONTEXT_FILES = { "background": ".trellis/project/BACKGROUND.md", "research_plan": ".trellis/project/RESEARCH_PLAN.md", @@ -123,6 +123,7 @@ "task_revision", "role", "profile", + "parent_job_id", "status", "conclusion", "uncertainties", @@ -211,16 +212,27 @@ def create_dispatch( role = normalized.role profile = normalized.profile work_package = _normalize_optional_text(spec.get("work_package")) - _validate_work_package(task, role, work_package) + parent_job_id = _normalize_optional_text(spec.get("parent_job_id")) + constraints = closure_constraints(task) + allowed_files = _normalize_patterns(spec.get("allowed_files"), "allowed_files") + forbidden_files = _normalize_patterns(spec.get("forbidden_files"), "forbidden_files") + handoff_writer = _is_handoff_writer( + task_dir, + repo_root, + role, + profile, + work_package, + allowed_files, + ) + _validate_handoff_file_boundary(task_dir, repo_root, allowed_files, handoff_writer) + _validate_role_write_scope(task_dir, repo_root, role, allowed_files) + _validate_work_package(task, role, work_package, allow_task_handoff=handoff_writer) refs = _normalize_refs( task_dir, repo_root, _merged_dispatch_refs(task, spec.get("refs"), repo_root, role, profile), role, ) - constraints = closure_constraints(task) - allowed_files = _normalize_patterns(spec.get("allowed_files"), "allowed_files") - forbidden_files = _normalize_patterns(spec.get("forbidden_files"), "forbidden_files") if role == "coder" and not allowed_files: raise DispatchError("missing_allowed_files", "coder dispatch requires allowed_files") if not forbidden_files: @@ -237,12 +249,23 @@ def create_dispatch( role, profile, work_package, + parent_job_id, objective, ) _validate_job_id(job_id) path = dispatch_path(task_dir, job_id) if path.exists(): raise DispatchError("duplicate_job", f"dispatch already exists for {job_id}") + _validate_parent_job( + task_dir, + task, + repo_root, + job_id=job_id, + role=role, + profile=profile, + work_package=work_package, + parent_job_id=parent_job_id, + ) mode = str(task.get("closure_mode") or "lean") gate_errors, gate_warnings = closure_mode_gate( @@ -275,6 +298,8 @@ def create_dispatch( "role": role, "profile": profile, "work_package": work_package, + "parent_job_id": parent_job_id, + "handoff_writer": handoff_writer, "objective": objective, "allowed_refs": refs, "refs": refs, @@ -435,6 +460,22 @@ def validate_dispatch( ) if dispatch.get("task_revision") != dispatch.get("hermes_revision"): errors.append("task_revision") + try: + parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) + if dispatch.get("parent_job_id") != parent_job_id: + errors.append("parent_job_id") + _validate_parent_job( + task_dir, + task, + repo_root, + job_id=str(dispatch.get("job_id") or ""), + role=str(dispatch.get("role") or ""), + profile=str(dispatch.get("profile") or ""), + work_package=_normalize_optional_text(dispatch.get("work_package")), + parent_job_id=parent_job_id, + ) + except DispatchError as exc: + errors.append(exc.code) if dispatch.get("allowed_refs") != dispatch.get("refs"): errors.append("allowed_refs") if dispatch.get("forbidden") != dispatch.get("forbidden_files"): @@ -453,10 +494,27 @@ def validate_dispatch( except RoleProfileError: errors.append("role_profile") try: + handoff_writer = _is_handoff_writer( + task_dir, + repo_root, + str(dispatch.get("role") or ""), + str(dispatch.get("profile") or ""), + dispatch.get("work_package"), + _string_list(dispatch.get("allowed_files")), + ) + if dispatch.get("handoff_writer") is not handoff_writer: + errors.append("handoff_writer") + _validate_handoff_file_boundary( + task_dir, + repo_root, + _string_list(dispatch.get("allowed_files")), + handoff_writer, + ) _validate_work_package( task, str(dispatch.get("role") or ""), dispatch.get("work_package"), + allow_task_handoff=handoff_writer, ) except DispatchError as exc: errors.append(exc.code) @@ -506,11 +564,6 @@ def prepare_dispatch_for_agent( if role and dispatch.get("role") != role: raise DispatchError("role_mismatch", "Agent role does not match validated dispatch") mode = str(task.get("closure_mode") or "lean") - if platform == "codex" and not hook_active and mode == "publication": - raise DispatchError( - "advisory_forbidden", - "publication cannot use advisory Codex native dispatch; use strict mode", - ) task_id = str(task.get("id") or task_dir.name) if hook_active: record_firewall_heartbeat( @@ -562,7 +615,8 @@ def prepare_dispatch_for_agent( def build_canonical_prompt(dispatch: dict[str, Any]) -> str: refs = _string_list(dispatch.get("refs")) - constraints = dispatch.get("constraints") if isinstance(dispatch.get("constraints"), dict) else {} + raw_constraints = dispatch.get("constraints") + constraints: dict[str, Any] = raw_constraints if isinstance(raw_constraints, dict) else {} verification_level = constraints.get("validation_level", "targeted") excluded_platforms = _string_list(constraints.get("excluded_platforms")) excluded_paths = _string_list(constraints.get("excluded_paths")) @@ -572,6 +626,7 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: f"task: {dispatch.get('task_path')} @ revision {dispatch.get('hermes_revision')}", f"role: {dispatch.get('role')}:{dispatch.get('profile')}", f"work_package: {dispatch.get('work_package') or 'null'}", + f"parent_job_id: {dispatch.get('parent_job_id') or 'null'}", f"objective: {dispatch.get('objective')}", "refs: " + (", ".join(refs) if refs else "none"), "allowed_files: " + (", ".join(_string_list(dispatch.get("allowed_files"))) or "none"), @@ -582,11 +637,15 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: "Read only these refs unless the objective names a deterministic ledger check.", "For targeted verification, run only the current work package's directed check and record basic evidence; do not claim completion from chat.", "Do not include logs, diffs, search history, tool traces, secrets, or absolute user paths.", - "Return exactly one JSON object with: schema, job_id, task_revision, role, profile, status(success|failure|blocked), conclusion, evidence_refs, artifact_refs, changed_files, verification, risks, uncertainties, next_action.", + "Return exactly one JSON object with: schema, job_id, task_revision, role, profile, parent_job_id when present, status(success|failure|blocked), conclusion, evidence_refs, artifact_refs, changed_files, verification, risks, uncertainties, next_action.", f"conclusion must be <= {MAX_CONCLUSION_CHARS} characters; uncertainties is required.", ] if dispatch.get("blind_review"): lines.append("Blind review: do not read coder/runner explanations or worker result prose; judge current artifacts and ledgers only.") + if dispatch.get("handoff_writer"): + lines.append( + "Handoff writer: run closure.py handoff for this task. Do not edit task state or other files; report HANDOFF.md as the only changed file." + ) body = "\n".join(lines) _check_sensitive_text(body, "dispatch body") if ABSOLUTE_USER_PATH_RE.search(body): @@ -641,6 +700,13 @@ def accept_result_text( _apply_result_state(updated_task, dispatch, sanitized) try: save_task(task_dir, updated_task, lock_held=True) + if dispatch.get("handoff_writer") and sanitized["status"] == "success": + dispatch["status"] = "confirmed" + dispatch["confirmed_revision"] = _task_revision(updated_task) + dispatch["confirmed_at"] = now_utc() + dispatch["result_sha256"] = sanitized["audit"]["result_sha256"] + _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) + write_handoff(task_dir, updated_task, repo_root) _append_worker_result(task_dir, dispatch, sanitized) except (ClosureError, DispatchError, OSError) as exc: raise DispatchError("state_confirmation_failed", "result was stored but state confirmation failed") from exc @@ -728,6 +794,10 @@ def validate_result_envelope( raise DispatchError("result_revision_mismatch", "result task_revision does not match dispatch") if value.get("role") != dispatch.get("role") or value.get("profile") != dispatch.get("profile"): raise DispatchError("result_role_mismatch", "result role/profile does not match dispatch") + result_parent_job_id = _normalize_optional_text(value.get("parent_job_id")) + dispatch_parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) + if "parent_job_id" in value and result_parent_job_id != dispatch_parent_job_id: + raise DispatchError("result_parent_mismatch", "result parent_job_id does not match dispatch") if value.get("status") not in {"success", "failure", "blocked"}: raise DispatchError("invalid_result_status", "status must be success, failure, or blocked") conclusion = _required_text(value.get("conclusion"), "conclusion") @@ -789,6 +859,7 @@ def validate_result_envelope( "task_revision": value["task_revision"], "role": value["role"], "profile": value["profile"], + "parent_job_id": dispatch_parent_job_id, "status": value["status"], "conclusion": conclusion, "uncertainties": uncertainties, @@ -825,6 +896,7 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: "task_revision", "role", "profile", + "parent_job_id", "status", "conclusion", "uncertainties", @@ -847,6 +919,7 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: "task_revision": dispatch.get("task_revision", dispatch.get("hermes_revision")), "role": dispatch.get("role"), "profile": dispatch.get("profile"), + "parent_job_id": dispatch.get("parent_job_id"), "status": "blocked" if status == "blocked" else status, "conclusion": "Result envelope was rejected by the context firewall.", "uncertainties": [f"invalid_result_attempts={attempts}"], @@ -874,6 +947,7 @@ def list_dispatches(task_dir: Path) -> list[dict[str, Any]]: "role": value.get("role"), "profile": value.get("profile"), "work_package": value.get("work_package"), + "parent_job_id": value.get("parent_job_id"), "hermes_revision": value.get("hermes_revision"), "status": value.get("status"), "invalid_result_attempts": value.get("invalid_result_attempts", 0), @@ -949,326 +1023,6 @@ def supersede_dispatch( return dispatch -def run_codex_strict( - task_dir: Path, - task: dict[str, Any], - repo_root: Path, - job_id: str, - *, - codex_bin: str = "codex", -) -> tuple[dict[str, Any] | None, list[str]]: - dispatch = load_dispatch(task_dir, job_id) - validate_dispatch(task_dir, task, dispatch, repo_root) - executable = shutil.which(codex_bin) - capability_errors = _codex_capability_errors(executable or codex_bin, repo_root) - mode = str(task.get("closure_mode") or "lean") - if capability_errors: - if mode in {"standard", "publication"}: - raise DispatchError("codex_strict_unavailable", capability_errors[0], capability_errors) - dispatch["status"] = "validated" - dispatch["advisory"] = True - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - return None, ["Codex strict capability is unavailable; using advisory native dispatch."] - - record_firewall_heartbeat( - repo_root, - "codex", - "strict", - task_id=str(task.get("id") or task_dir.name), - job_id=job_id, - ) - dispatch["status"] = "running" - dispatch["platform"] = "codex" - dispatch["execution_mode"] = "strict" - dispatch["started_at"] = now_utc() - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - runtime_dir = raw_trace_path(repo_root, task_dir, job_id).parent - runtime_dir.mkdir(parents=True, exist_ok=True) - try: - with tempfile.TemporaryDirectory(prefix="trellis-codex-strict-") as temporary: - workspace = Path(temporary) / "workspace" - _copy_strict_workspace(repo_root, workspace) - output_dir = workspace / ".trellis" / ".strict-output" - output_dir.mkdir(parents=True, exist_ok=True) - output_path = output_dir / f"{job_id}.last-message.json" - schema_path = workspace / ".trellis" / "hermes" / "schemas" / "result-envelope.schema.json" - before = _snapshot_workspace(workspace) - sandbox_mode = "workspace-write" if dispatch.get("role") in EXECUTION_ROLES else "read-only" - command = [ - executable or codex_bin, - "exec", - "--ephemeral", - "--ignore-user-config", - "--skip-git-repo-check", - "--sandbox", - sandbox_mode, - "--ask-for-approval", - "never", - "--output-schema", - str(schema_path), - "--json", - "-o", - str(output_path), - str(dispatch["body"]), - ] - completed = subprocess.run( - command, - cwd=workspace, - env=_strict_environment(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=3600, - check=False, - ) - after = _snapshot_workspace(workspace) - actual_changes = sorted( - path for path in set(before) | set(after) if before.get(path) != after.get(path) - ) - if completed.returncode != 0: - _mark_dispatch_terminal(task_dir, dispatch, "failed", "codex_exec_failed") - _apply_failed_execution_state(task_dir, task, dispatch, "Codex strict execution failed.") - raise DispatchError("codex_exec_failed", "Codex strict execution failed") - if not _changes_allowed(actual_changes, dispatch): - _mark_dispatch_terminal(task_dir, dispatch, "failed", "unauthorized_actual_changes") - _apply_failed_execution_state( - task_dir, - task, - dispatch, - "Codex strict changed files outside dispatch permissions.", - ) - raise DispatchError( - "unauthorized_actual_changes", - "Codex strict workspace changes violate dispatch permissions", - actual_changes, - ) - try: - raw_result = output_path.read_text(encoding="utf-8") - except OSError as exc: - raise DispatchError("missing_result", "Codex did not write the final result envelope") from exc - # Validate the complete envelope before any isolated code reaches the - # caller's workspace. A rejected result must leave source untouched, - # while still receiving the same recorded invalid-result outcome as - # a native dispatch. - try: - validate_result_envelope(task_dir, task, dispatch, raw_result, repo_root) - _validate_reported_changes(raw_result, actual_changes) - except DispatchError as exc: - _store_raw_trace(repo_root, task_dir, job_id, raw_result, "codex_strict_result") - _record_invalid_result(task_dir, task, dispatch, exc) - raise - sync_state = _prepare_strict_sync(repo_root, actual_changes, Path(temporary) / "sync-backup") - try: - _sync_strict_changes(workspace, repo_root, actual_changes, sync_state) - except Exception: - _restore_strict_sync(repo_root, sync_state) - raise - try: - accepted_result = accept_result_text(task_dir, task, repo_root, job_id, raw_result) - except Exception: - _restore_strict_sync(repo_root, sync_state) - raise - except DispatchError: - raise - except (OSError, subprocess.SubprocessError) as exc: - _store_raw_trace(repo_root, task_dir, job_id, str(exc), "codex_exec_error") - raise DispatchError("codex_exec_failed", "Codex strict execution failed") from exc - trace = json.dumps( - {"stdout": completed.stdout, "stderr": completed.stderr, "exit_code": completed.returncode}, - ensure_ascii=False, - ) - _store_raw_trace(repo_root, task_dir, job_id, trace, "codex_exec_trace") - return accepted_result, [] - - -def _copy_strict_workspace(repo_root: Path, workspace: Path) -> None: - def ignore(directory: str, names: list[str]) -> set[str]: - relative = Path(directory).resolve().relative_to(repo_root.resolve()) - ignored = { - name - for name in names - if name in { - ".aws", - ".git", - ".netrc", - ".npmrc", - ".pypirc", - ".ssh", - "__pycache__", - "coverage", - "credentials", - "dist", - "id_ed25519", - "id_rsa", - "node_modules", - } - or name.startswith(".env") - or name.endswith((".key", ".pem", ".p12", ".pfx")) - or any(marker in name.casefold() for marker in ("credential", "secret", "token")) - or _strict_workspace_file_contains_secret(Path(directory) / name) - } - if relative == Path(".trellis"): - ignored.add(".runtime") - return ignored - - shutil.copytree(repo_root, workspace, ignore=ignore, symlinks=True) - for path in workspace.rglob("*"): - if not path.is_symlink(): - continue - try: - path.resolve(strict=True).relative_to(workspace.resolve()) - except (OSError, ValueError) as exc: - raise DispatchError( - "strict_workspace_symlink", - f"strict workspace contains an external or broken symlink: {path.relative_to(workspace)}", - ) from exc - - -def _strict_workspace_file_contains_secret(path: Path) -> bool: - if not path.is_file(): - return False - try: - if path.stat().st_size > 1024 * 1024: - return False - content = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return True - return any(pattern.search(content) for pattern in SECRET_PATTERNS) - - -def _snapshot_workspace(workspace: Path) -> dict[str, str]: - snapshot: dict[str, str] = {} - for path in workspace.rglob("*"): - relative = path.relative_to(workspace).as_posix() - if relative == ".trellis/.strict-output" or relative.startswith(".trellis/.strict-output/"): - continue - if path.is_symlink(): - snapshot[relative] = f"symlink:{os.readlink(path)}" - elif path.is_file(): - digest = hashlib.sha256() - try: - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as exc: - raise DispatchError("strict_snapshot_failed", f"cannot hash {relative}") from exc - snapshot[relative] = f"file:{digest.hexdigest()}" - return snapshot - - -def _strict_environment() -> dict[str, str]: - allowed = { - "PATH", - "HOME", - "USER", - "LOGNAME", - "LANG", - "LC_ALL", - "LC_CTYPE", - "TERM", - "SHELL", - "TMPDIR", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - "XDG_CACHE_HOME", - "CODEX_HOME", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - } - return {key: value for key, value in os.environ.items() if key in allowed} - - -def _validate_reported_changes(raw_result: str, actual_changes: list[str]) -> None: - try: - value = json.loads(raw_result) - except json.JSONDecodeError as exc: - raise DispatchError("invalid_json", "Codex strict result is not JSON") from exc - reported = value.get("changed_files") if isinstance(value, dict) else None - if not isinstance(reported, list) or any(not isinstance(item, str) for item in reported): - raise DispatchError("invalid_result_field", "changed_files must describe actual strict changes") - normalized = sorted(dict.fromkeys(item.replace("\\", "/").removeprefix("./") for item in reported)) - if normalized != actual_changes: - raise DispatchError( - "changed_files_mismatch", - "reported changed_files do not match the strict workspace diff", - [f"reported={normalized}", f"actual={actual_changes}"], - ) - - -def _prepare_strict_sync( - repo_root: Path, - changed_files: list[str], - backup_root: Path, -) -> dict[str, Any]: - backups: dict[str, Path | None] = {} - for relative in changed_files: - target = repo_root / relative - if target.is_symlink(): - raise DispatchError("strict_workspace_symlink", f"cannot sync symlinked path: {relative}") - if target.exists(): - if target.is_dir(): - raise DispatchError("unauthorized_actual_changes", f"strict sync cannot replace directory: {relative}") - backup = backup_root / relative - backup.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, backup) - backups[relative] = backup - else: - backups[relative] = None - return {"backups": backups, "created_dirs": []} - - -def _ensure_sync_parent(target: Path, repo_root: Path, state: dict[str, Any]) -> None: - missing: list[Path] = [] - parent = target.parent - while parent != repo_root and not parent.exists(): - missing.append(parent) - parent = parent.parent - for directory in reversed(missing): - directory.mkdir() - state["created_dirs"].append(directory) - - -def _sync_strict_changes( - workspace: Path, - repo_root: Path, - changed_files: list[str], - state: dict[str, Any], -) -> None: - for relative in changed_files: - source = workspace / relative - target = repo_root / relative - if source.is_symlink() or target.is_symlink(): - raise DispatchError("strict_workspace_symlink", f"cannot sync symlinked path: {relative}") - if source.is_file(): - _ensure_sync_parent(target, repo_root, state) - shutil.copy2(source, target) - elif target.exists(): - if target.is_dir(): - raise DispatchError("unauthorized_actual_changes", f"strict sync cannot delete directory: {relative}") - target.unlink() - - -def _restore_strict_sync(repo_root: Path, state: dict[str, Any]) -> None: - backups = state.get("backups") - if not isinstance(backups, dict): - return - for relative, backup in backups.items(): - if not isinstance(relative, str): - continue - target = repo_root / relative - if isinstance(backup, Path): - _ensure_sync_parent(target, repo_root, state) - shutil.copy2(backup, target) - elif target.is_file(): - target.unlink() - for directory in reversed(state.get("created_dirs", [])): - try: - directory.rmdir() - except OSError: - pass - - def result_json_schema() -> dict[str, Any]: string_array = {"type": "array", "items": {"type": "string"}} return { @@ -1299,6 +1053,7 @@ def result_json_schema() -> dict[str, Any]: "task_revision": {"type": "integer", "minimum": 0}, "role": {"type": "string", "enum": ["planner", "researcher", "coder", "runner", "reviewer"]}, "profile": {"type": "string"}, + "parent_job_id": {"type": ["string", "null"], "pattern": JOB_ID_RE.pattern}, "status": {"type": "string", "enum": ["success", "failure", "blocked"]}, "conclusion": {"type": "string", "maxLength": MAX_CONCLUSION_CHARS}, "uncertainties": string_array, @@ -1333,6 +1088,7 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: "revision": "pass", "role_profile": "pass", "work_package": "pass", + "parent_job": "pass", "refs": "pass", "sensitive_content": "pass", "absolute_user_path": "pass", @@ -1344,6 +1100,7 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: "ref_count": len(dispatch.get("refs") or []), "allowed_file_patterns": len(dispatch.get("allowed_files") or []), "forbidden_file_patterns": len(dispatch.get("forbidden_files") or []), + "has_parent_job": dispatch.get("parent_job_id") is not None, }, "dispatch_sha256": _stable_hash(dispatch, {"audit"}), } @@ -1360,6 +1117,7 @@ def _sanitized_result( sanitized["dispatch_revision"] = dispatch.get("hermes_revision") sanitized["role"] = dispatch.get("role") sanitized["profile"] = dispatch.get("profile") + sanitized["parent_job_id"] = dispatch.get("parent_job_id") sanitized["work_package"] = dispatch.get("work_package") verification = dict(result.get("verification") or {}) verification["run_refs"] = list(result.get("run_refs") or []) @@ -1522,6 +1280,7 @@ def _append_worker_task_card(task_dir: Path, dispatch: dict[str, Any]) -> None: "profile": dispatch["profile"], "objective": dispatch["objective"], "work_package": dispatch["work_package"], + "parent_job_id": dispatch["parent_job_id"], "hermes_revision": dispatch["hermes_revision"], "worktree_id": dispatch["worktree_id"], "status": "queued", @@ -1549,6 +1308,7 @@ def _append_worker_result( "id": f"cp-{dispatch['job_id']}", "timestamp": now_utc(), "job_id": dispatch["job_id"], + "parent_job_id": dispatch.get("parent_job_id"), "checkpoint": "result-envelope-validated", "resume_from": "sanitized result", "evidence_refs": result["evidence_refs"], @@ -1560,6 +1320,7 @@ def _append_worker_result( "id": f"rs-{dispatch['job_id']}", "timestamp": now_utc(), "job_id": dispatch["job_id"], + "parent_job_id": dispatch.get("parent_job_id"), "status": "done" if result["status"] == "success" else result["status"], "summary": result["conclusion"], "uncertainties": result["uncertainties"], @@ -1678,10 +1439,134 @@ def _mark_dispatch_terminal( ) -def _validate_work_package(task: dict[str, Any], role: str, work_package: Any) -> None: +def _is_handoff_writer( + task_dir: Path, + repo_root: Path, + role: str, + profile: str, + work_package: Any, + allowed_files: list[str], +) -> bool: + expected = f"{_repo_relative(task_dir, repo_root)}/HANDOFF.md" + return ( + role == "coder" + and profile == "configuration" + and _normalize_optional_text(work_package) is None + and allowed_files == [expected] + ) + + +def _validate_handoff_file_boundary( + task_dir: Path, + repo_root: Path, + allowed_files: list[str], + handoff_writer: bool, +) -> None: + expected = f"{_repo_relative(task_dir, repo_root)}/HANDOFF.md" + if any(_path_matches(expected, pattern) for pattern in allowed_files) and not handoff_writer: + raise DispatchError( + "handoff_path_reserved", + "HANDOFF.md is reserved for the dedicated coder:configuration handoff dispatch", + ) + + +def _validate_role_write_scope( + task_dir: Path, + repo_root: Path, + role: str, + allowed_files: list[str], +) -> None: + if role == "coder" or not allowed_files: + return + task_ref = _repo_relative(task_dir, repo_root) + roots = { + "planner": [f"{task_ref}/hermes/analysis"], + "researcher": [f"{task_ref}/research", f"{task_ref}/hermes/research"], + "reviewer": [f"{task_ref}/hermes/reviews"], + "runner": [], + }.get(role, []) + invalid = [ + pattern + for pattern in allowed_files + if not any(pattern == root or pattern.startswith(root + "/") for root in roots) + ] + if invalid: + raise DispatchError( + "role_write_scope", + f"{role} allowed_files may only target its task-scoped record directory", + invalid, + ) + + +def _validate_parent_job( + task_dir: Path, + task: dict[str, Any], + repo_root: Path, + *, + job_id: str, + role: str, + profile: str, + work_package: str | None, + parent_job_id: str | None, +) -> None: + """Bind formal review work to one existing job in the same package.""" + requires_parent = role == "reviewer" and profile not in INDEPENDENT_REVIEWER_PROFILES + if role == "runner" and profile in {"test", "build"}: + requires_parent = True + if role == "runner" and profile == "validation" and parent_job_id is None: + dispatch_dir = task_dir / "hermes" / "dispatches" + for candidate_path in dispatch_dir.glob("*.dispatch.json"): + try: + candidate = json.loads(candidate_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if ( + isinstance(candidate, dict) + and candidate.get("role") == "coder" + and candidate.get("work_package") == work_package + and candidate.get("status") != "superseded" + ): + requires_parent = True + break + if parent_job_id is None: + if requires_parent: + raise DispatchError( + "missing_parent_job", + f"{role}:{profile} dispatch requires parent_job_id for the work being checked", + ) + return + _validate_job_id(parent_job_id) + if parent_job_id == job_id: + raise DispatchError("parent_job_self_reference", "parent_job_id cannot equal job_id") + parent = load_dispatch(task_dir, parent_job_id) + if parent.get("job_id") != parent_job_id: + raise DispatchError("parent_job_invalid", "parent dispatch job_id does not match its record") + expected_task_id = str(task.get("id") or task_dir.name) + if parent.get("task_id") != expected_task_id: + raise DispatchError("parent_job_task_mismatch", "parent dispatch belongs to a different task") + if parent.get("task_path") != _repo_relative(task_dir, repo_root): + raise DispatchError("parent_job_task_mismatch", "parent dispatch path does not match this task") + try: + parent_work_package = _normalize_optional_text(parent.get("work_package")) + except DispatchError as exc: + raise DispatchError("parent_job_invalid", "parent dispatch has an invalid work_package") from exc + if parent_work_package != work_package: + raise DispatchError( + "parent_job_work_package_mismatch", + "parent dispatch must use the same work_package", + ) + + +def _validate_work_package( + task: dict[str, Any], + role: str, + work_package: Any, + *, + allow_task_handoff: bool = False, +) -> None: current = task.get("current_work_package") normalized = _normalize_optional_text(work_package) - if role in EXECUTION_ROLES: + if role in EXECUTION_ROLES and not allow_task_handoff: if not normalized: raise DispatchError("missing_work_package", f"{role} dispatch requires work_package") if normalized != current: @@ -1977,10 +1862,11 @@ def _generated_job_id( role: str, profile: str, work_package: str | None, + parent_job_id: str | None, objective: str, ) -> str: payload = json.dumps( - [task_name, revision, role, profile, work_package, objective], + [task_name, revision, role, profile, work_package, parent_job_id, objective], ensure_ascii=False, separators=(",", ":"), ) diff --git a/packages/cli/src/templates/trellis/scripts/common/firewall.py b/packages/cli/src/templates/trellis/scripts/common/firewall.py index 1ec3aa5..7d5b4a4 100644 --- a/packages/cli/src/templates/trellis/scripts/common/firewall.py +++ b/packages/cli/src/templates/trellis/scripts/common/firewall.py @@ -38,7 +38,7 @@ def record_firewall_heartbeat( job_id: str | None = None, session_id: str | None = None, ) -> bool: - if platform not in SUPPORTED_PLATFORMS or mechanism not in {"hooks", "strict"}: + if platform not in SUPPORTED_PLATFORMS or mechanism != "hooks": return False if mechanism == "hooks" and hooks_disabled(): return False @@ -85,7 +85,7 @@ def firewall_health( continue record_platform = value.get("platform") record_mechanism = value.get("mechanism") - if record_platform not in SUPPORTED_PLATFORMS or record_mechanism not in {"hooks", "strict"}: + if record_platform not in SUPPORTED_PLATFORMS or record_mechanism != "hooks": continue age = (now - timestamp).total_seconds() if timestamp is not None else None if timestamp is None or age is None or age < -5 or age > _heartbeat_ttl(): @@ -124,7 +124,6 @@ def firewall_health( "hard_gate": bool(deduped), "advisory_signals": { "hooks_env": os.environ.get("TRELLIS_HOOKS_ACTIVE") == "1", - "codex_strict_env": os.environ.get("TRELLIS_CODEX_STRICT") == "1", }, } @@ -148,21 +147,11 @@ def closure_mode_gate( session_id=session_id, ) active = health["active"] - if mode == "lean": - if not active: - return [], [ - "Lean context firewall is advisory because no fresh hook or strict heartbeat is active." - ] - return [], [] if active: return [], [] - if mode == "publication": - return [ - "publication requires an active Claude hook or Codex strict context-firewall heartbeat" - ], [] - return [ - "standard requires an active hook heartbeat or Codex strict execution" - ], [] + return [], [ + "No active context-firewall hook was detected; use the validated dispatch and Result Envelope protocol." + ] def _parse_timestamp(value: Any) -> datetime | None: diff --git a/packages/cli/src/templates/trellis/scripts/common/types.py b/packages/cli/src/templates/trellis/scripts/common/types.py index e88139f..95e6b22 100644 --- a/packages/cli/src/templates/trellis/scripts/common/types.py +++ b/packages/cli/src/templates/trellis/scripts/common/types.py @@ -60,6 +60,7 @@ class TaskData(TypedDict, total=False): research_route: str research_change_fields: list[str] grill_completed: bool + decision_ref: str | None constraints: dict work_packages: list[dict] current_work_package: str | None diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index 0811875..68139f4 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -24,7 +24,6 @@ load_sanitized_result, prepare_dispatch_for_agent, result_json_schema, - run_codex_strict, sanitized_summary, supersede_dispatch, validate_dispatch, @@ -43,6 +42,7 @@ def build_parser() -> argparse.ArgumentParser: create.add_argument("--role", required=True) create.add_argument("--profile") create.add_argument("--work-package") + create.add_argument("--parent-job-id") create.add_argument("--objective", required=True) create.add_argument("--ref", action="append", default=[]) create.add_argument("--allowed-file", action="append", default=[]) @@ -71,8 +71,8 @@ def build_parser() -> argparse.ArgumentParser: _task_arg(run) run.add_argument("--job-id", required=True) run.add_argument("--platform", choices=["claude", "codex"], required=True) - run.add_argument("--mode", choices=["native", "strict"], default="native") - run.add_argument("--codex-bin", default="codex") + run.add_argument("--mode", choices=["native", "strict"], default="native", + help="strict is retained as a compatibility alias for native") apply = subparsers.add_parser("apply") _task_arg(apply) @@ -134,6 +134,7 @@ def main(argv: list[str] | None = None) -> int: "role": args.role, "profile": args.profile, "work_package": args.work_package, + "parent_job_id": args.parent_job_id, "objective": args.objective, "refs": args.ref, "allowed_files": args.allowed_file, @@ -169,16 +170,11 @@ def main(argv: list[str] | None = None) -> int: print(dispatch["body"] if args.prompt else json.dumps(dispatch, ensure_ascii=False, indent=2)) return 0 if args.command == "run": - if args.platform == "codex" and args.mode == "strict": - result, warnings = run_codex_strict(task_dir, task, root, args.job_id, codex_bin=args.codex_bin) - for warning in warnings: - print(f"warning: {warning}", file=sys.stderr) - if result is None: - dispatch = load_dispatch(task_dir, args.job_id) - _print({"job_id": args.job_id, "status": "advisory", "prompt": dispatch["body"]}) - else: - _print(sanitized_summary(task_dir, args.job_id)) - return 0 + if args.mode == "strict": + print( + "warning: strict dispatch is retired; using the current project workspace with native dispatch", + file=sys.stderr, + ) dispatch = prepare_dispatch_for_agent( task_dir, task, diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runner.py b/packages/cli/src/templates/trellis/scripts/hermes/runner.py index a47ec01..c9b1c21 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runner.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runner.py @@ -4,10 +4,7 @@ import argparse import hashlib import json -import os import re -import shlex -import shutil import subprocess import sys import time @@ -17,15 +14,12 @@ from runtime import ( add_seconds, append_record, - container_image_error, find_task_card, format_timestamp, make_record_id, now_utc, normalize_path, parse_interval_seconds, - parse_bool, - parse_container_runtime_command, parse_timestamp, read_jsonl, record_path, @@ -35,6 +29,7 @@ run_manifest_path, task_card_error, validate_experiment_config, + validate_data_preflight, validate_run_manifest_records, ) @@ -93,8 +88,13 @@ def heartbeat_record( } -def checkpoint_record(args: argparse.Namespace) -> dict[str, Any]: - return { +def checkpoint_record( + args: argparse.Namespace, + parent_job_id: Any = None, + *, + has_parent_field: bool = False, +) -> dict[str, Any]: + record: dict[str, Any] = { "type": "checkpoint", "id": make_record_id("cp", args.job_id), "timestamp": now_utc(), @@ -104,10 +104,20 @@ def checkpoint_record(args: argparse.Namespace) -> dict[str, Any]: "evidence_refs": [], "open_items": [], } + if has_parent_field: + record["parent_job_id"] = parent_job_id + return record -def result_record(args: argparse.Namespace, manifest_id: str, outputs: list[str]) -> dict[str, Any]: - return { +def result_record( + args: argparse.Namespace, + manifest_id: str, + outputs: list[str], + parent_job_id: Any = None, + *, + has_parent_field: bool = False, +) -> dict[str, Any]: + record: dict[str, Any] = { "type": "result", "id": make_record_id("rs", args.job_id), "timestamp": now_utc(), @@ -119,6 +129,9 @@ def result_record(args: argparse.Namespace, manifest_id: str, outputs: list[str] "risk_flags": [], "handoff": f"run manifest {manifest_id}", } + if has_parent_field: + record["parent_job_id"] = parent_job_id + return record def rejection_record( @@ -178,30 +191,6 @@ def env_summary() -> dict[str, str]: } -def minimal_subprocess_env() -> dict[str, str]: - allowed_names = { - "HOME", - "LANG", - "LC_ALL", - "PATH", - "PYTHONPATH", - "SYSTEMROOT", - "TMPDIR", - "TEMP", - "TMP", - "USERPROFILE", - "VIRTUAL_ENV", - } - env: dict[str, str] = {} - for name in allowed_names: - value = os.environ.get(name) - if value: - env[name] = value - if "PATH" not in env: - env["PATH"] = os.defpath - return env - - def command_display(command: list[str]) -> str: return " ".join(command) @@ -224,61 +213,6 @@ def command_allowed(command: list[str], allowed_commands: Any) -> bool: return False -def sandbox_execution_settings(experiment: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: - sandbox = experiment.get("sandbox", {"mode": "none", "required": False}) - if not isinstance(sandbox, dict): - return {}, ["sandbox must be a mapping"] - mode = sandbox.get("mode", "none") - required = parse_bool(sandbox.get("required", False)) - if required is None: - return {}, ["sandbox.required must be true or false"] - if mode not in {"none", "container", "external"}: - return {}, ["sandbox.mode must be one of none, container, external"] - if required and mode == "none": - return {}, ["sandbox.required=true cannot use mode=none"] - settings: dict[str, Any] = {"mode": mode, "required": required} - if mode == "none": - return settings, [] - - command = sandbox.get("command") - if not isinstance(command, str) or not command.strip(): - command = "docker" if mode == "container" else "" - if not command: - return settings, ["sandbox mode external requires sandbox.command"] - if mode == "container": - runtime_command, command_error = parse_container_runtime_command(command) - if command_error is not None: - return settings, [command_error] - else: - try: - runtime_command = shlex.split(command) - except ValueError as exc: - return settings, [f"sandbox.command is invalid: {exc}"] - if not runtime_command: - return settings, [f"sandbox mode {mode} requires sandbox.command"] - executable = runtime_command[0] - if shutil.which(executable) is None: - return settings, [f"sandbox mode {mode} not available: command {executable} not found"] - settings["runtime_command"] = runtime_command - if mode == "container": - image = sandbox.get("image") - 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, [ - f"sandbox mode {mode} availability was checked, but this runner does not provide an OS sandbox" - ] - - -def sandbox_gate_errors(experiment: dict[str, Any]) -> list[str]: - _, errors = sandbox_execution_settings(experiment) - return errors - - def redact_secret_text(value: str) -> str: secret_name_pattern = re.compile( r"\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*\b" @@ -290,67 +224,6 @@ def manifest_command(command: list[str]) -> list[str]: return [redact_secret_text(part) for part in command] -def container_workdir(root: Path, cwd: Path) -> str: - relative = relative_to_root(root, cwd) - if relative in {"", "."}: - return "/workspace" - return f"/workspace/{relative}" - - -def containerized_command( - command: list[str], - root: Path, - cwd: Path, - sandbox: dict[str, Any], -) -> list[str]: - runtime_command = sandbox.get("runtime_command") - image = sandbox.get("image") - if not isinstance(runtime_command, list) or not all( - isinstance(part, str) and part for part in runtime_command - ): - raise ValueError("container runtime command is not configured") - if not isinstance(image, str) or not image.strip(): - raise ValueError("container image is not configured") - return [ - *runtime_command, - "run", - "--rm", - "-v", - f"{root.resolve(strict=False).as_posix()}:/workspace", - "-w", - container_workdir(root, cwd), - image, - *command, - ] - - -def command_for_execution( - command: list[str], - root: Path, - cwd: Path, - sandbox: dict[str, Any], -) -> list[str]: - if sandbox.get("mode") == "container": - return containerized_command(command, root, cwd, sandbox) - return command - - -def manifest_sandbox(sandbox: dict[str, Any]) -> dict[str, str] | None: - mode = sandbox.get("mode") - if mode == "none": - return None - entry: dict[str, str] = {"mode": str(mode)} - image = sandbox.get("image") - if isinstance(image, str) and image.strip(): - entry["image"] = redact_secret_text(image.strip()) - runtime_command = sandbox.get("runtime_command") - if isinstance(runtime_command, list) and runtime_command: - runtime = runtime_command[0] - if isinstance(runtime, str) and runtime.strip(): - entry["runtime"] = redact_secret_text(Path(runtime).name) - return entry - - def resolve_repo_path(root: Path, base: Path, value: str, label: str) -> tuple[Path | None, str | None]: raw = value.strip() if not raw: @@ -406,11 +279,6 @@ def run_command(args: argparse.Namespace) -> int: for error in experiment_load_errors: print(error, file=sys.stderr) return 1 - sandbox, sandbox_errors = sandbox_execution_settings(experiment) - if sandbox_errors: - for error in sandbox_errors: - print(error, file=sys.stderr) - return 1 if not command_allowed(command, experiment.get("allowed_commands")): print( f"command not allowed by experiment allowed_commands: {command_display(command)}", @@ -424,13 +292,6 @@ def run_command(args: argparse.Namespace) -> int: print("invalid --heartbeat-interval; use values like 30s, 5m, or 1h", file=sys.stderr) return 2 - hermes_dir = worker_path.parent - runs_dir = hermes_dir / "runs" - run_id = make_record_id("run", job_id) - run_dir = runs_dir / run_id - run_dir.mkdir(parents=True, exist_ok=True) - stdout_path = run_dir / "stdout.log" - stderr_path = run_dir / "stderr.log" if args.cwd is None: cwd = root else: @@ -449,14 +310,51 @@ def run_command(args: argparse.Namespace) -> int: for error in path_errors: print(error, file=sys.stderr) return 2 - try: - execution_command = command_for_execution(command, root, cwd, sandbox) - except ValueError as exc: - print(str(exc), file=sys.stderr) + preflight_paths, preflight_errors = validate_data_preflight(exp_path, experiment) + if preflight_errors: + for error in preflight_errors: + print(error, file=sys.stderr) return 1 - + declared_inputs = { + resolved + for value in inputs + for resolved, error in [resolve_repo_path(root, cwd, value, "input")] + if error is None and resolved is not None + } + missing_preflight_inputs = [ + path.relative_to(root).as_posix() + for path in preflight_paths + if path not in declared_inputs + ] + if missing_preflight_inputs: + print( + "data_preflight files must be declared with --input: " + + ", ".join(missing_preflight_inputs), + file=sys.stderr, + ) + return 1 + # Snapshot declared input hashes before process launch. The command may + # legitimately transform an input later, but the manifest must preserve + # the exact pre-run material it received. + input_entries = [path_entry(root, cwd, value, "input") for value in inputs] + hermes_dir = worker_path.parent + runs_dir = hermes_dir / "runs" + run_id = make_record_id("run", job_id) + run_dir = runs_dir / run_id + run_dir.mkdir(parents=True, exist_ok=True) + stdout_path = run_dir / "stdout.log" + stderr_path = run_dir / "stderr.log" started_at = now_utc() - append_record(worker_path, checkpoint_record(args)) + has_parent_field = "parent_job_id" in task_card + parent_job_id = task_card.get("parent_job_id") + append_record( + worker_path, + checkpoint_record( + args, + parent_job_id, + has_parent_field=has_parent_field, + ), + ) append_record(worker_path, heartbeat_record(args.job_id, args.checkpoint, args.summary, interval_seconds)) last_heartbeat = time.monotonic() launch_error: OSError | None = None @@ -464,11 +362,10 @@ def run_command(args: argparse.Namespace) -> int: with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle: try: process = subprocess.Popen( - execution_command, + command, cwd=str(cwd), stdout=stdout_handle, stderr=stderr_handle, - env=minimal_subprocess_env(), ) except OSError as exc: launch_error = exc @@ -499,7 +396,7 @@ def run_command(args: argparse.Namespace) -> int: "command": manifest_command(command), "cwd": relative_to_root(root, cwd), "env_summary": env_summary(), - "inputs": [path_entry(root, cwd, value, "input") for value in inputs], + "inputs": input_entries, "outputs": output_entries + log_entries, "exit_code": exit_code, "started_at": started_at, @@ -507,16 +404,22 @@ def run_command(args: argparse.Namespace) -> int: "checkpoint": args.checkpoint, "resume_from": args.resume_from, } - sandbox_record = manifest_sandbox(sandbox) - if sandbox_record is not None: - manifest_record["sandbox"] = sandbox_record if launch_error is not None: manifest_record["error"] = f"cannot start command: {launch_error}" manifest_path = run_manifest_path(root, task) append_record(manifest_path, manifest_record) if exit_code == 0: - append_record(worker_path, result_record(args, run_id, [entry["path"] for entry in output_entries])) + append_record( + worker_path, + result_record( + args, + run_id, + [entry["path"] for entry in output_entries], + parent_job_id, + has_parent_field=has_parent_field, + ), + ) else: append_record( worker_path, diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index 238f4e5..5dd5e15 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -3,7 +3,6 @@ import json import hashlib import re -import shlex import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -55,6 +54,7 @@ ] CODER_REVIEW_HANDOFFS = {"review", "claim_ready"} TERMINAL_WORKER_RECORD_TYPES = {"result", "rejection", "stalled"} +INDEPENDENT_REVIEWER_PROFILES = {"closure", "safety"} APPROVAL_CHANGE_ID_FIELDS = { "change_id", "change_ids", @@ -97,10 +97,12 @@ "allowed_commands", "artifact_dir", ] +DATA_PREFLIGHT_CHANGE_FIELDS = {"dataset", "split", "preprocessing"} +DATA_PREFLIGHT_CHECKS = ("schema", "missing", "duplicates", "split_leakage") +DATA_PREFLIGHT_STATES = {"checked", "not_applicable"} SECRET_ENV_NAME_PATTERN = re.compile( r"\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*\b" ) -CONTAINER_IMAGE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$") REQUIRED_FIELDS = { "task_card": [ @@ -454,32 +456,6 @@ def is_nonempty_string(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) -def container_image_error(image: Any) -> str | None: - if not isinstance(image, str) or not image.strip(): - return "sandbox mode container requires sandbox.image" - normalized = image.strip() - if normalized.startswith("-"): - return "sandbox.image must be a container image reference, not a docker flag" - if any(char.isspace() for char in normalized): - return "sandbox.image must not contain whitespace" - if CONTAINER_IMAGE_PATTERN.fullmatch(normalized) is None: - return "sandbox.image contains unsupported characters" - return None - - -def parse_container_runtime_command(command: Any) -> tuple[list[str], str | None]: - text = command if isinstance(command, str) and command.strip() else "docker" - try: - runtime_command = shlex.split(text) - except ValueError as exc: - return [], f"sandbox.command is invalid: {exc}" - if len(runtime_command) != 1: - return [], "sandbox.command for container mode must be docker without extra arguments" - if Path(runtime_command[0]).name != "docker": - return [], "sandbox mode container requires docker as sandbox.command" - return runtime_command, None - - def validate_optional_string_list( record: dict[str, Any], field: str, @@ -1196,6 +1172,8 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: if len(entries) > 1: lines = ", ".join(str(entry.line_number) for entry in entries) errors.append(f"duplicate task_card for job_id {job_id}: lines {lines}") + continue + errors.extend(_parent_job_card_errors(entries[0], task_cards)) active_writers: dict[str, list[JsonlRecord]] = {} for job_id, entries in task_cards.items(): @@ -1226,6 +1204,8 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: if record_type == "result" and isinstance(job_id, str): entries = task_cards.get(job_id, []) card = entries[0].value if len(entries) == 1 else None + if card is not None: + errors.extend(_parent_job_result_errors(entry, card)) if card and not is_change_set_allowed(card, value.get("changed_files", [])): errors.append( f"line {entry.line_number}: result changed files violate task_card permissions" @@ -1238,7 +1218,12 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: errors.append( f"line {entry.line_number}: missing checkpoint for job_id {job_id}" ) - if card and card.get("role") == "coder" and is_coder_review_handoff(value): + if ( + card + and card.get("role") == "coder" + and card.get("profile") != "configuration" + and is_coder_review_handoff(value) + ): if not has_related_record( records, task_cards, @@ -1279,6 +1264,131 @@ def is_coder_review_handoff(record: dict[str, Any]) -> bool: ) +def _parent_job_value(value: dict[str, Any]) -> tuple[bool, str | None]: + """Return whether a record uses the new parent field and its valid value.""" + if "parent_job_id" not in value: + return False, None + parent = value.get("parent_job_id") + if parent is None: + return True, None + if isinstance(parent, str) and parent.strip(): + return True, parent.strip() + return True, "" + + +def _parent_job_card_errors( + entry: JsonlRecord, + task_cards: dict[str, list[JsonlRecord]], +) -> list[str]: + card = entry.value + job_id = card.get("job_id") + if not isinstance(job_id, str) or not job_id: + return [] + explicit, parent_job_id = _parent_job_value(card) + role = card.get("role") + profile = card.get("profile") + if not explicit: + return [] + if parent_job_id == "": + return [f"line {entry.line_number}: parent_job_id must be a non-empty string or null"] + if ( + role == "reviewer" + and profile not in INDEPENDENT_REVIEWER_PROFILES + and parent_job_id is None + ): + return [ + f"line {entry.line_number}: reviewer task_card requires parent_job_id unless profile is closure or safety" + ] + if role == "runner" and profile in {"test", "build"} and parent_job_id is None: + return [ + f"line {entry.line_number}: runner {profile} task_card requires parent_job_id" + ] + if role == "runner" and profile == "validation" and parent_job_id is None: + has_coder = any( + len(entries) == 1 + and entries[0].value.get("role") == "coder" + and entries[0].value.get("work_package") == card.get("work_package") + for entries in task_cards.values() + ) + if has_coder: + return [ + f"line {entry.line_number}: runner validation task_card requires parent_job_id when checking coder work" + ] + if parent_job_id is None: + return [] + if parent_job_id == job_id: + return [f"line {entry.line_number}: parent_job_id cannot equal job_id"] + parent_entries = task_cards.get(parent_job_id, []) + if len(parent_entries) != 1: + return [f"line {entry.line_number}: parent_job_id {parent_job_id} has no unique task_card"] + parent = parent_entries[0].value + if parent.get("work_package") != card.get("work_package"): + return [ + f"line {entry.line_number}: parent_job_id {parent_job_id} must use the same work_package" + ] + return [] + + +def _parent_job_result_errors(entry: JsonlRecord, card: dict[str, Any]) -> list[str]: + explicit_card, card_parent = _parent_job_value(card) + explicit_result, result_parent = _parent_job_value(entry.value) + if not explicit_card or not explicit_result: + return [] + if result_parent == "": + return [f"line {entry.line_number}: result parent_job_id must be a non-empty string or null"] + if result_parent != card_parent: + return [f"line {entry.line_number}: result parent_job_id does not match task_card"] + return [] + + +def _legacy_parent_candidates( + task_cards: dict[str, list[JsonlRecord]], + child_job_id: str, + work_package: Any, +) -> set[str]: + candidates: set[str] = set() + for candidate_job_id, entries in task_cards.items(): + if candidate_job_id == child_job_id or len(entries) != 1: + continue + candidate = entries[0].value + if candidate.get("role") == "reviewer": + continue + if candidate.get("work_package") == work_package: + candidates.add(candidate_job_id) + return candidates + + +def _record_matches_parent( + record: dict[str, Any], + card: dict[str, Any], + task_cards: dict[str, list[JsonlRecord]], + parent_job_id: str, +) -> bool: + explicit_card, card_parent = _parent_job_value(card) + explicit_record, record_parent = _parent_job_value(record) + parent_entries = task_cards.get(parent_job_id, []) + if len(parent_entries) != 1: + return False + same_package = parent_entries[0].value.get("work_package") == card.get("work_package") + if explicit_card: + return same_package and card_parent == parent_job_id and ( + not explicit_record or record_parent == card_parent + ) + if explicit_record: + return same_package and record_parent == parent_job_id + child_job_id = card.get("job_id") + if not isinstance(child_job_id, str): + return False + # Old records did not carry parent_job_id. Keep compatibility only when + # same-package ownership has exactly one possible parent; never infer by + # timestamp or result ordering. + return _legacy_parent_candidates( + task_cards, + child_job_id, + card.get("work_package"), + ) == {parent_job_id} + + def has_related_record( records: list[JsonlRecord], task_cards: dict[str, list[JsonlRecord]], @@ -1303,11 +1413,7 @@ def has_related_record( continue if profiles is not None and card.get("profile") not in profiles: continue - if ( - result_job_id == coder_job_id - or card.get("parent_job_id") == coder_job_id - or value.get("parent_job_id") == coder_job_id - ): + if _record_matches_parent(value, card, task_cards, coder_job_id): return True return False @@ -1333,6 +1439,15 @@ def compatibility_warnings(path: Path, kind: str) -> list[str]: seen.add(warning) message = f"line {entry.line_number}: {warning}" warnings.append(message) + role = entry.value.get("role") + if role in {"runner", "reviewer"} and "parent_job_id" not in entry.value: + message = ( + f"line {entry.line_number}: legacy task_card has no parent_job_id; " + "relationship matching is limited to one unambiguous same-work_package candidate" + ) + if message not in seen: + seen.add(message) + warnings.append(message) return warnings @@ -1816,32 +1931,153 @@ def validate_experiment_config(path: Path) -> list[str]: for command in parsed["allowed_commands"] ): return [f"{path}: allowed_commands must be a non-empty list of strings"] - sandbox = parsed.get("sandbox", {"mode": "none", "required": False}) - if not isinstance(sandbox, dict): - return [f"{path}: sandbox must be a mapping"] - sandbox_mode = sandbox.get("mode", "none") - if sandbox_mode not in {"none", "container", "external"}: - return [f"{path}: sandbox.mode must be one of none, container, external"] - sandbox_required = parse_bool(sandbox.get("required", False)) - if sandbox_required is None: - return [f"{path}: sandbox.required must be true or false"] - if sandbox_required and sandbox_mode == "none": - return [f"{path}: sandbox.required=true cannot use mode=none"] - if sandbox_mode == "container": - _, command_error = parse_container_runtime_command(sandbox.get("command")) - if command_error is not None: - return [f"{path}: {command_error}"] - image_error = container_image_error(sandbox.get("image")) - if image_error is not None: - return [f"{path}: {image_error}"] - if sandbox_mode == "external": - command = sandbox.get("command") - if command is not None and not is_nonempty_string(command): - return [f"{path}: sandbox.command must be a non-empty string when present"] if not isinstance(parsed.get("artifact_dir"), str) or not parsed["artifact_dir"].strip(): return [f"{path}: artifact_dir must be a non-empty string"] - return [] + _, preflight_errors = validate_data_preflight(path, parsed) + return preflight_errors + + +def _repo_file(root: Path, value: Any, label: str) -> tuple[Path | None, str | None]: + if not isinstance(value, str) or not value.strip(): + return None, f"{label} must be a non-empty repository-relative path" + normalized = value.strip().replace("\\", "/") + if normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized) or ".." in Path(normalized).parts: + return None, f"{label} must stay inside the repository" + candidate = (root / normalized).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + return None, f"{label} must stay inside the repository" + if not candidate.is_file() or has_symlink_component(candidate): + return None, f"{label} does not exist as a regular repository file: {normalized}" + return candidate, None + + +def _task_requires_data_preflight(path: Path) -> tuple[bool, list[str]]: + task_path = path.parent.parent / "task.json" + if not task_path.is_file(): + return False, [ + f"{path}: task.json is missing; formal experiment validation requires task state" + ] + if has_symlink_component(task_path): + return False, [ + f"{path}: task.json cannot be read through a symlink for formal experiment validation" + ] + try: + content = task_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + return False, [f"{path}: task.json cannot be read: {exc}"] + try: + task = json.loads(content) + except json.JSONDecodeError as exc: + return False, [f"{path}: task.json contains invalid JSON: {exc.msg}"] + if not isinstance(task, dict): + return False, [f"{path}: task.json must be a mapping"] + # Legacy non-closure tasks keep their existing experiment behavior. Only + # closure tasks with an explicit data-changing exploration route require + # data_preflight. + if not any(key in task for key in ("closure_state", "hermes_phase", "work_packages")): + return False, [] + if task.get("research_route") != "exploration": + return False, [] + fields = task.get("research_change_fields") + normalized_fields = { + item.strip().casefold() + for item in fields + if isinstance(item, str) and item.strip() + } if isinstance(fields, list) else set() + return bool(DATA_PREFLIGHT_CHANGE_FIELDS.intersection(normalized_fields)), [] + + +def _load_preflight_checks(path: Path) -> tuple[dict[str, Any], str | None]: + try: + content = path.read_text(encoding="utf-8") + parsed = json.loads(content) if path.suffix.casefold() == ".json" else parse_simple_yaml(content) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + return {}, f"data_preflight.checks_ref cannot be parsed: {exc}" + if not isinstance(parsed, dict): + return {}, "data_preflight.checks_ref must contain a mapping" + checks = parsed.get("checks", parsed) + if not isinstance(checks, dict): + return {}, "data_preflight.checks_ref checks must be a mapping" + return checks, None + + +def validate_data_preflight( + path: Path, + experiment: dict[str, Any], +) -> tuple[list[Path], list[str]]: + required, task_errors = _task_requires_data_preflight(path) + if task_errors: + return [], task_errors + preflight = experiment.get("data_preflight") + if preflight is None: + if required: + return [], [f"{path}: data_preflight is required for exploration changes to dataset, split, or preprocessing"] + return [], [] + if not isinstance(preflight, dict): + return [], [f"{path}: data_preflight must be a mapping"] + + errors: list[str] = [] + for field in ("source", "version"): + if not isinstance(preflight.get(field), str) or not str(preflight[field]).strip(): + errors.append(f"{path}: data_preflight.{field} must be a non-empty string") + + input_manifest = preflight.get("input_manifest") + data_path = preflight.get("data_path") + selected = [ + (name, value) + for name, value in (("input_manifest", input_manifest), ("data_path", data_path)) + if isinstance(value, str) and value.strip() + ] + if len(selected) != 1: + errors.append(f"{path}: data_preflight requires exactly one of input_manifest or data_path") + + root = repo_root(path) + tracked_paths: list[Path] = [] + data_file: Path | None = None + if len(selected) == 1: + field, value = selected[0] + data_file, error = _repo_file(root, value, f"data_preflight.{field}") + if error: + errors.append(f"{path}: {error}") + elif data_file is not None: + tracked_paths.append(data_file) + + expected_hash = preflight.get("hash") + if not isinstance(expected_hash, str) or not is_valid_sha256(expected_hash): + errors.append(f"{path}: data_preflight.hash must be sha256:<64 lowercase hex>") + elif data_file is not None: + actual_hash = sha256_file(data_file) + if actual_hash != expected_hash: + errors.append( + f"{path}: data_preflight hash mismatch for {data_file.relative_to(root).as_posix()}" + ) + + checks_path, checks_error = _repo_file( + root, + preflight.get("checks_ref"), + "data_preflight.checks_ref", + ) + if checks_error: + errors.append(f"{path}: {checks_error}") + elif checks_path is not None: + tracked_paths.append(checks_path) + checks, parse_error = _load_preflight_checks(checks_path) + if parse_error: + errors.append(f"{path}: {parse_error}") + else: + for check in DATA_PREFLIGHT_CHECKS: + raw = checks.get(check) + if isinstance(raw, dict): + raw = raw.get("status") + state = str(raw).strip().casefold() if raw is not None else "" + if state not in DATA_PREFLIGHT_STATES: + errors.append( + f"{path}: data_preflight check {check} must be checked or not_applicable" + ) + return tracked_paths, errors def parse_bool(value: Any) -> bool | None: @@ -1897,9 +2133,6 @@ def write_experiment_skeleton(path: Path, task: str) -> bool: ' shell: "bash"', "allowed_commands:", ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: false", f'artifact_dir: ".trellis/tasks/{task}/hermes/runs"', "", ] diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index f8e3107..c84568b 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -214,7 +214,7 @@ def cmd_start(args: argparse.Namespace) -> int: if is_closure_task(task_data): phase = task_data.get("hermes_phase") if phase == "planning": - errors, _ = validate_closure(task_data) + errors, _ = validate_closure(task_data, full_path) detail = "; ".join(errors[:3]) if errors else "plan has not been validated" print(colored( f"Error: Closure task is not ready: {detail}", @@ -293,34 +293,21 @@ def cmd_start(args: argparse.Namespace) -> int: def cmd_finish(args: argparse.Namespace) -> int: """Clear active task.""" repo_root = get_repo_root() - active = clear_active_task(repo_root) + active = resolve_active_task(repo_root) current = active.task_path if not current: print(colored("No current task set", Colors.YELLOW)) return 0 - # Resolve task.json path before clearing task_json_path = repo_root / current / FILE_TASK_JSON + cleared = clear_active_task(repo_root) + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) - print(f"Source: {active.source}") + print(f"Source: {cleared.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 f2fd9ee..959718d 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -89,13 +89,15 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 +科研协作以证据和事实为先,不以迎合用户结论为目标。主代理应明确重要假设、不确定性和有证据的异议,科研取舍由用户批准。普通可逆任务直接执行;有限不确定性先声明假设再执行;只有改变研究问题、协议或主张边界的高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后再继续。提示词不能代替任务和实验中的结构化记录,也不能因此让每个任务都经过 grill。 + ```bash # 规划与验证 python3 ./.trellis/scripts/closure.py plan --task \ --intent "可观察目标" --done-when "完成条件" # 研究协议分流:探索任务需记录变化字段和已完成 grill python3 ./.trellis/scripts/closure.py route --task --route exploration --research-change hypothesis -python3 ./.trellis/scripts/closure.py grill --task --complete +python3 ./.trellis/scripts/closure.py grill --task --complete --decision-ref design.md python3 ./.trellis/scripts/closure.py validate --task # 每次只处理当前 package @@ -108,10 +110,10 @@ python3 ./.trellis/scripts/closure.py audit --task python3 ./.trellis/scripts/closure.py repair --task python3 ./.trellis/scripts/closure.py close --task -# 正式角色派发:Claude 只传 job_id;Codex strict 由 CLI 执行 +# 正式角色派发:Claude 只传 job_id;Codex 使用同一紧凑派发协议 python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective "任务" python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id -python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex ``` 每轮 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`。 @@ -122,7 +124,9 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - 科研任务按研究协议是否改变分流,不按任务大小。`delivery`(确定性交付)不改变研究协议,等价重构和性能优化留在这里;`execution`(已冻结协议的实验执行)只运行种子或预先批准的参数搜索;`exploration`(探索性研究)记录 hypothesis、model_architecture、dataset、split、preprocessing、objective/loss、metric_definition、baseline、claim_scope 中实际变化的字段,并在 validate 前记录已完成的 grill。新假设或模型功能架构变化属于高风险探索,必须创建独立探索任务(可挂同一父项目),不能伪装成普通 work package。 -Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex native 仅为 advisory,strict 才机械执行和净化结果。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 +新完成的高风险 grill 必须用 `decision_ref`(决策记录引用)指向仓库内或任务内的 `prd.md`(需求文档)、`design.md`(设计文档)等记录。该记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件);事件和 Task Capsule 只保存引用,不注入正文。旧任务只有 `grill_completed=true`(已完成讨论)而没有引用时继续兼容并提示警告。涉及 dataset、split 或 preprocessing 的 exploration 还必须在任务级 `experiment.yaml`(实验配置)填写 `data_preflight`(数据预检);其他 exploration、delivery 和 execution 不增加该步骤。 + +Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 @@ -213,13 +217,22 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 [workflow-state:no_task] -No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +No active task. First analyze every actionable request: intent, scope, observable completion, research-protocol impact, risk, and the smallest useful 1-4 package split. Then ask for task-creation consent before creating any Trellis task. Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +Do not load a planning skill by default. Use brainstorm only for unresolved requirements and grill-me only for a material unresolved research or scope decision. 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] +### 用户入口优先级 + +先根据活动任务、当前工作包、任务胶囊和用户的自然语言请求自动选择规划、执行、审核、交接或收尾流程;不要等待用户输入固定命令。`continue`(继续)、`status`(状态)、`handoff`(交接)、`finish-work`(收尾)和 `start`(启动)只是明确入口或兼容入口,不能绕过任何状态门禁。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 + +技能不按固定数量加载,也不构成一条每次都要执行的流水线。新任务由主代理完成轻量分析和 1–4 个结果型工作包;已有任务的普通补充继续当前工作包,只有范围或研究协议变化才 amend 并重新 validate。主代理负责触发和整合,具体执行按权限下发:`before-dev` 给 coder,测试/实验给 runner,质量/证据/统计/closure 给 reviewer,根因或方法分析给 planner,资料检索给 researcher,规范与 handoff 写入给 `coder:configuration`。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) @@ -238,7 +251,8 @@ Multi-deliverable scope: consider a parent task plus independently verifiable ch Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. -For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +Use three intervention levels: execute ordinary reversible work directly; state material assumptions and proceed under limited uncertainty; pause only for a high-risk research change, then record the approved decision. Evidence conflict or a failed critical assumption means blocked plus an approved amendment. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. A new completed high-risk exploration grill also records a repository- or task-relative decision_ref; prompts never replace this record, and ordinary tasks do not acquire a grill gate. When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; 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 does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional 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 the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, 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 same task-level review-gate set. @@ -252,7 +266,7 @@ Before `task.py start`, record the development strategy decisions in the task do [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. +Analyze every new task, but load `trellis-brainstorm` only for unresolved requirements and `trellis-grill-me` only for a material unresolved research decision. 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. Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. @@ -274,14 +288,15 @@ 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 the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -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`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Test/build/code-validation runners and quality/evidence/claim/statistics reviewers bind `parent_job_id` to the exact job under review; independent experiments, closure, and independent safety review may remain task-level. Only coder edits implementation files; planner, researcher, and reviewer may write only their task-scoped record directories, and runner executes through `runner.py`. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. +Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. 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. +Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. Dispatch prompt starts with `Active task: `. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. Claude review gates are read-only gates. They report PASS / FAIL, blocking issues, and suggested next actions; they do not modify code directly. -If a Claude review gate fails, the main agent repairs the code, then re-runs the same gate before advancing. +If a Claude code review gate fails, the main agent dispatches a bounded coder repair, then re-runs the same gate before advancing. For any enabled `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, and `trellis-merge-review`, count repeated failures per gate and per task. If the same gate blocks the same task more than 3 times in a row, the main agent must briefly report that status to the user, re-check whether the requirements have drifted, and ask whether to skip the current review gate. If the chosen strategy is `subagent + worktree`, all code-development subagents must use the same `./.trellis/trellis-worktrees/` path. If the chosen strategy is TDD, align implementation and review expectations to `trellis-tdd`. diff --git a/packages/cli/src/utils/codex-config.ts b/packages/cli/src/utils/codex-config.ts new file mode 100644 index 0000000..515786d --- /dev/null +++ b/packages/cli/src/utils/codex-config.ts @@ -0,0 +1,517 @@ +export const CODEX_CONFIG_BLOCK_START = "# TRELLIS:CODEX_CONFIG:START"; +export const CODEX_CONFIG_BLOCK_END = "# TRELLIS:CODEX_CONFIG:END"; +export const CODEX_MODEL_BLOCK_START = "# TRELLIS:CODEX_MODEL_DEFAULTS:START"; +export const CODEX_MODEL_BLOCK_END = "# TRELLIS:CODEX_MODEL_DEFAULTS:END"; +export const CODEX_TABLES_BLOCK_START = "# TRELLIS:CODEX_TABLES:START"; +export const CODEX_TABLES_BLOCK_END = "# TRELLIS:CODEX_TABLES:END"; + +const LEGACY_MODEL_LINE = /^\s*model\s*=\s*"gpt-5\.6-sol"\s*$/; +const LEGACY_REASONING_LINE = /^\s*model_reasoning_effort\s*=\s*"high"\s*$/; +const MODEL_ASSIGNMENT = /^\s*model\s*=/; +const REASONING_ASSIGNMENT = /^\s*model_reasoning_effort\s*=/; +const TABLE_HEADER = /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/; + +export interface CodexConfigMergeResult { + content: string; + changed: boolean; + migratedLegacyDefaults: boolean; +} + +interface BlockRange { + start: number; + end: number; +} + +interface TopLevelModelAssignment { + index: number; + line: string; + kind: "model" | "reasoning"; +} + +function markerRange( + content: string, + startMarker: string, + endMarker: string, +): BlockRange | null { + const start = content.indexOf(startMarker); + if ( + start < 0 || + content.indexOf(startMarker, start + startMarker.length) !== -1 + ) { + return null; + } + const endStart = content.indexOf(endMarker, start + startMarker.length); + if ( + endStart < 0 || + content.indexOf(endMarker, endStart + endMarker.length) !== -1 + ) { + return null; + } + return { start, end: endStart + endMarker.length }; +} + +function hasAnyTrellisMarker(content: string): boolean { + return [ + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ].some((marker) => content.includes(marker)); +} + +function replaceRange( + content: string, + range: BlockRange, + replacement: string, +): string { + return content.slice(0, range.start) + replacement + content.slice(range.end); +} + +function topLevelModelAssignments(content: string): TopLevelModelAssignment[] { + const assignments: TopLevelModelAssignment[] = []; + let inTable = false; + + for (const [index, line] of content.split(/\r?\n/).entries()) { + if (TABLE_HEADER.test(line)) { + inTable = true; + continue; + } + if (inTable) continue; + if (MODEL_ASSIGNMENT.test(line)) { + assignments.push({ index, line, kind: "model" }); + } else if (REASONING_ASSIGNMENT.test(line)) { + assignments.push({ index, line, kind: "reasoning" }); + } + } + + return assignments; +} + +function topLevelLegacyDefaultLines(content: string): { + model: number; + reasoning: number; +} | null { + const assignments = topLevelModelAssignments(content); + if (assignments.length !== 2) return null; + + const model = assignments.find( + (assignment) => + assignment.kind === "model" && LEGACY_MODEL_LINE.test(assignment.line), + ); + const reasoning = assignments.find( + (assignment) => + assignment.kind === "reasoning" && + LEGACY_REASONING_LINE.test(assignment.line), + ); + + return model && reasoning + ? { model: model.index, reasoning: reasoning.index } + : null; +} + +function customTopLevelModelLines(content: string): string[] | null { + const assignments = topLevelModelAssignments(content); + if (assignments.length === 0 || topLevelLegacyDefaultLines(content)) { + return null; + } + return assignments.map((assignment) => assignment.line); +} + +function modelBlock(template: string): string | null { + const range = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range ? template.slice(range.start, range.end) : null; +} + +function buildModelBlock(lines: string[]): string { + return [CODEX_MODEL_BLOCK_START, ...lines, CODEX_MODEL_BLOCK_END].join("\n"); +} + +function preserveCustomModelBlock(template: string, source: string): string { + const customLines = customTopLevelModelLines(source); + if (!customLines) return template; + + const range = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range + ? replaceRange(template, range, buildModelBlock(customLines)) + : template; +} + +function removeModelBlock(content: string): string { + const range = markerRange( + content, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range ? replaceRange(content, range, "") : content; +} + +function migrateLegacyDefaults( + existing: string, + template: string, +): string | null { + const defaults = topLevelLegacyDefaultLines(existing); + const replacement = modelBlock(template); + if (!defaults || !replacement) return null; + + const lines = existing.split(/\r?\n/); + const insertAt = Math.min(defaults.model, defaults.reasoning); + const remove = new Set([defaults.model, defaults.reasoning]); + const result: string[] = []; + for (const [index, line] of lines.entries()) { + if (index === insertAt) { + result.push(replacement); + } + if (!remove.has(index)) { + result.push(line); + } + } + return result.join("\n"); +} + +function mergeWholeConfigBlock( + existing: string, + template: string, +): string | null { + const current = markerRange( + existing, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + if (!current || !replacement) return null; + + const existingBlock = existing.slice(current.start, current.end); + const existingPrefix = existing.slice(0, current.start); + const hasUserModelBeforeBlock = + topLevelModelAssignments(existingPrefix).length > 0; + let nextBlock = template.slice(replacement.start, replacement.end); + + if (hasUserModelBeforeBlock) { + nextBlock = removeModelBlock(nextBlock); + } else { + nextBlock = preserveCustomModelBlock(nextBlock, existingBlock); + } + + const legacyWholeBlock = existingBlock + .split(/\r?\n/) + .some((line) => TABLE_HEADER.test(line)); + const tables = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (legacyWholeBlock && tables) { + nextBlock += `\n\n${template.slice(tables.start, tables.end)}`; + } + return replaceRange(existing, current, nextBlock); +} + +function mergeTablesBlock(existing: string, template: string): string { + const current = markerRange( + existing, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!current || !replacement) return existing; + return replaceRange( + existing, + current, + managedTablesForExisting(template, existing), + ); +} + +function tableHeaders(content: string): Set { + return new Set( + content + .split(/\r?\n/) + .filter((line) => TABLE_HEADER.test(line)) + .map((line) => line.trim()), + ); +} + +function managedTablesForExisting(template: string, existing: string): string { + const templateRange = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!templateRange) return ""; + const currentRange = markerRange( + existing, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + const external = currentRange + ? replaceRange(existing, currentRange, "") + : existing; + const existingHeaders = tableHeaders(external); + if (existingHeaders.size === 0) { + return template.slice(templateRange.start, templateRange.end); + } + + const lines = template + .slice(templateRange.start, templateRange.end) + .split(/\r?\n/); + const sections: string[][] = []; + for (let index = 0; index < lines.length; index += 1) { + if (!TABLE_HEADER.test(lines[index])) continue; + let end = index + 1; + while ( + end < lines.length && + !TABLE_HEADER.test(lines[end]) && + lines[end] !== CODEX_TABLES_BLOCK_END + ) { + end += 1; + } + if (!existingHeaders.has(lines[index].trim())) { + sections.push(lines.slice(index, end)); + } + index = end - 1; + } + return [ + CODEX_TABLES_BLOCK_START, + ...sections.flat(), + CODEX_TABLES_BLOCK_END, + ].join("\n"); +} + +function firstTableLine(lines: string[]): number { + const index = lines.findIndex((line) => TABLE_HEADER.test(line)); + return index < 0 ? lines.length : index; +} + +function withoutProjectDocDefault(block: string): string { + return block + .split(/\r?\n/) + .filter((line) => !/^\s*project_doc_fallback_filenames\s*=/.test(line)) + .join("\n"); +} + +function mergeUnmarkedConfig( + existing: string, + template: string, +): string | null { + const topRange = markerRange( + template, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + const tablesRange = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!topRange || !tablesRange) return null; + + const lines = existing.split(/\r?\n/); + const tableAt = firstTableLine(lines); + const userTop = lines.slice(0, tableAt).join("\n").trim(); + const userTables = lines.slice(tableAt).join("\n").trim(); + let managedTop = template.slice(topRange.start, topRange.end); + if (topLevelModelAssignments(userTop).length > 0) { + managedTop = removeModelBlock(managedTop); + } + if (/^\s*project_doc_fallback_filenames\s*=/m.test(userTop)) { + managedTop = withoutProjectDocDefault(managedTop); + } + const managedTables = managedTablesForExisting(template, existing); + return ( + [userTop, managedTop.trim(), userTables, managedTables.trim()] + .filter(Boolean) + .join("\n\n") + "\n" + ); +} + +function mergeModelDefaultsBlock( + existing: string, + template: string, +): string | null { + const current = markerRange( + existing, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + if (!current || !replacement) return null; + + const existingPrefix = existing.slice(0, current.start); + const hasUserModelBeforeBlock = + topLevelModelAssignments(existingPrefix).length > 0; + const nextBlock = hasUserModelBeforeBlock + ? "" + : preserveCustomModelBlock( + template.slice(replacement.start, replacement.end), + existing.slice(current.start, current.end), + ); + + return replaceRange(existing, current, nextBlock); +} + +/** + * Preserve content outside Trellis markers. A user-selected model inside a + * managed block survives template updates. An old unmarked exact default pair + * is migrated in place; other unmarked configuration is left untouched. + */ +export function mergeTrellisCodexConfig( + existing: string, + template: string, +): CodexConfigMergeResult { + const wholeBlock = mergeWholeConfigBlock(existing, template); + if (wholeBlock !== null) { + const merged = mergeTablesBlock(wholeBlock, template); + return { + content: merged, + changed: merged !== existing, + migratedLegacyDefaults: false, + }; + } + + const defaultsBlock = mergeModelDefaultsBlock(existing, template); + if (defaultsBlock !== null) { + return { + content: defaultsBlock, + changed: defaultsBlock !== existing, + migratedLegacyDefaults: false, + }; + } + + if (hasAnyTrellisMarker(existing)) { + return { content: existing, changed: false, migratedLegacyDefaults: false }; + } + + const migrated = migrateLegacyDefaults(existing, template); + if (migrated !== null) { + return { + content: migrated, + changed: migrated !== existing, + migratedLegacyDefaults: true, + }; + } + const merged = mergeUnmarkedConfig(existing, template); + return { + content: merged ?? existing, + changed: merged !== null && merged !== existing, + migratedLegacyDefaults: false, + }; +} + +export function isLegacyCodexDefaultMigration( + existing: string, + candidate: string, +): boolean { + const migrated = migrateLegacyDefaults(existing, candidate); + return migrated !== null && migrated === candidate; +} + +/** True when an update candidate only replaces Trellis-managed configuration. */ +export function isTrellisCodexConfigMerge( + existing: string, + candidate: string, +): boolean { + return ( + stripTrellisCodexConfig(existing) === stripTrellisCodexConfig(candidate) || + isLegacyCodexDefaultMigration(existing, candidate) + ); +} + +function removeLegacyDefaultLines(content: string): string { + const defaults = topLevelLegacyDefaultLines(content); + if (!defaults) return content; + const remove = new Set([defaults.model, defaults.reasoning]); + return content + .split(/\r?\n/) + .filter((_line, index) => !remove.has(index)) + .join("\n"); +} + +function customModelLinesFromBlock(content: string): string { + return customTopLevelModelLines(content)?.join("\n") ?? ""; +} + +/** + * Remove Trellis configuration while preserving a user-selected model. Older + * unmarked files lose only the exact legacy default model pair. + */ +export function stripTrellisCodexConfig(content: string): string { + const wholeBlock = markerRange( + content, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + let result = content; + + if (wholeBlock) { + result = replaceRange( + result, + wholeBlock, + customModelLinesFromBlock( + content.slice(wholeBlock.start, wholeBlock.end), + ), + ); + } else { + const defaultsBlock = markerRange( + result, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + if (defaultsBlock) { + result = replaceRange( + result, + defaultsBlock, + customModelLinesFromBlock( + result.slice(defaultsBlock.start, defaultsBlock.end), + ), + ); + } + } + + const tablesBlock = markerRange( + result, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (tablesBlock) { + result = replaceRange(result, tablesBlock, ""); + } + + result = removeLegacyDefaultLines(result); + const lines = result.split(/\r?\n/); + const compacted: string[] = []; + let previousBlank = true; + for (const line of lines) { + const blank = line.trim().length === 0; + if (blank && previousBlank) continue; + compacted.push(line); + previousBlank = blank; + } + while ( + compacted.length > 0 && + compacted[compacted.length - 1].trim() === "" + ) { + compacted.pop(); + } + return compacted.length > 0 ? `${compacted.join("\n")}\n` : ""; +} diff --git a/packages/cli/src/utils/codex-version.ts b/packages/cli/src/utils/codex-version.ts new file mode 100644 index 0000000..0748f5d --- /dev/null +++ b/packages/cli/src/utils/codex-version.ts @@ -0,0 +1,109 @@ +import { execFileSync } from "node:child_process"; + +export const MIN_CODEX_VERSION = "0.144.0"; + +export interface CodexVersionProbe { + status: "available" | "missing" | "unreadable"; + raw: string | null; + version: string | null; + supported: boolean; +} + +export type CodexVersionRunner = () => string; + +const VERSION_PATTERN = + /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?)/; + +function defaultRunner(): string { + return String( + execFileSync("codex", ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }), + ); +} + +export function parseCodexVersion(output: string): string | null { + const match = output.match(VERSION_PATTERN); + return match?.[1] ?? null; +} + +function isSupportedCodexVersion(version: string): boolean { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) return false; + const current = [Number(match[1]), Number(match[2]), Number(match[3])]; + const minimum = [0, 144, 0]; + for (let index = 0; index < current.length; index += 1) { + if (current[index] > minimum[index]) return true; + if (current[index] < minimum[index]) return false; + } + return !match[4]; +} + +/** Probe `codex --version` without shelling out or contacting a network. */ +export function probeCodexVersion( + runner: CodexVersionRunner = defaultRunner, +): CodexVersionProbe { + try { + const raw = runner().trim(); + const version = parseCodexVersion(raw); + return { + status: version ? "available" : "unreadable", + raw: raw || null, + version, + supported: version ? isSupportedCodexVersion(version) : false, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { + status: code === "ENOENT" ? "missing" : "unreadable", + raw: null, + version: null, + supported: false, + }; + } +} + +let cachedProbe: CodexVersionProbe | undefined; + +/** Cache the host probe for one CLI process so configuration never probes per turn. */ +export function getCodexVersion(): CodexVersionProbe { + cachedProbe ??= probeCodexVersion(); + return cachedProbe; +} + +export function resetCodexVersionProbeForTests(): void { + cachedProbe = undefined; +} + +export function codexVersionWarning(probe: CodexVersionProbe): string | null { + if (probe.status === "missing") { + return ( + "Codex was not found on PATH. " + + `Codex ${MIN_CODEX_VERSION} or newer is needed to recognize Trellis model and reasoning-effort defaults.` + ); + } + if (probe.status === "unreadable") { + return ( + "Codex version could not be read. " + + `Codex ${MIN_CODEX_VERSION} or newer is needed to recognize Trellis model and reasoning-effort defaults.` + ); + } + if (!probe.supported) { + return ( + `Codex ${probe.version} is below ${MIN_CODEX_VERSION}. ` + + "Trellis will continue, but this Codex version may not recognize the configured model and reasoning-effort defaults." + ); + } + return null; +} + +export function warnCodexVersion(): void { + if (process.env.VITEST || process.env.TRELLIS_QUIET) return; + const warning = codexVersionWarning(getCodexVersion()); + if (warning) { + process.stderr.write(`Warning: ${warning}\n`); + } +} diff --git a/packages/cli/src/utils/uninstall-scrubbers.ts b/packages/cli/src/utils/uninstall-scrubbers.ts index e654bfe..241bd7d 100644 --- a/packages/cli/src/utils/uninstall-scrubbers.ts +++ b/packages/cli/src/utils/uninstall-scrubbers.ts @@ -12,6 +12,120 @@ * does not matter — we just look for the manifest-relative file path. */ +import { stripTrellisCodexConfig } from "./codex-config.js"; + +const LEGACY_CODEX_COMMENTS = new Set([ + "Project-scoped Codex defaults for Trellis workflows.", + "Codex loads this after ~/.codex/config.toml when you work in this project.", + "Codex merges this layer after the user-level config when the project", + "is marked as a trusted project. To trust this project, add it under", + "`[projects]` in ~/.codex/config.toml, e.g.:", + '[projects."/abs/path/to/this/repo"]', + 'trust_level = "trusted"', + "Without trust, the [features] block below is loaded but disabled.", + "Default coordinator model for this research workflow.", + "Default coordinator model for this research workflow. Codex 0.144.0 or", + "newer is required to recognize these model and reasoning-effort values.", + "Keep AGENTS.md as the primary project instruction file.", + "NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.", + "Add the following to your USER-level config at ~/.codex/config.toml", + "(not this project file - features.* must be enabled globally):", + "[features]", + "hooks = true", + "codex_hooks = true", + "Without this flag, hooks.json is ignored and Trellis context won't", + "be injected into Codex sessions.", + "Codex hooks (`hooks.json` in this directory) only fire when the user", + "has enabled them in their USER-level config: `[features].hooks = true`", + "in ~/.codex/config.toml (Codex 0.129+; legacy name: `codex_hooks = true`,", + "still works but emits a deprecation warning on 0.129+). Project-level", + "config.toml cannot set feature flags; they must be user-level.", + "Codex 0.129+ additionally gates each installed hook behind a one-time", + "`/hooks` TUI review; until the user approves it, the hook stays inactive.", + "multi_agent_v2 forces structured subagent orchestration with the", + "instead of cancelling / re-spawning. Incompatible with", + "[agents].max_threads (codex will reject the combination).", + "NOT auto-enable the feature without it.", + "report progress through its task record instead of holding the coordinator.", + "short for Trellis subagents that routinely take 2-10 min. Hard", + "ceiling is 3,600,000 (1 h).", + "Native custom-agent dispatch cannot mechanically replace or validate the", + "agent prompt/output, so its Context Firewall authority is advisory only.", + "Use the generated dispatch CLI strict wrapper for enforced Hermes work:", + "python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict", + "Native custom-agent dispatch uses the validated Hermes dispatch and Result", + "Envelope files. It works directly in the current project workspace.", +]); + +const LEGACY_CODEX_TABLE_VALUES = new Map>([ + [ + "[features.multi_agent_v2]", + new Set([ + "enabled = true", + "max_concurrent_threads_per_session = 6", + "min_wait_timeout_ms = 480000", + "max_concurrent_threads_per_session = 3", + "min_wait_timeout_ms = 120000", + ]), + ], + [ + "[hermes.context_firewall]", + new Set([ + 'native_authority = "advisory"', + 'strict_authority = "enforced"', + 'strict_runtime = "codex exec --output-schema --json -o"', + 'native_authority = "protocol"', + ]), + ], +]); + +function codexCommentText(line: string): string | null { + const trimmed = line.trim(); + return trimmed.startsWith("#") ? trimmed.replace(/^#+\s?/, "").trim() : null; +} + +function isLegacyCodexComment(comment: string | null): boolean { + if (comment === null) return false; + return ( + comment === "" || + LEGACY_CODEX_COMMENTS.has(comment) || + comment.startsWith("(not this project file") || + comment.startsWith("`wait` tool") || + comment.startsWith("`enabled = true`") || + comment.startsWith("- max_concurrent_threads_per_session:") || + comment.startsWith("- min_wait_timeout_ms:") + ); +} + +function legacyCodexTableEnd(lines: string[], start: number): number | null { + const allowedValues = LEGACY_CODEX_TABLE_VALUES.get(lines[start].trim()); + if (!allowedValues) return null; + + let end = start + 1; + let hasValue = false; + while ( + end < lines.length && + !/^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/.test(lines[end]) + ) { + const trimmed = lines[end].trim(); + if (trimmed.length === 0) { + end += 1; + continue; + } + const comment = codexCommentText(lines[end]); + if (comment !== null) { + if (!isLegacyCodexComment(comment)) return null; + } else if (allowedValues.has(trimmed)) { + hasValue = true; + } else { + return null; + } + end += 1; + } + + return hasValue ? end : null; +} + export interface ScrubResult { content: string; fullyEmpty: boolean; @@ -305,73 +419,39 @@ export function scrubPiSettings(content: string): ScrubResult { /** * Scrub `.codex/config.toml`. * - * The current trellis-emitted file has two distinct chunks: - * 1. The line `project_doc_fallback_filenames = ["AGENTS.md"]` - * 2. A multi-line comment block that begins with the marker - * `# NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.` - * and continues through `# be injected into Codex sessions.` - * - * Plus the leading "Project-scoped Codex defaults" header comments. - * - * Strategy: line-based removal. We strip: - * - the `project_doc_fallback_filenames = ...` line - * - any line that is *only* a comment introduced by trellis (the entire file - * as shipped is comments + that one assignment) - * - blank lines that surrounded those removals - * - * If the user added their own non-trellis lines, they are preserved as-is. - * "Fully empty" = post-scrub content has no non-whitespace characters. + * Explicit Trellis marker blocks are removed as units. A custom model inside + * those blocks is retained. Older unmarked Trellis defaults are recognized by + * their exact values, so user-owned configuration stays intact. */ export function scrubCodexConfigToml(content: string): ScrubResult { - const trellisCommentMarkers = [ - "Project-scoped Codex defaults for Trellis workflows.", - "Codex loads this after ~/.codex/config.toml when you work in this project.", - "Keep AGENTS.md as the primary project instruction file.", - "NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.", - "Add the following to your USER-level config at ~/.codex/config.toml", - "(not this project file — features.* must be enabled globally):", - "[features]", - "hooks = true", - "codex_hooks = true", - "Without this flag, hooks.json is ignored and Trellis context won't", - "be injected into Codex sessions.", - ]; + const stripped = stripTrellisCodexConfig(content); + const lines = stripped.split(/\r?\n/); + const kept: string[] = []; + let previousBlank = true; + for (let index = 0; index < lines.length; index += 1) { + const tableEnd = legacyCodexTableEnd(lines, index); + if (tableEnd !== null) { + index = tableEnd - 1; + continue; + } - // A comment line is "trellis-known" if its content (after `#` and spaces) - // matches one of the known marker strings exactly OR is an empty `#` line. - function isTrellisCommentLine(line: string): boolean { + const line = lines[index]; const trimmed = line.trim(); - if (!trimmed.startsWith("#")) return false; - const inner = trimmed.replace(/^#+\s?/, "").trim(); - if (inner.length === 0) return true; // bare `#` line inside trellis block - return trellisCommentMarkers.some((m) => inner === m); - } - - function isTrellisAssignment(line: string): boolean { - return /^\s*project_doc_fallback_filenames\s*=/.test(line); - } - - const out: string[] = []; - let prevWasBlank = true; // start-of-file counts as blank for collapsing - - for (const rawLine of content.split(/\r?\n/)) { - if (isTrellisAssignment(rawLine) || isTrellisCommentLine(rawLine)) { - continue; // drop - } - const isBlank = rawLine.trim().length === 0; - if (isBlank && prevWasBlank) { - continue; // collapse runs of blanks created by removals - } - out.push(rawLine); - prevWasBlank = isBlank; + const comment = codexCommentText(line); + const trellisLegacyLine = + /^\s*project_doc_fallback_filenames\s*=\s*\[\s*"AGENTS\.md"\s*\]\s*$/.test( + line, + ) || isLegacyCodexComment(comment); + if (trellisLegacyLine) continue; + const blank = trimmed.length === 0; + if (blank && previousBlank) continue; + kept.push(line); + previousBlank = blank; } - - // Trim trailing blank lines. - while (out.length > 0 && out[out.length - 1].trim().length === 0) { - out.pop(); + while (kept.length > 0 && kept[kept.length - 1].trim() === "") { + kept.pop(); } - - const result = out.length > 0 ? out.join("\n") + "\n" : ""; + const result = kept.length > 0 ? `${kept.join("\n")}\n` : ""; const fullyEmpty = result.trim().length === 0; return { content: result, fullyEmpty }; } diff --git a/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts index fb38e3d..4e352b2 100644 --- a/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts +++ b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts @@ -42,8 +42,41 @@ describe("empty repository bootstrap closure", () => { fs.writeFileSync(path.join(project, "RESEARCH_PLAN.md"), "# Research Plan\n\nMethod: run one fixed sample.\n"); fs.writeFileSync(path.join(project, "CONSTRAINTS.md"), "# Constraints\n\nLimit: keep the first task small.\n"); + const bootstrapPrd = path.join( + root, + ".trellis", + "tasks", + "00-bootstrap-guidelines", + "prd.md", + ); + fs.appendFileSync( + bootstrapPrd, + [ + "", + "## Decision", + "Verify one agreed sample before expanding the project.", + "", + "## Rationale", + "A fixed sample provides the smallest observable result.", + "", + "## Evidence", + "The project contract records the agreed sample and constraints.", + "", + "## Alternatives", + "Build a larger evaluation workflow first.", + "", + "## Failure Conditions", + "The sample cannot be reproduced under the recorded constraints.", + "", + ].join("\n"), + "utf-8", + ); + const closure = (args: string[]) => run(root, "closure.py", args); - expect(closure(["grill", "--task", "00-bootstrap-guidelines", "--complete"]).status).toBe(0); + expect(closure([ + "grill", "--task", "00-bootstrap-guidelines", "--complete", + "--decision-ref", "prd.md", + ]).status).toBe(0); expect(closure(["validate", "--task", "00-bootstrap-guidelines"]).status).toBe(0); expect(closure(["package-start", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); expect(closure(["package-check", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); diff --git a/packages/cli/test/commands/init.integration.test.ts b/packages/cli/test/commands/init.integration.test.ts index ad32d5b..20bd333 100644 --- a/packages/cli/test/commands/init.integration.test.ts +++ b/packages/cli/test/commands/init.integration.test.ts @@ -30,7 +30,7 @@ import { init } from "../../src/commands/init.js"; import { VERSION } from "../../src/constants/version.js"; import { DIR_NAMES, FILE_NAMES, PATHS } from "../../src/constants/paths.js"; import { collectPlatformTemplates } from "../../src/configurators/index.js"; -import { computeHash } from "../../src/utils/template-hash.js"; +import { computeHash, loadHashes } from "../../src/utils/template-hash.js"; import { execSync } from "node:child_process"; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -324,6 +324,12 @@ describe("init() integration", () => { expect(fs.existsSync(path.join(tmpDir, ".github", "copilot"))).toBe(false); expect(fs.existsSync(path.join(tmpDir, ".factory"))).toBe(false); expect(fs.existsSync(path.join(tmpDir, ".pi"))).toBe(false); + const claudeEntry = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(claudeEntry.length).toBeLessThan(300); + expect(claudeEntry).toContain("AGENTS.md"); + expect(claudeEntry).toContain(".trellis/project/PROJECT_INDEX.md"); + expect(claudeEntry).toContain("Task Capsule"); + expect(claudeEntry).toContain("HANDOFF.md"); expect( fs.existsSync( path.join(tmpDir, ".claude", "skills", "trellis-meta", "SKILL.md"), @@ -342,6 +348,16 @@ describe("init() integration", () => { ).toBe(true); }); + it("preserves an existing root CLAUDE.md even during force init", async () => { + const existing = "# Team Claude instructions\n\nKeep this file.\n"; + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), existing, "utf-8"); + + await init({ yes: true, claude: true, force: true }); + + expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(existing); + expect(loadHashes(tmpDir)).not.toHaveProperty("CLAUDE.md"); + }); + it("#3 multi platform creates all selected platform directories", async () => { await init({ yes: true, claude: true, cursor: true, opencode: true }); diff --git a/packages/cli/test/commands/release-preflight.test.ts b/packages/cli/test/commands/release-preflight.test.ts index 4f493bb..d39b693 100644 --- a/packages/cli/test/commands/release-preflight.test.ts +++ b/packages/cli/test/commands/release-preflight.test.ts @@ -24,7 +24,13 @@ const packedCliRequiredFiles = [ "package/dist/templates/trellis/scripts/hermes/experiment.py", "package/dist/templates/trellis/scripts/hermes/runner.py", "package/dist/templates/trellis/scripts/hermes/report.py", + "package/dist/templates/trellis/scripts/common/closure.py", + "package/dist/templates/trellis/scripts/common/dispatch.py", + "package/dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "package/dist/templates/shared-hooks/hermes-runtime-guard.py", + "package/dist/templates/shared-hooks/inject-workflow-state.py", + "package/dist/templates/common/commands/status.md", + "package/dist/templates/common/commands/handoff.md", ]; const packedCoreRequiredFiles = [ @@ -290,6 +296,10 @@ describe("release-preflight verify-packed-cli", () => { missingFile: "package/dist/templates/trellis/scripts/hermes/runner.py", }, { label: "COPYRIGHT", missingFile: "package/COPYRIGHT" }, + { + label: "the handoff command template", + missingFile: "package/dist/templates/common/commands/handoff.md", + }, ])("fails when packed CLI is missing $label", ({ missingFile }) => { const cliPkg = JSON.parse(fs.readFileSync(cliPkgPath, "utf-8")) as { name: string; diff --git a/packages/cli/test/commands/uninstall.integration.test.ts b/packages/cli/test/commands/uninstall.integration.test.ts index 978f7bc..6852ef5 100644 --- a/packages/cli/test/commands/uninstall.integration.test.ts +++ b/packages/cli/test/commands/uninstall.integration.test.ts @@ -201,6 +201,17 @@ describe("uninstall() integration", () => { expect(fs.existsSync(userHookDir)).toBe(true); }); + it("preserves a project-owned root CLAUDE.md on uninstall", async () => { + const existing = "# Project-owned Claude rules\n"; + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), existing, "utf-8"); + await init({ yes: true, claude: true, force: true }); + expect(loadHashes(tmpDir)).not.toHaveProperty("CLAUDE.md"); + + await uninstall({ yes: true }); + + expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(existing); + }); + it("#8a empty managed sub-dirs and root dir are pruned (kilo: no structured config)", async () => { // Kilo has no hooks.json/settings.json/config.toml/package.json — every // manifest file is opaque and gets deleted, so the entire .kilocode/ diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index d1da0e6..da02b6e 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -345,6 +345,51 @@ describe("update() integration", () => { expect(fs.readFileSync(targetFull, "utf-8")).toBe(templateContent); }); + it("updates managed Codex settings without resetting a user model", async () => { + await init({ yes: true, force: true, codex: true }); + + const configRelative = ".codex/config.toml"; + const configPath = projectFile(configRelative); + const customized = readProjectFile(configRelative) + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + ); + fs.writeFileSync(configPath, customized); + + await update({}); + + const updated = fs.readFileSync(configPath, "utf-8"); + expect(updated).toContain('model = "gpt-5.6-custom"'); + expect(updated).toContain("max_concurrent_threads_per_session = 3"); + expect(readHashesV2(hashFilePath())[configRelative]).toBe( + computeHash(updated), + ); + }); + + it("migrates the legacy Codex default model pair during update", async () => { + await init({ yes: true, force: true, codex: true }); + + const configRelative = ".codex/config.toml"; + const configPath = projectFile(configRelative); + const legacy = readProjectFile(configRelative) + .replace("# TRELLIS:CODEX_CONFIG:START\n", "") + .replace("\n# TRELLIS:CODEX_CONFIG:END", "") + .replace("# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", "") + .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", "") + .replace("# TRELLIS:CODEX_TABLES:START\n", "") + .replace("\n# TRELLIS:CODEX_TABLES:END", ""); + fs.writeFileSync(configPath, legacy); + + await update({}); + + const migrated = fs.readFileSync(configPath, "utf-8"); + expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:START"); + expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + expect(migrated).not.toContain("# TRELLIS:CODEX_CONFIG:START"); + }); + it("#4b auto-updates Claude implement agent when the installed template is older but unmodified", async () => { await init({ yes: true, force: true, claude: true }); @@ -365,6 +410,18 @@ describe("update() integration", () => { expect(fs.readFileSync(targetFull, "utf-8")).toBe(templateContent); }); + it("preserves an untracked root CLAUDE.md during force update", async () => { + const existing = "# Project-owned Claude rules\n"; + writeProjectFile("CLAUDE.md", existing); + await init({ yes: true, force: true, claude: true }); + expect(readHashesV2(hashFilePath())).not.toHaveProperty("CLAUDE.md"); + + await update({ force: true }); + + expect(readProjectFile("CLAUDE.md")).toBe(existing); + expect(readHashesV2(hashFilePath())).not.toHaveProperty("CLAUDE.md"); + }); + it("#4bb auto-updates the Claude subagent hook when the installed template is older but unmodified", async () => { await init({ yes: true, force: true, claude: true }); diff --git a/packages/cli/test/configurators/index.test.ts b/packages/cli/test/configurators/index.test.ts index 15f4da7..333e8aa 100644 --- a/packages/cli/test/configurators/index.test.ts +++ b/packages/cli/test/configurators/index.test.ts @@ -339,6 +339,7 @@ describe("collectPlatformTemplates", () => { if (result) { const managedPaths = getPlatformManagedPaths(id); for (const [filePath] of result) { + if (id === "claude-code" && filePath === "CLAUDE.md") continue; expect( managedPaths.some( (managedPath) => @@ -360,6 +361,12 @@ describe("collectPlatformTemplates", () => { } }); + it("tracks the Claude-only root thin entry", () => { + const claude = collectPlatformTemplates("claude-code"); + expect(claude?.get("CLAUDE.md")).toContain("AGENTS.md"); + expect(collectPlatformTemplates("codex")?.has("CLAUDE.md")).toBe(false); + }); + it("tracks bundled trellis-meta files for every skill-writing platform", () => { for (const [id, skillRoot] of Object.entries(SKILL_ROOTS)) { const result = collectPlatformTemplates(id as AITool); diff --git a/packages/cli/test/configurators/platforms.test.ts b/packages/cli/test/configurators/platforms.test.ts index 6471eef..2601b8b 100644 --- a/packages/cli/test/configurators/platforms.test.ts +++ b/packages/cli/test/configurators/platforms.test.ts @@ -335,6 +335,45 @@ describe("configurePlatform", () => { ); }); + it("configurePlatform('codex') preserves a user-selected model in managed config", async () => { + const config = getCodexConfigTemplate(); + const configPath = path.join(tmpDir, ".codex", config.targetPath); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + config.content + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + ), + ); + + await configurePlatform("codex", tmpDir); + + const written = fs.readFileSync(configPath, "utf-8"); + expect(written).toContain('model = "gpt-5.6-custom"'); + expect(written).toContain("max_concurrent_threads_per_session = 3"); + }); + + it("configurePlatform('codex') adds managed settings to an unmarked user config", async () => { + const configPath = path.join(tmpDir, ".codex", "config.toml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + 'model = "user-model"\n\n[project]\nname = "kept"\n', + ); + + await configurePlatform("codex", tmpDir); + + const written = fs.readFileSync(configPath, "utf-8"); + expect(written).toContain('model = "user-model"'); + expect(written).toContain('[project]\nname = "kept"'); + expect(written).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(written).toContain("# TRELLIS:CODEX_TABLES:START"); + expect(written).not.toContain('model = "gpt-5.6-sol"'); + }); + it("configurePlatform('codex') resolves PYTHON_CMD in hooks.json", async () => { await configurePlatform("codex", tmpDir); diff --git a/packages/cli/test/configurators/shared.test.ts b/packages/cli/test/configurators/shared.test.ts index 1400b9e..08016dd 100644 --- a/packages/cli/test/configurators/shared.test.ts +++ b/packages/cli/test/configurators/shared.test.ts @@ -516,6 +516,24 @@ describe("resolvePlaceholdersNeutral", () => { // --------------------------------------------------------------------------- describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { + it("routes shared skills through research roles without forcing an engineering pipeline", () => { + const skills = new Map( + resolveSkillsNeutral(AI_TOOLS.codex.templateContext).map((skill) => [ + skill.name, + skill.content, + ]), + ); + + expect(skills.get("trellis-brainstorm")).toContain("It is not part of every task"); + expect(skills.get("trellis-grill-me")).toContain("Task size alone is not a trigger"); + expect(skills.get("trellis-before-dev")).toContain("validated `coder` dispatch"); + expect(skills.get("trellis-tdd")).toContain("passing tests do not validate a hypothesis"); + expect(skills.get("trellis-check")).toContain("Pure experiment and literature packages skip this skill"); + expect(skills.get("trellis-break-loop")).toContain("negative scientific result"); + expect(skills.get("trellis-improve-codebase-architecture")).toContain("explicit software-architecture skill"); + expect(skills.get("trellis-update-spec")).toContain("Evaluate this once near closure"); + }); + it("resolveSkillsNeutral includes Hermes audit, provenance, service queue, and quality-gate checks in trellis-check", () => { const skills = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); const check = skills.find((skill) => skill.name === "trellis-check"); @@ -564,6 +582,20 @@ describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { } }); + it("resolveCommands exposes compact status and handoff entry points", () => { + const commands = resolveCommands(claudeCtx); + const status = commands.find((command) => command.name === "status")?.content ?? ""; + const handoff = commands.find((command) => command.name === "handoff")?.content ?? ""; + const py = getPythonCommandForPlatform(); + + expect(status).toContain(`${py} ./.trellis/scripts/closure.py status --task `); + expect(status).toContain(`${py} ./.trellis/scripts/closure.py next --task `); + expect(status).toContain("This command is read-only"); + expect(handoff).toContain("coder:configuration"); + expect(handoff).toContain("must not run the write command"); + expect(handoff).toContain("does not complete, close, archive, approve, or amend"); + }); + it("resolveCommands injects the trellis-switch guard", () => { const commands = resolveCommands(claudeCtx); const guard = `${getPythonCommandForPlatform()} ./.trellis/scripts/assert_trellis_enabled.py`; @@ -644,15 +676,15 @@ describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { } }); - it("resolveAllAsSkillsNeutral keeps the 5 shared skills byte-identical to resolveSkillsNeutral", () => { + it("resolveAllAsSkillsNeutral keeps shared skills byte-identical to resolveSkillsNeutral", () => { const all = resolveAllAsSkillsNeutral(AI_TOOLS.codex.templateContext); - const fiveOnly = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); - const sharedNames = new Set(fiveOnly.map((s) => s.name)); + const sharedOnly = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); + const sharedNames = new Set(sharedOnly.map((s) => s.name)); const allShared = all.filter((s) => sharedNames.has(s.name)); - expect(allShared.length).toBe(fiveOnly.length); - for (const five of fiveOnly) { - const match = allShared.find((s) => s.name === five.name); - expect(match?.content).toBe(five.content); + expect(allShared.length).toBe(sharedOnly.length); + for (const shared of sharedOnly) { + const match = allShared.find((s) => s.name === shared.name); + expect(match?.content).toBe(shared.content); } }); }); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index cd488ac..f03e3a5 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -5250,7 +5250,7 @@ describe("regression: research agent persists findings to task dir", () => { expect(content).not.toMatch(/^- Modify any files\s*$/m); }); - it("codex research.toml uses workspace-write sandbox and persist instruction", () => { + it("codex research.toml uses direct workspace access and persist instruction", () => { const content = fs.readFileSync( path.join( repoRoot, @@ -5258,7 +5258,7 @@ describe("regression: research agent persists findings to task dir", () => { ), "utf-8", ); - expect(content).toMatch(/sandbox_mode\s*=\s*"workspace-write"/); + expect(content).not.toContain("sandbox_mode"); expect(content).toContain("{TASK_DIR}/research/"); expect(content).toMatch(/persist|Persist/); }); @@ -5457,7 +5457,7 @@ describe("regression: Gemini CLI 0.40.x template compatibility (#224)", () => { `Codex and Gemini disagree on ${filePath} — last-writer-wins would corrupt the shared skill`, ).toBe(codexContent); } - // At least the 5 shared workflow skills + bundled trellis-meta files must + // Shared workflow skills + bundled trellis-meta files must // overlap. If this drops to 0 the assertion above is silently passing. expect(overlapCount).toBeGreaterThan(0); }); @@ -5509,8 +5509,8 @@ describe("regression: Gemini CLI 0.40.x template compatibility (#224)", () => { }); it("[#224] needsCodexUpgrade looks for Codex-only command-as-skill markers, not bare `.agents/skills/` prefix", () => { - // Regression: with Gemini also writing to `.agents/skills/` (5 shared - // workflow skills only), the legacy-Codex detector previously triggered + // Regression: with Gemini also writing shared skills to `.agents/skills/` + // (workflow skills only), the legacy-Codex detector previously triggered // a false-positive `.codex/` install on every fresh `init --gemini` + // `update` cycle. The fix narrows detection to Codex-only files // (`trellis-continue/SKILL.md`, `trellis-finish-work/SKILL.md`) which diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index b7ab13a..6b99aa1 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -250,6 +250,45 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.audit.metrics.ref_count).toBe(1); }); + it("permits a coder configuration dispatch only for the current task handoff", () => { + const result = run([ + "create", + "--task", "demo", + "--job-id", "job-handoff", + "--role", "coder", + "--profile", "configuration", + "--objective", "Write the current task handoff.", + "--ref", "prd.md", + "--allowed-file", ".trellis/tasks/demo/HANDOFF.md", + ]); + expect(result.status, result.stderr).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-handoff.dispatch.json"), + "utf-8", + ), + ) as { handoff_writer: boolean; work_package: string | null; body: string }; + expect(dispatch.handoff_writer).toBe(true); + expect(dispatch.work_package).toBeNull(); + expect(dispatch.body).toContain("Handoff writer:"); + + const applied = apply("job-handoff", validResult("job-handoff", { + changed_files: [".trellis/tasks/demo/HANDOFF.md"], + })); + expect(applied.status, applied.stderr).toBe(0); + const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); + expect(handoff).toContain("# Task Handoff"); + expect(handoff).toContain("- .trellis/tasks/demo/HANDOFF.md"); + }); + + it("reserves the task handoff path for the dedicated handoff dispatch", () => { + const result = create("job-handoff-escape", "coder", [ + "--allowed-file", ".trellis/tasks/demo/HANDOFF.md", + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("handoff_path_reserved"); + }); + it("adds only role-matched project context after explicit task refs", () => { const projectDir = path.join(root, ".trellis", "project"); fs.mkdirSync(projectDir, { recursive: true }); @@ -260,9 +299,17 @@ describe("Agent Context Firewall dispatch CLI", () => { fs.writeFileSync(path.join(taskDir, "evidence.md"), "Evidence\n"); expect(create("job-project-coder", "coder").status).toBe(0); - expect(create("job-project-runner", "runner").status).toBe(0); expect( - create("job-project-review", "reviewer", ["--profile", "evidence"]) + create("job-project-runner", "runner", [ + "--parent-job-id", "job-project-coder", + ]).status, + ).toBe(0); + expect( + create("job-project-review", "reviewer", [ + "--profile", "evidence", + "--work-package", "WP1", + "--parent-job-id", "job-project-runner", + ]) .status, ).toBe(0); expect( @@ -305,7 +352,9 @@ describe("Agent Context Firewall dispatch CLI", () => { it("allows planner and reviewer task-level work without a package", () => { expect(create("job-planner", "planner").status).toBe(0); - expect(create("job-review", "reviewer").status).toBe(0); + expect( + create("job-review", "reviewer", ["--profile", "closure"]).status, + ).toBe(0); const review = JSON.parse( fs.readFileSync( path.join(taskDir, "hermes", "dispatches", "job-review.dispatch.json"), @@ -316,6 +365,36 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(review.blind_review).toBe(true); }); + it("restricts non-coder writes to role-owned task record directories", () => { + const sourceWrite = create("job-review-source-write", "reviewer", [ + "--profile", "closure", + "--allowed-file", "src/**", + ]); + expect(sourceWrite.status).toBe(1); + expect(sourceWrite.stderr).toContain("role_write_scope"); + + const reviewRecord = create("job-review-record", "reviewer", [ + "--profile", "closure", + "--allowed-file", ".trellis/tasks/demo/hermes/reviews/**", + ]); + expect(reviewRecord.status, reviewRecord.stderr).toBe(0); + }); + + it("requires a runner checking coder work to bind the coder job", () => { + expect(create("job-parent-coder", "coder").status).toBe(0); + const unbound = create("job-unbound-runner", "runner", [ + "--profile", "validation", + ]); + expect(unbound.status).toBe(1); + expect(unbound.stderr).toContain("missing_parent_job"); + + const bound = create("job-bound-runner", "runner", [ + "--profile", "validation", + "--parent-job-id", "job-parent-coder", + ]); + expect(bound.status, bound.stderr).toBe(0); + }); + it("rejects missing jobs, stale revisions, and execution without the current package", () => { const missing = run(["validate", "--task", "demo", "--job-id", "missing"]); expect(missing.status).toBe(1); @@ -422,7 +501,7 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.forbidden_files).toContain("generated/**"); }); - it("allows publication packet creation but refuses execution without a hard gate", () => { + it("allows publication packet creation with the compact protocol", () => { writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); const result = run( [ @@ -432,7 +511,7 @@ describe("Agent Context Firewall dispatch CLI", () => { { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, ); expect(result.status).toBe(0); - expect(result.stderr).toContain("execution and close remain blocked"); + expect(result.stderr).toContain("validated dispatch and Result Envelope protocol"); const execution = run( [ "run", "--task", "demo", "--job-id", "job-publication", @@ -440,8 +519,8 @@ describe("Agent Context Firewall dispatch CLI", () => { ], { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, ); - expect(execution.status).toBe(1); - expect(execution.stderr).toContain("advisory_forbidden"); + expect(execution.status).toBe(0); + expect(execution.stdout).toContain('"status": "advisory"'); }); it("rejects invalid JSON, missing uncertainties, long logs, and full diffs", () => { @@ -592,8 +671,13 @@ describe("Agent Context Firewall dispatch CLI", () => { }); it("limits evidence and claim reviewers to proposed judgments", () => { + expect(create("job-judgment-source", "runner").status).toBe(0); expect( - create("job-evidence-judgment", "reviewer", ["--profile", "evidence"]).status, + create("job-evidence-judgment", "reviewer", [ + "--profile", "evidence", + "--work-package", "WP1", + "--parent-job-id", "job-judgment-source", + ]).status, ).toBe(0); const approved = apply( "job-evidence-judgment", @@ -605,7 +689,11 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(approved.stderr).toContain("review_authority_violation"); expect( - create("job-claim-judgment", "reviewer", ["--profile", "claim"]).status, + create("job-claim-judgment", "reviewer", [ + "--profile", "claim", + "--work-package", "WP1", + "--parent-job-id", "job-judgment-source", + ]).status, ).toBe(0); const proposed = apply( "job-claim-judgment", @@ -784,7 +872,14 @@ describe("Agent Context Firewall dispatch CLI", () => { }); it("binds blind reviewer reads to allowed refs and rejects worker context", () => { - expect(create("job-review-read", "reviewer", ["--profile", "quality"]).status).toBe(0); + expect(create("job-review-source", "coder").status).toBe(0); + expect( + create("job-review-read", "reviewer", [ + "--profile", "quality", + "--work-package", "WP1", + "--parent-job-id", "job-review-source", + ]).status, + ).toBe(0); runHook("inject-subagent-context.py", { cwd: root, tool_name: "Agent", @@ -843,216 +938,6 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(guard.stdout).toBe(""); }); - it("runs Codex strict through output-schema/json/o and stores only sanitized output", () => { - expect(create("job-codex-strict").status).toBe(0); - const fakeCodex = path.join(root, "fake-codex.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('src/app.ts').write_text('strict change\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Strict result.', 'uncertainties': [], 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review package.'}), encoding='utf-8')", - "print(json.dumps({'type': 'trace', 'private': 'raw codex trace'}))", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-strict", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("Strict result."); - expect(result.stdout).not.toContain("raw codex trace"); - const dispatch = JSON.parse( - fs.readFileSync( - path.join(taskDir, "hermes", "dispatches", "job-codex-strict.dispatch.json"), - "utf-8", - ), - ); - expect(dispatch.execution_mode).toBe("strict"); - expect(dispatch.status).toBe("confirmed"); - }); - - it("excludes environment and credential files from the Codex strict workspace", () => { - expect(create("job-codex-secret-workspace").status).toBe(0); - fs.writeFileSync(path.join(root, ".env"), "SECRET_VALUE=do-not-copy\n", "utf-8"); - fs.writeFileSync(path.join(root, ".npmrc"), "//registry.example/:_authToken=do-not-copy\n", "utf-8"); - fs.mkdirSync(path.join(root, "config"), { recursive: true }); - fs.writeFileSync( - path.join(root, "config", "service-account.json"), - `token=${["github_pat_", "B".repeat(24)].join("")}\n`, - "utf-8", - ); - const fakeCodex = path.join(root, "fake-codex-secret-workspace.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "if pathlib.Path('.env').exists() or pathlib.Path('.npmrc').exists() or pathlib.Path('config/service-account.json').exists():", - " raise SystemExit('sensitive file copied into strict workspace')", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Sensitive files were excluded.', 'uncertainties': [], 'changed_files': [], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-secret-workspace", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status, result.stderr).toBe(0); - expect(fs.existsSync(path.join(root, ".env"))).toBe(true); - expect(fs.existsSync(path.join(root, ".npmrc"))).toBe(true); - expect(fs.existsSync(path.join(root, "config", "service-account.json"))).toBe(true); - }); - - it("rejects Codex strict when actual changes escape allowed files", () => { - expect(create("job-codex-escape").status).toBe(0); - fs.writeFileSync(path.join(root, "README.md"), "original\n", "utf-8"); - const originalReadme = fs.readFileSync(path.join(root, "README.md"), "utf-8"); - const fakeCodex = path.join(root, "fake-codex-escape.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('README.md').write_text('unauthorized\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Escaped.', 'uncertainties': [], 'changed_files': ['README.md'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-escape", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status).toBe(1); - expect(result.stderr).toContain("unauthorized_actual_changes"); - expect(fs.readFileSync(path.join(root, "README.md"), "utf-8")).toBe(originalReadme); - }); - - it("does not sync strict workspace changes when the result envelope is invalid", () => { - expect(create("job-codex-invalid").status).toBe(0); - const originalSource = fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8"); - const fakeCodex = path.join(root, "fake-codex-invalid.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('src/app.ts').write_text('untrusted strict change\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Missing required uncertainties.', 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-invalid", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("missing_result_fields"); - expect(fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8")).toBe( - originalSource, - ); - const dispatch = JSON.parse( - fs.readFileSync( - path.join(taskDir, "hermes", "dispatches", "job-codex-invalid.dispatch.json"), - "utf-8", - ), - ) as { status: string; invalid_result_attempts: number; last_result_error: string }; - expect(dispatch).toMatchObject({ - status: "rewrite_required", - invalid_result_attempts: 1, - last_result_error: "missing_result_fields", - }); - }); - - it("degrades missing Codex strict capability by closure mode", () => { - expect(create("job-codex-lean").status).toBe(0); - const lean = run([ - "run", "--task", "demo", "--job-id", "job-codex-lean", - "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", - ]); - expect(lean.status).toBe(0); - expect(lean.stdout).toContain('"status": "advisory"'); - - writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); - expect(create("job-codex-publication", "planner").status).toBe(0); - const publication = run([ - "run", "--task", "demo", "--job-id", "job-codex-publication", - "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", - ]); - expect(publication.status).toBe(1); - expect(publication.stderr).toContain("codex_strict_unavailable"); - }); - - it("does not accept environment flags or unsigned future heartbeats as a hard gate", () => { - writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "standard" })); - expect(create("job-forged", "planner", ["--platform", "codex"]).status).toBe(0); - const heartbeatDir = path.join(root, ".trellis", ".runtime", "context-firewall"); - fs.mkdirSync(heartbeatDir, { recursive: true }); - writeJson(path.join(heartbeatDir, "codex-hooks-forged.json"), { - platform: "codex", - mechanism: "hooks", - task_id: "demo", - job_id: "job-forged", - timestamp: "2999-01-01T00:00:00Z", - }); - const forged = run( - [ - "run", "--task", "demo", "--job-id", "job-forged", - "--platform", "codex", "--mode", "native", - ], - { TRELLIS_HOOKS_ACTIVE: "1", TRELLIS_CODEX_STRICT: "1" }, - ); - expect(forged.status).toBe(1); - expect(forged.stderr).toContain("firewall_unavailable"); - }); it("supersedes a blocked dispatch and restores the package mechanically", () => { expect(create("job-old").status).toBe(0); diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index ca2ad32..3d25b22 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -1,4 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -31,6 +32,7 @@ interface TestTask { research_route?: string; research_change_fields?: string[]; grill_completed?: boolean; + decision_ref?: string | null; } function hasPython(): boolean { @@ -159,6 +161,27 @@ function writeTask(taskDir: string, value: Record): void { ); } +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, stableValue(item)]), + ); + } + return value; +} + +function resultSha256(value: Record): string { + const payload = Object.fromEntries( + Object.entries(value).filter(([key]) => key !== "audit"), + ); + return `sha256:${createHash("sha256") + .update(JSON.stringify(stableValue(payload)), "utf8") + .digest("hex")}`; +} + 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); @@ -166,6 +189,37 @@ function plan(root: string, doneWhen: string[], extra: string[] = []) { return run(root, ...args); } +function writeDecisionRecord( + taskDir: string, + filename = "design.md", + marker = "DECISION_BODY_MUST_NOT_BE_IN_CAPSULE", +): string { + fs.writeFileSync( + path.join(taskDir, filename), + [ + "# Research Decision", + "", + "## Decision", + marker, + "", + "## Rationale", + "The choice follows the current research objective.", + "", + "## Evidence", + "Repository evidence supports this bounded choice.", + "", + "## Alternatives", + "Keep the previous protocol.", + "", + "## Failure Conditions", + "Conflicting evidence or a failed critical assumption.", + "", + ].join("\n"), + "utf-8", + ); + return filename; +} + function planTwoExplicitPackages(root: string) { return plan(root, ["Result A", "Result B"], [ "--package", @@ -239,12 +293,56 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(blocked.status).toBe(1); expect(blocked.stderr).toContain("completed grill"); - expect(run(root, "grill", "--complete").status).toBe(0); + const missingDecision = run(root, "grill", "--complete"); + expect(missingDecision.status).not.toBe(0); + expect(missingDecision.stderr).toContain("--decision-ref"); + + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); expect(run(root, "validate").status).toBe(0); const capsule = run(root, "capsule"); expect(capsule.stdout).toContain("Route: exploration"); expect(capsule.stdout).toContain("Research changes: model_architecture"); expect(capsule.stdout).toContain("Grill: complete"); + expect(capsule.stdout).toContain("Decision ref: design.md"); + expect(capsule.stdout).not.toContain("DECISION_BODY_MUST_NOT_BE_IN_CAPSULE"); + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events.find((event) => event.event_type === "grill_completed")).toMatchObject({ + decision_ref: "design.md", + }); + }); + + it("keeps legacy completed grills without decision_ref compatible with a warning", () => { + writeTask(taskDir, baseTask({ + intent: "Preserve a legacy exploration task", + definition_of_done: ["Legacy result is verified"], + research_route: "exploration", + research_change_fields: ["dataset"], + grill_completed: true, + work_packages: [ + { + id: "WP1", + title: "Legacy result", + outcome: "Legacy result is verified", + done_when: ["Legacy result is verified"], + evidence_required: [], + depends_on: [], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ], + })); + + const result = run(root, "validate"); + expect(result.status).toBe(0); + expect(result.stderr).toContain("legacy completed grill has no decision_ref"); }); it("automatically routes recorded research changes to exploration", () => { @@ -609,7 +707,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); - it("writes a handoff when an open task session is finished", () => { + it("finishes a session without mutating an open task or writing a handoff", () => { expect(plan(root, ["Result A"]).status).toBe(0); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); fs.mkdirSync(sessions, { recursive: true }); @@ -619,8 +717,74 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { ); 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); + expect(result.stdout).not.toContain("Handoff updated"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(false); + expect(readTask(taskDir).closure_state).toBe("open"); + }); + + it("writes handoffs from validated task results only", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + fs.mkdirSync(path.join(taskDir, "hermes", "dispatches"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "owned.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "src", "forged.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "unrelated.txt"), "other task change\n"); + const task = readTask(taskDir); + writeTask(taskDir, { ...task, confirmed_dispatches: ["job-1"] }); + const confirmedResult = { + job_id: "job-1", + task_revision: task.hermes_revision, + status: "success", + confirmed: true, + changed_files: ["src/owned.ts"], + }; + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "job-1.dispatch.json"), + `${JSON.stringify({ + job_id: "job-1", + hermes_revision: task.hermes_revision, + confirmed_revision: task.hermes_revision, + result_sha256: resultSha256(confirmedResult), + status: "confirmed", + })}\n`, + ); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "job-1.result.json"), + `${JSON.stringify({ ...confirmedResult, changed_files: ["src/forged.ts"] })}\n`, + ); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "forged.result.json"), + `${JSON.stringify({ + job_id: "forged", + status: "success", + confirmed: true, + changed_files: ["src/forged.ts"], + })}\n`, + ); + + expect(run(root, "handoff").status).toBe(0); + const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); + expect(handoff).toContain("# Task Handoff"); + expect(handoff).toContain(`## Task Revision\n${readTask(taskDir).hermes_revision}`); + expect(handoff).not.toContain("- src/owned.ts"); + expect(handoff).not.toContain("src/forged.ts"); + expect(handoff).not.toContain("unrelated.txt"); + expect(handoff).toContain("job-1: confirmed result integrity check failed"); + }); + + + it("derives next action from current closure state instead of stale task text", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const task = readTask(taskDir); + writeTask(taskDir, { ...task, next_action: "obsolete action" }); + const result = run(root, "next"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Run closure.py validate."); + expect(result.stdout).not.toContain("obsolete action"); + + const status = run(root, "status", "--json"); + expect(status.status).toBe(0); + expect(JSON.parse(status.stdout).next_action).toContain("Run closure.py validate."); }); it("prints a compact actionable audit gap", () => { @@ -634,7 +798,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(result.stdout.length).toBeLessThan(800); }); - it("strongly warns when lean closure runs without hooks or strict heartbeat", () => { + it("warns when lean closure runs without a context hook", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); const result = spawnSync( @@ -647,7 +811,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }, ); expect(result.stdout).toContain("warnings:"); - expect(result.stdout).toContain("advisory"); + expect(result.stdout).toContain("validated dispatch"); }); it("repairs only the first gap and preserves completed packages", () => { @@ -765,8 +929,83 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.research_change_fields).toEqual(["dataset"]); expect(task.grill_completed).toBe(false); expect(run(root, "validate").status).toBe(1); - expect(run(root, "grill", "--complete").status).toBe(0); + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + }); + + it("preserves the completed decision until a research amendment is applied", () => { + expect( + plan(root, ["Research result is verified"], [ + "--route", + "exploration", + "--research-change", + "dataset", + ]).status, + ).toBe(0); + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); expect(run(root, "validate").status).toBe(0); + + const taskWithInvalidNestedTarget = readTask(taskDir); + taskWithInvalidNestedTarget.meta.research_contract = { dataset: "dataset-v1" }; + writeTask(taskDir, taskWithInvalidNestedTarget); + + const denied = run( + root, + "amend", + "--field", + "split", + "--value", + "split-v2", + "--reason", + "unapproved split change", + ); + expect(denied.status).toBe(1); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: decisionRef, + grill_completed: true, + }); + + const invalid = run( + root, + "amend", + "--field", + "dataset.version", + "--value", + "v2", + "--reason", + "invalid nested dataset change", + "--approved-by", + "human/root", + ); + expect(invalid.status).toBe(1); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: decisionRef, + grill_completed: true, + }); + + const approved = run( + root, + "amend", + "--field", + "split", + "--value", + "split-v2", + "--reason", + "approved split change", + "--approved-by", + "human/root", + ); + expect(approved.status).toBe(0); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: null, + grill_completed: false, + }); }); it("treats functional model architecture changes as high risk", () => { @@ -958,33 +1197,6 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).closure_state).toBe("open"); }); - it("does not close publication mode when hooks are explicitly disabled", () => { - expect( - plan(root, ["Publication result verified"], ["--mode", "publication"]) - .status, - ).toBe(0); - expect(run(root, "validate").status).toBe(0); - completeCurrent(root, "ev-1"); - const result = spawnSync( - PYTHON, - [".trellis/scripts/closure.py", "close", "--task", "demo"], - { - cwd: root, - encoding: "utf-8", - env: { - ...process.env, - TRELLIS_HOOKS: "0", - TRELLIS_HOOKS_ACTIVE: "1", - }, - }, - ); - expect(result.status).toBe(1); - expect(result.stdout).toContain("context firewall"); - const task = readTask(taskDir); - expect(task.status).toBe("in_progress"); - expect(task.closure_state).toBe("open"); - expect(task.hermes_phase).not.toBe("closed"); - }); it("keeps legacy non-Hermes archive behavior", () => { const legacyRoot = fs.mkdtempSync( diff --git a/packages/cli/test/scripts/hermes-preflight.test.ts b/packages/cli/test/scripts/hermes-preflight.test.ts index 05b7fa5..28af902 100644 --- a/packages/cli/test/scripts/hermes-preflight.test.ts +++ b/packages/cli/test/scripts/hermes-preflight.test.ts @@ -6,9 +6,8 @@ import path from "node:path"; import { checkHermesRequiredFiles, checkHookMatcherIncludes, + checkLocalRunnerConfiguration, checkNoPythonCaches, - checkSandboxConfiguration, - checkSecurityGateDocumentation, } from "../../scripts/hermes-preflight.js"; const repoRoot = path.resolve(import.meta.dirname, "../../../../"); @@ -74,89 +73,16 @@ describe("hermes-preflight checks", () => { }); }); - it("requires deployment docs to state Bash and approval security boundaries", () => { + it("checks direct local runner templates", () => { withTempRoot((root) => { - writeFile( - root, - "docs/hermes_deployment_preflight.md", - "`Bash` is partially guarded.\n", - ); - - const result = checkSecurityGateDocumentation(root); - - expect(result.ok).toBe(false); - expect(result.messages.join("\n")).toContain("approval_records"); - expect(result.messages.join("\n")).toContain("human/root"); - }); - }); - - it("requires deployment docs to state hardening and storage boundaries", () => { - withTempRoot((root) => { - writeFile( - root, - "docs/hermes_deployment_preflight.md", - [ - "`deployment candidate hardening` is the scope.", - "`Bash` writes fail closed.", - "`allowed_commands` is not a strong command sandbox.", - "`not an OS sandbox`.", - "`approval_records` require `external human/root approval`.", - "", - ].join("\n"), - ); - - const result = checkSecurityGateDocumentation(root); - - expect(result.ok).toBe(false); - expect(result.messages.join("\n")).toContain("JSONL"); - expect(result.messages.join("\n")).toContain("not tamper-proof"); - }); - }); - - it("checks sandbox configuration defaults and documentation boundaries", () => { - withTempRoot((root) => { - writeFile( - root, - "packages/cli/src/templates/trellis/hermes/config.yaml", - [ - 'runtime_scope: "deployment candidate hardening"', - "sandbox:", - ' mode: "none"', - " required: false", - ' note: "not an OS sandbox"', - "", - ].join("\n"), - ); - writeFile( - root, - "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", - [ - "sandbox:", - ' mode: "none"', - " required: false", - "", - ].join("\n"), - ); - writeFile( - root, - "docs/hermes_deployment_preflight.md", - [ - "`sandbox.required=true` with `mode=none` must fail closed.", - "`mode=none` is only local execution.", - "`container` and `external` modes are availability checks only.", - "`not an OS sandbox`.", - "`allowed_commands` is not a strong command sandbox.", - "", - ].join("\n"), - ); - - const result = checkSandboxConfiguration(root); - + writeFile(root, "packages/cli/src/templates/trellis/hermes/config.yaml", 'runtime_boundary: "runs directly in the project workspace"\n'); + writeFile(root, "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", "allowed_commands:\n - \"python3\"\nartifact_dir: \".trellis/tasks/{task}/hermes/runs\"\n"); + const result = checkLocalRunnerConfiguration(root); expect(result.ok).toBe(true); }); }); - it("quick CLI runs security gate without build/typecheck", () => { + it("quick CLI runs local runner checks without build/typecheck", () => { const result = spawnSync( process.execPath, [scriptPath, "--", "--quick", "--skip-python-compile"], @@ -167,9 +93,8 @@ describe("hermes-preflight checks", () => { ); expect(result.status).toBe(0); - expect(result.stdout).toContain("Hermes deployment preflight"); + expect(result.stdout).toContain("Hermes local workflow preflight"); expect(result.stdout).toContain("quick mode"); - expect(result.stdout).toContain("Bash"); - expect(result.stdout).toContain("approval_records"); + expect(result.stdout).toContain("current project workspace"); }); }); diff --git a/packages/cli/test/templates/codex-packaging.test.ts b/packages/cli/test/templates/codex-packaging.test.ts new file mode 100644 index 0000000..40598ab --- /dev/null +++ b/packages/cli/test/templates/codex-packaging.test.ts @@ -0,0 +1,51 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, "../../../.."); +const cliRoot = path.join(repoRoot, "packages", "cli"); + +describe("codex template packaging", () => { + const temporaryRoots: string[] = []; + + afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("copies config.toml with its managed markers into the package template tree", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-pack-")); + temporaryRoots.push(root); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.cpSync( + path.join(cliRoot, "src", "templates"), + path.join(root, "src", "templates"), + { recursive: true }, + ); + fs.cpSync( + path.join(cliRoot, "src", "migrations", "manifests"), + path.join(root, "src", "migrations", "manifests"), + { recursive: true }, + ); + fs.copyFileSync( + path.join(cliRoot, "scripts", "copy-templates.js"), + path.join(root, "scripts", "copy-templates.js"), + ); + + execFileSync(process.execPath, ["scripts/copy-templates.js"], { + cwd: root, + stdio: "pipe", + }); + + const copied = fs.readFileSync( + path.join(root, "dist", "templates", "codex", "config.toml"), + "utf-8", + ); + expect(copied).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(copied).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + expect(copied).toContain("# TRELLIS:CODEX_TABLES:END"); + }); +}); diff --git a/packages/cli/test/templates/codex.test.ts b/packages/cli/test/templates/codex.test.ts index 9912f08..30c789c 100644 --- a/packages/cli/test/templates/codex.test.ts +++ b/packages/cli/test/templates/codex.test.ts @@ -69,19 +69,37 @@ describe("codex getAllAgents", () => { } }); - it("marks native Hermes agents advisory and points enforced work to strict dispatch", () => { + it("keeps Hermes agents on the compact dispatch protocol", () => { const agents = new Map(getAllAgents().map((agent) => [agent.name, agent.content])); for (const role of ["planner", "researcher", "coder", "runner", "reviewer"]) { const content = agents.get(`hermes-${role}`); - expect(content).toContain("Native Codex dispatch is advisory"); expect(content).toContain(`role: ${role}:`); expect(content).toMatch(/Profile|profile/); expect(content).toContain("multi_agent = false"); - expect(content).toContain("--mode strict"); expect(content).toContain("Result Envelope JSON"); + expect(content).not.toContain("sandbox_mode"); + expect(content).not.toContain("--mode strict"); expect(content).not.toContain(".trellis/tasks//prd.md"); } }); + + it("assigns research reasoning and execution models consistently", () => { + const agents = new Map(getAllAgents().map((agent) => [agent.name, agent.content])); + for (const name of [ + "hermes-planner", + "hermes-researcher", + "hermes-reviewer", + "trellis-check", + "trellis-research", + ]) { + expect(agents.get(name)).toContain('model = "gpt-5.6-sol"'); + expect(agents.get(name)).toContain('model_reasoning_effort = "high"'); + } + for (const name of ["hermes-coder", "hermes-runner", "trellis-implement"]) { + expect(agents.get(name)).toContain('model = "gpt-5.6-terra"'); + expect(agents.get(name)).toContain('model_reasoning_effort = "max"'); + } + }); }); describe("codex getAllCodexSkills (platform-specific)", () => { @@ -97,8 +115,14 @@ describe("codex getConfigTemplate", () => { expect(config.targetPath).toBe("config.toml"); expect(config.content).toContain("project_doc_fallback_filenames"); expect(config.content).toContain("AGENTS.md"); - expect(config.content).toContain('native_authority = "advisory"'); - expect(config.content).toContain("--output-schema --json -o"); + expect(config.content).toContain('model = "gpt-5.6-sol"'); + expect(config.content).toContain('model_reasoning_effort = "high"'); + expect(config.content).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(config.content).toContain("# TRELLIS:CODEX_CONFIG:END"); + expect(config.content).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:START"); + expect(config.content).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + expect(config.content).toContain('native_authority = "protocol"'); + expect(config.content).not.toContain("strict_authority"); }); }); diff --git a/packages/cli/test/templates/hermes-runtime.test.ts b/packages/cli/test/templates/hermes-runtime.test.ts index fcb6c99..471f94a 100644 --- a/packages/cli/test/templates/hermes-runtime.test.ts +++ b/packages/cli/test/templates/hermes-runtime.test.ts @@ -77,9 +77,6 @@ function writeExperimentConfig(taskDir: string): void { ' shell: "bash"', "allowed_commands:", ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: false", 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', "", ].join("\n"), @@ -106,9 +103,6 @@ function writeExperimentConfigWithAllowedCommands( ' shell: "bash"', "allowed_commands:", ...allowedCommands.map((command) => ` - "${command}"`), - "sandbox:", - ' mode: "none"', - " required: false", 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', "", ].join("\n"), @@ -116,57 +110,70 @@ function writeExperimentConfigWithAllowedCommands( ); } -function writeFakeDocker(root: string): { binDir: string; logPath: string } { - const binDir = path.join(root, "fake-bin"); - const logPath = path.join(root, "fake-docker-argv.json"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "docker"), + +function sha256Text(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function writeRunnerCard(taskDir: string, jobId: string): void { + writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ + { + type: "task_card", + id: `tc-${jobId}`, + timestamp: "2026-06-29T00:00:00Z", + job_id: jobId, + role: "runner", + worktree_id: "main", + status: "queued", + allowed_files: ["**"], + forbidden_files: [], + heartbeat_interval: "1s", + timeout_at: "2099-01-01T00:00:00Z", + checkpoint: "not-started", + resume_from: "task_card", + record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", + evidence_refs: [], + risk_flags: [], + }, + ]); +} + +function markDataExploration(taskDir: string): void { + writeJson(path.join(taskDir, "task.json"), { + id: "01-test", + status: "in_progress", + closure_state: "open", + hermes_phase: "running", + research_route: "exploration", + research_change_fields: ["dataset"], + }); +} + +function appendDataPreflight( + taskDir: string, + hash: string, + checksContent: string, +): { manifestPath: string; checksPath: string } { + const manifestPath = "data/input-manifest.json"; + const checksPath = "data/checks.yaml"; + const root = path.resolve(taskDir, "../../../"); + fs.mkdirSync(path.join(root, "data"), { recursive: true }); + fs.writeFileSync(path.join(root, manifestPath), '{"rows":2}\n', "utf-8"); + fs.writeFileSync(path.join(root, checksPath), checksContent, "utf-8"); + fs.appendFileSync( + path.join(taskDir, "hermes", "experiment.yaml"), [ - "#!/usr/bin/env python3", - "import json", - "import os", - "import subprocess", - "import sys", - `log_path = ${JSON.stringify(logPath)}`, - "args = sys.argv[1:]", - "with open(log_path, 'w', encoding='utf-8') as handle:", - " json.dump(args, handle)", - "if len(args) < 7 or args[0:2] != ['run', '--rm']:", - " sys.exit(64)", - "index = 2", - "host_root = None", - "container_root = None", - "workdir = None", - "while index < len(args):", - " if args[index] in ['-v', '--volume']:", - " host_root, container_root = args[index + 1].split(':', 1)", - " index += 2", - " elif args[index] in ['-w', '--workdir']:", - " workdir = args[index + 1]", - " index += 2", - " else:", - " break", - "if host_root is None or container_root is None or workdir is None:", - " sys.exit(65)", - "if index >= len(args):", - " sys.exit(66)", - "command = args[index + 1:]", - "host_cwd = host_root", - "if workdir != container_root:", - " host_cwd = os.path.join(host_root, os.path.relpath(workdir, container_root))", - "result = subprocess.run(command, cwd=host_cwd)", - "sys.exit(result.returncode)", + "data_preflight:", + ' source: "fixture"', + ' version: "v1"', + ` input_manifest: "${manifestPath}"`, + ` hash: "${hash}"`, + ` checks_ref: "${checksPath}"`, "", ].join("\n"), "utf-8", ); - fs.chmodSync(path.join(binDir, "docker"), 0o755); - return { binDir, logPath }; -} - -function sha256Text(value: string): string { - return `sha256:${createHash("sha256").update(value).digest("hex")}`; + return { manifestPath, checksPath }; } describe("Hermes runtime scripts", () => { @@ -661,6 +668,135 @@ describe("Hermes runtime scripts", () => { )).toBe(""); }); + it("keeps ordinary experiment validation free of data_preflight", () => { + writeExperimentConfig(taskDir); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(0); + }); + + it("blocks a data-changing exploration before runner command execution when data_preflight is missing", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + writeRunnerCard(taskDir, "job-data-preflight-missing"); + + const run = runHermes(tmpDir, "runner.py", [ + "run", + "--task", + "01-test", + "--job-id", + "job-data-preflight-missing", + "--checkpoint", + "data-preflight", + "--summary", + "validate data before execution", + "--", + "python3", + "-c", + "import pathlib; pathlib.Path('should-not-run.txt').write_text('bad')", + ]); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("data_preflight is required"); + expect(fs.existsSync(path.join(tmpDir, "should-not-run.txt"))).toBe(false); + expect(fs.existsSync(path.join(taskDir, "hermes", "run_manifest.jsonl"))).toBe(false); + }); + + it("rejects a data_preflight hash that does not match its input manifest", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + appendDataPreflight( + taskDir, + `sha256:${"0".repeat(64)}`, + "schema: checked\nmissing: checked\nduplicates: checked\nsplit_leakage: not_applicable\n", + ); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(1); + expect(validate.stderr).toContain("data_preflight hash mismatch"); + }); + + it("rejects data_preflight checks with a missing required check", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + const manifestContent = '{"rows":2}\n'; + appendDataPreflight( + taskDir, + sha256Text(manifestContent), + "schema: checked\nmissing: checked\nduplicates: checked\n", + ); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(1); + expect(validate.stderr).toContain("split_leakage must be checked or not_applicable"); + }); + + it("accepts valid data_preflight and records its files as runner inputs", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + const manifestContent = '{"rows":2}\n'; + const refs = appendDataPreflight( + taskDir, + sha256Text(manifestContent), + "schema: checked\nmissing: checked\nduplicates: checked\nsplit_leakage: not_applicable\n", + ); + writeRunnerCard(taskDir, "job-data-preflight-valid"); + + const run = runHermes(tmpDir, "runner.py", [ + "run", + "--task", + "01-test", + "--job-id", + "job-data-preflight-valid", + "--checkpoint", + "data-preflight", + "--summary", + "run with validated data", + "--input", + refs.manifestPath, + "--input", + refs.checksPath, + "--output", + "data-run.txt", + "--", + "python3", + "-c", + "import pathlib; pathlib.Path('data/input-manifest.json').write_text('{\"rows\":99}\\n'); pathlib.Path('data-run.txt').write_text('ok')", + ]); + + expect(run.status).toBe(0); + const manifest = fs + .readFileSync(path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(manifest).toHaveLength(1); + expect(manifest[0].inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: refs.manifestPath, hash: sha256Text(manifestContent) }), + expect.objectContaining({ path: refs.checksPath }), + ]), + ); + expect(sha256Text(fs.readFileSync(path.join(tmpDir, refs.manifestPath), "utf-8"))).not.toBe( + sha256Text(manifestContent), + ); + }); + it("rejects worker results when there is no task_card", () => { fs.writeFileSync( path.join(taskDir, "hermes", "worker_records.jsonl"), @@ -1019,6 +1155,62 @@ describe("Hermes runtime scripts", () => { expect(validate.stderr).toContain("coder review handoff requires reviewer"); }); + it("accepts a configuration handoff without inventing code test records", () => { + writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ + { + type: "task_card", + id: "tc-20260629-000000-config", + timestamp: "2026-06-29T00:00:00Z", + job_id: "job-config", + role: "coder", + profile: "configuration", + worktree_id: "main", + status: "queued", + allowed_files: [".trellis/tasks/01-test/HANDOFF.md"], + forbidden_files: [], + heartbeat_interval: "5m", + timeout_at: "2099-01-01T00:00:00Z", + checkpoint: "not-started", + resume_from: "task_card", + record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", + evidence_refs: [], + risk_flags: [], + }, + { + type: "checkpoint", + id: "cp-20260629-000100-config", + timestamp: "2026-06-29T00:01:00Z", + job_id: "job-config", + checkpoint: "handoff-written", + resume_from: "return result", + evidence_refs: [], + open_items: [], + }, + { + type: "result", + id: "rs-20260629-000200-config", + timestamp: "2026-06-29T00:02:00Z", + job_id: "job-config", + status: "done", + summary: "wrote task handoff", + changed_files: [".trellis/tasks/01-test/HANDOFF.md"], + evidence_refs: [], + risk_flags: [], + handoff: "review", + }, + ]); + + const validate = runHermes(tmpDir, "validate.py", [ + "--task", + "01-test", + "--kind", + "worker", + ]); + + expect(validate.status).toBe(0); + expect(validate.stdout).toContain("valid"); + }); + it("rejects coder review handoff when runner has no result record", () => { writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ { @@ -1665,7 +1857,7 @@ describe("Hermes runtime scripts", () => { }); }); - it("runner wraps a long command with heartbeats and a replayable manifest", () => { + it("runner uses local execution for legacy sandbox configs and records a replayable manifest", () => { const workerRecords = [ { type: "task_card", @@ -1691,6 +1883,11 @@ describe("Hermes runtime scripts", () => { workerRecords.map((record) => JSON.stringify(record)).join("\n") + "\n", ); writeExperimentConfig(taskDir); + fs.appendFileSync( + path.join(taskDir, "hermes", "experiment.yaml"), + 'sandbox:\n mode: "container"\n required: true\n', + "utf-8", + ); const run = runHermes(tmpDir, "runner.py", [ "run", @@ -1984,7 +2181,7 @@ describe("Hermes runtime scripts", () => { ).toBe(false); }); - it("runner run does not pass common secret env vars to subprocess or run_manifest", () => { + it("runner inherits the project runtime environment without recording it", () => { const workerRecords = [ { type: "task_card", @@ -2044,7 +2241,7 @@ describe("Hermes runtime scripts", () => { expect(result.status).toBe(0); expect( fs.readFileSync(path.join(tmpDir, "env-output.txt"), "utf-8"), - ).toBe("missing"); + ).toBe("test-secret-value"); const manifest = fs.readFileSync( path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8", @@ -2053,417 +2250,6 @@ describe("Hermes runtime scripts", () => { expect(manifest).not.toContain("test-secret-value"); }); - it("runner rejects sandbox.required=true when sandbox mode is none", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-none", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run?"', - 'hypothesis: "The command should not run without a sandbox."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: true", - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - - const run = runHermes(tmpDir, "runner.py", [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-none", - "--checkpoint", - "command-running", - "--summary", - "run without sandbox", - "--", - "python3", - "-c", - "import pathlib; pathlib.Path('should-not-run.txt').write_text('bad', encoding='utf-8')", - ]); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.required=true"); - expect(run.stderr).toContain("mode=none"); - expect(fs.existsSync(path.join(tmpDir, "should-not-run.txt"))).toBe(false); - expect( - fs.existsSync(path.join(taskDir, "hermes", "run_manifest.jsonl")), - ).toBe(false); - }); - - it("runner rejects unavailable container sandbox with an explicit error", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-container", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Missing container runtime fails closed."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - ` command: "${path.join(tmpDir, "__missing_hermes_container_runtime__", "docker")}"`, - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - - const run = runHermes(tmpDir, "runner.py", [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-container", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ]); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox mode container"); - expect(run.stderr).toContain("not available"); - }); - - it("runner rejects container sandbox images that look like docker flags", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-image", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Docker flags must not be accepted as images."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "--network=host"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-image", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.image"); - expect(fs.existsSync(fakeDocker.logPath)).toBe(false); - }); - - it("runner rejects container sandbox commands with docker global options", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-command", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Docker global options must not be accepted."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - ' command: "docker --context test"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-command", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.command"); - expect(fs.existsSync(fakeDocker.logPath)).toBe(false); - }); - - it("runner runs required container sandbox through docker run with the repo mounted", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-docker", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.mkdirSync(path.join(tmpDir, "workspace"), { recursive: true }); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "The container runner writes the expected artifact."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-docker", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--cwd", - "workspace", - "--output", - "container-output.txt", - "--", - "python3", - "-c", - "import pathlib; pathlib.Path('container-output.txt').write_text('container ok', encoding='utf-8')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(0); - expect( - fs.readFileSync(path.join(tmpDir, "workspace", "container-output.txt"), "utf-8"), - ).toBe("container ok"); - - const dockerArgs = JSON.parse( - fs.readFileSync(fakeDocker.logPath, "utf-8"), - ) as string[]; - expect(dockerArgs.slice(0, 2)).toEqual(["run", "--rm"]); - expect(dockerArgs).toEqual( - expect.arrayContaining([ - "-v", - `${tmpDir}:/workspace`, - "-w", - "/workspace/workspace", - "python:3.12-slim", - "python3", - ]), - ); - - const manifestRows = fs - .readFileSync(path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - expect(manifestRows[0]).toMatchObject({ - job_id: "job-runner-sandbox-docker", - cwd: "workspace", - exit_code: 0, - sandbox: { - mode: "container", - image: "python:3.12-slim", - }, - }); - expect(manifestRows[0].command).toEqual( - expect.arrayContaining(["python3", "-c"]), - ); - }); it("runner replay passes when output hashes match", () => { const output = "stable output"; diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 50002eb..31c3439 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -208,7 +208,10 @@ function commitRepoState(repoRoot: string, message = "init"): void { } } -function runSessionStart(worktreeRoot: string): string { +function runSessionStart( + worktreeRoot: string, + hookInput: Record = {}, +): string { const sessionStart = getSharedHookScripts().find( (h) => h.name === "session-start.py", ); @@ -228,7 +231,7 @@ function runSessionStart(worktreeRoot: string): string { ...process.env, CLAUDE_PROJECT_DIR: worktreeRoot, }, - input: JSON.stringify({ cwd: worktreeRoot }), + input: JSON.stringify({ cwd: worktreeRoot, ...hookInput }), }); if (result.status !== 0) { throw new Error(result.stderr || result.stdout || "session-start failed"); @@ -239,7 +242,11 @@ function runSessionStart(worktreeRoot: string): string { } } -function runWorkflowState(repoRoot: string, contextId: string): string { +function runWorkflowState( + repoRoot: string, + contextId: string, + hookInput: Record = {}, +): string { const hook = getSharedHookScripts().find( (item) => item.name === "inject-workflow-state.py", ); @@ -258,7 +265,7 @@ function runWorkflowState(repoRoot: string, contextId: string): string { CLAUDE_PROJECT_DIR: repoRoot, TRELLIS_CONTEXT_ID: contextId, }, - input: JSON.stringify({ cwd: repoRoot, session_id: contextId }), + input: JSON.stringify({ cwd: repoRoot, session_id: contextId, ...hookInput }), }); if (result.status !== 0) { throw new Error(result.stderr || result.stdout || "workflow-state failed"); @@ -561,6 +568,54 @@ describe("shared-hooks capability table", () => { } }); + it("reinjects a compact closure capsule after a session compaction", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-compact-capsule-")); + try { + setupMainRepo(repoRoot, "compact-task", "# prd\n"); + const taskDir = path.join(repoRoot, ".trellis", "tasks", "compact-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: "Restore closure context after compaction", + definition_of_done: ["Focused validation is recorded"], + current_work_package: "WP1", + next_action: "stale persisted text", + blockers: [], + repair_count: 0, + max_repair_count: 1, + work_packages: [{ + id: "WP1", + title: "Restore state", + outcome: "Current work package remains visible", + done_when: ["Focused validation is recorded"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }], + }); + fs.writeFileSync(taskJsonPath, `${JSON.stringify(task, null, 2)}\n`); + const contextId = setSessionActiveTask(repoRoot, "compact-task", "compact-session"); + const payload = JSON.parse(runSessionStart(repoRoot, { + session_id: contextId, + source: "compact", + })) as { hookSpecificOutput?: { additionalContext?: string } }; + const context = payload.hookSpecificOutput?.additionalContext ?? ""; + expect(context).toContain("Status: HERMES CLOSURE"); + expect(context).toContain("Current: WP1"); + expect(context).toContain("Done when: Focused validation is recorded"); + expect(context).toContain("Complete WP1 outcome"); + expect(context).not.toContain("stale persisted text"); + expect(context.match(/Current: WP1/g)).toHaveLength(1); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("gives Codex the same project context index through its first prompt hook", () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-project-index-")); try { @@ -654,21 +709,81 @@ describe("shared-hooks capability table", () => { expect(context).toContain("Hermes closure task: capsule-task"); expect(context).toContain("Route: delivery"); expect(context).toContain("Route rule:"); + expect(context).toContain("Skill route: coder uses before-dev"); + expect(context).toContain(""); + expect(context).toContain( + "Do not regenerate existing work packages for an in-scope continuation", + ); + expect(context).toContain( + ".trellis/tasks/capsule-task/task.json", + ); + expect(context).not.toContain("HANDOFF.md"); 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); + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "# Task Handoff\n\n## Task Revision\n0\n\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + const repeated = JSON.parse(runWorkflowState(repoRoot, contextId)) as { hookSpecificOutput: { additionalContext: string }; }; expect(repeated.hookSpecificOutput.additionalContext).toContain( - "anchor revision", + "anchor revision 0 remains active", ); expect(repeated.hookSpecificOutput.additionalContext).not.toContain( "", ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "", + ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "Skill route: coder uses before-dev", + ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + ".trellis/tasks/capsule-task/HANDOFF.md", + ); + expect(repeated.hookSpecificOutput.additionalContext).not.toContain( + "HANDOFF_BODY_MUST_NOT_BE_INJECTED", + ); + + const stableHandoff = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(stableHandoff.hookSpecificOutput.additionalContext).not.toContain( + "/HANDOFF.md", + ); + + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "# Legacy Task Handoff\n\nLEGACY_HANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + const legacyHandoff = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(legacyHandoff.hookSpecificOutput.additionalContext).toContain( + "Legacy HANDOFF.md has no Task Revision", + ); + expect(legacyHandoff.hookSpecificOutput.additionalContext).not.toContain( + "LEGACY_HANDOFF_BODY_MUST_NOT_BE_INJECTED", + ); + + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "# Task Handoff\n\n## Task Revision\n0\n\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + runWorkflowState(repoRoot, contextId); + + const compacted = JSON.parse(runWorkflowState(repoRoot, contextId, { + source: "compact", + })) as { hookSpecificOutput: { additionalContext: string } }; + expect(compacted.hookSpecificOutput.additionalContext).toContain( + "", + ); const revisedTask = JSON.parse(fs.readFileSync(taskJsonPath, "utf-8")); revisedTask.hermes_revision = 1; @@ -683,6 +798,24 @@ describe("shared-hooks capability table", () => { expect(revised.hookSpecificOutput.additionalContext).toContain( "", ); + expect(revised.hookSpecificOutput.additionalContext).toContain( + "HANDOFF.md is stale", + ); + + revisedTask.hermes_revision = 2; + revisedTask.current_work_package = + "PACKAGE_HEAD " + "x".repeat(2000) + " PACKAGE_TAIL"; + fs.writeFileSync(taskJsonPath, `${JSON.stringify(revisedTask, null, 2)}\n`); + const longAction = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + const resume = + /\n([\s\S]*?)\n<\/task-resume>/.exec( + longAction.hookSpecificOutput.additionalContext, + )?.[1] ?? ""; + expect(resume).toContain("PACKAGE_HEAD"); + expect(resume).not.toContain("PACKAGE_TAIL"); + expect(resume.length).toBeLessThanOrEqual(800); } finally { fs.rmSync(repoRoot, { recursive: true, force: true }); } @@ -1092,12 +1225,20 @@ describe.skipIf(!hasPython())( fs.writeFileSync(path.join(repoRoot, "docs", name), name, "utf-8"); } const created = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "review-source", + "--role", "researcher", "--profile", "codebase", + "--work-package", "WP1", + "--objective", "Locate the bounded evidence inputs.", + ]); + expect(created.status, created.stderr).toBe(0); + const reviewCreated = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "review-evidence", "--role", "reviewer", "--profile", "evidence", + "--work-package", "WP1", "--parent-job-id", "review-source", "--objective", "Review the current package evidence.", "--ref", "docs/direct.md", "--ref", "docs/second.md", "--ref", "docs/third.md", ]); - expect(created.status, created.stderr).toBe(0); + expect(reviewCreated.status, reviewCreated.stderr).toBe(0); const contextId = setSessionActiveTask(repoRoot, taskName, "hermes-role-session"); const result = runPreToolUseHook( repoRoot, @@ -1117,7 +1258,8 @@ describe.skipIf(!hasPython())( const prompt = String(result.hookSpecificOutput?.updatedInput?.prompt ?? ""); expect(prompt).toContain("Hermes Agent Context Firewall dispatch"); expect(prompt).toContain("role: reviewer:evidence"); - expect(prompt).toContain("work_package: null"); + expect(prompt).toContain("work_package: WP1"); + expect(prompt).toContain("parent_job_id: review-source"); expect(prompt).toContain("docs/direct.md"); expect(prompt).not.toContain("docs/fourth.md"); expect(prompt).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); @@ -1140,9 +1282,16 @@ describe.skipIf(!hasPython())( current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + const source = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "legacy-source", + "--role", "researcher", "--profile", "codebase", + "--objective", "Locate the bounded evidence inputs.", + ]); + expect(source.status, source.stderr).toBe(0); const created = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "legacy-evidence", "--role", "reviewer", "--profile", "evidence", + "--parent-job-id", "legacy-source", "--objective", "Evaluate evidence.", ]); expect(created.status, created.stderr).toBe(0); @@ -1630,6 +1779,86 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(decision.permissionDecisionReason).toContain("main agent firewall"); } + function runDispatchCli(...args: string[]) { + return spawnSync( + PYTHON, + [ + path.join(repoRoot, ".trellis", "scripts", "hermes", "dispatch.py"), + ...args, + "--task", + taskName, + ], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); + } + + function applyDispatchResult( + jobId: string, + role: string, + profile: string, + parentJobId?: string, + ) { + const dispatchPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "dispatches", + `${jobId}.dispatch.json`, + ); + const dispatch = JSON.parse(fs.readFileSync(dispatchPath, "utf-8")) as { + task_revision: number; + }; + const envelope: Record = { + schema: "hermes-result/v1", + job_id: jobId, + task_revision: dispatch.task_revision, + role, + profile, + status: "success", + conclusion: `${jobId} completed`, + uncertainties: [], + changed_files: [], + evidence_refs: [], + artifact_refs: [], + verification: { run_refs: [] }, + risks: [], + next_action: "review", + }; + if (parentJobId) envelope.parent_job_id = parentJobId; + return runDispatchCli( + "apply", + "--job-id", + jobId, + "--result-json", + JSON.stringify(envelope), + ); + } + + function markClosureClosed(): void { + const taskPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "task.json", + ); + const task = JSON.parse(fs.readFileSync(taskPath, "utf-8")); + task.closure_state = "closed"; + task.hermes_phase = "closed"; + task.status = "completed"; + fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + } + it("blocks Stop when an active task has no Hermes worker records", () => { const result = runHermesRuntimeGuard(repoRoot, { cwd: repoRoot, @@ -1832,6 +2061,180 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.stderr).toBe(""); }); + it("allows Stop for a recorded experiment despite unrelated worktree changes", () => { + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "src", "app.ts"), "fixture\n"); + commitRepoState(repoRoot, "experiment baseline"); + fs.writeFileSync(path.join(repoRoot, "unrelated-user-note.md"), "keep me\n"); + setSessionActiveTask(repoRoot, taskName); + writeRunManifest(0); + writeHermesWorkerRecords([ + taskCard("job-runner", "runner", { profile: "experiment" }), + checkpoint("job-runner", "experiment-recorded"), + resultRecord("job-runner", "experiment ran", { + changed_files: [], + evidence_refs: ["run-tests"], + }), + taskCard("job-reviewer", "reviewer", { + profile: "evidence", + parent_job_id: "job-runner", + }), + checkpoint("job-reviewer", "evidence-reviewed"), + resultRecord("job-reviewer", "evidence reviewed", { + changed_files: [], + }), + ]); + + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + + it("uses an explicit dispatch parent chain from researcher results through Stop", () => { + const researcher = runDispatchCli( + "create", + "--job-id", + "job-research", + "--role", + "researcher", + "--profile", + "codebase", + "--objective", + "Inspect the bounded implementation context", + ); + expect(researcher.status, researcher.stderr).toBe(0); + const researcherResult = applyDispatchResult( + "job-research", + "researcher", + "codebase", + ); + expect(researcherResult.status, researcherResult.stderr).toBe(0); + + const reviewer = runDispatchCli( + "create", + "--job-id", + "job-review", + "--role", + "reviewer", + "--profile", + "quality", + "--parent-job-id", + "job-research", + "--objective", + "Review the exact researcher result", + ); + expect(reviewer.status, reviewer.stderr).toBe(0); + const reviewerResult = applyDispatchResult( + "job-review", + "reviewer", + "quality", + "job-research", + ); + expect(reviewerResult.status, reviewerResult.stderr).toBe(0); + + markClosureClosed(); + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + expect(stop.stdout).toBe(""); + + const records = fs.readFileSync( + path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "worker_records.jsonl", + ), + "utf-8", + ); + expect(records).toContain('"parent_job_id":"job-research"'); + }); + + it("warns when one legacy same-package parent candidate is used", () => { + markClosureClosed(); + writeHermesWorkerRecords([ + taskCard("job-research", "researcher", { + profile: "codebase", + work_package: "WP1", + }), + checkpoint("job-research", "research-complete"), + resultRecord("job-research", "research complete", { + changed_files: [], + }), + taskCard("job-review", "reviewer", { + profile: "quality", + work_package: "WP1", + }), + checkpoint("job-review", "review-complete"), + resultRecord("job-review", "review complete", { + changed_files: [], + }), + ]); + + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + const decision = parseDecision(stop); + expect(decision.additionalContext).toContain("compatibility warning"); + expect(decision.additionalContext).toContain("sole non-reviewer candidate"); + expect(decision.additionalContext).toContain("parent_job_id"); + }); + + it("blocks ambiguous legacy reviewer records instead of guessing by time", () => { + markClosureClosed(); + writeHermesWorkerRecords([ + taskCard("job-research", "researcher", { + profile: "codebase", + work_package: "WP1", + }), + checkpoint("job-research", "research-complete"), + resultRecord("job-research", "research complete", { + changed_files: [], + }), + taskCard("job-plan", "planner", { + profile: "task_planning", + work_package: "WP1", + }), + checkpoint("job-plan", "plan-complete"), + resultRecord("job-plan", "plan complete", { changed_files: [] }), + taskCard("job-review", "reviewer", { + profile: "quality", + work_package: "WP1", + }), + checkpoint("job-review", "review-complete"), + resultRecord("job-review", "review complete", { + changed_files: [], + }), + ]); + + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + const payload = JSON.parse(stop.stdout) as { + decision?: string; + reason?: string; + }; + expect(payload.decision).toBe("block"); + expect(payload.reason).toContain("related independent reviewer record"); + }); + it("denies main-agent Write before worker file-boundary checks", () => { writeHermesWorkerRecords([ taskCard("job-coder", "coder"), @@ -1919,11 +2322,31 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("allows parameterized Hermes control-plane commands for the main agent", () => { + it("allows the bounded main-agent control and Git read matrix", () => { for (const command of [ "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo --role coder --objective bounded", - "python3 ./.trellis/scripts/closure.py audit --task demo", + "python3 ./.trellis/scripts/hermes/dispatch.py supersede --task demo --job-id old --reason stale", "python3 ./.trellis/scripts/task.py archive demo --no-commit", + "python3 ./.trellis/scripts/task.py set-scope demo local", + "python3 ./.trellis/scripts/closure.py plan --task demo --intent bounded", + "python3 ./.trellis/scripts/closure.py route --task demo --route delivery", + "python3 ./.trellis/scripts/closure.py grill --task demo --complete --decision-ref design.md", + "python3 ./.trellis/scripts/closure.py validate --task demo", + "python3 ./.trellis/scripts/closure.py status --task demo", + "python3 ./.trellis/scripts/closure.py next --task demo", + "python3 ./.trellis/scripts/closure.py capsule --task demo", + "python3 ./.trellis/scripts/closure.py package-start --task demo", + "python3 ./.trellis/scripts/closure.py package-check --task demo", + "python3 ./.trellis/scripts/closure.py package-done --task demo", + "python3 ./.trellis/scripts/closure.py package-block --task demo --reason blocked", + "python3 ./.trellis/scripts/closure.py amend --task demo --field intent --value bounded --reason update", + "python3 ./.trellis/scripts/closure.py repair --task demo", + "python3 ./.trellis/scripts/closure.py audit --task demo", + "python3 ./.trellis/scripts/closure.py close --task demo", + "git branch --show-current", + "git status --short -- src/app.ts", + "git diff --name-only -- src/app.ts", + "git log --oneline -n5", ]) { const result = runHermesRuntimeGuard(repoRoot, { cwd: repoRoot, @@ -1935,9 +2358,6 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); expect(result.stdout).toBe(""); } - expectMainAgentBashDenied( - "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo; touch src/app.ts", - ); }); it("allows main-agent git status short paths as read-only Bash", () => { @@ -1972,102 +2392,28 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.stderr).toBe(""); }); - it("denies main-agent git pathspecs with shell expansion syntax", () => { - for (const command of [ - "git diff --name-only -- $PWD", - "git diff --name-only -- ${PWD}", - "git diff --name-only -- $(pwd)", - "git diff --name-only -- `pwd`", - "git diff --name-only -- ~", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("allows main-agent git log oneline with an explicit safe pathspec", () => { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - tool_name: "Bash", - tool_input: { - command: "git log --oneline -- src/app.ts", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("denies main-agent git pathspecs that target env-like paths", () => { - for (const command of [ - "git status --short -- .", - "git diff --name-only -- .ENV", - "git diff --name-only -- ENV/config.ts", - "git diff --name-only -- config/prod.env", - "git diff --name-only -- env/config.ts", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent git summaries without explicit safe pathspecs", () => { - for (const command of [ - "git status", - "git diff --name-only", - "git diff --stat", - "git log --oneline", - "git log --stat", - "git diff --name-only -- '*env*'", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent read-only-looking Bash with shell control or redirection", () => { - for (const command of [ - "git status && cat .trellis/tasks/06-04-hermes-runtime/hermes/worker_records.jsonl", - "git status > out.txt", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent jq commands that can read non-RecordBus files", () => { - for (const command of [ - `jq . .trellis/tasks/${taskName}/hermes/worker_records.jsonl .env`, - `jq . .trellis/tasks/${taskName}/hermes/worker_records.jsonl tmp/extra.jsonl`, - `jq . .trellis/tasks/${taskName}/hermes/../worker_records.jsonl`, - `jq -f filter.jq .trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - `jq --rawfile secret .env . .trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent git commands with unsafe read-only options", () => { - for (const command of [ - "git diff --output patch.txt", - "git diff --output=patch.txt", - "git diff --ext-diff", - "git diff --external-diff", - "git --exec-path=/tmp diff", - "git --config core.pager=cat diff", - "git -c core.pager=cat diff", - "git diff --no-index .env /dev/null", - "git log -p -- .env", - ]) { - expectMainAgentBashDenied(command); - } - }); it("denies main-agent execution and mutating git Bash commands", () => { for (const command of [ "rm -rf dist", + "npm test", + "pnpm build", "git add src/app.ts", "git commit -m test", "git push", + "git diff --output=diff.txt", + "git diff --ext-diff", + "git diff --no-index left right", + "git diff --name-only -- .env.local", + "git status --short -- .envrc", + "cat .trellis/tasks/demo/task.json", + "jq . .trellis/tasks/demo/task.json", + "python3 ./.trellis/scripts/hermes/experiment.py validate --task demo", + "python3 ./.trellis/scripts/hermes/runner.py run --task demo", + "python3 ./.trellis/scripts/closure.py handoff --task demo", + "python3 ./.trellis/scripts/task.py archive demo", + "python3 ./.trellis/scripts/closure.py audit --task demo > audit.txt", + "python3 ./.trellis/scripts/closure.py audit --task demo && touch src/app.ts", "pytest", "go test ./...", "cargo test", @@ -2082,7 +2428,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { "trellis-code-architecture-review", "trellis-merge-review", "trellis-improve-codebase-architecture", - ])("treats %s as a non-main review gate subagent", (subagentType) => { + ])("keeps %s inside the reviewer write boundary", (subagentType) => { writeHermesWorkerRecords([ taskCard("job-review", "reviewer"), heartbeat("job-review"), @@ -2101,27 +2447,30 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); const decision = parseDecision(result); - expect(decision.permissionDecision).not.toBe("deny"); - expect(decision.additionalContext).toContain("Hermes Runtime"); - }); - - it("treats common subagent identity fields as non-main requests", () => { - for (const identity of [ - { subagent_type: "coder" }, - { subagentType: "builder" }, - { subagent: "runner" }, - { agent_role: "hermes-coder" }, - { agent_role: "hermes-scientist" }, - { agent_role: "scientist" }, - { agent_role: "claim-reviewer" }, - { agent_role: "research/scout" }, - { subagent_type: "trellis-implement" }, - { subagent_type: "trellis-spec-review" }, - { subagent_type: "trellis-code-architecture-review" }, - ]) { + expect(decision.permissionDecision).toBe("deny"); + expect(decision.permissionDecisionReason).toContain( + "reviewer cannot write implementation files", + ); + }); + + it("binds common subagent identity fields to their task-card role", () => { + const identities: [Record, string][] = [ + [{ subagent_type: "coder" }, "coder"], + [{ subagentType: "builder" }, "coder"], + [{ subagent: "runner" }, "runner"], + [{ agent_role: "hermes-coder" }, "coder"], + [{ agent_role: "hermes-scientist" }, "planner"], + [{ agent_role: "scientist" }, "planner"], + [{ agent_role: "claim-reviewer" }, "reviewer"], + [{ agent_role: "research/scout" }, "researcher"], + [{ subagent_type: "trellis-implement" }, "coder"], + [{ subagent_type: "trellis-spec-review" }, "reviewer"], + [{ subagent_type: "trellis-code-architecture-review" }, "reviewer"], + ]; + for (const [identity, role] of identities) { writeHermesWorkerRecords([ - taskCard("job-coder", "coder"), - heartbeat("job-coder"), + taskCard("job-role", role), + heartbeat("job-role"), ]); const result = runHermesRuntimeGuard(repoRoot, { @@ -2137,11 +2486,38 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); const decision = parseDecision(result); - expect(decision.permissionDecision).not.toBe("deny"); - expect(decision.additionalContext).toContain("Hermes Runtime"); + if (role === "coder") { + expect(decision.permissionDecision).not.toBe("deny"); + expect(decision.additionalContext).toContain("Hermes Runtime"); + } else { + expect(decision.permissionDecision).toBe("deny"); + expect(decision.permissionDecisionReason).not.toContain( + "main agent firewall", + ); + } } }); + it("allows reviewer records only inside the task review directory", () => { + writeHermesWorkerRecords([ + taskCard("job-review", "reviewer", { + allowed_files: [`.trellis/tasks/${taskName}/hermes/reviews/**`], + }), + heartbeat("job-review"), + ]); + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "reviewer", + tool_name: "Write", + tool_input: { + file_path: `.trellis/tasks/${taskName}/hermes/reviews/quality.md`, + }, + session_id: "test-session", + }); + expect(parseDecision(result).permissionDecision).not.toBe("deny"); + }); + it("denies main-agent writes without an active task while keeping read-only git allowed", () => { writeIdleSession(); @@ -2263,7 +2639,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("lets runner Bash execution pass through existing record policy", () => { + it("allows runner Bash only through the registered runner command", () => { writeHermesWorkerRecords([ taskCard("job-runner", "runner"), heartbeat("job-runner"), @@ -2275,7 +2651,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { agent_role: "runner", tool_name: "Bash", tool_input: { - command: "pnpm test", + command: `python3 ./.trellis/scripts/hermes/runner.py validate --task ${taskName}`, }, session_id: "test-session", }); @@ -2291,6 +2667,16 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(payload.hookSpecificOutput?.additionalContext).toContain( "Hermes Runtime", ); + + const direct = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "runner", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + session_id: "test-session", + }); + expect(parseDecision(direct).permissionDecision).toBe("deny"); }); it("denies PreToolUse write tools when an active task has no Hermes worker records", () => { @@ -2860,356 +3246,6 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("denies PreToolUse Bash commands that redirect to unauthorized files", () => { - const hermesDir = path.join( - repoRoot, - ".trellis", - "tasks", - taskName, - "hermes", - ); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.writeFileSync( - path.join(hermesDir, "worker_records.jsonl"), - `${JSON.stringify({ - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-active", - role: "coder", - worktree_id: "main", - status: "queued", - allowed_files: ["src/**"], - forbidden_files: ["secrets/**"], - heartbeat_interval: "5m", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: `.trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - evidence_refs: [], - risk_flags: [], - })}\n${JSON.stringify({ - type: "heartbeat", - id: "hb-20260629-000100-demo", - timestamp: "2026-06-29T00:01:00Z", - job_id: "job-active", - status: "running", - checkpoint: "files-read", - summary: "active", - next_check_at: "2099-01-01T00:00:00Z", - })}\n`, - ); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf secret > secrets/out.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "outside allowed_files", - ); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "secrets/out.txt", - ); - }); - - it("denies PreToolUse Bash tee commands when any target is unauthorized", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf ok | tee src/ok.txt docs/bad.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "outside allowed_files", - ); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "docs/bad.txt", - ); - }); - - it("treats tee operands after -- as targets even when they start with a dash", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf ok | tee -- -secret src/ok.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const decision = parseDecision(result); - expect(decision.permissionDecision).toBe("deny"); - expect(decision.permissionDecisionReason).toContain("outside allowed_files"); - expect(decision.permissionDecisionReason).toContain("-secret"); - }); - - it("denies PreToolUse Bash commands with write-like content when targets cannot be parsed", () => { - const hermesDir = path.join( - repoRoot, - ".trellis", - "tasks", - taskName, - "hermes", - ); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.writeFileSync( - path.join(hermesDir, "worker_records.jsonl"), - `${JSON.stringify({ - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-active", - role: "coder", - worktree_id: "main", - status: "queued", - allowed_files: ["src/**"], - forbidden_files: [], - heartbeat_interval: "5m", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: `.trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - evidence_refs: [], - risk_flags: [], - })}\n${JSON.stringify({ - type: "heartbeat", - id: "hb-20260629-000100-demo", - timestamp: "2026-06-29T00:01:00Z", - job_id: "job-active", - status: "running", - checkpoint: "files-read", - summary: "active", - next_check_at: "2099-01-01T00:00:00Z", - })}\n`, - ); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: - "python3 -c \"import pathlib, os; pathlib.Path(os.environ['TARGET']).write_text('x')\"", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - }); - - it("denies PreToolUse Bash scripts with unparsed destructive file APIs", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "python3 -c \"import os; os.remove('docs/bad.txt')\"", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - }); - - it("denies PreToolUse Node inline async file APIs when targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "node -e \"require('fs').writeFile('docs/bad.txt','x',()=>{})\"", - "node -e \"require('fs').copyFileSync('src/app.ts','docs/bad.txt')\"", - "node -e \"const fs = require('fs'); fs.promises.writeFile('docs/bad.txt','x')\"", - "node -e \"require('fs').promises.copyFile('src/app.ts','docs/bad.txt')\"", - "node -e \"const fs = require('fs'); fs.promises.rm('docs/bad.txt')\"", - "node -pe \"const { writeFileSync: w } = require('node:fs'); w('docs/bad.txt','x')\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); - - it("denies PreToolUse Python, Ruby, and Perl inline copy and move APIs when targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "python3 -c \"import shutil; shutil.copyfile('src/app.ts','docs/bad.txt')\"", - "python3 -c \"import shutil; shutil.move('src/app.ts','docs/bad.txt')\"", - "ruby -e \"require 'fileutils'; FileUtils.cp('src/app.ts','docs/bad.txt')\"", - "perl -e \"use File::Copy; move('src/app.ts','docs/bad.txt')\"", - "perl -e \"unlink 'docs/bad.txt'\"", - "perl -e \"rename 'src/app.ts','docs/bad.txt'\"", - "perl -we \"unlink 'docs/bad.txt'\"", - "perl -we \"rename 'src/app.ts','docs/bad.txt'\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); - - it("denies inline file API aliases when exact targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "python3 -c \"from shutil import copyfile as c; c('src/app.ts','docs/bad.txt')\"", - "node -e \"const { writeFileSync: w } = require('fs'); w('docs/bad.txt','x')\"", - "node -e \"const { writeFileSync: w } = require('node:fs'); w('docs/bad.txt','x')\"", - "node -e \"import { writeFileSync as w } from 'node:fs'; w('docs/bad.txt','x')\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const decision = parseDecision(result); - expect(decision.permissionDecision).toBe("deny"); - expect(decision.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); it("denies PreToolUse write tools when Hermes worker records are invalid", () => { const hermesDir = path.join( diff --git a/packages/cli/test/templates/trellis.test.ts b/packages/cli/test/templates/trellis.test.ts index 5f47d7f..2ae91b4 100644 --- a/packages/cli/test/templates/trellis.test.ts +++ b/packages/cli/test/templates/trellis.test.ts @@ -222,17 +222,15 @@ describe("trellis template constants", () => { expect(block).toMatch(/codex|copilot|gemini|qoder/); }); - it("[issue-237] workflow.md in_progress breadcrumb self-exempts implement/check sub-agents", () => { + it("[issue-237] workflow.md in_progress breadcrumb prevents recursive sub-agent dispatch", () => { // The in_progress breadcrumb may be injected into sub-agent turns on some // hosts, so its main-session dispatch guidance must not recursively apply // to a sub-agent that is already doing the requested work. const block = inProgressBreadcrumb(); expect(block).toContain("Main-session default"); expect(block).toContain("Sub-agent self-exemption"); - expect(block).toContain("already running as `trellis-implement`"); - expect(block).toContain("do NOT spawn another `trellis-implement`"); - expect(block).toContain("already running as `trellis-check`"); - expect(block).toContain("do NOT spawn another `trellis-check`"); + expect(block).toContain("an active sub-agent never dispatches another worker"); + expect(block).toContain("immediately close that subagent"); expect(block).toContain("main session only"); }); @@ -243,8 +241,7 @@ describe("trellis template constants", () => { expect(block).toContain("Review-gate contract: explicit-selection-v1"); expect(block).toContain("Optional review gates status: configured"); expect(block).toContain("trellis-check"); - expect(block).toContain("merge if needed"); - expect(block).toContain("build/test"); + expect(block).toContain("runner performs final validation/archive actions"); expect(block).toContain("trellis-code-architecture-review"); expect(block).toContain("does not by itself enable or block deep-review"); }); @@ -346,8 +343,9 @@ describe("trellis template constants", () => { "utf-8", ); expect(brainstorm).toContain("Do not enter `trellis-grill-me` only because a task is legacy or complex"); - expect(start).toContain("For a Lean Research Closure task, read the Task Capsule first"); - expect(start).toContain("legacy or explicitly complex Claude Code tasks"); + expect(start).toContain("For a Hermes closure task"); + expect(start).toContain("run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task ` first"); + expect(start).toContain("code-heavy complex tasks add `design.md` and `implement.md`"); }); it("workflow.md planning breadcrumb records Claude Code development strategy decisions before start", () => { @@ -433,9 +431,6 @@ describe("trellis template constants", () => { expect(config).toContain("approval_records"); expect(config).toContain('experiment: ".trellis/tasks/{task}/hermes/experiment.yaml"'); expect(config).toContain('run_manifest: ".trellis/tasks/{task}/hermes/run_manifest.jsonl"'); - expect(config).toContain("sandbox:"); - expect(config).toContain('mode: "none"'); - expect(config).toContain("required: false"); expect(config).toContain("heartbeat_beat"); expect(config).toContain("heartbeat_watch"); expect(config).toContain("runner_run"); @@ -448,8 +443,8 @@ describe("trellis template constants", () => { expect(config).toContain("service_enqueue"); expect(config).toContain("service_cancel"); expect(config).toContain("append-only"); - expect(config).toContain("deployment candidate hardening"); - expect(config).toContain("not an OS sandbox"); + expect(config).toContain("lightweight research collaboration"); + expect(config).toContain("runs directly in the project workspace"); expect(config).not.toContain("claim_allowed"); for (const state of [ @@ -530,7 +525,7 @@ describe("trellis template constants", () => { expect(recordBus).toContain("provenance"); expect(recordBus).toContain("audit"); expect(recordBus).toContain("service queue"); - expect(recordBus).toContain("not an OS sandbox"); + expect(recordBus).toContain("lightweight local collaboration tool"); expect(recordBus).toContain("report.md"); expect(recordBus).toContain("HumanGate"); @@ -599,6 +594,14 @@ describe("trellis template constants", () => { expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( "Main Agent / Main Pilot", ); + expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( + "Routing Priority", + ); + expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( + "Do not wait for the user to invoke a command", + ); + expect(workflowMdTemplate).toContain("用户入口优先级"); + expect(workflowMdTemplate).toContain("主代理也必须在相应条件出现时自动触发"); expect(templates.get("metrics/metrics_schema.yaml")).toContain("HumanGate"); expect(templates.get("reports/report.md")).toContain("Core Conclusions"); expect(templates.get("schemas/result-envelope.schema.json")).toContain( diff --git a/packages/cli/test/utils/codex-config.test.ts b/packages/cli/test/utils/codex-config.test.ts new file mode 100644 index 0000000..f08d25d --- /dev/null +++ b/packages/cli/test/utils/codex-config.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { getConfigTemplate } from "../../src/templates/codex/index.js"; +import { + CODEX_CONFIG_BLOCK_END, + CODEX_CONFIG_BLOCK_START, + CODEX_MODEL_BLOCK_END, + CODEX_MODEL_BLOCK_START, + CODEX_TABLES_BLOCK_END, + CODEX_TABLES_BLOCK_START, + isTrellisCodexConfigMerge, + mergeTrellisCodexConfig, + stripTrellisCodexConfig, +} from "../../src/utils/codex-config.js"; + +const TEMPLATE = getConfigTemplate().content; + +function withoutMarkers(content: string): string { + return content + .replace(`${CODEX_CONFIG_BLOCK_START}\n`, "") + .replace(`\n${CODEX_CONFIG_BLOCK_END}`, "") + .replace(`${CODEX_MODEL_BLOCK_START}\n`, "") + .replace(`\n${CODEX_MODEL_BLOCK_END}`, "") + .replace(`${CODEX_TABLES_BLOCK_START}\n`, "") + .replace(`\n${CODEX_TABLES_BLOCK_END}`, ""); +} + +describe("mergeTrellisCodexConfig", () => { + it("refreshes managed settings without replacing a user-selected model", () => { + const existing = `${TEMPLATE.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ) + .replace( + 'model_reasoning_effort = "high"', + 'model_reasoning_effort = "medium"', + ) + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + )} +[local] +keep = true +`; + const nextTemplate = TEMPLATE.replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 4", + ); + + const merged = mergeTrellisCodexConfig(existing, nextTemplate); + + expect(merged.content).toContain('model = "gpt-5.6-custom"'); + expect(merged.content).toContain('model_reasoning_effort = "medium"'); + expect(merged.content).toContain("max_concurrent_threads_per_session = 4"); + expect(merged.content).toContain("[local]\nkeep = true"); + expect(merged.migratedLegacyDefaults).toBe(false); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("migrates only the exact legacy default pair", () => { + const legacy = withoutMarkers(TEMPLATE); + const migrated = mergeTrellisCodexConfig(legacy, TEMPLATE); + + expect(migrated.migratedLegacyDefaults).toBe(true); + expect(migrated.content).toContain(CODEX_MODEL_BLOCK_START); + expect(migrated.content).toContain(CODEX_MODEL_BLOCK_END); + expect(migrated.content).not.toContain(CODEX_CONFIG_BLOCK_START); + expect(isTrellisCodexConfigMerge(legacy, migrated.content)).toBe(true); + }); + + it("installs managed settings around an unmarked user config without replacing its model", () => { + const existing = `model = "my-project-model" +model_reasoning_effort = "low" + +[project] +name = "example" +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.changed).toBe(true); + expect(merged.migratedLegacyDefaults).toBe(false); + expect(merged.content).toContain('model = "my-project-model"'); + expect(merged.content).toContain('model_reasoning_effort = "low"'); + expect(merged.content).not.toContain('model = "gpt-5.6-sol"'); + expect(merged.content).toContain(CODEX_CONFIG_BLOCK_START); + expect(merged.content).toContain(CODEX_TABLES_BLOCK_START); + expect(merged.content).toContain("[project]\nname = \"example\""); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("does not alter malformed marker blocks", () => { + const existing = `${CODEX_CONFIG_BLOCK_START} +model = "my-project-model" +`; + + expect(mergeTrellisCodexConfig(existing, TEMPLATE).content).toBe(existing); + }); + + it("does not duplicate a user-owned Codex table when adding managed settings", () => { + const existing = `[features.multi_agent_v2] +enabled = false +max_concurrent_threads_per_session = 1 +`; + const merged = mergeTrellisCodexConfig(existing, TEMPLATE).content; + + expect(merged.match(/\[features\.multi_agent_v2\]/g)).toHaveLength(1); + expect(merged).toContain("enabled = false"); + expect(merged).toContain("[hermes.context_firewall]"); + }); +}); + +describe("stripTrellisCodexConfig", () => { + it("keeps a custom model while removing the managed block", () => { + const custom = TEMPLATE.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ); + + expect(stripTrellisCodexConfig(custom)).toBe( + 'model = "gpt-5.6-custom"\nmodel_reasoning_effort = "high"\n', + ); + }); +}); diff --git a/packages/cli/test/utils/codex-version.test.ts b/packages/cli/test/utils/codex-version.test.ts new file mode 100644 index 0000000..f498361 --- /dev/null +++ b/packages/cli/test/utils/codex-version.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + codexVersionWarning, + parseCodexVersion, + probeCodexVersion, +} from "../../src/utils/codex-version.js"; + +describe("parseCodexVersion", () => { + it("reads stable, v-prefixed, and prerelease versions", () => { + expect(parseCodexVersion("codex-cli 0.144.0")).toBe("0.144.0"); + expect(parseCodexVersion("codex v0.145.0-beta.1+build.7")).toBe( + "0.145.0-beta.1+build.7", + ); + }); + + it("returns null when no semantic version is available", () => { + expect(parseCodexVersion("codex-cli development build")).toBeNull(); + }); +}); + +describe("probeCodexVersion", () => { + it("classifies supported and unsupported releases", () => { + expect(probeCodexVersion(() => "codex-cli 0.144.0")).toMatchObject({ + status: "available", + version: "0.144.0", + supported: true, + }); + expect(probeCodexVersion(() => "codex-cli 0.144.0-rc.1")).toMatchObject({ + status: "available", + version: "0.144.0-rc.1", + supported: false, + }); + expect(probeCodexVersion(() => "codex-cli 0.145.0-beta.1")).toMatchObject({ + status: "available", + version: "0.145.0-beta.1", + supported: true, + }); + }); + + it("distinguishes a missing binary from unreadable output", () => { + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + + expect( + probeCodexVersion(() => { + throw missing; + }), + ).toMatchObject({ + status: "missing", + raw: null, + version: null, + supported: false, + }); + expect(probeCodexVersion(() => "not a version")).toMatchObject({ + status: "unreadable", + raw: "not a version", + version: null, + supported: false, + }); + }); +}); + +describe("codexVersionWarning", () => { + it("explains unsupported and unavailable installations", () => { + expect( + codexVersionWarning({ + status: "available", + raw: "codex-cli 0.143.9", + version: "0.143.9", + supported: false, + }), + ).toContain("0.143.9 is below 0.144.0"); + expect( + codexVersionWarning({ + status: "missing", + raw: null, + version: null, + supported: false, + }), + ).toContain("not found on PATH"); + }); +}); diff --git a/packages/cli/test/utils/uninstall-scrubbers.test.ts b/packages/cli/test/utils/uninstall-scrubbers.test.ts index ec03fd6..1a9380c 100644 --- a/packages/cli/test/utils/uninstall-scrubbers.test.ts +++ b/packages/cli/test/utils/uninstall-scrubbers.test.ts @@ -14,6 +14,7 @@ import { scrubPiSettings, scrubCodexConfigToml, } from "../../src/utils/uninstall-scrubbers.js"; +import { getConfigTemplate } from "../../src/templates/codex/index.js"; const CLAUDE_DELETE_PATHS = [ ".claude/hooks/session-start.py", @@ -270,7 +271,8 @@ describe("scrubHooksJson — flat schema", () => { { type: "command", bash: "python3 .github/copilot/hooks/inject-workflow-state.py", - powershell: "python3 .github/copilot/hooks/inject-workflow-state.py", + powershell: + "python3 .github/copilot/hooks/inject-workflow-state.py", timeoutSec: 5, }, ], @@ -465,4 +467,31 @@ project_doc_fallback_filenames = ["AGENTS.md"] expect(fullyEmpty).toBe(true); expect(content.trim()).toBe(""); }); + + it("preserves a custom model from the managed config block", () => { + const custom = getConfigTemplate().content.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ); + + const { content, fullyEmpty } = scrubCodexConfigToml(custom); + + expect(fullyEmpty).toBe(false); + expect(content).toBe( + 'model = "gpt-5.6-custom"\nmodel_reasoning_effort = "high"\n', + ); + }); + + it("removes the current unmarked legacy Codex defaults", () => { + const legacy = getConfigTemplate() + .content.replace("# TRELLIS:CODEX_CONFIG:START\n", "") + .replace("\n# TRELLIS:CODEX_CONFIG:END", "") + .replace("# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", "") + .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", ""); + + const { content, fullyEmpty } = scrubCodexConfigToml(legacy); + + expect(fullyEmpty).toBe(true); + expect(content).toBe(""); + }); }); diff --git a/packages/core/src/task/schema.ts b/packages/core/src/task/schema.ts index 3c31dd7..4b54695 100644 --- a/packages/core/src/task/schema.ts +++ b/packages/core/src/task/schema.ts @@ -86,6 +86,7 @@ export interface TrellisTaskRecord { research_route?: ResearchRoute; research_change_fields?: string[]; grill_completed?: boolean; + decision_ref?: string | null; constraints?: ClosureConstraints; work_packages?: ClosureWorkPackage[]; current_work_package?: string | null; @@ -137,6 +138,7 @@ export const TASK_RECORD_FIELD_ORDER = [ "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package", @@ -181,6 +183,7 @@ const NULLABLE_STRING_FIELDS: ReadonlySet = new Set([ "parent", "current_work_package", "next_action", + "decision_ref", ]); const STRING_ARRAY_FIELDS: ReadonlySet = new Set([ @@ -208,6 +211,7 @@ const OPTIONAL_CLOSURE_FIELDS: ReadonlySet = new Set([ "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package", @@ -438,6 +442,7 @@ export function emptyTaskRecord( research_route: "delivery", research_change_fields: [], grill_completed: false, + decision_ref: null, constraints: { excluded_platforms: [], excluded_paths: [], diff --git a/packages/core/test/task/schema.test.ts b/packages/core/test/task/schema.test.ts index 2234501..9dc9932 100644 --- a/packages/core/test/task/schema.test.ts +++ b/packages/core/test/task/schema.test.ts @@ -29,6 +29,7 @@ describe("emptyTaskRecord", () => { expect(record.research_route).toBe("delivery"); expect(record.research_change_fields).toEqual([]); expect(record.grill_completed).toBe(false); + expect(record.decision_ref).toBeNull(); expect(record.constraints).toEqual({ excluded_platforms: [], excluded_paths: [], @@ -121,6 +122,7 @@ describe("taskRecordSchema", () => { "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package",