diff --git a/.codex/config.toml b/.codex/config.toml index 693214d..9a7287d 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -25,11 +25,11 @@ project_doc_fallback_filenames = ["AGENTS.md"] # [agents].max_threads (codex will reject the combination). # `enabled = true` is required inside the table — the table form does # NOT auto-enable the feature without it. -# - max_concurrent_threads_per_session: bumps default 4 → 6. -# - min_wait_timeout_ms: 480000 ms = 8 min. Codex default is 10 s, too -# short for Trellis subagents that routinely take 2-10 min. Hard -# ceiling is 3,600,000 (1 h). +# - max_concurrent_threads_per_session: keeps at most three bounded workers. +# - min/default wait timeout: 120000 ms = 2 min. Codex requires the default +# to be greater than or equal to the configured minimum. [features.multi_agent_v2] enabled = false -max_concurrent_threads_per_session = 6 -min_wait_timeout_ms = 480000 +max_concurrent_threads_per_session = 3 +min_wait_timeout_ms = 120000 +default_wait_timeout_ms = 120000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d324fa..4651d34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,18 +51,35 @@ jobs: package-smoke: needs: verify - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - node-version: [18, 20, 22, 24] - name: Package smoke (Node.js ${{ matrix.node-version }}) + include: + - os: ubuntu-latest + node-version: 18 + - os: ubuntu-latest + node-version: 20 + - os: ubuntu-latest + node-version: 22 + - os: ubuntu-latest + node-version: 24 + - os: macos-latest + node-version: 24 + - os: windows-latest + node-version: 24 + name: Package/project smoke (${{ matrix.os }}, Node.js ${{ matrix.node-version }}) steps: - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Download package artifacts uses: actions/download-artifact@v8 with: @@ -74,14 +91,13 @@ jobs: run: | smoke_dir="${RUNNER_TEMP}/package-smoke" mkdir "${smoke_dir}" - mapfile -t tarballs < <(find "${RUNNER_TEMP}/package-artifacts" -type f -name '*.tgz' -print) - if [ "${#tarballs[@]}" -ne 2 ]; then - echo "Expected exactly two package tarballs, found ${#tarballs[@]}" >&2 - exit 1 - fi + core_tarball="$(find "${RUNNER_TEMP}/package-artifacts" -type f -name 'research-trellis-core-*.tgz' -print -quit)" + cli_tarball="$(find "${RUNNER_TEMP}/package-artifacts" -type f -name 'research-trellis-*.tgz' ! -name 'research-trellis-core-*.tgz' -print -quit)" + test -n "${core_tarball}" + test -n "${cli_tarball}" cd "${smoke_dir}" - npm install --ignore-scripts "${tarballs[@]}" + npm install --ignore-scripts "${core_tarball}" "${cli_tarball}" expected_version="$(node -p "require('./node_modules/research-trellis/package.json').version")" core_version="$(node -p "require('./node_modules/research-trellis-core/package.json').version")" @@ -140,3 +156,96 @@ jobs: ]; await Promise.all(specifiers.map((specifier) => import(specifier))); NODE + + project_dir="${RUNNER_TEMP}/research-trellis-project-${{ matrix.node-version }}" + mkdir "${project_dir}" + cd "${project_dir}" + "${smoke_dir}/node_modules/.bin/research-trellis" init -y -u ci --codex --no-monorepo + + node --input-type=module <<'NODE' + import fs from "node:fs"; + + const configPath = ".codex/config.toml"; + let config = fs.readFileSync(configPath, "utf8"); + const minimum = Number(config.match(/min_wait_timeout_ms\s*=\s*(\d+)/)?.[1]); + const defaultWait = Number(config.match(/default_wait_timeout_ms\s*=\s*(\d+)/)?.[1]); + if (!(minimum > 0 && defaultWait >= minimum)) { + throw new Error(`Invalid Codex wait defaults: min=${minimum}, default=${defaultWait}`); + } + if (config.includes("[hermes.context_firewall]")) { + throw new Error("Generated Codex config contains an unsupported Hermes table"); + } + + for (const marker of [ + "# TRELLIS:CODEX_CONFIG:START\n", + "\n# TRELLIS:CODEX_CONFIG:END", + "# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", + "\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", + "# TRELLIS:CODEX_TABLES:START\n", + "\n# TRELLIS:CODEX_TABLES:END", + ]) { + config = config.replace(marker, ""); + } + config = config + .replace("model = \"gpt-5.6-sol\"", "model = \"gpt-5.6-custom\"") + .replace( + "model_reasoning_effort = \"high\"", + "model_reasoning_effort = \"medium\"", + ) + .replace("min_wait_timeout_ms = 120000", "min_wait_timeout_ms = 480000") + .replace("default_wait_timeout_ms = 120000\n", ""); + config += '\n[hermes.context_firewall]\nnative_authority = "protocol"\n'; + fs.writeFileSync(configPath, config); + fs.writeFileSync(".trellis/.version", "0.7.1-beta.0"); + NODE + + "${smoke_dir}/node_modules/.bin/research-trellis" update < /dev/null + + node --input-type=module <<'NODE' + import fs from "node:fs"; + import { spawnSync } from "node:child_process"; + + const config = fs.readFileSync(".codex/config.toml", "utf8"); + if ( + !config.includes('model = "gpt-5.6-custom"') || + !config.includes('model_reasoning_effort = "medium"') + ) { + throw new Error("Legacy custom Codex model settings were not preserved"); + } + if (!config.includes("min_wait_timeout_ms = 480000")) { + throw new Error("Legacy minimum wait was not preserved"); + } + if (!config.includes("default_wait_timeout_ms = 480000")) { + throw new Error("Legacy default wait was not repaired"); + } + if (config.includes("[hermes.context_firewall]")) { + throw new Error("Legacy unsupported Hermes table was not removed"); + } + + const hooks = JSON.parse(fs.readFileSync(".codex/hooks.json", "utf8")); + const commands = []; + const visit = (value) => { + if (Array.isArray(value)) return value.forEach(visit); + if (!value || typeof value !== "object") return; + if (typeof value.command === "string") commands.push(value.command); + Object.values(value).forEach(visit); + }; + visit(hooks); + const command = commands.find((item) => + item.includes("inject-workflow-state.py"), + ); + if (!command) throw new Error("Codex workflow hook command is missing"); + const [executable, ...args] = command.split(" "); + const hook = spawnSync(executable, args, { + cwd: process.cwd(), + input: "{}\n", + encoding: "utf8", + }); + if (hook.status !== 0) { + throw new Error(`Codex workflow hook failed: ${hook.stderr}`); + } + NODE + + if [ "${{ matrix.os }}" = "ubuntu-latest" ] && [ "${{ matrix.node-version }}" = "24" ]; then + npx --yes @openai/codex@0.145.0 --version >/dev/null + fi diff --git a/README.md b/README.md index f4320cb..043a118 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -108,7 +108,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -204,7 +204,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 +每次正式角色派发先生成绑定 `plan_revision`(计划修订号)的结构化文件,并记录创建时的 `state_revision`(状态修订号)。只有计划实际变化才会让旧派发过期,并行结果登记不会互相失效。目标正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index faab496..cf5b08a 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,7 +11,7 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.7.1-beta.2`(测试版)。 +- 接受当前版本为 `0.7.1-beta.3`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 @@ -45,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "AGPL|Hermes|未实现" README.md docs/manual/appendix-f-feature-status.md`(静态核对)。 - 结果:项目定位、上游归属、实验性与未实现边界已对齐。 diff --git a/docs/manual/02-installation.md b/docs/manual/02-installation.md index a1a0e99..c9d67c5 100644 --- a/docs/manual/02-installation.md +++ b/docs/manual/02-installation.md @@ -28,7 +28,7 @@ git --version 新包发布后,推荐固定安装本手册对应的测试版: ```bash -npm install --global research-trellis@0.7.1-beta.2 +npm install --global research-trellis@0.7.1-beta.3 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.7.1-beta.2 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.1-beta.3 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.7.1-beta.2 +npm install --global research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.7.1-beta.2`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.1-beta.3`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.7.1-beta.2`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.1-beta.3`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index f3d21ff..55c3c17 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "experiment.py|task.py start|--codex" packages/cli/src packages/cli/test`(实现与测试核对)。 - 结果:初始化、任务创建、实验校验和启动步骤与当前入口一致。 diff --git a/docs/manual/04-how-it-works.md b/docs/manual/04-how-it-works.md index 23b6175..a436f6b 100644 --- a/docs/manual/04-how-it-works.md +++ b/docs/manual/04-how-it-works.md @@ -62,7 +62,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "state_machine|approval-gate|task.py" packages/cli/src/templates`(协议与实现核对)。 - 结果:任务生命周期有实现支持;科研状态文件已准确标为协议模板。 diff --git a/docs/manual/05-project-setup.md b/docs/manual/05-project-setup.md index dc4c41f..e66ffb6 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "options.yes|registry|selectedTemplate" packages/cli/src/commands/init.ts packages/cli/test/commands/init.integration.test.ts`(分支核对)。 - 结果:默认、交互、指定模板、自定义源和 `-y`(自动确认)的失败行为已分开记录。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index 410f53a..b5df629 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -104,7 +104,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "plan_change|decision_state|supersedes" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(变更协议核对)。 - 结果:提出、审阅、接受或拒绝、替代和校验流程已补全,并明示了结构校验边界。 diff --git a/docs/manual/07-spec-and-context.md b/docs/manual/07-spec-and-context.md index d560489..5ac5dd8 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -62,7 +62,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "implement.jsonl|check.jsonl|context" packages/cli/src/templates/trellis/scripts packages/cli/test`(上下文实现核对)。 - 结果:上下文清单、重复检查和平台读取路径与当前模板一致。 diff --git a/docs/manual/08-hermes-records.md b/docs/manual/08-hermes-records.md index 3eed7ec..676ad0d 100644 --- a/docs/manual/08-hermes-records.md +++ b/docs/manual/08-hermes-records.md @@ -72,7 +72,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind evidence ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "validate_artifact_file|artifact_ref_errors|has_symlink_component" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(路径与引用边界核对)。 - 结果:示例已改为先创建产物和摘要,再写产物与证据账本;路径检查范围已明示。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 0cc11a7..fc640b6 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -78,7 +78,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 - 结果:命令允许列表和本地运行记录均可定位。 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index 51adae6..becbcbd 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/runner.py replay --task "$TASK" --run-id "$RUN ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "run_manifest|replay" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(运行清单与复查核对)。 - 结果:运行清单、输出摘要和复查失败分支均有实现与测试依据。 diff --git a/docs/manual/11-metrics-and-comparison.md b/docs/manual/11-metrics-and-comparison.md index 238fb13..22e59d4 100644 --- a/docs/manual/11-metrics-and-comparison.md +++ b/docs/manual/11-metrics-and-comparison.md @@ -77,7 +77,7 @@ python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality-gate|evidence_refs|claim_refs" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(门禁和引用核对)。 - 结果:质量门禁已有账本编号存在性检查以及有效、缺失引用测试。 diff --git a/docs/manual/12-reports-and-claims.md b/docs/manual/12-reports-and-claims.md index f72d30a..2e4e645 100644 --- a/docs/manual/12-reports-and-claims.md +++ b/docs/manual/12-reports-and-claims.md @@ -69,7 +69,7 @@ python3 ./.trellis/scripts/hermes/report.py approval-gate --task "$TASK" --claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "claim-review|approval-gate" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(主张与审批边界核对)。 - 结果:主张审查和审批门禁入口可定位,报告与人工批准保持分离。 diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index d080a3f..d27cd86 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -81,7 +81,9 @@ profile: evidence ### 派发与结果防火墙 -1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。测试、构建或代码验证 runner 必须用 `parent_job_id`(父工作编号)绑定被检查的 coder;独立实验 runner 可以不绑定。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)也必须绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 +1. 每次派发先创建 dispatch(派发)。命令会绑定 `plan_revision`(计划修订号)和创建时的 `state_revision`(状态修订号),并同时写入兼容的 `task_card`(任务卡)。只有计划实际变化才会让旧派发过期;兄弟派发登记结果只增加状态修订号,不会互相失效。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。测试、构建或代码验证 runner 必须用 `parent_job_id`(父工作编号)绑定被检查的 coder;独立实验 runner 可以不绑定。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)也必须绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 + +任务关闭后拒绝全部派发。任务阻塞或计划漂移时,正式编码、运行和审查会停止,但仍允许范围受限的 `planner`(规划诊断)和 `researcher`(资料研究)派发,用于找出恢复条件;它们不能借此修改任务状态或继续正式实施。 ```bash JOB="job-$(date -u +%Y%m%d-%H%M%S)-coder" @@ -179,7 +181,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`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 947019a..5e3bcb1 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -71,7 +71,7 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 -`v0.7.1-beta.2`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`v0.7.1-beta.3`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 @@ -124,7 +124,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index 8638011..f4863ef 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "OpenCode|channel|mem" packages/cli/src/commands packages/core/src packages/cli/test packages/core/test`(频道与会话读取核对)。 - 结果:频道、会话读取和 `OpenCode`(开放代码工具)未实现分支均有当前代码与测试依据。 diff --git a/docs/manual/16-update-recovery-and-uninstall.md b/docs/manual/16-update-recovery-and-uninstall.md index 0e80a97..61fd6ca 100644 --- a/docs/manual/16-update-recovery-and-uninstall.md +++ b/docs/manual/16-update-recovery-and-uninstall.md @@ -69,7 +69,7 @@ research-trellis uninstall --yes ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dry-run|create-new|allow-downgrade" packages/cli/src/commands packages/cli/test/commands`(更新与恢复核对)。 - 结果:预览、冲突副本、降级拒绝和卸载预览分支均可定位。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index e8cebf7..23b0859 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -49,7 +49,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ``` -`task.json`(任务状态)是当前状态的唯一事实源。每次 Closure 语义写入都会原子替换文件并递增 `hermes_revision`(Hermes 修订号)。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。 +`task.json`(任务状态)是当前状态的唯一事实源。`plan_revision`(计划修订号)只在范围、完成条件、工作包或研究契约等计划内容实际变化时递增;`state_revision`(状态修订号)在 Closure 语义写入时递增。兼容字段 `task_revision`(任务修订号)对应计划修订,`hermes_revision`(Hermes 修订号)对应状态修订。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。 ## 默认生命周期协调 @@ -81,7 +81,7 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ## 状态可见性与回放 -`status --json`(状态 JSON)会从最新未应用的 `plan_amended`(计划变更)事件显示待处理事项:变更字段、当前 `hermes_revision`(Hermes 修订号)、仍缺少的人工批准或决策记录,以及恢复条件。它不会把聊天中的“已同意”当作批准。 +`status --json`(状态 JSON)会从最新未应用的 `plan_amended`(计划变更)事件显示待处理事项:变更字段、当前 `plan_revision`(计划修订号)、仍缺少的人工批准或决策记录,以及恢复条件。它不会把聊天中的“已同意”当作批准。 ```bash python3 ./.trellis/scripts/closure.py status --task "$TASK" --json @@ -110,7 +110,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" \ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" ``` -执行前会比较当前修订号,状态变化后的旧派发返回 `stale_dispatch`(过期派发)。结果必须包含 `uncertainties`(不确定项),结论最多 1200 字符;长日志、完整差异、搜索过程、敏感信息和绝对用户路径不会进入主上下文。两次非法返回只阻塞该 job(工作编号)和当前工作包,不会把科研任务标成完成。 +执行前会比较当前计划修订号,只有计划或计划锚点变化才返回 `stale_dispatch`(过期派发);兄弟派发完成产生的状态变化不会使其过期。同一结果可安全重试,不同结果不能覆盖已登记结果。结果必须包含 `uncertainties`(不确定项),结论最多 1200 字符;长日志、完整差异、搜索过程、敏感信息和绝对用户路径不会进入主上下文。两次非法返回只阻塞该 job(工作编号)和当前工作包,不会把科研任务标成完成。 ## 角色与阶段 @@ -198,7 +198,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ `HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 -有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。新生成的交接会记录任务修订号;只有修订号仍匹配时,恢复提示才会给出 `HANDOFF.md`(交接摘要)路径。旧格式或过期交接只显示警告,任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理;`task.py finish`(结束会话)只清除会话指针,不会隐式生成交接。 +有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。新生成的交接同时记录计划修订号和状态修订号;恢复提示按状态修订号判断交接是否仍新鲜。旧格式或过期交接只显示警告,任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理;`task.py finish`(结束会话)只清除会话指针,不会隐式生成交接。 上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 @@ -210,7 +210,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ - `package-done` 提示缺少证据:先运行实际验证,再通过 `--evidence` 登记已有仓库文件或 `evidence_ledger.jsonl`(证据账本)中的证据编号;任意文字不会被接受为证据。 - `close` 提示工作包未处置:完成、defer 或 waive 对应工作包;不要直接改 JSON 状态。 - `close` 提示派发未确认:先校验或处置对应结果;满足关闭条件后仍要显式运行 `audit`(审计)和 `close`(关闭)。 -- `stale_dispatch`(过期派发):当前状态已变化,按新修订号重新创建派发,不要复用旧结果。 +- `stale_dispatch`(过期派发):当前计划或计划锚点已变化,按新计划修订号重新创建派发;普通状态更新不会触发此错误。 - `repair` 达到上限:查看 `HANDOFF.md` 和阻塞原因,由人工决定停止任务,或批准提高 `max_repair_count` 后重新校验。 - `archive` 被拒绝:先运行 `closure.py audit` 和 `closure.py close`。旧任务没有 closure 字段时仍走原版兼容归档。 @@ -229,7 +229,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index ac924c1..d4fba6b 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.2`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.3`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 9233a33..5d32c7d 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1-beta.2`(测试版)。 +适用于 `0.7.1-beta.3`(测试版)。 ## 前置条件 @@ -118,7 +118,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 配置)等目录由平台选择决定。 @@ -71,7 +71,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "tasks|hermes|workflow" packages/cli/src/constants packages/cli/src/configurators packages/cli/src/templates/trellis`(目录来源核对)。 - 结果:项目、任务和科研记录目录均与当前常量及模板对齐。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index 8f95dea..1bc9b61 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -60,7 +60,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`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 fe49968..ae11cb8 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -34,8 +34,8 @@ | 文件 | 关键字段 | | --- | --- | -| `.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` | +| `.dispatch.json`(派发文件) | `schema,schema_version,job_id,task_id,plan_revision,state_revision,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,plan_revision,state_revision,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/`(本地运行目录),不属于任务记录或发布包。 @@ -62,7 +62,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`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 41f35a3..51bb8b9 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -17,6 +17,7 @@ | 现象 | 处理 | | --- | --- | | 初始化提示 Python 不可用 | 确认 `python3 --version`(Python 版本)至少为 3.9,必要时设置 `TRELLIS_PYTHON_CMD`(Python 命令覆盖) | +| Codex 提示默认等待时间小于最小等待时间 | 升级到 `0.7.1-beta.3`(测试版)或更高版本,并运行 `research-trellis update`(更新项目模板) | | 不允许在主目录初始化 | 进入真实项目根目录,不要绕过保护 | | 任务启动提示缺少会话身份 | 在人工智能会话运行,或设置唯一 `TRELLIS_CONTEXT_ID`(会话标识) | | 任务启动提示 closure 未准备 | 先填写 intent 和完成定义,再运行 `closure.py plan` 与 `closure.py validate` | @@ -33,7 +34,7 @@ | `evidence-curator` 不能派发 | 使用 `evidence.py collect/validate/summary`(证据整理、校验和摘要),质量判断交给 `reviewer:evidence`(证据复核模式) | | 子代理上下文仍很长 | 检查是否走正式 Hermes 角色入口;派发只应包含公共摘要、角色/模式摘要、任务胶囊和最多 3 个引用 | | 派发提示 `missing_dispatch`(缺少派发) | 先运行 `dispatch.py create`(创建派发),Claude Agent 只传 `job_id`(工作编号) | -| 派发提示 `stale_dispatch`(过期派发) | 当前 `hermes_revision`(Hermes 修订号)已变化,重新创建派发 | +| 派发提示 `stale_dispatch`(过期派发) | 当前 `plan_revision`(计划修订号)或计划锚点已变化,确认计划后重新创建派发;兄弟派发完成不会触发 | | 返回提示 `invalid_json`(非法 JSON)或 `long_log`(长日志) | 只返回结果信封 JSON;日志、差异和搜索过程保留在文件或本地原始跟踪中 | | 两次返回无效后 job 被阻塞 | 修正返回契约并创建替代派发;不要把整个科研任务改成完成 | | 子代理派发没有收到最小上下文 | 检查 Claude/Codex 钩子是否启用,或显式读取当前派发文件;关闭仍以任务记录、证据和审计为准 | @@ -65,7 +66,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`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 9d8351b..228b8e8 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1-beta.2`(测试版),后续版本应重新核对。 +适用于 `0.7.1-beta.3`(测试版),后续版本应重新核对。 ## 前置条件 @@ -25,7 +25,7 @@ | 五角色与 Profile | 已实现、实验性 | 正式平台代理模板当前只提供 Claude Code 和 Codex;旧任务卡可兼容读取 | | Agent Context Firewall | 已实现、实验性 | Claude 支持派发替换与结果净化;Codex 支持写入、命令和结束门禁,子代理输入仍依赖协议 | | 结构化 Result Envelope | 已实现 | 拒绝日志、完整差异、搜索过程和敏感信息;原始跟踪只保存在忽略发布的本地运行目录 | -| `hermes_revision`(Hermes 修订号) | 已实现 | Closure 语义写入原子递增;执行前拒绝过期派发 | +| 计划/状态双修订号 | 已实现 | 计划变化使旧派发过期;普通进度更新不影响并行兄弟派发;保留旧字段兼容 | | 确定性证据整理 | 已实现 | 校验和汇总引用,不判断证据质量或批准主张 | | Closure Audit 与有限 Repair | 已实现 | 只处理缺口和状态,不自动运行实验或批准主张 | | 科研记录与结构校验 | 已实现、实验性 | 追加式约定,不防篡改 | @@ -70,7 +70,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node packages/cli/scripts/release-preflight.js check-licenses`(许可证元数据与权利说明一致性检查);`rg -n -m 1 "COPYRIGHT|LICENSE|files" packages/cli/package.json packages/core/package.json packages/cli/scripts/release-preflight.js`(打包权利文件核对)。 - 结果:项目和两个包的 `SPDX`(许可证标识)均为 `AGPL-3.0-or-later`(AGPL 第 3 版或任何后续版本);两个包均声明 `LICENSE`(许可证)和 `COPYRIGHT`(版权声明),发布预检也将其列为必需文件。 diff --git a/docs/manual/sources.md b/docs/manual/sources.md index a92ee4d..9648066 100644 --- a/docs/manual/sources.md +++ b/docs/manual/sources.md @@ -40,7 +40,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.2`(测试版)。 +- 版本:`0.7.1-beta.3`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "^## |^# " docs/manual/*.md`(手册结构核对)。 - 结果:所有手册页的标题结构可读,来源索引仍指向当前仓库文件。 diff --git a/packages/cli/README.md b/packages/cli/README.md index f4320cb..043a118 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -98,7 +98,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -108,7 +108,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -204,7 +204,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 +每次正式角色派发先生成绑定 `plan_revision`(计划修订号)的结构化文件,并记录创建时的 `state_revision`(状态修订号)。只有计划实际变化才会让旧派发过期,并行结果登记不会互相失效。目标正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e78085..0c32ee0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis", - "version": "0.7.1-beta.2", + "version": "0.7.1-beta.3", "description": "Auditable AI-assisted research workflow tooling built on Trellis", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 88f9a42..aac1cb3 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -38,6 +38,7 @@ import { mergeTrellisCodexConfig, } from "../utils/codex-config.js"; import { warnCodexVersion } from "../utils/codex-version.js"; +import { resolveSupportedPython } from "./init.js"; // Import templates for comparison import { @@ -1811,11 +1812,15 @@ export async function update(options: UpdateOptions): Promise { // Check if Trellis is initialized if (!fs.existsSync(path.join(cwd, DIR_NAMES.WORKFLOW))) { - console.log(chalk.red("Error: Trellis not initialized in this directory.")); - console.log(chalk.gray("Run 'research-trellis init' first.")); - return; + throw new Error( + "Trellis is not initialized in this directory. Run 'research-trellis init' first.", + ); } + // Update runs in a fresh CLI process, so init's in-memory Python choice is + // unavailable. Probe again before rendering hooks and command references. + resolveSupportedPython(); + console.log(chalk.cyan("\nTrellis Update")); console.log(chalk.cyan("══════════════\n")); @@ -2039,6 +2044,15 @@ export async function update(options: UpdateOptions): Promise { printMigrationSummary(classifiedMigrations); + if (classifiedMigrations.conflict.length > 0 && !options.dryRun) { + const paths = classifiedMigrations.conflict + .map((item) => (item.to ? `${item.from} -> ${item.to}` : item.from)) + .join(", "); + throw new Error( + `Migration conflict: both paths contain user content (${paths}). Resolve the conflict before updating.`, + ); + } + // Hard-stop: pending rename/delete work from a breaking release requires --migrate. // Why: without --migrate, those entries are skipped and update()'s later path silently // bumps the version stamp, leaving old paths orphaned next to new templates. Force @@ -2138,7 +2152,8 @@ export async function update(options: UpdateOptions): Promise { options.migrate && classifiedMigrations && (classifiedMigrations.auto.length > 0 || - classifiedMigrations.confirm.length > 0); + classifiedMigrations.confirm.length > 0 || + classifiedMigrations.conflict.length > 0); if (!options.dryRun && getConfiguredPlatforms(cwd).has("claude-code")) { try { @@ -2250,9 +2265,24 @@ export async function update(options: UpdateOptions): Promise { return; } - // Batch-resolution flags are explicit consent for non-interactive runs. - // Prompting here breaks CI and `node ... update --force --migrate` smoke tests. - if (!options.force && !options.skipAll && !options.createNew) { + const hasInteractiveDecisions = + changes.changedFiles.length > 0 || + Boolean( + options.migrate && + classifiedMigrations && + (classifiedMigrations.confirm.length > 0 || + classifiedMigrations.conflict.length > 0), + ); + const canAutoProceed = !process.stdin.isTTY && !hasInteractiveDecisions; + + // Safe template-only updates can run with closed stdin. User conflicts and + // confirm-required migrations stay on the interactive decision path. + if ( + !options.force && + !options.skipAll && + !options.createNew && + !canAutoProceed + ) { const { proceed } = await inquirer.prompt<{ proceed: boolean }>([ { type: "confirm", diff --git a/packages/cli/src/migrations/manifests/0.7.1-beta.3.json b/packages/cli/src/migrations/manifests/0.7.1-beta.3.json new file mode 100644 index 0000000..fb9afb9 --- /dev/null +++ b/packages/cli/src/migrations/manifests/0.7.1-beta.3.json @@ -0,0 +1,9 @@ +{ + "version": "0.7.1-beta.3", + "description": "修复 Codex 多代理等待配置、历史项目升级和跨平台 Python 命令探测。", + "breaking": false, + "recommendMigrate": false, + "changelog": "**Codex 配置生命周期:**\n- fix(codex): 默认等待时间不再小于最小等待时间,避免 Codex 启动时拒绝项目配置。\n- fix(update): 更新历史无标记配置时补齐合法等待时间,并移除 Codex 不识别的旧 Hermes 自定义表。\n- fix(python): 更新时重新探测当前机器可用的 Python 命令,避免生成不可执行的 hooks。\n- test(package): 使用真实打包产物验证初始化、历史升级和跨平台配置生成。\n\n**兼容性:**\n- 用户模型、并发数和有效等待时间继续保留。\n- Hermes Context Firewall 的事实配置继续位于 .trellis/hermes/config.yaml,不再写入 Codex 配置命名空间。", + "migrations": [], + "notes": "运行 research-trellis update 即可修复旧项目配置;无需手工修改任务、spec 或科研记录。" +} diff --git a/packages/cli/src/templates/claude/agents/hermes-researcher.md b/packages/cli/src/templates/claude/agents/hermes-researcher.md index 0dae556..9254061 100644 --- a/packages/cli/src/templates/claude/agents/hermes-researcher.md +++ b/packages/cli/src/templates/claude/agents/hermes-researcher.md @@ -7,6 +7,6 @@ tools: Read, Bash, Glob, Grep, WebSearch, WebFetch Use only the validated dispatch body injected by `PreToolUse(Agent)`. Follow its role/profile limits and read at most its three refs plus sources explicitly required by the objective; do not load all history, specs, or ledgers. -Return exactly one Result Envelope JSON object with traceable refs, applicability limits, and `uncertainties`. Do not modify code, results, claims, approvals, package state, or closure state. +Return exactly one Result Envelope JSON object with traceable refs, applicability limits, and `uncertainties`. You may write only `allowed_files` below the current task's `research/**` or `hermes/research/**` directories. Do not modify code, experiment runs or results, formal conclusions or claims, approvals, package state, task status, or closure state. -Do not spawn another sub-agent. Write only source notes or worker records explicitly allowed by the task card. +Do not spawn another sub-agent. Write only source notes explicitly allowed by the task card. diff --git a/packages/cli/src/templates/codex/agents/hermes-researcher.toml b/packages/cli/src/templates/codex/agents/hermes-researcher.toml index 52b77b3..ffcd4a5 100644 --- a/packages/cli/src/templates/codex/agents/hermes-researcher.toml +++ b/packages/cli/src/templates/codex/agents/hermes-researcher.toml @@ -8,7 +8,7 @@ You are the Hermes researcher. Do not spawn another sub-agent. 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. +Read only sources needed by the active Profile. Return exactly the Result Envelope JSON with traceable refs, applicability limits, and uncertainties. You may write only allowed_files below the current task's research/** or hermes/research/** directories. Do not modify code, experiment runs or results, formal conclusions or claims, approvals, package state, task status, or closure state. """ [features] diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index 5cfc190..d24af93 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -45,9 +45,4 @@ enabled = true max_concurrent_threads_per_session = 3 min_wait_timeout_ms = 120000 default_wait_timeout_ms = 120000 - -# 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 = "protocol" # TRELLIS:CODEX_TABLES:END 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 edb80bc..93ae729 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -1229,9 +1229,14 @@ def guard_tool_input_permissions( invalid_targets = [ target for target in target_files - if not any(target == item or target.startswith(item + "/") for item in roots) + if not any(target.startswith(item + "/") for item in roots) ] if invalid_targets: + if role == "researcher": + return ( + "Hermes Runtime: researcher writes are limited to allowed task research artifacts; " + "do not modify code, runs, results, claims, approvals, or task state." + ) return ( f"Hermes Runtime: {role or 'unknown'} cannot write implementation files; " "use the role's task-scoped record directory or delegate to coder." 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 bb50533..72470b3 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -262,18 +262,20 @@ def _handoff_state(task_dir: Path, current_revision: int) -> tuple[str, str | No 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) + match = re.search(r"(?m)^##\s+State Revision\s*\n\s*(\d+)\s*$", content) + if match is 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.", + "Legacy HANDOFF.md has no Task Revision or State 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 " + "HANDOFF.md is stale: State 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.", ) @@ -324,6 +326,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None research_route_rule, research_route_summary, skill_route_guidance, + state_revision, ) from common.lifecycle import reconcile_task_lifecycle # type: ignore[import-not-found] except Exception: @@ -344,7 +347,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None # task requires explicit repair through the closure CLI. pass task_id = str(data.get("id") or task_dir.name) - revision = data.get("hermes_revision", 0) + revision = state_revision(data) phase = str(data.get("hermes_phase") or "planning") next_action = closure_next_action(data) route_summary = research_route_summary(data) 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 253c1ce..7af3561 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 @@ -186,7 +186,7 @@ Each subagent must have: * a role; * a profile; * a task ID; -* a bound `hermes_revision`; +* a bound `plan_revision` and an informational `state_revision`; * the current work package for execution roles; * explicit input files; * explicit allowed files; @@ -207,7 +207,9 @@ 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; 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. +plan revision, package, objective, and at most three refs. The 2000-character +budget applies to the readable objective, while refs and permissions remain +bounded structured fields. The subagent reads a referenced file only when the assignment needs it. Full PRDs, all historical reports, all events, all ledgers, and all specifications diff --git a/packages/cli/src/templates/trellis/hermes/config.yaml b/packages/cli/src/templates/trellis/hermes/config.yaml index b6b19b3..99d6e14 100644 --- a/packages/cli/src/templates/trellis/hermes/config.yaml +++ b/packages/cli/src/templates/trellis/hermes/config.yaml @@ -1,6 +1,6 @@ # Hermes research overlay config. -hermes_version: "0.7.1-beta.2" +hermes_version: "0.7.1-beta.3" runtime_scope: "lightweight research collaboration" runtime_boundary: "runs directly in the project workspace" 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 a2f1f3a..d2ae86a 100644 --- a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md +++ b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md @@ -29,13 +29,16 @@ The validated dispatch defines: - `checkpoint` - `resume_from` - `record_uri` -- `hermes_revision` +- `plan_revision` (the approved plan version that controls staleness) +- `state_revision` (the progress snapshot when the dispatch was created) +- `task_revision` / `hermes_revision` compatibility fields - `work_package` - no more than three `refs` The dispatch and task card are the contract for a single worker. Execution roles bind the current work package. Planner and reviewer task-level work may -use `work_package: null`. A stale revision is rejected before execution. +use `work_package: null`. A changed plan revision is rejected before execution; +a sibling result changing only state revision does not invalidate the packet. Explicit task refs and task context pins take priority. The dispatch then adds only the project background, research plan, or constraints that match the diff --git a/packages/cli/src/templates/trellis/hermes/roles/researcher.md b/packages/cli/src/templates/trellis/hermes/roles/researcher.md index f1703d9..b90b4ab 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/researcher.md +++ b/packages/cli/src/templates/trellis/hermes/roles/researcher.md @@ -9,10 +9,12 @@ Find and summarize traceable information from the repository, literature, offici - Search and read sources relevant to the task card. - Record concise findings, source references, and limitations. - Flag missing or contradictory information. +- When `allowed_files` is present, write only task research artifacts below `.trellis/tasks//research/**` or `.trellis/tasks//hermes/research/**`. ## Forbidden actions -- Modify code, experiment results, claims, approvals, package state, or closure state. +- Modify code, experiment runs or results, formal conclusions or claims, approvals, package state, task status, or closure state. +- Write outside the validated `allowed_files` research-artifact paths. - Invent citations, source identifiers, or conclusions not supported by a source. - Load unrelated history or every project specification by default. diff --git a/packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json b/packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json index 2ef9195..4f5d356 100644 --- a/packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json +++ b/packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json @@ -23,9 +23,12 @@ "schema": { "type": "string", "const": "hermes-result/v1" }, "schema_version": { "type": "string", "const": "hermes-result/v1" }, "job_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" }, + "plan_revision": { "type": "integer", "minimum": 0 }, + "state_revision": { "type": "integer", "minimum": 0 }, "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": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" }, "status": { "type": "string", "enum": ["success", "failure", "blocked"] }, "conclusion": { "type": "string", "maxLength": 1200 }, "uncertainties": { "type": "array", "items": { "type": "string" } }, diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index 6ce3791..d27d569 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_gate_status, closure_timeline, closure_next_action, close_task, @@ -352,6 +353,7 @@ def _status_record(task_dir, data: dict[str, Any]) -> dict[str, Any]: "closure_mode": data.get("closure_mode"), "current_work_package": current_package, "next_action": closure_next_action(data), + "gates": closure_gate_status(task_dir, data), "blockers": data.get("blockers") or [], "pending_approvals": pending_approval_summaries(task_dir, data), "repair_count": data.get("repair_count", 0), diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index ccbfad8..84bae5a 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -228,6 +228,9 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "blockers": [], "repair_count": 0, "max_repair_count": default_repair_limit(mode), + "plan_revision": 0, + "state_revision": 0, + "task_revision": 0, "hermes_revision": 0, "plan_anchor": None, "confirmed_dispatches": [], @@ -239,7 +242,11 @@ def ensure_closure_defaults(data: dict[str, Any], mode: str | None = None) -> No if selected_mode not in CLOSURE_MODES: selected_mode = "lean" defaults = closure_defaults(selected_mode) + # Revisions need legacy-aware defaults, so do not insert zeroes before + # reading an older task that only has hermes_revision/task_revision. for key, value in defaults.items(): + if key in {"plan_revision", "state_revision", "task_revision", "hermes_revision"}: + continue if key not in data: data[key] = deepcopy(value) constraints = data.get("constraints") @@ -251,6 +258,54 @@ def ensure_closure_defaults(data: dict[str, Any], mode: str | None = None) -> No data["closure_mode"] = selected_mode if not isinstance(data.get("max_repair_count"), int): data["max_repair_count"] = default_repair_limit(selected_mode) + inherited_plan_revision = plan_revision(data) + inherited_state_revision = state_revision(data) + data.setdefault("plan_revision", inherited_plan_revision) + data.setdefault("state_revision", inherited_state_revision) + data.setdefault("task_revision", inherited_plan_revision) + data.setdefault("hermes_revision", inherited_state_revision) + + +def _valid_revision(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def plan_revision(data: dict[str, Any]) -> int: + """Read the plan revision while accepting pre-split task records.""" + for field in ("plan_revision", "task_revision", "hermes_revision"): + value = data.get(field) + if _valid_revision(value): + assert isinstance(value, int) + return value + return 0 + + +def state_revision(data: dict[str, Any]) -> int: + """Read the mutable-state revision while accepting pre-split task records.""" + for field in ("state_revision", "hermes_revision", "plan_revision", "task_revision"): + value = data.get(field) + if _valid_revision(value): + assert isinstance(value, int) + return value + return 0 + + +def _plan_revision_snapshot(data: dict[str, Any], task_dir: Path) -> dict[str, Any]: + anchor = data.get("plan_anchor") + anchor_identity = ( + { + key: anchor.get(key) + for key in ("schema", "id", "hash", "projection") + } + if isinstance(anchor, dict) + else anchor + ) + return { + "projection": plan_anchor_projection(data, task_dir), + "plan_anchor": anchor_identity, + "grill_completed": data.get("grill_completed") is True, + "pending_plan_projection": data.get(PENDING_PLAN_PROJECTION_FIELD), + } def resolve_closure_task( @@ -285,20 +340,42 @@ def save_task( with task_state_lock(task_dir): save_task(task_dir, data, lock_held=True) return - revision = data.get("hermes_revision", 0) - if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: - raise ClosureError("hermes_revision must be a non-negative integer") current = read_json(task_dir / FILE_TASK_JSON) if not isinstance(current, dict): raise ClosureError("failed to read current task.json under task lock") - current_revision = current.get("hermes_revision", 0) - if current_revision != revision: + expected_state_revision = state_revision(data) + current_state_revision = state_revision(current) + expected_plan_revision = plan_revision(data) + current_plan_revision = plan_revision(current) + if current_state_revision != expected_state_revision: raise ClosureError( - f"stale task revision: expected {revision}, current {current_revision}" + "stale task revision: " + f"expected state {expected_state_revision}, current {current_state_revision}" ) - data["hermes_revision"] = revision + 1 + if current_plan_revision != expected_plan_revision: + raise ClosureError( + "stale task revision: " + f"expected plan {expected_plan_revision}, current {current_plan_revision}" + ) + previous_revisions = { + field: data.get(field) + for field in ("plan_revision", "state_revision", "task_revision", "hermes_revision") + } + next_plan_revision = current_plan_revision + int( + _plan_revision_snapshot(current, task_dir) != _plan_revision_snapshot(data, task_dir) + ) + next_state_revision = current_state_revision + 1 + data["plan_revision"] = next_plan_revision + data["state_revision"] = next_state_revision + data["task_revision"] = next_plan_revision + # Keep the old field as a state-change counter for legacy readers. + data["hermes_revision"] = next_state_revision if not write_json_atomic(task_dir / FILE_TASK_JSON, data): - data["hermes_revision"] = revision + for field, value in previous_revisions.items(): + if value is None: + data.pop(field, None) + else: + data[field] = value raise ClosureError("failed to write task.json") @@ -451,6 +528,62 @@ def closure_constraints(data: dict[str, Any]) -> dict[str, Any]: } +def closure_gate_status(task_dir: Path, data: dict[str, Any]) -> dict[str, dict[str, str]]: + """Return the compact, actionable gates shown before work is dispatched.""" + phase = str(data.get("hermes_phase") or "planning") + anchor = plan_anchor_status(data, task_dir) + exploration_needs_grill = ( + research_route(data) == "exploration" + and data.get("grill_completed") is not True + ) + has_blockers = phase == "blocked" or bool(_string_list(data.get("blockers"))) + closed = phase == "closed" or data.get("closure_state") == "closed" + if closed: + return { + "literature_research": {"status": "blocked", "unblock": "Reopen or create a new task before research."}, + "formal_execution": {"status": "blocked", "unblock": "Reopen or create a new task before execution."}, + "formal_review": {"status": "blocked", "unblock": "Reopen or create a new task before review."}, + } + + literature = { + "status": "allowed", + "unblock": "Use a scoped researcher dispatch and save only task research artifacts.", + } + if has_blockers: + unblock = "Resolve the recorded blocker or record an approved closure amendment." + elif exploration_needs_grill: + unblock = "Complete the exploration grill, then run closure.py validate." + elif anchor["status"] == "missing": + unblock = "Run closure.py validate to create a plan anchor." + elif anchor["status"] != "clean": + unblock = "Record an approved closure.py amend, then run closure.py validate." + elif phase == "planning": + unblock = "Run closure.py validate to enter the ready phase." + else: + unblock = "Start or continue the current work package." + formal_status = "allowed" if ( + anchor["status"] == "clean" + and not exploration_needs_grill + and not has_blockers + and phase in {"ready", "running", "review"} + ) else "blocked" + execution_unblock = ( + "No unblock required; follow the current work package contract." + if formal_status == "allowed" + else unblock + ) + review_unblock = ( + "No unblock required; bind review to the current artifacts and evidence." + if formal_status == "allowed" + else unblock + ) + return { + "literature_research": literature, + "formal_execution": {"status": formal_status, "unblock": execution_unblock}, + "formal_review": {"status": formal_status, "unblock": review_unblock}, + } + + def plan_anchor_status( data: dict[str, Any], task_dir: Path | None = None, @@ -585,8 +718,7 @@ def create_plan_anchor( """Freeze the validated plan while keeping task.json as the live state.""" projection = plan_anchor_projection(data, task_dir) digest = _plan_anchor_hash(projection) - revision = data.get("hermes_revision", 0) - next_revision = revision + 1 if isinstance(revision, int) and not isinstance(revision, bool) else 0 + next_revision = plan_revision(data) + 1 return { "schema": PLAN_ANCHOR_SCHEMA, "id": f"pa-{digest.removeprefix('sha256:')[:16]}", @@ -1397,8 +1529,18 @@ def validate_closure( value = data.get(field) if not isinstance(value, int) or isinstance(value, bool) or value < 0: errors.append(f"{field} must be a non-negative integer") - revision = data.get("hermes_revision", 0) - if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: + for field in ("plan_revision", "state_revision"): + if field in data and not _valid_revision(data.get(field)): + errors.append(f"{field} must be a non-negative integer") + if "task_revision" in data and data.get("task_revision") != plan_revision(data): + errors.append("task_revision must equal plan_revision") + if ( + "state_revision" in data + and "hermes_revision" in data + and data.get("state_revision") != data.get("hermes_revision") + ): + errors.append("state_revision must equal hermes_revision") + if "hermes_revision" in data and not _valid_revision(data.get("hermes_revision")): errors.append("hermes_revision must be a non-negative integer") if not str(data.get("intent") or "").strip(): errors.append("intent is required") @@ -2411,7 +2553,7 @@ def close_task( current = read_json(task_dir / FILE_TASK_JSON) if not isinstance(current, dict): raise ClosureError("cannot read task.json while closing") - if current.get("hermes_revision", 0) != data.get("hermes_revision", 0): + if state_revision(current) != state_revision(data): raise ClosureError("task changed while close was being prepared; rerun audit") if current.get("closure_state") == "closed" and current.get("hermes_phase") == "closed": data.clear() @@ -2491,48 +2633,67 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None current = package_by_id(data, str(current_id)) except ClosureError: current = None - scope = _compact_list(_string_list(data.get("in_scope")), 2) - excluded = _compact_list(_string_list(data.get("out_of_scope")), 2) - blockers = _compact_list(_string_list(data.get("blockers")), 2) + scope = _compact_list(_string_list(data.get("in_scope")), 2, 52) + excluded = _compact_list(_string_list(data.get("out_of_scope")), 2, 52) + blockers = _compact_list(_string_list(data.get("blockers")), 2, 65) constraints = closure_constraints(data) anchor = plan_anchor_status(data, task_dir) + gates = closure_gate_status(task_dir, data) anchor_label = anchor["id"] or "unvalidated" anchor_state = anchor["status"] pending_approvals = pending_approval_summaries(task_dir, data) - lines = [ - f"Task: {data.get('id') or task_dir.name} | {data.get('title') or task_dir.name}", - f"Intent: {_clip(str(data.get('intent') or data.get('description') or ''), 180)}", + head = [ + "Task: " + _clip( + f"{data.get('id') or task_dir.name} | {data.get('title') or task_dir.name}", + 82, + ), + f"Intent: {_clip(str(data.get('intent') or data.get('description') or ''), 92)}", f"Scope: {scope or '-'} | Out: {excluded or '-'}", f"Mode/Phase: {data.get('closure_mode', 'lean')} / {data.get('hermes_phase', 'planning')}", ] + optional: list[str] = [] if pending_approvals: pending = pending_approvals[0] requirements = "+".join(pending["requirements"]) - lines.append( + optional.append( "Pending approval: " + f"{pending['field']} @ r{pending['task_revision']}; {requirements}; " - + _clip(pending["recovery_condition"], 180) + + _clip(pending["recovery_condition"], 90) ) - lines.extend([ + optional.extend([ f"Plan anchor: {anchor_label} / {anchor_state}", - "Definition: " + _compact_list(_string_list(data.get("definition_of_done")), 2, 220), - research_route_summary(data), - "Route rule: " + research_route_rule(data), - f"Verify: {constraints['validation_level']} | Excluded platforms: {_compact_list(constraints['excluded_platforms'], 2) or '-'}", - f"Current: {current_id or '-'}" + (f" - {_clip(str(current.get('outcome') or ''), 140)}" if current else ""), + "Gates: literature " + gates["literature_research"]["status"] + + "; execution " + gates["formal_execution"]["status"] + + "; review " + gates["formal_review"]["status"], + "Gate unblock: " + _clip(gates["formal_execution"]["unblock"], 90), + _clip(research_route_summary(data), 110), + "Route rule: " + _clip(research_route_rule(data), 90), + f"Verify: {constraints['validation_level']} | Excluded: {_compact_list(constraints['excluded_platforms'], 2, 50) or '-'}", ]) + tail = [ + "Definition: " + _compact_list(_string_list(data.get("definition_of_done")), 2, 88), + "Current: " + _clip( + f"{current_id or '-'}" + (f" - {current.get('outcome') or ''}" if current else ""), + 82, + ), + ] if current: - lines.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 240)) + tail.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 92)) 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 '-'}") + optional.append("Decision ref: " + _clip(decision_ref.strip(), 80)) + tail.append(f"Next: {_clip(closure_next_action(data), 82)}") + tail.append(f"Blockers: {blockers or '-'}") refs = _context_refs(task_dir, data, root or get_repo_root())[:3] if refs: - lines.append("Refs: " + ", ".join(refs)) - capsule = "\n".join(lines) - return capsule if len(capsule) <= 1000 else capsule[:997].rstrip() + "..." + tail.append("Refs: " + _clip(", ".join(_clip(ref, 52) for ref in refs), 118)) + lines = [*head, *tail] + for line in optional: + candidate = [*head, line, *lines[len(head):]] + if len("\n".join(candidate)) <= 1000: + lines = candidate + head.append(line) + return "\n".join(lines) def pending_approval_summaries(task_dir: Path, data: dict[str, Any]) -> list[dict[str, Any]]: @@ -2550,9 +2711,7 @@ def pending_approval_summaries(task_dir: Path, data: dict[str, Any]) -> list[dic if isinstance(field, str) and field.strip(): latest_by_field[field] = event - revision = data.get("hermes_revision", 0) - if not isinstance(revision, int) or isinstance(revision, bool): - revision = 0 + revision = plan_revision(data) pending: list[dict[str, Any]] = [] for event in latest_by_field.values(): if event.get("applied") is not False: @@ -2667,17 +2826,19 @@ def closure_timeline( } ) entries.sort(key=lambda item: (str(item["timestamp"]), str(item["source"]), str(item["event"]))) - revision = data.get("hermes_revision", 0) return { "task_id": data.get("id") or task_dir.name, - "hermes_revision": revision if isinstance(revision, int) and not isinstance(revision, bool) else 0, + "plan_revision": plan_revision(data), + "state_revision": state_revision(data), + "task_revision": plan_revision(data), + "hermes_revision": state_revision(data), "entries": entries[-min(limit, 100):], } def format_closure_timeline(value: dict[str, Any]) -> str: lines = [ - f"Timeline: {value.get('task_id')} @ r{value.get('hermes_revision')}", + f"Timeline: {value.get('task_id')} @ plan r{value.get('plan_revision', value.get('task_revision', 0))}", ] entries = value.get("entries") if isinstance(value.get("entries"), list) else [] if not entries: @@ -2695,9 +2856,15 @@ def format_closure_timeline(value: dict[str, Any]) -> str: return "\n".join(lines) -def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: - revision = data.get("hermes_revision", 0) - revision_text = str(revision) if isinstance(revision, int) and not isinstance(revision, bool) else "unknown" +def write_handoff( + task_dir: Path, + data: dict[str, Any], + root: Path | None = None, + *, + additional_changed_files: list[str] | None = None, +) -> Path: + plan_revision_text = str(plan_revision(data)) + state_revision_text = str(state_revision(data)) 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([ @@ -2706,14 +2873,21 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None for ref in _string_list(item.get("evidence_refs")) ]) changed, integrity_warnings = _task_changed_file_records(task_dir, data) + changed = _dedupe([*changed, *(additional_changed_files or [])])[:20] failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) anchor = plan_anchor_status(data, task_dir) content = "\n".join( [ "# Task Handoff", "", + "## Plan Revision", + plan_revision_text, + "", + "## State Revision", + state_revision_text, + "", "## Task Revision", - revision_text, + state_revision_text, "", "## Current Goal", str(data.get("intent") or data.get("title") or "-"), @@ -3384,7 +3558,7 @@ def _task_changed_file_records(task_dir: Path, data: dict[str, Any]) -> tuple[li files: list[str] = [] integrity_warnings: list[str] = [] confirmed = set(_string_list(data.get("confirmed_dispatches"))) - current_revision = data.get("hermes_revision") + current_state_revision = state_revision(data) for result_path in sorted((task_dir / "hermes" / "dispatches").glob("*.result.json")): value = read_json(result_path) if not isinstance(value, dict): @@ -3393,19 +3567,22 @@ def _task_changed_file_records(task_dir: Path, data: dict[str, Any]) -> tuple[li 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 + confirmed_revision = dispatch.get("confirmed_state_revision", dispatch.get("confirmed_revision")) if isinstance(dispatch, dict) else None + dispatch_plan_revision = ( + plan_revision(dispatch) + if isinstance(dispatch, dict) + else 0 + ) 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 value.get("task_revision") != dispatch_plan_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 + or confirmed_revision > current_state_revision ): continue expected_hash = dispatch.get("result_sha256") diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index 17a577c..fb6b417 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -15,12 +15,15 @@ from .closure import ( ClosureError, + closure_gate_status, closure_constraints, is_closure_task, now_utc, package_by_id, plan_anchor_status, + plan_revision, save_task, + state_revision, task_state_lock, write_handoff, ) @@ -121,6 +124,8 @@ "schema", "schema_version", "job_id", + "plan_revision", + "state_revision", "task_revision", "role", "profile", @@ -195,11 +200,9 @@ def create_dispatch( ) -> tuple[dict[str, Any], list[str]]: if not is_closure_task(task): raise DispatchError("not_hermes_task", "dispatch is available only for Hermes closure tasks") - revision = _task_revision(task) + task_plan_revision = _plan_revision(task) + task_state_revision = _state_revision(task) anchor = plan_anchor_status(task, task_dir) - if anchor["status"] in {"drifted", "invalid"}: - changed = ", ".join(anchor["differences"][:3]) or "plan contract" - raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") objective = _required_text(spec.get("objective"), "objective") _check_sensitive_text(objective, "objective") try: @@ -216,6 +219,10 @@ def create_dispatch( role = normalized.role profile = normalized.profile + if anchor["status"] in {"drifted", "invalid"} and role not in {"planner", "researcher"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") + _validate_dispatch_gate(task_dir, task, role) if anchor["status"] == "missing" and ( task.get("hermes_phase") != "planning" or role not in {"planner", "researcher"} @@ -240,10 +247,18 @@ def create_dispatch( _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) + merged_refs, explicit_refs, automatic_refs = _merged_dispatch_refs( + task, + spec.get("refs"), + task_dir, + repo_root, + role, + profile, + ) refs = _normalize_refs( task_dir, repo_root, - _merged_dispatch_refs(task, spec.get("refs"), repo_root, role, profile), + merged_refs, role, ) if role == "coder" and not allowed_files: @@ -258,7 +273,8 @@ def create_dispatch( requested_job_id = _normalize_optional_text(spec.get("job_id")) job_id = requested_job_id or _generated_job_id( task_dir.name, - revision, + task_plan_revision, + task_state_revision, role, profile, work_package, @@ -304,8 +320,10 @@ def create_dispatch( "job_id": job_id, "task_id": str(task.get("id") or task_dir.name), "task_path": _repo_relative(task_dir, repo_root), - "task_revision": revision, - "hermes_revision": revision, + "plan_revision": task_plan_revision, + "state_revision": task_state_revision, + "task_revision": task_plan_revision, + "hermes_revision": task_state_revision, "plan_anchor_id": anchor["id"], "plan_anchor_hash": anchor["hash"], "closure_mode": mode, @@ -316,6 +334,8 @@ def create_dispatch( "parent_job_id": parent_job_id, "handoff_writer": handoff_writer, "objective": objective, + "explicit_refs": explicit_refs, + "automatic_refs": automatic_refs, "allowed_refs": refs, "refs": refs, "allowed_files": allowed_files, @@ -343,13 +363,23 @@ def create_dispatch( body = build_canonical_prompt(dispatch) dispatch["body"] = body dispatch["audit"] = _dispatch_audit(dispatch) - _write_task_runtime_json(task_dir, path, dispatch) - _append_worker_task_card(task_dir, dispatch) - _append_dispatch_audit(task_dir, dispatch, "dispatch_validated", "allowed", { - "body_chars": len(body), - "objective_chars": len(objective), - "ref_count": len(refs), - }) + with task_state_lock(task_dir): + current = read_json(task_dir / "task.json") + if not isinstance(current, dict): + raise DispatchError("state_confirmation_failed", "cannot reload task state") + if path.exists(): + raise DispatchError("duplicate_job", f"dispatch already exists for {job_id}") + validate_dispatch(task_dir, current, dispatch, repo_root) + _append_worker_task_card(task_dir, dispatch) + _append_dispatch_audit(task_dir, dispatch, "dispatch_validated", "allowed", { + **_dispatch_body_metrics(dispatch, body), + "ref_count": len(refs), + "explicit_ref_count": len(explicit_refs), + "automatic_ref_count": len(automatic_refs), + }) + # The dispatch file is the readiness marker. Stable record IDs make a + # retry converge if either append succeeds before this final write. + _write_task_runtime_json(task_dir, path, dispatch) return dispatch, gate_warnings @@ -464,21 +494,31 @@ def validate_dispatch( errors.append("job_id") if dispatch.get("task_id") != str(task.get("id") or task_dir.name): errors.append("task_id") - if before_execution and dispatch.get("hermes_revision") != _task_revision(task): + dispatch_role = str(dispatch.get("role") or "") + anchor = plan_anchor_status(task, task_dir) + if anchor["status"] in {"drifted", "invalid"} and dispatch_role not in {"planner", "researcher"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") + try: + _validate_dispatch_gate(task_dir, task, dispatch_role) + except DispatchError as exc: + errors.append(exc.code) + dispatch_plan_revision = _dispatch_plan_revision(dispatch) + if before_execution and dispatch_plan_revision != _plan_revision(task): raise DispatchError( "stale_dispatch", - "dispatch revision does not match current task state", + "dispatch plan revision does not match the current approved plan", [ - f"dispatch_revision={dispatch.get('hermes_revision')}", - f"current_revision={_task_revision(task)}", + f"dispatch_plan_revision={dispatch_plan_revision}", + f"current_plan_revision={_plan_revision(task)}", ], ) - if dispatch.get("task_revision") != dispatch.get("hermes_revision"): + if dispatch.get("task_revision") != dispatch_plan_revision: errors.append("task_revision") - anchor = plan_anchor_status(task, task_dir) - if anchor["status"] in {"drifted", "invalid"}: - changed = ", ".join(anchor["differences"][:3]) or "plan contract" - raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") + if "plan_revision" in dispatch and dispatch.get("plan_revision") != dispatch_plan_revision: + errors.append("plan_revision") + if "state_revision" in dispatch and dispatch.get("state_revision") != _dispatch_state_revision(dispatch): + errors.append("state_revision") if before_execution and anchor["status"] == "missing" and ( task.get("hermes_phase") != "planning" or dispatch.get("role") not in {"planner", "researcher"} @@ -542,6 +582,12 @@ def validate_dispatch( _string_list(dispatch.get("allowed_files")), handoff_writer, ) + _validate_role_write_scope( + task_dir, + repo_root, + str(dispatch.get("role") or ""), + _string_list(dispatch.get("allowed_files")), + ) _validate_work_package( task, str(dispatch.get("role") or ""), @@ -551,28 +597,47 @@ def validate_dispatch( except DispatchError as exc: errors.append(exc.code) try: + explicit_source = dispatch.get("explicit_refs", dispatch.get("refs")) + expected_merged_refs, expected_explicit_refs, expected_automatic_refs = _merged_dispatch_refs( + task, + explicit_source, + task_dir, + repo_root, + str(dispatch.get("role") or ""), + str(dispatch.get("profile") or ""), + ) expected_refs = _normalize_refs( task_dir, repo_root, - _merged_dispatch_refs( - task, - dispatch.get("refs"), - repo_root, - str(dispatch.get("role") or ""), - str(dispatch.get("profile") or ""), - ), + expected_merged_refs, str(dispatch.get("role") or ""), ) if expected_refs != dispatch.get("refs"): errors.append("refs") + if "explicit_refs" in dispatch and dispatch.get("explicit_refs") != expected_explicit_refs: + errors.append("explicit_refs") + if "automatic_refs" in dispatch and dispatch.get("automatic_refs") != expected_automatic_refs: + errors.append("automatic_refs") except DispatchError as exc: errors.append(exc.code) - try: - expected_body = build_canonical_prompt({**dispatch, "body": None, "audit": None}) - if dispatch.get("body") != expected_body: - errors.append("body_not_canonical") - except DispatchError as exc: - errors.append(exc.code) + if _is_legacy_dispatch(dispatch): + body = dispatch.get("body") + if not isinstance(body, str) or not body.strip(): + errors.append("body") + else: + try: + _check_sensitive_text(body, "dispatch body") + if ABSOLUTE_USER_PATH_RE.search(body): + errors.append("absolute_user_path") + except DispatchError as exc: + errors.append(exc.code) + else: + try: + expected_body = build_canonical_prompt({**dispatch, "body": None, "audit": None}) + if dispatch.get("body") != expected_body: + errors.append("body_not_canonical") + except DispatchError as exc: + errors.append(exc.code) if errors: raise DispatchError("invalid_dispatch", "dispatch validation failed", sorted(set(errors))) return [] @@ -639,8 +704,10 @@ def prepare_dispatch_for_agent( dispatch["started_at"] = now_utc() _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) _append_dispatch_audit(task_dir, dispatch, "dispatch_started", "allowed", { - "body_chars": len(str(dispatch.get("body") or "")), + **_dispatch_body_metrics(dispatch, str(dispatch.get("body") or "")), "ref_count": len(dispatch.get("refs") or []), + "explicit_ref_count": len(dispatch.get("explicit_refs") or []), + "automatic_ref_count": len(dispatch.get("automatic_refs") or []), }) return dispatch @@ -655,7 +722,7 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: lines = [ "Hermes Agent Context Firewall dispatch", f"job_id: {dispatch.get('job_id')}", - f"task: {dispatch.get('task_path')} @ revision {dispatch.get('hermes_revision')}", + f"task: {dispatch.get('task_path')} @ plan revision {_dispatch_plan_revision(dispatch)}", "plan_anchor: " + ( f"{dispatch.get('plan_anchor_id')} @ {dispatch.get('plan_anchor_hash')}" if dispatch.get("plan_anchor_id") and dispatch.get("plan_anchor_hash") @@ -679,6 +746,10 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: ] 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("role") == "researcher": + lines.append( + "Researcher writes are limited to allowed_files under this task's research/ or hermes/research/ directories; do not modify code, runs, results, claims, approvals, or task state." + ) 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." @@ -687,11 +758,12 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: _check_sensitive_text(body, "dispatch body") if ABSOLUTE_USER_PATH_RE.search(body): raise DispatchError("absolute_user_path", "dispatch body contains an absolute user path") - if len(body) > MAX_DISPATCH_BODY_CHARS: + metrics = _dispatch_body_metrics(dispatch, body) + if metrics["readable_body_chars"] > MAX_DISPATCH_BODY_CHARS: raise DispatchError( "dispatch_too_long", - f"dispatch body exceeds {MAX_DISPATCH_BODY_CHARS} characters", - [f"body_chars={len(body)}"], + f"dispatch objective exceeds {MAX_DISPATCH_BODY_CHARS} characters", + _dispatch_metric_details(metrics), ) return body @@ -705,63 +777,109 @@ def accept_result_text( ) -> dict[str, Any]: dispatch = load_dispatch(task_dir, job_id) _store_raw_trace(repo_root, task_dir, job_id, raw_text, "agent_result") - stored = load_sanitized_result(task_dir, job_id) - if stored is not None and _task_confirms_dispatch(task, dispatch): - dispatch["status"] = "confirmed" if stored.get("status") == "success" else "failed" - dispatch["confirmed_revision"] = _task_revision(task) - dispatch["confirmed_at"] = dispatch.get("confirmed_at") or now_utc() - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - return stored + error: DispatchError | None = None + invalid_result = False + sanitized: dict[str, Any] | None = None try: - validate_dispatch(task_dir, task, dispatch, repo_root) - result = validate_result_envelope(task_dir, task, dispatch, raw_text, repo_root) - except DispatchError as exc: - if exc.code in {"stale_dispatch", "missing_dispatch"}: - _mark_dispatch_terminal(task_dir, dispatch, "stale", exc.code) - raise - _record_invalid_result(task_dir, task, dispatch, exc) - raise + with task_state_lock(task_dir): + current = read_json(task_dir / "task.json") + current_dispatch = read_json(dispatch_path(task_dir, job_id)) + if not isinstance(current, dict) or not isinstance(current_dispatch, dict): + raise DispatchError("state_confirmation_failed", "cannot reload result state") + dispatch = current_dispatch + stored = load_sanitized_result(task_dir, job_id) + try: + result = validate_result_envelope( + task_dir, + current, + dispatch, + raw_text, + repo_root, + ) + except DispatchError as exc: + if stored is not None: + raise DispatchError( + "result_conflict", + "a validated result is already stored for this job", + ) from exc + invalid_result = True + raise + candidate = _sanitized_result(dispatch, result, raw_text) + if stored is not None and _result_identity(stored) != _result_identity(candidate): + raise DispatchError( + "result_conflict", + "a different validated result is already stored for this job", + ) + sanitized = stored or candidate + + already_confirmed = _task_confirms_dispatch(current, dispatch) + if not already_confirmed: + validate_dispatch(task_dir, current, dispatch, repo_root) + if stored is None: + _write_task_runtime_json(task_dir, result_path(task_dir, job_id), sanitized) + _append_worker_result(task_dir, dispatch, sanitized) + updated_task = deepcopy(current) + _apply_result_state(updated_task, dispatch, sanitized) + save_task(task_dir, updated_task, lock_held=True) + current = updated_task + else: + if stored is None: + _write_task_runtime_json(task_dir, result_path(task_dir, job_id), sanitized) + _append_worker_result(task_dir, dispatch, sanitized) - with task_state_lock(task_dir): - current = read_json(task_dir / "task.json") - if not isinstance(current, dict): - raise DispatchError("state_confirmation_failed", "cannot reload task state") - validate_dispatch(task_dir, current, dispatch, repo_root) - sanitized = _sanitized_result(dispatch, result, raw_text) - dispatch["status"] = "result_returned" - dispatch["result_returned_at"] = now_utc() - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - _write_task_runtime_json(task_dir, result_path(task_dir, job_id), sanitized) - - updated_task = deepcopy(current) - _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 - task.clear() - task.update(updated_task) - - dispatch["status"] = "confirmed" if sanitized["status"] == "success" else "failed" - dispatch["confirmed_revision"] = _task_revision(task) - dispatch["confirmed_at"] = now_utc() - dispatch["result_sha256"] = sanitized["audit"]["result_sha256"] - try: + write_handoff( + task_dir, + current, + repo_root, + additional_changed_files=_string_list(sanitized.get("changed_files")), + ) + + confirmed_revision = dispatch.get("confirmed_state_revision") + if not isinstance(confirmed_revision, int) or isinstance(confirmed_revision, bool): + confirmed_revision = _state_revision(current) + dispatch["status"] = "confirmed" if sanitized["status"] == "success" else "failed" + dispatch["confirmed_state_revision"] = confirmed_revision + dispatch["confirmed_revision"] = confirmed_revision + dispatch["confirmed_at"] = dispatch.get("confirmed_at") or now_utc() + dispatch["result_sha256"] = _result_identity(sanitized) _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - except DispatchError as exc: - raise DispatchError("state_confirmation_failed", "task state updated but dispatch confirmation failed") from exc - _append_dispatch_audit(task_dir, dispatch, "result_confirmed", "allowed", sanitized["audit"]["metrics"]) + _append_dispatch_audit( + task_dir, + dispatch, + "result_confirmed", + "allowed", + { + **sanitized["audit"]["metrics"], + "result_sha256": _result_identity(sanitized), + }, + ) + task.clear() + task.update(current) + except DispatchError as exc: + error = exc + except (ClosureError, OSError) as exc: + raise DispatchError( + "state_confirmation_failed", + "result convergence failed and can be retried safely", + ) from exc + + if error is not None: + if error.code in {"stale_dispatch", "missing_dispatch"}: + _mark_dispatch_terminal(task_dir, dispatch, "stale", error.code) + elif invalid_result: + _record_invalid_result(task_dir, task, dispatch, error) + raise error + if sanitized is None: + raise DispatchError("state_confirmation_failed", "result convergence produced no stored result") return sanitized +def _result_identity(result: dict[str, Any]) -> str: + """Return the semantic result hash, excluding recovery-only audit metadata.""" + return _stable_hash(result, {"audit"}) + + def _task_confirms_dispatch(task: dict[str, Any], dispatch: dict[str, Any]) -> bool: job_id = str(dispatch.get("job_id")) if job_id in _string_list(task.get("confirmed_dispatches")): @@ -829,6 +947,10 @@ def validate_result_envelope( raise DispatchError("result_schema_mismatch", "result schema does not match the canonical envelope") if value.get("task_revision") != dispatch.get("task_revision", dispatch.get("hermes_revision")): raise DispatchError("result_revision_mismatch", "result task_revision does not match dispatch") + if "plan_revision" in value and value.get("plan_revision") != _dispatch_plan_revision(dispatch): + raise DispatchError("result_revision_mismatch", "result plan_revision does not match dispatch") + if "state_revision" in value and value.get("state_revision") != _dispatch_state_revision(dispatch): + raise DispatchError("result_revision_mismatch", "result state_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")) @@ -893,6 +1015,8 @@ def validate_result_envelope( "schema": RESULT_SCHEMA, "schema_version": RESULT_SCHEMA, "job_id": value["job_id"], + "plan_revision": _dispatch_plan_revision(dispatch), + "state_revision": _dispatch_state_revision(dispatch), "task_revision": value["task_revision"], "role": value["role"], "profile": value["profile"], @@ -930,6 +1054,8 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: for field in ( "schema", "job_id", + "plan_revision", + "state_revision", "task_revision", "role", "profile", @@ -953,7 +1079,9 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: return { "schema": RESULT_SCHEMA, "job_id": job_id, - "task_revision": dispatch.get("task_revision", dispatch.get("hermes_revision")), + "plan_revision": _dispatch_plan_revision(dispatch), + "state_revision": _dispatch_state_revision(dispatch), + "task_revision": _dispatch_plan_revision(dispatch), "role": dispatch.get("role"), "profile": dispatch.get("profile"), "parent_job_id": dispatch.get("parent_job_id"), @@ -985,6 +1113,8 @@ def list_dispatches(task_dir: Path) -> list[dict[str, Any]]: "profile": value.get("profile"), "work_package": value.get("work_package"), "parent_job_id": value.get("parent_job_id"), + "plan_revision": _dispatch_plan_revision(value), + "state_revision": _dispatch_state_revision(value), "hermes_revision": value.get("hermes_revision"), "status": value.get("status"), "invalid_result_attempts": value.get("invalid_result_attempts", 0), @@ -1040,8 +1170,10 @@ def supersede_dispatch( task.clear() task.update(updated) if replacement is not None: - replacement["hermes_revision"] = _task_revision(task) - replacement["task_revision"] = _task_revision(task) + replacement["plan_revision"] = _plan_revision(task) + replacement["state_revision"] = _state_revision(task) + replacement["task_revision"] = _plan_revision(task) + replacement["hermes_revision"] = _state_revision(task) replacement["body"] = build_canonical_prompt(replacement) replacement["audit"] = _dispatch_audit(replacement) _write_task_runtime_json( @@ -1087,6 +1219,8 @@ def result_json_schema() -> dict[str, Any]: "schema": {"type": "string", "const": RESULT_SCHEMA}, "schema_version": {"type": "string", "const": RESULT_SCHEMA}, "job_id": {"type": "string", "pattern": JOB_ID_RE.pattern}, + "plan_revision": {"type": "integer", "minimum": 0}, + "state_revision": {"type": "integer", "minimum": 0}, "task_revision": {"type": "integer", "minimum": 0}, "role": {"type": "string", "enum": ["planner", "researcher", "coder", "runner", "reviewer"]}, "profile": {"type": "string"}, @@ -1118,7 +1252,6 @@ def result_json_schema() -> dict[str, Any]: def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: body = str(dispatch.get("body") or "") - objective = str(dispatch.get("objective") or "") return { "checks": { "schema": "pass", @@ -1132,9 +1265,10 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: "length": "pass", }, "metrics": { - "body_chars": len(body), - "objective_chars": len(objective), + **_dispatch_body_metrics(dispatch, body), "ref_count": len(dispatch.get("refs") or []), + "explicit_ref_count": len(dispatch.get("explicit_refs") or []), + "automatic_ref_count": len(dispatch.get("automatic_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, @@ -1143,6 +1277,84 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: } +def _dispatch_body_metrics(dispatch: dict[str, Any], body: str) -> dict[str, int]: + """Expose only safe lengths so structured context never consumes objective budget.""" + objective_chars = len(str(dispatch.get("objective") or "")) + refs_chars = sum(len(item) for item in _string_list(dispatch.get("refs"))) + allowed_files_chars = sum(len(item) for item in _string_list(dispatch.get("allowed_files"))) + forbidden_files_chars = sum(len(item) for item in _string_list(dispatch.get("forbidden_files"))) + structured_chars = refs_chars + allowed_files_chars + forbidden_files_chars + return { + "body_chars": len(body), + "readable_body_chars": objective_chars, + "objective_chars": objective_chars, + "refs_chars": refs_chars, + "allowed_files_chars": allowed_files_chars, + "forbidden_files_chars": forbidden_files_chars, + "fixed_protocol_chars": max(0, len(body) - objective_chars - structured_chars), + } + + +def _dispatch_metric_details(metrics: dict[str, int]) -> list[str]: + return [f"{field}={metrics[field]}" for field in ( + "readable_body_chars", + "objective_chars", + "refs_chars", + "allowed_files_chars", + "forbidden_files_chars", + "fixed_protocol_chars", + "body_chars", + )] + + +def record_dispatch_rejection( + task_dir: Path, + task: dict[str, Any], + spec: dict[str, Any], + error: DispatchError, +) -> None: + """Append a metadata-only rejection audit without persisting unsafe input.""" + raw_job_id = spec.get("job_id") + job_id_text = raw_job_id.strip() if isinstance(raw_job_id, str) and raw_job_id.strip() else None + job_id_hash = ( + hashlib.sha256(job_id_text.encode("utf-8")).hexdigest()[:16] + if job_id_text is not None + else None + ) + metrics: dict[str, Any] = _dispatch_body_metrics( + { + "objective": spec.get("objective"), + "refs": spec.get("refs"), + "allowed_files": spec.get("allowed_files"), + "forbidden_files": spec.get("forbidden_files"), + }, + "", + ) + metrics.update({ + "ref_count": len(_string_list(spec.get("refs"))), + "explicit_ref_count": len(_string_list(spec.get("refs"))), + "automatic_ref_count": 0, + "allowed_file_patterns": len(_string_list(spec.get("allowed_files"))), + "forbidden_file_patterns": len(_string_list(spec.get("forbidden_files"))), + "error_code": error.code if re.fullmatch(r"[a-z_]+", error.code) else "rejected", + }) + source = f"dispatch_rejected:{job_id_hash or 'unknown'}:{now_utc()}" + _append_jsonl(task_dir / "hermes" / "dispatch_audit.jsonl", { + "type": "dispatch_audit", + "id": f"da-{hashlib.sha256(source.encode()).hexdigest()[:16]}", + "timestamp": now_utc(), + "job_id": None, + "job_id_hash": job_id_hash, + "plan_revision": _plan_revision(task), + "state_revision": _state_revision(task), + "task_revision": _plan_revision(task), + "hermes_revision": _state_revision(task), + "event": "dispatch_rejected", + "decision": "blocked", + "metrics": metrics, + }) + + def _sanitized_result( dispatch: dict[str, Any], result: dict[str, Any], @@ -1150,8 +1362,11 @@ def _sanitized_result( ) -> dict[str, Any]: sanitized = dict(result) sanitized["schema"] = RESULT_SCHEMA - sanitized["task_revision"] = dispatch.get("task_revision", dispatch.get("hermes_revision")) - sanitized["dispatch_revision"] = dispatch.get("hermes_revision") + sanitized["plan_revision"] = _dispatch_plan_revision(dispatch) + sanitized["state_revision"] = _dispatch_state_revision(dispatch) + sanitized["task_revision"] = _dispatch_plan_revision(dispatch) + sanitized["dispatch_revision"] = _dispatch_plan_revision(dispatch) + sanitized["dispatch_state_revision"] = _dispatch_state_revision(dispatch) sanitized["role"] = dispatch.get("role") sanitized["profile"] = dispatch.get("profile") sanitized["parent_job_id"] = dispatch.get("parent_job_id") @@ -1201,6 +1416,17 @@ def _record_invalid_result( current_dispatch = read_json(dispatch_path(task_dir, str(dispatch["job_id"]))) if not isinstance(current_task, dict) or not isinstance(current_dispatch, dict): raise DispatchError("state_confirmation_failed", "cannot reload invalid-result state") + # An invalid envelope can finish validation just before a sibling process + # confirms a valid result. Never let that delayed rejection undo confirmed work. + if ( + load_sanitized_result(task_dir, str(dispatch["job_id"])) is not None + or _task_confirms_dispatch(current_task, current_dispatch) + ): + _append_dispatch_audit(task_dir, current_dispatch, "result_rejected", "ignored", { + "error_code": error.code, + "reason": "result_already_confirmed", + }) + return attempts = int(current_dispatch.get("invalid_result_attempts") or 0) + 1 current_dispatch["invalid_result_attempts"] = attempts current_dispatch["last_result_error"] = error.code @@ -1318,6 +1544,9 @@ def _append_worker_task_card(task_dir: Path, dispatch: dict[str, Any]) -> None: "objective": dispatch["objective"], "work_package": dispatch["work_package"], "parent_job_id": dispatch["parent_job_id"], + "plan_revision": _dispatch_plan_revision(dispatch), + "state_revision": _dispatch_state_revision(dispatch), + "task_revision": _dispatch_plan_revision(dispatch), "hermes_revision": dispatch["hermes_revision"], "worktree_id": dispatch["worktree_id"], "status": "queued", @@ -1368,7 +1597,11 @@ def _append_worker_result( "risk_flags": result["risk_flags"], "handoff": result["next_action"], "work_package": dispatch["work_package"], - "dispatch_revision": dispatch["hermes_revision"], + "plan_revision": _dispatch_plan_revision(dispatch), + "state_revision": _dispatch_state_revision(dispatch), + "task_revision": _dispatch_plan_revision(dispatch), + "dispatch_revision": _dispatch_plan_revision(dispatch), + "dispatch_state_revision": _dispatch_state_revision(dispatch), } _append_jsonl(worker_path, record) @@ -1380,12 +1613,21 @@ def _append_dispatch_audit( decision: str, metrics: dict[str, Any], ) -> None: - audit_source = f"{event}:{dispatch.get('job_id')}:{now_utc()}" + timestamp = now_utc() + stable_events = {"dispatch_validated", "result_confirmed"} + audit_source = ( + f"{event}:{dispatch.get('job_id')}" + if event in stable_events + else f"{event}:{dispatch.get('job_id')}:{timestamp}" + ) record = { "type": "dispatch_audit", "id": f"da-{hashlib.sha256(audit_source.encode()).hexdigest()[:16]}", - "timestamp": now_utc(), + "timestamp": timestamp, "job_id": dispatch.get("job_id"), + "plan_revision": _dispatch_plan_revision(dispatch), + "state_revision": _dispatch_state_revision(dispatch), + "task_revision": _dispatch_plan_revision(dispatch), "hermes_revision": dispatch.get("hermes_revision"), "event": event, "decision": decision, @@ -1525,16 +1767,57 @@ def _validate_role_write_scope( invalid = [ pattern for pattern in allowed_files - if not any(pattern == root or pattern.startswith(root + "/") for root in roots) + if not any(pattern.startswith(root + "/") for root in roots) ] if invalid: + detail = ( + "researcher allowed_files may only target task research artifacts" + if role == "researcher" + else f"{role} allowed_files may only target its task-scoped record directory" + ) raise DispatchError( "role_write_scope", - f"{role} allowed_files may only target its task-scoped record directory", + detail, invalid, ) +def _validate_dispatch_gate( + task_dir: Path, + task: dict[str, Any], + role: str, +) -> None: + """Apply the same phase gate during creation, validation, and execution.""" + gates = closure_gate_status(task_dir, task) + closed = ( + task.get("closure_state") == "closed" + or task.get("hermes_phase") == "closed" + or task.get("status") == "completed" + ) + if closed: + raise DispatchError( + "task_closed", + gates["formal_execution"]["unblock"], + ) + if role in {"planner", "researcher"}: + gate_name = "literature_research" if role == "researcher" else None + elif role == "reviewer": + gate_name = "formal_review" + else: + gate_name = "formal_execution" + if gate_name is None or gates[gate_name]["status"] == "allowed": + return + if plan_anchor_status(task, task_dir)["status"] == "missing": + raise DispatchError( + "plan_not_validated", + "formal execution or review requires closure.py validate and a plan anchor", + ) + raise DispatchError( + "dispatch_gate_blocked", + f"{gate_name} is blocked: {gates[gate_name]['unblock']}", + ) + + def _validate_parent_job( task_dir: Path, task: dict[str, Any], @@ -1626,7 +1909,15 @@ def _normalize_refs( ) -> list[str]: refs = _string_list(value) if len(refs) > MAX_REFS: - raise DispatchError("too_many_refs", f"dispatch supports at most {MAX_REFS} refs") + raise DispatchError( + "too_many_refs", + f"dispatch supports at most {MAX_REFS} refs", + [ + f"explicit_refs={len(refs)} (normalized input)", + "automatic_refs=0 (normalization)", + f"max_refs={MAX_REFS}", + ], + ) normalized: list[str] = [] task_relative_root = _repo_relative(task_dir, repo_root) for raw in refs: @@ -1661,27 +1952,49 @@ def _normalize_refs( def _merged_dispatch_refs( task: dict[str, Any], value: Any, + task_dir: Path, repo_root: Path, role: str, profile: str, -) -> list[str]: - """Keep explicit task context first, then add fitting project context.""" - merged: list[str] = [] - for raw in [*_string_list(task.get("context_pins")), *_string_list(value)]: - if raw not in merged: - merged.append(raw) - if len(merged) >= MAX_REFS: - return merged +) -> tuple[list[str], list[str], list[str]]: + """Keep explicit CLI refs first, then fill spare slots with task and project context.""" + pins = _string_list(task.get("context_pins")) + requested = _string_list(value) + explicit: list[str] = [] + normalized_seen: set[str] = set() + for raw in requested: + normalized = _normalize_refs(task_dir, repo_root, [raw], role)[0] + if normalized in normalized_seen: + continue + normalized_seen.add(normalized) + explicit.append(raw) + if len(explicit) > MAX_REFS: + raise DispatchError( + "too_many_refs", + f"dispatch supports at most {MAX_REFS} refs", + [ + f"explicit_refs={len(explicit)} (dispatch.refs={len(requested)})", + f"automatic_refs=0 (remaining_slots=0; task.context_pins={len(pins)}, project_context=0)", + f"max_refs={MAX_REFS}", + ], + ) + automatic: list[str] = [] + automatic_candidates = list(pins) role_profiles = PROJECT_CONTEXT_BY_ROLE_PROFILE.get(role, {}) for context_key in role_profiles.get(profile, ()): project_ref = PROJECT_CONTEXT_FILES[context_key] - if not (repo_root / project_ref).is_file() or project_ref in merged: - continue - merged.append(project_ref) - if len(merged) >= MAX_REFS: + if (repo_root / project_ref).is_file(): + automatic_candidates.append(project_ref) + for candidate in automatic_candidates: + if len(explicit) + len(automatic) >= MAX_REFS: break - return merged + normalized = _normalize_refs(task_dir, repo_root, [candidate], role)[0] + if normalized in normalized_seen: + continue + normalized_seen.add(normalized) + automatic.append(candidate) + return [*explicit, *automatic], explicit, automatic def _normalize_patterns(value: Any, field: str) -> list[str]: @@ -1802,12 +2115,17 @@ def _check_sensitive_text(text: str, field: str) -> None: def _validate_dispatch_payload(value: dict[str, Any]) -> None: list_limits = { "refs": MAX_REFS, + "explicit_refs": MAX_REFS, + "automatic_refs": MAX_REFS, "allowed_files": 60, "forbidden_files": 60, "risk_flags": 12, } per_field_chars = { - "objective": 1200, + # The canonical prompt performs the 2k readable-objective check and + # returns segmented metrics. Keep this structural guard higher so it + # does not hide that diagnostic behind a generic field-size error. + "objective": MAX_RAW_RESULT_CHARS, "human_gate": 300, "worktree_id": 120, "heartbeat_interval": 40, @@ -1895,7 +2213,8 @@ def _validate_job_id(value: Any) -> None: def _generated_job_id( task_name: str, - revision: int, + plan_revision_value: int, + state_revision_value: int, role: str, profile: str, work_package: str | None, @@ -1903,7 +2222,16 @@ def _generated_job_id( objective: str, ) -> str: payload = json.dumps( - [task_name, revision, role, profile, work_package, parent_job_id, objective], + [ + task_name, + plan_revision_value, + state_revision_value, + role, + profile, + work_package, + parent_job_id, + objective, + ], ensure_ascii=False, separators=(",", ":"), ) @@ -1911,13 +2239,41 @@ def _generated_job_id( return f"job-{role}-{digest}" -def _task_revision(task: dict[str, Any]) -> int: - value = task.get("hermes_revision", 0) +def _plan_revision(task: dict[str, Any]) -> int: + value = plan_revision(task) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise DispatchError("invalid_revision", "task plan_revision must be a non-negative integer") + return value + + +def _state_revision(task: dict[str, Any]) -> int: + value = state_revision(task) if not isinstance(value, int) or isinstance(value, bool) or value < 0: raise DispatchError("invalid_revision", "task hermes_revision must be a non-negative integer") return value +def _dispatch_plan_revision(dispatch: dict[str, Any]) -> int: + for field in ("plan_revision", "task_revision", "hermes_revision"): + value = dispatch.get(field) + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + raise DispatchError("invalid_revision", "dispatch plan_revision must be a non-negative integer") + + +def _dispatch_state_revision(dispatch: dict[str, Any]) -> int: + for field in ("state_revision", "hermes_revision", "plan_revision", "task_revision"): + value = dispatch.get(field) + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + raise DispatchError("invalid_revision", "dispatch hermes_revision must be a non-negative integer") + + +def _is_legacy_dispatch(dispatch: dict[str, Any]) -> bool: + """Identify pre-split packets whose stored prompt cannot be regenerated byte-for-byte.""" + return "plan_revision" not in dispatch or "state_revision" not in dispatch + + def _default_timeout() -> str: return (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat().replace("+00:00", "Z") diff --git a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py index 810749d..4011d88 100644 --- a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py +++ b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py @@ -15,7 +15,9 @@ create_plan_anchor, is_closure_task, plan_anchor_status, + plan_revision, save_task, + state_revision, task_state_lock, ) from .config import get_task_lifecycle_enabled @@ -275,7 +277,10 @@ def _result( "changed": bool(changed_fields), "changed_fields": changed_fields, "issues": issues, - "hermes_revision": data.get("hermes_revision"), + "plan_revision": plan_revision(data), + "state_revision": state_revision(data), + "task_revision": plan_revision(data), + "hermes_revision": state_revision(data), "hermes_phase": data.get("hermes_phase"), "current_work_package": data.get("current_work_package"), "next_action": data.get("next_action"), diff --git a/packages/cli/src/templates/trellis/scripts/common/types.py b/packages/cli/src/templates/trellis/scripts/common/types.py index 95e6b22..5691b44 100644 --- a/packages/cli/src/templates/trellis/scripts/common/types.py +++ b/packages/cli/src/templates/trellis/scripts/common/types.py @@ -68,6 +68,9 @@ class TaskData(TypedDict, total=False): blockers: list[str] repair_count: int max_repair_count: int + plan_revision: int + state_revision: int + task_revision: int hermes_revision: int confirmed_dispatches: list[str] diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index cdbe278..73e6110 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -24,6 +24,7 @@ load_dispatch, load_sanitized_result, prepare_dispatch_for_agent, + record_dispatch_rejection, result_json_schema, sanitized_summary, supersede_dispatch, @@ -156,7 +157,14 @@ def main(argv: list[str] | None = None) -> int: "platform": args.platform, "blind_review": not args.not_blind, } - dispatch, warnings = create_dispatch(task_dir, task, spec, root) + try: + dispatch, warnings = create_dispatch(task_dir, task, spec, root) + except DispatchError as exc: + try: + record_dispatch_rejection(task_dir, task, spec, exc) + except (DispatchError, OSError): + pass + raise for warning in warnings: print(f"warning: {warning}", file=sys.stderr) _print({"job_id": dispatch["job_id"], "status": dispatch["status"], "path": str(dispatch_path(task_dir, dispatch["job_id"]).relative_to(root)), "audit": dispatch["audit"]}) diff --git a/packages/cli/src/templates/trellis/scripts/hermes/evidence.py b/packages/cli/src/templates/trellis/scripts/hermes/evidence.py index 830e0cd..93bf10a 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/evidence.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/evidence.py @@ -104,10 +104,13 @@ def build_summary(root: Path, task: str) -> tuple[dict[str, Any], list[str]]: } ) - validation_errors = [ - *validate_records(artifact_path, "artifact"), - *validate_records(evidence_path, "evidence"), - ] + validation_errors = list(validate_records(evidence_path, "evidence")) + if artifact_path.exists(): + validation_errors.extend(validate_records(artifact_path, "artifact")) + elif referenced_artifacts: + validation_errors.append( + "artifact ledger is missing while evidence records reference artifacts" + ) if manifest_path.exists(): validation_errors.extend(validate_records(manifest_path, "run_manifest")) validation_errors = list(dict.fromkeys(validation_errors)) diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index 5dd5e15..271d7e7 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -1671,12 +1671,35 @@ def is_forbidden_file(path: str, forbidden_files: Any) -> bool: ) +def researcher_write_scope_error(task_card: dict[str, Any], path: str) -> str | None: + """Keep researcher mutations to the two task-owned research artifact roots.""" + if task_card.get("role") != "researcher": + return None + record_uri = task_card.get("record_uri") + if not isinstance(record_uri, str): + return "researcher task_card has no task-scoped record_uri" + normalized_uri = normalize_path(record_uri) + suffix = "/hermes/worker_records.jsonl" + if normalized_uri is None or not normalized_uri.endswith(suffix): + return "researcher task_card has no task-scoped record_uri" + task_root = normalized_uri.removesuffix(suffix) + normalized_path = normalize_path(path) + if normalized_path is None: + return "researcher write path is invalid" + roots = (f"{task_root}/research", f"{task_root}/hermes/research") + if any(normalized_path.startswith(root + "/") for root in roots): + return None + return "researcher writes are limited to task research artifacts" + + def is_change_set_allowed(task_card: dict[str, Any], changed_files: Any) -> bool: if not isinstance(changed_files, list): return False for changed_file in changed_files: if not isinstance(changed_file, str): return False + if researcher_write_scope_error(task_card, changed_file) is not None: + return False if is_forbidden_file(changed_file, task_card.get("forbidden_files")): return False if not is_allowed_file(changed_file, task_card.get("allowed_files")): @@ -1699,10 +1722,20 @@ def permission_errors( for file_path in changed_files if is_forbidden_file(file_path, task_card.get("forbidden_files")) ] + role_scope = [ + file_path + for file_path in changed_files + if researcher_write_scope_error(task_card, file_path) is not None + ] if outside: errors.append(f"outside allowed_files: {', '.join(outside)}") if forbidden: errors.append(f"matches forbidden_files: {', '.join(forbidden)}") + if role_scope: + errors.append( + "researcher writes are limited to task research artifacts: " + + ", ".join(role_scope) + ) return errors diff --git a/packages/cli/src/utils/codex-config.ts b/packages/cli/src/utils/codex-config.ts index 515786d..1e93d19 100644 --- a/packages/cli/src/utils/codex-config.ts +++ b/packages/cli/src/utils/codex-config.ts @@ -9,7 +9,10 @@ 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*(?:#.*)?$/; +const TOML_TABLE_HEADER = /^\s*(\[\[[^[\]\r\n]+\]\]|\[[^[\]\r\n]+\])\s*(#.*)?$/; +const MULTI_AGENT_TABLE = "[features.multi_agent_v2]"; +const LEGACY_HERMES_TABLE = "[hermes.context_firewall]"; +const SIMPLE_DOTTED_KEY = /^[A-Za-z0-9_-]+(?:\s*\.\s*[A-Za-z0-9_-]+)*$/; export interface CodexConfigMergeResult { content: string; @@ -28,6 +31,332 @@ interface TopLevelModelAssignment { kind: "model" | "reasoning"; } +interface TomlTableHeader { + canonical: string; + index: number; +} + +interface TomlTableScan { + lines: string[]; + headers: TomlTableHeader[]; + outsideMultiline: boolean[]; + ambiguous: boolean; +} + +interface TableLineRange { + lines: string[]; + outsideMultiline: boolean[]; + start: number; + end: number; + header: TomlTableHeader; +} + +type TomlMultilineString = "basic" | "literal"; + +function canonicalTableHeader(header: string): string | null { + const array = header.startsWith("[["); + const offset = array ? 2 : 1; + const body = header.slice(offset, -offset).trim(); + if (!SIMPLE_DOTTED_KEY.test(body)) return null; + + const canonicalBody = body + .split(".") + .map((part) => part.trim()) + .join("."); + return array ? `[[${canonicalBody}]]` : `[${canonicalBody}]`; +} + +function isTrellisMarker(line: string): boolean { + const trimmed = line.trim(); + 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, + ].includes(trimmed); +} + +function isEscaped(line: string, index: number): boolean { + let backslashes = 0; + for ( + let cursor = index - 1; + cursor >= 0 && line[cursor] === "\\"; + cursor -= 1 + ) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +function scanTomlStringLine( + line: string, + initialState: TomlMultilineString | null, +): { state: TomlMultilineString | null; ambiguous: boolean } { + let state = initialState; + let index = 0; + + while (index < line.length) { + if (state === "basic") { + if (line.startsWith('"""', index) && !isEscaped(line, index)) { + state = null; + index += 3; + } else { + index += 1; + } + continue; + } + if (state === "literal") { + if (line.startsWith("'''", index)) { + state = null; + index += 3; + } else { + index += 1; + } + continue; + } + + if (line[index] === "#") break; + if (line.startsWith('"""', index)) { + state = "basic"; + index += 3; + continue; + } + if (line.startsWith("'''", index)) { + state = "literal"; + index += 3; + continue; + } + if (line[index] === '"') { + index += 1; + while ( + index < line.length && + (line[index] !== '"' || isEscaped(line, index)) + ) { + index += 1; + } + if (index >= line.length) return { state, ambiguous: true }; + index += 1; + continue; + } + if (line[index] === "'") { + const end = line.indexOf("'", index + 1); + if (end < 0) return { state, ambiguous: true }; + index = end + 1; + continue; + } + index += 1; + } + + return { state, ambiguous: false }; +} + +function scanTomlTables(content: string): TomlTableScan { + const lines = content.split(/\r?\n/); + const headers: TomlTableHeader[] = []; + const outsideMultiline: boolean[] = []; + let state: TomlMultilineString | null = null; + let ambiguous = false; + + for (const [index, line] of lines.entries()) { + outsideMultiline[index] = state === null; + if (state === null) { + const header = line.match(TOML_TABLE_HEADER); + if (header) { + const canonical = canonicalTableHeader(header[1]); + if (canonical) { + headers.push({ canonical, index }); + } else { + ambiguous = true; + } + } else if (/^\s*\[/.test(line)) { + ambiguous = true; + } + } + + const scanned = scanTomlStringLine(line, state); + state = scanned.state; + ambiguous ||= scanned.ambiguous; + } + + return { + lines, + headers, + outsideMultiline, + ambiguous: ambiguous || state !== null, + }; +} + +function tableRanges(scan: TomlTableScan, header: string): TableLineRange[] { + return scan.headers + .filter((item) => item.canonical === header) + .map((item) => { + const nextHeader = scan.headers.find( + (candidate) => candidate.index > item.index, + ); + let end = nextHeader?.index ?? scan.lines.length; + for (let index = item.index + 1; index < end; index += 1) { + if ( + scan.outsideMultiline[index] && + isTrellisMarker(scan.lines[index]) + ) { + end = index; + break; + } + } + return { + lines: scan.lines, + outsideMultiline: scan.outsideMultiline, + start: item.index, + end, + header: item, + }; + }); +} + +function tableLineRange( + content: string, + header: string, +): TableLineRange | null { + const scan = scanTomlTables(content); + const ranges = tableRanges(scan, header); + return !scan.ambiguous && ranges.length === 1 ? ranges[0] : null; +} + +function replaceLines(content: string, lines: string[]): string { + const result = lines.join("\n"); + return content.endsWith("\n") && !result.endsWith("\n") + ? `${result}\n` + : result; +} + +function normalizedHermesTableValues(range: TableLineRange): string[] | null { + const values: string[] = []; + for (let index = range.start + 1; index < range.end; index += 1) { + if (!range.outsideMultiline[index]) return null; + const trimmed = range.lines[index].trim(); + if (trimmed.length === 0) continue; + if (trimmed.startsWith("#")) continue; + if (!/^native_authority\s*=\s*"protocol"\s*(?:#.*)?$/.test(trimmed)) { + return null; + } + values.push('native_authority = "protocol"'); + } + return values; +} + +function isLegacyHermesTable(range: TableLineRange): boolean { + const values = normalizedHermesTableValues(range); + return values?.length === 1; +} + +function hasSimpleUniqueKeys(range: TableLineRange): boolean { + const keys = new Set(); + for (let index = range.start + 1; index < range.end; index += 1) { + if (!range.outsideMultiline[index]) return false; + const trimmed = range.lines[index].trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const assignment = trimmed.match(/^([A-Za-z0-9_-]+)\s*=/); + if (!assignment || keys.has(assignment[1])) return false; + keys.add(assignment[1]); + } + return true; +} + +function hasUnambiguousManagedTables(content: string): boolean { + const scan = scanTomlTables(content); + return ( + !scan.ambiguous && + tableRanges(scan, MULTI_AGENT_TABLE).length <= 1 && + tableRanges(scan, LEGACY_HERMES_TABLE).length <= 1 && + tableRanges(scan, MULTI_AGENT_TABLE).every(hasSimpleUniqueKeys) && + tableRanges(scan, LEGACY_HERMES_TABLE).every(hasSimpleUniqueKeys) + ); +} + +function hasOnlyRemovableHermesTable(content: string): boolean { + const scan = scanTomlTables(content); + const ranges = tableRanges(scan, LEGACY_HERMES_TABLE); + return ( + !scan.ambiguous && + ranges.length <= 1 && + (ranges.length === 0 || isLegacyHermesTable(ranges[0])) + ); +} + +/** Remove only an exact historical Trellis Hermes table. */ +function removeLegacyHermesTable(content: string): string { + const range = tableLineRange(content, LEGACY_HERMES_TABLE); + if (!range || !isLegacyHermesTable(range)) return content; + + const lines = [...range.lines]; + lines.splice(range.start, range.end - range.start); + return replaceLines(content, lines); +} + +/** + * Repair only the Codex wait-time invariant in an existing table. This keeps + * user-selected concurrency and timeout values while making legacy unmarked + * project configs loadable by current Codex releases. + */ +function repairMultiAgentWaitInvariant(content: string): string { + const range = tableLineRange(content, MULTI_AGENT_TABLE); + if (!range) return content; + + const lines = [...range.lines]; + const minimumPattern = /^(\s*min_wait_timeout_ms\s*=\s*)(\d+)(.*)$/; + const defaultPattern = /^(\s*default_wait_timeout_ms\s*=\s*)(\d+)(.*)$/; + let minimumIndex = -1; + let minimum = 0; + let defaultIndex = -1; + let defaultWait = 0; + + for (let index = range.start + 1; index < range.end; index += 1) { + const minimumMatch = lines[index].match(minimumPattern); + if (minimumMatch) { + minimumIndex = index; + minimum = Number(minimumMatch[2]); + } + const defaultMatch = lines[index].match(defaultPattern); + if (defaultMatch) { + defaultIndex = index; + defaultWait = Number(defaultMatch[2]); + } + } + + if (minimumIndex < 0 || !Number.isSafeInteger(minimum) || minimum <= 0) { + return content; + } + if (defaultIndex < 0) { + const indentation = lines[minimumIndex].match(/^\s*/)?.[0] ?? ""; + lines.splice( + minimumIndex + 1, + 0, + `${indentation}default_wait_timeout_ms = ${minimum}`, + ); + return replaceLines(content, lines); + } + if (defaultWait >= minimum) return content; + + const match = lines[defaultIndex].match(defaultPattern); + if (!match) return content; + lines[defaultIndex] = `${match[1]}${minimum}${match[3]}`; + return replaceLines(content, lines); +} + +function normalizeLegacyCodexConfig(content: string): string { + return repairMultiAgentWaitInvariant(removeLegacyHermesTable(content)); +} + +function normalizeCodexConfigForComparison(content: string): string { + return content + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .join("\n") + .trim(); +} + function markerRange( content: string, startMarker: string, @@ -71,14 +400,17 @@ function replaceRange( function topLevelModelAssignments(content: string): TopLevelModelAssignment[] { const assignments: TopLevelModelAssignment[] = []; + const scan = scanTomlTables(content); + if (scan.ambiguous) return assignments; let inTable = false; - for (const [index, line] of content.split(/\r?\n/).entries()) { - if (TABLE_HEADER.test(line)) { + const headerLines = new Set(scan.headers.map((header) => header.index)); + for (const [index, line] of scan.lines.entries()) { + if (headerLines.has(index)) { inTable = true; continue; } - if (inTable) continue; + if (inTable || !scan.outsideMultiline[index]) continue; if (MODEL_ASSIGNMENT.test(line)) { assignments.push({ index, line, kind: "model" }); } else if (REASONING_ASSIGNMENT.test(line)) { @@ -206,16 +538,23 @@ function mergeWholeConfigBlock( nextBlock = preserveCustomModelBlock(nextBlock, existingBlock); } - const legacyWholeBlock = existingBlock - .split(/\r?\n/) - .some((line) => TABLE_HEADER.test(line)); + const existingTables = scanTomlTables(existingBlock); + const legacyWholeBlock = + !existingTables.ambiguous && existingTables.headers.length > 0; const tables = markerRange( template, CODEX_TABLES_BLOCK_START, CODEX_TABLES_BLOCK_END, ); if (legacyWholeBlock && tables) { - nextBlock += `\n\n${template.slice(tables.start, tables.end)}`; + const external = replaceRange(existing, current, ""); + nextBlock += `\n\n${managedTablesForExisting( + template, + external, + existingBlock, + )}`; + const customHermes = preservedCustomHermesTable(existingBlock); + if (customHermes) nextBlock += `\n\n${customHermes}`; } return replaceRange(existing, current, nextBlock); } @@ -232,60 +571,57 @@ function mergeTablesBlock(existing: string, template: string): string { CODEX_TABLES_BLOCK_END, ); if (!current || !replacement) return existing; - return replaceRange( - existing, - current, - managedTablesForExisting(template, existing), - ); + const currentBlock = existing.slice(current.start, current.end); + const external = replaceRange(existing, current, ""); + let nextBlock = managedTablesForExisting(template, external, currentBlock); + const customHermes = preservedCustomHermesTable(currentBlock); + if (customHermes) nextBlock += `\n\n${customHermes}`; + return replaceRange(existing, current, nextBlock); } function tableHeaders(content: string): Set { - return new Set( - content - .split(/\r?\n/) - .filter((line) => TABLE_HEADER.test(line)) - .map((line) => line.trim()), - ); + return new Set(scanTomlTables(content).headers.map((item) => item.canonical)); +} + +function rawTable(range: TableLineRange): string { + return range.lines.slice(range.start, range.end).join("\n").trimEnd(); +} + +function preservedCustomHermesTable(content: string): string | null { + const range = tableLineRange(content, LEGACY_HERMES_TABLE); + return range && !isLegacyHermesTable(range) ? rawTable(range) : null; } -function managedTablesForExisting(template: string, existing: string): string { +function managedTablesForExisting( + template: string, + external: string, + preserved = "", +): 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) { + const preservedMultiAgent = tableLineRange(preserved, MULTI_AGENT_TABLE); + if (existingHeaders.size === 0 && !preservedMultiAgent) { return template.slice(templateRange.start, templateRange.end); } - const lines = template - .slice(templateRange.start, templateRange.end) - .split(/\r?\n/); + const templateBlock = template.slice(templateRange.start, templateRange.end); + const scan = scanTomlTables(templateBlock); 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)); + for (const header of scan.headers) { + const range = tableRanges(scan, header.canonical).find( + (item) => item.start === header.index, + ); + if (!range || existingHeaders.has(header.canonical)) continue; + if (header.canonical === MULTI_AGENT_TABLE && preservedMultiAgent) { + sections.push(rawTable(preservedMultiAgent).split("\n")); + } else { + sections.push(scan.lines.slice(range.start, range.end)); } - index = end - 1; } return [ CODEX_TABLES_BLOCK_START, @@ -294,9 +630,9 @@ function managedTablesForExisting(template: string, existing: string): string { ].join("\n"); } -function firstTableLine(lines: string[]): number { - const index = lines.findIndex((line) => TABLE_HEADER.test(line)); - return index < 0 ? lines.length : index; +function firstTableLine(content: string): number { + const scan = scanTomlTables(content); + return scan.headers[0]?.index ?? scan.lines.length; } function withoutProjectDocDefault(block: string): string { @@ -323,7 +659,7 @@ function mergeUnmarkedConfig( if (!topRange || !tablesRange) return null; const lines = existing.split(/\r?\n/); - const tableAt = firstTableLine(lines); + const tableAt = firstTableLine(existing); 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); @@ -381,7 +717,9 @@ export function mergeTrellisCodexConfig( ): CodexConfigMergeResult { const wholeBlock = mergeWholeConfigBlock(existing, template); if (wholeBlock !== null) { - const merged = mergeTablesBlock(wholeBlock, template); + const merged = normalizeLegacyCodexConfig( + mergeTablesBlock(wholeBlock, template), + ); return { content: merged, changed: merged !== existing, @@ -391,9 +729,10 @@ export function mergeTrellisCodexConfig( const defaultsBlock = mergeModelDefaultsBlock(existing, template); if (defaultsBlock !== null) { + const normalized = normalizeLegacyCodexConfig(defaultsBlock); return { - content: defaultsBlock, - changed: defaultsBlock !== existing, + content: normalized, + changed: normalized !== existing, migratedLegacyDefaults: false, }; } @@ -404,16 +743,19 @@ export function mergeTrellisCodexConfig( const migrated = migrateLegacyDefaults(existing, template); if (migrated !== null) { + const normalized = normalizeLegacyCodexConfig(migrated); return { - content: migrated, - changed: migrated !== existing, + content: normalized, + changed: normalized !== existing, migratedLegacyDefaults: true, }; } const merged = mergeUnmarkedConfig(existing, template); + const normalized = + merged === null ? existing : normalizeLegacyCodexConfig(merged); return { - content: merged ?? existing, - changed: merged !== null && merged !== existing, + content: normalized, + changed: normalized !== existing, migratedLegacyDefaults: false, }; } @@ -431,8 +773,23 @@ export function isTrellisCodexConfigMerge( existing: string, candidate: string, ): boolean { + if ( + !hasUnambiguousManagedTables(existing) || + !hasUnambiguousManagedTables(candidate) || + !hasOnlyRemovableHermesTable(existing) || + !hasOnlyRemovableHermesTable(candidate) + ) { + return false; + } + + const comparableExisting = normalizeCodexConfigForComparison( + stripTrellisCodexConfig(normalizeLegacyCodexConfig(existing)), + ); + const comparableCandidate = normalizeCodexConfigForComparison( + stripTrellisCodexConfig(candidate), + ); return ( - stripTrellisCodexConfig(existing) === stripTrellisCodexConfig(candidate) || + comparableExisting === comparableCandidate || isLegacyCodexDefaultMigration(existing, candidate) ); } diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index 919f62a..800f329 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -10,6 +10,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import inquirer from "inquirer"; +import { execSync } from "node:child_process"; // === External dependency mocks (hoisted by vitest) === @@ -36,7 +37,10 @@ import { VERSION } from "../../src/constants/version.js"; import { DIR_NAMES, FILE_NAMES, PATHS } from "../../src/constants/paths.js"; import { computeHash } from "../../src/utils/template-hash.js"; import { workflowMdTemplate } from "../../src/templates/trellis/index.js"; -import { replacePythonCommandLiterals } from "../../src/configurators/shared.js"; +import { + replacePythonCommandLiterals, + resetResolvedPythonCommand, +} from "../../src/configurators/shared.js"; // A managed template file that update always handles (Python script) const MANAGED_FILE = `${PATHS.SCRIPTS}/get_context.py`; @@ -140,6 +144,13 @@ describe("update() integration", () => { } beforeEach(() => { + resetResolvedPythonCommand(); + vi.mocked(execSync) + .mockReset() + .mockImplementation((command: string) => { + const python = process.platform === "win32" ? "python" : "python3"; + return command === `${python} --version` ? "Python 3.11.12" : ""; + }); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-update-int-")); vi.spyOn(process, "cwd").mockReturnValue(tmpDir); // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -157,11 +168,16 @@ describe("update() integration", () => { }); afterEach(() => { + resetResolvedPythonCommand(); vi.restoreAllMocks(); vi.unstubAllGlobals(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("fails clearly when Trellis is not initialized", async () => { + await expect(update({})).rejects.toThrow("Trellis is not initialized"); + }); + it("#1 same version update is a true no-op (zero file changes, no backup)", async () => { await setupProject(); @@ -363,7 +379,7 @@ describe("update() integration", () => { 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(updated).toContain("max_concurrent_threads_per_session = 1"); expect(updated).toContain("default_wait_timeout_ms = 120000"); expect(readHashesV2(hashFilePath())[configRelative]).toBe( computeHash(updated), @@ -375,13 +391,17 @@ describe("update() integration", () => { 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", ""); + 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", "") + .replace("min_wait_timeout_ms = 120000", "min_wait_timeout_ms = 480000") + .replace("default_wait_timeout_ms = 120000\n", "") + + '\n[hermes.context_firewall]\nnative_authority = "protocol"\n'; fs.writeFileSync(configPath, legacy); await update({}); @@ -390,6 +410,68 @@ describe("update() integration", () => { expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:START"); expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); expect(migrated).not.toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(migrated).toContain("min_wait_timeout_ms = 480000"); + expect(migrated).toContain("default_wait_timeout_ms = 480000"); + expect(migrated).not.toContain("[hermes.context_firewall]"); + }); + + it("automatically migrates a complete legacy custom-model Codex config", 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", "") + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + 'model_reasoning_effort = "high"', + 'model_reasoning_effort = "medium"', + ) + .replace("min_wait_timeout_ms = 120000", "min_wait_timeout_ms = 480000") + .replace("default_wait_timeout_ms = 120000\n", "") + + '\n[hermes.context_firewall]\nnative_authority = "protocol"\n'; + fs.writeFileSync(configPath, legacy); + vi.mocked(inquirer.prompt).mockClear(); + + await update({}); + + const migrated = fs.readFileSync(configPath, "utf-8"); + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(migrated).toContain('model = "gpt-5.6-custom"'); + expect(migrated).toContain('model_reasoning_effort = "medium"'); + expect(migrated).toContain( + "# Project-scoped Codex defaults for Trellis workflows.", + ); + expect(migrated).toContain("min_wait_timeout_ms = 480000"); + expect(migrated).toContain("default_wait_timeout_ms = 480000"); + expect(migrated).not.toContain("[hermes.context_firewall]"); + }); + + it("re-detects the working Python command before updating hooks", async () => { + await init({ yes: true, force: true, codex: true }); + resetResolvedPythonCommand(); + + const fallback = process.platform === "win32" ? "py -3" : "python"; + vi.mocked(execSync).mockImplementation(((command: string) => + command === `${fallback} --version` + ? "Python 3.11.12" + : "") as typeof execSync); + + await update({ force: true }); + + const hooks = readProjectFile(".codex/hooks.json"); + expect(hooks).toContain( + `"command": "${fallback} -X utf8 .codex/hooks/inject-workflow-state.py"`, + ); + expect(hooks).not.toContain( + `"command": "python3 -X utf8 .codex/hooks/inject-workflow-state.py"`, + ); }); it("#4b auto-updates Claude implement agent when the installed template is older but unmodified", async () => { @@ -1118,6 +1200,57 @@ describe("update() integration", () => { expect(fs.readFileSync(versionPath, "utf-8")).toBe(VERSION); }); + it("stops before writes when both migration paths contain user content", async () => { + await setupProject(); + stageLegacy040Project(); + + const oldPath = projectFile(".claude/commands/trellis/before-dev.md"); + const newPath = projectFile( + ".claude/skills/trellis-before-dev/SKILL.md", + ); + fs.writeFileSync(oldPath, "old user content\n"); + fs.writeFileSync(newPath, "new user content\n"); + expect(fs.existsSync(oldPath)).toBe(true); + expect(fs.existsSync(newPath)).toBe(true); + + const stdinDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", + ); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + vi.mocked(inquirer.prompt).mockRejectedValueOnce( + Object.assign(new Error("stdin is closed"), { + code: "ERR_USE_AFTER_CLOSE", + }), + ); + + try { + await expect(update({ migrate: true })).rejects.toThrow( + "Migration conflict", + ); + expect(inquirer.prompt).not.toHaveBeenCalled(); + } finally { + vi.mocked(inquirer.prompt) + .mockReset() + .mockResolvedValue({ proceed: true }); + if (stdinDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + } + + expect(fs.readFileSync(versionFilePath(), "utf-8")).toBe("0.4.0"); + expect( + fs + .readdirSync(projectFile(DIR_NAMES.WORKFLOW)) + .some((entry) => entry.startsWith(".backup-")), + ).toBe(false); + }); + // The [b] Backup-rename path in the confirm prompt promises "keeps a .backup // copy". Previously it was identical to [r] (both relied on the full project // snapshot). We now write an INLINE .backup next to the new path so users can diff --git a/packages/cli/test/configurators/platforms.test.ts b/packages/cli/test/configurators/platforms.test.ts index 47b79f6..5575e45 100644 --- a/packages/cli/test/configurators/platforms.test.ts +++ b/packages/cli/test/configurators/platforms.test.ts @@ -353,7 +353,7 @@ describe("configurePlatform", () => { const written = fs.readFileSync(configPath, "utf-8"); expect(written).toContain('model = "gpt-5.6-custom"'); - expect(written).toContain("max_concurrent_threads_per_session = 3"); + expect(written).toContain("max_concurrent_threads_per_session = 1"); expect(written).toContain("default_wait_timeout_ms = 120000"); }); 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 e402e26..2d98c61 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -137,6 +137,32 @@ describe("Agent Context Firewall dispatch CLI", () => { }; } + function runAsync(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn( + PYTHON, + [path.join(root, ".trellis", "scripts", "hermes", "dispatch.py"), ...args], + { + cwd: root, + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf-8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf-8").on("data", (chunk) => { stderr += chunk; }); + child.on("close", (status) => resolve({ + status, + stdout: stdout.trim(), + stderr: stderr.trim(), + })); + }); + } + function create( jobId: string, role = "coder", @@ -260,11 +286,17 @@ describe("Agent Context Firewall dispatch CLI", () => { "utf-8", ), ) as { + plan_revision: number; + state_revision: number; + task_revision: number; hermes_revision: number; refs: string[]; body: string; audit: { metrics: { body_chars: number; ref_count: number } }; }; + expect(dispatch.plan_revision).toBe(4); + expect(dispatch.state_revision).toBe(4); + expect(dispatch.task_revision).toBe(dispatch.plan_revision); expect(dispatch.hermes_revision).toBe(4); expect(dispatch.refs).toEqual([".trellis/tasks/demo/prd.md"]); expect(dispatch.body.length).toBeLessThanOrEqual(2000); @@ -327,6 +359,8 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(fs.existsSync( path.join(taskDir, "hermes", "dispatches", "job-document-drift.dispatch.json"), )).toBe(false); + const diagnosis = create("job-document-drift-research", "researcher"); + expect(diagnosis.status, diagnosis.stderr).toBe(0); }); it("permits a coder configuration dispatch only for the current task handoff", () => { @@ -429,6 +463,163 @@ describe("Agent Context Firewall dispatch CLI", () => { ]); }); + it("keeps dispatch --ref ahead of automatic context pins", () => { + fs.writeFileSync(path.join(taskDir, "explicit.md"), "explicit\n"); + fs.writeFileSync(path.join(taskDir, "pin.md"), "pin\n"); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = ["pin.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const created = run([ + "create", "--task", "demo", "--job-id", "job-ref-priority", "--role", "planner", + "--objective", "Use the explicit source first.", "--ref", "explicit.md", + ]); + expect(created.status, created.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-ref-priority.dispatch.json"), + "utf-8", + )); + expect(dispatch.explicit_refs).toEqual(["explicit.md"]); + expect(dispatch.refs).toEqual([ + ".trellis/tasks/demo/explicit.md", + ".trellis/tasks/demo/pin.md", + ]); + }); + + it("uses one explicit ref and only the first two of three automatic context pins", () => { + for (const name of ["explicit.md", "pin-a.md", "pin-b.md", "pin-c.md"]) { + fs.writeFileSync(path.join(taskDir, name), `${name}\n`); + } + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = ["pin-a.md", "pin-b.md", "pin-c.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const created = run([ + "create", "--task", "demo", "--job-id", "job-three-pins", "--role", "planner", + "--objective", "Use the explicit source and bounded pins.", "--ref", "explicit.md", + ]); + expect(created.status, created.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-three-pins.dispatch.json"), + "utf-8", + )); + expect(dispatch.refs).toEqual([ + ".trellis/tasks/demo/explicit.md", + ".trellis/tasks/demo/pin-a.md", + ".trellis/tasks/demo/pin-b.md", + ]); + expect(dispatch.automatic_refs).toEqual(["pin-a.md", "pin-b.md"]); + }); + + it("does not add automatic pins or project context after three explicit refs", () => { + const projectDir = path.join(root, ".trellis", "project"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, "BACKGROUND.md"), "background\n"); + fs.writeFileSync(path.join(projectDir, "CONSTRAINTS.md"), "constraints\n"); + for (const name of ["one.md", "two.md", "three.md", "pin.md"]) { + fs.writeFileSync(path.join(taskDir, name), `${name}\n`); + } + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = ["pin.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const created = run([ + "create", "--task", "demo", "--job-id", "job-explicit-full", "--role", "planner", + "--objective", "Use only explicit refs.", + "--ref", "one.md", "--ref", "two.md", "--ref", "three.md", + ]); + expect(created.status, created.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-explicit-full.dispatch.json"), + "utf-8", + )); + expect(dispatch.refs).toEqual([ + ".trellis/tasks/demo/one.md", + ".trellis/tasks/demo/two.md", + ".trellis/tasks/demo/three.md", + ]); + expect(dispatch.automatic_refs).toEqual([]); + }); + + it("fills remaining slots from context pins before role-matched project context", () => { + const projectDir = path.join(root, ".trellis", "project"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, "BACKGROUND.md"), "background\n"); + fs.writeFileSync(path.join(projectDir, "CONSTRAINTS.md"), "constraints\n"); + fs.writeFileSync(path.join(taskDir, "explicit.md"), "explicit\n"); + fs.writeFileSync(path.join(taskDir, "pin.md"), "pin\n"); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = ["pin.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const created = run([ + "create", "--task", "demo", "--job-id", "job-auto-order", "--role", "planner", + "--objective", "Use bounded automatic sources.", "--ref", "explicit.md", + ]); + expect(created.status, created.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-auto-order.dispatch.json"), + "utf-8", + )); + expect(dispatch.refs).toEqual([ + ".trellis/tasks/demo/explicit.md", + ".trellis/tasks/demo/pin.md", + ".trellis/project/BACKGROUND.md", + ]); + }); + + it("deduplicates an explicit ref that also appears in automatic context", () => { + const projectDir = path.join(root, ".trellis", "project"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, "BACKGROUND.md"), "background\n"); + fs.writeFileSync(path.join(taskDir, "shared.md"), "shared\n"); + fs.writeFileSync(path.join(taskDir, "pin.md"), "pin\n"); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = [".trellis/tasks/demo/shared.md", "pin.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const created = run([ + "create", "--task", "demo", "--job-id", "job-ref-dedupe", "--role", "planner", + "--objective", "Avoid duplicate sources.", "--ref", "shared.md", + ]); + expect(created.status, created.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-ref-dedupe.dispatch.json"), + "utf-8", + )); + expect(dispatch.refs).toEqual([ + ".trellis/tasks/demo/shared.md", + ".trellis/tasks/demo/pin.md", + ".trellis/project/BACKGROUND.md", + ]); + }); + + it("rejects more than three explicit refs with source and occupancy metrics", () => { + for (const name of ["a.md", "b.md", "c.md", "d.md"]) { + fs.writeFileSync(path.join(taskDir, name), `${name}\n`); + } + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.context_pins = ["a.md", "b.md", "c.md"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); + + const rejected = run([ + "create", "--task", "demo", "--job-id", "job-ref-overflow", "--role", "planner", + "--objective", "Too many explicit refs.", + "--ref", "a.md", "--ref", "b.md", "--ref", "c.md", "--ref", "d.md", + ]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("too_many_refs"); + expect(rejected.stderr).toContain("explicit_refs=4"); + expect(rejected.stderr).toContain("automatic_refs=0"); + expect(rejected.stderr).toContain("task.context_pins=3"); + }); + it("allows planner and reviewer task-level work without a package", () => { expect(create("job-planner", "planner").status).toBe(0); expect( @@ -459,6 +650,256 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(reviewRecord.status, reviewRecord.stderr).toBe(0); }); + it("allows researchers to write only whitelisted task research artifacts", () => { + const allowed = create("job-research-note", "researcher", [ + "--allowed-file", ".trellis/tasks/demo/research/source-notes.md", + ]); + expect(allowed.status, allowed.stderr).toBe(0); + const nestedAllowed = create("job-hermes-research-note", "researcher", [ + "--allowed-file", ".trellis/tasks/demo/hermes/research/literature.json", + ]); + expect(nestedAllowed.status, nestedAllowed.stderr).toBe(0); + + for (const [jobId, file] of [ + ["job-research-code", "src/app.ts"], + ["job-research-run", ".trellis/tasks/demo/hermes/runs/output.json"], + ["job-research-claim", ".trellis/tasks/demo/hermes/claim_ledger.jsonl"], + ["job-research-state", ".trellis/tasks/demo/task.json"], + ]) { + const rejected = create(jobId, "researcher", ["--allowed-file", file]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("role_write_scope"); + } + }); + + it("confirms sibling dispatch results in either order without making the plan stale", () => { + expect(create("job-parallel-a").status).toBe(0); + expect(create("job-parallel-b").status).toBe(0); + + const second = apply("job-parallel-b", validResult("job-parallel-b")); + expect(second.status, second.stderr).toBe(0); + const first = apply("job-parallel-a", validResult("job-parallel-a")); + expect(first.status, first.stderr).toBe(0); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.plan_revision).toBe(4); + expect(task.state_revision).toBe(6); + expect(task.confirmed_dispatches).toEqual( + expect.arrayContaining(["job-parallel-a", "job-parallel-b"]), + ); + }); + + it("accepts concurrent identical results once and rejects a later conflicting result", async () => { + expect(create("job-idempotent").status).toBe(0); + const value = validResult("job-idempotent"); + const args = [ + "apply", "--task", "demo", "--job-id", "job-idempotent", + "--result-json", JSON.stringify(value), + ]; + const results = await Promise.all([runAsync(args), runAsync(args)]); + expect(results.map((item) => item.status)).toEqual([0, 0]); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.state_revision).toBe(5); + const records = fs.readFileSync( + path.join(taskDir, "hermes", "worker_records.jsonl"), + "utf-8", + ).trim().split("\n").map((line) => JSON.parse(line)); + expect(records.filter((item) => item.id === "cp-job-idempotent")).toHaveLength(1); + expect(records.filter((item) => item.id === "rs-job-idempotent")).toHaveLength(1); + + const conflict = apply("job-idempotent", validResult("job-idempotent", { + conclusion: "A different result must not replace the accepted result.", + })); + expect(conflict.status).toBe(1); + expect(conflict.stderr).toContain("result_conflict"); + expect(JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")).state_revision).toBe(5); + + const invalidDuplicate = apply("job-idempotent", {}); + expect(invalidDuplicate.status).toBe(1); + expect(invalidDuplicate.stderr).toContain("result_conflict"); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-idempotent.dispatch.json"), + "utf-8", + )); + expect(dispatch.status).toBe("confirmed"); + expect(dispatch.invalid_result_attempts).toBe(0); + }); + + it("does not let delayed invalid-result bookkeeping block a confirmed dispatch", () => { + expect(create("job-delayed-invalid").status).toBe(0); + expect(apply("job-delayed-invalid", validResult("job-delayed-invalid")).status).toBe(0); + + const delayed = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.dispatch import DispatchError, _record_invalid_result", + "task_dir = Path('.trellis/tasks/demo')", + "task = json.loads((task_dir / 'task.json').read_text(encoding='utf-8'))", + "dispatch = json.loads((task_dir / 'hermes/dispatches/job-delayed-invalid.dispatch.json').read_text(encoding='utf-8'))", + "_record_invalid_result(task_dir, task, dispatch, DispatchError('invalid_result_json', 'late invalid output'))", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(delayed.status, delayed.stderr).toBe(0); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-delayed-invalid.dispatch.json"), + "utf-8", + )); + expect(dispatch.status).toBe("confirmed"); + expect(dispatch.invalid_result_attempts).toBe(0); + expect(task.work_packages[0].status).toBe("running"); + expect(task.work_packages[0].dispatch_blockers ?? []).toEqual([]); + }); + + it("recovers a stored result whose task and worker records were not confirmed", () => { + expect(create("job-recover").status).toBe(0); + const value = validResult("job-recover"); + expect(apply("job-recover", value).status).toBe(0); + + const recordsPath = path.join(taskDir, "hermes", "worker_records.jsonl"); + const records = fs.readFileSync(recordsPath, "utf-8").trim().split("\n") + .map((line) => JSON.parse(line)) + .filter((item) => !["cp-job-recover", "rs-job-recover"].includes(item.id)); + writeJsonl(recordsPath, records); + writeJson(path.join(taskDir, "task.json"), baseTask()); + anchorTask(); + + const recovered = apply("job-recover", value); + expect(recovered.status, recovered.stderr).toBe(0); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.confirmed_dispatches).toContain("job-recover"); + const recoveredRecords = fs.readFileSync(recordsPath, "utf-8"); + expect(recoveredRecords).toContain('"id":"cp-job-recover"'); + expect(recoveredRecords).toContain('"id":"rs-job-recover"'); + }); + + it("blocks formal work on blocked or closed tasks while allowing scoped diagnosis", () => { + const blocked = baseTask({ + hermes_phase: "blocked", + blockers: ["Await a bounded diagnosis"], + }); + writeJson(path.join(taskDir, "task.json"), blocked); + anchorTask(); + expect(create("job-blocked-coder").stderr).toContain("dispatch_gate_blocked"); + expect(create("job-blocked-review", "reviewer", ["--profile", "closure"]).stderr) + .toContain("dispatch_gate_blocked"); + expect(create("job-blocked-plan", "planner").status).toBe(0); + expect(create("job-blocked-research", "researcher").status).toBe(0); + + const closed = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + closed.status = "completed"; + closed.hermes_phase = "closed"; + closed.closure_state = "closed"; + writeJson(path.join(taskDir, "task.json"), closed); + const rejected = create("job-closed-plan", "planner"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("task_closed"); + }); + + it("keeps safe legacy dispatches executable but rechecks legacy researcher writes", () => { + expect(create("job-legacy").status).toBe(0); + const legacyPath = path.join(taskDir, "hermes", "dispatches", "job-legacy.dispatch.json"); + const legacy = JSON.parse(fs.readFileSync(legacyPath, "utf-8")); + delete legacy.plan_revision; + delete legacy.state_revision; + legacy.body = "Legacy bounded dispatch body."; + writeJson(legacyPath, legacy); + const accepted = run(["validate", "--task", "demo", "--job-id", "job-legacy"]); + expect(accepted.status, accepted.stderr).toBe(0); + + expect(create("job-legacy-research", "researcher", [ + "--allowed-file", ".trellis/tasks/demo/research/notes.md", + ]).status).toBe(0); + const researchPath = path.join( + taskDir, + "hermes", + "dispatches", + "job-legacy-research.dispatch.json", + ); + const research = JSON.parse(fs.readFileSync(researchPath, "utf-8")); + delete research.plan_revision; + delete research.state_revision; + research.body = "Legacy researcher dispatch body."; + research.allowed_files = ["src/**"]; + writeJson(researchPath, research); + const rejected = run([ + "validate", "--task", "demo", "--job-id", "job-legacy-research", + ]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("role_write_scope"); + }); + + it("generates a fresh job id when the same objective is dispatched after a state update", () => { + const args = [ + "create", "--task", "demo", "--role", "planner", + "--objective", "Repeat the same bounded planning check.", + ]; + const first = run(args); + expect(first.status, first.stderr).toBe(0); + const firstJob = JSON.parse(first.stdout).job_id as string; + expect( + apply(firstJob, validResult(firstJob, { changed_files: [] })).status, + ).toBe(0); + + const second = run(args); + expect(second.status, second.stderr).toBe(0); + const secondJob = JSON.parse(second.stdout).job_id as string; + expect(secondJob).not.toBe(firstJob); + }); + + it("keeps the objective budget independent from structured dispatch fields and audits rejected creates", () => { + const longObjective = "o".repeat(2000); + const accepted = run([ + "create", "--task", "demo", "--job-id", "job-long-objective", "--role", "planner", + "--objective", longObjective, + ]); + expect(accepted.status, accepted.stderr).toBe(0); + const dispatch = JSON.parse(fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-long-objective.dispatch.json"), + "utf-8", + )); + expect(dispatch.body.length).toBeGreaterThan(2000); + expect(dispatch.audit.metrics.readable_body_chars).toBe(2000); + + const overBudget = run([ + "create", "--task", "demo", "--job-id", "job-over-objective", "--role", "planner", + "--objective", "o".repeat(2001), + ]); + expect(overBudget.status).toBe(1); + expect(overBudget.stderr).toContain("dispatch_too_long"); + expect(overBudget.stderr).toContain("readable_body_chars=2001"); + expect(overBudget.stderr).toContain("fixed_protocol_chars="); + + const token = `github_pat_${"A".repeat(24)}`; + const rejected = run([ + "create", "--task", "demo", "--job-id", "job-redacted-create", "--role", "planner", + "--objective", `Do not persist ${token} from /home/alice/private.md`, + ]); + expect(rejected.status).toBe(1); + const rejectedJobId = run([ + "create", "--task", "demo", "--job-id", token, "--role", "planner", + "--objective", "Reject a secret-shaped job id without persisting it.", + ]); + expect(rejectedJobId.status).toBe(1); + const auditText = fs.readFileSync( + path.join(taskDir, "hermes", "dispatch_audit.jsonl"), + "utf-8", + ); + expect(auditText).toContain("dispatch_rejected"); + expect(auditText).toContain("objective_chars"); + expect(auditText).not.toContain(token); + expect(auditText).not.toContain("/home/alice/private.md"); + }); + 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", [ @@ -474,15 +915,26 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(bound.status, bound.stderr).toBe(0); }); - it("rejects missing jobs, stale revisions, and execution without the current package", () => { + it("rejects missing jobs and stale plans, while state revisions keep a plan dispatch valid", () => { const missing = run(["validate", "--task", "demo", "--job-id", "missing"]); expect(missing.status).toBe(1); expect(missing.stderr).toContain("missing_dispatch"); expect(create("job-stale").status).toBe(0); const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.plan_revision = 4; + task.task_revision = 4; + task.state_revision = 5; task.hermes_revision = 5; writeJson(path.join(taskDir, "task.json"), task); + const currentPlan = run(["validate", "--task", "demo", "--job-id", "job-stale"]); + expect(currentPlan.status, currentPlan.stderr).toBe(0); + + task.plan_revision = 5; + task.task_revision = 5; + task.definition_of_done = ["A revised plan requirement"]; + writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); const stale = run(["validate", "--task", "demo", "--job-id", "job-stale"]); expect(stale.status).toBe(1); expect(stale.stderr).toContain("stale_dispatch"); @@ -510,6 +962,8 @@ describe("Agent Context Firewall dispatch CLI", () => { "--objective", "Plan", "--ref", "a.md", "--ref", "b.md", "--ref", "c.md", "--ref", "d.md", ]); expect(tooMany.stderr).toContain("too_many_refs"); + expect(tooMany.stderr).toContain("explicit_refs=4"); + expect(tooMany.stderr).toContain("automatic_refs=0"); for (const [job, ref, code] of [ ["job-missing-ref", "missing.md", "invalid_ref"], ["job-abs-ref", path.join(root, "src", "app.ts"), "absolute_user_path"], @@ -575,8 +1029,8 @@ describe("Agent Context Firewall dispatch CLI", () => { ), ) as { forbidden_files: string[]; refs: string[] }; expect(dispatch.refs).toEqual([ - "src/app.ts", ".trellis/tasks/demo/prd.md", + "src/app.ts", ]); expect(dispatch.forbidden_files).toContain("generated/**"); }); @@ -809,7 +1263,7 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(prompt.length).toBeLessThanOrEqual(2000); }); - it("denies Claude Hermes dispatch without a job, with stale state, or in async mode", () => { + it("denies Claude Hermes dispatch without a job, with a stale plan, or in async mode", () => { const missing = runHook("inject-subagent-context.py", { cwd: root, tool_name: "Agent", @@ -820,8 +1274,11 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(create("job-hook-stale").status).toBe(0); const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); - task.hermes_revision += 1; + task.plan_revision = 5; + task.task_revision = 5; + task.definition_of_done = ["A changed hook plan"]; writeJson(path.join(taskDir, "task.json"), task); + anchorTask(); const stale = runHook("inject-subagent-context.py", { cwd: root, tool_name: "Agent", @@ -829,7 +1286,7 @@ describe("Agent Context Firewall dispatch CLI", () => { session_id: "firewall-session", }); expect(JSON.parse(stale.stdout).hookSpecificOutput.permissionDecisionReason).toContain( - "dispatch revision does not match", + "dispatch plan revision does not match", ); writeJson(path.join(taskDir, "task.json"), baseTask()); @@ -1041,7 +1498,7 @@ describe("Agent Context Firewall dispatch CLI", () => { fs.readFileSync(path.join(taskDir, "hermes", "dispatches", "job-replacement.dispatch.json"), "utf-8"), ); expect(oldDispatch.status).toBe("superseded"); - expect(replacement.task_revision).toBe(task.hermes_revision); + expect(replacement.task_revision).toBe(task.plan_revision); }); it("blocks Stop for unconfirmed results, undisposed packages, and pending close", () => { diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 08a3d1c..a5fcc21 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -298,6 +298,11 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(before.research_route).toBe("exploration"); expect(before.research_change_fields).toEqual(["model_architecture"]); expect(before.grill_completed).toBe(false); + const gates = JSON.parse(run(root, "status", "--json").stdout); + expect(gates.gates.literature_research.status).toBe("allowed"); + expect(gates.gates.formal_execution.status).toBe("blocked"); + expect(gates.gates.formal_review.status).toBe("blocked"); + expect(gates.gates.formal_execution.unblock).toContain("exploration grill"); const blocked = run(root, "validate"); expect(blocked.status).toBe(1); expect(blocked.stderr).toContain("completed grill"); @@ -534,6 +539,9 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "utf-8", ); expect(events).toContain('"event_type":"plan_drift_detected"'); + const gates = JSON.parse(run(root, "status", "--json").stdout).gates; + expect(gates.literature_research.status).toBe("allowed"); + expect(gates.formal_execution.status).toBe("blocked"); }); it("blocks direct changes to validated plan documents", () => { @@ -898,6 +906,29 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(result.stdout).toContain("Done when:"); }); + it("keeps next action, blockers, and refs when every capsule field is long", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + const planned = readTask(taskDir) as TestTask & Record; + planned.intent = `Intent ${"i".repeat(500)}`; + planned.in_scope = [`Scope ${"s".repeat(500)}`]; + planned.out_of_scope = [`Excluded ${"o".repeat(500)}`]; + writeTask(taskDir, planned); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const task = readTask(taskDir) as TestTask & Record; + task.next_action = `NEXT_MARKER ${"n".repeat(500)}`; + task.blockers = [`BLOCKER_MARKER ${"b".repeat(500)}`]; + task.context_pins = ["capsule-ref.md"]; + fs.writeFileSync(path.join(taskDir, "capsule-ref.md"), "reference\n", "utf-8"); + writeTask(taskDir, task); + const result = run(root, "capsule"); + expect(result.status).toBe(0); + expect(result.stdout.trim().length).toBeLessThanOrEqual(1000); + expect(result.stdout).toContain("Next:"); + expect(result.stdout).toContain("BLOCKER_MARKER"); + expect(result.stdout).toContain("capsule-ref.md"); + }); + it("does not complete a package without validation evidence", () => { expect(plan(root, ["Artifact exists"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -1075,7 +1106,13 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { 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}`); + const current = readTask(taskDir) as TestTask & { + plan_revision?: number; + state_revision?: number; + }; + expect(handoff).toContain(`## Plan Revision\n${current.plan_revision ?? current.hermes_revision}`); + expect(handoff).toContain(`## State Revision\n${current.state_revision ?? current.hermes_revision}`); + expect(handoff).toContain(`## Task Revision\n${current.state_revision ?? current.hermes_revision}`); expect(handoff).not.toContain("- src/owned.ts"); expect(handoff).not.toContain("src/forged.ts"); expect(handoff).not.toContain("unrelated.txt"); @@ -1095,6 +1132,22 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { const status = run(root, "status", "--json"); expect(status.status).toBe(0); expect(JSON.parse(status.stdout).next_action).toContain("Run closure.py validate."); + const gates = JSON.parse(status.stdout).gates; + expect(gates.literature_research.status).toBe("allowed"); + expect(gates.formal_execution.status).toBe("blocked"); + expect(gates.formal_execution.unblock).toContain("closure.py validate"); + + const blockedTask = readTask(taskDir); + writeTask(taskDir, { + ...blockedTask, + hermes_phase: "blocked", + blockers: ["WP1: source unavailable"], + }); + const blockedGates = JSON.parse(run(root, "status", "--json").stdout).gates; + expect(blockedGates.formal_execution.status).toBe("blocked"); + expect(blockedGates.formal_review.status).toBe("blocked"); + expect(blockedGates.formal_execution.unblock).toContain("recorded blocker"); + expect(blockedGates.formal_review.unblock).toContain("recorded blocker"); }); it("prints a compact actionable audit gap", () => { @@ -1247,7 +1300,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(pending).toEqual([ expect.objectContaining({ field: "dataset", - task_revision: readTask(taskDir).hermes_revision, + task_revision: readTask(taskDir).plan_revision, requires_decision_record: true, requires_human_approval: true, }), diff --git a/packages/cli/test/templates/codex.test.ts b/packages/cli/test/templates/codex.test.ts index 9cbcdc4..9fcc945 100644 --- a/packages/cli/test/templates/codex.test.ts +++ b/packages/cli/test/templates/codex.test.ts @@ -121,7 +121,7 @@ describe("codex getConfigTemplate", () => { 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("[hermes.context_firewall]"); expect(config.content).not.toContain("strict_authority"); const minimum = Number( diff --git a/packages/cli/test/templates/hermes-runtime.test.ts b/packages/cli/test/templates/hermes-runtime.test.ts index 471f94a..4fa8d39 100644 --- a/packages/cli/test/templates/hermes-runtime.test.ts +++ b/packages/cli/test/templates/hermes-runtime.test.ts @@ -32,7 +32,8 @@ function runHermes( | "experiment.py" | "runner.py" | "report.py" - | "service.py", + | "service.py" + | "evidence.py", args: string[], options: { env?: Record } = {}, ): { status: number | null; stdout: string; stderr: string } { @@ -649,6 +650,41 @@ describe("Hermes runtime scripts", () => { expect(validate.stdout).toContain("valid"); }); + it("treats a missing artifact ledger as empty only when evidence has no artifact refs", () => { + writeJsonl(path.join(taskDir, "hermes", "evidence_ledger.jsonl"), [ + { + type: "evidence", + id: "ev-no-artifact", + timestamp: "2026-06-29T00:00:00Z", + source: "repository note", + summary: "no artifact is needed", + limits: "unit test only", + artifact_refs: [], + command_refs: [], + }, + ]); + + const noRefs = runHermes(tmpDir, "evidence.py", ["validate", "--task", "01-test"]); + expect(noRefs.status, noRefs.stderr).toBe(0); + + writeJsonl(path.join(taskDir, "hermes", "evidence_ledger.jsonl"), [ + { + type: "evidence", + id: "ev-dangling-artifact", + timestamp: "2026-06-29T00:00:00Z", + source: "repository note", + summary: "an artifact is claimed", + limits: "unit test only", + artifact_refs: ["ar-missing"], + command_refs: [], + }, + ]); + const dangling = runHermes(tmpDir, "evidence.py", ["validate", "--task", "01-test"]); + expect(dangling.status).toBe(1); + expect(dangling.stderr).toContain("artifact ledger is missing"); + expect(dangling.stderr).toContain("dangling evidence artifact references"); + }); + it("initializes task-scoped experiment and run manifest skeleton paths", () => { const init = runHermes(tmpDir, "experiment.py", [ "init", diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 7fdaf06..c57e2b4 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -2559,6 +2559,39 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(parseDecision(result).permissionDecision).not.toBe("deny"); }); + it("allows researcher notes only inside whitelisted task research artifacts", () => { + writeHermesWorkerRecords([ + taskCard("job-research", "researcher", { + allowed_files: [`.trellis/tasks/${taskName}/research/**`], + }), + heartbeat("job-research"), + ]); + + const allowed = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "researcher", + tool_name: "Write", + tool_input: { + file_path: `.trellis/tasks/${taskName}/research/source-notes.md`, + }, + session_id: "test-session", + }); + expect(parseDecision(allowed).permissionDecision).not.toBe("deny"); + + const denied = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "researcher", + tool_name: "Write", + tool_input: { file_path: "src/app.ts" }, + session_id: "test-session", + }); + const decision = parseDecision(denied); + expect(decision.permissionDecision).toBe("deny"); + expect(decision.permissionDecisionReason).toContain("researcher writes are limited"); + }); + it("denies main-agent writes without an active task while keeping read-only git allowed", () => { writeIdleSession(); diff --git a/packages/cli/test/templates/trellis.test.ts b/packages/cli/test/templates/trellis.test.ts index 2ae91b4..3a1d8a9 100644 --- a/packages/cli/test/templates/trellis.test.ts +++ b/packages/cli/test/templates/trellis.test.ts @@ -607,6 +607,12 @@ describe("trellis template constants", () => { expect(templates.get("schemas/result-envelope.schema.json")).toContain( "uncertainties", ); + expect(templates.get("schemas/result-envelope.schema.json")).toContain( + "plan_revision", + ); + expect(templates.get("schemas/result-envelope.schema.json")).toContain( + "state_revision", + ); }); }); diff --git a/packages/cli/test/utils/codex-config.test.ts b/packages/cli/test/utils/codex-config.test.ts index f08d25d..2d2b080 100644 --- a/packages/cli/test/utils/codex-config.test.ts +++ b/packages/cli/test/utils/codex-config.test.ts @@ -50,7 +50,7 @@ keep = true 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("max_concurrent_threads_per_session = 1"); expect(merged.content).toContain("[local]\nkeep = true"); expect(merged.migratedLegacyDefaults).toBe(false); expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); @@ -83,7 +83,7 @@ name = "example" 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(merged.content).toContain('[project]\nname = "example"'); expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); }); @@ -104,7 +104,156 @@ max_concurrent_threads_per_session = 1 expect(merged.match(/\[features\.multi_agent_v2\]/g)).toHaveLength(1); expect(merged).toContain("enabled = false"); - expect(merged).toContain("[hermes.context_firewall]"); + expect(merged).not.toContain("[hermes.context_firewall]"); + }); + + it("recognizes a target table header with a trailing comment", () => { + const existing = `[features.multi_agent_v2] # keep +enabled = false +max_concurrent_threads_per_session = 5 +min_wait_timeout_ms = 480000 +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.content.match(/\[features\.multi_agent_v2\]/g)).toHaveLength( + 1, + ); + expect(merged.content).toContain("[features.multi_agent_v2] # keep"); + expect(merged.content).toContain("default_wait_timeout_ms = 480000"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("does not treat a table-like line in a multiline string as a table", () => { + const existing = `instructions = """ +[hermes.context_firewall] +native_authority = "protocol" +""" +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.content).toContain(existing.trim()); + expect(merged.content).toContain("[hermes.context_firewall]"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("preserves a customized legacy Hermes table and marks the merge unsafe", () => { + const existing = `[hermes.context_firewall] +native_authority = "protocol" +user_override = true +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.content).toContain("[hermes.context_firewall]"); + expect(merged.content).toContain("user_override = true"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(false); + }); + + it("repairs legacy wait defaults without replacing user concurrency", () => { + const existing = `[features.multi_agent_v2] +enabled = false +max_concurrent_threads_per_session = 7 +min_wait_timeout_ms = 480000 +default_wait_timeout_ms = 10000 + +[hermes.context_firewall] +native_authority = "protocol" +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.content).toContain("max_concurrent_threads_per_session = 7"); + expect(merged.content).toContain("min_wait_timeout_ms = 480000"); + expect(merged.content).toContain("default_wait_timeout_ms = 480000"); + expect(merged.content).not.toContain("[hermes.context_firewall]"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("preserves custom multi-agent values from the legacy managed block", () => { + const existing = `${CODEX_CONFIG_BLOCK_START} +model = "custom-model" +model_reasoning_effort = "medium" + +[features.multi_agent_v2] +enabled = false +max_concurrent_threads_per_session = 7 +min_wait_timeout_ms = 480000 +default_wait_timeout_ms = 600000 +max_wait_timeout_ms = 900000 +${CODEX_CONFIG_BLOCK_END} +`; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.content.match(/\[features\.multi_agent_v2\]/g)).toHaveLength( + 1, + ); + expect(merged.content).toContain("enabled = false"); + expect(merged.content).toContain("max_concurrent_threads_per_session = 7"); + expect(merged.content).toContain("min_wait_timeout_ms = 480000"); + expect(merged.content).toContain("default_wait_timeout_ms = 600000"); + expect(merged.content).toContain("max_wait_timeout_ms = 900000"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("keeps managed table markers when migrating a custom-model legacy config", () => { + const existing = `model = "custom-model" +model_reasoning_effort = "medium" + +[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 480000 + +[hermes.context_firewall] +native_authority = "protocol" +`; + + const first = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(first.content).toContain('model = "custom-model"'); + expect(first.content).toContain("default_wait_timeout_ms = 480000"); + expect(first.content).not.toContain("[hermes.context_firewall]"); + expect(first.content.match(/# TRELLIS:CODEX_TABLES:START/g)).toHaveLength( + 1, + ); + expect(first.content.match(/# TRELLIS:CODEX_TABLES:END/g)).toHaveLength(1); + expect(isTrellisCodexConfigMerge(existing, first.content)).toBe(true); + + const second = mergeTrellisCodexConfig(first.content, TEMPLATE); + expect(second.content.match(/# TRELLIS:CODEX_TABLES:START/g)).toHaveLength( + 1, + ); + expect(second.content.match(/# TRELLIS:CODEX_TABLES:END/g)).toHaveLength(1); + }); + + it("recognizes a complete legacy template with a custom model as a safe merge", () => { + const existing = + withoutMarkers(TEMPLATE) + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + 'model_reasoning_effort = "high"', + 'model_reasoning_effort = "medium"', + ) + .replace("min_wait_timeout_ms = 120000", "min_wait_timeout_ms = 480000") + .replace("default_wait_timeout_ms = 120000\n", "") + + '\n[hermes.context_firewall]\nnative_authority = "protocol"\n'; + + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + + expect(merged.content).toContain('model = "gpt-5.6-custom"'); + expect(merged.content).toContain('model_reasoning_effort = "medium"'); + expect(merged.content).toContain( + "# Project-scoped Codex defaults for Trellis workflows.", + ); + expect(merged.content).toContain("min_wait_timeout_ms = 480000"); + expect(merged.content).toContain("default_wait_timeout_ms = 480000"); + expect(merged.content).not.toContain("[hermes.context_firewall]"); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + expect( + isTrellisCodexConfigMerge( + existing, + merged.content.replace( + 'model = "gpt-5.6-custom"', + 'model = "gpt-5.6-different"', + ), + ), + ).toBe(false); }); }); diff --git a/packages/core/README.md b/packages/core/README.md index 38163d6..a0f44cd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.2 +npm install -g research-trellis@0.7.1-beta.3 research-trellis --version ``` diff --git a/packages/core/package.json b/packages/core/package.json index 20f0fa8..31cebb3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis-core", - "version": "0.7.1-beta.2", + "version": "0.7.1-beta.3", "description": "Research Trellis core SDK for channels, tasks, memory, and auditable research records", "type": "module", "main": "./dist/index.js",